From f4fb97e7cd683389bee459789696e79630285adc Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 23 Sep 2026 10:58:09 +0200 Subject: [PATCH 1/3] fix: absolute CSS URLs and no stale portal copies in a pierced fragment Embedded with server-side piercing, reframed copies every linked sub-app stylesheet into an adopted constructed stylesheet when it portals the fragment, and only drops the copy once the sheet is fetched again. On the moveBefore() path it never is, so the first app's CSS stayed applied to the catalog and to every other app for the life of the fragment. The copy also resolves url() against the host document, whose router strips the trailing slash, so a relative url() in a sub-app stylesheet 404'd (/knowledge-base/docs/... instead of /knowledge-base/user-guide/docs/...). - copyAssets() now makes every url() and @import in copied sub-app CSS absolute, resolving relative ones against the stylesheet's own URL (rewriteCssUrls(), shared with the inline-style rewrite). - embedded-transitions.js drops the knowledge base's own adopted copies before the first swap, identified by the layer-order statement every built sub-app stylesheet opens with. Serving scripts and styles from a separate path prefix was evaluated and rejected: the gateway dispatches on sec-fetch-dest, not on the path, and a retargeted build reproduced both failures unchanged. Tests: relative url() rewrite (unit + dist scan), and an embedded test that starts on an app page, crosses to another app, and asserts no copy remains, styles match a hard load and no request fails. Both fail without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- scripts/build-vite.js | 24 +++++++------ src/scripts/embedded-transitions.js | 37 ++++++++++++++++++- src/utils/transform.js | 29 +++++++++++---- tests/build-integrity.spec.js | 23 ++++++++++++ tests/css-isolation.spec.js | 56 +++++++++++++++++++++++++++++ tests/transform.spec.js | 20 ++++++++++- 7 files changed, 170 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1432605..00bc457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ Known gap: inline `on*` handlers in sub-app HTML are not stripped (#67). They ar `transform.js` parses the document with **parse5** and rewrites every URL-bearing attribute to an absolute `/{prefix}/{slug}/…` path: `href`/`src`/`action`/`formaction`/`poster`, `object[data]`, `srcset`/`imagesrcset`, `url()` in inline `style=` and `' }), + '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 %} +
+ {{ config.site_name }} + +
+ {% endif %} + +
+ + + {{ 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/" } +]