diff --git a/content/docs/01-callout.md b/content/docs/01-callout.md deleted file mode 100644 index f8bfb6f0..00000000 --- a/content/docs/01-callout.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -slug: notro-fixture-callout -title: "[Fixture] Callout" ---- - -# Callout Blocks - -## Basic callout with icon and color - - - This is a blue info callout with an explicit icon attribute. - - - - This is a warning callout. - - It can contain **multiple paragraphs** with inline formatting. - - - - This is a red callout indicating an error or critical notice. - - -## Callout without attributes (emoji extracted from content) - - - 🎉 The leading emoji is extracted as the icon when no explicit icon= attribute is present. - - - - 📌 Another callout where the emoji becomes the icon automatically. - - The rest of the content follows normally. - - -## Callout with color only (no icon) - - - This callout uses a gray background color. - - -## Nested callouts - - - Outer callout content. - - Inner nested callout with its own icon and color. - - - -## Triple-nested callouts - - - Level 1 content. - - Level 2 content. - - Level 3 (innermost) content. - - - - -## Callout containing rich content - - - A callout can contain any block content: - - - List item 1 - - List item 2 - - List item 3 - - And also code: - - ```typescript - const x: number = 42; - console.log(x); - ``` - diff --git a/content/docs/02-toggle.md b/content/docs/02-toggle.md deleted file mode 100644 index fbc367e7..00000000 --- a/content/docs/02-toggle.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -slug: notro-fixture-toggle -title: "[Fixture] Toggle" ---- - -# Toggle Blocks - -## Basic toggle with plain text - -
-Click to expand: plain text content - This is the body of the toggle block. - - It can contain multiple paragraphs. -
- -## Toggle with rich text summary - -
-Toggle with **bold** and *italic* in summary - Content inside this toggle. -
- -## Toggle containing a list - -
-Toggle with a list inside - - First item - - Second item - - Third item - - Nested item A - - Nested item B -
- -## Toggle containing a code block - -
-Toggle with a code block - Here is some code: - - ```javascript - function greet(name) { - return `Hello, ${name}!`; - } - - console.log(greet("World")); - ``` -
- -## Toggle containing a table - -
-Toggle with a table - | Name | Type | Description | - |---------|--------|--------------------------| - | id | number | Unique identifier | - | title | string | Page title | - | public | bool | Whether page is public | -
- -## Toggle containing another toggle (nested) - -
-Outer toggle - This is in the outer toggle. - -
- Inner nested toggle - This is inside the nested toggle. -
-
- -## Toggle containing a callout - -
-Toggle with a callout inside - - This callout is nested inside a toggle block. - -
- -## Toggle with heading in summary - -
-Setup Instructions - ## Step 1: Install dependencies - - ```bash - pnpm install - ``` - - ## Step 2: Configure environment - - Copy `.env.example` to `.env` and fill in your credentials. - - ## Step 3: Run the dev server - - ```bash - pnpm run dev - ``` -
diff --git a/content/docs/03-columns.md b/content/docs/03-columns.md deleted file mode 100644 index 24911fbf..00000000 --- a/content/docs/03-columns.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -slug: notro-fixture-columns -title: "[Fixture] Columns" ---- - -# Column Layouts - -## Two-column layout - - - - ## Left Column - - This is content in the left column. - - - Item A - - Item B - - Item C - - - ## Right Column - - This is content in the right column. - - Some paragraph text that demonstrates that each column is independent. - - - -## Three-column layout - - - - ### Column 1 - - First column content. - - - ### Column 2 - - Second column content. - - - ### Column 3 - - Third column content. - - - -## Columns with rich content - - - - ## Code Example - - ```typescript - interface User { - id: number; - name: string; - email: string; - } - ``` - - - ## Notes - - - The `id` field is auto-generated - - The `name` field is required - - The `email` field must be unique - - - Use TypeScript for better type safety. - - - - -## Columns with table - - - - ## Frontend Stack - - | Technology | Version | - |------------|---------| - | Astro | 6.x | - | TailwindCSS| 4.x | - | TypeScript | 5.x | - - - ## Backend Stack - - | Technology | Version | - |------------|---------| - | Node.js | 24.x | - | pnpm | 10.x | - | Notion API | 2026-03 | - - - -Text after columns should render as a normal paragraph. diff --git a/content/docs/04-table.md b/content/docs/04-table.md deleted file mode 100644 index ef0e0a5f..00000000 --- a/content/docs/04-table.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -slug: notro-fixture-table -title: "[Fixture] Table" ---- - -# Table Blocks - -## Standard GFM table - -| Header 1 | Header 2 | Header 3 | -|----------|-----------|------------| -| Cell 1 | Cell 2 | Cell 3 | -| Cell 4 | Cell 5 | Cell 6 | -| Cell 7 | Cell 8 | Cell 9 | - -## Table with alignment - -| Left-aligned | Center-aligned | Right-aligned | -|:-------------|:--------------:|--------------:| -| left | center | right | -| text | text | text | - -## Notion raw HTML table (with links in cells) - -Notion exports tables with complex content as raw HTML. Links inside `` cells use markdown syntax that must be converted to `` tags. - - - - - - - - - - - - - - - - - - - - - - - - - - -
PackageVersionDocumentation
remark-nfm0.0.3[README](https://github.com/mosugi/notro/blob/main/packages/remark-nfm/README.md)
notro-loader0.0.1[README](https://github.com/mosugi/notro/blob/main/packages/notro-loader/README.md)
notro-ui0.0.1[README](https://github.com/mosugi/notro/blob/main/packages/notro-ui/README.md)
- -## Table with multiple links per cell - - - - - - - - - - - - - - - - - - -
TopicResources
Astro[Official Docs](https://docs.astro.build) and [GitHub](https://github.com/withastro/astro)
Notion API[API Reference](https://developers.notion.com/reference) and [Changelog](https://developers.notion.com/changelog)
- -## Table with inline code and formatting - -| Feature | Status | Notes | -|--------------|--------|--------------------------------| -| `callout` | ✅ | Supports nested callouts | -| `toggle` | ✅ | Tab-indented content dedented | -| `columns` | ✅ | Multi-column layouts | -| `table` | ✅ | Raw HTML + GFM tables | -| `math` | ✅ | Inline `$...$` and block `$$` | diff --git a/content/docs/05-math.md b/content/docs/05-math.md deleted file mode 100644 index ac0ccaf5..00000000 --- a/content/docs/05-math.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -slug: notro-fixture-math -title: "[Fixture] Math" ---- - -# Math Equations - -## Inline math - -Einstein's mass-energy equivalence: $E = mc^2$ - -The quadratic formula: $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ - -Euler's identity: $e^{i\pi} + 1 = 0$ - -The derivative definition: $f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h}$ - -## Block math (display equations) - -The Gaussian integral: - -$$ -\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} -$$ - -Maxwell's equations in differential form: - -$$ -\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0} -$$ - -$$ -\nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \epsilon_0 \frac{\partial \mathbf{E}}{\partial t} -$$ - -A matrix multiplication example: - -$$ -\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} e \\ f \end{pmatrix} = \begin{pmatrix} ae + bf \\ ce + df \end{pmatrix} -$$ - -## Notion-specific: backtick-delimited inline math - -Notion sometimes outputs inline math as $`formula`$ format. This should be normalized to $formula$. - -The preprocessor converts $`\alpha + \beta = \gamma`$ to standard inline math. - -## LaTeX commands without backslash (Notion API quirk) - -Notion's API sometimes strips backslashes from LaTeX commands. These should be restored: - -$$ -frac{1}{2} + frac{1}{3} = frac{5}{6} -$$ - -$$ -\sum_{n=1}^{\infty} frac{1}{n^2} = frac{\pi^2}{6} -$$ - -## Mixed inline and block math in paragraphs - -When we integrate $f(x) = x^2$ from $0$ to $1$, we get: - -$$ -\int_0^1 x^2 dx = \left[ \frac{x^3}{3} \right]_0^1 = \frac{1}{3} -$$ - -This demonstrates that the area under the parabola is $\frac{1}{3}$. diff --git a/content/docs/06-colors.md b/content/docs/06-colors.md deleted file mode 100644 index c0b7e438..00000000 --- a/content/docs/06-colors.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -slug: notro-fixture-colors -title: "[Fixture] Colors" ---- - -# Color Annotations - -Notion allows coloring text at the block level using `{color="..."}` syntax. -The preprocessor converts these to raw HTML elements so the color prop can be passed to heading/paragraph components. - -## Colored headings - -# Red Heading {color="red"} - -## Blue Heading {color="blue"} - -### Green Heading {color="green"} - -#### Purple Heading {color="purple"} - -## Colored paragraphs - -This is a normal paragraph without color. - -A red paragraph text. {color="red"} - -A blue paragraph text. {color="blue"} - -A green paragraph with more words to show how the text looks across the line. {color="green"} - -A gray paragraph for subtle de-emphasis. {color="gray"} - -## Background colors - -Text with a yellow background for highlighting. {color="yellow_background"} - -Text with a blue background color. {color="blue_background"} - -Text with a red background color. {color="red_background"} - -Text with a gray background for subtle emphasis. {color="gray_background"} - -## All Notion color names - -These are all the supported color values: - -Default paragraph text (no color annotation). - -Red text {color="red"} - -Orange text {color="orange"} - -Yellow text {color="yellow"} - -Green text {color="green"} - -Blue text {color="blue"} - -Purple text {color="purple"} - -Pink text {color="pink"} - -Brown text {color="brown"} - -Gray text {color="gray"} - -Red background {color="red_background"} - -Orange background {color="orange_background"} - -Yellow background {color="yellow_background"} - -Green background {color="green_background"} - -Blue background {color="blue_background"} - -Purple background {color="purple_background"} - -Pink background {color="pink_background"} - -Brown background {color="brown_background"} - -Gray background {color="gray_background"} - -## Mixed: colored section followed by normal content - -## Important Notice {color="red"} - -This paragraph has no color annotation and should render normally. - -### Details below {color="blue"} - -More normal content here, uncolored. diff --git a/content/docs/07-code.md b/content/docs/07-code.md deleted file mode 100644 index de2f668d..00000000 --- a/content/docs/07-code.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -slug: notro-fixture-code -title: "[Fixture] Code" ---- - -# Code Blocks - -## TypeScript - -```typescript -interface NotionPage { - id: string; - properties: Record; - markdown: string; - truncated: boolean; -} - -async function fetchPage(pageId: string): Promise { - const response = await fetch(`/api/pages/${pageId}`); - if (!response.ok) { - throw new Error(`Failed to fetch page: ${response.statusText}`); - } - return response.json(); -} -``` - -## JavaScript - -```javascript -const preprocessNotionMarkdown = (markdown) => { - // Fix 1: Ensure --- dividers have a blank line before them - let result = markdown.replace(/([^\n])\n(---+)(\n|$)/g, "$1\n\n$2$3"); - - // Fix 7: Ensure is treated as a standalone element - result = result.replace(/([^\n])\n()/g, "$1\n\n$2"); - result = result.replace(/()\n([^\n])/g, "$1\n\n$2"); - - return result; -}; -``` - -## Python - -```python -import json -import sys - -def extract_page_titles(response_json: str) -> list[str]: - """Extract page titles from a Notion API response.""" - data = json.loads(response_json) - titles = [] - for page in data.get("results", []): - name_prop = page.get("properties", {}).get("Name", {}) - title_parts = name_prop.get("title", []) - if title_parts: - titles.append(title_parts[0]["plain_text"]) - return titles - -if __name__ == "__main__": - raw = sys.stdin.read() - for title in extract_page_titles(raw): - print(title) -``` - -## Bash / Shell - -```bash -# Build and preview the blog template -pnpm run build -pnpm --filter notro-blog run preview - -# Fetch page list from Notion API -curl -s "https://api.notion.com/v1/databases/$NOTION_DATASOURCE_ID/query" \ - -H "Authorization: Bearer $NOTION_TOKEN" \ - -H "Notion-Version: 2026-03-11" \ - -H "Content-Type: application/json" \ - -d '{}' | python3 -m json.tool -``` - -## JSON - -```json -{ - "name": "remark-notro", - "version": "0.0.3", - "type": "module", - "scripts": { - "build": "tsdown", - "test": "vitest run" - }, - "dependencies": { - "micromark-extension-directive": "^4.0.0" - } -} -``` - -## CSS - -```css -.notro-callout { - border-left: 4px solid var(--callout-color, #e5e7eb); - padding: 0.75rem 1rem; - border-radius: 0.375rem; - display: flex; - gap: 0.5rem; - align-items: flex-start; -} - -.notro-callout[data-color="blue"] { - --callout-color: #3b82f6; - background-color: #eff6ff; -} -``` - -## Inline code - -Use `pnpm install` to install dependencies, then `pnpm run dev` to start the development server at `http://localhost:4321`. - -The `preprocessNotionMarkdown()` function in `packages/remark-nfm/src/transformer.ts` handles all preprocessing. diff --git a/content/docs/08-misc.md b/content/docs/08-misc.md deleted file mode 100644 index 8a49e658..00000000 --- a/content/docs/08-misc.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -slug: notro-fixture-misc -title: "[Fixture] Misc" ---- - -# Miscellaneous NFM Features - -## Table of Contents - -The TOC block is inserted by Notion and renders a navigable list of headings on the page. - - - ---- - -## Dividers (Horizontal Rules) - -The `---` syntax is used for dividers. The preprocessor ensures they are not misread as setext H2 headings by inserting a blank line before them. - -Content before the divider. - ---- - -Content after the divider. - -Another paragraph before a divider. - ---- - -## Empty Blocks - -Empty blocks in Notion create vertical spacing. They must be isolated with blank lines so remark treats them as block-level elements rather than inline content. - - - -Text after an empty block. - -Text before an empty block. - - - -## Blockquotes - -> This is a blockquote with a single paragraph. - -Regular paragraph that should NOT be absorbed into the blockquote (Fix 12 prevents lazy continuation). - -> Multi-line quote: -> Line two of the same blockquote. -> Line three. - -This paragraph follows the blockquote and should be separate. - -> Nested blockquotes: -> -> > This is a nested blockquote inside the outer one. -> > -> > It can have multiple paragraphs. - -## Synced Blocks - -Synced blocks share content across multiple pages. The preprocessor strips the wrapper tags and exposes the content directly. - - - This content comes from a synced block. - - It can contain any markdown content including **bold**, *italic*, and `code`. - - -## Standard Markdown Features - -### Text Formatting - -Regular paragraph with **bold text**, *italic text*, ~~strikethrough~~, and `inline code`. - -A paragraph with a [hyperlink](https://notrotail.mosugi.com) and an auto-link. - -### Lists - -Unordered list: - -- First item -- Second item - - Nested item - - Another nested item -- Third item - -Ordered list: - -1. First step -2. Second step - 1. Sub-step A - 2. Sub-step B -3. Third step - -Task list: - -- [x] Completed task -- [ ] Pending task -- [x] Another completed task - -### Images - -Notion inline images are handled via the `` Astro component through the component mapping. - -### Headings at All Levels - -# Heading 1 - -## Heading 2 - -### Heading 3 - -#### Heading 4 - -Text under Heading 4. diff --git a/content/docs/ja-deployment-cloudflare-pages.md b/content/docs/ja-deployment-cloudflare-pages.md new file mode 100644 index 00000000..22facde8 --- /dev/null +++ b/content/docs/ja-deployment-cloudflare-pages.md @@ -0,0 +1,96 @@ +--- +slug: ja/deployment/cloudflare-pages +title: Cloudflare Pages へのデプロイ +--- + +# Cloudflare Pages へのデプロイ + +このガイドでは、notro サイトを [Cloudflare Pages](https://pages.cloudflare.com/) にデプロイする方法を説明します。 + +## 前提条件 + +- [Cloudflare アカウント](https://dash.cloudflare.com/sign-up) +- GitHub または GitLab にプッシュされたプロジェクト + +## 1. Pages プロジェクトを作成する + +1. [Cloudflare ダッシュボード](https://dash.cloudflare.com/) にログイン +2. **Workers & Pages** → **Pages** → **Create a project** に移動 +3. **Connect to Git** をクリックして Cloudflare のリポジトリへのアクセスを承認 +4. リポジトリを選択して **Begin setup** をクリック + +## 2. ビルドを設定する + +ビルド設定フォームに以下を入力します: + +| 設定 | 値 | +|---|---| +| **Framework preset** | Astro | +| **Build command** | `pnpm build` | +| **Build output directory** | `dist` | +| **Root directory** | `/`(プロジェクトがリポジトリルートにある場合は空白のまま) | +| **Node.js version** | `24` | + +モノレポで Astro サイトがサブディレクトリ(例: `templates/blog/`)にある場合は、**Root directory** にそのパスを設定します。 + +## 3. 環境変数を設定する + +**Environment variables (advanced)** をクリックして以下を追加します: + +| 変数 | 値 | +|---|---| +| `NOTION_TOKEN` | Notion インテグレーションシークレット | +| `NOTION_DATASOURCE_ID` | Notion データベース UUID | + +> **ヒント:** これらを **Production** のみに設定するか、プレビューデプロイでも Notion からのフェッチを行う場合は **Preview** にも複製してください。 + +## 4. デプロイする + +**Save and Deploy** をクリックします。Cloudflare がリポジトリをクローンし、`pnpm install` と `pnpm build` を実行し、`dist/` ディレクトリを Cloudflare エッジネットワークにデプロイします。 + +## 5. 自動デプロイの設定 + +初回デプロイ後、Cloudflare は本番ブランチ(通常 `main`)へのプッシュごとに自動的にデプロイします。 + +notro サイトでは Notion のコンテンツ変更は自動的にリビルドをトリガー**しません**。Cloudflare の Deploy Hooks を使ってスケジュールリビルドを設定します: + +1. **Pages** → プロジェクト → **Settings** → **Builds & deployments** に移動 +2. **Deploy hooks** → **Add deploy hook** をクリック +3. 名前(例: `Notion content update`)を入力してビルドするブランチを選択 +4. 生成された URL をコピー + +コンテンツ変更時のリビルドをトリガーするため、この Webhook URL をクロンジョブや Notion オートメーションで使用します。 + +## カスタムドメイン + +1. Pages プロジェクト → **Custom domains** → **Set up a custom domain** に移動 +2. ドメインを入力して DNS 設定の指示に従う +3. Cloudflare が TLS 証明書を自動プロビジョニング + +## Node.js バージョン + +Cloudflare Pages は `NODE_VERSION` 環境変数で Node.js バージョンをサポートします。プロジェクトの環境変数で `24` を設定するか、リポジトリに `.node-version` ファイルを追加します: + +``` +24 +``` + +## トラブルシューティング + +**"pnpm not found" でビルド失敗** + +`package.json` に `pnpm` バージョンを追加します: + +```json +{ + "packageManager": "pnpm@10.33.0" +} +``` + +**環境変数が利用できない** + +変数が **Production** 環境に設定されていること(Preview のみでないこと)を確認し、追加後に再デプロイしたことを確認してください。 + +**大規模サイトが 20,000 ファイル制限に達する** + +Cloudflare Pages には 20,000 ファイルの制限があります。大規模サイトでは KV ストレージを使った Cloudflare Workers か、Vercel/Netlify を検討してください。 diff --git a/content/docs/ja-deployment-netlify.md b/content/docs/ja-deployment-netlify.md new file mode 100644 index 00000000..3b99942e --- /dev/null +++ b/content/docs/ja-deployment-netlify.md @@ -0,0 +1,147 @@ +--- +slug: ja/deployment/netlify +title: Netlify へのデプロイ +--- + +# Netlify へのデプロイ + +このガイドでは、notro サイトを [Netlify](https://www.netlify.com/) にデプロイする方法を説明します。 + +## 前提条件 + +- [Netlify アカウント](https://app.netlify.com/signup) +- GitHub、GitLab、または Bitbucket にプッシュされたプロジェクト + +## 1. 新しいサイトを作成する + +1. [app.netlify.com](https://app.netlify.com/) にログインして **Add new site** → **Import an existing project** をクリック +2. Git プロバイダーに接続してリポジトリを選択 + +## 2. ビルドを設定する + +以下のビルド設定を入力します: + +| 設定 | 値 | +|---|---| +| **Base directory** | _(空白のまま、またはモノレポのパッケージパスを設定)_ | +| **Build command** | `pnpm build` | +| **Publish directory** | `dist` | + +### netlify.toml(推奨) + +ビルド設定をバージョン管理に保存するため、リポジトリルートの `netlify.toml` に定義します: + +```toml +[build] + command = "pnpm build" + publish = "dist" + +[build.environment] + NODE_VERSION = "24" + PNPM_VERSION = "10" +``` + +Astro プロジェクトがサブディレクトリにあるモノレポの場合: + +```toml +[build] + base = "templates/blog" + command = "pnpm build" + publish = "dist" + +[build.environment] + NODE_VERSION = "24" +``` + +## 3. 環境変数を設定する + +Netlify ダッシュボードで **Site configuration** → **Environment variables** → **Add a variable** に移動します: + +| キー | 値 | +|---|---| +| `NOTION_TOKEN` | Notion インテグレーションシークレット | +| `NOTION_DATASOURCE_ID` | Notion データベース UUID | + +> **ヒント:** プレビュービルドで Notion からのフェッチを不要にする場合は、**Scopes** を使って変数を Production コンテキストのみに制限できます。 + +## 4. デプロイする + +**Deploy site** をクリックします。Netlify がリポジトリをクローンし、依存関係をインストールし、ビルドを実行し、`dist/` ディレクトリを CDN から提供します。 + +## 5. 自動デプロイとリビルドトリガー + +Netlify は本番ブランチへのプッシュごとに自動的にデプロイします。Notion コンテンツの変更には Build Hook を使います: + +1. **Site configuration** → **Build & deploy** → **Build hooks** に移動 +2. **Add build hook** をクリックして名前(例: `Notion content`)を入力してブランチを選択 +3. 生成された URL をコピー + +フックをトリガーしてリビルド: + +```bash +curl -X POST -d {} "https://api.netlify.com/build_hooks/YOUR_HOOK_ID" +``` + +### Netlify Functions によるスケジュールリビルド + +スケジュールされた Netlify Function を作成してスケジュールリビルドを実装: + +```ts +// netlify/functions/scheduled-rebuild.ts +import type { Config } from "@netlify/functions"; + +export default async function handler() { + await fetch(process.env.BUILD_HOOK_URL!, { method: "POST" }); + return { statusCode: 200 }; +} + +export const config: Config = { + schedule: "0 2 * * *", // 毎日 UTC 2:00 +}; +``` + +`BUILD_HOOK_URL` を自分のビルドフック URL を指す環境変数として追加してください。 + +## カスタムドメイン + +1. **Domain management** → **Add a domain** に移動 +2. カスタムドメインを入力して DNS 設定の指示に従う +3. Netlify が Let's Encrypt で TLS 証明書を自動プロビジョニング + +## Netlify Edge Functions(任意) + +SSR には Netlify アダプターをインストールします: + +```bash +pnpm add @astrojs/netlify +``` + +```js +// astro.config.mjs +import netlify from "@astrojs/netlify"; + +export default defineConfig({ + output: "server", + adapter: netlify(), + // ... +}); +``` + +## トラブルシューティング + +**`pnpm: command not found`** + +環境変数または `netlify.toml` で `PNPM_VERSION` を設定します: + +```toml +[build.environment] + PNPM_VERSION = "10" +``` + +**ビルドは成功するがサイトが 404 を表示** + +**Publish directory** が `dist` に設定されていることを確認してください(プロジェクトルートではありません)。 + +**ビルド時に環境変数が利用できない** + +変数の **Builds** スコープが有効になっていることを確認してください。変数設定でスコープが **Builds**(**Runtime** のみではない)を含んでいるか確認してください。 diff --git a/content/docs/ja-deployment-vercel.md b/content/docs/ja-deployment-vercel.md new file mode 100644 index 00000000..d10aa281 --- /dev/null +++ b/content/docs/ja-deployment-vercel.md @@ -0,0 +1,124 @@ +--- +slug: ja/deployment/vercel +title: Vercel へのデプロイ +--- + +# Vercel へのデプロイ + +このガイドでは、notro サイトを [Vercel](https://vercel.com/) にデプロイする方法を説明します。 + +## 前提条件 + +- [Vercel アカウント](https://vercel.com/signup) +- GitHub、GitLab、または Bitbucket にプッシュされたプロジェクト + +## 1. プロジェクトをインポートする + +1. [vercel.com](https://vercel.com/) にログインして **Add New Project** をクリック +2. **Import Git Repository** をクリックしてリポジトリを選択 +3. Vercel が Astro プロジェクトを自動検出してほとんどの設定を事前入力します + +## 2. ビルドを設定する + +ビルド設定を確認します: + +| 設定 | 値 | +|---|---| +| **Framework Preset** | Astro | +| **Build Command** | `pnpm build` | +| **Output Directory** | `dist` | +| **Install Command** | `pnpm install` | +| **Node.js Version** | `24.x`(**Settings → General** で設定) | + +Astro サイトがサブディレクトリにあるモノレポの場合は、**Root Directory** にパッケージパス(例: `templates/blog`)を設定します。 + +## 3. 環境変数を設定する + +**Environment Variables** をクリックして以下を追加します: + +| 変数 | 環境 | 値 | +|---|---|---| +| `NOTION_TOKEN` | Production、Preview | Notion インテグレーションシークレット | +| `NOTION_DATASOURCE_ID` | Production、Preview | Notion データベース UUID | + +## 4. デプロイする + +**Deploy** をクリックします。Vercel が `pnpm install` と `pnpm build` を実行し、`dist/` ディレクトリをグローバルにデプロイします。 + +## 5. 自動デプロイとリビルドトリガー + +Vercel は本番ブランチへのプッシュごとに自動的にデプロイします。Notion のコンテンツのみの変更には、Deploy Hook を設定します: + +1. **Settings** → **Git** → **Deploy Hooks** に移動 +2. **Create Hook** をクリックして名前を入力し、ブランチを選択 +3. URL をコピー + +この URL をクロンジョブ、GitHub Actions、または Notion オートメーションで使ってコンテンツ変更時のリビルドをトリガーします: + +```bash +curl -X POST "https://api.vercel.com/v1/integrations/deploy/HOOK_ID" +``` + +### GitHub Actions によるスケジュールリビルド + +```yaml +# .github/workflows/rebuild.yml +name: Scheduled rebuild +on: + schedule: + - cron: "0 2 * * *" # 毎日 UTC 2:00 + +jobs: + rebuild: + runs-on: ubuntu-latest + steps: + - name: Trigger Vercel deploy hook + run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" +``` + +`VERCEL_DEPLOY_HOOK` を GitHub リポジトリの Secrets に追加してください。 + +## カスタムドメイン + +1. プロジェクト → **Settings** → **Domains** → **Add** に移動 +2. ドメインを入力して DNS 設定の指示に従う +3. Vercel が Let's Encrypt で TLS 証明書を自動プロビジョニング + +## Vercel Edge Functions(任意) + +Astro の SSR 機能を使う場合は Vercel アダプターをインストールします: + +```bash +pnpm add @astrojs/vercel +``` + +```js +// astro.config.mjs +import vercel from "@astrojs/vercel/serverless"; + +export default defineConfig({ + output: "server", + adapter: vercel(), + // ... +}); +``` + +> **注意:** ほとんどのサイトには notro の静的サイト生成モード(`output: "static"`、デフォルト)が推奨されます。SSR はサーバーサイドの検索やパーソナライゼーションなどの機能に必要な場合のみ使用してください。 + +## トラブルシューティング + +**"Cannot find module" でビルド失敗** + +`node_modules` がリポジトリにコミットされていないことを確認してください。Vercel は依存関係を一から インストールします。 + +**Node.js バージョンの不一致** + +**Settings → General → Node.js Version** で `24.x` を設定するか、`.nvmrc` ファイルを追加します: + +``` +24 +``` + +**大規模な Notion データベースでメモリ制限超過** + +Vercel のビルドコンテナには 4 GB のメモリ制限があります。大規模なサイトでは、`loader()` の `filter` オプションを使って公開ページのみ取得することを検討してください。 diff --git a/content/docs/ja-getting-started-introduction.md b/content/docs/ja-getting-started-introduction.md new file mode 100644 index 00000000..7f5c0c29 --- /dev/null +++ b/content/docs/ja-getting-started-introduction.md @@ -0,0 +1,63 @@ +--- +slug: ja/getting-started/introduction +title: イントロダクション +--- + +# イントロダクション + +**notro** は Notion を Astro に変換する静的サイトジェネレーターです。Notion Public API を通じてコンテンツを取得し、MDX としてコンパイルし、すべての Notion ブロックタイプをスタイル付き Astro コンポーネントにマッピングします。CMS を別途管理することなく、高速で SEO 最適化された静的サイトを生成できます。 + +## なぜ notro を使うのか + +Notion は強力な執筆ツールですが、そのコンテンツを洗練されたウェブサイトとして公開するには、従来はカスタム API 統合と手動のレンダリングロジックが必要でした。notro がそのすべてを処理します: + +- **ビルド時のレンダリングレイテンシーゼロ** — ページはビルド時に静的生成されます +- **Notion ブロックの完全サポート** — コールアウト、トグル、カラム、同期ブロック、数式など +- **MDX パイプライン** — remark/rehype プラグイン(数式、Mermaid 図、シンタックスハイライト)を Notion コンテンツと組み合わせて使用 +- **コピーして使うコンポーネント** — notro-ui が自由にカスタマイズできるスタイル付き Notion ブロックコンポーネントを提供 +- **複数のテンプレート** — フル機能のブログテンプレートまたはミニマルな blank テンプレートから始められます + +## 仕組み + +notro は [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) の上に構築されています。`loader()` 関数はカスタムコンテンツローダーとして動作します: + +1. `dataSources.query` を通じて Notion データソースのページ一覧を取得 +2. `pages.retrieveMarkdown` で各ページの Markdown を取得 +3. `last_edited_time` でページをキャッシュし、再ビルド時の不要な API 呼び出しを回避 +4. 前処理済みの Markdown を Content Collection ストアに保存 + +レンダリング時、`NotroContent` コンポーネントが保存された Markdown を MDX パイプラインでコンパイルし、Notion コンポーネントマッピングで描画します。 + +``` +Notion データベース + ↓ notro-loader(Astro Content Loader) +Content Collection ストア + ↓ NotroContent + compileMdx +レンダリング済み HTML ページ +``` + +## パッケージ + +notro はモノレポです。公開されている npm パッケージは以下のとおりです: + +| パッケージ | 用途 | +|---|---| +| `notro-loader` | Astro Content Loader + MDX コンパイルパイプライン + Notion ブロックコンポーネント | +| `remark-nfm` | Notion Flavored Markdown 正規化用 remark プラグイン | +| `notro-ui` | コピーして使うスタイル付き Notion ブロックコンポーネント(shadcn スタイル) | +| `rehype-beautiful-mermaid` | Mermaid コードブロックをビルド時にインライン SVG にレンダリング | +| `create-notro` | CLI スキャフォールディングツール(`npm create notro@latest`) | + +## テンプレート + +2 種類のスターターテンプレートが利用できます: + +- **notro-blog** — ページネーション、タグ、カテゴリ、RSS、サイトマップを備えたフル機能のブログ +- **notro-blank** — カスタムプロジェクト向けのミニマルな一ページスターター + +どちらのテンプレートも TailwindCSS 4、notro-ui コンポーネント、MDX パイプラインが事前設定されています。 + +## 次のステップ + +- [クイックスタート](/ja/getting-started/quick-start) — 数分でプロジェクトを作成して Notion に接続 +- [Notion セットアップ](/ja/getting-started/notion-setup) — インテグレーションの作成とデータベースの設定方法 diff --git a/content/docs/ja-getting-started-notion-setup.md b/content/docs/ja-getting-started-notion-setup.md new file mode 100644 index 00000000..dccd9cce --- /dev/null +++ b/content/docs/ja-getting-started-notion-setup.md @@ -0,0 +1,113 @@ +--- +slug: ja/getting-started/notion-setup +title: Notion セットアップ +--- + +# Notion セットアップ + +このページでは、Notion Internal Integration の作成、正しいスキーマのデータベースセットアップ、そして notro に必要な認証情報の取得方法を説明します。 + +## 1. Notion インテグレーションを作成する + +1. [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations) を開く +2. **+ New integration** をクリック +3. 以下を入力: + - **Name** — 例: `notro-blog` + - **Associated workspace** — ワークスペースを選択 + - **Type** — Internal +4. **Capabilities** で **Read content** にチェックが入っていることを確認 +5. **Submit** をクリック +6. **Internal Integration Secret** をコピー — これが `NOTION_TOKEN` です + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +> **セキュリティ:** `NOTION_TOKEN` はパスワードと同様に扱ってください。バージョン管理にコミットしないでください。`.env` を `.gitignore` に追加してください。 + +## 2. データベースを作成する + +Notion で新しいフルページデータベースを作成します。blog テンプレートでは以下のプロパティスキーマが必要です: + +| プロパティ | タイプ | 必須 | 用途 | +|---|---|---|---| +| `Name` | Title | ✓ | 投稿タイトル | +| `Slug` | Rich text | ✓ | URL スラグ(例: `hello-world`) | +| `Description` | Rich text | | 一覧に表示される抜粋 | +| `Public` | Checkbox | ✓ | `Public = true` のページのみビルドに含まれます | +| `Date` | Date | | 公開日 | +| `Tags` | Multi-select | | フィルタリング用タグ | +| `Category` | Select | | フィルタリング用カテゴリ | + +追加のプロパティも自由に追加できます。notro はすべてのプロパティを `content.config.ts` で定義したスキーマに通して処理します。 + +## 3. データベースをインテグレーションと共有する + +Notion インテグレーションはデフォルトではコンテンツにアクセスできません。 + +1. Notion でデータベースを開く +2. **⋯**(右上)→ **Connections** → **+ Add connections** をクリック +3. インテグレーション名を検索して **Confirm** をクリック + +これでインテグレーションがこのデータベースへの読み取りアクセスを持ちます。 + +## 4. データベース ID を取得する + +`NOTION_DATASOURCE_ID` はデータベース URL の UUID です。 + +データベースをフルページとして開きます。URL は以下のような形式です: + +``` +https://www.notion.so/your-workspace/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?v=... +``` + +32 文字の 16 進数文字列(ハイフンあり・なし問わず)がデータベース ID です: + +```bash +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +## 5. 環境変数を設定する + +プロジェクトの `.env` ファイルに両方の値を追加します: + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +本番デプロイでは、`.env` ファイルを含めるのではなく、ホスティングプラットフォーム(Cloudflare Pages、Vercel、Netlify)の環境変数として設定します。 + +## 6. 最初のページを作成する + +Notion データベースで新しいページを作成します: + +1. **Name** に投稿タイトルを設定(例: `Hello, World!`) +2. **Slug** に URL フレンドリーな文字列を設定(例: `hello-world`) +3. **Public** にチェックしてビルドに含める +4. **Date** に今日の日付を設定 +5. ページ本文にコンテンツを書く + +プロジェクトで `pnpm dev` を実行 — `/blog/hello-world` にページが表示されるはずです。 + +## ヒント + +### Notion での Markdown + +notro は Notion の Markdown Content API でコンテンツを取得します。この API は Notion ブロックを Markdown に変換します。ほとんどのブロックタイプがサポートされています: + +- コールアウト、トグル、カラム +- シンタックスハイライト付きコードブロック +- テーブル、画像、埋め込み +- 数式(LaTeX) +- 同期ブロック + +サポートされていないブロックは無視されます。Notion は API レスポンスの `unknown_block_ids` にブロック ID を記録し、notro はこれを警告としてログに出力します。 + +### Notion Flavored Markdown + +Notion の Markdown 出力にはいくつかのクセがあります(区切り線と見出しの曖昧さ、カラーアノテーションなど)。`remark-nfm` プラグインがこれらを自動的に処理するため、特別な対応は不要です。 + +### Notion の画像 + +Notion は画像を有効期限付きのプリサインド S3 URL として提供します。notro には `notionImageService`(`astro.config.mjs` で設定)が含まれており、有効期限切れのクエリパラメーターをキャッシュキーの計算前に除去するため、繰り返しのビルドでもキャッシュ済みの画像を再利用できます。設定の詳細は[設定](/ja/guides/configuration)を参照してください。 diff --git a/content/docs/ja-getting-started-quick-start.md b/content/docs/ja-getting-started-quick-start.md new file mode 100644 index 00000000..b338edb6 --- /dev/null +++ b/content/docs/ja-getting-started-quick-start.md @@ -0,0 +1,121 @@ +--- +slug: ja/getting-started/quick-start +title: クイックスタート +--- + +# クイックスタート + +このガイドでは、ゼロから 10 分以内に notro サイトを立ち上げる手順を説明します。 + +## 前提条件 + +- **Node.js 24+** および **pnpm 9+**(または npm/yarn) +- 既存のデータベースを持つ [Notion アカウント](https://www.notion.so/)、または作成権限 + +## 1. プロジェクトを作成する + +```bash +npm create notro@latest +``` + +CLI が以下の項目を尋ねます: + +1. **テンプレートを選択** — `blog`(フル機能)または `blank`(ミニマル) +2. **プロジェクト名を入力** — ディレクトリ名として使用されます + +``` +◆ Which template would you like to use? +│ ● blog Full-featured blog with pagination, tags, and RSS +│ ○ blank Minimal starter +◆ Project name: my-notro-site +◆ Scaffolding to ./my-notro-site… +✔ Done! Next steps: +``` + +## 2. 依存関係をインストールする + +```bash +cd my-notro-site +pnpm install +``` + +## 3. 環境変数を設定する + +サンプル env ファイルをコピーして Notion の認証情報を入力します: + +```bash +cp .env.example .env +``` + +`.env` を開いて以下を設定します: + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +これらの値の取得方法は [Notion セットアップ](/ja/getting-started/notion-setup) を参照してください。 + +## 4. 開発サーバーを起動する + +```bash +pnpm dev +``` + +Astro 開発サーバーが **http://localhost:4321** で起動します。初回起動時、notro は Notion データベースからすべてのページを取得してキャッシュします。 + +> **ヒント:** 初回起動はデータベースのページ数によって数秒かかる場合があります。2 回目以降は `last_edited_time` によるキャッシュが効くため高速です。 + +## 5. Notion でコンテンツを編集する + +開発サーバーの起動中に、Notion データベースのページを編集します。開発サーバーを再起動(またはファイルを保存してリフレッシュ)すると変更が反映されます。 + +## 6. 本番ビルドを作成する + +```bash +pnpm build +``` + +Astro が `astro check`(型チェック)に続けて `astro build` を実行し、静的サイトを `dist/` に生成します。 + +```bash +pnpm preview # ローカルで本番ビルドをプレビュー +``` + +## 7. デプロイする + +プロジェクトを GitHub にプッシュし、ホスティングプラットフォームに接続します。手順ごとのガイドはデプロイメントガイドを参照してください: + +- [Cloudflare Pages](/ja/deployment/cloudflare-pages) +- [Vercel](/ja/deployment/vercel) +- [Netlify](/ja/deployment/netlify) + +## プロジェクト構成 + +スキャフォールド後のプロジェクト構成(blog テンプレート): + +``` +my-notro-site/ +├── src/ +│ ├── components/ # Header、Footer、BlogList +│ ├── layouts/ # Layout.astro +│ ├── lib/ # blog.ts、nav.ts、seo.ts +│ ├── pages/ # ファイルベースルーティング +│ │ ├── index.astro +│ │ └── blog/ +│ │ ├── [...page].astro +│ │ └── [slug].astro +│ ├── styles/ +│ │ ├── global.css # TailwindCSS 4 + レイアウトユーティリティ +│ │ └── notro-theme.css # Notion ブロックカラートークン +│ └── content.config.ts # Astro Content Collections +├── astro.config.mjs +├── package.json +└── .env +``` + +## 次のステップ + +- [Notion セットアップ](/ja/getting-started/notion-setup) — Notion インテグレーションとデータベーススキーマの設定 +- [設定](/ja/guides/configuration) — notro パイプライン、プラグイン、イメージサービスのカスタマイズ +- [コンポーネントのカスタマイズ](/ja/guides/customizing-components) — Notion ブロックコンポーネントのオーバーライドと拡張 diff --git a/content/docs/ja-guides-architecture.md b/content/docs/ja-guides-architecture.md new file mode 100644 index 00000000..34433228 --- /dev/null +++ b/content/docs/ja-guides-architecture.md @@ -0,0 +1,130 @@ +--- +slug: ja/guides/architecture +title: アーキテクチャ +--- + +# アーキテクチャ + +このページでは、notro が内部でどのように動作するかを説明します — Notion コンテンツの取得から最終的な HTML ページのレンダリングまでの流れです。 + +## 概要 + +``` +Notion データベース + ↓ loader() — Astro Content Loader(notro-loader) +Content Collection — ページごとにキャッシュされた markdown + プロパティ + ↓ NotroContent — compileMdx() + コンポーネントマッピング +レンダリング済み HTML ページ +``` + +notro は [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) の上に完全に構築されています。別のサーバーや Webhook は不要で、すべてビルド時(または `astro dev` 中)に処理されます。 + +## コンテンツの読み込み + +`notro-loader` の `loader()` 関数はカスタム Astro Content Loader です。ビルドまたは開発サーバー起動のたびに以下を実行します: + +1. `notion.dataSources.query` を呼び出してデータソースの全ページを一覧取得(ページネーション対応) +2. 各ページについて `last_edited_time` を比較してキャッシュが有効かチェック +3. 古くなったページや新しいページは `notion.pages.retrieveMarkdown` で raw markdown を取得 +4. raw markdown に `preprocessNotionMarkdown()`(`remark-nfm` から)を実行して構造的な問題を修正 +5. ページの `id`、`properties`、前処理済み `markdown` を Content Collection ストアに保存 + +Notion に存在しなくなったページはストアから削除されます。 + +### キャッシュの無効化 + +以下の場合にエントリが無効化されます: + +- Notion の `last_edited_time` がキャッシュ値より新しい +- キャッシュ済み markdown に期限切れの Notion プリサインド S3 画像 URL が含まれている(`X-Amz-Expires`) +- ページが Notion に存在しなくなった(削除または共有解除) + +### エラーハンドリング + +| エラー | 動作 | +|---|---| +| `429 rate_limited` / `500` / `503` | 指数バックオフでリトライ(1s、2s、4s;最大 3 回) | +| `401 unauthorized` / `403 restricted_resource` / `404 object_not_found` | 警告をログに出力してページをスキップ — ビルドは継続 | +| その他の予期しないエラー | 警告をログに出力してページをスキップ — ビルドは継続 | + +## MDX コンパイルパイプライン + +`NotroContent` がページをレンダリングするとき、`compileMdxCached()` を呼び出します。これは保存された markdown を `@mdx-js/mdx` の `evaluate()` で以下のプラグインパイプラインを通して処理します: + +### remark プラグイン(Markdown AST) + +| プラグイン | 用途 | +|---|---| +| `remarkNfm` | `preprocessNotionMarkdown` 正規化、ディレクティブ構文 + GFM(打ち消し線、タスクリスト)、コールアウト変換をまとめて処理 | +| _(ユーザー指定)_ | 例: `remark-math`(LaTeX 数式用) | + +### rehype プラグイン(HTML AST) + +| プラグイン | 順序 | 用途 | +|---|---|---| +| `rehypeRaw` | 1 | Markdown 中の raw HTML 文字列を hast ノードに変換;カスタム要素はそのまま通す | +| `rehypeNotionColor` | 2 | `color="gray_bg"` 属性を `notro-*` CSS クラスに変換 | +| `rehypeBlockElements` | 3 | Notion ブロック要素を PascalCase にリネーム(`video` → `Video`) | +| `rehypeInlineMentions` | 4 | インライン mention 要素をリネーム(`mention-user` → `MentionUser`) | +| _(ユーザー指定)_ | 5 | 例: `rehype-katex`、`rehype-beautiful-mermaid` | +| `rehypeShiki` | 6 | シンタックスハイライト(`shikiConfig` 設定時に注入) | +| `rehypeSlug` | 7 | 見出しに `id` 属性を追加 | +| `rehypeToc` | 8 | `` にアンカーリンクを設定 | +| `resolvePageLinks` | 9 | `linkToPages` マップを使って `notion.so` URL をサイト相対 URL に解決 | + +### コンポーネントマッピング + +`evaluate()` の後、`` がすべての Notion ブロックタイプを Astro コンポーネントにマッピングします: + +```ts +const notionComponents = { + callout: Callout, + toggle: Toggle, + columns: Columns, + column: Column, + video: Video, + table_of_contents: TableOfContents, + // ... など + a: Link, + img: NotionImage, + pre: CodeBlock, + // ... +}; +``` + +`NotroContent` の `components` プロパティでカスタムオーバーライドをマージできます。 + +## Markdown 前処理 + +MDX パイプラインが実行される前に、`preprocessNotionMarkdown()` が Notion の raw Markdown 出力の構造的問題を修正します: + +| 修正 | 対象の問題 | +|---|---| +| Fix 1 | 前の空行なしの `---` が setext H2 として誤認識される | +| Fix 2 | コールアウトディレクティブ構文の正規化 | +| Fix 3 | ブロックレベルのカラーアノテーションを raw HTML に変換 | +| Fix 4 | `` を CommonMark 検出のため `
` で囲む | +| Fix 5 | インライン数式フォーマットの正規化 | +| Fix 6 | `` ラッパーを削除 | +| Fix 7 | `` をブロックレベル要素として独立させる | +| Fix 8 | 閉じタグに末尾の空行を追加(CommonMark が後続の markdown を飲み込むのを防ぐ) | +| Fix 9 | `` セル内の Markdown リンクを `` タグに変換 | + +## 画像処理 + +Notion はページ画像を有効期限付きのプリサインド S3 URL として提供します(`X-Amz-Expires`、`X-Amz-Date` などのクエリパラメーター)。これらは API 呼び出しのたびに変わるため、Astro のイメージキャッシュが毎回ミスします。 + +`notionImageService` は Astro の組み込み Sharp サービスをラップし、これらの有効期限切れパラメーターをキャッシュキーの計算前に除去します。これにより、実際のコンテンツが変化したときのみ画像が再処理されます。 + +## パッケージエントリーポイント + +`notro-loader` は異なるインポートコンテキストに対応するため 4 つのエントリーポイントを提供します: + +| エントリーポイント | 用途 | +|---|---| +| `notro-loader` | コンポーネントとローダー — `.astro` と `content.config.ts` で使用 | +| `notro-loader/integration` | `notro()` Astro インテグレーション — `astro.config.mjs` で使用 | +| `notro-loader/utils` | 純粋な TypeScript ヘルパー — `astro.config.mjs` や Node スクリプトでも安全に使用可能 | +| `notro-loader/image-service` | `notionImageService` — `astro.config.mjs` の `image.service` で使用 | + +`astro.config.mjs` は JSX レンダラーが登録される前に評価されるため、設定ファイル時に Astro コンポーネントをインポートすると失敗します。この分割はその問題を解決します。 diff --git a/content/docs/ja-guides-configuration.md b/content/docs/ja-guides-configuration.md new file mode 100644 index 00000000..39d3fd7c --- /dev/null +++ b/content/docs/ja-guides-configuration.md @@ -0,0 +1,154 @@ +--- +slug: ja/guides/configuration +title: 設定 +--- + +# 設定 + +このページでは notro のすべての設定オプション — Astro インテグレーション、環境変数、イメージサービス — を説明します。 + +## astro.config.mjs + +blog テンプレートの典型的な `astro.config.mjs` は以下のようになります: + +```js +import { defineConfig } from "astro/config"; +import sitemap from "@astrojs/sitemap"; +import { notro } from "notro-loader/integration"; +import { notionImageService } from "notro-loader/image-service"; +import { rehypeMermaid } from "rehype-beautiful-mermaid"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; + +export default defineConfig({ + site: "https://example.com", + image: { + service: notionImageService, + }, + integrations: [ + notro({ + shikiConfig: { theme: "github-dark" }, + remarkPlugins: [remarkMath], + rehypePlugins: [ + [rehypeMermaid, { theme: "github-dark" }], + rehypeKatex, + ], + }), + sitemap(), + ], +}); +``` + +## notro() インテグレーションのオプション + +`notro()` インテグレーションは `@astrojs/mdx` を notro のコアプラグインスイートで登録します。すべてのオプションは任意です。 + +| オプション | 型 | デフォルト | 説明 | +|---|---|---|---| +| `remarkPlugins` | `PluggableList` | `[]` | `remarkNfm` の後に追加する remark プラグイン | +| `rehypePlugins` | `PluggableList` | `[]` | `rehypeShiki` の前に挿入する rehype プラグイン | +| `shikiConfig` | `Record` | `undefined` | 設定すると `@shikijs/rehype` を最後のプラグインとして注入。`npm i @shikijs/rehype` が必要 | +| `viteExternals` | `string[]` | `[]` | Vite の `ssr.external` に追加するパッケージ(ネイティブバイナリ用) | +| `extendMarkdownConfig` | `boolean` | `false` | Astro の基本 markdown 設定を拡張するかどうか | + +### シンタックスハイライトの追加 + +```js +notro({ + shikiConfig: { + theme: "github-dark", + // または複数テーマ: + themes: { light: "github-light", dark: "github-dark" }, + }, +}), +``` + +### 数式サポートの追加 + +```bash +pnpm add remark-math rehype-katex +``` + +```js +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; + +notro({ + remarkPlugins: [remarkMath], + rehypePlugins: [rehypeKatex], +}), +``` + +### Mermaid 図の追加 + +```bash +pnpm add rehype-beautiful-mermaid +``` + +```js +import { rehypeMermaid } from "rehype-beautiful-mermaid"; + +notro({ + rehypePlugins: [[rehypeMermaid, { theme: "github-dark" }]], + viteExternals: ["@mermaid-js/mermaid-zenuml"], // ZenUML 使用時 +}), +``` + +## イメージサービス + +`notionImageService` を `astro.config.mjs` の `image.service` に設定してください。Astro の組み込み Sharp サービスをラップし、Notion の有効期限付き S3 URL パラメーター(`X-Amz-*`)をイメージキャッシュキーの計算前に除去します。これにより、毎ビルドでの無駄な再処理を防ぎます。 + +```js +import { notionImageService } from "notro-loader/image-service"; + +export default defineConfig({ + image: { + service: notionImageService, + }, + // ... +}); +``` + +このサービスを設定しない場合、Notion のプリサインド URL はフェッチのたびに変わるため、毎ビルドで画像が再処理されます。 + +## 環境変数 + +| 変数 | 必須 | 説明 | +|---|---|---| +| `NOTION_TOKEN` | ✓ | Notion Internal Integration Secret | +| `NOTION_DATASOURCE_ID` | ✓ | Notion データベース(データソース)UUID | + +プロジェクトルートに `.env` ファイルを作成します(すでに `.gitignore` に含まれています): + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +本番環境では、ホスティングプラットフォームの環境変数として設定します。詳細はデプロイメントガイドを参照してください。 + +## TypeScript 設定 + +生成された `tsconfig.json` は Astro の strict 設定を拡張しています。notro のために変更は不要です。 + +`@notionhq/client` の型エラーが表示される場合は、`skipLibCheck: true` が設定されていることを確認してください: + +```json +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "skipLibCheck": true + } +} +``` + +## サイト URL + +`astro.config.mjs` の `site` オプションを本番 URL に設定してください。カノニカル URL、`og:url`、サイトマップに必要です: + +```js +export default defineConfig({ + site: "https://your-site.com", + // ... +}); +``` diff --git a/content/docs/ja-guides-content-collections.md b/content/docs/ja-guides-content-collections.md new file mode 100644 index 00000000..5bab1ecd --- /dev/null +++ b/content/docs/ja-guides-content-collections.md @@ -0,0 +1,192 @@ +--- +slug: ja/guides/content-collections +title: コンテンツコレクション +--- + +# コンテンツコレクション + +notro は [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) を使って Notion ページを管理します。このページではコレクションスキーマとローダーの設定方法を説明します。 + +## 基本セットアップ + +`src/content.config.ts` でコレクションを定義します。blog テンプレートには `posts` コレクションが含まれています: + +```ts +import { defineCollection } from "astro:content"; +import { loader, pageWithMarkdownSchema, notroProperties } from "notro-loader"; +import { getPlainText } from "notro-loader/utils"; +import { z } from "zod"; + +const postsSchema = pageWithMarkdownSchema + .extend({ + properties: z.object({ + Name: notroProperties.title, + Description: notroProperties.richText.optional(), + Slug: notroProperties.richText, + Public: notroProperties.checkbox, + Date: notroProperties.date.optional(), + Tags: notroProperties.multiSelect.optional(), + Category: notroProperties.select.optional(), + }), + }) + .transform((data) => ({ + ...data, + title: getPlainText(data.properties.Name) ?? "Untitled", + description: getPlainText(data.properties.Description) ?? undefined, + slug: getPlainText(data.properties.Slug) ?? data.id, + date: data.properties.Date?.date?.start, + tags: data.properties.Tags?.multi_select.map((t) => t.name) ?? [], + category: data.properties.Category?.select?.name, + isPublic: data.properties.Public.checkbox, + })); + +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: postsSchema, + }), +}; +``` + +## pageWithMarkdownSchema + +`pageWithMarkdownSchema` はローダーが返す Notion ページの基本 Zod スキーマです: + +| フィールド | 型 | 説明 | +|---|---|---| +| `id` | `string` | Notion ページ UUID | +| `markdown` | `string` | 前処理済み markdown コンテンツ | +| `last_edited_time` | `string` | ISO 8601 タイムスタンプ | +| `properties` | `Record` | raw Notion プロパティ(`.extend()` で拡張) | + +## notroProperties ヘルパー + +`notroProperties` は各 Notion プロパティタイプの形状にマッチする Zod スキーマを提供します: + +| ヘルパー | Notion タイプ | アクセスパターン | +|---|---|---| +| `notroProperties.title` | Title | `prop.title[0]?.plain_text` (`getPlainText()` 経由) | +| `notroProperties.richText` | Rich text | `prop.rich_text[0]?.plain_text` (`getPlainText()` 経由) | +| `notroProperties.checkbox` | Checkbox | `prop.checkbox` → `boolean` | +| `notroProperties.date` | Date | `prop.date?.start` → `string \| undefined` | +| `notroProperties.select` | Select | `prop.select?.name` → `string \| undefined` | +| `notroProperties.multiSelect` | Multi-select | `prop.multi_select.map(o => o.name)` | +| `notroProperties.number` | Number | `prop.number` → `number \| null` | +| `notroProperties.url` | URL | `prop.url` → `string \| null` | + +## loader() オプション + +```ts +loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + sorts: [{ property: "Date", direction: "descending" }], + }, + clientOptions: { + auth: import.meta.env.NOTION_TOKEN, + }, +}) +``` + +### queryParameters + +`notion.dataSources.query` に直接渡されます。すべての Notion フィルターとソートオプションをサポートします。 + +**フィルターの例:** + +```ts +// 公開ページのみ +filter: { property: "Public", checkbox: { equals: true } } + +// 特定のタグを持つページ +filter: { property: "Tags", multi_select: { contains: "tutorial" } } + +// 複合フィルター +filter: { + and: [ + { property: "Public", checkbox: { equals: true } }, + { property: "Category", select: { equals: "blog" } }, + ], +} +``` + +**ソートの例:** + +```ts +// 日付の降順 +sorts: [{ property: "Date", direction: "descending" }] + +// 最終編集時刻順 +sorts: [{ timestamp: "last_edited_time", direction: "descending" }] +``` + +### clientOptions + +`@notionhq/client` の `Client` コンストラクターに渡されます。`auth` に Notion トークンを設定してください。 + +## 複数のコレクション + +異なる Notion データベースを指す複数のコレクションを定義できます: + +```ts +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { data_source_id: import.meta.env.NOTION_BLOG_DB_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: postsSchema, + }), + projects: defineCollection({ + loader: loader({ + queryParameters: { data_source_id: import.meta.env.NOTION_PROJECTS_DB_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: projectsSchema, + }), +}; +``` + +## ページコンポーネントでのコレクションデータの使用 + +```astro +--- +// src/pages/blog/[slug].astro +import { getCollection } from "astro:content"; +import { NotroContent } from "notro-loader"; + +export async function getStaticPaths() { + const posts = await getCollection("posts"); + return posts.map((post) => ({ + params: { slug: post.data.slug }, + props: { post }, + })); +} + +const { post } = Astro.props; +--- +
+

{post.data.title}

+ +
+``` + +## getPlainText ユーティリティ + +`getPlainText` は Notion のリッチテキストまたはタイトルプロパティ値からプレーンテキスト文字列を抽出します: + +```ts +import { getPlainText } from "notro-loader/utils"; + +getPlainText(properties.Name) // "My Post Title" +getPlainText(properties.Description) // "A short description" | undefined +``` + +プロパティが存在しないか、テキストコンテンツがない場合は `undefined` を安全に返します。 diff --git a/content/docs/ja-guides-customizing-components.md b/content/docs/ja-guides-customizing-components.md new file mode 100644 index 00000000..ab2ff7c7 --- /dev/null +++ b/content/docs/ja-guides-customizing-components.md @@ -0,0 +1,183 @@ +--- +slug: ja/guides/customizing-components +title: コンポーネントのカスタマイズ +--- + +# コンポーネントのカスタマイズ + +notro は Notion ブロックを Astro コンポーネントでレンダリングします。このページではブロックの見た目と動作をカスタマイズする 3 つの方法を説明します。 + +## notro-ui: コピーして使うコンポーネント + +Notion ブロックコンポーネントは `notro-ui` CLI で管理します。コンポーネントは `packages/notro-ui/src/templates/` にソースとして置かれており、プロジェクトにコピーすることで自分のコードとして扱えます。 + +### CLI のインストール + +```bash +npm i -g notro-ui +# または pnpm dlx で使用: +pnpm dlx notro-ui --help +``` + +### プロジェクトにコンポーネントを追加する + +プロジェクトルート(`notro.json` を置く場所)で以下を実行します: + +```bash +# 初期化(notro.json を作成し、notro-theme.css を配置) +notro-ui init + +# すべてのコンポーネントを追加(既存ファイルはスキップ) +notro-ui add --all + +# 特定のコンポーネントを追加 +notro-ui add callout toggle columns +``` + +### アップストリームからコンポーネントを更新する + +```bash +# 更新を取得(ローカルの変更を上書き — 注意して使用) +notro-ui update --all --yes + +# 変更をプレビュー(--yes なしで確認プロンプトを表示) +notro-ui update --all +``` + +### 利用可能なコマンド + +| コマンド | 動作 | +|---|---| +| `notro-ui init` | `notro.json` を作成し、`notro-theme.css` を配置 | +| `notro-ui add [name...] [--all]` | コンポーネントを追加(**既存ファイルはスキップ**) | +| `notro-ui update [name...] [--all] [--yes]` | コンポーネントを更新(**上書き**) | +| `notro-ui remove [name...] [--all]` | コンポーネントを削除 | +| `notro-ui list [--installed]` | 利用可能・インストール済みコンポーネントを一覧表示 | + +### notro.json + +`notro-ui init` はプロジェクトルートに `notro.json` を作成します: + +```json +{ + "outDir": "src/components/notion", + "stylesDir": "src/styles", + "components": ["callout", "toggle", "columns", "code-block"] +} +``` + +インストール済みのコンポーネントを追跡するために `notro.json` を git にコミットしてください。 + +--- + +## classMap による CSS クラスのオーバーライド + +コンポーネントを置き換えずにスタイルをカスタマイズする最も簡単な方法は、`NotroContent` の `classMap` プロパティです。`ClassMapKeys → string` の部分マップを渡して、特定の要素に追加の Tailwind クラスを注入できます: + +```astro +--- +import { NotroContent } from "notro-loader"; +const { entry } = Astro.props; +--- + +``` + +クラスマップのキーはコンポーネントスロットに対応します。利用可能なキーは `notro-ui list` で確認できます。 + +--- + +## components による完全なコンポーネントオーバーライド + +完全なコントロールが必要な場合は、`components` プロパティでカスタム Astro コンポーネントを渡します: + +```astro +--- +import { NotroContent } from "notro-loader"; +import MyCallout from "./MyCallout.astro"; +import MyCodeBlock from "./MyCodeBlock.astro"; +const { entry } = Astro.props; +--- + +``` + +指定したキーのみがオーバーライドされ、他はすべて notro のデフォルトコンポーネントが使われます。 + +### カスタムコンポーネントの書き方 + +コンポーネントのプロパティは Notion が生成する HTML 属性をミラーします。例えば、カスタムコールアウトは `icon`、`color`、スロットコンテンツを受け取ります: + +```astro +--- +// MyCallout.astro +interface Props { + icon?: string; + color?: string; +} +const { icon, color } = Astro.props; +--- + +``` + +### コンポーネントマップのリファレンス + +| キー | 要素 | Notion ブロック | +|---|---|---| +| `callout` | `` | コールアウトブロック | +| `toggle` | `
` | トグルブロック | +| `columns` | `` | カラムレイアウトラッパー | +| `column` | `` | 個別のカラム | +| `video` | `
` | コードブロック |
+| `img` | `` | 画像 |
+| `a` | `` | リンク |
+| `h1`–`h4` | `

`–`

` | 見出し | + +--- + +## notro-theme.css によるスタイリング + +`notro-theme.css` は Notion ブロックカラー、トグルスタイル、テーブルスタイルなどの CSS 変数とユーティリティクラスを定義します。`notro-ui init` によって `src/styles/` に配置されます。 + +グローバルスタイルシートでインポートします: + +```css +/* global.css */ +@import "tailwindcss"; +@import "./notro-theme.css"; +``` + +### カラートークン + +```css +/* テキストカラー */ +.notro-text-gray .notro-text-brown .notro-text-orange +.notro-text-yellow .notro-text-green .notro-text-blue +.notro-text-purple .notro-text-pink .notro-text-red + +/* 背景カラー */ +.notro-bg-gray .notro-bg-brown .notro-bg-orange +.notro-bg-yellow .notro-bg-green .notro-bg-blue +.notro-bg-purple .notro-bg-pink .notro-bg-red +``` + +これらは Notion ページにカラーアノテーション付きブロックが含まれている場合に `rehypeNotionColor` プラグインが自動的に適用します。 diff --git a/content/docs/ja-guides-rss-and-sitemap.md b/content/docs/ja-guides-rss-and-sitemap.md new file mode 100644 index 00000000..ccd698a7 --- /dev/null +++ b/content/docs/ja-guides-rss-and-sitemap.md @@ -0,0 +1,161 @@ +--- +slug: ja/guides/rss-and-sitemap +title: RSS とサイトマップ +--- + +# RSS とサイトマップ + +blog テンプレートには RSS フィードとサイトマップが事前設定されています。このページではその仕組みとカスタマイズ方法を説明します。 + +## サイトマップ + +サイトマップは `@astrojs/sitemap` によって生成されます。静的生成されたすべてのページが自動的に含まれます。 + +### セットアップ + +インテグレーションをインストールします(blog テンプレートには含まれています): + +```bash +pnpm add @astrojs/sitemap +``` + +`astro.config.mjs` に追加します: + +```js +import sitemap from "@astrojs/sitemap"; + +export default defineConfig({ + site: "https://your-site.com", // サイトマップに必要 + integrations: [ + notro(), + sitemap(), + ], +}); +``` + +サイトマップは `/sitemap-index.xml` に生成され、`` から自動的にリンクされます。 + +### ページの除外 + +特定のページをサイトマップから除外するには: + +```js +sitemap({ + filter: (page) => !page.includes("/draft/"), +}), +``` + +--- + +## RSS フィード + +RSS フィードは `/rss.xml` で提供され、`@astrojs/rss` によって生成されます。 + +### セットアップ + +パッケージをインストールします(blog テンプレートには含まれています): + +```bash +pnpm add @astrojs/rss +``` + +`src/pages/rss.xml.ts` を作成します: + +```ts +import rss from "@astrojs/rss"; +import { getCollection } from "astro:content"; +import { getSortedPosts, excludeFixedPages } from "@/lib/posts"; +import { config } from "@/config"; +import type { APIContext } from "astro"; + +export async function GET(context: APIContext) { + const allPosts = await getCollection("posts"); + const posts = getSortedPosts(excludeFixedPages(allPosts)); + + return rss({ + title: config.site.name, + description: config.site.description, + site: context.site!, + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.date ? new Date(post.data.date) : new Date(), + link: `/blog/${post.data.slug}/`, + })), + customData: `ja`, + }); +} +``` + +### フィードのリンク + +`Layout.astro` の `` に RSS フィードリンクを追加します: + +```astro + +``` + +### フィードへのコンテンツの含め方 + +RSS フィードに各投稿の完全な HTML コンテンツを含めるには、`sanitizeHtml` と `marked` を使います: + +```bash +pnpm add sanitize-html marked +``` + +```ts +import sanitizeHtml from "sanitize-html"; +import { marked } from "marked"; + +items: posts.map((post) => ({ + title: post.data.title, + pubDate: post.data.date ? new Date(post.data.date) : new Date(), + link: `/blog/${post.data.slug}/`, + content: sanitizeHtml(await marked.parse(post.data.markdown)), +})), +``` + +> **注意:** RSS コンテンツは Notion コンポーネント付きのコンパイル済み MDX ではなく、raw markdown からレンダリングされます。カスタムブロックタイプ(コールアウト、トグルなど)はフィードリーダーではプレーンテキストとして表示されます。 + +--- + +## robots.txt + +クローラーのアクセスを制御するために `public/robots.txt` を作成します: + +```txt +User-agent: * +Allow: / + +Sitemap: https://your-site.com/sitemap-index.xml +``` + +--- + +## Open Graph と SEO + +blog テンプレートの `Layout.astro` には Open Graph メタタグが自動的に含まれます: + +```astro + + + + +``` + +`og:url` を絶対 URL にするために `astro.config.mjs` で `site` を設定してください。 + +### 投稿ごとの OG 画像 + +投稿ごとの Open Graph 画像を追加するには、Astro の `@vercel/og` や `satori` を使って生成します: + +```bash +pnpm add @vercel/og +``` + +`src/pages/og/[slug].png.ts` を作成し、レイアウトの `og:image` として URL を渡します。動的イメージ生成の詳細は Astro ドキュメントを参照してください。 diff --git a/content/docs/ja-guides-tags-and-filtering.md b/content/docs/ja-guides-tags-and-filtering.md new file mode 100644 index 00000000..b8a032a5 --- /dev/null +++ b/content/docs/ja-guides-tags-and-filtering.md @@ -0,0 +1,172 @@ +--- +slug: ja/guides/tags-and-filtering +title: タグとフィルタリング +--- + +# タグとフィルタリング + +blog テンプレートはタグとカテゴリによるフィルタリングをすぐに使える状態でサポートしています。このページではデータモデルと拡張方法を説明します。 + +## Notion プロパティ + +blog テンプレートは 2 つのフィルタリングプロパティを使います: + +| プロパティ | Notion タイプ | 用途 | +|---|---|---| +| `Tags` | Multi-select | 投稿ごとの複数ラベル(例: `TypeScript`、`Astro`) | +| `Category` | Select | 投稿ごとの単一のプライマリカテゴリ(例: `Tutorial`) | +| `Public` | Checkbox | ページをビルドに含めるかを制御 | + +## API レベルでのフィルタリング + +最も効率的なフィルタリングは `loader()` クエリで行います。これにより、マッチするページのみが取得されます: + +```ts +// content.config.ts +loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, +}) +``` + +全ページを取得してクライアントサイドでフィルタリングするより、API 呼び出し数とビルド時間を削減できます。 + +## ページコンポーネントでのフィルタリング + +コレクションが読み込まれた後、Astro ページコンポーネントでさらにエントリをフィルタリングできます。 + +### タグでフィルタリング + +```ts +// src/lib/posts.ts +import type { CollectionEntry } from "astro:content"; + +export function getPostsByTag( + posts: CollectionEntry<"posts">[], + tag: string, +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.tags?.includes(tag)); +} +``` + +```astro +--- +// src/pages/blog/tag/[tag]/[...page].astro +import { getCollection } from "astro:content"; +import { getPostsByTag, getSortedPosts } from "@/lib/posts"; + +export async function getStaticPaths({ paginate }) { + const allPosts = await getCollection("posts"); + const allTags = [...new Set(allPosts.flatMap((p) => p.data.tags ?? []))]; + + return allTags.flatMap((tag) => { + const tagPosts = getSortedPosts(getPostsByTag(allPosts, tag)); + return paginate(tagPosts, { params: { tag }, pageSize: 10 }); + }); +} + +const { page, params } = Astro.props; +--- +

Posts tagged: {params.tag}

+
    + {page.data.map((post) =>
  • {post.data.title}
  • )} +
+``` + +### カテゴリでフィルタリング + +```ts +export function getPostsByCategory( + posts: CollectionEntry<"posts">[], + category: string, +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.category === category); +} +``` + +### タグの集計 + +```ts +export function getTagCounts( + posts: CollectionEntry<"posts">[], +): Map { + const counts = new Map(); + for (const post of posts) { + for (const tag of post.data.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + return counts; +} +``` + +## 投稿のソート + +```ts +export function getSortedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return [...posts].sort((a, b) => { + const dateA = a.data.date ? new Date(a.data.date).getTime() : 0; + const dateB = b.data.date ? new Date(b.data.date).getTime() : 0; + return dateB - dateA; + }); +} +``` + +## ピン留め投稿 + +blog テンプレートは特殊な `Tags` 値でピン留め投稿をサポートします。Notion でページに `pinned` タグを付け、ページコンポーネントでフィルタリングします: + +```ts +export function getPinnedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.tags?.includes("pinned")); +} + +export function excludePinnedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => !post.data.tags?.includes("pinned")); +} +``` + +## 固定ページ + +About ページなど、トップナビゲーションには表示するがブログ一覧には表示しないページがあります。慣例として `page` タグを使います: + +```ts +export function excludeFixedPages( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => !post.data.tags?.includes("page")); +} +``` + +## 高度な Notion フィルター + +`queryParameters` で Notion のフィルター API を使って複雑なフィルターを組み合わせることができます: + +```ts +// 特定のカテゴリ AND 特定のタグを持つ投稿 +filter: { + and: [ + { property: "Category", select: { equals: "Tutorial" } }, + { property: "Tags", multi_select: { contains: "TypeScript" } }, + ], +} + +// いずれかのタグを持つ投稿 +filter: { + or: [ + { property: "Tags", multi_select: { contains: "Astro" } }, + { property: "Tags", multi_select: { contains: "notro" } }, + ], +} +``` + +フィルター構文の詳細は [Notion API フィルタードキュメント](https://developers.notion.com/reference/post-database-query-filter) を参照してください。 diff --git a/content/docs/ja-reference-integration.md b/content/docs/ja-reference-integration.md new file mode 100644 index 00000000..a6ab4575 --- /dev/null +++ b/content/docs/ja-reference-integration.md @@ -0,0 +1,164 @@ +--- +slug: ja/reference/integration +title: notro() インテグレーション +--- + +# notro() インテグレーション + +`notro()` は `@astrojs/mdx` を notro のコア remark/rehype プラグインパイプラインで登録する Astro インテグレーションです。 + +## なぜ必要なのか + +`notro()` が必要な理由は 2 つあります: + +1. **`astro:jsx` レンダラー** — `@astrojs/mdx` が `@mdx-js/mdx` の `evaluate()` が Astro VNode を生成するために依存する `astro:jsx` レンダラーを登録します。これがないと `NotroContent` が実行時に失敗します。 +2. **静的な `.mdx` ファイル** — プロジェクトが Notion コンテンツと一緒に `.mdx` ファイルを使う場合、`notro()` がそれらを Notion コンテンツと同じプラグインパイプラインで処理することを保証します。 + +## インポート + +```js +// astro.config.mjs +import { notro } from "notro-loader/integration"; +``` + +> **重要:** 必ず `notro-loader/integration` からインポートしてください(`notro-loader` からではありません)。`/integration` エントリーポイントは Astro コンポーネントをインポートしないため、`astro.config.mjs` での使用が安全です。 + +## 使い方 + +```js +import { defineConfig } from "astro/config"; +import { notro } from "notro-loader/integration"; + +export default defineConfig({ + integrations: [ + notro(), + ], +}); +``` + +## オプション + +すべてのオプションは任意です。 + +```ts +notro({ + remarkPlugins?: PluggableList; + rehypePlugins?: PluggableList; + shikiConfig?: Record; + viteExternals?: string[]; + extendMarkdownConfig?: boolean; +}) +``` + +### remarkPlugins + +パイプラインの `remarkNfm` の後に追加する remark プラグイン。 + +```js +import remarkMath from "remark-math"; + +notro({ + remarkPlugins: [remarkMath], +}) +``` + +プラグインは指定した順序で追加されます。 + +### rehypePlugins + +パイプラインの `rehypeShiki`(設定済みの場合)の前に挿入する rehype プラグイン。 + +```js +import rehypeKatex from "rehype-katex"; +import { rehypeMermaid } from "rehype-beautiful-mermaid"; + +notro({ + rehypePlugins: [ + rehypeKatex, + [rehypeMermaid, { theme: "github-dark" }], + ], +}) +``` + +各アイテムはプラグイン関数または `[plugin, options]` のタプルです。 + +### shikiConfig + +設定すると、シンタックスハイライト用の `@shikijs/rehype` を最後の rehype プラグインとして注入します。`@shikijs/rehype` を別途インストールする必要があります。 + +```bash +pnpm add @shikijs/rehype +``` + +```js +notro({ + shikiConfig: { + theme: "github-dark", + }, +}) +``` + +すべてのキーは `@shikijs/rehype` に直接渡されます。利用可能なテーマとオプションは [Shiki ドキュメント](https://shiki.style/) を参照してください。 + +**デュアルテーマ(ライト/ダーク)の例:** + +```js +notro({ + shikiConfig: { + themes: { + light: "github-light", + dark: "github-dark", + }, + defaultColor: false, + }, +}) +``` + +### viteExternals + +Vite の `ssr.external` に追加するパッケージ。ネイティブバイナリや動的インポートを持ち、Vite がバンドルすべきでないパッケージに使います: + +```js +notro({ + rehypePlugins: [[rehypeMermaid, { strategy: "img-svg" }]], + viteExternals: ["@mermaid-js/mermaid-zenuml"], +}) +``` + +### extendMarkdownConfig + +`true` の場合、Astro の基本 markdown 設定を notro のプラグインパイプラインで拡張します。デフォルトは `false`。 + +通常は不要です。Notion ローダーで管理されていない Astro の組み込み `.md` と `.mdx` ファイルにも notro のプラグインを適用したい場合のみ有効にしてください。 + +## プラグインパイプラインの順序 + +すべてのオプションを設定した場合のフルパイプライン: + +``` +remarkNfm + ↓ (ユーザー remarkPlugins) +rehypeRaw +rehypeNotionColor +rehypeBlockElements +rehypeInlineMentions + ↓ (ユーザー rehypePlugins) +rehypeShiki ← shikiConfig 設定時のみ +rehypeSlug +rehypeToc +resolvePageLinks +``` + +## 型リファレンス + +```ts +interface NotroIntegrationOptions { + remarkPlugins?: PluggableList; + rehypePlugins?: PluggableList; + shikiConfig?: Record; + viteExternals?: string[]; + extendMarkdownConfig?: boolean; +} + +function notro(options?: NotroIntegrationOptions): AstroIntegration; +``` diff --git a/content/docs/ja-reference-loader.md b/content/docs/ja-reference-loader.md new file mode 100644 index 00000000..2ce1f1c4 --- /dev/null +++ b/content/docs/ja-reference-loader.md @@ -0,0 +1,177 @@ +--- +slug: ja/reference/loader +title: loader() +--- + +# loader() + +`loader()` 関数は Notion データソースからページを取得して Content Collection ストアに保存するカスタム [Astro Content Loader](https://docs.astro.build/en/reference/content-loader-reference/) です。 + +## インポート + +```ts +import { loader } from "notro-loader"; +``` + +## 使い方 + +```ts +// src/content.config.ts +import { defineCollection } from "astro:content"; +import { loader } from "notro-loader"; + +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + }), +}; +``` + +## オプション + +```ts +interface LoaderOptions { + queryParameters: DataSourceQueryParameters; + clientOptions: ClientOptions; +} +``` + +### queryParameters + +`notion.dataSources.query` に渡されるパラメーター。`data_source_id` は必須で、他はすべて任意です。 + +```ts +queryParameters: { + data_source_id: string; // 必須: Notion データベース UUID + filter?: FilterObject; // 任意: Notion フィルター + sorts?: SortObject[]; // 任意: ソート順 + page_size?: number; // 任意: ページあたりの件数(最大 100) +} +``` + +**フィルターの例:** + +```ts +// チェックボックスフィルター +filter: { property: "Public", checkbox: { equals: true } } + +// AND フィルター +filter: { + and: [ + { property: "Public", checkbox: { equals: true } }, + { property: "Tags", multi_select: { contains: "featured" } }, + ], +} +``` + +**ソートの例:** + +```ts +// Date の降順 +sorts: [{ property: "Date", direction: "descending" }] + +// 最終編集時刻順 +sorts: [{ timestamp: "last_edited_time", direction: "descending" }] +``` + +### clientOptions + +`@notionhq/client` の `Client` コンストラクターに渡されるオプション。 + +```ts +clientOptions: { + auth: string; // 必須: Notion API トークン + notionVersion?: string; // 任意: API バージョン(デフォルト: 最新) + timeoutMs?: number; // 任意: リクエストのタイムアウト(ms) +} +``` + +## ローダーが保存するデータ + +各 Notion ページについて、ローダーは以下のエントリを保存します: + +```ts +{ + id: string; // Notion ページ UUID + markdown: string; // 前処理済み markdown コンテンツ + last_edited_time: string; // ISO 8601 タイムスタンプ + properties: { + // raw Notion プロパティオブジェクト — データベーススキーマに依存 + Name: { title: [...] }, + Slug: { rich_text: [...] }, + // ... + }; +} +``` + +これらのフィールドを型付けするには `notro-loader` の `pageWithMarkdownSchema` を基本 Zod スキーマとして使い、データベース固有のプロパティで拡張してください。 + +## キャッシュの動作 + +ローダーは Astro の Content Layer ストアを使ってビルド間でページをキャッシュします。以下の場合にエントリが更新されます: + +- Notion の `last_edited_time` が前回のビルド以降に進んでいる +- キャッシュ済み markdown に期限切れの Notion プリサインド S3 URL が含まれている(画像 URL の `X-Amz-Expires` で検出) +- ページが Notion に存在しなくなった(ストアから削除) + +`last_edited_time` が変化していないページは再取得されず、インクリメンタルビルドが高速になります。 + +## エラーハンドリング + +| 状況 | 動作 | +|---|---| +| `429 rate_limited` | 指数バックオフでリトライ(1s、2s、4s;最大 3 回) | +| `500 / 503` サーバーエラー | 指数バックオフでリトライ | +| `401 unauthorized` | 警告をログに出力してページをスキップ | +| `403 restricted_resource` | 警告をログに出力してページをスキップ | +| `404 object_not_found` | 警告をログに出力してストアから削除 | +| コンテンツが切り詰められた | 警告をログに出力して切り詰められたコンテンツを使用 | +| 不明なブロック ID | ブロック ID リストとともに警告をログに出力して続行 | + +個別のページが失敗してもビルドは継続されます。 + +## ライブローダー(開発環境専用) + +サーバーを再起動せずにリアルタイムのコンテンツ更新が必要な開発環境では `liveLoader()` を使います: + +```ts +import { liveLoader } from "notro-loader"; + +export const collections = { + posts: defineCollection({ + loader: liveLoader({ + queryParameters: { data_source_id: import.meta.env.NOTION_DATASOURCE_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + }), +}; +``` + +`liveLoader()` は `astro dev` 中のリクエストごとにコンテンツを再取得します。本番ビルドには推奨しません。 + +## 型リファレンス + +```ts +function loader(options: LoaderOptions): AstroContentLoader; +function liveLoader(options: LoaderOptions): AstroContentLoader; + +interface LoaderOptions { + queryParameters: { + data_source_id: string; + filter?: unknown; + sorts?: unknown[]; + page_size?: number; + }; + clientOptions: { + auth: string; + notionVersion?: string; + timeoutMs?: number; + }; +} +``` diff --git a/content/docs/ja-reference-markdown-pipeline.md b/content/docs/ja-reference-markdown-pipeline.md new file mode 100644 index 00000000..adca8c10 --- /dev/null +++ b/content/docs/ja-reference-markdown-pipeline.md @@ -0,0 +1,197 @@ +--- +slug: ja/reference/markdown-pipeline +title: Markdown パイプライン +--- + +# Markdown パイプライン + +このページでは、markdown 処理パイプラインのすべてのステップ — Notion API の raw 出力からレンダリング済み HTML まで — を説明します。 + +## 概要 + +``` +Raw Notion markdown(pages.retrieveMarkdown) + ↓ preprocessNotionMarkdown() 構造的な問題を修正 + ↓ remarkNfm ディレクティブ + GFM + コールアウト変換 + ↓ (ユーザー remarkPlugins) + ↓ rehypeRaw HTML 文字列 → hast ノード + ↓ rehypeNotionColor color="gray" → notro-* クラス + ↓ rehypeBlockElements video → Video(PascalCase 変換) + ↓ rehypeInlineMentions mention-user → MentionUser + ↓ (ユーザー rehypePlugins) + ↓ rehypeShiki シンタックスハイライト + ↓ rehypeSlug 見出しに id 属性 + ↓ rehypeToc に目次を設定 + ↓ resolvePageLinks notion.so → サイト相対 URL + ↓ @mdx-js/mdx evaluate() + ↓ +レンダリング済み HTML +``` + +--- + +## preprocessNotionMarkdown + +`preprocessNotionMarkdown()` は AST パースの前に Notion の raw markdown 出力の構造的問題を修正する文字列プリプロセッサー(remark プラグインではない)です。`remarkNfm` によって自動的に呼び出されます。 + +### Fix 0 — エスケープされたインライン数式の移行 + +旧バージョンの notro はインライン数式を `\$…\$` にエスケープしていました。この修正はそれらを `$…$` に戻して互換性を確保します。 + +### Fix 1 — setext 見出しの誤認識 + +前の空行なしの `---` 区切り線が setext H2 の下線として誤認識されます。Fix 1 は bare `---` 区切り線の前に空行を挿入します。 + +**変換前:** +```md +Some text +--- +Next section +``` + +**変換後:** +```md +Some text + +--- +Next section +``` + +### Fix 2 — コールアウトディレクティブの正規化 + +Notion は `"::: callout {…}"` としてコールアウトブロックをエクスポートします。Fix 2 はスペースを `":::callout{…}"` に正規化して `remark-directive` パーサーに対応し、コールアウトブロック内のタブインデントされたコンテンツをインデント解除します。 + +### Fix 3 — ブロックレベルのカラーアノテーション + +段落と見出しの Notion カラーアノテーションはブロック末尾に `{color="gray_bg"}` としてエクスポートされます。Fix 3 はこれを raw HTML の `

` に変換し、後で `rehypeNotionColor` が CSS クラスに変換します。 + +### Fix 4 — 目次タグ + +``(アンダースコア付き)は CommonMark パーサーではブロックレベルの HTML 要素として認識されません。Fix 4 は `

` で囲んでブロックとして扱われるようにします。 + +### Fix 5 — インライン数式フォーマット + +Notion はインライン数式を `$\`…\`$` としてエクスポートします。Fix 5 はこれを `remark-math` 用の `$…$` に変換します。 + +### Fix 6 — 同期ブロックラッパーの削除 + +`` ラッパーを削除し、内部のコンテンツをドキュメントレベルにインデント解除します。 + +### Fix 7 — 空ブロックの独立 + +`` インライン要素を空行で囲み、remark がブロックレベル要素として扱うようにします(MDX コンポーネントルーティングに必要)。 + +### Fix 8 — 閉じタグへの空行追加 + +``、`
`、``、``、`` に末尾の空行を追加します。これがないと CommonMark の HTML ブロック検出モードが後続のすべてのコンテンツを raw テキストとして飲み込み、remark が後続の markdown を解析できなくなります。 + +### Fix 9 — テーブルセル内の Markdown リンク + +raw HTML の `` セル内の `[text](url)` 構文は remark によって処理されません(`` ブロック全体を raw HTML として扱うため)。Fix 9 はこれを AST パースの前に `text` タグに変換します。 + +--- + +## remarkNfm + +`remarkNfm` は `remark-nfm` パッケージのコア remark プラグインです。1 つのプラグインに 3 つの操作をまとめています: + +1. **`preprocessNotionMarkdown`** — 上記の文字列修正をパース前に実行 +2. **`remark-directive`** — `:::callout{…}` ディレクティブ構文を有効化 +3. **`remark-gfm`** — GFM 打ち消し線(`~~text~~`)とタスクリスト(`- [x]`)サポート +4. **コールアウト変換** — `:::callout` ディレクティブ AST ノードを raw `` HTML 要素に変換 + +### コールアウト構文 + +Notion は Fix 2 の後、以下のディレクティブフォーマットでコールアウトをエクスポートします: + +```md +:::callout{icon="💡" color="blue"} +コールアウトのコンテンツ +::: +``` + +`remarkNfm` はこれを以下に変換します: + +```html + +コールアウトのコンテンツ + +``` + +--- + +## rehype プラグイン + +### rehypeRaw + +markdown AST に埋め込まれた raw HTML 文字列を適切な hast ノードに変換し、後続の rehype プラグインがそれらをトラバース・変換できるようにします。カスタム Notion 要素(``、``、`
` | テーブル | + +## MDX コンパイルキャッシング + +`NotroContent` は内部で `compileMdxCached()` を使います。これは markdown 文字列のハッシュをキーとしてコンパイル済み MDX モジュールをキャッシュします。単一ビルド内で同じページが再レンダリングされる場合、MDX コンパイラを再呼び出しせずにキャッシュ済みモジュールを再利用します。 + +## CSS ラッパー + +`notro-markdown` CSS クラスは `NotroContent` の出力をラップする要素に適用されます(`NotroContent` 自体ではなく、テンプレートの親要素で適用)。`notro-theme.css` のこのクラスは以下のスタイルをスコープします: + +- `
` / `` ブロック
+- タスクリストのチェックボックス
+- テーブル
+- 引用
+
+```astro
+
+
+ +
+``` + +## 型リファレンス + +```ts +import type { NotionComponents, ClassMapKeys } from "notro-loader"; + +interface NotroContentProps { + markdown: string; + linkToPages?: Record; + classMap?: Partial>; + components?: Partial; +} +``` diff --git a/content/docs/zh-cn-deployment-cloudflare-pages.md b/content/docs/zh-cn-deployment-cloudflare-pages.md new file mode 100644 index 00000000..bfca291b --- /dev/null +++ b/content/docs/zh-cn-deployment-cloudflare-pages.md @@ -0,0 +1,96 @@ +--- +slug: zh-cn/deployment/cloudflare-pages +title: 部署到 Cloudflare Pages +--- + +# 部署到 Cloudflare Pages + +本指南介绍如何将 notro 站点部署到 [Cloudflare Pages](https://pages.cloudflare.com/)。 + +## 前提条件 + +- [Cloudflare 账户](https://dash.cloudflare.com/sign-up) +- 已推送到 GitHub 或 GitLab 的项目 + +## 1. 创建 Pages 项目 + +1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/) +2. 前往 **Workers & Pages** → **Pages** → **Create a project** +3. 点击 **Connect to Git** 并授权 Cloudflare 访问你的仓库 +4. 选择你的仓库并点击 **Begin setup** + +## 2. 配置构建 + +在构建配置表单中设置: + +| 设置 | 值 | +|---|---| +| **Framework preset** | Astro | +| **Build command** | `pnpm build` | +| **Build output directory** | `dist` | +| **Root directory** | `/`(如果项目在仓库根目录,则留空) | +| **Node.js version** | `24` | + +如果你的项目使用 monorepo,Astro 站点在子目录中(例如 `templates/blog/`),将 **Root directory** 设置为该路径。 + +## 3. 设置环境变量 + +点击 **Environment variables (advanced)** 并添加: + +| 变量 | 值 | +|---|---| +| `NOTION_TOKEN` | 你的 Notion 集成密钥 | +| `NOTION_DATASOURCE_ID` | 你的 Notion 数据库 UUID | + +> **提示:** 仅在 **Production** 下设置这些,或者如果你希望预览部署也从 Notion 获取内容,则为 **Preview** 复制它们。 + +## 4. 部署 + +点击 **Save and Deploy**。Cloudflare 将克隆你的仓库、运行 `pnpm install` 和 `pnpm build`,并将 `dist/` 目录部署到 Cloudflare 边缘网络。 + +## 5. 配置自动部署 + +首次部署后,Cloudflare 在每次推送到生产分支(通常是 `main`)时自动部署。 + +对于 notro 站点,Notion 中的内容更改**不会**自动触发重建。使用 Cloudflare 的 Deploy Hooks 设置定时重建: + +1. 前往 **Pages** → 你的项目 → **Settings** → **Builds & deployments** +2. 滚动到 **Deploy hooks** → **Add deploy hook** +3. 命名(例如 `Notion content update`)并选择要构建的分支 +4. 复制生成的 URL + +在内容更改时,使用此 Webhook URL 配合 cron 任务或 Notion 自动化触发重建。 + +## 自定义域名 + +1. 前往你的 Pages 项目 → **Custom domains** → **Set up a custom domain** +2. 输入你的域名并按照 DNS 设置说明操作 +3. Cloudflare 自动提供 TLS 证书 + +## Node.js 版本 + +Cloudflare Pages 通过 `NODE_VERSION` 环境变量支持 Node.js 版本。在项目的环境变量中将其设置为 `24`,或在仓库中添加 `.node-version` 文件: + +``` +24 +``` + +## 故障排查 + +**构建失败,提示"pnpm not found"** + +在 `package.json` 中添加 `pnpm` 版本: + +```json +{ + "packageManager": "pnpm@10.33.0" +} +``` + +**环境变量不可用** + +确保变量设置为 **Production** 环境(不只是 Preview),并确认添加后已重新部署。 + +**大型站点触及 20,000 文件限制** + +Cloudflare Pages 有 20,000 个文件的限制。对于大型站点,考虑使用带 KV 存储的 Cloudflare Workers,或考虑 Vercel/Netlify。 diff --git a/content/docs/zh-cn-deployment-netlify.md b/content/docs/zh-cn-deployment-netlify.md new file mode 100644 index 00000000..860813e1 --- /dev/null +++ b/content/docs/zh-cn-deployment-netlify.md @@ -0,0 +1,147 @@ +--- +slug: zh-cn/deployment/netlify +title: 部署到 Netlify +--- + +# 部署到 Netlify + +本指南介绍如何将 notro 站点部署到 [Netlify](https://www.netlify.com/)。 + +## 前提条件 + +- [Netlify 账户](https://app.netlify.com/signup) +- 已推送到 GitHub、GitLab 或 Bitbucket 的项目 + +## 1. 创建新站点 + +1. 登录 [app.netlify.com](https://app.netlify.com/) 并点击 **Add new site** → **Import an existing project** +2. 连接到你的 Git 提供商并选择你的仓库 + +## 2. 配置构建 + +设置以下构建设置: + +| 设置 | 值 | +|---|---| +| **Base directory** | _(留空,或为 monorepo 设置包路径)_ | +| **Build command** | `pnpm build` | +| **Publish directory** | `dist` | + +### netlify.toml(推荐) + +在仓库根目录的 `netlify.toml` 中定义构建设置以保持版本控制: + +```toml +[build] + command = "pnpm build" + publish = "dist" + +[build.environment] + NODE_VERSION = "24" + PNPM_VERSION = "10" +``` + +对于 Astro 项目在子目录中的 monorepo: + +```toml +[build] + base = "templates/blog" + command = "pnpm build" + publish = "dist" + +[build.environment] + NODE_VERSION = "24" +``` + +## 3. 设置环境变量 + +在 Netlify 控制台,前往 **Site configuration** → **Environment variables** → **Add a variable**: + +| 键 | 值 | +|---|---| +| `NOTION_TOKEN` | 你的 Notion 集成密钥 | +| `NOTION_DATASOURCE_ID` | 你的 Notion 数据库 UUID | + +> **提示:** 如果不想让预览构建从 Notion 获取内容,使用 **Scopes** 将变量限制为仅 Production 上下文。 + +## 4. 部署 + +点击 **Deploy site**。Netlify 克隆你的仓库、安装依赖、运行构建,并从其 CDN 提供 `dist/` 目录。 + +## 5. 自动部署和重建触发器 + +Netlify 在每次推送到生产分支时自动部署。对于 Notion 内容更改,使用 Build Hook: + +1. 前往 **Site configuration** → **Build & deploy** → **Build hooks** +2. 点击 **Add build hook**,命名(例如 `Notion content`)并选择分支 +3. 复制生成的 URL + +触发钩子重建: + +```bash +curl -X POST -d {} "https://api.netlify.com/build_hooks/YOUR_HOOK_ID" +``` + +### 使用 Netlify Functions 定时重建 + +创建定时 Netlify Function 按计划重建: + +```ts +// netlify/functions/scheduled-rebuild.ts +import type { Config } from "@netlify/functions"; + +export default async function handler() { + await fetch(process.env.BUILD_HOOK_URL!, { method: "POST" }); + return { statusCode: 200 }; +} + +export const config: Config = { + schedule: "0 2 * * *", // 每天 UTC 2:00 +}; +``` + +将 `BUILD_HOOK_URL` 添加为指向你自己构建钩子 URL 的环境变量。 + +## 自定义域名 + +1. 前往 **Domain management** → **Add a domain** +2. 输入你的自定义域名并按照 DNS 配置说明操作 +3. Netlify 通过 Let's Encrypt 自动提供 TLS 证书 + +## Netlify Edge Functions(可选) + +对于 SSR,安装 Netlify 适配器: + +```bash +pnpm add @astrojs/netlify +``` + +```js +// astro.config.mjs +import netlify from "@astrojs/netlify"; + +export default defineConfig({ + output: "server", + adapter: netlify(), + // ... +}); +``` + +## 故障排查 + +**`pnpm: command not found`** + +在环境变量或 `netlify.toml` 中设置 `PNPM_VERSION`: + +```toml +[build.environment] + PNPM_VERSION = "10" +``` + +**构建成功但站点显示 404** + +验证 **Publish directory** 设置为 `dist` 而不是项目根目录。 + +**环境变量在构建时不可用** + +确保变量设置了 **Builds** 作用域。在变量设置中检查作用域包括 **Builds**(不只是 **Runtime**)。 diff --git a/content/docs/zh-cn-deployment-vercel.md b/content/docs/zh-cn-deployment-vercel.md new file mode 100644 index 00000000..e5b75b3a --- /dev/null +++ b/content/docs/zh-cn-deployment-vercel.md @@ -0,0 +1,124 @@ +--- +slug: zh-cn/deployment/vercel +title: 部署到 Vercel +--- + +# 部署到 Vercel + +本指南介绍如何将 notro 站点部署到 [Vercel](https://vercel.com/)。 + +## 前提条件 + +- [Vercel 账户](https://vercel.com/signup) +- 已推送到 GitHub、GitLab 或 Bitbucket 的项目 + +## 1. 导入项目 + +1. 登录 [vercel.com](https://vercel.com/) 并点击 **Add New Project** +2. 点击 **Import Git Repository** 并选择你的仓库 +3. Vercel 自动检测 Astro 项目并预填大多数设置 + +## 2. 配置构建 + +验证构建配置中的设置: + +| 设置 | 值 | +|---|---| +| **Framework Preset** | Astro | +| **Build Command** | `pnpm build` | +| **Output Directory** | `dist` | +| **Install Command** | `pnpm install` | +| **Node.js Version** | `24.x`(在 **Settings → General** 中设置) | + +对于 Astro 站点位于子目录的 monorepo,将 **Root Directory** 设置为包路径(例如 `templates/blog`)。 + +## 3. 设置环境变量 + +点击 **Environment Variables** 并添加: + +| 变量 | 环境 | 值 | +|---|---|---| +| `NOTION_TOKEN` | Production、Preview | 你的 Notion 集成密钥 | +| `NOTION_DATASOURCE_ID` | Production、Preview | 你的 Notion 数据库 UUID | + +## 4. 部署 + +点击 **Deploy**。Vercel 运行 `pnpm install` 和 `pnpm build`,然后将 `dist/` 目录全球部署。 + +## 5. 自动部署和重建触发器 + +Vercel 在每次推送到生产分支时自动部署。对于仅内容的 Notion 更改,设置 Deploy Hook: + +1. 前往 **Settings** → **Git** → **Deploy Hooks** +2. 点击 **Create Hook**,命名并选择分支 +3. 复制 URL + +在 cron 任务、GitHub Actions 或 Notion 自动化中使用此 URL,在内容变更时触发重建: + +```bash +curl -X POST "https://api.vercel.com/v1/integrations/deploy/HOOK_ID" +``` + +### GitHub Actions 定时重建 + +```yaml +# .github/workflows/rebuild.yml +name: Scheduled rebuild +on: + schedule: + - cron: "0 2 * * *" # 每天 UTC 2:00 + +jobs: + rebuild: + runs-on: ubuntu-latest + steps: + - name: Trigger Vercel deploy hook + run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" +``` + +在 GitHub 仓库 Settings 中将 `VERCEL_DEPLOY_HOOK` 添加为 secret。 + +## 自定义域名 + +1. 前往项目 → **Settings** → **Domains** → **Add** +2. 输入你的域名并按照 DNS 说明操作 +3. Vercel 通过 Let's Encrypt 自动提供 TLS 证书 + +## Vercel Edge Functions(可选) + +要在 Vercel 上使用 Astro 的 SSR 功能,安装 Vercel 适配器: + +```bash +pnpm add @astrojs/vercel +``` + +```js +// astro.config.mjs +import vercel from "@astrojs/vercel/serverless"; + +export default defineConfig({ + output: "server", + adapter: vercel(), + // ... +}); +``` + +> **注意:** 大多数站点推荐使用 notro 的静态站点生成模式(`output: "static"`,默认)。只有在需要服务器端搜索或个性化等功能时才需要 SSR。 + +## 故障排查 + +**构建失败,提示"Cannot find module"** + +确保 `node_modules` 没有提交到你的仓库。Vercel 从头安装依赖。 + +**Node.js 版本不匹配** + +在 **Settings → General → Node.js Version** 中设置为 `24.x`,或添加 `.nvmrc` 文件: + +``` +24 +``` + +**大型 Notion 数据库超出内存限制** + +Vercel 的构建容器有 4 GB 内存限制。对于非常大的站点,考虑使用 `loader()` 中的 `filter` 选项只获取公开页面。 diff --git a/content/docs/zh-cn-getting-started-introduction.md b/content/docs/zh-cn-getting-started-introduction.md new file mode 100644 index 00000000..c7041233 --- /dev/null +++ b/content/docs/zh-cn-getting-started-introduction.md @@ -0,0 +1,63 @@ +--- +slug: zh-cn/getting-started/introduction +title: 简介 +--- + +# 简介 + +**notro** 是一个将 Notion 转换为 Astro 静态站点的生成器。它通过 Notion Public API 获取内容,将其编译为 MDX,并将每种 Notion 块类型映射到带样式的 Astro 组件,让你无需单独管理 CMS 即可生成快速、SEO 优化的静态网站。 + +## 为什么使用 notro? + +Notion 是一款强大的写作工具,但传统上将其内容发布为精美网站需要自定义 API 集成和手动渲染逻辑。notro 为你处理所有这些工作: + +- **零构建时渲染延迟** — 页面在构建时静态生成 +- **完整的 Notion 块支持** — 标注、折叠、分栏、同步块、公式等 +- **MDX 管道** — 与 Notion 内容一起使用 remark/rehype 插件(数学公式、Mermaid 图表、语法高亮) +- **复制即用的组件** — notro-ui 提供可自由定制的带样式 Notion 块组件 +- **多种模板** — 从功能完整的博客模板或极简 blank 模板开始 + +## 工作原理 + +notro 构建于 [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) 之上。`loader()` 函数作为自定义内容加载器: + +1. 通过 `dataSources.query` 查询 Notion 数据源的页面列表 +2. 通过 `pages.retrieveMarkdown` 获取每个页面的 Markdown +3. 通过 `last_edited_time` 缓存页面,避免重建时的冗余 API 调用 +4. 将预处理后的 Markdown 存储在 Content Collection store 中 + +渲染时,`NotroContent` 组件通过 MDX 管道编译存储的 Markdown,并使用完整的 Notion 组件映射进行渲染。 + +``` +Notion 数据库 + ↓ notro-loader(Astro Content Loader) +Content Collection store + ↓ NotroContent + compileMdx +渲染后的 HTML 页面 +``` + +## 包 + +notro 是一个 monorepo。已发布的 npm 包如下: + +| 包 | 用途 | +|---|---| +| `notro-loader` | Astro Content Loader + MDX 编译管道 + Notion 块组件 | +| `remark-nfm` | 用于 Notion Flavored Markdown 规范化的 remark 插件 | +| `notro-ui` | 复制即用的带样式 Notion 块组件(shadcn 风格) | +| `rehype-beautiful-mermaid` | 在构建时将 Mermaid 代码块渲染为内联 SVG | +| `create-notro` | CLI 脚手架工具(`npm create notro@latest`) | + +## 模板 + +提供两种入门模板: + +- **notro-blog** — 带分页、标签、分类、RSS 和站点地图的功能完整博客 +- **notro-blank** — 适合自定义项目的极简单页启动模板 + +两种模板都预配置了 TailwindCSS 4、notro-ui 组件和 MDX 管道。 + +## 下一步 + +- [快速开始](/zh-cn/getting-started/quick-start) — 在几分钟内创建项目并连接到 Notion +- [Notion 设置](/zh-cn/getting-started/notion-setup) — 如何创建集成并配置数据库 diff --git a/content/docs/zh-cn-getting-started-notion-setup.md b/content/docs/zh-cn-getting-started-notion-setup.md new file mode 100644 index 00000000..231c219f --- /dev/null +++ b/content/docs/zh-cn-getting-started-notion-setup.md @@ -0,0 +1,113 @@ +--- +slug: zh-cn/getting-started/notion-setup +title: Notion 设置 +--- + +# Notion 设置 + +本页介绍如何创建 Notion Internal Integration、设置具有正确模式的数据库,以及获取 notro 所需的凭据。 + +## 1. 创建 Notion 集成 + +1. 访问 [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations) +2. 点击 **+ New integration** +3. 填写: + - **Name** — 例如 `notro-blog` + - **Associated workspace** — 选择你的工作区 + - **Type** — Internal +4. 在 **Capabilities** 下,确保 **Read content** 已勾选 +5. 点击 **Submit** +6. 复制 **Internal Integration Secret** — 这就是你的 `NOTION_TOKEN` + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +> **安全提示:** 像对待密码一样对待 `NOTION_TOKEN`。切勿将其提交到版本控制。将 `.env` 添加到 `.gitignore`。 + +## 2. 创建数据库 + +在 Notion 中创建一个新的全页数据库。blog 模板需要以下属性模式: + +| 属性 | 类型 | 必填 | 用途 | +|---|---|---|---| +| `Name` | Title | ✓ | 文章标题 | +| `Slug` | Rich text | ✓ | URL slug(例如 `hello-world`) | +| `Description` | Rich text | | 列表中显示的摘要 | +| `Public` | Checkbox | ✓ | 只有 `Public = true` 的页面才会包含在构建中 | +| `Date` | Date | | 发布日期 | +| `Tags` | Multi-select | | 用于过滤的标签 | +| `Category` | Select | | 用于过滤的分类 | + +你可以添加任何其他属性;notro 会将所有属性传递给你在 `content.config.ts` 中定义的模式。 + +## 3. 将数据库与集成共享 + +Notion 集成默认没有访问内容的权限。 + +1. 在 Notion 中打开你的数据库 +2. 点击 **⋯**(右上角)→ **Connections** → **+ Add connections** +3. 搜索你的集成名称并点击 **Confirm** + +集成现在具有对此数据库的读取权限。 + +## 4. 获取数据库 ID + +`NOTION_DATASOURCE_ID` 是数据库 URL 中的 UUID。 + +以全页方式打开数据库。URL 格式如下: + +``` +https://www.notion.so/your-workspace/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?v=... +``` + +32 个字符的十六进制字符串(带或不带连字符)就是数据库 ID: + +```bash +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +## 5. 设置环境变量 + +将两个值都添加到项目的 `.env` 文件中: + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +对于生产部署,在托管平台(Cloudflare Pages、Vercel 或 Netlify)中将这些设置为环境变量,而不是附带 `.env` 文件。 + +## 6. 创建第一个页面 + +在你的 Notion 数据库中创建一个新页面: + +1. 将 **Name** 设置为文章标题(例如 `Hello, World!`) +2. 将 **Slug** 设置为 URL 友好的字符串(例如 `hello-world`) +3. 勾选 **Public** 使其包含在构建中 +4. 将 **Date** 设置为今天 +5. 在页面正文中写一些内容 + +在项目中运行 `pnpm dev` — 页面应该出现在 `/blog/hello-world`。 + +## 提示 + +### Notion 中的 Markdown + +notro 通过 Notion 的 Markdown Content API 获取内容,该 API 将 Notion 块转换为 Markdown。支持大多数块类型,包括: + +- 标注、折叠、分栏 +- 带语法高亮的代码块 +- 表格、图片、嵌入 +- 数学公式(LaTeX) +- 同步块 + +不支持的块会被静默忽略。Notion 在 API 响应的 `unknown_block_ids` 中记录块 ID;notro 会将这些记录为警告。 + +### Notion Flavored Markdown + +Notion 的 Markdown 输出有一些特殊之处(分隔线/标题的歧义、颜色注释等)。`remark-nfm` 插件会自动处理这些问题 — 你不需要做任何特殊处理。 + +### Notion 中的图片 + +Notion 将图片作为带有过期时间的预签名 S3 URL 提供。notro 包含 `notionImageService`(在 `astro.config.mjs` 中配置),它在计算缓存键前剥离过期查询参数,使重复构建可以重用缓存的图片。设置详情请参阅[配置](/zh-cn/guides/configuration)。 diff --git a/content/docs/zh-cn-getting-started-quick-start.md b/content/docs/zh-cn-getting-started-quick-start.md new file mode 100644 index 00000000..27aca77c --- /dev/null +++ b/content/docs/zh-cn-getting-started-quick-start.md @@ -0,0 +1,121 @@ +--- +slug: zh-cn/getting-started/quick-start +title: 快速开始 +--- + +# 快速开始 + +本指南将带你在十分钟内从零开始运行一个 notro 站点。 + +## 前提条件 + +- **Node.js 24+** 和 **pnpm 9+**(或 npm/yarn) +- 拥有现有数据库的 [Notion 账户](https://www.notion.so/),或有权限创建数据库 + +## 1. 创建项目 + +```bash +npm create notro@latest +``` + +CLI 将提示你: + +1. **选择模板** — `blog`(功能完整)或 `blank`(极简) +2. **输入项目名称** — 用作目录名 + +``` +◆ Which template would you like to use? +│ ● blog Full-featured blog with pagination, tags, and RSS +│ ○ blank Minimal starter +◆ Project name: my-notro-site +◆ Scaffolding to ./my-notro-site… +✔ Done! Next steps: +``` + +## 2. 安装依赖 + +```bash +cd my-notro-site +pnpm install +``` + +## 3. 配置环境变量 + +复制示例 env 文件并填入你的 Notion 凭据: + +```bash +cp .env.example .env +``` + +打开 `.env` 并设置: + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +有关如何获取这些值,请参阅 [Notion 设置](/zh-cn/getting-started/notion-setup)。 + +## 4. 启动开发服务器 + +```bash +pnpm dev +``` + +Astro 开发服务器在 **http://localhost:4321** 启动。首次运行时,notro 会从你的 Notion 数据库获取所有页面并缓存它们。 + +> **提示:** 首次运行可能需要几秒钟,具体取决于数据库中的页面数量。后续启动很快,因为页面按 `last_edited_time` 缓存。 + +## 5. 在 Notion 中编辑内容 + +在开发服务器运行时,编辑 Notion 数据库中的页面。重启开发服务器(或保存任何文件触发刷新)即可看到变更反映。 + +## 6. 构建生产版本 + +```bash +pnpm build +``` + +Astro 运行 `astro check`(类型检查),然后运行 `astro build`,在 `dist/` 中生成完全静态的站点。 + +```bash +pnpm preview # 在本地预览生产构建 +``` + +## 7. 部署 + +将项目推送到 GitHub 并连接到你首选的托管平台。有关步骤说明,请参阅部署指南: + +- [Cloudflare Pages](/zh-cn/deployment/cloudflare-pages) +- [Vercel](/zh-cn/deployment/vercel) +- [Netlify](/zh-cn/deployment/netlify) + +## 项目结构 + +脚手架后,你的项目结构如下(blog 模板): + +``` +my-notro-site/ +├── src/ +│ ├── components/ # Header、Footer、BlogList +│ ├── layouts/ # Layout.astro +│ ├── lib/ # blog.ts、nav.ts、seo.ts +│ ├── pages/ # 基于文件的路由 +│ │ ├── index.astro +│ │ └── blog/ +│ │ ├── [...page].astro +│ │ └── [slug].astro +│ ├── styles/ +│ │ ├── global.css # TailwindCSS 4 + 布局工具类 +│ │ └── notro-theme.css # Notion 块颜色令牌 +│ └── content.config.ts # Astro Content Collections +├── astro.config.mjs +├── package.json +└── .env +``` + +## 下一步 + +- [Notion 设置](/zh-cn/getting-started/notion-setup) — 配置 Notion 集成和数据库模式 +- [配置](/zh-cn/guides/configuration) — 自定义 notro 管道、插件和图像服务 +- [自定义组件](/zh-cn/guides/customizing-components) — 覆盖或扩展 Notion 块组件 diff --git a/content/docs/zh-cn-guides-architecture.md b/content/docs/zh-cn-guides-architecture.md new file mode 100644 index 00000000..3de64614 --- /dev/null +++ b/content/docs/zh-cn-guides-architecture.md @@ -0,0 +1,130 @@ +--- +slug: zh-cn/guides/architecture +title: 架构 +--- + +# 架构 + +本页介绍 notro 的内部工作原理 — 从获取 Notion 内容到渲染最终 HTML 页面的完整流程。 + +## 概述 + +``` +Notion 数据库 + ↓ loader() — Astro Content Loader(notro-loader) +Content Collection — 每个页面缓存的 markdown + 属性 + ↓ NotroContent — compileMdx() + 组件映射 +渲染后的 HTML 页面 +``` + +notro 完全构建于 [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) 之上。不需要单独的服务器或 Webhook — 所有事情都在构建时(或 `astro dev` 期间)发生。 + +## 内容加载 + +`notro-loader` 的 `loader()` 函数是一个自定义 Astro Content Loader。在每次构建或开发服务器启动时,它会: + +1. 调用 `notion.dataSources.query` 列出数据源中的所有页面(分页处理) +2. 对于每个页面,通过比较 `last_edited_time` 检查缓存条目是否仍然有效 +3. 对于过期或新页面,调用 `notion.pages.retrieveMarkdown` 获取原始 markdown +4. 在原始 markdown 上运行 `preprocessNotionMarkdown()`(来自 `remark-nfm`)修复结构性问题 +5. 将页面的 `id`、`properties` 和预处理后的 `markdown` 存储在 Content Collection store 中 + +Notion 中不再存在的页面将从 store 中删除。 + +### 缓存失效 + +以下情况会使条目失效: + +- Notion 的 `last_edited_time` 比缓存值更新 +- 缓存的 markdown 包含过期的 Notion 预签名 S3 图片 URL(`X-Amz-Expires`) +- 页面在 Notion 中不再存在(已删除或取消共享) + +### 错误处理 + +| 错误 | 行为 | +|---|---| +| `429 rate_limited` / `500` / `503` | 使用指数退避重试(1s、2s、4s;最多 3 次) | +| `401 unauthorized` / `403 restricted_resource` / `404 object_not_found` | 记录警告,跳过页面 — 构建继续 | +| 其他意外错误 | 记录警告,跳过页面 — 构建继续 | + +## MDX 编译管道 + +当 `NotroContent` 渲染页面时,它调用 `compileMdxCached()`,通过 `@mdx-js/mdx` 的 `evaluate()` 运行以下插件管道: + +### remark 插件(Markdown AST) + +| 插件 | 用途 | +|---|---| +| `remarkNfm` | 捆绑:`preprocessNotionMarkdown` 规范化、指令语法 + GFM(删除线、任务列表)、标注转换 | +| _(用户提供)_ | 例如 `remark-math`(LaTeX 公式) | + +### rehype 插件(HTML AST) + +| 插件 | 顺序 | 用途 | +|---|---|---| +| `rehypeRaw` | 1 | 将 markdown 中的原始 HTML 字符串解析为 hast 节点;自定义元素直接通过 | +| `rehypeNotionColor` | 2 | 将 `color="gray_bg"` 属性转换为 `notro-*` CSS 类 | +| `rehypeBlockElements` | 3 | 将 Notion 块元素重命名为 PascalCase(`video` → `Video`) | +| `rehypeInlineMentions` | 4 | 重命名内联 mention 元素(`mention-user` → `MentionUser`) | +| _(用户提供)_ | 5 | 例如 `rehype-katex`、`rehype-beautiful-mermaid` | +| `rehypeShiki` | 6 | 语法高亮(设置 `shikiConfig` 时注入) | +| `rehypeSlug` | 7 | 为标题添加 `id` 属性 | +| `rehypeToc` | 8 | 为 `` 填充锚点链接 | +| `resolvePageLinks` | 9 | 使用 `linkToPages` 映射将 `notion.so` URL 解析为站点相对 URL | + +### 组件映射 + +`evaluate()` 之后,`` 将每种 Notion 块类型映射到 Astro 组件: + +```ts +const notionComponents = { + callout: Callout, + toggle: Toggle, + columns: Columns, + column: Column, + video: Video, + table_of_contents: TableOfContents, + // ... 等等 + a: Link, + img: NotionImage, + pre: CodeBlock, + // ... +}; +``` + +自定义组件覆盖通过 `NotroContent` 的 `components` 属性合并进来。 + +## Markdown 预处理 + +在 MDX 管道运行之前,`preprocessNotionMarkdown()` 修复 Notion 原始 Markdown 输出中的结构性问题: + +| 修复 | 解决的问题 | +|---|---| +| Fix 1 | 没有前置空行的 `---` 被误读为 setext H2 | +| Fix 2 | 标注指令语法规范化 | +| Fix 3 | 块级颜色注释转换为原始 HTML | +| Fix 4 | `` 用 `
` 包裹以供 CommonMark 检测 | +| Fix 5 | 内联公式格式规范化 | +| Fix 6 | 删除 `` 包装器 | +| Fix 7 | 将 `` 隔离为块级元素 | +| Fix 8 | 为闭合标签添加尾随空行(防止 CommonMark 吞噬后续 markdown) | +| Fix 9 | 将 `
` 单元格内的 Markdown 链接转换为 `` 标签 | + +## 图片处理 + +Notion 将页面图片作为带有过期时间戳的预签名 S3 URL 提供(`X-Amz-Expires`、`X-Amz-Date` 等查询参数)。这些在每次 API 调用时都会变化,导致 Astro 的图片缓存每次都未命中。 + +`notionImageService` 包装 Astro 的内置 Sharp 服务,在计算缓存键前剥离这些过期参数,使图片只在实际内容变化时才重新处理。 + +## 包入口点 + +`notro-loader` 为不同的导入上下文提供四个入口点: + +| 入口点 | 用途 | +|---|---| +| `notro-loader` | 组件和加载器 — 在 `.astro` 和 `content.config.ts` 中使用 | +| `notro-loader/integration` | `notro()` Astro 集成 — 在 `astro.config.mjs` 中使用 | +| `notro-loader/utils` | 纯 TypeScript 辅助函数 — 在 `astro.config.mjs` 和 Node 脚本中安全使用 | +| `notro-loader/image-service` | `notionImageService` — 在 `astro.config.mjs` 的 `image.service` 中使用 | + +这种分离是因为 `astro.config.mjs` 在 JSX 渲染器注册之前被评估,所以在配置时导入 Astro 组件会失败。 diff --git a/content/docs/zh-cn-guides-configuration.md b/content/docs/zh-cn-guides-configuration.md new file mode 100644 index 00000000..a08d77b8 --- /dev/null +++ b/content/docs/zh-cn-guides-configuration.md @@ -0,0 +1,154 @@ +--- +slug: zh-cn/guides/configuration +title: 配置 +--- + +# 配置 + +本页涵盖 notro 的所有配置选项 — Astro 集成、环境变量和图像服务。 + +## astro.config.mjs + +blog 模板的典型 `astro.config.mjs` 如下所示: + +```js +import { defineConfig } from "astro/config"; +import sitemap from "@astrojs/sitemap"; +import { notro } from "notro-loader/integration"; +import { notionImageService } from "notro-loader/image-service"; +import { rehypeMermaid } from "rehype-beautiful-mermaid"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; + +export default defineConfig({ + site: "https://example.com", + image: { + service: notionImageService, + }, + integrations: [ + notro({ + shikiConfig: { theme: "github-dark" }, + remarkPlugins: [remarkMath], + rehypePlugins: [ + [rehypeMermaid, { theme: "github-dark" }], + rehypeKatex, + ], + }), + sitemap(), + ], +}); +``` + +## notro() 集成选项 + +`notro()` 集成使用 notro 的核心插件套件注册 `@astrojs/mdx`。所有选项都是可选的。 + +| 选项 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `remarkPlugins` | `PluggableList` | `[]` | 在 `remarkNfm` 之后追加的 remark 插件 | +| `rehypePlugins` | `PluggableList` | `[]` | 在 `rehypeShiki` 之前插入的 rehype 插件 | +| `shikiConfig` | `Record` | `undefined` | 设置后将 `@shikijs/rehype` 作为最后一个插件注入。需要 `npm i @shikijs/rehype` | +| `viteExternals` | `string[]` | `[]` | 要添加到 Vite 的 `ssr.external` 的包(用于原生二进制文件) | +| `extendMarkdownConfig` | `boolean` | `false` | 是否扩展 Astro 的基础 markdown 配置 | + +### 添加语法高亮 + +```js +notro({ + shikiConfig: { + theme: "github-dark", + // 或多主题: + themes: { light: "github-light", dark: "github-dark" }, + }, +}), +``` + +### 添加数学支持 + +```bash +pnpm add remark-math rehype-katex +``` + +```js +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; + +notro({ + remarkPlugins: [remarkMath], + rehypePlugins: [rehypeKatex], +}), +``` + +### 添加 Mermaid 图表 + +```bash +pnpm add rehype-beautiful-mermaid +``` + +```js +import { rehypeMermaid } from "rehype-beautiful-mermaid"; + +notro({ + rehypePlugins: [[rehypeMermaid, { theme: "github-dark" }]], + viteExternals: ["@mermaid-js/mermaid-zenuml"], // 使用 ZenUML 时 +}), +``` + +## 图像服务 + +应将 `notionImageService` 设置为 `astro.config.mjs` 中的 `image.service`。它包装 Astro 的内置 Sharp 服务,在计算图像缓存键前剥离 Notion 的过期 S3 URL 参数(`X-Amz-*`),防止每次构建时的冗余重处理。 + +```js +import { notionImageService } from "notro-loader/image-service"; + +export default defineConfig({ + image: { + service: notionImageService, + }, + // ... +}); +``` + +如果不配置此服务,由于 Notion 的预签名 URL 每次获取时都会变化,图像将在每次构建时重新处理。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|---|---|---| +| `NOTION_TOKEN` | ✓ | Notion Internal Integration Secret | +| `NOTION_DATASOURCE_ID` | ✓ | Notion 数据库(数据源)UUID | + +在项目根目录创建 `.env` 文件(已在 `.gitignore` 中): + +```bash +NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NOTION_DATASOURCE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +对于生产环境,在托管平台中将这些设置为环境变量。详情请参阅部署指南。 + +## TypeScript 配置 + +生成的 `tsconfig.json` 扩展了 Astro 的 strict 配置。notro 不需要任何更改。 + +如果你看到来自 `@notionhq/client` 的类型错误,确保设置了 `skipLibCheck: true`: + +```json +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "skipLibCheck": true + } +} +``` + +## 站点 URL + +在 `astro.config.mjs` 中将 `site` 选项设置为你的生产 URL。这是规范 URL、`og:url` 和站点地图所必需的: + +```js +export default defineConfig({ + site: "https://your-site.com", + // ... +}); +``` diff --git a/content/docs/zh-cn-guides-content-collections.md b/content/docs/zh-cn-guides-content-collections.md new file mode 100644 index 00000000..e8e182cc --- /dev/null +++ b/content/docs/zh-cn-guides-content-collections.md @@ -0,0 +1,192 @@ +--- +slug: zh-cn/guides/content-collections +title: 内容集合 +--- + +# 内容集合 + +notro 使用 [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) 管理 Notion 页面。本页介绍如何配置集合模式和加载器。 + +## 基本设置 + +`src/content.config.ts` 定义你的集合。blog 模板包含一个 `posts` 集合: + +```ts +import { defineCollection } from "astro:content"; +import { loader, pageWithMarkdownSchema, notroProperties } from "notro-loader"; +import { getPlainText } from "notro-loader/utils"; +import { z } from "zod"; + +const postsSchema = pageWithMarkdownSchema + .extend({ + properties: z.object({ + Name: notroProperties.title, + Description: notroProperties.richText.optional(), + Slug: notroProperties.richText, + Public: notroProperties.checkbox, + Date: notroProperties.date.optional(), + Tags: notroProperties.multiSelect.optional(), + Category: notroProperties.select.optional(), + }), + }) + .transform((data) => ({ + ...data, + title: getPlainText(data.properties.Name) ?? "Untitled", + description: getPlainText(data.properties.Description) ?? undefined, + slug: getPlainText(data.properties.Slug) ?? data.id, + date: data.properties.Date?.date?.start, + tags: data.properties.Tags?.multi_select.map((t) => t.name) ?? [], + category: data.properties.Category?.select?.name, + isPublic: data.properties.Public.checkbox, + })); + +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: postsSchema, + }), +}; +``` + +## pageWithMarkdownSchema + +`pageWithMarkdownSchema` 是加载器返回的 Notion 页面的基础 Zod 模式: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | `string` | Notion 页面 UUID | +| `markdown` | `string` | 预处理后的 markdown 内容 | +| `last_edited_time` | `string` | ISO 8601 时间戳 | +| `properties` | `Record` | 原始 Notion 属性(用 `.extend()` 扩展) | + +## notroProperties 辅助函数 + +`notroProperties` 提供与每种 Notion 属性类型形状匹配的 Zod 模式: + +| 辅助函数 | Notion 类型 | 访问模式 | +|---|---|---| +| `notroProperties.title` | Title | `prop.title[0]?.plain_text`(通过 `getPlainText()`) | +| `notroProperties.richText` | Rich text | `prop.rich_text[0]?.plain_text`(通过 `getPlainText()`) | +| `notroProperties.checkbox` | Checkbox | `prop.checkbox` → `boolean` | +| `notroProperties.date` | Date | `prop.date?.start` → `string \| undefined` | +| `notroProperties.select` | Select | `prop.select?.name` → `string \| undefined` | +| `notroProperties.multiSelect` | Multi-select | `prop.multi_select.map(o => o.name)` | +| `notroProperties.number` | Number | `prop.number` → `number \| null` | +| `notroProperties.url` | URL | `prop.url` → `string \| null` | + +## loader() 选项 + +```ts +loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + sorts: [{ property: "Date", direction: "descending" }], + }, + clientOptions: { + auth: import.meta.env.NOTION_TOKEN, + }, +}) +``` + +### queryParameters + +直接传递给 `notion.dataSources.query`。支持所有 Notion 过滤器和排序选项。 + +**过滤器示例:** + +```ts +// 只有公开页面 +filter: { property: "Public", checkbox: { equals: true } } + +// 包含特定标签的页面 +filter: { property: "Tags", multi_select: { contains: "tutorial" } } + +// 组合过滤器 +filter: { + and: [ + { property: "Public", checkbox: { equals: true } }, + { property: "Category", select: { equals: "blog" } }, + ], +} +``` + +**排序示例:** + +```ts +// 按日期降序 +sorts: [{ property: "Date", direction: "descending" }] + +// 按最后编辑时间排序 +sorts: [{ timestamp: "last_edited_time", direction: "descending" }] +``` + +### clientOptions + +传递给 `@notionhq/client` `Client` 构造函数。将 `auth` 设置为你的 Notion token。 + +## 多个集合 + +你可以定义指向不同 Notion 数据库的多个集合: + +```ts +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { data_source_id: import.meta.env.NOTION_BLOG_DB_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: postsSchema, + }), + projects: defineCollection({ + loader: loader({ + queryParameters: { data_source_id: import.meta.env.NOTION_PROJECTS_DB_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + schema: projectsSchema, + }), +}; +``` + +## 在页面组件中使用集合数据 + +```astro +--- +// src/pages/blog/[slug].astro +import { getCollection } from "astro:content"; +import { NotroContent } from "notro-loader"; + +export async function getStaticPaths() { + const posts = await getCollection("posts"); + return posts.map((post) => ({ + params: { slug: post.data.slug }, + props: { post }, + })); +} + +const { post } = Astro.props; +--- +
+

{post.data.title}

+ +
+``` + +## getPlainText 工具函数 + +`getPlainText` 从 Notion 富文本或标题属性值中提取纯文本字符串: + +```ts +import { getPlainText } from "notro-loader/utils"; + +getPlainText(properties.Name) // "My Post Title" +getPlainText(properties.Description) // "A short description" | undefined +``` + +如果属性不存在或没有文本内容,它会安全地返回 `undefined`。 diff --git a/content/docs/zh-cn-guides-customizing-components.md b/content/docs/zh-cn-guides-customizing-components.md new file mode 100644 index 00000000..98280dd4 --- /dev/null +++ b/content/docs/zh-cn-guides-customizing-components.md @@ -0,0 +1,183 @@ +--- +slug: zh-cn/guides/customizing-components +title: 自定义组件 +--- + +# 自定义组件 + +notro 通过 Astro 组件渲染 Notion 块。本页介绍自定义块外观和行为的三种方式。 + +## notro-ui:复制即用的组件 + +Notion 块组件通过 `notro-ui` CLI 管理。组件位于 `packages/notro-ui/src/templates/` 作为真实来源,你将它们复制到项目中后就成为你自己的代码。 + +### 安装 CLI + +```bash +npm i -g notro-ui +# 或使用 pnpm dlx: +pnpm dlx notro-ui --help +``` + +### 向项目添加组件 + +从项目根目录(放置 `notro.json` 的地方)运行: + +```bash +# 初始化(创建 notro.json,放置 notro-theme.css) +notro-ui init + +# 添加所有组件(跳过现有文件) +notro-ui add --all + +# 添加特定组件 +notro-ui add callout toggle columns +``` + +### 从上游更新组件 + +```bash +# 拉取更新(覆盖本地更改 — 谨慎使用) +notro-ui update --all --yes + +# 预览更改(不带 --yes 显示确认提示) +notro-ui update --all +``` + +### 可用命令 + +| 命令 | 行为 | +|---|---| +| `notro-ui init` | 创建 `notro.json`,放置 `notro-theme.css` | +| `notro-ui add [name...] [--all]` | 添加组件(**跳过现有文件**) | +| `notro-ui update [name...] [--all] [--yes]` | 更新组件(**覆盖**) | +| `notro-ui remove [name...] [--all]` | 删除组件 | +| `notro-ui list [--installed]` | 列出可用/已安装的组件 | + +### notro.json + +`notro-ui init` 在项目根目录创建 `notro.json`: + +```json +{ + "outDir": "src/components/notion", + "stylesDir": "src/styles", + "components": ["callout", "toggle", "columns", "code-block"] +} +``` + +提交 `notro.json` 以跟踪已安装的组件。 + +--- + +## 使用 classMap 覆盖 CSS 类 + +不替换组件而自定义样式最简单的方法是 `NotroContent` 上的 `classMap` 属性。传入 `ClassMapKeys → string` 的部分映射,向特定元素注入额外的 Tailwind 类: + +```astro +--- +import { NotroContent } from "notro-loader"; +const { entry } = Astro.props; +--- + +``` + +类映射键对应组件插槽。使用 `notro-ui list` 查看可用的键。 + +--- + +## 使用 components 完全覆盖组件 + +要完全控制,通过 `components` 属性传递自定义 Astro 组件: + +```astro +--- +import { NotroContent } from "notro-loader"; +import MyCallout from "./MyCallout.astro"; +import MyCodeBlock from "./MyCodeBlock.astro"; +const { entry } = Astro.props; +--- + +``` + +只有你提供的键会被覆盖 — 所有其他组件使用 notro 默认值。 + +### 编写自定义组件 + +组件属性反映 Notion 生成的 HTML 属性。例如,自定义标注接收 `icon`、`color` 和插槽内容: + +```astro +--- +// MyCallout.astro +interface Props { + icon?: string; + color?: string; +} +const { icon, color } = Astro.props; +--- + +``` + +### 组件映射参考 + +| 键 | 元素 | Notion 块 | +|---|---|---| +| `callout` | `` | 标注块 | +| `toggle` | `
` | 折叠块 | +| `columns` | `` | 分栏布局包装器 | +| `column` | `` | 单个列 | +| `video` | `
` | 代码块 |
+| `img` | `` | 图片 |
+| `a` | `` | 链接 |
+| `h1`–`h4` | `

`–`

` | 标题 | + +--- + +## 使用 notro-theme.css 进行样式设置 + +`notro-theme.css` 定义了 Notion 块颜色、折叠样式、表格样式等的 CSS 变量和工具类。由 `notro-ui init` 放置在 `src/styles/` 中。 + +在全局样式表中导入: + +```css +/* global.css */ +@import "tailwindcss"; +@import "./notro-theme.css"; +``` + +### 颜色令牌 + +```css +/* 文本颜色 */ +.notro-text-gray .notro-text-brown .notro-text-orange +.notro-text-yellow .notro-text-green .notro-text-blue +.notro-text-purple .notro-text-pink .notro-text-red + +/* 背景颜色 */ +.notro-bg-gray .notro-bg-brown .notro-bg-orange +.notro-bg-yellow .notro-bg-green .notro-bg-blue +.notro-bg-purple .notro-bg-pink .notro-bg-red +``` + +当 Notion 页面包含颜色注释的块时,`rehypeNotionColor` 插件会自动应用这些类。 diff --git a/content/docs/zh-cn-guides-rss-and-sitemap.md b/content/docs/zh-cn-guides-rss-and-sitemap.md new file mode 100644 index 00000000..fd15db50 --- /dev/null +++ b/content/docs/zh-cn-guides-rss-and-sitemap.md @@ -0,0 +1,161 @@ +--- +slug: zh-cn/guides/rss-and-sitemap +title: RSS 和网站地图 +--- + +# RSS 和网站地图 + +blog 模板预配置了 RSS feed 和网站地图。本页介绍其工作原理以及如何自定义。 + +## 网站地图 + +网站地图由 `@astrojs/sitemap` 生成,自动包含每个静态生成的页面。 + +### 设置 + +安装集成(blog 模板中已包含): + +```bash +pnpm add @astrojs/sitemap +``` + +添加到 `astro.config.mjs`: + +```js +import sitemap from "@astrojs/sitemap"; + +export default defineConfig({ + site: "https://your-site.com", // 网站地图必需 + integrations: [ + notro(), + sitemap(), + ], +}); +``` + +网站地图生成在 `/sitemap-index.xml`,并从 `` 自动链接。 + +### 排除页面 + +要从网站地图中排除特定页面: + +```js +sitemap({ + filter: (page) => !page.includes("/draft/"), +}), +``` + +--- + +## RSS feed + +RSS feed 在 `/rss.xml` 提供,由 `@astrojs/rss` 生成。 + +### 设置 + +安装包(blog 模板中已包含): + +```bash +pnpm add @astrojs/rss +``` + +创建 `src/pages/rss.xml.ts`: + +```ts +import rss from "@astrojs/rss"; +import { getCollection } from "astro:content"; +import { getSortedPosts, excludeFixedPages } from "@/lib/posts"; +import { config } from "@/config"; +import type { APIContext } from "astro"; + +export async function GET(context: APIContext) { + const allPosts = await getCollection("posts"); + const posts = getSortedPosts(excludeFixedPages(allPosts)); + + return rss({ + title: config.site.name, + description: config.site.description, + site: context.site!, + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.date ? new Date(post.data.date) : new Date(), + link: `/blog/${post.data.slug}/`, + })), + customData: `zh-CN`, + }); +} +``` + +### 链接 feed + +在 `Layout.astro` 的 `` 中添加 RSS feed 链接: + +```astro + +``` + +### 在 feed 中包含内容 + +要在 RSS feed 中包含每篇文章的完整 HTML 内容,使用 `sanitizeHtml` 和 `marked`: + +```bash +pnpm add sanitize-html marked +``` + +```ts +import sanitizeHtml from "sanitize-html"; +import { marked } from "marked"; + +items: posts.map((post) => ({ + title: post.data.title, + pubDate: post.data.date ? new Date(post.data.date) : new Date(), + link: `/blog/${post.data.slug}/`, + content: sanitizeHtml(await marked.parse(post.data.markdown)), +})), +``` + +> **注意:** RSS 内容从原始 markdown 渲染,而非带 Notion 组件的编译 MDX。自定义块类型(标注、折叠等)在 feed 阅读器中会显示为纯文本。 + +--- + +## robots.txt + +创建 `public/robots.txt` 控制爬虫访问: + +```txt +User-agent: * +Allow: / + +Sitemap: https://your-site.com/sitemap-index.xml +``` + +--- + +## Open Graph 和 SEO + +blog 模板的 `Layout.astro` 自动包含 Open Graph meta 标签: + +```astro + + + + +``` + +在 `astro.config.mjs` 中设置 `site` 以确保 `og:url` 是绝对 URL。 + +### 每篇文章的 OG 图片 + +要添加每篇文章的 Open Graph 图片,使用 Astro 的 `@vercel/og` 或 `satori` 生成: + +```bash +pnpm add @vercel/og +``` + +创建 `src/pages/og/[slug].png.ts` 并将生成的 URL 作为 `og:image` 传入布局。有关动态图片生成的详情,请参阅 Astro 文档。 diff --git a/content/docs/zh-cn-guides-tags-and-filtering.md b/content/docs/zh-cn-guides-tags-and-filtering.md new file mode 100644 index 00000000..3194edb9 --- /dev/null +++ b/content/docs/zh-cn-guides-tags-and-filtering.md @@ -0,0 +1,172 @@ +--- +slug: zh-cn/guides/tags-and-filtering +title: 标签和过滤 +--- + +# 标签和过滤 + +blog 模板开箱即支持基于标签和分类的过滤。本页介绍数据模型以及如何扩展它。 + +## Notion 属性 + +blog 模板使用两个过滤属性: + +| 属性 | Notion 类型 | 用途 | +|---|---|---| +| `Tags` | Multi-select | 每篇文章的多个标签(例如 `TypeScript`、`Astro`) | +| `Category` | Select | 每篇文章的单一主分类(例如 `Tutorial`) | +| `Public` | Checkbox | 控制页面是否包含在构建中 | + +## 在 API 层面过滤 + +最高效的过滤方式是在 `loader()` 查询中进行,这样只有匹配的页面才会被获取: + +```ts +// content.config.ts +loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, +}) +``` + +与获取所有页面后在客户端过滤相比,这减少了 API 调用和构建时间。 + +## 在页面组件中过滤 + +集合加载后,可以在 Astro 页面组件中进一步过滤条目。 + +### 按标签过滤 + +```ts +// src/lib/posts.ts +import type { CollectionEntry } from "astro:content"; + +export function getPostsByTag( + posts: CollectionEntry<"posts">[], + tag: string, +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.tags?.includes(tag)); +} +``` + +```astro +--- +// src/pages/blog/tag/[tag]/[...page].astro +import { getCollection } from "astro:content"; +import { getPostsByTag, getSortedPosts } from "@/lib/posts"; + +export async function getStaticPaths({ paginate }) { + const allPosts = await getCollection("posts"); + const allTags = [...new Set(allPosts.flatMap((p) => p.data.tags ?? []))]; + + return allTags.flatMap((tag) => { + const tagPosts = getSortedPosts(getPostsByTag(allPosts, tag)); + return paginate(tagPosts, { params: { tag }, pageSize: 10 }); + }); +} + +const { page, params } = Astro.props; +--- +

Posts tagged: {params.tag}

+
    + {page.data.map((post) =>
  • {post.data.title}
  • )} +
+``` + +### 按分类过滤 + +```ts +export function getPostsByCategory( + posts: CollectionEntry<"posts">[], + category: string, +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.category === category); +} +``` + +### 获取所有标签及计数 + +```ts +export function getTagCounts( + posts: CollectionEntry<"posts">[], +): Map { + const counts = new Map(); + for (const post of posts) { + for (const tag of post.data.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + return counts; +} +``` + +## 文章排序 + +```ts +export function getSortedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return [...posts].sort((a, b) => { + const dateA = a.data.date ? new Date(a.data.date).getTime() : 0; + const dateB = b.data.date ? new Date(b.data.date).getTime() : 0; + return dateB - dateA; + }); +} +``` + +## 置顶文章 + +blog 模板通过特殊的 `Tags` 值支持置顶文章。在 Notion 中用 `pinned` 标签标记页面,然后在页面组件中过滤: + +```ts +export function getPinnedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => post.data.tags?.includes("pinned")); +} + +export function excludePinnedPosts( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => !post.data.tags?.includes("pinned")); +} +``` + +## 固定页面 + +某些页面(如 About 页面)应出现在顶部导航中,但不在博客列表中。惯例是使用 `page` 标签: + +```ts +export function excludeFixedPages( + posts: CollectionEntry<"posts">[], +): CollectionEntry<"posts">[] { + return posts.filter((post) => !post.data.tags?.includes("page")); +} +``` + +## 高级 Notion 过滤器 + +可以使用 Notion 的过滤器 API 在 `queryParameters` 中组合复杂过滤器: + +```ts +// 特定分类 AND 特定标签的文章 +filter: { + and: [ + { property: "Category", select: { equals: "Tutorial" } }, + { property: "Tags", multi_select: { contains: "TypeScript" } }, + ], +} + +// 任一标签的文章 +filter: { + or: [ + { property: "Tags", multi_select: { contains: "Astro" } }, + { property: "Tags", multi_select: { contains: "notro" } }, + ], +} +``` + +完整的过滤器语法请参阅 [Notion API 过滤器文档](https://developers.notion.com/reference/post-database-query-filter)。 diff --git a/content/docs/zh-cn-reference-integration.md b/content/docs/zh-cn-reference-integration.md new file mode 100644 index 00000000..19dca118 --- /dev/null +++ b/content/docs/zh-cn-reference-integration.md @@ -0,0 +1,164 @@ +--- +slug: zh-cn/reference/integration +title: notro() 集成 +--- + +# notro() 集成 + +`notro()` 是一个 Astro 集成,使用 notro 的核心 remark/rehype 插件管道注册 `@astrojs/mdx`。 + +## 为什么需要它 + +`notro()` 有两个必要原因: + +1. **`astro:jsx` 渲染器** — `@astrojs/mdx` 注册 `@mdx-js/mdx` 的 `evaluate()` 用于生成 Astro VNode 所依赖的 `astro:jsx` 渲染器。没有它,`NotroContent` 在运行时会失败。 +2. **静态 `.mdx` 文件** — 如果你的项目将 `.mdx` 文件与 Notion 内容一起使用,`notro()` 确保它们使用与 Notion 内容相同的插件管道处理。 + +## 导入 + +```js +// astro.config.mjs +import { notro } from "notro-loader/integration"; +``` + +> **重要:** 始终从 `notro-loader/integration` 导入,而不是从 `notro-loader`。`/integration` 入口点不导入任何 Astro 组件,因此在 `astro.config.mjs` 中使用是安全的。 + +## 使用 + +```js +import { defineConfig } from "astro/config"; +import { notro } from "notro-loader/integration"; + +export default defineConfig({ + integrations: [ + notro(), + ], +}); +``` + +## 选项 + +所有选项都是可选的。 + +```ts +notro({ + remarkPlugins?: PluggableList; + rehypePlugins?: PluggableList; + shikiConfig?: Record; + viteExternals?: string[]; + extendMarkdownConfig?: boolean; +}) +``` + +### remarkPlugins + +在管道中 `remarkNfm` 之后追加的 remark 插件。 + +```js +import remarkMath from "remark-math"; + +notro({ + remarkPlugins: [remarkMath], +}) +``` + +插件按提供的顺序追加。 + +### rehypePlugins + +在管道中 `rehypeShiki`(如果已配置)之前插入的 rehype 插件。 + +```js +import rehypeKatex from "rehype-katex"; +import { rehypeMermaid } from "rehype-beautiful-mermaid"; + +notro({ + rehypePlugins: [ + rehypeKatex, + [rehypeMermaid, { theme: "github-dark" }], + ], +}) +``` + +每个项目可以是插件函数或 `[plugin, options]` 元组。 + +### shikiConfig + +设置后,将 `@shikijs/rehype` 作为最后一个 rehype 插件注入以进行语法高亮。需要单独安装 `@shikijs/rehype`。 + +```bash +pnpm add @shikijs/rehype +``` + +```js +notro({ + shikiConfig: { + theme: "github-dark", + }, +}) +``` + +所有键直接传递给 `@shikijs/rehype`。可用主题和选项请参阅 [Shiki 文档](https://shiki.style/)。 + +**双主题(亮/暗)示例:** + +```js +notro({ + shikiConfig: { + themes: { + light: "github-light", + dark: "github-dark", + }, + defaultColor: false, + }, +}) +``` + +### viteExternals + +要添加到 Vite 的 `ssr.external` 的包。适用于 Vite 不应打包的原生二进制文件或动态导入的包: + +```js +notro({ + rehypePlugins: [[rehypeMermaid, { strategy: "img-svg" }]], + viteExternals: ["@mermaid-js/mermaid-zenuml"], +}) +``` + +### extendMarkdownConfig + +为 `true` 时,用 notro 的插件管道扩展 Astro 的基础 markdown 配置。默认为 `false`。 + +这很少需要。只有在你希望 notro 的插件也处理 Astro 内置的 `.md` 和 `.mdx` 文件(那些不由 Notion 加载器管理的文件)时才启用。 + +## 插件管道顺序 + +配置所有选项时的完整管道: + +``` +remarkNfm + ↓(用户 remarkPlugins) +rehypeRaw +rehypeNotionColor +rehypeBlockElements +rehypeInlineMentions + ↓(用户 rehypePlugins) +rehypeShiki ← 仅当设置了 shikiConfig 时 +rehypeSlug +rehypeToc +resolvePageLinks +``` + +## 类型参考 + +```ts +interface NotroIntegrationOptions { + remarkPlugins?: PluggableList; + rehypePlugins?: PluggableList; + shikiConfig?: Record; + viteExternals?: string[]; + extendMarkdownConfig?: boolean; +} + +function notro(options?: NotroIntegrationOptions): AstroIntegration; +``` diff --git a/content/docs/zh-cn-reference-loader.md b/content/docs/zh-cn-reference-loader.md new file mode 100644 index 00000000..b3663547 --- /dev/null +++ b/content/docs/zh-cn-reference-loader.md @@ -0,0 +1,177 @@ +--- +slug: zh-cn/reference/loader +title: loader() +--- + +# loader() + +`loader()` 函数是一个自定义 [Astro Content Loader](https://docs.astro.build/en/reference/content-loader-reference/),用于从 Notion 数据源获取页面并将其存储在 Content Collection store 中。 + +## 导入 + +```ts +import { loader } from "notro-loader"; +``` + +## 使用 + +```ts +// src/content.config.ts +import { defineCollection } from "astro:content"; +import { loader } from "notro-loader"; + +export const collections = { + posts: defineCollection({ + loader: loader({ + queryParameters: { + data_source_id: import.meta.env.NOTION_DATASOURCE_ID, + filter: { property: "Public", checkbox: { equals: true } }, + }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + }), +}; +``` + +## 选项 + +```ts +interface LoaderOptions { + queryParameters: DataSourceQueryParameters; + clientOptions: ClientOptions; +} +``` + +### queryParameters + +传递给 `notion.dataSources.query` 的参数。`data_source_id` 是必填的;所有其他键都是可选的。 + +```ts +queryParameters: { + data_source_id: string; // 必填:Notion 数据库 UUID + filter?: FilterObject; // 可选:Notion 过滤器 + sorts?: SortObject[]; // 可选:排序顺序 + page_size?: number; // 可选:每页结果数(最大 100) +} +``` + +**过滤器示例:** + +```ts +// 简单复选框过滤器 +filter: { property: "Public", checkbox: { equals: true } } + +// AND 过滤器 +filter: { + and: [ + { property: "Public", checkbox: { equals: true } }, + { property: "Tags", multi_select: { contains: "featured" } }, + ], +} +``` + +**排序示例:** + +```ts +// 按 Date 降序 +sorts: [{ property: "Date", direction: "descending" }] + +// 按最后编辑时间排序 +sorts: [{ timestamp: "last_edited_time", direction: "descending" }] +``` + +### clientOptions + +传递给 `@notionhq/client` `Client` 构造函数的选项。 + +```ts +clientOptions: { + auth: string; // 必填:Notion API token + notionVersion?: string; // 可选:API 版本(默认:最新) + timeoutMs?: number; // 可选:请求超时(毫秒) +} +``` + +## 加载器存储的内容 + +对于每个 Notion 页面,加载器存储一个条目: + +```ts +{ + id: string; // Notion 页面 UUID + markdown: string; // 预处理后的 markdown 内容 + last_edited_time: string; // ISO 8601 时间戳 + properties: { + // 原始 Notion 属性对象 — 形状取决于你的数据库模式 + Name: { title: [...] }, + Slug: { rich_text: [...] }, + // ... + }; +} +``` + +使用 `notro-loader` 的 `pageWithMarkdownSchema` 作为基础 Zod 模式来为这些字段添加类型,然后用数据库的特定属性扩展它。 + +## 缓存行为 + +加载器使用 Astro 的 Content Layer store 在构建之间缓存页面。以下情况会刷新条目: + +- Notion 的 `last_edited_time` 自上次构建以来已更新 +- 缓存的 markdown 包含过期的 Notion 预签名 S3 URL(通过图片 URL 中的 `X-Amz-Expires` 检测) +- 页面在 Notion 中不再存在(从 store 中删除条目) + +`last_edited_time` 未变化的页面不会重新获取,使增量构建更快。 + +## 错误处理 + +| 情况 | 行为 | +|---|---| +| `429 rate_limited` | 使用指数退避重试(1s、2s、4s;最多 3 次) | +| `500 / 503` 服务器错误 | 使用指数退避重试 | +| `401 unauthorized` | 记录警告,跳过页面 | +| `403 restricted_resource` | 记录警告,跳过页面 | +| `404 object_not_found` | 记录警告,从 store 中删除 | +| 内容被截断 | 记录警告,使用截断的内容 | +| 未知块 ID | 记录带块 ID 列表的警告,继续 | + +即使单个页面失败,构建也会继续。 + +## 实时加载器(仅开发环境) + +对于需要不重启服务器就能实时更新内容的开发环境,使用 `liveLoader()`: + +```ts +import { liveLoader } from "notro-loader"; + +export const collections = { + posts: defineCollection({ + loader: liveLoader({ + queryParameters: { data_source_id: import.meta.env.NOTION_DATASOURCE_ID }, + clientOptions: { auth: import.meta.env.NOTION_TOKEN }, + }), + }), +}; +``` + +`liveLoader()` 在 `astro dev` 期间每次请求都重新获取内容。不建议用于生产构建。 + +## 类型参考 + +```ts +function loader(options: LoaderOptions): AstroContentLoader; +function liveLoader(options: LoaderOptions): AstroContentLoader; + +interface LoaderOptions { + queryParameters: { + data_source_id: string; + filter?: unknown; + sorts?: unknown[]; + page_size?: number; + }; + clientOptions: { + auth: string; + notionVersion?: string; + timeoutMs?: number; + }; +} +``` diff --git a/content/docs/zh-cn-reference-markdown-pipeline.md b/content/docs/zh-cn-reference-markdown-pipeline.md new file mode 100644 index 00000000..420ebc0b --- /dev/null +++ b/content/docs/zh-cn-reference-markdown-pipeline.md @@ -0,0 +1,197 @@ +--- +slug: zh-cn/reference/markdown-pipeline +title: Markdown 处理管道 +--- + +# Markdown 处理管道 + +本页记录 markdown 处理管道的每个步骤 — 从原始 Notion API 输出到渲染后的 HTML。 + +## 概述 + +``` +原始 Notion markdown(pages.retrieveMarkdown) + ↓ preprocessNotionMarkdown() 修复结构性问题 + ↓ remarkNfm 指令 + GFM + 标注转换 + ↓ (用户 remarkPlugins) + ↓ rehypeRaw HTML 字符串 → hast 节点 + ↓ rehypeNotionColor color="gray" → notro-* 类 + ↓ rehypeBlockElements video → Video(PascalCase) + ↓ rehypeInlineMentions mention-user → MentionUser + ↓ (用户 rehypePlugins) + ↓ rehypeShiki 语法高亮 + ↓ rehypeSlug 为标题添加 id 属性 + ↓ rehypeToc 填充 + ↓ resolvePageLinks notion.so → 站点相对 URL + ↓ @mdx-js/mdx evaluate() + ↓ +渲染后的 HTML +``` + +--- + +## preprocessNotionMarkdown + +`preprocessNotionMarkdown()` 是一个字符串预处理器(不是 remark 插件),在 AST 解析之前修复 Notion 原始 markdown 输出中的结构性问题。由 `remarkNfm` 自动调用。 + +### Fix 0 — 转义内联数学迁移 + +旧版 notro 将内联数学转义为 `\$…\$` 以防止 remark 将其视为文本。此修复将其转换回 `$…$` 以保持兼容性。 + +### Fix 1 — setext 标题误识别 + +没有前置空行的 `---` 分隔线会被误读为 setext H2 下划线。Fix 1 在裸 `---` 分隔线之前插入空行。 + +**修复前:** +```md +Some text +--- +Next section +``` + +**修复后:** +```md +Some text + +--- +Next section +``` + +### Fix 2 — 标注指令规范化 + +Notion 将标注块导出为 `"::: callout {…}"`。Fix 2 将间距规范化为 `":::callout{…}"` 以适配 `remark-directive` 解析器,并对标注块内的制表符缩进内容进行去缩进。 + +### Fix 3 — 块级颜色注释 + +段落和标题上的 Notion 颜色注释以 `{color="gray_bg"}` 形式导出在块末尾。Fix 3 将这些转换为原始 HTML `

`,后来由 `rehypeNotionColor` 转换为 CSS 类。 + +### Fix 4 — 目录标签 + +``(带下划线)不被 CommonMark 解析器识别为块级 HTML 元素。Fix 4 将其用 `

` 包裹以确保被视为块级元素。 + +### Fix 5 — 内联公式格式 + +Notion 将内联公式导出为 `$\`…\`$`。Fix 5 将其转换为 `remark-math` 所需的 `$…$`。 + +### Fix 6 — 同步块包装器 + +删除 `` 包装器,将内部内容去缩进到文档级别。 + +### Fix 7 — 空块隔离 + +用空行包围 `` 内联元素,使 remark 将其视为块级元素(MDX 组件路由所必需)。 + +### Fix 8 — 闭合标签空行 + +为 `
`、``、``、``、`` 添加尾随空行。没有这个,CommonMark 的 HTML 块检测模式会将所有后续内容作为原始文本吞噬,阻止 remark 解析后续 markdown。 + +### Fix 9 — 表格单元格中的 Markdown 链接 + +原始 HTML `` 单元格内的 `[text](url)` 语法不会被 remark 处理(它将整个 `` 块视为原始 HTML)。Fix 9 在 AST 解析之前将这些转换为 `text` 标签。 + +--- + +## remarkNfm + +`remarkNfm` 是 `remark-nfm` 包中的核心 remark 插件,将三个操作捆绑在一个插件中: + +1. **`preprocessNotionMarkdown`** — 在解析前运行上述字符串修复 +2. **`remark-directive`** — 启用 `:::callout{…}` 指令语法 +3. **`remark-gfm`** — GFM 删除线(`~~text~~`)和任务列表(`- [x]`)支持 +4. **标注转换** — 将 `:::callout` 指令 AST 节点转换为原始 `` HTML 元素 + +### 标注语法 + +Notion 在 Fix 2 之后以这种指令格式导出标注块: + +```md +:::callout{icon="💡" color="blue"} +这是标注内容。 +::: +``` + +`remarkNfm` 将其转换为: + +```html + +这是标注内容。 + +``` + +--- + +## rehype 插件 + +### rehypeRaw + +将 markdown AST 中嵌入的原始 HTML 字符串转换为适当的 hast 节点,允许后续 rehype 插件遍历和转换它们。自定义 Notion 元素(``、``、`
` | 表格 | + +## MDX 编译缓存 + +`NotroContent` 内部使用 `compileMdxCached()`,它以 markdown 字符串的哈希为键缓存编译后的 MDX 模块。单次构建中相同页面的重新渲染会重用缓存的模块,无需重新调用 MDX 编译器。 + +## CSS 包装器 + +`notro-markdown` CSS 类应用于包装 `NotroContent` 输出的元素(由模板中的父元素应用,而非 `NotroContent` 本身)。`notro-theme.css` 中的该类为以下内容设置作用域样式: + +- `
` / `` 块
+- 任务列表复选框
+- 表格
+- 引用
+
+```astro
+
+
+ +
+``` + +## 类型参考 + +```ts +import type { NotionComponents, ClassMapKeys } from "notro-loader"; + +interface NotroContentProps { + markdown: string; + linkToPages?: Record; + classMap?: Partial>; + components?: Partial; +} +``` diff --git a/scripts/push-translations.py b/scripts/push-translations.py new file mode 100644 index 00000000..78929cc0 --- /dev/null +++ b/scripts/push-translations.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Push Japanese and Chinese translation files to Notion docs data source.""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +NOTION_TOKEN = os.environ.get("NOTION_TOKEN") +# database_id for data_source_id 1126b8b6-8958-825a-941b-87e5a047a3a0 (NOTION_DATASOURCE_ID) +DOCS_DB_ID = "31c6b8b6-8958-81c0-96fd-e63fec21205b" +CONTENT_DIR = Path(__file__).parent.parent / "content" / "docs" + +if not NOTION_TOKEN: + print("ERROR: NOTION_TOKEN is not set", file=sys.stderr) + sys.exit(1) + + +def parse_frontmatter(content: str) -> tuple[dict, str]: + """Extract frontmatter and body from markdown content.""" + if not content.startswith("---"): + return {}, content + end = content.index("---", 3) + fm_text = content[3:end].strip() + body = content[end + 3:].strip() + fm = {} + for line in fm_text.splitlines(): + if ":" in line: + key, _, val = line.partition(":") + fm[key.strip()] = val.strip() + return fm, body + + +def create_notion_page(title: str, slug: str, markdown_body: str) -> dict: + """Create a page in the Notion docs data source.""" + payload = { + "parent": {"database_id": DOCS_DB_ID}, + "properties": { + "Name": {"title": [{"text": {"content": title}}]}, + "Slug": {"rich_text": [{"text": {"content": slug}}]}, + "Public": {"checkbox": True}, + }, + "markdown": markdown_body, + } + payload_json = json.dumps(payload) + + result = subprocess.run( + [ + "curl", "-s", + "https://api.notion.com/v1/pages", + "-H", f"Authorization: Bearer {NOTION_TOKEN}", + "-H", "Notion-Version: 2026-03-11", + "-H", "Content-Type: application/json", + "-d", payload_json, + ], + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def main(): + files = sorted(CONTENT_DIR.glob("*.md")) + + print(f"Found {len(files)} files to push") + + success = 0 + errors = 0 + + for filepath in files: + content = filepath.read_text(encoding="utf-8") + fm, body = parse_frontmatter(content) + title = fm.get("title", filepath.stem) + slug = fm.get("slug", "") + + print(f" Creating: {slug} ({title})") + response = create_notion_page(title, slug, body) + + if response.get("object") == "page": + page_id = response["id"] + print(f" ✓ Created page {page_id}") + success += 1 + else: + print(f" ✗ Error: {response.get('message', response)}") + errors += 1 + + print(f"\nDone: {success} created, {errors} errors") + if errors > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/templates/blog/src/components/Footer.astro b/templates/blog/src/components/Footer.astro index b7332abb..c8929224 100644 --- a/templates/blog/src/components/Footer.astro +++ b/templates/blog/src/components/Footer.astro @@ -1,10 +1,57 @@ --- import config from "../config"; + +const { pathname } = Astro.url; + +let currentLang = "en"; +let basePath = pathname; +if (pathname.startsWith("/blog/ja/")) { + currentLang = "ja"; + basePath = "/blog/" + pathname.slice("/blog/ja/".length); +} else if (pathname.startsWith("/blog/zh-cn/")) { + currentLang = "zh-cn"; + basePath = "/blog/" + pathname.slice("/blog/zh-cn/".length); +} + +const afterBlog = basePath.slice("/blog/".length); +const isArticle = + afterBlog.length > 0 && !/^\d+\/$/.test(afterBlog) && !afterBlog.startsWith("tag/"); + +function langUrl(target: string): string { + if (!isArticle) return "/blog/"; + if (target === "en") return basePath; + return `/blog/${target}/${basePath.slice("/blog/".length)}`; +} + +const langs = [ + { code: "en", label: "English" }, + { code: "ja", label: "日本語" }, + { code: "zh-cn", label: "中文" }, +]; ---
- diff --git a/templates/blog/src/content.config.ts b/templates/blog/src/content.config.ts index 85e80dad..3367c077 100644 --- a/templates/blog/src/content.config.ts +++ b/templates/blog/src/content.config.ts @@ -34,6 +34,7 @@ const postsCollection = defineCollection({ }), Tags: notroProperties.multiSelect, Date: notroProperties.date, + Lang: notroProperties.select.optional(), }), }), }); diff --git a/templates/blog/src/lib/blog.test.ts b/templates/blog/src/lib/blog.test.ts index 26817d09..cb09bb0c 100644 --- a/templates/blog/src/lib/blog.test.ts +++ b/templates/blog/src/lib/blog.test.ts @@ -17,6 +17,7 @@ function makeEntry(opts: { name?: string; tags?: string[]; date?: string; + lang?: string; }) { const tagOptions = (opts.tags ?? []).map((name) => ({ id: name, name, color: "default" })); return { @@ -30,6 +31,7 @@ function makeEntry(opts: { Name: { type: "title", title: opts.name ? [{ plain_text: opts.name }] : [] }, Tags: { type: "multi_select", multi_select: tagOptions }, Date: { type: "date", date: opts.date ? { start: opts.date } : null }, + Lang: { type: "select", select: opts.lang ? { id: opts.lang, name: opts.lang, color: "default" } : null }, }, }, } as any; @@ -116,6 +118,24 @@ describe("buildSlugMap", () => { expect(map.get("a2")).toBe("a-2"); expect(map.get("b2")).toBe("b-2"); }); + + it("prepends lang prefix for non-en articles", () => { + const posts = [ + makeEntry({ id: "en", slug: "hello-notro", lang: "en" }), + makeEntry({ id: "ja", slug: "hello-notro", lang: "ja" }), + makeEntry({ id: "zh", slug: "hello-notro", lang: "zh-cn" }), + ]; + const map = buildSlugMap(posts); + expect(map.get("en")).toBe("hello-notro"); + expect(map.get("ja")).toBe("ja/hello-notro"); + expect(map.get("zh")).toBe("zh-cn/hello-notro"); + }); + + it("treats null or undefined lang as en (no prefix)", () => { + const posts = [makeEntry({ id: "a", slug: "foo" })]; + const map = buildSlugMap(posts); + expect(map.get("a")).toBe("foo"); + }); }); // ───────────────────────────────────────────── diff --git a/templates/blog/src/lib/blog.ts b/templates/blog/src/lib/blog.ts index 89057b05..86b59e60 100644 --- a/templates/blog/src/lib/blog.ts +++ b/templates/blog/src/lib/blog.ts @@ -26,7 +26,9 @@ export function buildSlugMap(posts: PostEntry[]): Map { const result = new Map(); for (const entry of posts) { - const rawSlug = getPlainText(entry.data.properties.Slug) || entry.id; + const cleanSlug = getPlainText(entry.data.properties.Slug) || entry.id; + const lang = entry.data.properties.Lang?.select?.name; + const rawSlug = lang && lang !== "en" ? `${lang}/${cleanSlug}` : cleanSlug; const count = slugCounts.get(rawSlug) ?? 0; slugCounts.set(rawSlug, count + 1); diff --git a/templates/docs/astro.config.mjs b/templates/docs/astro.config.mjs index 12913f6b..5bed572e 100644 --- a/templates/docs/astro.config.mjs +++ b/templates/docs/astro.config.mjs @@ -35,6 +35,12 @@ export default defineConfig({ ja: { label: "日本語", lang: "ja" }, "zh-cn": { label: "简体中文", lang: "zh-CN" }, }, + sidebar: [ + { label: "Getting Started", autogenerate: { directory: "getting-started" } }, + { label: "Guides", autogenerate: { directory: "guides" } }, + { label: "Reference", autogenerate: { directory: "reference" } }, + { label: "Deployment", autogenerate: { directory: "deployment" } }, + ], }), notro(), ], diff --git a/templates/docs/src/content.config.ts b/templates/docs/src/content.config.ts index 63cf95a3..6e43e033 100644 --- a/templates/docs/src/content.config.ts +++ b/templates/docs/src/content.config.ts @@ -10,6 +10,7 @@ const notroDocsSchema = pageWithMarkdownSchema Description: notroProperties.richText.optional(), Slug: notroProperties.richText, Public: notroProperties.checkbox, + Lang: notroProperties.select.optional(), }), }) // Make all Notion fields optional and allow extra fields to pass through so that @@ -56,15 +57,18 @@ export const collections = { }, clientOptions: { auth: import.meta.env.NOTION_TOKEN }, useFilePath: true, - // Use the Slug property as the entry ID so Starlight's sidebar slugs match. - // e.g. Slug = "getting-started/introduction" → entry ID = "getting-started/introduction" + // Build entry ID from Lang + Slug so Starlight's locale routing works. + // root locale (en): "getting-started/introduction" + // other locales: "ja/getting-started/introduction" generateId: (page) => { + const lang = page.properties.Lang?.select?.name; // "en" | "ja" | "zh-cn" | undefined const slugProp = page.properties.Slug; - if (slugProp?.type === "rich_text" && slugProp.rich_text.length > 0) { - const text = slugProp.rich_text.map((t) => t.plain_text).join(""); - if (text) return text; - } - return page.id; + const slug = + slugProp?.type === "rich_text" && slugProp.rich_text.length > 0 + ? slugProp.rich_text.map((t) => t.plain_text).join("") + : ""; + if (!slug) return page.id; + return lang && lang !== "en" ? `${lang}/${slug}` : slug; }, }), schema: notroDocsSchema, diff --git a/templates/docs/src/pages/index.astro b/templates/docs/src/pages/index.astro index 363fae56..fe3351d9 100644 --- a/templates/docs/src/pages/index.astro +++ b/templates/docs/src/pages/index.astro @@ -14,7 +14,7 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; actions: [ { text: "Get Started", - link: "/hello-notro/", + link: "/getting-started/introduction/", icon: "right-arrow", variant: "primary", },