/** * -------------------------------------------------------------------- * docmd : the zero-config documentation engine. * * Slice C.1 + C.2 β€” Plugin-contract and public-API fixes * * Covers: * D-H1 buildSite(configPath) honours the configPath (and an * explicit opts.cwd) instead of always using process.cwd(). * D-H2 @docmd/core re-exports buildWorkspace, detectWorkspace, * and isWorkspace so the documented API actually imports. * D-H3 generateScripts gets a `target: 'head'|'body'` third arg * so plugins can render different content per slot. * D-S4 generateMetaTags returns must be a string; objects produce * a warning and are skipped instead of being stringified to * "[object Object]" and injected into every page's . * D-S5 generateScripts plain-string returns are treated as the * head slot; previously they were silently dropped on body. * D-M1 translations returns must be a plain stringβ†’string map; * strings used to spread into garbage numeric keys. * D-H7 onBeforeParse chain continues when a plugin throws. * D-S2 buildContextualUrl strips the `external:` prefix. * D-M2 PostBuildContext.config + pages are typed. * D-M6 URL utility re-export types are aligned. * T-Z3 Unknown top-level config keys produce a warning. * * Run: `node tests/runner.js --only=plugin-contract` * -------------------------------------------------------------------- */ import { DOCMD, TEST_ROOT, setup, writeFile, build, runTestFile } from '../shared.js'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'path'; let passed = 0; let failed = 0; const failures = []; function assert(condition, message) { if (!condition) { failed++; failures.push(message); console.log(` ❌ ${message}`); } else { passed++; console.log(` βœ… ${message}`); } } export const test = runTestFile({ name: 'Plugin contract + public API fixes (Slice C.1 + C.2)', emoji: 'πŸ”Œ', run: async () => { // D-H2 β€” re-exports. Direct import to confirm the symbols exist // on the public package surface. We don't need to run a full // build β€” the import-time check is the test. { const apiPkg = path.resolve(import.meta.dirname, '..', '..', 'packages', 'core', 'dist', 'index.js'); const source = fs.readFileSync(apiPkg, 'utf8'); assert(/export\s*\{\s*[^}]*buildWorkspace[^}]*\}\s*from/.test(source), 'D-H2: buildWorkspace re-exported from @docmd/core'); assert(/export\s*\{\s*[^}]*detectWorkspace[^}]*\}\s*from/.test(source), 'D-H2: detectWorkspace re-exported from @docmd/core'); assert(/export\s*\{\s*[^}]*isWorkspace[^}]*\}\s*from/.test(source), 'D-H2: isWorkspace re-exported from @docmd/core'); } // D-H1 β€” buildSite honours the configPath. We build the site from // a directory OTHER than cwd, and verify it actually picks up the // docs from that directory (not from cwd/docs). { const proj = setup('plugin-contract-c1-dh1-configpath'); writeFile(proj, 'docs/index.md', '# Hi\n'); writeFile(proj, 'docmd.config.json', JSON.stringify({ title: 'D-H1', src: './docs', out: './site' }, null, 2) + '\n'); // Also plant a decoy docs/ in cwd so a wrong cwd would be detected. const decoyDir = path.join(TEST_ROOT, '__cwd-decoy'); fs.mkdirSync(path.join(decoyDir, 'docs'), { recursive: true }); writeFile(decoyDir, 'docs/index.md', '# DECOY\n'); const configPath = path.join(proj, 'docmd.config.json'); const cmd = `node ${DOCMD} build --config "${configPath}"`; const result = (() => { try { return { ok: true, output: execSync(cmd, { cwd: decoyDir, stdio: 'pipe', encoding: 'utf8' }) }; } catch (e) { return { ok: false, output: (e.stderr || '') + (e.stdout || '') }; } })(); assert(result.ok, 'D-H1: build with --config from a foreign cwd succeeds'); assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-H1: build output written next to configPath, not cwd'); const builtHtml = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); assert(!/DECOY/.test(builtHtml), 'D-H1: built page is from configPath\'s docs/, not cwd\'s docs/'); } // D-S4 β€” generateMetaTags returning an object produces a warning // and does NOT inject "[object Object]" into . The dispatcher // also warns (exactly once) per build. { const proj = setup('plugin-contract-c1-ds4-object-return'); writeFile(proj, 'docs/index.md', '# Hi\n'); // Local-path plugins need a directory layout (package.json + index.js) // so the loader can resolve via safePath + package.json main field. writeFile(proj, 'plugins/evil-meta/package.json', JSON.stringify({ name: 'evil-meta', version: '1.0.0', type: 'module', main: 'index.js' }) + '\n'); writeFile(proj, 'plugins/evil-meta/index.js', [ "export const plugin = { name: 'evil-meta', version: '1.0.0', capabilities: ['head'] };", "export async function generateMetaTags() { return { random: 'object' }; }", "" ].join('\n')); writeFile(proj, 'docmd.config.json', JSON.stringify({ title: 'D-S4', src: './docs', out: './site', plugins: { './plugins/evil-meta': {} } }, null, 2) + '\n'); let output = ''; try { output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' }); } catch (e) { output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : ''); } assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-S4: build completes (plugin was loaded)'); const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); assert(!/\[object Object\]/.test(html), 'D-S4: object return from generateMetaTags is NOT injected into '); assert(/non-string/i.test(output), 'D-S4: TUI warns about the non-string return'); } // D-S5 β€” generateScripts returning a plain string is honoured on // the head side (previously dropped because `result?.bodyScriptsHtml` // was undefined, AND head was `result?.headScriptsHtml` which was // also undefined on a plain-string return). { const proj = setup('plugin-contract-c1-ds5-string-return'); writeFile(proj, 'docs/index.md', '# Hi\n'); writeFile(proj, 'plugins/string-scripts/package.json', JSON.stringify({ name: 'string-scripts', version: '1.0.0', type: 'module', main: 'index.js' }) + '\n'); writeFile(proj, 'plugins/string-scripts/index.js', [ "export const plugin = { name: 'string-scripts', version: '1.0.0', capabilities: ['head', 'body'] };", "export async function generateScripts() { return ''; }", "" ].join('\n')); writeFile(proj, 'docmd.config.json', JSON.stringify({ title: 'D-S5', src: './docs', out: './site', plugins: { './plugins/string-scripts': {} } }, null, 2) + '\n'); let output = ''; try { output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' }); } catch (e) { output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : ''); } const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8'); assert(/