✨ 亮点

本版本是一个安全与质量补丁,修复了 v0.8.6 实战测试报告中的六个非破坏性问题。最重要的修改是 Threads 插件中一个存储型 XSS——恶意的 authors.json 可以逃逸出 <script> 块,并在每一个加载了 Threads 客户端的页面上执行任意 JavaScript。修复方案将文件作为 JSON 解析,并通过一个 scriptLiteral 辅助函数重新序列化,该函数会转义 HTML 解析器特殊处理的四个字节序列(</script<!--、U+2028、U+2029)。

第二个用户可见的修复纠正了跨语言链接的重写:当一个非默认语言页面(例如 fr/index.md)写入指向默认语言前缀(例如 /en/)的链接时,构建过去会输出 <a href="/en/">,这会 404,因为默认语言页面实际位于根目录,而不是它自己的前缀下。修复方案在渲染后的 Markdown 中剥离默认语言前缀,使链接指向实际页面。

没有公共 API 变更,没有破坏性配置变更。纯粹是安全 + DX 版本。

🔒 安全:Threads 插件 </script> 逃逸(S-7)

Threads 插件用 fs.readFileSync 读取 docs/.threads/authors.json 并将原始文本插入到 <script> 体内。任何包含 </script> 的 author 字段都会闭合 script 标签,并在每一个使用 Threads 客户端的页面注入任意 HTML/JS。CWE-79 XSS,CVSS 8.1。

修复方案(位于 packages/plugins/threads/src/index.ts):

  1. authors.json 作为 JSON 解析(而非原始文本),从而对 payload 进行正确的字符串转义。
  2. 将顶层非对象值规约为 {}(运行时 schema 是纯对象)。
  3. 通过 @docmd/utilsscriptLiteral 重新序列化,该函数转义内联 <script> 块内不安全的四个字节序列:</script<!--、U+2028、U+2029。
  4. 解析失败时记录一次错误并回退到 {},而不是让构建因配置错误崩溃。

@docmd/utils 被添加为运行时依赖。回归探针位于 scripts/probe-threads-xss.mjs——它喂入攻击 payload(</script><script>、U+2028、非对象值)并断言输出中不含逃逸。

🔒 安全:init 模板生成的 JSON 无效(F9)

docmd init --yes 生成的默认 docmd.config.json 模板在 Agent Skills 导航条目和 Quick Guide 组之间漏掉了一个逗号。任何运行 init 的用户都会得到一个在位置 886 处 JSON 解析失败的文件。构建随后以 Error parsing config file: Expected ',' or ']' after array element 中止。

修复方案(位于 packages/core/src/commands/init.ts):对 defaultConfigContent 的单字符修正。init 演示模板、Docker 镜像的种子模板,以及每次新的 docmd init,现在在第一次运行时都会生成一个有效的配置。

🌐 跨语言链接重写(M-5)

当默认语言是 en 时,英语页面位于 /index.html/api/ 等处——/en/ 下。但非默认语言页面(例如 docs/fr/index.md)写 [English](/en/) 时,过去会输出 <a href="/en/">,这会 404,因为默认语言页面位于 / 而非 /en/hreflang 注释已经是正确的(0.8.10 修复);只有内联 Markdown 链接路径需要同样的处理。

修复方案(位于 packages/parser/src/markdown-processor.ts + packages/core/src/engine/generator.ts):

  1. 引擎通过现有的 env 对象,将 defaultLocaleallLocales 传递给 Markdown 后处理器。
  2. 新的 stripDefaultLocalePrefix() 辅助函数遍历渲染后 HTML 中的每个 <a href="/<defaultLocale>/..."> 并删除前缀,使 fr/ 页面中的 /en/foo 变成 /foo
  3. 该剥离操作在现有 #167 相对路径重写之前运行,因此两次处理都看到一个干净、正确的路径。
  4. 修复方案是语言 ID 感知的:它只匹配配置的默认语言,并且永远不会触及外部 URL、锚点、mailto:<img>src=

feature-integration.test.js 中添加了 Test 3.5(6 个断言),覆盖修复方案以及在线构建的无回归(无 index.html 后缀的干净 URL)。

📊 构建结束时的错误汇总(N-12)

此前用户看到 Build complete. Generated N pages in Tms,直到下游工具在错误的产物上崩溃时才偶然发现插件失败。现在每一个失败的插件都会在构建中止之前在 TUI 中按名称 + hook + 原因列出。

修复方案(位于 packages/core/src/commands/build.ts):

  1. 加载失败(getPluginLoadErrors())——每个失败的插件在 N plugin(s) could not be loaded TUI 错误块下列为 • <name> — <reason>
  2. 运行时 hook 失败(getPluginErrors())——每个插件错误在 Plugin errors during build TUI 错误块下列为 • <plugin> :: <hook> — <message>
  3. 构建仍然以退出码 1 结束(错误抛出后原始的 process.exit(1) 保留),但操作员现在在进程结束之前看到的是完整列表,而不仅仅是最后一个错误。

🔄 Docusaurus id / sidebar_label frontmatter 翻译(T-Z14 / T-Z17)

docmd migrate --docusaurus 此前原样复制 docs/ 目录,保留了所有 Docusaurus 特有的 frontmatter 键。docmd 的解析器不识别 id:(它从文件名派生路由 ID),也无法使用 sidebar_label:(它期望 nav_title: 用于导航覆盖)。迁移在技术上成功,但生成的配置在语义上是错误的。

修复方案(位于 packages/core/src/commands/migrate.ts):一个新的 translateDocusaurusFrontmatter() 辅助函数遍历已复制的 docs 目录,查找每个 .md / .mdx / .markdown 文件,并:

  1. 删除 id: —— docmd 从文件名派生路由,Docusaurus 的覆盖是无意义的。
  2. 翻译 sidebar_label:nav_title: —— docmd 一流的导航覆盖。

解析失败的文件保留其原始内容;TUI 一行报告被触及的文件数(例如 Translated Docusaurus frontmatter in 14 file(s))。翻译在每次 docmd migrate --docusaurus 调用时运行,包括 --dry-run 预览。

🐳 部署模板:node:20-alpinenode:22-alpine(N-5 / N-8)

部署生成器模板 packages/deployer/src/providers/docker.ts 固定在 node:20-alpine,尽管 Node 20 已于 2026-04-30 进入 End-of-Life。今天由 docmd deploy --docker 生成的新 Dockerfile 会在 EOL 基础镜像上构建。

修复方案:将模板升级到 node:22-alpine。已发布的 docker/Dockerfile(用于构建官方 ghcr.io/docmd-io/docmd 镜像)到下一个主版本发布前仍使用 node:20-alpine——部署模板可以领先于已发布的镜像,因为它是供用户定制的建议,而不是契约。

验证

  • pnpm --filter @docmd/parser build:通过
  • pnpm --filter @docmd/core build:通过
  • node tests/runner.js --skip-setup543 通过,0 失败(此前 537;feature-integration.test.js 中 M-5 回归测试 +6)
  • node tests/feature-integration.test.js143 通过,0 失败(此前 137)
  • node scripts/probe-threads-xss.mjs5/5 检查通过(XSS 回归探针)
  • 没有公共 API 变更,没有破坏性配置变更