diff --git a/CLAUDE.md b/CLAUDE.md
index 1432605..e408596 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -85,7 +85,7 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
- `scripts/artifacts.js` — Safe tarball extraction + tree copy, shared by both fetch paths. Validates archive members (no traversal, no absolute paths, no symlinks) before anything is written, and replaces the old `cp -r`/`tar` shell-outs so the build runs on Windows
- `scripts/hoist-inline-scripts.js` — Moves inline `',
+ body:
+ ' ' +
+ '
Write <base href="/"> or onclick="x()" in prose freely.
' +
+ 'Guide Top Intro ' +
+ 'External PDF Mail ' +
+ ' ',
+ }),
+ 'guide/index.html': page(),
+ 'assets/app.js': 'fetch(new URL("data.json", import.meta.url)); fetch("/api/x"); fetch(`${base}/y`);' +
+ 's.open("GET","function"==typeof e?e(n):e,!0);',
+ }), []);
+});
+
+console.log('\nManifest and artifact');
+
+check('KB-ART-001: a missing entry point', () => {
+ expectIds(site('no-entry', { 'other.html': page() }), ['KB-ART-001']);
+});
+
+check('KB-ART-002: a pages entry that is not in the output', () => {
+ const app = { ...APP, pages: [{ title: 'Gone', path: 'gone/index.html', order: 1 }] };
+ expectIds(site('gone-page', { 'index.html': page() }, app), ['KB-ART-002']);
+});
+
+check('KB-ART-003: no HTML at all', () => {
+ expectIds(site('no-html', { 'readme.txt': 'x' }, { ...APP, entryPoint: 'readme.txt' }), ['KB-ART-003']);
+});
+
+console.log('\nHTML');
+
+check('KB-HTML-001: a page without the headless marker, and the legacy marker by name', () => {
+ const missing = site('no-marker', { 'index.html': page({ html: 'lang="en"' }) });
+ expectIds(missing, ['KB-HTML-001']);
+ assert.match(missing[0].message, /missing data-kb-headless/);
+ const legacy = site('legacy-marker', { 'index.html': page({ html: 'data-mp-headless="true"' }) });
+ assert.match(legacy[0].message, /data-mp-headless.*pre-v1/);
+});
+
+check('KB-HTML-002: a element', () => {
+ expectIds(site('base', { 'index.html': page({ head: ' ' }) }), ['KB-HTML-002']);
+});
+
+check('KB-HTML-003: root-relative URLs, named with an example', () => {
+ const f = site('root-relative', { 'index.html': page({ body: 'G ' }) });
+ expectIds(f, ['KB-HTML-003']);
+ assert.match(f[0].message, /2 root-relative URL\(s\), e\.g\. "\/images\/logo\.png"/);
+});
+
+check('KB-HTML-004: an executable inline script, not a data block', () => {
+ expectIds(site('inline', { 'index.html': page({ body: '' }) }), ['KB-HTML-004']);
+});
+
+check('KB-HTML-005: an inline event handler', () => {
+ const f = site('handler', { 'index.html': page({ body: 'Go ' }) });
+ expectIds(f, ['KB-HTML-005']);
+ assert.match(f[0].message, //);
+});
+
+check('KB-HTML-006: a javascript: URL', () => {
+ expectIds(site('js-url', { 'index.html': page({ body: 'x ' }) }), ['KB-HTML-006']);
+});
+
+check('KB-HTML-007: a directly inside , not one inside the content', () => {
+ const html = page().replace('', '');
+ expectIds(site('site-header', { 'index.html': html }), ['KB-HTML-007']);
+});
+
+check('KB-HTML-008: a link to a non-HTML file in the app', () => {
+ const f = site('download', { 'index.html': page({ body: 'Spec Big ' }) });
+ expectIds(f, ['KB-HTML-008']);
+ assert.match(f[0].message, /2 link\(s\).*"files\/spec\.pdf"/);
+});
+
+console.log('\nCSS, theme, CSP, JavaScript');
+
+check('KB-CSS-001: !important in a stylesheet file and in an inline ' }),
+ 'assets/site.css': 'a { color: blue !important; } p { margin: 0 !important }',
+ });
+ expectIds(f, ['KB-CSS-001', 'KB-CSS-001']);
+ assert.ok(f.some((x) => x.where === 'demo/assets/site.css' && /^2 !important/.test(x.message)), JSON.stringify(f));
+});
+
+check('KB-THEME-001: a theme bootstrap script, and a dark class', () => {
+ const bootstrap = "if (localStorage.getItem('theme') === 'dark') document.documentElement.classList.add('dark');";
+ expectIds(site('theme-script', { 'index.html': page({ head: `` }) }), ['KB-HTML-004', 'KB-THEME-001']);
+ expectIds(site('theme-class', { 'index.html': page({ bodyAttrs: ' class="docs dark"' }) }), ['KB-THEME-001']);
+});
+
+check('KB-CSP-001: scripts, stylesheets and fonts from another origin', () => {
+ const f = site('csp', {
+ 'index.html': page({ head: ' ' }),
+ 'assets/site.css': "@import url('https://fonts.googleapis.com/css2?family=Inter');\n" +
+ "@font-face { font-family: X; src: url(https://cdn.example.com/x.woff2) format('woff2'); }",
+ });
+ expectIds(f, ['KB-CSP-001', 'KB-CSP-001']);
+});
+
+check('KB-JS-001: a page-relative fetch in a file and in an inline script', () => {
+ const f = site('fetch', {
+ 'index.html': page({ body: "" }),
+ 'assets/app.js': "const x = new XMLHttpRequest(); x.open('GET', 'data/index.json');",
+ });
+ expectIds(f, ['KB-HTML-004', 'KB-JS-001', 'KB-JS-001']);
+});
+
+console.log('\nCLI');
+
+function cli(dir, ...args) {
+ try {
+ return { code: 0, out: execFileSync(process.execPath, [CLI, ...args], { cwd: dir, encoding: 'utf8' }) };
+ } catch (err) {
+ return { code: err.status, out: `${err.stdout}${err.stderr}` };
+ }
+}
+
+function workspace(name, files) {
+ const dir = join(root, name);
+ rmSync(dir, { recursive: true, force: true });
+ mkdirSync(join(dir, 'dist'), { recursive: true });
+ writeFileSync(join(dir, 'kb-docs.json'), JSON.stringify({ kbVersion: '1', apps: [APP] }));
+ for (const [rel, content] of Object.entries(files)) {
+ mkdirSync(join(dir, 'dist', dirname(rel)), { recursive: true });
+ writeFileSync(join(dir, 'dist', rel), content);
+ }
+ return dir;
+}
+
+check('exits 0 on warnings only, 1 under --strict, and prints rule IDs', () => {
+ const dir = workspace('cli-warn', { 'index.html': page({ body: '' }) });
+ const plain = cli(dir);
+ assert.equal(plain.code, 0, plain.out);
+ assert.match(plain.out, /warning KB-HTML-004 demo\/index\.html:/);
+ assert.match(plain.out, /contract\/RULES\.md/);
+ assert.equal(cli(dir, '--strict').code, 1);
+});
+
+check('exits 1 on an error, and --json is machine-readable', () => {
+ const dir = workspace('cli-error', { 'index.html': page({ html: 'lang="en"' }) });
+ const { code, out } = cli(dir, '--json');
+ assert.equal(code, 1);
+ const findings = JSON.parse(out);
+ assert.deepEqual(findings.map((f) => [f.id, f.severity, f.where]), [['KB-HTML-001', 'error', 'demo/index.html']]);
+});
+
+check('reports a broken manifest as KB-MAN-001 instead of crashing', () => {
+ const dir = workspace('cli-manifest', { 'index.html': page() });
+ writeFileSync(join(dir, 'kb-docs.json'), '{ not json');
+ const { code, out } = cli(dir);
+ assert.equal(code, 1);
+ assert.match(out, /error {3}KB-MAN-001 kb-docs\.json: .*not valid JSON/);
+});
+
+console.log('\nCatalogue');
+
+check('contract/RULES.md and rules.js list the same rules, titles and severities', () => {
+ const md = readFileSync(RULES_MD, 'utf8');
+ const documented = {};
+ for (const m of md.matchAll(/^### (KB-[A-Z]+-\d{3}) — (.+)\n\n\*\*Severity:\*\* (error|warning)$/gm)) {
+ documented[m[1]] = { severity: m[3], title: m[2].replace(/`/g, '') };
+ }
+ const coded = Object.fromEntries(Object.entries(RULES).map(([id, r]) => [id, { severity: r.severity, title: r.title }]));
+ assert.deepEqual(documented, coded);
+
+ const indexed = [...md.matchAll(/^\| \[(KB-[A-Z]+-\d{3})\]\(#[^)]+\) \| (error|warning) \|/gm)].map((m) => [m[1], m[2]]);
+ assert.deepEqual(indexed, Object.entries(RULES).map(([id, r]) => [id, r.severity]), 'the index table is out of step');
+});
+
+check('every rule the checker can report is in the catalogue', () => {
+ const sources = ['check.js', 'check-cli.js', 'manifest.js', 'pack.js'].map((f) => readFileSync(join(__dirname, f), 'utf8')).join('\n');
+ const used = new Set(sources.match(/KB-[A-Z]+-\d{3}/g));
+ for (const id of used) assert.ok(RULES[id], `${id} is reported but not in rules.js`);
+ for (const id of Object.keys(RULES)) assert.ok(used.has(id), `${id} is in rules.js but nothing reports it`);
+});
+
+rmSync(root, { recursive: true, force: true });
+if (failures > 0) {
+ console.log(`\n\x1b[31m${failures} check(s) failed\x1b[0m`);
+ process.exit(1);
+}
+console.log('\nAll checks passed');
diff --git a/actions/lib/manifest.js b/actions/lib/manifest.js
index 56aa48e..2f52248 100644
--- a/actions/lib/manifest.js
+++ b/actions/lib/manifest.js
@@ -70,7 +70,7 @@ export function validateManifest(manifest, source) {
});
throw new PublishError(
- `${source} does not satisfy the knowledge base contract:\n${[...new Set(lines)].join('\n')}\n\n` +
+ `KB-MAN-001 ${source} does not satisfy the knowledge base contract:\n${[...new Set(lines)].join('\n')}\n\n` +
`See contract/ARTIFACT.md for what each field means.`,
);
}
@@ -83,7 +83,7 @@ export function validateManifest(manifest, source) {
export function readManifestFile(file) {
if (!existsSync(file)) {
throw new PublishError(
- `No manifest at ${file}.\n` +
+ `KB-MAN-001 No manifest at ${file}.\n` +
`Create a ${MANIFEST} in your repository root describing the app(s) this release publishes — ` +
`see contract/ARTIFACT.md for the shape, or set the action's "manifest" input if it lives elsewhere.`,
);
@@ -92,7 +92,7 @@ export function readManifestFile(file) {
try {
manifest = JSON.parse(readFileSync(file, 'utf8'));
} catch (err) {
- throw new PublishError(`${file} is not valid JSON — ${err.message}`);
+ throw new PublishError(`KB-MAN-001 ${file} is not valid JSON — ${err.message}`);
}
return validateManifest(manifest, file);
}
diff --git a/actions/lib/pack.js b/actions/lib/pack.js
index a9bca2c..d30637b 100644
--- a/actions/lib/pack.js
+++ b/actions/lib/pack.js
@@ -69,7 +69,7 @@ function checkSize(outPath) {
if (bytes > SIZE_LIMIT) {
throw new PublishError(
- `The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_LIMIT)} MB limit ` +
+ `KB-ART-005 The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_LIMIT)} MB limit ` +
`(which is also GitHub's per-asset release limit).\n` +
`The usual cause is uncompressed images or a vendored toolchain the built site does not ` +
`need at runtime. See contract/ARTIFACT.md.`,
@@ -77,7 +77,7 @@ function checkSize(outPath) {
}
if (bytes > SIZE_WARN) {
process.stdout.write(
- `::warning::The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_WARN)} MB target. ` +
+ `::warning title=KB-ART-004::KB-ART-004 The packed artifact is ${mb(bytes)} MB, over the ${mb(SIZE_WARN)} MB target. ` +
`Every knowledge base build downloads it — see contract/ARTIFACT.md.\n`,
);
}
diff --git a/actions/lib/rules.js b/actions/lib/rules.js
new file mode 100644
index 0000000..54458a9
--- /dev/null
+++ b/actions/lib/rules.js
@@ -0,0 +1,54 @@
+/**
+ * rules.js — the contract's rules, by ID.
+ *
+ * contract/RULES.md is the normative text: what each rule requires, why, and
+ * how to fix a violation. This is the machine side of the same catalogue, and
+ * the self-test fails if the two list different IDs or severities.
+ *
+ * Severity is what the publishing action does with a finding: an `error` stops
+ * the publish, a `warning` is annotated on the run and the publish goes ahead.
+ * New rules start as warnings; turning one into an error breaks repos that
+ * published fine yesterday, so that only happens with a major version.
+ */
+
+export const RULES = Object.freeze({
+ 'KB-MAN-001': { severity: 'error', title: 'kb-docs.json exists and satisfies the schema' },
+ 'KB-ART-001': { severity: 'error', title: 'The entry point exists in the built output' },
+ 'KB-ART-002': { severity: 'error', title: 'Every pages entry exists in the built output' },
+ 'KB-ART-003': { severity: 'error', title: 'Each app contains HTML' },
+ 'KB-ART-004': { severity: 'warning', title: 'The artifact is at most 20 MB' },
+ 'KB-ART-005': { severity: 'error', title: 'The artifact is at most 100 MB' },
+ 'KB-HTML-001': { severity: 'error', title: 'Every page is marked headless' },
+ 'KB-HTML-002': { severity: 'error', title: 'No element' },
+ 'KB-HTML-003': { severity: 'error', title: 'No root-relative URLs' },
+ 'KB-HTML-004': { severity: 'warning', title: 'No inline ') },
});
const { stdout, artifact } = publish(ws);
- assert.match(stdout, /::warning::.*inline
+
+
+ {% if not config.extra.headless %}
+
+ {% endif %}
+
+
+ Copy page
+ ◐
+ {{ page.content }}
+
+
+
+
+
diff --git a/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/style.css b/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/style.css
new file mode 100644
index 0000000..83785b8
--- /dev/null
+++ b/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/style.css
@@ -0,0 +1,7 @@
+:root { --bg-page: #f8f9fb; --text-body: #374151; }
+body { font-family: 'Source Sans 3', sans-serif; }
+.topnav { position: fixed; top: 0; height: 56px; }
+#sidebar { position: sticky; top: 0; }
+#content h1 { color: #1b0e12 !important; }
+#content a { text-decoration: underline !important; }
+.dark body { background: #111827; color: #e5e7eb; }
diff --git a/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/versions.json b/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/versions.json
new file mode 100644
index 0000000..28054e7
--- /dev/null
+++ b/tests/fixtures/kb-docs-add/onboarded-mkdocs/theme/versions.json
@@ -0,0 +1,4 @@
+[
+ { "title": "latest", "path": "" },
+ { "title": "2.x", "path": "2.x/" }
+]
diff --git a/tests/transform.spec.js b/tests/transform.spec.js
index fac5e81..eb266ba 100644
--- a/tests/transform.spec.js
+++ b/tests/transform.spec.js
@@ -12,7 +12,7 @@
*/
import { test, expect } from '@playwright/test';
-import { transformSubAppHtml, isThemeBootstrap } from '../src/utils/transform.js';
+import { transformSubAppHtml, isThemeBootstrap, rewriteCssUrls } from '../src/utils/transform.js';
import { layerSubAppCss, LAYER_ORDER, SUB_APP_LAYER } from '../src/utils/css-layers.js';
const PREFIX = 'knowledge-base';
@@ -116,6 +116,24 @@ test.describe('URL rewriting', () => {
expect(bodyHtml).toContain('url(/knowledge-base/demo/bg.png)');
expect(headHtml).toContain("url('/knowledge-base/demo/docs/img/hero.png')");
});
+
+ test('a stylesheet file resolves its URLs against its own path, never the page', () => {
+ const css = rewriteCssUrls(
+ '@import "base.css";\n@import url(../theme/t.css) screen;\n' +
+ ".a{background:url('img/x.png')}\n.b{background:url(/root.png)}\n" +
+ '.c{background:url(data:image/gif;base64,R0)}\n.d{mask:url(#m)}\n' +
+ '.e{background:url(https://cdn.example.com/y.png)}\n.f{background:url(//cdn.example.com/z.png)}',
+ '/knowledge-base/demo/assets/css/site.css', PREFIX, SLUG,
+ );
+ expect(css).toContain('@import "/knowledge-base/demo/assets/css/base.css";');
+ expect(css).toContain('@import url(/knowledge-base/demo/assets/theme/t.css) screen;');
+ expect(css).toContain("url('/knowledge-base/demo/assets/css/img/x.png')");
+ expect(css).toContain('url(/knowledge-base/demo/root.png)');
+ expect(css).toContain('url(data:image/gif;base64,R0)');
+ expect(css).toContain('url(#m)');
+ expect(css).toContain('url(https://cdn.example.com/y.png)');
+ expect(css).toContain('url(//cdn.example.com/z.png)');
+ });
});
// Inside a web fragment a sub-app stylesheet can outlive its page (reframed