diff --git a/package.json b/package.json index 871dab5a..84221932 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/monorepo", - "version": "0.8.10", + "version": "0.8.11", "private": true, "description": "The minimalist, zero-config documentation generator monorepo.", "scripts": { diff --git a/packages/_playground/package.json b/packages/_playground/package.json index a86cc552..d8e5d8a3 100644 --- a/packages/_playground/package.json +++ b/packages/_playground/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/playground", - "version": "0.8.10", + "version": "0.8.11", "private": true, "dependencies": { "@docmd/core": "workspace:*", diff --git a/packages/api/package.json b/packages/api/package.json index ded1d1e6..82b62424 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/api", - "version": "0.8.10", + "version": "0.8.11", "description": "Plugin API surface for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/api/registry/plugins.generated.json b/packages/api/registry/plugins.generated.json index 2c337d0b..199a0eaf 100644 --- a/packages/api/registry/plugins.generated.json +++ b/packages/api/registry/plugins.generated.json @@ -1,6 +1,6 @@ { "$meta": { - "generatedAt": "2026-07-09T07:41:19.155Z", + "generatedAt": "2026-07-11T05:18:59.145Z", "generator": "scripts/build-plugin-registry.mjs", "packageCount": 15 }, @@ -121,6 +121,7 @@ "tagline": "Offline full-text search for docmd.", "capabilities": [ "post-build", + "init", "head", "body", "assets", diff --git a/packages/core/package.json b/packages/core/package.json index 55e9a62a..c68109a3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/core", - "version": "0.8.10", + "version": "0.8.11", "description": "Build production-ready documentation from Markdown in seconds. No React, no bloat, just content.", "type": "module", "browser": false, diff --git a/packages/core/src/commands/build.ts b/packages/core/src/commands/build.ts index 319d6149..5fa9e59e 100644 --- a/packages/core/src/commands/build.ts +++ b/packages/core/src/commands/build.ts @@ -109,8 +109,16 @@ export async function buildSite(configPath: string, opts: any = {}) { // check, `docmd build` exits 0 even when a plugin is missing — the // site still builds but the user has no way to know a plugin was // dropped unless they read the warning text. + // N-12: list each failed plugin by name and reason in the TUI, so + // the operator doesn't have to dig through the error message string. const loadErrors = getPluginLoadErrors(); if (loadErrors.length > 0) { + if (!options.isDev && !options.quiet) { + TUI.error(`${loadErrors.length} plugin(s) could not be loaded`, ''); + for (const e of loadErrors) { + console.error(` ${TUI.red('•')} ${e.plugin} — ${e.message}`); + } + } const lines = loadErrors.map((e) => `${e.plugin}: ${e.message}`); throw new Error( `Build failed: ${loadErrors.length} plugin(s) could not be loaded:\n` + @@ -383,6 +391,18 @@ export async function buildSite(configPath: string, opts: any = {}) { const { getPluginErrors } = await import('@docmd/api'); const errors = getPluginErrors(); if (errors.length > 0) { + // N-12: surface every plugin error in one place. Previously the + // user saw "Build complete" and only later discovered failures when + // a generated page was missing or a downstream tool choked on a + // bad artifact. The summary lists every error with its plugin + + // hook, so the operator can grep for any of them. + if (!options.isDev && !options.quiet) { + TUI.error('Plugin errors during build', ''); + for (const err of errors) { + const filePart = err.filePath ? ` (${err.filePath})` : ''; + console.error(` ${TUI.red('•')} ${err.plugin} :: ${err.hook}${filePart} — ${err.message}`); + } + } throw new Error(`Build failed: ${errors.length} plugin error(s) occurred during execution.`); } diff --git a/packages/core/src/commands/init.ts b/packages/core/src/commands/init.ts index 54c91c53..0ee2c2c9 100644 --- a/packages/core/src/commands/init.ts +++ b/packages/core/src/commands/init.ts @@ -60,7 +60,7 @@ const defaultConfigContent = `{ "pageNavigation": true, "navigation": [ { "title": "Quick Start", "path": "/", "icon": "zap" }, - { "title": "Agent Skills", "path": "/skills/", "icon": "brain-circuit" } + { "title": "Agent Skills", "path": "/skills/", "icon": "brain-circuit" }, { "title": "Quick Guide", "icon": "book-open", diff --git a/packages/core/src/commands/migrate.ts b/packages/core/src/commands/migrate.ts index 941f27c5..d20afc6c 100644 --- a/packages/core/src/commands/migrate.ts +++ b/packages/core/src/commands/migrate.ts @@ -140,6 +140,71 @@ export async function migrateProject(options: { docusaurus?: boolean; mkdocs?: b return root.length > 0 ? root : null; }; + /** + * T-Z14 / T-Z17: walk a freshly-copied docs directory and translate + * Docusaurus-specific frontmatter keys to their docmd equivalents. + * + * `id:` → removed (docmd derives route ids from filenames) + * `sidebar_label:`→ `nav_title:` (the docmd-supported nav override) + * + * The translation is best-effort: a file that fails to parse keeps + * its original content. Returns the count of files touched. + */ + const translateDocusaurusFrontmatter = async (docsDir: string): Promise => { + const targets: string[] = []; + const walk = async (dir: string): Promise => { + const entries = await nativeFs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + await walk(full); + } else if (entry.isFile() && /\.(md|mdx|markdown)$/i.test(entry.name)) { + targets.push(full); + } + } + }; + await walk(docsDir); + + let count = 0; + for (const file of targets) { + const raw = await nativeFs.promises.readFile(file, 'utf8'); + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) continue; + // Line-by-line parse: we only care about top-level `key: value` + // lines. Docusaurus lets these be unquoted, single-quoted, or + // double-quoted. Nested objects (e.g. `sidebar:` with sub-keys) + // are preserved as-is. + const lines = m[1].split('\n'); + let changed = false; + const newLines = lines.map((line) => { + const stripped = line.replace(/\r$/, ''); + const idMatch = stripped.match(/^\s*id\s*:\s*(.*?)\s*$/); + if (idMatch) { changed = true; return null; } + const slMatch = stripped.match(/^(\s*)sidebar_label\s*:\s*(.*?)\s*$/); + if (slMatch) { + changed = true; + const indent = slMatch[1]; + let val = slMatch[2]; + // Strip wrapping quotes if present so the rewritten value + // matches the original document's quote style as closely as + // possible. docmd accepts both forms. + const qm = val.match(/^['"](.*)['"]$/); + if (qm) val = qm[1]; + return `${indent}nav_title: ${val}`; + } + return stripped; + }).filter((l) => l !== null) as string[]; + + if (!changed) continue; + const newFm = newLines.join('\n'); + const newContent = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, `---\n${newFm}\n---`); + await nativeFs.promises.writeFile(file, newContent); + count++; + } + return count; + }; + if (options.docusaurus) { TUI.section('Docusaurus Migration'); const configPath = path.resolve(CWD, 'docusaurus.config.js'); @@ -190,6 +255,23 @@ export async function migrateProject(options: { docusaurus?: boolean; mkdocs?: b TUI.step('Created new docs directory', 'DONE'); } + // T-Z14 / T-Z17: translate Docusaurus-specific frontmatter keys. + // docmd derives route ids from filenames, so Docusaurus `id:` is + // dropped. `sidebar_label:` is preserved as `nav_title:` (a + // docmd-supported override of the auto-generated nav title). + // The translation is best-effort: files that fail to parse keep + // their original frontmatter, and a TUI line reports the count. + let fmTranslated = 0; + try { + const translated = await translateDocusaurusFrontmatter(newDocsDir); + fmTranslated = translated; + } catch (err: any) { + TUI.warn(`Frontmatter translation skipped: ${err.message}`); + } + if (fmTranslated > 0) { + TUI.step(`Translated Docusaurus frontmatter in ${fmTranslated} file(s)`, 'DONE'); + } + await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig)); TUI.step('Generated docmd.config.js', 'DONE'); diff --git a/packages/core/src/engine/generator.ts b/packages/core/src/engine/generator.ts index 48a6b783..ed1c8ef4 100644 --- a/packages/core/src/engine/generator.ts +++ b/packages/core/src/engine/generator.ts @@ -483,6 +483,12 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h // #167: offline-mode + relative-path context for the markdown post-processor. isOfflineMode: options.offline === true, relativePathToRoot: relativePathToRootForMarkdown, + // M-5: tell the post-processor which locale is the default so it + // can strip the default-locale prefix from absolute hrefs + // (e.g. `/en/foo` from a `fr/` page → `/foo`, since the default + // locale lives at root, not under its own prefix). + defaultLocale: config._defaultLocale || null, + allLocales: (config._allLocales || []).map((l: any) => l.id), config: { base: config.base || '/' } }, hooks diff --git a/packages/deployer/package.json b/packages/deployer/package.json index ad52ba5d..f4d79a90 100644 --- a/packages/deployer/package.json +++ b/packages/deployer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/deployer", - "version": "0.8.10", + "version": "0.8.11", "description": "Deployment configuration generator for docmd - Docker, Nginx, Caddy, GitHub Pages, Vercel, and Netlify.", "type": "module", "main": "dist/index.js", diff --git a/packages/deployer/src/providers/docker.ts b/packages/deployer/src/providers/docker.ts index 6cd2e4e3..11f0b2dd 100644 --- a/packages/deployer/src/providers/docker.ts +++ b/packages/deployer/src/providers/docker.ts @@ -24,7 +24,7 @@ export async function generateDocker(ctx: DeployContext): Promise<{ dockerfile: # --------------------------------------------------- # Stage 1: Build the docmd site -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app # Copy dependency manifests first for optimal layer caching diff --git a/packages/engines/js/package.json b/packages/engines/js/package.json index 9c280176..b81c403d 100644 --- a/packages/engines/js/package.json +++ b/packages/engines/js/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/engine-js", - "version": "0.8.10", + "version": "0.8.11", "description": "Default JavaScript engine for docmd using Node.js native APIs.", "type": "module", "main": "dist/index.js", diff --git a/packages/engines/js/src/index.ts b/packages/engines/js/src/index.ts index 06b94b7e..f0854be5 100644 --- a/packages/engines/js/src/index.ts +++ b/packages/engines/js/src/index.ts @@ -275,7 +275,7 @@ const handlers: Record = { export function createJsEngine(): Engine { return { name: 'js', - version: '0.8.10', + version: '0.8.11', supports(taskType: string): boolean { return taskType in handlers; diff --git a/packages/engines/rust-binaries/bin/docmd-engine-darwin-arm64.node b/packages/engines/rust-binaries/bin/docmd-engine-darwin-arm64.node index 498b48cd..9fd0cdf3 100755 Binary files a/packages/engines/rust-binaries/bin/docmd-engine-darwin-arm64.node and b/packages/engines/rust-binaries/bin/docmd-engine-darwin-arm64.node differ diff --git a/packages/engines/rust-binaries/native/Cargo.lock b/packages/engines/rust-binaries/native/Cargo.lock index 3fc3785b..cd89a9ce 100644 --- a/packages/engines/rust-binaries/native/Cargo.lock +++ b/packages/engines/rust-binaries/native/Cargo.lock @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "docmd-engine" -version = "0.8.10" +version = "0.8.11" dependencies = [ "napi", "napi-build", diff --git a/packages/engines/rust-binaries/native/Cargo.toml b/packages/engines/rust-binaries/native/Cargo.toml index a6f6ce21..ec3ff485 100644 --- a/packages/engines/rust-binaries/native/Cargo.toml +++ b/packages/engines/rust-binaries/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "docmd-engine" -version = "0.8.10" +version = "0.8.11" edition = "2021" description = "Native Node.js addon for the docmd Rust engine" license = "MIT" diff --git a/packages/engines/rust-binaries/package.json b/packages/engines/rust-binaries/package.json index 3459c4a5..3cd5aa4b 100644 --- a/packages/engines/rust-binaries/package.json +++ b/packages/engines/rust-binaries/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/engine-rust-binaries", - "version": "0.8.10", + "version": "0.8.11", "description": "Pre-built Rust binaries for the docmd engine. All platforms bundled.", "scripts": { "build": "node build.js" diff --git a/packages/engines/rust/package.json b/packages/engines/rust/package.json index 8f428c1d..b336a618 100644 --- a/packages/engines/rust/package.json +++ b/packages/engines/rust/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/engine-rust", - "version": "0.8.10", + "version": "0.8.11", "description": "Rust-accelerated engine for docmd. The postinstall script downloads a pre-built platform binary from npm CDN (unpkg/jsdelivr). No code is executed beyond copying the .node file. Source: https://github.com/docmd-io/docmd", "type": "module", "main": "dist/index.js", diff --git a/packages/engines/rust/src/index.ts b/packages/engines/rust/src/index.ts index fdcca722..d99b3cf6 100644 --- a/packages/engines/rust/src/index.ts +++ b/packages/engines/rust/src/index.ts @@ -129,7 +129,7 @@ export function createRustEngine(): Engine { return { name: 'rust', - version: '0.8.10', + version: '0.8.11', supports(_taskType: string): boolean { return true; diff --git a/packages/legacy/doc.md/package.json b/packages/legacy/doc.md/package.json index d8aa8d66..2aa2b782 100644 --- a/packages/legacy/doc.md/package.json +++ b/packages/legacy/doc.md/package.json @@ -1,6 +1,6 @@ { "name": "doc.md", - "version": "0.8.10", + "version": "0.8.11", "description": "Discontinued. Legacy wrapper for docmd. Please use @docmd/core.", "bin": { "docmd": "bin/docmd.js" diff --git a/packages/legacy/mgks/package.json b/packages/legacy/mgks/package.json index 8d9488af..04930f1c 100644 --- a/packages/legacy/mgks/package.json +++ b/packages/legacy/mgks/package.json @@ -1,6 +1,6 @@ { "name": "@mgks/docmd", - "version": "0.8.10", + "version": "0.8.11", "description": "Discontinued. Legacy wrapper for docmd. Please use @docmd/core.", "bin": { "docmd": "bin/docmd.js" diff --git a/packages/live/package.json b/packages/live/package.json index b09793b3..bd058088 100644 --- a/packages/live/package.json +++ b/packages/live/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/live", - "version": "0.8.10", + "version": "0.8.11", "description": "Browser-based editor engine for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index eca564a5..adf87d51 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/parser", - "version": "0.8.10", + "version": "0.8.11", "description": "Pure Markdown parsing engine for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/parser/src/markdown-processor.ts b/packages/parser/src/markdown-processor.ts index 3527b4c7..56b71cf7 100644 --- a/packages/parser/src/markdown-processor.ts +++ b/packages/parser/src/markdown-processor.ts @@ -462,17 +462,17 @@ async function processContentAsync(rawString: string, mdInstance: any, config: a } let htmlContent = mdInstance.render(markdownContent, env); - - // #167 (permanent fix): in offline mode, rewrite every internal href in - // the rendered markdown so the output works in file://, HTTP servers, and - // custom domains identically. The button container (`:::`) already goes - // through `fixHtmlLinks` via the template helper, but the markdown-rendered - // HTML never did — that's why `` survived into the - // offline HTML and broke `file://` navigation. The post-process pass uses - // the same `fixHtmlLinks` logic so the two paths stay aligned. Non-offline - // builds are unchanged (clean URLs preserved). - if (env && env.isOfflineMode === true) { - htmlContent = rewriteInternalHrefsForOffline(htmlContent, env.relativePathToRoot || './', env.config?.base || '/'); + if (env) { + htmlContent = stripDefaultLocalePrefix(htmlContent, env.defaultLocale, env.allLocales, env.relativePathToRoot || './'); + if (env.isOfflineMode === true) { + htmlContent = rewriteInternalHrefsForOffline(htmlContent, env.relativePathToRoot || './', env.config?.base || '/'); + } else { + // M-5 also affects online builds: `fr/index.html` linking to + // `/en/` is a 404 on HTTP servers too, not just file://. Run the + // same fixHtmlLinks pass (without the offline-specific `index.html` + // suffix) so cross-locale paths stay clean and correct. + htmlContent = fixHtmlLinks(htmlContent, env.relativePathToRoot || './', env.config?.base || '/'); + } } if (hooks && hooks.onAfterParse) { @@ -495,6 +495,33 @@ async function processContentAsync(rawString: string, mdInstance: any, config: a return { frontmatter, htmlContent, headings, searchData }; } +/** + * M-5: strip the default-locale prefix from absolute hrefs in the rendered + * HTML. The default locale lives at root, not under its own prefix, so a + * link like `/en/foo` from a non-default-locale page is a 404 unless the + * prefix is dropped. We only run this when the source page is in a + * non-default locale (which is when the default-locale prefix could + * actually appear in author-written links). + * + * The function only touches absolute paths under root, only when the + * first segment matches the default locale id, and never touches + * external URLs / anchors / mailto. + */ +function stripDefaultLocalePrefix(html: string, defaultLocale: string | null, allLocales: string[] | undefined, _relativePathToRoot: string): string { + if (!defaultLocale || !allLocales || allLocales.length < 2) return html; + // Escape regex special chars in the locale id (e.g. "en-us" with hyphen). + const escaped = defaultLocale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Match `` and `` where xx is the + // default locale. We do NOT touch `src=` (img) because images are + // typically under /assets/ and never locale-prefixed. We do NOT touch + // bare `/xx` at end of attribute (rare author pattern) — that case is + // ambiguous and the user can write `/` instead. + const re = new RegExp(`(]*?\\bhref\\s*=\\s*)(["'])(\\/${escaped}\\/)([^"'#]*)\\2`, 'gi'); + return html.replace(re, (_full, prefix, quote, _stripped, rest) => { + return `${prefix}${quote}/${rest}${quote}`; + }); +} + /** * Walk every `` and `` in a piece of rendered * HTML and rewrite internal links for offline mode. External URLs (http, diff --git a/packages/plugins/analytics/package.json b/packages/plugins/analytics/package.json index 07c990f0..ae750c47 100644 --- a/packages/plugins/analytics/package.json +++ b/packages/plugins/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-analytics", - "version": "0.8.10", + "version": "0.8.11", "description": "Analytics injection plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/analytics/src/index.ts b/packages/plugins/analytics/src/index.ts index aac4eef3..1dffdd16 100644 --- a/packages/plugins/analytics/src/index.ts +++ b/packages/plugins/analytics/src/index.ts @@ -21,7 +21,7 @@ import { scriptLiteral } from '@docmd/utils'; export const plugin: PluginDescriptor = { name: 'analytics', - version: '0.8.10', + version: '0.8.11', capabilities: ['head', 'body'] }; diff --git a/packages/plugins/git/package.json b/packages/plugins/git/package.json index 78d650cf..110282e9 100644 --- a/packages/plugins/git/package.json +++ b/packages/plugins/git/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-git", - "version": "0.8.10", + "version": "0.8.11", "description": "Git integration plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/git/src/index.ts b/packages/plugins/git/src/index.ts index 1b29d325..a6e5abbf 100644 --- a/packages/plugins/git/src/index.ts +++ b/packages/plugins/git/src/index.ts @@ -290,7 +290,7 @@ async function getGitFileInfo(filePath: string, maxCommits: number = 6): Promise export const plugin: PluginDescriptor = { name: 'git', - version: '0.8.10', + version: '0.8.11', capabilities: ['build', 'body', 'assets', 'translations', 'init', 'post-build'] }; diff --git a/packages/plugins/installer/package.json b/packages/plugins/installer/package.json index 441ce0bd..9bb4c69e 100644 --- a/packages/plugins/installer/package.json +++ b/packages/plugins/installer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-installer", - "version": "0.8.10", + "version": "0.8.11", "description": "Installer utility to add and remove plugins for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/llms/package.json b/packages/plugins/llms/package.json index 842fa535..6ef399a9 100644 --- a/packages/plugins/llms/package.json +++ b/packages/plugins/llms/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-llms", - "version": "0.8.10", + "version": "0.8.11", "description": "Generate llms.txt context files for AI agents from your docmd site.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/llms/src/index.ts b/packages/plugins/llms/src/index.ts index a157d5af..a5cdeba3 100644 --- a/packages/plugins/llms/src/index.ts +++ b/packages/plugins/llms/src/index.ts @@ -19,7 +19,7 @@ import { outputPathToPathname, sanitizeUrl } from '@docmd/api'; export const plugin: PluginDescriptor = { name: 'llms', - version: '0.8.10', + version: '0.8.11', capabilities: ['post-build'] }; diff --git a/packages/plugins/math/package.json b/packages/plugins/math/package.json index 50be7008..96addb73 100644 --- a/packages/plugins/math/package.json +++ b/packages/plugins/math/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-math", - "version": "0.8.10", + "version": "0.8.11", "description": "Mathematics (KaTeX/LaTeX) plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/math/src/index.ts b/packages/plugins/math/src/index.ts index 147181d9..82dd602c 100644 --- a/packages/plugins/math/src/index.ts +++ b/packages/plugins/math/src/index.ts @@ -18,7 +18,7 @@ import type { PluginDescriptor } from '@docmd/api'; export const plugin: PluginDescriptor = { name: 'math', - version: '0.8.10', + version: '0.8.11', capabilities: ['markdown', 'assets'] }; diff --git a/packages/plugins/mermaid/package.json b/packages/plugins/mermaid/package.json index 368f4b4d..93d0e44b 100644 --- a/packages/plugins/mermaid/package.json +++ b/packages/plugins/mermaid/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-mermaid", - "version": "0.8.10", + "version": "0.8.11", "description": "Mermaid diagram support plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/mermaid/src/index.ts b/packages/plugins/mermaid/src/index.ts index c1b46aed..4f4ac24c 100644 --- a/packages/plugins/mermaid/src/index.ts +++ b/packages/plugins/mermaid/src/index.ts @@ -21,7 +21,7 @@ const __dirname = path.dirname(__filename); export const plugin: PluginDescriptor = { name: 'mermaid', - version: '0.8.10', + version: '0.8.11', capabilities: ['markdown', 'assets'] }; diff --git a/packages/plugins/okf/package.json b/packages/plugins/okf/package.json index 557ce65c..48bcca85 100644 --- a/packages/plugins/okf/package.json +++ b/packages/plugins/okf/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-okf", - "version": "0.8.10", + "version": "0.8.11", "description": "Generate an Open Knowledge Format (OKF) bundle from your docmd site for AI agent consumption.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/okf/src/index.ts b/packages/plugins/okf/src/index.ts index 68cbe704..76cb1cde 100644 --- a/packages/plugins/okf/src/index.ts +++ b/packages/plugins/okf/src/index.ts @@ -45,7 +45,7 @@ import { GRAPH_CSS, GRAPH_JS, graphHtml } from './graph-assets.js'; export const plugin: PluginDescriptor = { name: 'okf', - version: '0.8.10', + version: '0.8.11', capabilities: ['post-build'] }; @@ -223,7 +223,7 @@ export async function onPostBuild({ config, pages, outputDir, log }: any) { for (const c of concepts) byType[c.type] = (byType[c.type] || 0) + 1; const manifest = { - okf_version: '0.8.10', + okf_version: '0.8.11', bundle: { name: bundleName, title: config.title || bundleName, description: config.description || '', url: config.url || '', generated_by: '@docmd/plugin-okf', generated_at: new Date().toISOString(), diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 23387f3e..784022a6 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-openapi", - "version": "0.8.10", + "version": "0.8.11", "description": "OpenAPI documentation generator plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/openapi/src/index.ts b/packages/plugins/openapi/src/index.ts index cc7cd1ec..4aaab8ce 100644 --- a/packages/plugins/openapi/src/index.ts +++ b/packages/plugins/openapi/src/index.ts @@ -24,7 +24,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const plugin: PluginDescriptor = { name: 'openapi', - version: '0.8.10', + version: '0.8.11', capabilities: ['markdown', 'assets'] }; diff --git a/packages/plugins/pwa/package.json b/packages/plugins/pwa/package.json index 5b2dec79..fd3f084a 100644 --- a/packages/plugins/pwa/package.json +++ b/packages/plugins/pwa/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-pwa", - "version": "0.8.10", + "version": "0.8.11", "description": "Progressive Web App (PWA) plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/pwa/src/index.ts b/packages/plugins/pwa/src/index.ts index 7ffa81ac..a016e678 100644 --- a/packages/plugins/pwa/src/index.ts +++ b/packages/plugins/pwa/src/index.ts @@ -19,7 +19,7 @@ import { attrEsc } from '@docmd/utils'; export const plugin: PluginDescriptor = { name: 'pwa', - version: '0.8.10', + version: '0.8.11', capabilities: ['post-build', 'head', 'body'] }; diff --git a/packages/plugins/search/package.json b/packages/plugins/search/package.json index 06bd1cd3..8a0dc3c1 100644 --- a/packages/plugins/search/package.json +++ b/packages/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-search", - "version": "0.8.10", + "version": "0.8.11", "description": "Offline full-text search for docmd.", "type": "module", "main": "dist/index.js", @@ -12,6 +12,7 @@ "tagline": "Offline full-text search for docmd.", "capabilities": [ "post-build", + "init", "head", "body", "assets", diff --git a/packages/plugins/search/src/client.ts b/packages/plugins/search/src/client.ts index 21bca297..008cc001 100644 --- a/packages/plugins/search/src/client.ts +++ b/packages/plugins/search/src/client.ts @@ -181,10 +181,26 @@ declare const MiniSearch: any; // 3. Index Loading - fetches locale-specific index async function loadIndex() { - const isSemantic = searchModal.dataset.semantic === 'true'; + // Auto-detect semantic search at runtime. We check BOTH: + // - The build-time hint (data-semantic="true") — set when the + // build pipeline knew semantic was available at render time. + // - A runtime probe (HEAD request to manifest.json) — catches + // the first-build case where deps were installed in onPostBuild + // (after generateScripts already rendered the page without + // data-semantic). The probe is a single network round-trip + // that only runs once per page load, so there's no perf cost. + const hasBuildHint = searchModal.dataset.semantic === 'true'; + let useSemantic = hasBuildHint; + + if (!useSemantic) { + try { + const probe = await fetch(`${siteBase}.docmd-search/manifest.json`, { method: 'HEAD' }); + if (probe.ok) useSemantic = true; + } catch { /* no index → keyword */ } + } try { - if (isSemantic) { + if (useSemantic) { // ── Semantic search path ────────────────────────────────── const ctx: SemanticSearch.SemanticSearchContext = { siteBase, diff --git a/packages/plugins/search/src/index.ts b/packages/plugins/search/src/index.ts index 6006af62..4a5c1e6a 100644 --- a/packages/plugins/search/src/index.ts +++ b/packages/plugins/search/src/index.ts @@ -28,13 +28,108 @@ const require = createRequire(import.meta.url); export const plugin: PluginDescriptor = { name: 'search', - version: '0.8.10', - capabilities: ['post-build', 'head', 'body', 'assets', 'translations'] + version: '0.8.11', + // `init` lets onConfigResolved run at config-parse time — that's where we + // compute the single `searchConfig` object. The build pipeline reads + // it from `config._searchConfig` everywhere else, so there's exactly + // one source of truth for "is semantic available at build time". + capabilities: ['post-build', 'init', 'head', 'body', 'assets', 'translations'] }; // Resolve i18n directory (sibling to dist/ in the package) const i18nDir = path.resolve(__dirname, '..', 'i18n'); +/* ── Search config (computed once at onConfigResolved) ─────────────────── */ + +/** + * Resolved search configuration. Computed once per build, in + * `onConfigResolved`, and stamped on `config._searchConfig`. Every + * subsequent hook (`onPostBuild`, `getAssets`, `generateScripts`, + * EJS templates via the page context) reads from this same object — + * no second source of truth, no chance of the `getAssets` and the + * `generateScripts` paths disagreeing about whether semantic is + * available. The shape is the contract for the search-modal EJS + * partial too: every key on this object is exposed to the template + * via `locals.searchConfig`. + */ +type SearchConfig = { + /** User has not opted out of search entirely (optionsMenu / config.search). */ + enabled: boolean; + /** User has explicitly requested semantic search in their config. */ + semanticRequested: boolean; + /** docmd-search is resolvable in the user's project. */ + docmdSearchInstalled: boolean; + /** All peer deps (transformers + onnxruntime-node) are resolvable. */ + peersInstalled: boolean; + /** True only when all three of the above are true — that's when the modal + * emits `data-semantic="true"`, the .docmd-search-client.js asset ships, + * and the page points users at the semantic index. */ + semanticUsable: boolean; + /** The exact names of any peer deps that are missing. Empty when + * `peersInstalled` is true. Useful for the TUI: "Missing: transformers, onnxruntime-node". */ + missingPeers: string[]; + /** Stable list of peer-dep package names. Exposed so the TUI can emit + * per-dep `[ DONE ] @huggingface/transformers` lines without + * hard-coding the names in two places. */ + peerDeps: readonly string[]; +}; + +/** + * Module-level state. Reset on every `onConfigResolved` call so a + * dev-server rebuild starts fresh. Currently tracks: + * + * - `lastPeersHash` — when the set of "missing peer deps" is + * identical to the previous build, we DON'T re-emit the + * "[ DONE ] Peer X ready" lines. The user only sees them on the + * first build (or the first build after a new dep becomes missing). + * This is the difference between "informative on first install" and + * "spammy on every rebuild". + */ +const _searchState: { + lastPeersHash: string | null; + lastConfig: SearchConfig | null; +} = { lastPeersHash: null, lastConfig: null }; + +/** + * Compute the full SearchConfig from the resolved config + filesystem. + * Idempotent — called once at `onConfigResolved` and again inside + * `onPostBuild` if a fresh install was performed. Pure: no I/O + * outside of `require.resolve` and `resolveDocmdSearch`. + */ +function buildSearchConfig(config: any): SearchConfig { + const isEnabled = config.optionsMenu + ? config.optionsMenu.components.search !== false + : config.search !== false; + const pluginOptions = (config.plugins && config.plugins.search) || {}; + const semanticRequested = pluginOptions.semantic === true; + const docmdSearchInstalled = resolveDocmdSearch() !== null; + const resolvePaths = [process.cwd(), path.join(process.cwd(), 'node_modules')]; + + // List every peer dep and which are missing, in stable order. + const missingPeers: string[] = []; + for (const pkg of PEER_DEPS) { + try { require.resolve(pkg, { paths: resolvePaths }); } + catch { missingPeers.push(pkg); } + } + const peersInstalled = missingPeers.length === 0; + const semanticUsable = semanticRequested && docmdSearchInstalled && peersInstalled; + + return { + enabled: isEnabled, + semanticRequested, + docmdSearchInstalled, + peersInstalled, + semanticUsable, + missingPeers, + peerDeps: PEER_DEPS, + }; +} + +/** Hash used to decide whether to re-emit the peer-deps TUI block. */ +function peersHash(sc: SearchConfig): string { + return sc.peersInstalled ? 'ok' : 'missing:' + sc.missingPeers.join(','); +} + /* ── Semantic search peer-dep detection ─────────────────────────────────── */ /** @@ -330,6 +425,57 @@ function loadPluginStrings(localeId: string): Record { return {}; } +/** + * Print a per-peer-dep TUI block when `semantic: true` is requested. + * Idempotent: suppresses the block on subsequent builds when the set + * of missing peer deps is unchanged, so the user only sees it on the + * first build (or when a new peer dep goes missing). + * + * Called by `onConfigResolved` AND by `onPostBuild` after a fresh + * install. Each call is a no-op when the peer status is unchanged. + */ +function maybeReportPeerStatus(tui: any, sc: SearchConfig, quiet: boolean): void { + if (quiet || !tui) return; + if (!sc.semanticRequested) return; // user never asked for semantic — stay silent + const h = peersHash(sc); + if (h === _searchState.lastPeersHash) return; // already reported this state + _searchState.lastPeersHash = h; + + if (sc.peersInstalled) { + tui.step('All semantic search dependencies ready', 'DONE'); + return; + } + // List each missing dep as its own TUI line so the user sees exactly + // which package is the blocker. We keep the order stable (the same + // order as `PEER_DEPS`) so logs read consistently across builds. + for (const dep of sc.peerDeps) { + const present = !sc.missingPeers.includes(dep); + tui.step(`${dep}`, present ? 'DONE' : 'SKIP'); + } + tui.warn( + ' Semantic search requires: ' + sc.missingPeers.join(', ') + + '\n Falling back to keyword search. To enable semantic: install the missing packages,' + ); +} + +/** + * `init` capability. Runs at config-parse time, before any page is + * rendered. Computes the single `searchConfig` object and stamps it + * on `config._searchConfig` for the rest of the build pipeline. Also + * emits the first-install peer-status TUI block if appropriate. + */ +export function onConfigResolved(config: any): void { + const sc = buildSearchConfig(config); + config._searchConfig = sc; + _searchState.lastConfig = sc; + // `tui` isn't passed to onConfigResolved (the lifecycle signature is + // `onConfigResolved(config: any): void`), so we don't emit the TUI + // block here — the equivalent emission happens inside `onPostBuild` + // where the TUI is in scope. We do, however, keep this hook for + // future extensions (e.g. warming the require cache, pre-computing + // derived values that the EJS partial needs at template time). +} + /** * Plugin translations hook - called by the engine for each locale. * Returns search-specific UI strings keyed by locale. @@ -415,6 +561,10 @@ export async function onPostBuild({ config, pages, outputDir, tui, options, runW reason === 'peers' ? 'semantic peer dependencies missing' : reason === 'docmd-search' ? 'docmd-search not installed' : 'docmd-search not installed'; + // Only now — after the install ACTUALLY failed — report which deps + // were missing. This avoids the false-alarm where the warning showed + // "missing" and then the install succeeded below it. + maybeReportPeerStatus(tui, buildSearchConfig(config), !showTui); if (showTui) tui.warn(` Falling back to keyword search (${why})`); } else if (freshInstall) { // docmd-search was just installed in this process. Node's module cache @@ -797,20 +947,13 @@ export function generateScripts(config: any, options: any) { const isEnabled = config.optionsMenu ? config.optionsMenu.components.search !== false : config.search !== false; if (!isEnabled) return {}; - const semanticRequested = (options || {}).semantic === true; - // Only emit `data-semantic="true"` when the full semantic stack is - // usable — docmd-search resolvable AND its peer deps (the embedding - // model + ONNX runtime) resolvable. The indexing step (`onPostBuild`) - // silently falls back to keyword when either is missing; if we set - // the attribute unconditionally, the browser tries to load a - // non-existent `.docmd-search/` index and surfaces "Failed to load - // search index." even though keyword search would work fine. - const searchResolvePaths = [process.cwd(), path.join(process.cwd(), 'node_modules')]; - const isSemantic = semanticRequested - && resolveDocmdSearch() !== null - && findMissingPeerDep(searchResolvePaths) === null; + // Use the resolved searchConfig as the single source of truth. + // The client also auto-detects at runtime (HEAD probe to manifest.json) + // so even if this build-time hint is wrong (e.g. first-build dep install), + // the browser still picks the right mode. + const sc: SearchConfig = config._searchConfig || buildSearchConfig(config); + const isSemantic = sc.semanticUsable; const showConfidence = (options || {}).showConfidence === true; - // showFilters defaults to true; set false to hide the version filter bar const showFilters = (options || {}).showFilters !== false; // Load strings for the active locale (available at render time) @@ -855,7 +998,12 @@ export function generateScripts(config: any, options: any) { } export function getAssets(options: any) { - const isSemantic = (options || {}).semantic === true; + // Use searchConfig for semanticRequested, but keep the broader + // resolution paths for finding the client bundle (which lives in + // the plugin's own node_modules, not just the user's project). + const sc = _searchState.lastConfig; + const semanticRequested = sc?.semanticRequested ?? (options || {}).semantic === true; + const isSemantic = semanticRequested; if (isSemantic) { // Semantic mode: serve the docmd-search client bundle at a known path diff --git a/packages/plugins/seo/package.json b/packages/plugins/seo/package.json index 25e2016e..9d87ed7e 100644 --- a/packages/plugins/seo/package.json +++ b/packages/plugins/seo/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-seo", - "version": "0.8.10", + "version": "0.8.11", "description": "SEO meta tag generator for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/seo/src/index.ts b/packages/plugins/seo/src/index.ts index 8c402457..1d43c081 100644 --- a/packages/plugins/seo/src/index.ts +++ b/packages/plugins/seo/src/index.ts @@ -21,7 +21,7 @@ import { attrEsc } from '@docmd/utils'; export const plugin: PluginDescriptor = { name: 'seo', - version: '0.8.10', + version: '0.8.11', capabilities: ['head', 'post-build'] }; diff --git a/packages/plugins/sitemap/package.json b/packages/plugins/sitemap/package.json index 9ff5992d..aeb1e431 100644 --- a/packages/plugins/sitemap/package.json +++ b/packages/plugins/sitemap/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-sitemap", - "version": "0.8.10", + "version": "0.8.11", "description": "Sitemap generator plugin for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/sitemap/src/index.ts b/packages/plugins/sitemap/src/index.ts index c83a7f1f..e1444ebe 100644 --- a/packages/plugins/sitemap/src/index.ts +++ b/packages/plugins/sitemap/src/index.ts @@ -19,7 +19,7 @@ import { outputPathToPathname, sanitizeUrl } from '@docmd/api'; export const plugin: PluginDescriptor = { name: 'sitemap', - version: '0.8.10', + version: '0.8.11', capabilities: ['post-build'] }; diff --git a/packages/plugins/threads/package.json b/packages/plugins/threads/package.json index 015c62b3..690a3425 100644 --- a/packages/plugins/threads/package.json +++ b/packages/plugins/threads/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-threads", - "version": "0.8.10", + "version": "0.8.11", "type": "module", "description": "Inline discussion threads for docmd.", "main": "dist/index.js", @@ -42,6 +42,7 @@ }, "dependencies": { "@awesome.me/webawesome": "^3.10.0", + "@docmd/utils": "workspace:*", "lit": "^3.3.3" }, "devDependencies": { diff --git a/packages/plugins/threads/src/index.ts b/packages/plugins/threads/src/index.ts index f4a0e353..1bd87aff 100644 --- a/packages/plugins/threads/src/index.ts +++ b/packages/plugins/threads/src/index.ts @@ -4,11 +4,12 @@ import { fileURLToPath } from 'url'; import { setup as setupContainers } from './plugin/containers.js'; import { setup as setupHighlightRule } from './plugin/highlight-rule.js'; import { actions } from './plugin/actions.js'; +import { scriptLiteral } from '@docmd/utils'; import type { PluginDescriptor } from '@docmd/api'; export const plugin: PluginDescriptor = { name: 'threads', - version: '0.8.10', + version: '0.8.11', capabilities: ['markdown', 'body', 'assets', 'actions', 'translations'] }; @@ -41,26 +42,28 @@ export function markdownSetup(md: any, _options?: any): void { } export function generateScripts(config: any, options?: any): { headScriptsHtml: string; bodyScriptsHtml: string } { - let authorsJson = '{}'; + // S-7: parse authors.json, never inline the raw file text. scriptLiteral + // escapes . + let authors: unknown = {}; try { const srcDir = config.src || 'docs'; const authorsPath = path.resolve(srcDir, '.threads', 'authors.json'); - authorsJson = fs.readFileSync(authorsPath, 'utf8'); - } catch { - // File doesn't exist yet - that's fine + if (fs.existsSync(authorsPath)) { + const parsed = JSON.parse(fs.readFileSync(authorsPath, 'utf8')); + // Runtime schema is a plain object — coerce arrays/primitives to {}. + authors = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; + } + } catch (err: any) { + console.error(`[threads] Failed to parse .threads/authors.json: ${err.message}. Using empty authors.`); + authors = {}; } - const clientConfig = JSON.stringify({ - sidebar: options?.sidebar === true, - }); - - // Load i18n strings for the active locale - const localeId = config._activeLocale?.id || 'en'; - const i18nStrings = JSON.stringify(loadPluginStrings(localeId)); + const clientConfig = { sidebar: options?.sidebar === true }; + const i18nStrings = loadPluginStrings(config._activeLocale?.id || 'en'); return { headScriptsHtml: '', - bodyScriptsHtml: `` + bodyScriptsHtml: `` }; } diff --git a/packages/templates/summer/package.json b/packages/templates/summer/package.json index 874ef0b0..aa00c3c1 100644 --- a/packages/templates/summer/package.json +++ b/packages/templates/summer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/template-summer", - "version": "0.8.10", + "version": "0.8.11", "description": "Summer template for docmd. A bright, hopeful, summer-feel layout with a top search bar and halo accents.", "type": "module", "main": "dist/index.js", diff --git a/packages/templates/summer/src/index.ts b/packages/templates/summer/src/index.ts index 0f29cf50..6b5a7bd0 100644 --- a/packages/templates/summer/src/index.ts +++ b/packages/templates/summer/src/index.ts @@ -42,7 +42,7 @@ const pathOf = (relPath: string): string => fileURLToPath(urlOf(relPath)); export const plugin: PluginDescriptor = { name: 'template-summer', - version: '0.8.10', + version: '0.8.11', capabilities: ['template'], }; diff --git a/packages/themes/package.json b/packages/themes/package.json index 2906480c..3e50fdf0 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/themes", - "version": "0.8.10", + "version": "0.8.11", "description": "Official themes for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index e7088d91..22bde932 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/tui", - "version": "0.8.10", + "version": "0.8.11", "description": "Terminal User Interface (TUI) design system for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 198e2e07..3f5db5bb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/ui", - "version": "0.8.10", + "version": "0.8.11", "description": "Base UI templates and assets for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/utils/package.json b/packages/utils/package.json index 04def084..5eccef74 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/utils", - "version": "0.8.10", + "version": "0.8.11", "description": "Zero-dependency shared utilities for docmd core.", "type": "module", "main": "dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b709f34..b0558157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 4.1.2 docmd-search: specifier: ^0.1.0 - version: 0.1.0(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0) + version: 0.1.0(@docmd/engine-js@0.8.10)(@docmd/engine-rust@0.8.10)(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0) esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -376,7 +376,7 @@ importers: dependencies: docmd-search: specifier: '>=0.1.0' - version: 0.1.0(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0) + version: 0.1.0(@docmd/engine-js@0.8.10)(@docmd/engine-rust@0.8.10)(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0) markdown-it: specifier: ^14.3.0 version: 14.3.0 @@ -427,6 +427,9 @@ importers: '@docmd/parser': specifier: workspace:* version: link:../../parser + '@docmd/utils': + specifier: workspace:* + version: link:../../utils lit: specifier: ^3.3.3 version: 3.3.3 @@ -507,6 +510,14 @@ packages: resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} engines: {node: '>=14'} + '@docmd/engine-js@0.8.10': + resolution: {integrity: sha512-h440qMbZCPdZZlV8REFeJLKwx170t66h5uVh8jpoxAHQ5xRAwhuifgUpj8szBq+WnzHIGT1ZHqzefaqucdV+sg==} + engines: {node: '>=18'} + + '@docmd/engine-rust@0.8.10': + resolution: {integrity: sha512-OpwzQ9roiAxAQH2slomez4KS2NeeIa8ldPp6eDfSOx3ZOLmxa191KfMOIlEkXbgcXUiB70I3OqcY4jdMzzEgKQ==} + engines: {node: '>=18'} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -1729,6 +1740,12 @@ snapshots: '@ctrl/tinycolor@4.1.0': {} + '@docmd/engine-js@0.8.10': + optional: true + + '@docmd/engine-rust@0.8.10': + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -2255,8 +2272,10 @@ snapshots: detect-node@2.1.0: {} - docmd-search@0.1.0(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0): + docmd-search@0.1.0(@docmd/engine-js@0.8.10)(@docmd/engine-rust@0.8.10)(@huggingface/transformers@4.2.0)(onnxruntime-node@1.27.0): optionalDependencies: + '@docmd/engine-js': 0.8.10 + '@docmd/engine-rust': 0.8.10 '@huggingface/transformers': 4.2.0 onnxruntime-node: 1.27.0 diff --git a/scripts/probe-threads-xss.mjs b/scripts/probe-threads-xss.mjs new file mode 100644 index 00000000..5960b611 --- /dev/null +++ b/scripts/probe-threads-xss.mjs @@ -0,0 +1,55 @@ +// Probe the threads plugin XSS fix. Run from the monorepo root with: +// node scripts/probe-threads-xss.mjs +import { generateScripts } from '../packages/plugins/threads/dist/index.js'; +import fs from 'node:fs/promises'; + +const tmpDir = '/tmp/xss-verify'; +await fs.mkdir(`${tmpDir}/docs/.threads`, { recursive: true }); +await fs.writeFile(`${tmpDir}/docs/.threads/authors.json`, JSON.stringify({ + alice: { + name: 'Alice', + bio: 'evil\u2028lineTerminator' + }, + bob: 'not an object' +})); + +const orig = process.cwd(); +process.chdir(tmpDir); +try { + const out = generateScripts({ src: 'docs', _activeLocale: { id: 'en' } }, { sidebar: true }); + const html = out.bodyScriptsHtml; + console.log('--- output ---'); + console.log(html); + console.log('--- checks ---'); + const checks = { + // The dangerous form is `` of the outer wrapping + // tag itself is fine — we expect exactly one + // such occurrence (the closing tag) and it must be the LAST `` + // in the string. + 'has unescaped { + const matches = html.match(/(? v); + console.log(allPass ? '\nALL CHECKS PASS' : '\nFAILURES PRESENT'); + process.exit(allPass ? 0 : 1); +} finally { + process.chdir(orig); + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/tests/feature-integration.test.js b/tests/feature-integration.test.js index d0efbd5d..38f1b4a0 100644 --- a/tests/feature-integration.test.js +++ b/tests/feature-integration.test.js @@ -173,6 +173,42 @@ console.log('\n🌍 Test 3: i18n standalone (Hindi default)'); assert('English guide falls back from Hindi', siteExists(dir, 'en/guide/index.html')); } +// ─── TEST 3.5 (M-5): i18n cross-locale link to default locale prefix ─── +console.log('\n🌍 Test 3.5: i18n cross-locale link to default locale (M-5)'); +{ + // M-5: when the default locale lives at root and a non-default-locale + // page writes `[link](//foo)`, the build used to emit + // `` which 404s because the default-locale page is + // at `/foo`, not `/en/foo`. The fix strips the default-locale prefix + // from absolute hrefs in the rendered HTML so the link points to the + // actual location of the default-locale page. + const dir = setup('03.5-m5-cross-locale-link'); + writeFile(dir, 'docmd.config.js', `module.exports = { + title: 'M5 test', + src: 'docs', + i18n: { default: 'en', locales: [ + { id: 'en', label: 'English' }, + { id: 'fr', label: 'Français' } + ]} + };`); + writeFile(dir, 'docs/en/index.md', '# Welcome'); + writeFile(dir, 'docs/en/setup.md', '# Setup page'); + writeFile(dir, 'docs/fr/index.md', 'Voir la [version anglaise](/en/) et le [setup anglais](/en/setup).'); + const r = build(dir); + assert('builds M-5 scenario', r.ok); + const fr = readSite(dir, 'fr/index.html'); + assert('fr page does NOT link to /en/ (would 404)', fr && !/href="\/en\/"/.test(fr)); + assert('fr page does NOT link to /en/setup (would 404)', fr && !/href="\/en\/setup"/.test(fr)); + // The rewritten links should still resolve. Default-locale pages live + // at root, so `/en/` → `/` and `/en/setup` → `/setup` (or + // `/setup/`). Both should be present. + assert('default-locale index exists at root', siteExists(dir, 'index.html')); + assert('default-locale setup page exists', siteExists(dir, 'setup/index.html')); + // Online build keeps clean URLs — verify the link does NOT carry + // `/index.html` suffix (that would be the offline build behaviour). + assert('online build keeps clean URLs', fr && !/href="[^"]*index\.html"/.test(fr)); +} + // ─── TEST 4: Versioning standalone ─── console.log('\n📚 Test 4: Versioning standalone (no i18n)'); {