Markdown 部落格(多語言、標籤化)
一個內容以檔案為核心的部落格,而非資料庫中的紀錄。整個設計圍繞一個核心概念:
一篇文章的身份是其 translationKey,而非其 slug。 Slug 是為了 SEO 而本地化的,每種語言都不同;而 translationKey 才是讓三個檔案成為同一篇文章的關鍵,它驅動了 hreflang、語言切換器、相關文章以及跨語言重定向。只要正確設定這個欄位,其他一切都只是查詢操作。
由實際部署過此模組的工程師撰寫。之前實作經審計的對象是一個多語言、靜態生成的營銷部落格,包含其 Sitemap 和 SEO 表面。以下的事實和規則是內容層套件和建置流程所驗證的;references/provenance.md 中有相應的記錄。
使用時機
- 內容以 Markdown 形式存放於程式碼庫中,並隨應用程式一起部署
- 支援多種語言,且每種語言使用不同的 slug
- 需要標籤頁面、相關文章、摘要或 hreflang
- 希望整個部落格在建置時靜態生成
不應使用的時機
| 替代方案 | 使用此技能時機 |
|---|---|
| 使用託管的 CMS 作為資料來源 | 直接使用該 CMS 的 SDK;僅保留 references/content-model.md |
| 文件網站/幫助中心(包含分類、側邊欄、搜尋) | 使用幫助中心技能 — 導航模型不同 |
| 單一語言的部落格,少於 10 篇文章 | 直接使用 fs + gray-matter 內嵌;此技能會造成額外開銷 |
| 渲染使用者提交的 Markdown | 使用清潔渲染器;請參閱 references/rendering.md 中的 XSS 說明 |
架構
content/blog/<locale>/<localized-slug>.md # frontmatter + body
|
v
parseFrontmatter --YAML, 若遇到格式錯誤的引號則回退到行解析器
|
v
getAllPosts(locale) <-- 在生產環境中按語言記憶化
|
+--> translationKey ----> alternates / hreflang / 語言切換
+--> tags -> tag SLUG ---> 標籤頁面(slug 是身份,而非標籤名稱)
+--> related (EN slugs) -> 相關文章,附帶 EN 備援
|
v
/blog /blog/[slug] /blog/tag/[slug] sitemap.xml feed.xml
關鍵事實
- 關聯只在一處撰寫,即預設語言,並使用預設語言的 slug。 翻譯後的檔案可以省略
related,但仍然能透過translationKey解析出正確的相關文章,因此每個翻譯版本都渲染出相同的相關區塊。備援測試對此有驗證。 - 標籤的身份是其 slug,而非標籤名稱。
"AI Agents"和"AI agents"會 slug 成相同的 URL。按 slug 分組並比對文章標籤,這樣所有拼寫變體的文章都會出現在同一個標籤頁面上。有兩個測試對此驗證。 gray-matter在遇到包含原始引號字符的雙引號 YAML 標量時會拋錯 — 翻譯後的摘要中使用的印刷體引號會觸發此問題。如果沒有回退解析器,建置會在該檔案上停止;逐行回退解析器能保證其正常載入。已針對該函式庫驗證,並有測試覆蓋。gray-matter會將失敗的解析快取為空結果。 它在解析之前就寫入了快取條目,因此對於一個曾拋錯的檔案,第二次及之後的所有解析都會返回{}— 沒有錯誤,沒有 frontmatter。始終使用matter(contents, {})呼叫;任何選項物件都會跳出快取。- 每個頁面的載入成本是 O(文章數 x 語言數),除非你進行記憶化。 按語言記憶化,這樣每個內容檔案在建置時只被讀取一次;否則在
generateMetadata中解析 alternates 會導致 71 倍的檔案讀取放大(根據先前實作測量)。 getPostSlugs必須過濾.md檔案。 這個過濾器可以防止一個不相關的.DS_Store檔案被當作.DS_Store.md讀取而導致ENOENT錯誤。有測試驗證此點。- 標籤頁面沒有跨語言的身份。 標籤是每種語言的自由文字,因此語言切換器在標籤 URL 上必須回退到部落格首頁,而不是建立一個會導致 404 的 URL。
嚴格規則
切勿以 slug 為鍵進行跨語言查詢。 Slug 是故意本地化的。
translationKey才是連接欄位;不同語言間 slug 的匹配只是巧合,不是一種關聯。
切勿讓
getPostBySlug隱式決定草稿的可見性。 在載入器中過濾草稿;dynamicParams = false僅僅作為副作用隱藏它們,一旦此標誌改變,它就不再生效。草稿測試會驗證此過濾器。
切勿渲染一個固定的「5 分鐘閱讀」常數。 應根據內容本體計算閱讀時間;只需一次
split操作,閱讀時間測試會驗證此點。
切勿在讀取時從標籤名稱推導出標籤頁面的 URL,而在渲染時又從 URL 推導出標籤名稱,而不進行碰撞檢查。 僅當每個標籤名稱都能唯一地 slug 化時,這種往返才是無損的。
快速開始
- 設定語言合約和 frontmatter 結構 — content-model.md
- 建立載入器(記憶化、
.md過濾、回退解析器) — content-loader.md - 建立語言、alternates 和跨語言重定向的連線 — i18n-and-routing.md
- 新增標籤分組和標籤頁面 — tags.md
- 建立三個路由、中繼資料和 JSON-LD — pages-and-seo.md
- 渲染內容本體和封面圖 — rendering.md
- 加入驗證腳本並閱讀操作員空白 — operations.md
- 運行測試 fixtures 和測試套件 — testing.md
想將此技能移植到現有應用程式中?請先在 adaptation.md 中填寫接縫表 — 這只需十分鐘,可以省去後續重新命名的麻煩。
參考目錄
| 情境 | 觸發關鍵字 | 參考文件 |
|---|---|---|
| Frontmatter 欄位、草稿、翻譯鍵、關聯 | frontmatter, schema, translationKey, draft, related, excerpt | content-model.md |
| 讀取檔案、快取、YAML 失敗、閱讀時間 | gray-matter, fs, cache, memoize, ENOENT, YAMLException, parse | content-loader.md |
| 語言、hreflang、alternates、錯誤語言連結 | i18n, locale, hreflang, alternates, canonical, language switcher, redirect | i18n-and-routing.md |
| 標籤 URL、碰撞、稀疏標籤頁面 | tag, slug, diacritics, collision, tag page, taxonomy | tags.md |
| 路由、中繼資料、JSON-LD、Sitemap、RSS | page.tsx, generateStaticParams, generateMetadata, dynamicParams, sitemap, RSS, feed, BlogPosting | pages-and-seo.md |
| Markdown 轉 React、程式碼區塊、封面圖 | react-markdown, remark-gfm, syntax highlighting, cover, motif, accent, XSS | rendering.md |
| 驗證內容、建置成本、編輯器工作流程 | validation, CI check, build time, drafts, authoring, operator | operations.md |
| Fixtures 和通過的測試套件 | test, fixture, vitest, node:test, regression, assert | testing.md |
| 將此技能整合到宿主應用程式:接縫、重新命名、探測 | adapt, port, host, seam, rename, CMS, integrate | adaptation.md |
| 審計記錄:與先前實作相比,更改了、保留了和新增了什麼 | provenance, deviations, fidelity, audit, ledger | provenance.md |