Markdown 博客(多语言,带标签)
一个内容为文件而非数据库行的博客。整个设计围绕一个核心理念:
博文的身份是它的 translationKey,而不是它的 slug。 Slug 是为 SEO 而本地化的,不同语言各不相同;而 translationKey 将三个文件关联为一篇文章,并驱动 hreflang、语言切换器、相关文章和跨语言重定向。只要这个字段设置正确,其他所有事情都只是查找。
由已成功交付此模块的工程师编写。它此前被审计对照的早期实现是一个多语言、静态生成的营销博客,包含其站点地图和 SEO 表面。以下的事实和规则是内容层套件和构建过程所验证的;references/provenance.md 中有记录。
何时使用
- 内容以 Markdown 形式存储在仓库中,随应用一起部署
- 不止一种语言,且不同语言拥有不同的 slug
- 涉及标签页面、相关文章、Feed 或 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 ----> 替代页 / hreflang / 语言切换
+--> tags -> 标签 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 分组,并按 slug 匹配文章,这样每个拼写变体的文章都出现在同一个标签页面上。两个测试保障了这一点。 gray-matter会在双引号 YAML 标量包含原始引号字符时抛出异常——翻译摘录中的印刷体引号就会触发此问题。没有回退解析器,构建就会卡在单个文件上;逐行回退解析可以确保加载继续。已针对该库验证,并有测试覆盖。gray-matter会将失败的解析缓存为空结果。 它在解析之前就会写入缓存条目,因此第二次及以后对同一文件的解析都会返回{}——没有错误,也没有 frontmatter。始终调用matter(contents, {});任何 options 对象都会跳过缓存。- 如果不记忆化,每次页面加载的时间复杂度是 O(文章数 x 语言数)。 按语言记忆化,使每个内容文件在每次构建中只被读取一次;否则,在
generateMetadata中解析替代页会导致 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 - 接入语言、替代页和跨语言重定向——i18n-and-routing.md
- 添加标签分组和标签页面——tags.md
- 构建三个路由、元数据和 JSON-LD——pages-and-seo.md
- 渲染正文和封面——rendering.md
- 添加验证脚本并阅读运维差距——operations.md
- 运行测试固件和测试——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、替代页、错误语言链接 | i18n, locale, hreflang, alternates, canonical, language switcher, redirect | i18n-and-routing.md |
| 标签 URL、冲突、稀疏标签页面 | tag, slug, diacritics, collision, tag page, taxonomy | tags.md |
| 路由、元数据、JSON-LD、站点地图、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 |
| 测试固件及通过的测试套件 | 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 |