✨ 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):
- Parse
authors.jsonas JSON (not raw text) so the payload gets proper string escaping. - Coerce non-object top-level values to
{}(the runtime schema is a plain object). - Re-serialise through
scriptLiteralfrom@docmd/utils, which escapes the four byte sequences that are unsafe inside an inline<script>block:</script,<!--, U+2028, U+2029. - 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.
🌐 Cross-locale link rewrite (M-5)
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):
- Engine passes
defaultLocaleandallLocalesto the markdown post-processor through the existingenvobject. - A new
stripDefaultLocalePrefix()helper walks every<a href="/<defaultLocale>/...">in the rendered HTML and drops the prefix, so/en/foofrom afr/page becomes/foo. - The strip runs before the existing
#167relative-path rewrite, so both passes see a clean, correct path. - The fix is locale-id-aware: it only matches the configured default locale, and it never touches external URLs, anchors,
mailto:, orsrc=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):
- Load failures (
getPluginLoadErrors()) — each failed plugin is listed as• <name> — <reason>under aN plugin(s) could not be loadedTUI error block. - Runtime hook failures (
getPluginErrors()) — every plugin error is listed as• <plugin> :: <hook> — <message>under aPlugin errors during buildTUI error block. - 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:
- Drops
id:— docmd derives the route from the filename, so the Docusaurus override is meaningless. - 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-alpine → node: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: cleanpnpm --filter @docmd/core build: cleannode 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