✨ Highlights

This release is a security and quality patch addressing six non-breaking issues from the v0.8.6 battle-test report. The most important change is a stored XSS in the threads plugin — a malicious authors.json could escape the <script> block and execute arbitrary JavaScript on every page that loads the threads client. The fix parses the file as JSON and re-serialises through a scriptLiteral helper that escapes the four byte sequences the HTML parser treats specially (</script, <!--, U+2028, U+2029).

A second user-visible fix corrects the cross-locale link rewrite: when a non-default-locale page (e.g. fr/index.md) writes a link to the default-locale prefix (e.g. /en/), the build used to emit <a href="/en/"> which 404s because the default-locale page actually lives at root, not under its own prefix. The fix strips the default-locale prefix from absolute hrefs in the rendered markdown so the link points to the actual page.

No public API changes. No breaking config changes. Purely a security + DX release.

🔒 Security: threads plugin </script> breakout (S-7)

The threads plugin read docs/.threads/authors.json with fs.readFileSync and interpolated the raw text into a <script> body. Any author field containing </script> would close the script tag and inject arbitrary HTML/JS into every page that uses the threads client. CWE-79 XSS, CVSS 8.1.

The fix (in packages/plugins/threads/src/index.ts):

  1. Parse authors.json as JSON (not raw text) so the payload gets proper string escaping.
  2. Coerce non-object top-level values to {} (the runtime schema is a plain object).
  3. Re-serialise through scriptLiteral from @docmd/utils, which escapes the four byte sequences that are unsafe inside an inline <script> block: </script, <!--, U+2028, U+2029.
  4. Log a single error and fall back to {} on parse failure rather than letting the build crash on a malformed config.

Added @docmd/utils as a runtime dependency. A regression probe lives at scripts/probe-threads-xss.mjs — it feeds attacker payloads (</script><script>, U+2028, non-object values) and asserts the output contains no breakout.

🔒 Security: init template generated invalid JSON (F9)

The default docmd.config.json template produced by docmd init --yes was missing a comma between the Agent Skills navigation entry and the Quick Guide group. Any user who ran init got a config file that failed JSON parse at position 886. The build then aborted with Error parsing config file: Expected ',' or ']' after array element.

The fix (in packages/core/src/commands/init.ts): single-character correction to defaultConfigContent. The init demo template, the Docker image’s seeded template, and every fresh docmd init now produce a valid config on the first run.

When the default locale is en, English pages live at /index.html, /api/, etc. — not at /en/. But a non-default-locale page (e.g. docs/fr/index.md) writing [English](/en/) produced <a href="/en/"> which 404s, because the default-locale page is at / not /en/. The hreflang annotations were already correct (0.8.10 fix); only the inline markdown link path needed the same treatment.

The fix (in packages/parser/src/markdown-processor.ts + packages/core/src/engine/generator.ts):

  1. Engine passes defaultLocale and allLocales to the markdown post-processor through the existing env object.
  2. A new stripDefaultLocalePrefix() helper walks every <a href="/<defaultLocale>/..."> in the rendered HTML and drops the prefix, so /en/foo from a fr/ page becomes /foo.
  3. The strip runs before the existing #167 relative-path rewrite, so both passes see a clean, correct path.
  4. The fix is locale-id-aware: it only matches the configured default locale, and it never touches external URLs, anchors, mailto:, or src= on <img>.

Adds Test 3.5 in feature-integration.test.js (6 assertions) covering both the fix and the no-regression for the online build (clean URLs without index.html suffix).

📊 End-of-build error summary (N-12)

Previously the user saw Build complete. Generated N pages in Tms and only later discovered plugin failures when a downstream tool choked on a bad artifact. Now every failed plugin is listed by name + hook + reason in the TUI before the build aborts.

The fix (in packages/core/src/commands/build.ts):

  1. Load failures (getPluginLoadErrors()) — each failed plugin is listed as • <name> — <reason> under a N plugin(s) could not be loaded TUI error block.
  2. Runtime hook failures (getPluginErrors()) — every plugin error is listed as • <plugin> :: <hook> — <message> under a Plugin errors during build TUI error block.
  3. The build still exits 1 (the original process.exit(1) after error throw is preserved) but the operator now sees the full list before the process dies, not just the last error.

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

docmd migrate --docusaurus used to copy the docs/ directory verbatim, leaving every Docusaurus-specific frontmatter key in place. docmd’s parser didn’t recognise id: (it derives route IDs from filenames) and had no way to use sidebar_label: (it expects nav_title: for navigation overrides). The migration was technically successful but produced a config that was semantically wrong.

The fix (in packages/core/src/commands/migrate.ts): a new translateDocusaurusFrontmatter() helper walks the copied docs directory, finds every .md / .mdx / .markdown file, and:

  1. Drops id: — docmd derives the route from the filename, so the Docusaurus override is meaningless.
  2. Translates sidebar_label:nav_title: — docmd’s first-class nav override.

Files that fail to parse keep their original content; a TUI line reports the count of files touched (e.g. Translated Docusaurus frontmatter in 14 file(s)). The translation runs on every docmd migrate --docusaurus invocation including --dry-run previews.

🐳 Deploy template: node:20-alpinenode:22-alpine (N-5 / N-8)

The deploy generator template packages/deployer/src/providers/docker.ts was pinned to node:20-alpine even though Node 20 went End-of-Life on 2026-04-30. New Dockerfiles generated today by docmd deploy --docker would have been built on an EOL base image.

The fix: bump the template to node:22-alpine. The published docker/Dockerfile (used to build the official ghcr.io/docmd-io/docmd image) is still on node:20-alpine until the next major release — the deploy template can lead the published image because it’s a suggestion for users to customise, not a contract.

Verification

  • pnpm --filter @docmd/parser build: clean
  • pnpm --filter @docmd/core build: clean
  • node tests/runner.js --skip-setup: 543 passed, 0 failed (was 537; +6 from M-5 regression tests in feature-integration.test.js)
  • node tests/feature-integration.test.js: 143 passed, 0 failed (was 137)
  • node scripts/probe-threads-xss.mjs: 5/5 checks pass (XSS regression probe)
  • No public API changes, no breaking config changes