diff --git a/eslint.config.mjs b/eslint.config.mjs index cdcdd4e3..eb7ca47d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,7 +39,7 @@ export default [ }, rules: { '@typescript-eslint/no-require-imports': 'off', - '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', 'preserve-caught-error': 'off', '@typescript-eslint/ban-ts-comment': 'warn', diff --git a/package.json b/package.json index f5ece4b7..71c73708 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/monorepo", - "version": "0.8.12", + "version": "0.8.13", "private": true, "description": "The minimalist, zero-config documentation generator monorepo.", "scripts": { @@ -18,6 +18,8 @@ "docmd-live": "node packages/live/bin/docmd-live.js --cwd packages/_playground", "prep": "node tools/prep.js", "verify": "node tools/verify.js", + "simulate": "echo 'Run from a consumer project: cd ../docs && npm run sim' && exit 1", + "simulate:local": "echo 'Run from a consumer project: cd ../docs && npm run sim' && exit 1", "lint": "eslint .", "bump": "node tools/bump.js", "docker:build": "docker build -t docmd:latest -f docker/Dockerfile .", diff --git a/packages/_playground/package.json b/packages/_playground/package.json index bdd06625..e58941ea 100644 --- a/packages/_playground/package.json +++ b/packages/_playground/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/playground", - "version": "0.8.12", + "version": "0.8.13", "private": true, "dependencies": { "@docmd/core": "workspace:*", diff --git a/packages/api/package.json b/packages/api/package.json index 6dabfa8f..c2950207 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/api", - "version": "0.8.12", + "version": "0.8.13", "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 c618eab0..01f7eaf4 100644 --- a/packages/api/registry/plugins.generated.json +++ b/packages/api/registry/plugins.generated.json @@ -1,6 +1,6 @@ { "$meta": { - "generatedAt": "2026-07-12T12:23:44.202Z", + "generatedAt": "2026-07-13T20:57:53.220Z", "generator": "scripts/build-plugin-registry.mjs", "packageCount": 15 }, diff --git a/packages/api/src/hooks.ts b/packages/api/src/hooks.ts index ca4eebc4..a0e14612 100644 --- a/packages/api/src/hooks.ts +++ b/packages/api/src/hooks.ts @@ -327,27 +327,60 @@ export function resolveTemplateName(key: string): string { // --------------------------------------------------------------------------- // // The registry loader, package-manager detector, version pinner, and -// `spawn`-based installer live in `./runtime-deps.ts`. This module -// replaces the previous inline `execSync(\`pnpm add ${pkg}\`)` which -// was a CWE-78 (shell injection) surface. The shared module is also -// consumed by `engine.ts` for engine auto-install. +// `spawn`-based installer live in `./runtime-deps.ts`. We import them +// here and wrap `installRuntimeDep` with the project's warn-once +// deduper so a dev-server rebuild can't flood the TUI. // --------------------------------------------------------------------------- // Load & Register // --------------------------------------------------------------------------- export async function loadPlugins(config: any, opts?: { resolvePaths?: string[] }): Promise { - // 1. Resolution paths for plugin imports - the caller (e.g. @docmd/core) should - // pass its own __dirname so plugins that are core's dependencies can be found - // even under pnpm's strict node_modules layout. - const resolvePaths = [ - process.cwd(), - __dirname, - __monorepoRoot, - path.join(__monorepoRoot, 'packages/plugins'), - path.join(__monorepoRoot, 'packages/templates'), - ...(opts?.resolvePaths || []) - ]; + // The monorepo dev fallback (loading plugins/templates from + // packages/plugins or packages/templates in the monorepo source) + // should ONLY fire when the process is running from inside the + // monorepo itself. When an external project runs the monorepo's + // dist binary (e.g. docs/ running `node ../docmd/packages/core/ + // dist/bin/docmd.js dev`), the monorepo root is resolvable but the + // external project's node_modules is the right place to look. + // Without this gate, a template like @docmd/template-summer is found + // in the monorepo source, loaded directly, and auto-install never + // fires — so the package never lands in the user's package.json or + // node_modules. (Search plugin already scopes itself to + // process.cwd()/node_modules; this applies the same principle to + // the generic plugin/template loader.) + const isMonorepoContext = process.cwd().startsWith(__monorepoRoot + path.sep) || process.cwd() === __monorepoRoot; + + // 1. Resolution paths for plugin imports. + // + // When running INSIDE the monorepo (dev/test), we include __dirname + // and the monorepo package dirs so workspace packages resolve during + // development. + // + // When running OUTSIDE the monorepo (an external consumer project like + // docs/ running `dev:local`), we deliberately EXCLUDE __dirname. Why: + // __dirname inside the monorepo binary points to docmd/packages/api/dist/, + // and Node walks up from there to docmd/node_modules/ where EVERY workspace + // package is symlinked. That means template-summer, math, pwa — packages + // the consumer never installed — resolve silently from the monorepo. The + // consumer never sees the auto-install path fire, the package never lands + // in their package.json, and CI/production breaks. Restricting to + // process.cwd() means core plugins (deps of @docmd/core) resolve from the + // consumer's own node_modules, and optional plugins trigger auto-install. + const resolvePaths = isMonorepoContext + ? [ + process.cwd(), + __dirname, + __monorepoRoot, + path.join(__monorepoRoot, 'packages/plugins'), + path.join(__monorepoRoot, 'packages/templates'), + ...(opts?.resolvePaths || []), + ] + : [ + process.cwd(), + path.join(process.cwd(), 'node_modules'), + ...(opts?.resolvePaths || []), + ]; // 1. Reset hooks hooks.markdownSetup = []; @@ -436,14 +469,17 @@ export async function loadPlugins(config: any, opts?: { resolvePaths?: string[] // 1. Monorepo Priority: if it's an official plugin OR template, try local // monorepo source first. This prevents older versions installed in project // node_modules from taking precedence during monorepo development. - if (name.startsWith('@docmd/plugin-')) { + // Gated on isMonorepoContext so external projects running the monorepo + // binary (e.g. via `node ../docmd/.../docmd.js`) don't accidentally load + // from the monorepo source and bypass auto-install. + if (isMonorepoContext && name.startsWith('@docmd/plugin-')) { const id = name.replace('@docmd/plugin-', ''); const localPath = path.resolve(__monorepoRoot, 'packages/plugins', id, 'dist/index.js'); if (nativeFs.existsSync(localPath)) { rawModule = await import(pathToFileURL(localPath).href); loadedFromMonorepo = true; } - } else if (name.startsWith('@docmd/template-')) { + } else if (isMonorepoContext && name.startsWith('@docmd/template-')) { // Templates live under packages/templates// in the monorepo. const id = name.replace('@docmd/template-', ''); const localPath = path.resolve(__monorepoRoot, 'packages/templates', id, 'dist/index.js'); @@ -521,10 +557,10 @@ export async function loadPlugins(config: any, opts?: { resolvePaths?: string[] warnOnce(`registry:${name}`, TUI.yellow(`Plugin "${shortNameLocal ?? name}" not in official registry`)); continue; } - // Retry loading after install. We use dynamic `import()` (not - // `require.resolve` + file:// import) so packages that declare - // `exports` with only an `import` condition are still resolvable. - const reloaded = await tryLoadAfterInstall(name); + // Retry loading after install, scoped to the consumer's + // node_modules (not the monorepo's, which is what bare + // `import(name)` would walk into). + const reloaded = await tryLoadAfterInstall(name, process.cwd()); if (!reloaded) { warnOnce( `autoinstall:${name}`, diff --git a/packages/api/src/runtime-deps.ts b/packages/api/src/runtime-deps.ts index e1dab2d4..384bed3e 100644 --- a/packages/api/src/runtime-deps.ts +++ b/packages/api/src/runtime-deps.ts @@ -44,7 +44,7 @@ import path from 'node:path'; import nativeFs from 'node:fs'; import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import process from 'node:process'; import { spawn } from 'node:child_process'; import { TUI } from '@docmd/tui'; @@ -137,6 +137,11 @@ export function detectPackageManager(cwd: string): 'pnpm' | 'yarn' | 'bun' | 'np * a CI image without node_modules linked). */ export function getDocmdVersion(): string { + // DOCMD_INSTALL_VERSION overrides everything. Lets users / CI pin auto-installs + // to a specific version (or 'latest') independent of the installed core. + if (process.env.DOCMD_INSTALL_VERSION) { + return process.env.DOCMD_INSTALL_VERSION.trim() || 'latest'; + } try { const corePkgPath = require.resolve('@docmd/core/package.json', { paths: [process.cwd(), __dirname, __monorepoRoot], @@ -349,15 +354,82 @@ export function shortKey(packageName: string): string | null { } /** - * Re-load `pkg` after an install attempt. Returns the module reference - * or null when the import still fails. We use dynamic `import(pkgName)` - * (not file:// resolution) so `exports` with only the `import` - * condition still resolves on modern Node. + * Manually resolve a package entry point by walking up from `startDir` + * looking for `node_modules//package.json`. This bypasses + * Node's internal module resolution cache, which can fail to find a + * package that was just installed during the same process (the cache + * remembers the "not found" result from the initial failed resolve). + * + * Returns the absolute path to the entry JS file, or null if not found. + */ +function manualResolvePackageEntry(packageName: string, startDir: string): string | null { + const pkgSubPath = path.join('node_modules', packageName); + let dir = path.resolve(startDir); + // Walk up the directory tree looking for node_modules/ + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, pkgSubPath, 'package.json'); + if (nativeFs.existsSync(candidate)) { + try { + const pkg = JSON.parse(nativeFs.readFileSync(candidate, 'utf8')); + // Resolve the entry point: exports['.'] > main > index.js + let entry: string | undefined; + if (pkg.exports && typeof pkg.exports === 'object' && pkg.exports['.']) { + const exp = pkg.exports['.']; + entry = typeof exp === 'string' ? exp : (exp.import || exp.require || exp.default); + } + if (!entry) entry = pkg.main || 'index.js'; + const cleanEntry = (entry || 'index.js').replace(/^\.\//, ''); + const entryPath = path.join(dir, pkgSubPath, cleanEntry); + if (nativeFs.existsSync(entryPath)) return entryPath; + // Try dist/index.js as a common fallback + const distEntry = path.join(dir, pkgSubPath, 'dist', 'index.js'); + if (nativeFs.existsSync(distEntry)) return distEntry; + } catch { + // Malformed package.json — keep walking + } + } + dir = path.dirname(dir); + } + return null; +} + +/** + * Re-load `pkg` after an install attempt. Tries `createRequire` first + * (honours exports conditions), then falls back to a manual node_modules + * walk-up that bypasses Node's internal resolution cache. The cache can + * stale-fail when a package was just `npm install`ed during the same + * process — the initial `require.resolve` failure is remembered even + * after the package appears on disk. + * + * Returns the module reference or null on failure. */ -export async function tryLoadAfterInstall(packageName: string): Promise { +export async function tryLoadAfterInstall( + packageName: string, + consumerCwd: string = process.cwd(), +): Promise { + // Strategy 1: createRequire (honours exports field, but may stale-cache) try { - return await import(packageName); + const consumerRequire = createRequire(consumerCwd + '/'); + const entry = consumerRequire.resolve(packageName); + return await import(pathToFileURL(entry).href); } catch { - return null; + // Fall through to manual resolution } + + // Strategy 2: manual node_modules walk-up (bypasses cache, always + // does a fresh filesystem check). This is the reliable path when + // the package was installed seconds ago in the same process. + const manualEntry = manualResolvePackageEntry(packageName, consumerCwd); + if (manualEntry) { + try { + return await import(pathToFileURL(manualEntry).href); + } catch (e: any) { + const detail = e?.code ? `${e.code}: ${e.message}` : (e?.message || String(e)); + TUI.warn(`Post-install load of ${packageName} (manual resolve to ${manualEntry}) failed: ${detail}`); + return null; + } + } + + TUI.warn(`Post-install load of ${packageName} from ${consumerCwd} failed: package not found in node_modules tree`); + return null; } diff --git a/packages/core/package.json b/packages/core/package.json index c460a32b..d86f5128 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/core", - "version": "0.8.12", + "version": "0.8.13", "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/engine/generator.ts b/packages/core/src/engine/generator.ts index ed1c8ef4..4fc68e06 100644 --- a/packages/core/src/engine/generator.ts +++ b/packages/core/src/engine/generator.ts @@ -46,7 +46,7 @@ import nativeFs from 'fs'; const _require = createRequire(import.meta.url); import * as parser from '@docmd/parser'; import { TUI } from '@docmd/tui'; -import { findPageNeighbors, findBreadcrumbs, normalizeNavPaths, createUrlContext, buildContextualUrl, computePageUrls, buildAbsoluteUrl, sanitizeUrl } from '@docmd/parser'; +import { findPageNeighbors, findBreadcrumbs, normalizeNavPaths, createUrlContext, buildContextualUrl, computePageUrls, buildAbsoluteUrl, sanitizeUrl, normaliseBaseTag } from '@docmd/parser'; import * as ui from '@docmd/ui'; @@ -540,6 +540,19 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h else relativePathToRoot += '/'; relativePathToRoot = relativePathToRoot.replace(/\\/g, '/'); + // Canonical absolute site root (subpath deployment). The generator + // owns this decision — templates never compute it themselves. + // root deploy → '/' + // locale/version subpath → '/de/' or '/v1/' + // explicit `config.base` → '/my-repo/' (3rd-party deploys) + let siteRootAbs = '/'; + if (config._activePrefix && config._activePrefix !== '/') { + siteRootAbs = config._activePrefix; + } else if (config.base && config.base !== '/') { + siteRootAbs = config.base; + } + if (siteRootAbs !== '/' && !siteRootAbs.endsWith('/')) siteRootAbs += '/'; + // Navigation Context let navPath = '/' + page.outputPath.replace(/\\/g, '/').replace(/\/index\.html$/, '').replace(/^index\.html$/, ''); if (navPath === '/.') navPath = '/'; @@ -777,16 +790,7 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h localePrefix: config._localeOutputPrefix || '', currentPagePath: navPath, outputPrefix, - siteRootAbs: (() => { - let root = '/'; - if (config._activePrefix && config._activePrefix !== '/') { - root = config._activePrefix; - } else if (config.base && config.base !== '/') { - root = config.base; - } - if (root !== '/' && !root.endsWith('/')) root += '/'; - return root; - })(), + siteRootAbs, t, buildAbsoluteUrl, sanitizeUrl, @@ -815,6 +819,20 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h fullHtml = pageObj.html; + // ── Centralised enforcement (single source of truth) ── + // The generator is the ONLY authority for the tag. Templates + // must not emit one themselves — anything they emit will be stripped + // here and replaced with the canonical one computed from + // (siteRootAbs, isOfflineMode). This way old templates, third-party + // templates, and the default template all produce identical + // behaviour without any per-template logic to drift. + // + // Emit rules: + // isOfflineMode=true → no (file://) + // siteRootAbs === '/' (root deploy) → no + // siteRootAbs !== '/' (subpath deploy) → + fullHtml = normaliseBaseTag(fullHtml, !!options.offline, siteRootAbs); + // Queue the write writeQueue.push({ finalPath, html: fullHtml }); (page as any).urls = pageUrls; diff --git a/packages/deployer/package.json b/packages/deployer/package.json index efe5f636..75b556e9 100644 --- a/packages/deployer/package.json +++ b/packages/deployer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/deployer", - "version": "0.8.12", + "version": "0.8.13", "description": "Deployment configuration generator for docmd - Docker, Nginx, Caddy, GitHub Pages, Vercel, and Netlify.", "type": "module", "main": "dist/index.js", diff --git a/packages/engines/js/package.json b/packages/engines/js/package.json index 4c78eff3..38c369b9 100644 --- a/packages/engines/js/package.json +++ b/packages/engines/js/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/engine-js", - "version": "0.8.12", + "version": "0.8.13", "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 a3ef7f0d..3edc2272 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.12', + version: '0.8.13', 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 5218260e..aa43e1f6 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 7a1df150..33a3fe3c 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.12" +version = "0.8.13" dependencies = [ "napi", "napi-build", diff --git a/packages/engines/rust-binaries/native/Cargo.toml b/packages/engines/rust-binaries/native/Cargo.toml index 3013274d..db868cd0 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.12" +version = "0.8.13" 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 db05018c..84736bcd 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.12", + "version": "0.8.13", "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 1434b014..7564baa5 100644 --- a/packages/engines/rust/package.json +++ b/packages/engines/rust/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/engine-rust", - "version": "0.8.12", + "version": "0.8.13", "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 752b93a8..569aa477 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.12', + version: '0.8.13', supports(_taskType: string): boolean { return true; diff --git a/packages/legacy/doc.md/package.json b/packages/legacy/doc.md/package.json index 1f296a1c..982ce4aa 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.12", + "version": "0.8.13", "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 aee96550..f684c595 100644 --- a/packages/legacy/mgks/package.json +++ b/packages/legacy/mgks/package.json @@ -1,6 +1,6 @@ { "name": "@mgks/docmd", - "version": "0.8.12", + "version": "0.8.13", "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 26972627..cae6205a 100644 --- a/packages/live/package.json +++ b/packages/live/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/live", - "version": "0.8.12", + "version": "0.8.13", "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 7bb91bb6..cab9197a 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/parser", - "version": "0.8.12", + "version": "0.8.13", "description": "Pure Markdown parsing engine for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 2f0a8cee..af886291 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -58,5 +58,6 @@ export { createUrlContext, computePageUrls, buildAbsoluteUrl, + normaliseBaseTag, } from './utils/url-utils.js'; export type { UrlContext, PageUrls } from './utils/url-utils.js'; \ No newline at end of file diff --git a/packages/parser/src/utils/url-utils.ts b/packages/parser/src/utils/url-utils.ts index 411288e4..919697cf 100644 --- a/packages/parser/src/utils/url-utils.ts +++ b/packages/parser/src/utils/url-utils.ts @@ -292,3 +292,68 @@ export function buildAbsoluteUrl( const result = normalizedBase + localePrefix + versionPrefix + pagePath; return sanitizeUrl(result); } + +/** + * Normalise the `` tag in a fully-rendered HTML document. + * + * The generator is the single source of truth for the `` tag. Templates + * are NOT allowed to emit one themselves — anything a template emits is + * stripped here and replaced with the canonical decision: + * + * • offline mode (`--offline`) → no `` tag (file:// re-roots) + * • root deploy (`siteRootAbs === '/'`) → no `` tag (relative URLs + * resolve against document path) + * • subpath deploy (`siteRootAbs !== '/'`) → `` + * + * Implementation notes: + * + * • Removes ALL existing `` tags (any source: template, partial, + * stray plugin output) using a regex that handles attributes in any + * order, self-closing forms, single/double quotes. + * • When emitting, inserts the canonical `` immediately after the + * `` tag so it applies to all subsequent `<link>`, `<script>`, + * and asset references in the document head. + * • Idempotent: running it twice is a no-op. + * + * Centralising this means old templates (pre-0.8.13 with unconditional + * `<base href="/">`), the default template, and any third-party template + * all produce the same correct output. Templates do not need to know about + * base tags at all. + * + * @param html - The fully-rendered HTML string from the layout template + * @param isOffline - Whether the build was invoked with `--offline` + * @param siteRootAbs - The absolute subpath root, e.g. `'/'`, `'/repo/'`, `'/docs/v1/'` + * @returns - The HTML with the canonical `<base>` (or none) in place + */ +export function normaliseBaseTag(html: string, isOffline: boolean, siteRootAbs: string): string { + // Step 1: strip every existing <base> tag (self-closing or with close, any attribute order) + const BASE_TAG_RE = /<base\b[^>]*\/?>/gi; + const cleaned = html.replace(BASE_TAG_RE, ''); + + // Step 2: decide whether to emit a canonical one + if (isOffline) return cleaned; + if (!siteRootAbs || siteRootAbs === '/') return cleaned; + + // Inject right after <title>.... If no exists, inject before </head>. + const canonical = `<base href="${escapeHtmlAttr(siteRootAbs)}">`; + + const titleClose = cleaned.match(/<\/title>/i); + if (titleClose && typeof titleClose.index === 'number') { + return cleaned.slice(0, titleClose.index + titleClose[0].length) + + canonical + + cleaned.slice(titleClose.index + titleClose[0].length); + } + const headClose = cleaned.match(/<\/head>/i); + if (headClose && typeof headClose.index === 'number') { + return cleaned.slice(0, headClose.index) + + canonical + + cleaned.slice(headClose.index); + } + // Final fallback: prepend (shouldn't happen for valid HTML) + return canonical + cleaned; +} + +/** Minimal attribute-value escaper for the canonical <base href="...">. */ +function escapeHtmlAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); +} diff --git a/packages/plugins/analytics/package.json b/packages/plugins/analytics/package.json index 7668206e..b2d4e430 100644 --- a/packages/plugins/analytics/package.json +++ b/packages/plugins/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-analytics", - "version": "0.8.12", + "version": "0.8.13", "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 67796205..665e91a3 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.12', + version: '0.8.13', capabilities: ['head', 'body'] }; diff --git a/packages/plugins/git/package.json b/packages/plugins/git/package.json index aae27e4f..aca7e684 100644 --- a/packages/plugins/git/package.json +++ b/packages/plugins/git/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-git", - "version": "0.8.12", + "version": "0.8.13", "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 0e410b83..69e28c1c 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.12', + version: '0.8.13', capabilities: ['build', 'body', 'assets', 'translations', 'init', 'post-build'] }; diff --git a/packages/plugins/installer/package.json b/packages/plugins/installer/package.json index d7bb6b3c..08363566 100644 --- a/packages/plugins/installer/package.json +++ b/packages/plugins/installer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-installer", - "version": "0.8.12", + "version": "0.8.13", "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 e6dec222..9a17e881 100644 --- a/packages/plugins/llms/package.json +++ b/packages/plugins/llms/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-llms", - "version": "0.8.12", + "version": "0.8.13", "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 25271b67..21bf1c8b 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.12', + version: '0.8.13', capabilities: ['post-build'] }; diff --git a/packages/plugins/math/package.json b/packages/plugins/math/package.json index afd0a9cf..5d3b60c5 100644 --- a/packages/plugins/math/package.json +++ b/packages/plugins/math/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-math", - "version": "0.8.12", + "version": "0.8.13", "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 4ea6d579..72ff0813 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.12', + version: '0.8.13', capabilities: ['markdown', 'assets'] }; diff --git a/packages/plugins/mermaid/package.json b/packages/plugins/mermaid/package.json index 2f67eb27..f4e8ccd6 100644 --- a/packages/plugins/mermaid/package.json +++ b/packages/plugins/mermaid/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-mermaid", - "version": "0.8.12", + "version": "0.8.13", "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 c01e5410..730a975d 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.12', + version: '0.8.13', capabilities: ['markdown', 'assets'] }; diff --git a/packages/plugins/okf/package.json b/packages/plugins/okf/package.json index 64640ba9..cbb04580 100644 --- a/packages/plugins/okf/package.json +++ b/packages/plugins/okf/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-okf", - "version": "0.8.12", + "version": "0.8.13", "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 e9c6f9fa..bf538ea6 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.12', + version: '0.8.13', 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.12', + okf_version: '0.8.13', 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 260c2f95..bb860080 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-openapi", - "version": "0.8.12", + "version": "0.8.13", "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 ec4be3f1..ad25e2b5 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.12', + version: '0.8.13', capabilities: ['markdown', 'assets'] }; @@ -143,7 +143,7 @@ function resolveRef(ref: string, spec: OASpec): OASchema | null { return node as OASchema | null; } -function resolveSchema(schema: OASchema | undefined, spec: OASpec, depth = 0): OASchema { +function resolveSchema(schema: OASchema | undefined, spec: OASpec, _depth = 0): OASchema { if (!schema) return {}; if (schema.$ref) return resolveRef(schema.$ref, spec) || schema; return schema; @@ -195,7 +195,6 @@ function renderOperation(method: string, path_: string, op: OAOperation, spec: O let paramsHtml = ''; if (!summaryOnly && op.parameters && op.parameters.length > 0) { const rows = op.parameters.map(p => { - const r = resolveSchema(p.schema, spec); return `<tr> <td><code>${esc(p.name)}</code>${p.required ? ' <span class="oa-required">*</span>' : ''}</td> <td><span class="oa-param-in">${esc(p.in)}</span></td> @@ -274,7 +273,6 @@ function parseSpec(specPath: string): OASpec { // We avoid a full YAML dep by using JSON if possible, otherwise note the limitation try { // Try to require js-yaml if available (won't throw at import time since it's optional) - // eslint-disable-next-line @typescript-eslint/no-require-imports const yaml = require('js-yaml'); return yaml.load(raw) as OASpec; } catch { diff --git a/packages/plugins/pwa/package.json b/packages/plugins/pwa/package.json index e8fb16a0..9d5ff2ec 100644 --- a/packages/plugins/pwa/package.json +++ b/packages/plugins/pwa/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-pwa", - "version": "0.8.12", + "version": "0.8.13", "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 a684614a..c7f87238 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.12', + version: '0.8.13', capabilities: ['post-build', 'head', 'body'] }; diff --git a/packages/plugins/search/i18n/en.json b/packages/plugins/search/i18n/en.json index 130d77e0..997ef48c 100644 --- a/packages/plugins/search/i18n/en.json +++ b/packages/plugins/search/i18n/en.json @@ -2,6 +2,7 @@ "searchPlaceholder": "Search documentation...", "searchNoResults": "No results found.", "searchError": "Failed to load search index.", + "searchOffline": "Search requires a web server. Open this site via http://localhost instead of file:// to enable search.", "searchInitial": "Type to start searching...", "searchClose": "Close search", "searchNavigate": "to navigate", diff --git a/packages/plugins/search/i18n/hi.json b/packages/plugins/search/i18n/hi.json index 4332c5dd..59fccf5c 100644 --- a/packages/plugins/search/i18n/hi.json +++ b/packages/plugins/search/i18n/hi.json @@ -2,6 +2,7 @@ "searchPlaceholder": "दस्तावेज़ खोजें...", "searchNoResults": "कोई परिणाम नहीं मिला।", "searchError": "खोज अनुक्रमणिका लोड करने में विफल।", + "searchOffline": "खोज के लिए वेब सर्वर आवश्यक है। खोज सक्षम करने के लिए file:// के बजाय http://localhost से साइट खोलें।", "searchInitial": "खोजने के लिए टाइप करें...", "searchClose": "खोज बंद करें", "searchNavigate": "नेविगेट करें", diff --git a/packages/plugins/search/i18n/ko.json b/packages/plugins/search/i18n/ko.json index d6681834..8dd2fe0b 100644 --- a/packages/plugins/search/i18n/ko.json +++ b/packages/plugins/search/i18n/ko.json @@ -2,6 +2,7 @@ "searchPlaceholder": "문서 검색...", "searchNoResults": "검색 결과가 없습니다.", "searchError": "검색 색인을 불러오지 못했습니다.", + "searchOffline": "검색하려면 웹 서버가 필요합니다. file:// 대신 http://localhost로 사이트를 열어 검색을 활성화하세요.", "searchInitial": "검색어를 입력하세요...", "searchClose": "검색 닫기", "searchNavigate": "이동", diff --git a/packages/plugins/search/i18n/zh.json b/packages/plugins/search/i18n/zh.json index 17b4480b..de2582ae 100644 --- a/packages/plugins/search/i18n/zh.json +++ b/packages/plugins/search/i18n/zh.json @@ -2,6 +2,7 @@ "searchPlaceholder": "搜索文档...", "searchNoResults": "未找到结果。", "searchError": "无法加载搜索索引。", + "searchOffline": "搜索需要网页服务器。请通过 http://localhost 而非 file:// 打开站点以启用搜索。", "searchInitial": "输入开始搜索...", "searchClose": "关闭搜索", "searchNavigate": "导航", diff --git a/packages/plugins/search/package.json b/packages/plugins/search/package.json index 30f22780..0437e83e 100644 --- a/packages/plugins/search/package.json +++ b/packages/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-search", - "version": "0.8.12", + "version": "0.8.13", "description": "Offline full-text search for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/search/src/client.ts b/packages/plugins/search/src/client.ts index 008cc001..c5b1ebb6 100644 --- a/packages/plugins/search/src/client.ts +++ b/packages/plugins/search/src/client.ts @@ -49,7 +49,8 @@ declare const MiniSearch: any; const strings = { initial: searchModal.dataset.searchInitial || 'Type to start searching...', noResults: searchModal.dataset.searchNoResults || 'No results found.', - error: searchModal.dataset.searchError || 'Failed to load search index.' + error: searchModal.dataset.searchError || 'Failed to load search index.', + offline: searchModal.dataset.searchOffline || 'Search requires a web server. Open this site via http://localhost instead of file:// to enable search.' }; // Use Site Root if available (for versioning), fallback to Context Root @@ -181,6 +182,16 @@ declare const MiniSearch: any; // 3. Index Loading - fetches locale-specific index async function loadIndex() { + // file:// detection: browsers block fetch() from file:// URLs for + // security (CORS). Show a helpful message instead of the generic + // "Failed to load" error. The user needs to serve the site via a + // local HTTP server (e.g. `npx serve site`) for search to work. + if (window.location.protocol === 'file:') { + const sanitized = `<div class="search-error">${escapeHtml(strings.offline)}</div>`; + searchResults.innerHTML = sanitized; + return; + } + // 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. diff --git a/packages/plugins/search/src/index.ts b/packages/plugins/search/src/index.ts index 03db95c1..4e2dc6b3 100644 --- a/packages/plugins/search/src/index.ts +++ b/packages/plugins/search/src/index.ts @@ -28,7 +28,7 @@ const require = createRequire(import.meta.url); export const plugin: PluginDescriptor = { name: 'search', - version: '0.8.12', + version: '0.8.13', // `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 @@ -975,6 +975,7 @@ export function generateScripts(config: any, options: any) { data-search-placeholder="${escape(strings.searchPlaceholder || 'Search documentation...')}" data-search-no-results="${escape(strings.searchNoResults || 'No results found.')}" data-search-error="${escape(strings.searchError || 'Failed to load search index.')}" + data-search-offline="${escape(strings.searchOffline || 'Search requires a web server. Open this site via http://localhost instead of file:// to enable search.')}" data-search-initial="${escape(strings.searchInitial || 'Type to start searching...')}" data-search-navigate="${escape(strings.searchNavigate || 'to navigate')}" data-search-escape="${escape(strings.searchEscape || 'to close')}"> diff --git a/packages/plugins/seo/package.json b/packages/plugins/seo/package.json index 748d142f..00968f4c 100644 --- a/packages/plugins/seo/package.json +++ b/packages/plugins/seo/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-seo", - "version": "0.8.12", + "version": "0.8.13", "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 72f46a94..646665bd 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.12', + version: '0.8.13', capabilities: ['head', 'post-build'] }; diff --git a/packages/plugins/sitemap/package.json b/packages/plugins/sitemap/package.json index d0253580..404d5f19 100644 --- a/packages/plugins/sitemap/package.json +++ b/packages/plugins/sitemap/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-sitemap", - "version": "0.8.12", + "version": "0.8.13", "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 49b0f370..ccd1fef3 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.12', + version: '0.8.13', capabilities: ['post-build'] }; diff --git a/packages/plugins/threads/package.json b/packages/plugins/threads/package.json index 687fc45e..bd7450ac 100644 --- a/packages/plugins/threads/package.json +++ b/packages/plugins/threads/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/plugin-threads", - "version": "0.8.12", + "version": "0.8.13", "type": "module", "description": "Inline discussion threads for docmd.", "main": "dist/index.js", diff --git a/packages/plugins/threads/src/index.ts b/packages/plugins/threads/src/index.ts index 5bc1cbc0..496b2f62 100644 --- a/packages/plugins/threads/src/index.ts +++ b/packages/plugins/threads/src/index.ts @@ -9,7 +9,7 @@ import type { PluginDescriptor } from '@docmd/api'; export const plugin: PluginDescriptor = { name: 'threads', - version: '0.8.12', + version: '0.8.13', capabilities: ['markdown', 'body', 'assets', 'actions', 'translations'] }; diff --git a/packages/templates/summer/package.json b/packages/templates/summer/package.json index 980e281d..ed8e1dac 100644 --- a/packages/templates/summer/package.json +++ b/packages/templates/summer/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/template-summer", - "version": "0.8.12", + "version": "0.8.13", "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 af92ab28..e9efc883 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.12', + version: '0.8.13', capabilities: ['template'], }; diff --git a/packages/templates/summer/templates/layout.ejs b/packages/templates/summer/templates/layout.ejs index 0c92d4ed..138f4f84 100644 --- a/packages/templates/summer/templates/layout.ejs +++ b/packages/templates/summer/templates/layout.ejs @@ -32,8 +32,6 @@ <meta name="theme-color" content="#13110d" media="(prefers-color-scheme: dark)"> <title><%= pageTitle %><% if (siteTitle && pageTitle !== siteTitle) { %> · <%= siteTitle %><% } %> - - diff --git a/packages/themes/package.json b/packages/themes/package.json index 98669b49..0a169193 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/themes", - "version": "0.8.12", + "version": "0.8.13", "description": "Official themes for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 872c031c..be865f1c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/tui", - "version": "0.8.12", + "version": "0.8.13", "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 73153103..576df08f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/ui", - "version": "0.8.12", + "version": "0.8.13", "description": "Base UI templates and assets for docmd.", "type": "module", "main": "dist/index.js", diff --git a/packages/ui/templates/layout.ejs b/packages/ui/templates/layout.ejs index 31ea1949..80019a1b 100644 --- a/packages/ui/templates/layout.ejs +++ b/packages/ui/templates/layout.ejs @@ -46,7 +46,6 @@ <%= pageTitle %><% if (siteTitle && pageTitle !== siteTitle && !(frontmatter && frontmatter.titleAppend === false)) { %> : <%= siteTitle %><% } %> - <%- faviconLinkHtml || '' %> <% if (config.theme?.codeHighlight !==false) { const isDarkDefault=appearance==='dark' ; %> diff --git a/packages/utils/package.json b/packages/utils/package.json index 52adb563..70155ce8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@docmd/utils", - "version": "0.8.12", + "version": "0.8.13", "description": "Zero-dependency shared utilities for docmd core.", "type": "module", "main": "dist/index.js", diff --git a/tests/cli-contracts/asset-base-url.test.js b/tests/cli-contracts/asset-base-url.test.js index 4c84511f..c79f022d 100644 --- a/tests/cli-contracts/asset-base-url.test.js +++ b/tests/cli-contracts/asset-base-url.test.js @@ -40,6 +40,7 @@ import { } from '../shared.js'; import fs from 'node:fs'; import path from 'path'; +import { execFileSync } from 'node:child_process'; let passed = 0; let failed = 0; @@ -61,24 +62,24 @@ export const test = runTestFile({ emoji: '🔗', run: async () => { - // URL-1a: default layout emits a tag using the absolute - // siteRootAbs exposed by the renderer. Lock the requirement in by - // source so a future template edit can't silently break the - // no-trailing-slash case (e.g. serving `/search` resolves - // `./assets/...` to `/search/assets/...` instead of root). + // URL-1a: the tag is now centralised in the generator, NOT in + // the template. Templates must NOT emit a tag themselves — the + // generator strips any existing one and injects the canonical decision + // based on (isOfflineMode, siteRootAbs). This assertion guards that + // contract: the default layout must not contain a tag. { const layoutPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'ui', 'templates', 'layout.ejs'); const source = fs.readFileSync(layoutPath, 'utf8'); - assert(/"\s*>/.test(source), - 'URL-1a: default layout emits using the absolute site path'); + assert(!/ (generator owns it centrally)'); } - // URL-1b: same for the summer template (the user-reported 404 path). + // URL-1b: same contract for the summer template — no in template. { const layoutPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'templates', 'summer', 'templates', 'layout.ejs'); const source = fs.readFileSync(layoutPath, 'utf8'); - assert(/"\s*>/.test(source), - 'URL-1b: summer template emits for absolute path resolution'); + assert(!/ (generator owns it centrally)'); assert(/window\.DOCMD_SITE_ROOT\s*=\s*"<\%=\s*siteRootAbs\s*\%>"/.test(source), 'URL-1b: summer template sets window.DOCMD_SITE_ROOT to siteRootAbs so JS plugins (search semantic client) resolve ./ URLs correctly'); } @@ -99,8 +100,10 @@ export const test = runTestFile({ const result = build(proj); assert(result.ok, 'URL-1c: sub-project build with summer template succeeds'); const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); - assert(//.test(html), - 'URL-1c: rendered HTML contains (root project) so ./assets/... resolves at any URL shape'); + // Root deploys no longer emit — relies on + // relativePathToRoot + ./assets/... + assert(!//.test(html), + 'URL-1c: root project does NOT emit (fixes #175 and #177)'); assert(/href="\.\/assets\/template\/summer\.css/.test(html), 'URL-1c: rendered HTML uses relative ./assets/template/summer.css'); assert(/src="\.\/assets\/template\/summer\.js/.test(html), @@ -123,8 +126,10 @@ export const test = runTestFile({ const result = build(proj); assert(result.ok, 'URL-1d: default-template build succeeds'); const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); - assert(//.test(html), - 'URL-1d: default-template HTML also contains '); + // Root deploys with default template: no tag (relies on + // relative ./assets/... resolution + assert(!//.test(html), + 'URL-1d: root deploy default template does NOT emit '); } // URL-1e: the workspace sub-site case — a /search project must @@ -164,6 +169,53 @@ export const test = runTestFile({ 'URL-1e: /search/ sub-site has window.DOCMD_SITE_ROOT = "/search/"'); } + // URL-1f: issue #175 — GitHub Pages project site (url has subpath, + // no explicit base). base must be auto-derived to the subpath and + // must point at it so assets resolve correctly. + { + const proj = setup('asset-base-url-gh-pages-subpath'); + writeFile(proj, 'docs/index.md', '# Home\n'); + writeFile(proj, 'docmd.config.json', JSON.stringify({ + title: 'URL-1f', + url: 'https://alexhelms.github.io/some-project', + src: './docs', + out: './site' + }, null, 2) + '\n'); + + const result = build(proj); + assert(result.ok, 'URL-1f: GH Pages subpath build succeeds'); + const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); + assert(//.test(html), + 'URL-1f: GH Pages subpath emits (fixes #175)'); + assert(/href="\.\/assets\/css\/docmd-main\.css/.test(html), + 'URL-1f: CSS link is relative (resolves via base)'); + } + + // URL-1g: issue #177 — --offline mode must NOT emit a tag. + // file:// resolution breaks if is present because + // it re-roots every relative URL to the filesystem root. + { + const proj = setup('asset-base-url-offline-mode'); + fs.mkdirSync(path.join(proj, 'docs/guide'), { recursive: true }); + writeFile(proj, 'docs/index.md', '# Home\n'); + writeFile(proj, 'docs/guide/page.md', '# Guide\n'); + writeFile(proj, 'docmd.config.json', JSON.stringify({ + title: 'URL-1g', + src: './docs', + out: './site' + }, null, 2) + '\n'); + + const result = execFileSync('node', [path.resolve(import.meta.dirname, '..', '..', 'packages', 'core', 'dist', 'bin', 'docmd.js'), 'build', '--offline'], { + cwd: proj, encoding: 'utf8', stdio: 'pipe' + }); + assert(result.includes('Build complete'), 'URL-1g: --offline build completes'); + const pageHtml = fs.readFileSync(path.join(proj, 'site/guide/page/index.html'), 'utf8'); + assert(!/ tag (fixes #177)'); + assert(/href="\.\.\/\.\.\/assets\/css\/docmd-main\.css/.test(pageHtml), + 'URL-1g: nested page uses page-relative CSS path (resolves under file://)'); + } + // URL-2: `engine: "rust"` (and `engines: { rust: {...} }`) is in // KNOWN_KEYS so a project that requests the rust preview engine // does not print an "Unknown property" warning. diff --git a/tools/prep.js b/tools/prep.js index c211c545..dd65c3f6 100644 --- a/tools/prep.js +++ b/tools/prep.js @@ -133,6 +133,20 @@ function finishStep(s, status, summary) { ); } +// Same shape as finishStep() but prints a FRESH line below the streamed +// content instead of rewriting the [WAIT] line via cursor-up. Use this +// for steps whose child process has written lines of its own between +// startStep() and now, because the cursor is no longer on the WAIT +// line — finishStep()'s `\x1b[1A` would clobber an unrelated output line. +function writeVerdictLine(s, status, summary) { + const ms = Date.now() - s.startMs; + const t = ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; + const tag = status === 'done' + ? `${C.green}[ DONE ]${C.reset}` + : `${C.red}[ FAIL ]${C.reset}`; + console.log(`${s.bar} ${s.text.padEnd(52)}${tag} ${C.dim}${t}${C.reset} ${C.dim}${summary}${C.reset}`); +} + function run(cmd, opts = {}) { // opts.silent — when true (default), suppress output unless cmd failed. // opts.capture — return stdout/stderr as strings; never stream to the TUI. @@ -215,62 +229,6 @@ function runLint() { else if (warnings > 0) addIssue('warning', 'Lint', `${warnings} lint warning(s)`, details); } -// ── Collapsed-mode test runner ──────────────────────────────────────── -// Captures test output, parses pass/fail counts and any failure -// titles, then renders a one-line summary. Verbose mode bypasses -// the capture and streams raw output as it arrives instead. -function summariseTests(stdout) { - // tests/runner.js emits a single aggregate line at the end. - // Pattern: "Test summary: 367 passed, 0 failed across 9 files" - let m = stdout.match(/Test summary:\s+(\d+)\s+passed,\s+(\d+)\s+failed\s+across\s+(\d+)\s+files?/i); - if (m) { - return { - tests: parseInt(m[1]) + parseInt(m[2]), - pass: parseInt(m[1]), - fail: parseInt(m[2]), - units: parseInt(m[3]), - unitLabel: 'files', - failures: extractFailures(stdout), - }; - } - - // Per-package output (pnpm -r run test): each suite prints its own - // ℹ tests N / ℹ pass N / ℹ fail N block, so we sum them all up. - let tests = 0, pass = 0, fail = 0, pkgs = 0; - for (const t of stdout.matchAll(/ℹ\s+tests\s+(\d+)/g)) tests += parseInt(t[1]); - for (const p of stdout.matchAll(/ℹ\s+pass\s+(\d+)/g)) pass += parseInt(p[1]); - for (const f of stdout.matchAll(/ℹ\s+fail\s+(\d+)/g)) fail += parseInt(f[1]); - // Count distinct packages that reported a result. Look for the - // pnpm-recursive "Done" line which marks each package's end. - for (const _ of stdout.matchAll(/\bDone$/gm)) pkgs++; - // Fallback when no "Done" markers are present: count `ℹ tests` lines. - if (pkgs === 0) { - for (const _ of stdout.matchAll(/ℹ\s+tests\s+\d+/g)) pkgs++; - } - return { - tests, pass, fail, - units: pkgs, - unitLabel: pkgs === 1 ? 'package' : 'packages', - failures: extractFailures(stdout), - }; -} - -function extractFailures(stdout) { - // node:test prints "not ok N - " lines on failure. Trim - // surrounding ANSI noise and keep the title only — full diagnostics - // remain in --verbose output. - const out = []; - // Build the ANSI-stripping regex at runtime so the static linter - // doesn't flag the embedded ESC (\x1b) control character. - const ANSI_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g'); - for (const m of stdout.matchAll(/not ok \d+ - (.+)/g)) { - const t = m[1].replace(ANSI_RE, '').trim(); - if (t) out.push(t); - if (out.length >= 20) break; - } - return out; -} - function runTestStep(label, cmd, statLabel = label) { const s = startStep(label); @@ -287,27 +245,36 @@ function runTestStep(label, cmd, statLabel = label) { return; } - // Default (collapsed) mode: capture output, summarise, render one line. - const result = run(cmd, { capture: true }); - const sum = summariseTests(result.stdout + result.stderr); + // Default (collapsed) mode: STREAM LIVE so the operator sees the + // runner's own per-section TUI (each `┌─ ` is a new section + // starting, each `[ PASS ]` / `[ FAIL ]` is a section finishing). + // Previously this branch captured stdout into a buffer and printed a + // single summary line at the end — useful for clean logs but useless + // when a multi-minute test suite looks completely silent. Inheriting + // stdio lets the runner talk to the operator directly; the runner's + // own `Test summary: N passed, M failed across K files` line (printed + // inline at the end of its run) is now the canonical counts source. + // The cursor is no longer on the WAIT line after streaming, so we use + // writeVerdictLine() to print a fresh verdict line BELOW the streamed + // content instead of trying to rewrite the WAIT line in place. + let exitCode = 0; + try { + execSync(cmd, { stdio: 'inherit', maxBuffer: 64 * 1024 * 1024 }); + } catch (e) { + exitCode = e.status ?? 1; + } - if (result.ok && sum.fail === 0 && sum.tests > 0) { - finishStep(s, 'done'); - addStat(statLabel, `${sum.pass} passed across ${sum.units} ${sum.unitLabel}`, 'ok'); - } else if (result.ok && sum.tests === 0) { - // No tests ran at all (e.g. --only matched nothing). Be honest - // about that rather than printing a misleading "0 passed". - finishStep(s, 'warn'); - addStat(statLabel, 'no tests ran', 'warn'); + if (exitCode === 0) { + writeVerdictLine(s, 'done', 'all sections passed — see runner summary above'); + addStat(statLabel, 'passed (see runner output for counts)', 'ok'); } else { - const statValue = sum.fail > 0 - ? `${sum.fail} of ${sum.tests} failed across ${sum.units} ${sum.unitLabel}` - : `command exited with status ${result.status}`; - finishStep(s, 'fail'); - addStat(statLabel, statValue, 'fail'); - addIssue('error', label, - sum.fail > 0 ? `${sum.fail} test failure(s)` : 'test command failed', - sum.failures); + const statusLine = `runner exited with status ${exitCode}`; + writeVerdictLine(s, 'fail', statusLine); + addStat(statLabel, `failed (exit ${exitCode})`, 'fail'); + addIssue('error', label, 'test command failed', [ + statusLine, + 're-run with --verbose to see the full failure output inline', + ]); } } diff --git a/tools/simulate-consumer.mjs b/tools/simulate-consumer.mjs new file mode 100644 index 00000000..c8e6f0c8 --- /dev/null +++ b/tools/simulate-consumer.mjs @@ -0,0 +1,688 @@ +#!/usr/bin/env node +/** + * -------------------------------------------------------------------- + * docmd : the zero-config documentation engine. + * + * @package @docmd/core (and ecosystem) + * @website https://docmd.io + * @repository https://github.com/docmd-io/docmd + * @license MIT + * @copyright (c) 2025-present docmd.io + * + * [docmd-source] - Please do not remove this header. + * -------------------------------------------------------------------- + * + * simulate-consumer : test @docmd/core the way a real user installs it. + * + * PROBLEM: + * dev:local runs the monorepo binary directly. Node resolves modules + * from the monorepo's node_modules, which has EVERY workspace package + * symlinked. This masks missing deps, auto-install failures, and + * version mismatches. Issues only surface after publishing, when CI + * or GitHub Pages deploys fail. + * + * SOLUTION: + * Create a throwaway project, install @docmd/core the same way a user + * would, copy a real config + content set, and run the build. Any + * missing-dep, auto-install, or resolution issue fires here, before + * the release. + * + * USAGE: + * Run FROM the consumer project directory (docs/, docmd.io/, your own repo): + * + * node /path/to/docmd/tools/simulate-consumer.mjs # npm, temp dir + * node /path/to/docmd/tools/simulate-consumer.mjs --local # monorepo tarballs, temp dir + * node /path/to/docmd/tools/simulate-consumer.mjs --local --in-place # work in consumer dir directly + * node /path/to/docmd/tools/simulate-consumer.mjs --local --dev # live dev server + * node /path/to/docmd/tools/simulate-consumer.mjs --local --in-place --dev # full real-user dev experience + * node /path/to/docmd/tools/simulate-consumer.mjs --keep # keep temp dir for inspection + * node /path/to/docmd/tools/simulate-consumer.mjs --no-clean # in-place: keep existing node_modules + * node /path/to/docmd/tools/simulate-consumer.mjs --verbose # stream all output + * node /path/to/docmd/tools/simulate-consumer.mjs --skip-monorepo-build # assume dist/ is current + * + * Or pass --source explicitly (overrides process.cwd()): + * node tools/simulate-consumer.mjs --source ../docs --local + * + * The consumer project is the directory you point at: it must contain + * a docmd config (docmd.config.{js,json,mjs}) and the markdown source + * the config references. + * + * MODES (two independent axes): + * + * Install source: + * default : npm install @docmd/core@latest from the public registry. + * --local : Copy every monorepo package to /tmp staging, resolve + * workspace:* refs in the COPIES (monorepo never touched), + * pack tarballs from staging. Tests UNRELEASED local changes + * with zero risk to the monorepo working tree. + * + * Working directory: + * default : Copy consumer content to a throwaway temp dir + * (/tmp/docmd-consumer-sim-*), install there, build there. + * Cleanest simulation — nothing touches the consumer dir. + * --in-place : Install and run directly in the consumer dir. The + * consumer's package.json, node_modules, and site/ are + * modified exactly as a normal `docmd build` would. + * This is the closest to the real user experience: + * you see site/ appear, node_modules get updated, and + * live changes are reflected in `--dev` mode. + * --dev : Run `docmd dev` (live server) instead of `docmd build`. + * Inherits stdio so you interact with the server normally. + * Best with --in-place: edit your docs and see live + * updates, exactly like a real user. + * + * SAFETY: + * This is a read-only diagnostic tool. It never commits, never + * pushes, never modifies git state, and NEVER mutates the monorepo's + * package.json files. The --local mode COPIES each package to a + * staging directory under /tmp, rewrites workspace:* refs in the + * COPIES only, and packs from there. If the process is killed at + * any point (Ctrl+C, crash, power loss), the monorepo is untouched. + * Only /tmp is dirty. The only monorepo side-effect is an optional + * `pnpm -r run build` (writes gitignored dist/). + * + * EXIT CODES: + * 0 : build succeeded, no missing-dep warnings + * 1 : build failed OR auto-install warnings appeared + * 2 : setup error (build failed, pack failed, etc.) + * (dev mode: exit code is the server's exit, usually 0 on Ctrl+C) + * -------------------------------------------------------------------- + */ + +import { execSync, spawnSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MONOREPO_ROOT = path.resolve(__dirname, '..'); + +const args = process.argv.slice(2); +const USE_LOCAL = args.includes('--local'); +const IN_PLACE = args.includes('--in-place'); +const RUN_DEV = args.includes('--dev'); +const KEEP = args.includes('--keep'); +const NO_CLEAN = args.includes('--no-clean'); +const VERBOSE = args.includes('--verbose') || args.includes('--v'); +const SKIP_BUILD = args.includes('--skip-monorepo-build') || args.includes('--skip-build'); +const SOURCE_FLAG = args.find((a) => a.startsWith('--source=')); +// Default: the directory you ran the tool from. Makes it generic: cd into +// any consumer project (docs/, docmd.io/, your own repo) and point at the +// monorepo tool. No hardcoded path. +const SOURCE_DIR = SOURCE_FLAG + ? path.resolve(SOURCE_FLAG.slice('--source='.length)) + : path.resolve(process.cwd()); + +const TMP = path.resolve('/tmp/docmd-consumer-sim-' + Date.now()); + +// --- ANSI helpers (minimal, no deps) --- +const DIM = (s) => `\x1b[2m${s}\x1b[0m`; +const GREEN = (s) => `\x1b[32m${s}\x1b[0m`; +const RED = (s) => `\x1b[31m${s}\x1b[0m`; +const CYAN = (s) => `\x1b[36m${s}\x1b[0m`; +const BOLD = (s) => `\x1b[1m${s}\x1b[0m`; +const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`; + +function step(label, fn) { + process.stdout.write(` ${DIM('WAIT')} ${label}...`); + try { + const result = fn(); + process.stdout.write(`\r ${GREEN('DONE')} ${label} \n`); + return result; + } catch (err) { + process.stdout.write(`\r ${RED('FAIL')} ${label} \n`); + if (VERBOSE) console.error(err.stderr?.toString() || err.message); + throw err; + } +} + +function run(cmd, opts = {}) { + return execSync(cmd, { encoding: 'utf8', stdio: VERBOSE ? 'inherit' : 'pipe', timeout: 120000, ...opts }); +} + +// ----------------------------------------------------------------------- +// Step 1: Build the monorepo (needed for --local mode, skipped for npm mode) +// ----------------------------------------------------------------------- + +function buildMonorepo() { + step('Building monorepo (pnpm -r run build)', () => { + run('pnpm -r run build', { cwd: MONOREPO_ROOT }); + }); +} + +// ----------------------------------------------------------------------- +// Step 2a: --local mode — pack all packages into tarballs +// +// SAFETY: This function NEVER mutates the monorepo. Instead of rewriting +// package.json files in place (which risks leaving the monorepo broken +// if the process is killed mid-pack), it COPIES each package to a +// staging directory under /tmp, rewrites the copies, and packs from +// there. If the process is killed at ANY point, the monorepo is +// untouched — only /tmp is dirty. +// ----------------------------------------------------------------------- + +// Collect all @docmd/* packages under packages/ with their file lists. +// Returns [{ pkgDir, name, version, files }] for every packable package. +function collectMonorepoPackages() { + const packagesDir = path.join(MONOREPO_ROOT, 'packages'); + const out = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + const pkgJsonPath = path.join(full, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + if (pkg.name && pkg.name.startsWith('@docmd/')) { + out.push({ + pkgDir: full, + name: pkg.name, + version: pkg.version, + files: Array.isArray(pkg.files) ? pkg.files : ['dist'], + }); + } + } else if (fs.statSync(full).isDirectory() && !entry.startsWith('_') && !entry.startsWith('.')) { + walk(full); + } + } + }; + walk(packagesDir); + return out; +} + +// Copy a package to a staging dir, including only the files listed in its +// `files` field (what would go into the tarball) plus package.json itself. +function copyPackageToStaging(pkgInfo, stagingRoot) { + const { pkgDir, name } = pkgInfo; + // Scoped name → flatten to a safe directory name. + const flatName = name.replace('@', '').replace('/', '-'); + const destDir = path.join(stagingRoot, 'packages', flatName); + fs.mkdirSync(destDir, { recursive: true }); + + // Always copy package.json. + fs.copyFileSync(path.join(pkgDir, 'package.json'), path.join(destDir, 'package.json')); + + // Copy each entry from the `files` field (usually ['dist'] or ['dist','registry']). + for (const fileEntry of pkgInfo.files) { + const src = path.join(pkgDir, fileEntry); + const dest = path.join(destDir, fileEntry); + if (fs.existsSync(src)) { + if (fs.statSync(src).isDirectory()) { + run(`cp -R "${src}" "${dest}"`); + } else { + fs.copyFileSync(src, dest); + } + } + } + return destDir; +} + +// Build version map from all collected packages (name → version). +function buildVersionMap(packages) { + const map = {}; + for (const p of packages) { + map[p.name] = p.version; + } + return map; +} + +// Rewrite workspace:* → ^ in a SINGLE package.json (the staging copy). +// Uses the version map to resolve cross-package refs. +function rewriteDepsInPackageJson(pkgJsonPath, versionMap) { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + let changed = false; + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + if (!pkg[field]) continue; + for (const dep in pkg[field]) { + if (pkg[field][dep].startsWith('workspace:')) { + const version = versionMap[dep]; + if (version) { + pkg[field][dep] = `^${version}`; + changed = true; + } + } + } + } + if (changed) { + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + } +} + +function packLocal() { + const stagingDir = path.join(TMP, '_staging'); + const tarballDir = path.join(stagingDir, 'tarballs'); + fs.mkdirSync(stagingDir, { recursive: true }); + fs.mkdirSync(tarballDir, { recursive: true }); + + console.log(DIM(` staging: ${stagingDir}`)); + + const packages = collectMonorepoPackages(); + const versionMap = buildVersionMap(packages); + + const stagedDirs = []; + step(`Copying ${packages.length} packages to staging (monorepo untouched)`, () => { + for (const pkgInfo of packages) { + const stagedDir = copyPackageToStaging(pkgInfo, stagingDir); + stagedDirs.push(stagedDir); + } + }); + + step('Resolving workspace:* → ^version (in staging copies only)', () => { + for (const stagedDir of stagedDirs) { + const pkgJsonPath = path.join(stagedDir, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + rewriteDepsInPackageJson(pkgJsonPath, versionMap); + } + } + }); + + const tarballs = []; + step('Packing @docmd/* packages into tarballs (from staging)', () => { + for (const stagedDir of stagedDirs) { + const pkg = JSON.parse(fs.readFileSync(path.join(stagedDir, 'package.json'), 'utf8')); + const result = spawnSync('pnpm', ['pack', '--pack-destination', tarballDir], { + cwd: stagedDir, + encoding: 'utf8', + }); + if (result.status === 0) { + const tgzName = result.stdout.trim().split('\n').pop(); + if (tgzName) tarballs.push(path.join(tarballDir, path.basename(tgzName))); + } else if (VERBOSE) { + console.error(` pack failed for ${pkg.name}: ${result.stderr}`); + } + } + }); + + return tarballs; +} + +// ----------------------------------------------------------------------- +// Step 2b: npm mode — just install from the public registry +// ----------------------------------------------------------------------- + +function installFromNpm(projectDir) { + step('Installing @docmd/core from npm', () => { + run('npm install @docmd/core@latest', { cwd: projectDir }); + }); +} + +function installFromTarballs(projectDir, tarballs) { + step(`Installing ${tarballs.length} local tarballs`, () => { + // Install all local tarballs in one npm install so npm resolves the + // full dep graph from the tarballs first, then npm for anything else. + run(`npm install ${tarballs.map((t) => `"${t}"`).join(' ')}`, { cwd: projectDir }); + }); +} + +// ----------------------------------------------------------------------- +// Step 3: Copy consumer content (config + docs) +// ----------------------------------------------------------------------- + +function copyConsumerContent(projectDir) { + step(`Copying content from ${path.basename(SOURCE_DIR)}`, () => { + // Validate: the source dir must look like a docmd consumer project. + const configCandidates = ['docmd.config.js', 'docmd.config.json', 'docmd.config.mjs', 'docmd.config.ts']; + const foundConfig = configCandidates.find((c) => fs.existsSync(path.join(SOURCE_DIR, c))); + if (!foundConfig) { + throw new Error( + `No docmd.config.{js,json,mjs,ts} found in ${SOURCE_DIR}. ` + + `Run this tool from inside a docmd consumer project, or pass --source=.`, + ); + } + + // Copy the entire source project, excluding paths that would either + // pollute the temp project or mask the consumer experience we're + // trying to simulate. node_modules is the key exclusion: if we copied + // the consumer's own node_modules we'd inherit whatever they happen to + // have installed, defeating the point of a clean install. + const EXCLUDE = new Set([ + 'node_modules', + '.git', + '.DS_Store', + 'site', // docmd's default build output + 'dist', // common alt build output + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + ]); + + const entries = fs.readdirSync(SOURCE_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (EXCLUDE.has(entry.name)) continue; + const src = path.join(SOURCE_DIR, entry.name); + const dest = path.join(projectDir, entry.name); + if (entry.isDirectory()) { + run(`cp -R "${src}" "${dest}"`); + } else { + fs.copyFileSync(src, dest); + } + } + + // Strip @docmd/core from the consumer's package.json so the install + // step is the only thing that adds it (mimics a fresh user install). + const consumerPkgPath = path.join(projectDir, 'package.json'); + if (fs.existsSync(consumerPkgPath)) { + const pkg = JSON.parse(fs.readFileSync(consumerPkgPath, 'utf8')); + let changed = false; + for (const field of ['dependencies', 'devDependencies']) { + if (pkg[field] && pkg[field]['@docmd/core']) { + delete pkg[field]['@docmd/core']; + changed = true; + } + } + // Force private so npm doesn't complain about missing fields. + pkg.private = true; + if (!pkg.name) pkg.name = 'docmd-consumer-sim'; + if (changed || !pkg.private || !pkg.name) { + fs.writeFileSync(consumerPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + } + } + }); +} + +// ----------------------------------------------------------------------- +// Step 4: Run the build and capture issues +// ----------------------------------------------------------------------- + +function runBuild(projectDir) { + let output = ''; + let exitCode = 0; + + process.stdout.write(` ${DIM('WAIT')} Running docmd build...`); + try { + output = run('npx docmd build', { cwd: projectDir, timeout: 120000 }); + process.stdout.write(`\r ${GREEN('DONE')} Running docmd build \n`); + } catch (err) { + output = (err.stdout || '') + (err.stderr || ''); + exitCode = err.status ?? 1; + process.stdout.write(`\r ${RED('FAIL')} Running docmd build \n`); + } + + if (VERBOSE || exitCode !== 0) { + console.log(output); + } + + return { output, exitCode }; +} + +// ----------------------------------------------------------------------- +// Step 4b: Run the dev server (long-running, inherits stdio) +// ----------------------------------------------------------------------- + +function runDev(projectDir) { + // Dev server is long-running: inherit stdio so the user sees live + // output, can edit docs and watch hot reloads, and Ctrl+C to stop. + // No timeout, no output capture — this is the real user experience. + console.log(` ${CYAN('▶')} Starting docmd dev server in ${DIM(projectDir)}`); + console.log(` ${DIM(' Edit your docs and watch live changes. Ctrl+C to stop.')}`); + console.log(''); + + const child = spawn('npx', ['docmd', 'dev'], { + cwd: projectDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + return new Promise((resolve) => { + child.on('close', (code) => { + resolve({ exitCode: code ?? 0 }); + }); + child.on('error', (err) => { + console.error(RED(`\n Dev server failed: ${err.message}`)); + resolve({ exitCode: 1 }); + }); + // Propagate Ctrl+C to the child so the user can stop the server. + process.on('SIGINT', () => child.kill('SIGINT')); + }); +} + +// ----------------------------------------------------------------------- +// Step 5: Analyse output for issues a real user would hit +// ----------------------------------------------------------------------- + +function analyseOutput(output, exitCode) { + const issues = []; + + // Check for missing-dep warnings that auto-install couldn't fix. + if (/not found in official registry/i.test(output)) { + issues.push('Plugin/template not in registry — auto-install refused'); + } + if (/Auto-install of.*failed/i.test(output)) { + issues.push('Auto-install failed for at least one package'); + } + if (/Could not load.*after auto-install/i.test(output)) { + issues.push('Package installed but could not be loaded (exports/ESM issue)'); + } + if (/pluginLoadErrors|Could not load plugin/i.test(output)) { + issues.push('Plugin load error — missing or misconfigured'); + } + if (exitCode !== 0) { + issues.push(`Build exited with code ${exitCode}`); + } + + return issues; +} + +// ----------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------- + +function cleanup() { + if (KEEP) { + console.log(DIM(`\n Kept: ${TMP}`)); + return; + } + try { + fs.rmSync(TMP, { recursive: true, force: true }); + } catch { + // intentional: best-effort temp cleanup, never fatal + } +} + +async function main() { + // Guard: refuse to simulate the monorepo against itself. Running from + // inside docmd/ would copy the entire monorepo (packages/, scripts/, + // tests/) into the temp project, which makes no sense and would take + // forever. The user must point at a CONSUMER project (docs/, docmd.io/, + // or their own repo). + if (SOURCE_DIR === MONOREPO_ROOT || SOURCE_DIR.startsWith(MONOREPO_ROOT + path.sep)) { + console.log(''); + console.log(RED(BOLD(' ✗ Source is inside the docmd monorepo.'))); + console.log(YELLOW(` SOURCE: ${SOURCE_DIR}`)); + console.log(YELLOW(` MONOREPO: ${MONOREPO_ROOT}`)); + console.log(''); + console.log(DIM(' This tool simulates a CONSUMER project (docs/, docmd.io/, your repo).')); + console.log(DIM(' Run it from the consumer directory:')); + console.log(DIM(' cd ../docs')); + console.log(DIM(' node ../docmd/tools/simulate-consumer.mjs --local')); + console.log(''); + process.exit(2); + } + + console.log(''); + console.log(CYAN(BOLD('docmd consumer simulation'))); + console.log(DIM(` install: ${USE_LOCAL ? 'local tarballs (unreleased)' : 'npm (published)'}`)); + console.log(DIM(` workdir: ${IN_PLACE ? 'in-place (' + SOURCE_DIR + ')' : 'temp (' + TMP + ')'}`)); + console.log(DIM(` command: ${RUN_DEV ? 'dev (live server)' : 'build (one-shot)'}`)); + console.log(DIM(` source: ${SOURCE_DIR}`)); + console.log(''); + + try { + if (IN_PLACE) { + await runInPlaceMode(); + } else { + await runTempMode(); + } + } catch (err) { + console.log(''); + console.log(RED(BOLD(' ✗ Simulation setup failed:'))); + console.log(RED(` ${err.message}`)); + if (VERBOSE && err.stack) console.log(DIM(err.stack)); + cleanup(); + process.exit(2); + } +} + +// ----------------------------------------------------------------------- +// Install @docmd/core into a target directory (shared by both modes) +// ----------------------------------------------------------------------- + +async function installCore(targetDir) { + if (USE_LOCAL) { + if (!SKIP_BUILD) { + buildMonorepo(); + } else { + console.log(DIM(' (skipping monorepo build — assuming packages/*/dist/ is current)')); + } + const tarballs = packLocal(); + installFromTarballs(targetDir, tarballs); + } else { + installFromNpm(targetDir); + } +} + +// ----------------------------------------------------------------------- +// Mode A: Temp-dir simulation (isolated, clean, CI-like) +// ----------------------------------------------------------------------- + +async function runTempMode() { + fs.mkdirSync(TMP, { recursive: true }); + run('npm init -y', { cwd: TMP }); + + await installCore(TMP); + copyConsumerContent(TMP); + + if (RUN_DEV) { + const { exitCode } = await runDev(TMP); + cleanup(); + process.exit(exitCode); + } + + const { output, exitCode } = runBuild(TMP); + const issues = analyseOutput(output, exitCode); + + console.log(''); + if (issues.length === 0) { + console.log(GREEN(BOLD(' ✓ No consumer-facing issues detected'))); + console.log(DIM(` Build succeeded, all deps resolved, no missing-plugin warnings.`)); + console.log(DIM(` Site output: ${TMP}/site`)); + console.log(DIM(` Preview with: npx serve ${TMP}/site`)); + cleanup(); + process.exit(0); + } else { + console.log(RED(BOLD(` ✗ ${issues.length} issue(s) a real user would hit:`))); + for (const issue of issues) { + console.log(RED(` - ${issue}`)); + } + console.log(DIM(`\n Temp dir preserved: ${TMP}`)); + console.log(DIM(' Re-run with --keep to inspect, or --verbose for full output.')); + process.exit(1); + } +} + +// ----------------------------------------------------------------------- +// Mode B: In-place simulation (works directly in the consumer dir) +// +// This modifies the consumer dir exactly as a normal `docmd build` would: +// - package.json gets @docmd/core (and auto-installed plugins/templates) +// - node_modules is created/updated +// - site/ (or dist/) is created by the build +// +// The user sees everything happen in front of them. To revert: +// git checkout package.json && rm -rf node_modules site +// ----------------------------------------------------------------------- + +// Remove stale state from the consumer dir so every sim run starts from +// a clean install. Without this, leftover node_modules from a previous +// sim run or from dev:local mask missing-dep issues — the exact bug we're +// trying to catch. The clean is mandatory in in-place mode unless the +// user passes --no-clean. +function cleanConsumerDir(dir) { + step('Cleaning stale state (node_modules, lockfiles, site/)', () => { + const paths = [ + 'node_modules', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'site', + ]; + for (const rel of paths) { + const full = path.join(dir, rel); + if (fs.existsSync(full)) { + fs.rmSync(full, { recursive: true, force: true }); + } + } + }); +} + +// Reset the consumer's package.json to a known-clean baseline before +// installing. Strips @docmd/* entries (added by auto-install in previous +// runs) and any stale dep references, leaving only the user's own deps. +function resetConsumerPackageJson(dir) { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) return; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + for (const field of ['dependencies', 'devDependencies']) { + if (!pkg[field]) continue; + // Remove any @docmd/* packages that a previous sim auto-installed. + // The user's real deps (docmd-search, etc.) stay. + for (const dep of Object.keys(pkg[field])) { + if (dep.startsWith('@docmd/')) { + delete pkg[field][dep]; + } + } + // Drop the field if it's now empty. + if (Object.keys(pkg[field]).length === 0) { + delete pkg[field]; + } + } + + pkg.private = true; + if (!pkg.name) pkg.name = 'docmd-consumer-sim'; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); +} + +async function runInPlaceMode() { + console.log(YELLOW(` ⚠ In-place mode: ${SOURCE_DIR} will be modified.`)); + console.log(DIM(' package.json, node_modules, and site/ will change as a normal docmd run.')); + console.log(DIM(' Revert with: git checkout package.json && rm -rf node_modules site')); + console.log(''); + + // Clean first so every run starts from a real fresh install. This is + // the whole point of the simulation — if stale node_modules survive + // from a previous run, the sim can't catch missing-dep issues. + // Pass --no-clean to skip (useful for rapid iteration where you only + // changed markdown content and want to keep the install step fast). + if (!NO_CLEAN) { + cleanConsumerDir(SOURCE_DIR); + resetConsumerPackageJson(SOURCE_DIR); + } else { + console.log(DIM(' (--no-clean: keeping existing node_modules and package.json)')); + } + + await installCore(SOURCE_DIR); + + if (RUN_DEV) { + const { exitCode } = await runDev(SOURCE_DIR); + process.exit(exitCode); + } + + const { output, exitCode } = runBuild(SOURCE_DIR); + const issues = analyseOutput(output, exitCode); + + console.log(''); + if (issues.length === 0) { + console.log(GREEN(BOLD(' ✓ Build completed in-place, no consumer-facing issues.'))); + console.log(DIM(` site/ is ready in ${SOURCE_DIR}`)); + process.exit(0); + } else { + console.log(RED(BOLD(` ✗ ${issues.length} issue(s) a real user would hit:`))); + for (const issue of issues) { + console.log(RED(` - ${issue}`)); + } + process.exit(1); + } +} + +main();