✨ 亮点
本版本是一个安全与质量补丁,修复了 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):
- 将
authors.json作为 JSON 解析(而非原始文本),从而对 payload 进行正确的字符串转义。 - 将顶层非对象值规约为
{}(运行时 schema 是纯对象)。 - 通过
@docmd/utils的scriptLiteral重新序列化,该函数转义内联<script>块内不安全的四个字节序列:</script、<!--、U+2028、U+2029。 - 解析失败时记录一次错误并回退到
{},而不是让构建因配置错误崩溃。
@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):
- 引擎通过现有的
env对象,将defaultLocale和allLocales传递给 Markdown 后处理器。 - 新的
stripDefaultLocalePrefix()辅助函数遍历渲染后 HTML 中的每个<a href="/<defaultLocale>/...">并删除前缀,使fr/页面中的/en/foo变成/foo。 - 该剥离操作在现有
#167相对路径重写之前运行,因此两次处理都看到一个干净、正确的路径。 - 修复方案是语言 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):
- 加载失败(
getPluginLoadErrors())——每个失败的插件在N plugin(s) could not be loadedTUI 错误块下列为• <name> — <reason>。 - 运行时 hook 失败(
getPluginErrors())——每个插件错误在Plugin errors during buildTUI 错误块下列为• <plugin> :: <hook> — <message>。 - 构建仍然以退出码 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 文件,并:
- 删除
id:—— docmd 从文件名派生路由,Docusaurus 的覆盖是无意义的。 - 翻译
sidebar_label:→nav_title:—— docmd 一流的导航覆盖。
解析失败的文件保留其原始内容;TUI 一行报告被触及的文件数(例如 Translated Docusaurus frontmatter in 14 file(s))。翻译在每次 docmd migrate --docusaurus 调用时运行,包括 --dry-run 预览。
🐳 部署模板:node:20-alpine → node: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-setup:543 通过,0 失败(此前 537;feature-integration.test.js 中 M-5 回归测试 +6)node tests/feature-integration.test.js:143 通过,0 失败(此前 137)node scripts/probe-threads-xss.mjs:5/5 检查通过(XSS 回归探针)- 没有公共 API 变更,没有破坏性配置变更