From 263ad5720caeb6087aea5caf1523af9601051efd Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 23 Oct 2026 07:16:02 -0400 Subject: [PATCH 01/15] fix(capture): media.mjs downloads the bare-path original on query-string image hosts, lists each variant, and marks a resized fallback --- skills/capture-site/SKILL.md | 14 ++- skills/capture-site/scripts/media.mjs | 128 +++++++++++++++++++++----- test/capture/media.test.mjs | 100 +++++++++++++++++++- 3 files changed, 219 insertions(+), 23 deletions(-) diff --git a/skills/capture-site/SKILL.md b/skills/capture-site/SKILL.md index 9dac815..2b9e84d 100644 --- a/skills/capture-site/SKILL.md +++ b/skills/capture-site/SKILL.md @@ -124,6 +124,16 @@ source. Run each step as its own command, in this order: only)`: repeat that line to the person. Use `--sizes all` when the owner says old emails or other sites may link to those sizes. + Hosts that resize by query string (WordPress VIP, Jetpack Photon: `photo.jpg?w=1024`, + `?resize=300,200`) serve every size at the same path. The original is the bare path, so that + is what is requested and saved; each query string seen is listed under `variants` (not + requested; `--sizes all` saves them as `%3F`). When the bare path does not + answer 200, the widest variant is saved in its place and the entry says `resized: true` and + `downloadedFrom`; the run prints a line per file and a count (`2 originals unavailable + (resized: true) ...`). Repeat that count to the person and list those files in the plan and + runbook as originals the live site no longer serves: the gates compare against the resized + bytes actually saved, so they do not catch it. + 4. **Templates.** Clusters the pages that answered 200 into `capture/templates.json`. On WordPress, pages group by template body class first (`home`, `page`, `single-post`, `archive`, template classes) and a page whose markup is clearly different (Jaccard below 0.5) @@ -242,7 +252,9 @@ redirects to a captcha path. Every page then looks the same, so a capture of it excluded prefix (kept in the HTML, never in internal, never requested) scripts.json third-party vendors with their ids and pages media.json every media file with sources, pages, status, type and bytes; a size - only the REST list names: downloaded: false, "unreferenced size" + only the REST list names: downloaded: false, "unreferenced size"; + query-string sizes under variants; resized: true when only a + resized copy could be saved css/ every stylesheet, inline `; + +const document = (title, body) => ` + + + + +${escapeHtml(title)} +${STYLE} + + +${body} + + +`; + +// A file name for each route, unique within the sheet. +function pageNames(routes) { + const used = new Set(); + return new Map(routes.map((route) => { + const stem = String(route).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '') || 'route'; + let name = stem; + for (let n = 2; used.has(name.toLowerCase()); n += 1) name = `${stem}-${n}`; + used.add(name.toLowerCase()); + return [route, `${name}.html`]; + })); +} + +// Writes the index at out and one page per route beside it; returns the counts. +export function writeSheet({ liveDir, newDir, diffFile, stylesFile, out }) { const live = indexManifest(liveDir); const next = indexManifest(newDir); const diff = readJson(diffFile, 'diff'); if (!Array.isArray(diff)) throw new UsageError(`${diffFile} is not a JSON array`); const styles = stylesFile ? readJson(stylesFile, 'styles') : []; + if (!Array.isArray(styles)) throw new UsageError(`${stylesFile} is not a JSON array`); const diffDir = dirname(diffFile); const diffByKey = new Map(diff.map((d) => [key(d.route, d.width), d])); - const order = []; - const seen = new Set(); + const widthsOf = new Map(); for (const { route, width } of [...diff, ...live.values(), ...next.values(), ...styles]) { - const k = key(route, width); - if (!seen.has(k)) { - seen.add(k); - order.push({ route, width }); - } + if (!widthsOf.has(route)) widthsOf.set(route, []); + if (!widthsOf.get(route).includes(width)) widthsOf.get(route).push(width); } - const routes = [...new Set(order.map((o) => o.route))]; + const badStyles = new Map(); + for (const s of styles.filter((row) => !row.pass)) { + const k = key(s.route, s.width); + if (!badStyles.has(k)) badStyles.set(k, []); + badStyles.get(k).push(s); + } + const routes = [...widthsOf.keys()]; + const names = pageNames(routes); + const stem = basename(out, extname(out)); + const pagesDir = join(dirname(out), `${stem}-pages`); + mkdirSync(pagesDir, { recursive: true }); let failing = 0; - const sections = routes.map((route) => { - const rows = order.filter((o) => o.route === route).map(({ width }) => { + let pairs = 0; + const indexRows = []; + for (const route of routes) { + const cells = []; + let routeFailing = 0; + const rows = widthsOf.get(route).map((width) => { + pairs += 1; const k = key(route, width); const a = live.get(k); const b = next.get(k); const d = diffByKey.get(k); - const relativeFile = a ? relative(liveDir, a.path) : b ? relative(newDir, b.path) : null; + const relativeFile = a?.path ? relative(liveDir, a.path) : b?.path ? relative(newDir, b.path) : null; const diffImage = d && relativeFile ? join(diffDir, relativeFile) : null; - const badStyles = styles.filter((s) => s.route === route && s.width === width && !s.pass); - if (d && !d.pass) failing += 1; - failing += badStyles.length; - const percent = Number.isFinite(d?.percent) ? `${d.percent.toFixed(2)} %` : 'n/a'; + const bad = badStyles.get(k) ?? []; + const rowFailing = (d && !d.pass ? 1 : 0) + bad.length; + routeFailing += rowFailing; + cells.push(`${escapeHtml(width)} px ${d ? `${d.pass ? 'pass' : 'fail'} ${escapeHtml(percentOf(d))}` : 'no diff'}${bad.length ? ` ${bad.length} style` : ''}`); const summary = d - ? `${verdict(d.pass)} ${escapeHtml(percent)} differing pixels heights ${escapeHtml(d.heightA ?? '?')} / ${escapeHtml(d.heightB ?? '?')} px` + ? `${verdict(d.pass)} ${escapeHtml(percentOf(d))} differing pixels heights ${escapeHtml(d.heightA ?? '?')} / ${escapeHtml(d.heightB ?? '?')} px` : `${a || b ? 'no diff' : 'style only'}`; const shots = a || b || d ? `
-${figure('Live', a?.path, a?.bannerClosed ? 'banner closed' : '')} -${figure('New', b?.path, b?.bannerClosed ? 'banner closed' : '')} -${d ? figure('Diff', diffImage) : ''} +${figure('Live', a?.path, pagesDir, a?.bannerClosed ? 'banner closed' : '')} +${figure('New', b?.path, pagesDir, b?.bannerClosed ? 'banner closed' : '')} +${d ? figure('Diff', diffImage, pagesDir) : ''}
` : ''; return `

${escapeHtml(width)} px ${summary}

${shots} -${styleTable(badStyles)} +${styleTable(bad)}
`; }); - return `

${escapeHtml(route)}

\n${rows.join('\n')}\n
`; - }); + failing += routeFailing; + const name = names.get(route); + writeFileSync(join(pagesDir, name), document(`Visual review: ${route}`, `

All routes

+

${escapeHtml(route)}

+

${rows.length} widths, ${routeFailing} failing rows.

+${rows.join('\n')} +
`)); + indexRows.push(`${escapeHtml(route)}${routeFailing ? `${routeFailing}` : '0'}${cells.join('
')}`); + } - const html = ` - - - - -Visual review - - - -

Visual review

-

${routes.length} routes, ${order.length} route-width rows, ${failing} failing rows.

-${sections.join('\n')} - - -`; - return { html, failing }; + writeFileSync(out, document('Visual review', `

Visual review

+

${routes.length} routes, ${pairs} route-width rows, ${failing} failing rows.

+ +${indexRows.join('\n')} +
RouteFailingWidths
`)); + return { failing, routes: routes.length, pagesDir }; } function options(argv) { @@ -186,10 +233,9 @@ export function main(argv = process.argv.slice(2)) { console.log(HELP); return 0; } - const { html, failing } = buildSheet(opts); mkdirSync(dirname(opts.out), { recursive: true }); - writeFileSync(opts.out, html); - console.log(`Wrote ${opts.out} (${failing} failing rows)`); + const { failing, routes, pagesDir } = writeSheet(opts); + console.log(`Wrote ${opts.out} and ${routes} route pages in ${pagesDir} (${failing} failing rows)`); return 0; } catch (err) { if (!(err instanceof UsageError)) throw err; diff --git a/test/visual/sheet.test.mjs b/test/visual/sheet.test.mjs index 1b3043b..997cd5d 100644 --- a/test/visual/sheet.test.mjs +++ b/test/visual/sheet.test.mjs @@ -1,7 +1,7 @@ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PNG } from 'pngjs'; @@ -77,7 +77,16 @@ before(() => { }); after(() => rmSync(TMP, { recursive: true, force: true })); -const withoutDataUris = (html) => html.replace(/data:image\/png;base64,[A-Za-z0-9+/=]+/g, ''); +// The page files the index links, in its order, and the html of each. +function pagesOf(out) { + const index = readFileSync(out, 'utf8'); + const links = [...index.matchAll(//g)].map((m) => m[1]); + return { index, pages: links.map((href) => ({ href, file: join(dirname(out), decodeURI(href)), html: readFileSync(join(dirname(out), decodeURI(href)), 'utf8') })) }; +} +// Every of a page resolves, from the page's folder, to a file on disk. +function imagesOf(page) { + return [...page.html.matchAll(/]*\bsrc="([^"]+)"/g)].map((m) => m[1]); +} test('--help prints usage and exits 0', async () => { const { code, stdout } = await run(['--help']); @@ -90,28 +99,37 @@ test('a missing diff.json is a usage error', async () => { assert.equal(code, 2); }); -test('the contact sheet embeds every image and marks every failing row', async () => { +test('the contact sheet is an index plus one page per route, every image linked by path and every failing row marked', async () => { const out = join(TMP, 'index.html'); - const { code, stderr } = await run([ + const { code, stderr, stdout } = await run([ '--live', join(TMP, 'live'), '--new', join(TMP, 'new'), '--diff', join(TMP, 'diff', 'diff.json'), '--styles', join(TMP, 'styles.json'), '--out', out, ]); assert.equal(code, 0, stderr); - const html = readFileSync(out, 'utf8'); - assert.match(html, /^/i); - const images = html.match(//i); + assert.deepEqual(pages.map((p) => p.href), ['index-pages/home.html', 'index-pages/about.html', 'index-pages/blog.html']); + assert.match(stdout, /3 route pages/); + assert.doesNotMatch(index, / p.html).join('\n'); + assert.doesNotMatch(index + all, /data:image/, 'no image is inlined'); + const images = pages.flatMap((page) => imagesOf(page).map((src) => join(dirname(page.file), decodeURI(src)))); assert.equal(images.length, liveManifest.length + newManifest.length + diff.length); - assert.ok(!/]+src="(?!data:)/.test(html), 'every image is inlined'); + for (const file of images) assert.ok(existsSync(file), `${file} exists`); + for (const page of pages) { + assert.match(page.html, /^/i); + assert.match(page.html, //, 'each page links back to the index'); + } const failingRows = diff.filter((d) => !d.pass).length + styles.filter((s) => !s.pass).length; - const fails = withoutDataUris(html).match(/\bFAIL\b/g) ?? []; - assert.equal(fails.length, failingRows); - assert.match(html, /3\.25\s*%/); - assert.match(html, /margin-top/); - assert.doesNotMatch(html, /line-height/, 'passing style rows are not listed'); - assert.equal((html.match(/style only/g) ?? []).length, 1, 'only the route without screenshots is style only'); - assert.match(html, /

blog<\/code><\/h2>/); - const rowsOf = (route) => withoutDataUris(html).split('
').find((s) => s.includes(`

${route}

`)) - .split('
/

1440 px/.test(row)); assert.match(about1440, / { - if (!existsSync(source)) { + const refused = []; + const excluded = meta.excluded ?? []; + + // Copies source (which must lie inside root) to public/ at livePath, unless livePath is under a + // prefix the capture excluded (an old download may still hold such files). + const put = (source, livePath, what, root = capture, sitePath = livePath) => { + if (excludedBy(excluded, sitePath)) { + counts.excluded += 1; + return false; + } + const full = resolve(source); + if (!full.startsWith(resolve(root) + sep)) { + refused.push(`${what} ${source}: outside ${root}`); + return false; + } + if (!existsSync(full)) { missing.push(`${what} ${relative(process.cwd(), source) || source}`); return false; } @@ -120,7 +137,7 @@ export function copyAssets({ capture, site, mediaCache, plan: planFile }) { const source = join(capture, 'js', entry.file); if (isConcat(url)) { const key = concatKey(url); - if (put(source, concatPath(key), `script ${entry.src}:`)) { + if (put(source, concatPath(key), `script ${entry.src}:`, capture, url.pathname)) { concat[key] = concatPath(key); counts.scripts += 1; counts.concat += 1; @@ -154,14 +171,14 @@ export function copyAssets({ capture, site, mediaCache, plan: planFile }) { if (existsSync(cache)) { plan = mediaPlan(site, planFile); if (!plan) throw new UsageError(`media was downloaded to ${cache} but no media plan was found (docs/media-plan.json or .conversion/media-plan.json; pass --plan)`); - const prefix = plan.mode === 'repo' ? null : decode(plan.prefix); + const prefix = plan.mode === 'repo' ? null : `/${decode(plan.prefix).replace(/^\/+|\/+$/g, '')}/`; for (const file of walk(cache)) { const livePath = `/${relative(cache, file).split(sep).join('/')}`; - if (prefix && livePath.startsWith(prefix)) continue; - if (put(file, livePath, 'media')) counts.media += 1; + if (prefix && excludedBy([prefix], livePath)) continue; + if (put(file, livePath, 'media', cache)) counts.media += 1; } } - return { counts, missing, skipped, plan }; + return { counts, missing, skipped, refused, plan, excluded }; } function options(argv) { @@ -203,7 +220,7 @@ function main() { console.error('Run with --help for usage.'); process.exit(2); } - const { counts, missing, skipped, plan } = result; + const { counts, missing, skipped, refused, plan } = result; console.log(`scripts: ${counts.scripts} (${counts.concat} concatenated)`); console.log(`fonts: ${counts.fonts}`); console.log(`root icons: ${counts.rootIcons}`); @@ -211,9 +228,11 @@ function main() { if (!plan) console.log('media: 0 (no media download)'); else if (plan.mode === 'repo') console.log(`media (repo mode, every file): ${counts.media}`); else console.log(`media outside ${plan.prefix}: ${counts.media}`); + if (counts.excluded) console.log(`excluded: ${counts.excluded} (under a prefix the capture left out: ${(result.excluded ?? []).join(', ')})`); for (const line of skipped) console.log(`skipped ${line}`); for (const line of missing) console.error(`missing: ${line}`); - process.exitCode = missing.length ? 1 : 0; + for (const line of refused) console.error(`refused: ${line}`); + process.exitCode = missing.length || refused.length ? 1 : 0; } if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/skills/convert-site/scripts/lib/excluded.mjs b/skills/convert-site/scripts/lib/excluded.mjs new file mode 100644 index 0000000..d96a53d --- /dev/null +++ b/skills/convert-site/scripts/lib/excluded.mjs @@ -0,0 +1,31 @@ +// Path prefixes a capture left out (capture.mjs --exclude, baseline/meta.json excluded), matched +// in one canonical form on both sides: the URL parser's encoding of the path (non-ASCII as +// UTF-8 percent escapes), every escape of an unreserved character (A-Z a-z 0-9 - . _ ~) decoded +// and every other escape in upper case. So /présidence/, /pr%c3%a9sidence/ and /pr%C3%A9sidence/ +// are one path, and /%70resident/ is /president/. Letters outside escapes stay case-sensitive. +// canonicalPath(path) -> that form. excludedBy(prefixes, pathname) -> the first prefix covering +// the path (a prefix ending in / also covers the same path without it), or null. +const UNRESERVED = /[A-Za-z0-9\-._~]/; + +export function canonicalPath(path) { + let pathname; + try { + pathname = new URL(path, 'http://x').pathname; + } catch { + pathname = String(path); + } + return pathname.replace(/%([0-9a-f]{2})/gi, (escape, hex) => { + const char = String.fromCharCode(parseInt(hex, 16)); + return UNRESERVED.test(char) ? char : `%${hex.toUpperCase()}`; + }); +} + +export function excludedBy(prefixes, pathname) { + if (!prefixes?.length) return null; + const path = canonicalPath(pathname); + for (const prefix of prefixes) { + const want = canonicalPath(prefix); + if (path.startsWith(want) || (want.endsWith('/') && path === want.slice(0, -1))) return prefix; + } + return null; +} diff --git a/skills/convert-site/scripts/link-exceptions.mjs b/skills/convert-site/scripts/link-exceptions.mjs index cf7e911..aeb1213 100644 --- a/skills/convert-site/scripts/link-exceptions.mjs +++ b/skills/convert-site/scripts/link-exceptions.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { assertNodeVersion } from './lib/node-version.mjs'; import { linkRecords } from './lib/link-records.mjs'; +import { excludedBy } from './lib/excluded.mjs'; export const API_REASON = 'WordPress API link kept as captured'; export const BROKEN_REASON = 'broken on live'; @@ -80,7 +81,7 @@ export function linkExceptions({ baseline, capture, redirects, existing, date }) const origin = originOf(meta.canonicalOrigin ?? '') ?? originOf(meta.origin ?? '') ?? 'http://site.invalid'; const internalOrigins = new Set([originOf(meta.origin ?? ''), originOf(meta.canonicalOrigin ?? '')].filter(Boolean)); const excluded = meta.excluded ?? []; - const internal = (url) => (internalOrigins.has(url.origin) || url.origin === origin) && !excluded.some((p) => url.pathname.startsWith(p)); + const internal = (url) => (internalOrigins.has(url.origin) || url.origin === origin) && !excludedBy(excluded, url.pathname); const brokenPaths = new Set(routes.filter((r) => r.status === 404 || r.status === 410).map((r) => r.path)); const sources = redirectSources(redirects); const resolves = (path) => sources.has(path) || sources.has(path.replace(/\/$/, '')) || sources.has(`${path}/`); diff --git a/skills/convert-site/scripts/prelaunch-reconcile.mjs b/skills/convert-site/scripts/prelaunch-reconcile.mjs index d9ecfba..9b14081 100644 --- a/skills/convert-site/scripts/prelaunch-reconcile.mjs +++ b/skills/convert-site/scripts/prelaunch-reconcile.mjs @@ -47,9 +47,12 @@ Narrow rules that need no decision: site's sitemap the default build keeps, as live had it) output.og-image a row " does not exist in the build" whose path the baseline media lists, under the media prefix of a media plan in - mode store or live-temporary, with a rewrite for that prefix in - /vercel.json, a _redirects line ( or /public) or - a Pages Function under /functions/: the rewrite serves it + mode store or live-temporary, with the whole prefix sent to + another origin: a /vercel.json rewrite whose source is the + prefix plus a parameter (/:path*) and whose destination is an + absolute URL, a _redirects line ( or /public) + "* 200|302", or a Pages Function + under /functions/: the rewrite serves it output.noindex-utility-pages rows are pages: accepted when live had the same page indexable (no noindex in baseline/seo.json) and a decision names the check, or the page and "noindex", "robots" or "indexable". @@ -400,8 +403,15 @@ function rewriteServes(env, path) { vercel = []; } const redirects = [join(env.dist, '_redirects'), join(env.dir, 'public', '_redirects')].map(read).filter(Boolean).join('\n'); - const covered = vercel.some((r) => typeof r?.source === 'string' && r.source.startsWith(prefix)) - || redirects.split('\n').some((line) => line.trim().startsWith(`${prefix}*`)) + // The whole prefix (a parameter or splat right after it), sent to another origin: the store or + // live. A one-file rewrite under the prefix, or one to a path on this site, serves nothing else. + const offOrigin = (destination) => /^https?:\/\//i.test(String(destination ?? '').trim()); + const wholePrefix = (source) => typeof source === 'string' && source.startsWith(prefix) && /^(?::[A-Za-z_]\w*|\*)/.test(source.slice(prefix.length)); + const covered = vercel.some((r) => wholePrefix(r?.source) && offOrigin(r.destination)) + || redirects.split('\n').some((line) => { + const [from, to, status] = line.trim().split(/\s+/); + return from === `${prefix}*` && offOrigin(to) && ['200', '302'].includes(status); + }) || existsSync(join(env.dir, 'functions', ...prefix.split('/').filter(Boolean), '[[path]].js')); return covered ? `media plan (${plan.mode})` : null; } diff --git a/skills/visual-review/scripts/sheet.mjs b/skills/visual-review/scripts/sheet.mjs index 9d91fcc..26d00fb 100644 --- a/skills/visual-review/scripts/sheet.mjs +++ b/skills/visual-review/scripts/sheet.mjs @@ -2,7 +2,7 @@ // Contact sheet: an index page plus one HTML page per route, with live, new and diff screenshots // side by side per width and the failing style rows. Images are linked by path, never inlined, // and each route page is written as soon as it is built, so a site of any size fits. -import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; @@ -15,7 +15,9 @@ fail and differing-pixel percent, and links to one page per route in - (review/index.html -> review/index-pages/.html). A route page has a row per width with the live, new and diff screenshots side by side, the percent, pass or fail, both heights, and the failing style rows beneath. Images are referenced by relative path, not embedded: keep the -sheet beside the screenshot folders (or copy them with it) to open it elsewhere. +sheet beside the screenshot folders (or copy them with it) to open it elsewhere. A route page an +earlier run wrote (listed in -pages/.sheet.json) whose route is gone is removed; no +other file there is touched. --live, --new screenshot folders holding manifest.json and /.png --diff diff.json; diff images are read from the folder it sits in @@ -104,6 +106,9 @@ ${body} `; +// The route pages the last run wrote, so the next run removes only its own stale pages. +const SHEET_LIST = '.sheet.json'; + // A file name for each route, unique within the sheet. function pageNames(routes) { const used = new Set(); @@ -193,7 +198,20 @@ ${rows.join('\n')} ${indexRows.join('\n')}
RouteFailingWidths
`)); - return { failing, routes: routes.length, pagesDir }; + // Route pages an earlier run wrote (listed in its .sheet.json) that this set no longer has. + const listFile = join(pagesDir, SHEET_LIST); + const written = new Set(names.values()); + let earlier = []; + try { + earlier = existsSync(listFile) ? JSON.parse(readFileSync(listFile, 'utf8')) : []; + } catch { + earlier = []; + } + const removed = (Array.isArray(earlier) ? earlier : []) + .filter((name) => typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*\.html$/.test(name) && !written.has(name)); + for (const name of removed) rmSync(join(pagesDir, name), { force: true }); + writeFileSync(listFile, `${JSON.stringify([...written].sort(), null, 2)}\n`); + return { failing, routes: routes.length, pagesDir, removed: removed.length }; } function options(argv) { @@ -234,8 +252,8 @@ export function main(argv = process.argv.slice(2)) { return 0; } mkdirSync(dirname(opts.out), { recursive: true }); - const { failing, routes, pagesDir } = writeSheet(opts); - console.log(`Wrote ${opts.out} and ${routes} route pages in ${pagesDir} (${failing} failing rows)`); + const { failing, routes, pagesDir, removed } = writeSheet(opts); + console.log(`Wrote ${opts.out} and ${routes} route pages in ${pagesDir} (${failing} failing rows)${removed ? `; removed ${removed} route page${removed === 1 ? '' : 's'} no longer in the set` : ''}`); return 0; } catch (err) { if (!(err instanceof UsageError)) throw err; diff --git a/test/convert/copy-assets.test.mjs b/test/convert/copy-assets.test.mjs index 73538c6..56dbe2d 100644 --- a/test/convert/copy-assets.test.mjs +++ b/test/convert/copy-assets.test.mjs @@ -135,3 +135,41 @@ test('media on disk without a media plan is a usage error', async () => { assert.equal(result.code, 2); assert.match(result.stderr, /media plan/); }); + +test('a file the capture names outside the capture folder is refused with exit 1', async () => { + const dir = site('traversal', { mode: 'store', prefix: '/wp-content/uploads/' }); + writeFileSync(join(dir, '.env'), 'SECRET=1\n'); + const file = join(dir, 'capture', 'js', 'manifest.json'); + const manifest = JSON.parse(readFileSync(file, 'utf8')); + manifest.push({ file: '../../.env', src: `${origin}/wp-content/themes/fixture/js/env.js`, kind: 'script', pages: ['/'] }); + writeFileSync(file, JSON.stringify(manifest)); + const result = await copy(dir); + assert.equal(result.code, 1); + assert.match(result.stderr, /refused: script .*env\.js: .*\.env: outside /); + assert.ok(!existsSync(join(dir, 'public', 'wp-content/themes/fixture/js/env.js')), 'nothing written for it'); + assert.ok(existsSync(join(dir, 'public', 'wp-content/themes/fixture/js/view.js')), 'the rest is copied'); +}); + +test('nothing under an excluded prefix is copied, and the media prefix counts without its trailing slash', async () => { + const dir = site('excluded', { mode: 'store', prefix: '/wp-content/uploads' }); + const metaFile = join(dir, 'capture', 'meta.json'); + const meta = JSON.parse(readFileSync(metaFile, 'utf8')); + writeFileSync(metaFile, JSON.stringify({ ...meta, excluded: ['/wp-content/themes/fixture/fonts/', '/old-site'] })); + const cache = join(dir, '.conversion', 'media-cache'); + mkdirSync(join(cache, 'old-site', 'files'), { recursive: true }); + writeFileSync(join(cache, 'old-site', 'files', 'a.pdf'), 'pdf'); + mkdirSync(join(cache, 'wp-content', 'uploads-old'), { recursive: true }); + writeFileSync(join(cache, 'wp-content', 'uploads-old', 'b.jpg'), 'jpg'); + const result = await copy(dir); + assert.equal(result.code, 0, result.stderr); + const pub = join(dir, 'public'); + assert.deepEqual(files(join(pub, 'wp-content', 'themes', 'fixture', 'fonts')), [], 'excluded fonts'); + assert.ok(!existsSync(join(pub, 'old-site')), 'an excluded media folder'); + assert.match(result.stdout, /excluded: \d+ \(under a prefix the capture left out: \/wp-content\/themes\/fixture\/fonts\/, \/old-site\)/); + assert.deepEqual(files(join(pub, 'wp-content', 'uploads')), [], 'the prefix written without its slash still covers uploads/'); + assert.equal(readFileSync(join(pub, 'wp-content', 'uploads-old', 'b.jpg'), 'utf8'), 'jpg', 'but not a sibling folder that starts the same'); +}); + +test('convert-site carries a byte-identical copy of the gates\' excluded.mjs', () => { + assert.ok(readFileSync(join(SKILLS, 'convert-site', 'scripts', 'lib', 'excluded.mjs')).equals(readFileSync(join(SKILLS, 'parity-gates', 'scripts', 'lib', 'excluded.mjs')))); +}); diff --git a/test/convert/link-exceptions.test.mjs b/test/convert/link-exceptions.test.mjs index fcfe509..d15f1ad 100644 --- a/test/convert/link-exceptions.test.mjs +++ b/test/convert/link-exceptions.test.mjs @@ -1,7 +1,7 @@ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { startFixture } from '../fixture/serve.mjs'; @@ -122,3 +122,17 @@ test('a missing baseline is a usage error', async () => { const result = await run(SCRIPT, ['--baseline', join(root, 'nope'), '--out', join(root, 'x.json')]); assert.equal(result.code, 2); }); + +test('a link under an excluded prefix gets no record: the links gate does not check it', async () => { + const dir = join(root, 'baseline-excluded'); + cpSync(baseline, dir, { recursive: true }); + const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')); + writeFileSync(join(dir, 'meta.json'), JSON.stringify({ ...meta, excluded: ['/deleted-post', '/wp-json/'] })); + const out = join(root, 'excluded.json'); + const result = await run(SCRIPT, ['--baseline', dir, '--capture', capture, '--out', out, '--redirects', join(root, 'redirects.csv')]); + assert.equal(result.code, 0, result.stderr); + const records = JSON.parse(readFileSync(out, 'utf8')); + assert.ok(!records.some((r) => r.reason === 'broken on live'), '/deleted-post written without its slash still covers /deleted-post/'); + assert.ok(!records.some((r) => r.to.includes('/wp-json/'))); + assert.ok(records.some((r) => r.to.includes('xmlrpc.php'))); +}); diff --git a/test/convert/prelaunch-reconcile.test.mjs b/test/convert/prelaunch-reconcile.test.mjs index cdaf629..eedfb27 100644 --- a/test/convert/prelaunch-reconcile.test.mjs +++ b/test/convert/prelaunch-reconcile.test.mjs @@ -466,11 +466,16 @@ test('output.og-image: a missing image under the media prefix is accepted when t const media = JSON.parse(readFileSync(join(baseline, 'media.json'), 'utf8')); const image = media.find((m) => m.path.startsWith('/wp-content/uploads/') && m.path.endsWith('.jpg')).path; const row = (path) => failing('output.og-image', '1 og:image does not resolve', [`https://www.example.com${path} does not exist in the build (used by about/index.html)`], 'url'); - const site = (name, { mode, rewrite = true }) => { + const WHOLE = { source: '/wp-content/uploads/:path*', destination: 'https://www.example.com/wp-content/uploads/:path*' }; + const site = (name, { mode, rewrite = true, rewrites = rewrite ? [WHOLE] : [], redirects = null }) => { const dir = join(root, name); mkdirSync(join(dir, 'docs'), { recursive: true }); writeFileSync(join(dir, 'docs', 'media-plan.json'), JSON.stringify({ mode, prefix: '/wp-content/uploads/' })); - writeFileSync(join(dir, 'vercel.json'), JSON.stringify({ rewrites: rewrite ? [{ source: '/wp-content/uploads/:path*', destination: 'https://www.example.com/wp-content/uploads/:path*' }] : [] })); + writeFileSync(join(dir, 'vercel.json'), JSON.stringify({ rewrites })); + if (redirects) { + mkdirSync(join(dir, 'public'), { recursive: true }); + writeFileSync(join(dir, 'public', '_redirects'), redirects); + } return ['--dir', dir]; }; const ok = await reconcile([row(image)], { planFile: bare, extra: site('site-live-temporary', { mode: 'live-temporary' }) }); @@ -486,6 +491,16 @@ test('output.og-image: a missing image under the media prefix is accepted when t assert.equal(noRewrite.code, 1); const unknown = await reconcile([row('/wp-content/uploads/2026/01/not-captured.jpg')], { planFile: bare, extra: site('site-unknown', { mode: 'store' }) }); assert.equal(unknown.code, 1, 'a path the baseline media does not list'); + // A rewrite that covers one file under the prefix, or sends the prefix to a path on this site, + // serves nothing else under it. + const oneFile = await reconcile([row(image)], { planFile: bare, extra: site('site-one-file', { mode: 'store', rewrites: [{ source: image, destination: `https://store.example.com${image}` }] }) }); + assert.equal(oneFile.code, 1, 'a one-file rewrite does not cover the prefix'); + const local = await reconcile([row(image)], { planFile: bare, extra: site('site-local', { mode: 'store', rewrites: [{ source: '/wp-content/uploads/:path*', destination: '/media/:path*' }] }) }); + assert.equal(local.code, 1, 'a rewrite to a path on the same site'); + const pages = await reconcile([row(image)], { planFile: bare, extra: site('site-redirects', { mode: 'live-temporary', rewrite: false, redirects: '/wp-content/uploads/* https://www.example.com/wp-content/uploads/:splat 302\n' }) }); + assert.equal(pages.code, 0, `a _redirects splat to another origin covers it\n${pages.stdout}`); + const pagesLocal = await reconcile([row(image)], { planFile: bare, extra: site('site-redirects-local', { mode: 'live-temporary', rewrite: false, redirects: `/wp-content/uploads/* /media/:splat 200\n${image} https://www.example.com${image} 302\n` }) }); + assert.equal(pagesLocal.code, 1, 'a local splat or a one-file line does not'); }); test('output.noindex-utility-pages: accepted when live indexed the same page and a decision names it', async () => { diff --git a/test/visual/sheet.test.mjs b/test/visual/sheet.test.mjs index 997cd5d..d336e26 100644 --- a/test/visual/sheet.test.mjs +++ b/test/visual/sheet.test.mjs @@ -178,6 +178,32 @@ test('200 page pairs stream to small files: no page holds more than its route, n } }); +test('a rerun removes the route pages it wrote that are no longer in the set, and nothing else', async () => { + const dir = join(TMP, 'rerun'); + const out = join(dir, 'review.html'); + const args = (live, next, diffFile) => ['--live', live, '--new', next, '--diff', diffFile, '--out', out]; + const first = await run(args(join(TMP, 'live'), join(TMP, 'new'), join(TMP, 'diff', 'diff.json'))); + assert.equal(first.code, 0, first.stderr); + const pagesDir = join(dir, 'review-pages'); + assert.ok(existsSync(join(pagesDir, 'about.html'))); + writeFileSync(join(pagesDir, 'notes.html'), 'mine'); + // The same shots without the about route. + for (const side of ['live', 'new']) { + cpSync(join(TMP, side), join(dir, side), { recursive: true }); + const file = join(dir, side, 'manifest.json'); + writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8')).filter((e) => e.route !== 'about'))); + } + cpSync(join(TMP, 'diff'), join(dir, 'diff'), { recursive: true }); + writeFileSync(join(dir, 'diff', 'diff.json'), JSON.stringify(diff.filter((d) => d.route !== 'about'))); + const second = await run(args(join(dir, 'live'), join(dir, 'new'), join(dir, 'diff', 'diff.json'))); + assert.equal(second.code, 0, second.stderr); + assert.match(second.stdout, /removed 1 route page no longer in the set/); + assert.ok(!existsSync(join(pagesDir, 'about.html')), 'the stale route page is gone'); + assert.ok(existsSync(join(pagesDir, 'home.html'))); + assert.equal(readFileSync(join(pagesDir, 'notes.html'), 'utf8'), 'mine', 'a file the sheet never wrote is kept'); + assert.deepEqual(pagesOf(out).pages.map((p) => p.href), ['review-pages/home.html']); +}); + test('without --styles the sheet still renders, failing diff rows only', async () => { const out = join(TMP, 'plain.html'); const { code } = await run([ From f9cdbecfb0312218d0a115660d9312ba6013df8c Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 23 Oct 2026 07:23:27 -0400 Subject: [PATCH 10/15] feat(css-prune): prune each linked sheet at its live path and keep each page's head order; media, multisite and unparsable sheets --- skills/convert-site/scripts/css-prune.mjs | 435 +++++++++++++++---- skills/convert-site/scripts/lib/head-css.mjs | 41 ++ test/convert/css.test.mjs | 227 +++++++++- test/e2e/lib/run.mjs | 2 +- 4 files changed, 613 insertions(+), 92 deletions(-) create mode 100644 skills/convert-site/scripts/lib/head-css.mjs diff --git a/skills/convert-site/scripts/css-prune.mjs b/skills/convert-site/scripts/css-prune.mjs index e223a94..8fd3273 100644 --- a/skills/convert-site/scripts/css-prune.mjs +++ b/skills/convert-site/scripts/css-prune.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -// Builds one stylesheet from the captured CSS, dropping the rules no page used -// according to css-coverage.mjs, and keeping the ones coverage cannot see. +// Prunes the captured CSS, dropping the rules no page used according to css-coverage.mjs and +// keeping the ones coverage cannot see: by default each linked stylesheet on its own at its live +// path and each page's `, + '', + ``, + ].join('\n') }, + { path: '/about/', slug: 'about', head: [ + ``, + '', + '', + '', + ``, + ].join('\n') }, + ], + }); + const coverage = join(dir, 'coverage.json'); + writeFileSync(coverage, JSON.stringify({ + // A rule only /about/ uses is used all the same: coverage merges every page's ranges. + [`${SITE_ORIGIN}/wp-content/themes/t/a.css?ver=1.2`]: { text: THEME_A, usedRanges: usedRules(THEME_A, ['.a-home', '.a-about']) }, + [`${SITE_ORIGIN}/wp-includes/css/b.css`]: { text: BLOCKS_B, usedRanges: usedRules(BLOCKS_B, ['.b-home']) }, + // Chromium applies no print rule on screen: coverage sees nothing used in the print sheet. + [`${SITE_ORIGIN}/wp-content/themes/t/print.css`]: { text: PRINT, usedRanges: [] }, + 'inline:1': { text: HOME_STYLE, usedRanges: usedRules(HOME_STYLE, ['.s-home']), pages: ['/'] }, + 'inline:2': { text: SHARED_STYLE, usedRanges: usedRules(SHARED_STYLE, ['.layout']), pages: ['/', '/about/'] }, + 'inline:3': { text: ABOUT_STYLE, usedRanges: [[0, ABOUT_STYLE.length]], pages: ['/about/'] }, + })); + return { dir, coverage }; +} + +const perSheetCli = (dir, coverage, extra = []) => pruneCli([ + '--css', join(dir, 'css'), '--coverage', coverage, '--report', join(dir, 'report.json'), + '--public', join(dir, 'public'), '--inline-out', join(dir, 'inline'), ...extra, +]); + +test('per-sheet: each linked sheet is pruned on its own at its live path, inline blocks per page, and each page keeps its head order', async () => { + const { dir, coverage } = twoSheetCapture('per-sheet'); + const pub = join(dir, 'public'); + const inlineOut = join(dir, 'inline'); + mkdirSync(inlineOut, { recursive: true }); + writeFileSync(join(inlineOut, 'inline-stale-1.css'), '.old { color: red; }\n'); + const result = await perSheetCli(dir, coverage); + assert.equal(result.code, 0, result.stderr); + + // Live paths, query string dropped from the file name. + const a = readFileSync(join(pub, 'wp-content', 'themes', 't', 'a.css'), 'utf8'); + assert.ok(a.includes('.a-home { color: red; }') && a.includes('.a-about { color: green; }'), a); + assert.ok(!a.includes('.a-unused'), 'an unused rule is removed'); + const b = readFileSync(join(pub, 'wp-includes', 'css', 'b.css'), 'utf8'); + assert.equal(b, '.b-home { margin: 0; }\n', 'one sheet\'s rules never land in another'); + // A print sheet: coverage is screen-only, so it is kept whole and its link keeps media="print". + assert.equal(readFileSync(join(pub, 'wp-content', 'themes', 't', 'print.css'), 'utf8'), PRINT); + + const summary = readJson(join(dir, 'report.json')); + assert.equal(summary.mode, 'per-sheet'); + assert.equal(summary.siteCss, null, 'nothing is left for a global site.css'); + assert.deepEqual(summary.sheets.map(({ href, file }) => ({ href, file })), [ + { href: `${SITE_ORIGIN}/wp-content/themes/t/a.css?ver=1.2`, file: join(pub, 'wp-content', 'themes', 't', 'a.css') }, + { href: `${SITE_ORIGIN}/wp-includes/css/b.css`, file: join(pub, 'wp-includes', 'css', 'b.css') }, + { href: `${SITE_ORIGIN}/wp-content/themes/t/print.css`, file: join(pub, 'wp-content', 'themes', 't', 'print.css') }, + ]); + for (const sheet of summary.sheets) { + assert.deepEqual(Object.keys(sheet).sort(), ['bytesAfter', 'bytesBefore', 'file', 'href', 'rulesRemoved']); + assert.equal(sheet.bytesAfter, statSync(sheet.file).size); + } + assert.deepEqual(summary.sheets.map((s) => s.rulesRemoved), [1, 1, 0]); + assert.deepEqual(summary.external, ['https://fonts.googleapis.com/css2?family=Inter']); + + // Each page's own order, links root-relative with their query, inline blocks as files. + assert.deepEqual(summary.heads['/'], [ + { kind: 'link', href: '/wp-content/themes/t/a.css?ver=1.2', media: 'all' }, + { kind: 'inline', file: 'inline-home-1.css' }, + { kind: 'link', href: '/wp-includes/css/b.css' }, + { kind: 'inline', file: 'inline-home-2.css' }, + ]); + assert.deepEqual(summary.heads['/about/'], [ + { kind: 'inline', file: 'inline-home-2.css' }, + { kind: 'link', href: 'https://fonts.googleapis.com/css2?family=Inter' }, + { kind: 'link', href: '/wp-content/themes/t/print.css', media: 'print' }, + { kind: 'link', href: '/wp-content/themes/t/a.css?ver=1.2', media: 'all' }, + { kind: 'inline', file: 'inline-about-2.css' }, + ]); + assert.deepEqual(readdirSync(inlineOut).sort(), ['inline-about-2.css', 'inline-home-1.css', 'inline-home-2.css'], 'stale inline files removed'); + assert.equal(readFileSync(join(inlineOut, 'inline-home-1.css'), 'utf8'), '.s-home { padding: 0; }\n'); + assert.equal(readFileSync(join(inlineOut, 'inline-home-2.css'), 'utf8'), '.layout { max-width: 640px; }\n'); + assert.equal(readFileSync(join(inlineOut, 'inline-about-2.css'), 'utf8'), `${ABOUT_STYLE}\n`); + assert.deepEqual(summary.inline.map(({ file, pages }) => ({ file, pages })), [ + { file: 'inline-home-1.css', pages: ['/'] }, + { file: 'inline-home-2.css', pages: ['/', '/about/'] }, + { file: 'inline-about-2.css', pages: ['/about/'] }, + ]); + assert.deepEqual(summary.pageSheets, [], 'each page keeps its own blocks, so nothing collides'); + assert.deepEqual(summary.unmatched, []); + assert.deepEqual(summary.unparsed, []); + assert.match(result.stdout, /wrote 3 sheet\(s\) at their live paths under .*public and 3 inline block file\(s\) to .*inline; no site\.css/); +}); + +test('per-sheet: identical sheets under two paths (a multisite) keep their own coverage, never each other\'s', async () => { + const text = '.c-block { margin-bottom: 2rem; }\n.b-gallery { margin-bottom: 0; }\n'; + const main = `${SITE_ORIGIN}/wp-content/themes/x/style.css`; + const sub = `${SITE_ORIGIN}/president/wp-content/themes/x/style.css`; + const dir = pageCapture('multisite', { + sheets: [{ file: '1.css', href: main, text }, { file: '2.css', href: sub, text }], + pages: [ + { path: '/', slug: 'home', head: '' }, + { path: '/president/', slug: 'president', head: '' }, + ], + }); + const coverage = join(dir, 'coverage.json'); + writeFileSync(coverage, JSON.stringify({ + [main]: { text, usedRanges: usedRules(text, ['.c-block']) }, + [sub]: { text, usedRanges: usedRules(text, ['.b-gallery']) }, + })); + const result = await perSheetCli(dir, coverage); + assert.equal(result.code, 0, result.stderr); + const pub = join(dir, 'public'); + assert.equal(readFileSync(join(pub, 'wp-content', 'themes', 'x', 'style.css'), 'utf8'), '.c-block { margin-bottom: 2rem; }\n'); + assert.equal(readFileSync(join(pub, 'president', 'wp-content', 'themes', 'x', 'style.css'), 'utf8'), '.b-gallery { margin-bottom: 0; }\n'); +}); + +test('a sheet css-tree cannot parse is kept byte for byte and listed under unparsed, without changing the exit code', async () => { + const broken = `${'@media screen{'.repeat(20000)}.deep { color: red; }`; + const href = `${SITE_ORIGIN}/wp-content/plugins/mc/embed.css`; + const dir = pageCapture('unparsed', { + sheets: [{ file: '1.css', href, text: broken }], + pages: [{ path: '/', slug: 'home', head: '' }], + }); + const coverage = join(dir, 'coverage.json'); + writeFileSync(coverage, JSON.stringify({ [href]: { text: broken, usedRanges: [] } })); + const result = await perSheetCli(dir, coverage); + assert.equal(result.code, 0, result.stderr); + assert.equal(readFileSync(join(dir, 'public', 'wp-content', 'plugins', 'mc', 'embed.css'), 'utf8'), broken); + const { unparsed } = readJson(join(dir, 'report.json')); + assert.equal(unparsed.length, 1); + assert.equal(unparsed[0].href, href); + assert.match(unparsed[0].error, /call stack/); + assert.equal(result.stderr.trim().split('\n').filter((line) => line.includes('could not parse')).length, 1, result.stderr); +}); + +test('--single-sheet wraps a sheet linked with a media attribute in that @media and keeps a print sheet whole', async () => { + const { dir, coverage } = twoSheetCapture('single-media'); + const out = join(dir, 'site.css'); + const result = await pruneCli(['--single-sheet', '--css', join(dir, 'css'), '--coverage', coverage, '--out', out, '--report', join(dir, 'r.json')]); + assert.equal(result.code, 0, result.stderr); + const css = readFileSync(out, 'utf8'); + assert.ok(css.includes(`@media print {\n${PRINT.trimEnd()}\n}`), css); + assert.ok(!/@media all/.test(css), 'media="all" adds no wrapper'); + assert.equal(readJson(join(dir, 'r.json')).mode, 'single-sheet'); +}); + +test('css-prune usage: --out needs --single-sheet, and per-sheet mode needs the capture\'s inventory', async () => { + const { dir, coverage } = twoSheetCapture('usage'); + const out = await perSheetCli(dir, coverage, ['--out', join(dir, 'site.css')]); + assert.equal(out.code, 2); + assert.match(out.stderr, /--out is for --single-sheet/); + rmSync(join(dir, 'inventory.json')); + const bare = await perSheetCli(dir, coverage); + assert.equal(bare.code, 2); + assert.match(bare.stderr, /inventory\.json/); +}); diff --git a/test/e2e/lib/run.mjs b/test/e2e/lib/run.mjs index bb5b3ce..d06bd09 100644 --- a/test/e2e/lib/run.mjs +++ b/test/e2e/lib/run.mjs @@ -432,7 +432,7 @@ export async function mediaAndCss({ site }) { for (const entry of readJson(join(site, 'capture', 'js', 'manifest.json')).filter((e) => e.file)) { cpSync(join(site, 'capture', 'js', entry.file), join(site, 'public', decodeURIComponent(new URL(entry.src).pathname).replace(/^\//, ''))); } - const prune = await node(site, kit('convert-site', 'css-prune.mjs'), ['--css', 'capture/css', '--coverage', 'capture/css-coverage.json', '--out', 'src/styles/site.css', '--report', '.conversion/css-prune-report.json']); + const prune = await node(site, kit('convert-site', 'css-prune.mjs'), ['--single-sheet', '--css', 'capture/css', '--coverage', 'capture/css-coverage.json', '--out', 'src/styles/site.css', '--report', '.conversion/css-prune-report.json']); // Base.astro links src/styles/site.css itself (before the head slot), so nothing is added here. return { prune }; } From 9586f3ca1b9a753dba88116f13293bc26631db58 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 23 Oct 2026 07:31:50 -0400 Subject: [PATCH 11/15] feat(convert): Base.astro prints each page's head CSS list in live order; the e2e run serves sheets at their live paths --- .../templates/site/src/layouts/Base.astro | 30 ++++++++++--- test/convert/init-site.test.mjs | 6 +++ test/e2e/convert-fixture.test.mjs | 32 ++++++++----- test/e2e/lib/run.mjs | 45 ++++++++++++------- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/skills/convert-site/templates/site/src/layouts/Base.astro b/skills/convert-site/templates/site/src/layouts/Base.astro index e516784..eb2b3fc 100644 --- a/skills/convert-site/templates/site/src/layouts/Base.astro +++ b/skills/convert-site/templates/site/src/layouts/Base.astro @@ -1,14 +1,26 @@ --- // Shared document shell. Head values come from capture/, never retyped. -// site.css is linked by URL before the head slot: an imported stylesheet would land at the end of -// , after a page's own `; +}; --- @@ -20,8 +32,14 @@ const pageCss = pageSheet ? pageSheets[`../styles/pages/${pageSheet}`] : null; {canonical && } {robots && } {!INDEXABLE && } - - {pageCss && } + {css ? css.map((entry) => (entry.kind === 'inline' + ? + : )) : ( + <> + + {pageCss && } + + )} diff --git a/test/convert/init-site.test.mjs b/test/convert/init-site.test.mjs index 669188a..a842e44 100644 --- a/test/convert/init-site.test.mjs +++ b/test/convert/init-site.test.mjs @@ -167,6 +167,12 @@ test('init scaffolds templates, copies scripts, commits on main and switches to assert.doesNotMatch(base, /^import '\.\.\/styles\/site\.css';$/m); const order = ['', '{pageCss && }', ''].map((s) => base.indexOf(s)); assert.ok(order.every((at, i) => at > 0 && (i === 0 || at > order[i - 1])), `site.css, the page sheet, then the head slot: ${order}`); + // The default build: the page's own head CSS list (css-prune's heads) in live order, links to + // the sheets at their live paths and the pruned