kiss-ssg 2.11.2 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/AIKB/asset-copy.md +2 -1
  2. package/AIKB/asset-manifest.md +2 -1
  3. package/AIKB/asset-minify.md +31 -0
  4. package/AIKB/assets.md +1 -1
  5. package/AIKB/audit.md +26 -6
  6. package/AIKB/build-finish.md +5 -0
  7. package/AIKB/build-report.md +6 -3
  8. package/AIKB/cache-headers.md +45 -0
  9. package/AIKB/check.md +1 -1
  10. package/AIKB/config.md +5 -2
  11. package/AIKB/consent-client.md +1 -0
  12. package/AIKB/consent-gate.md +7 -14
  13. package/AIKB/consent-ui.md +3 -3
  14. package/AIKB/consent.md +14 -7
  15. package/AIKB/css-audit.md +45 -0
  16. package/AIKB/css-purge.md +41 -0
  17. package/AIKB/design.md +2 -2
  18. package/AIKB/embed.md +44 -0
  19. package/AIKB/handlebars-helpers.md +5 -4
  20. package/AIKB/html-split.md +1 -1
  21. package/AIKB/image-encode.md +31 -0
  22. package/AIKB/image-header.md +35 -0
  23. package/AIKB/images-sweep.md +34 -0
  24. package/AIKB/images.md +40 -0
  25. package/AIKB/init.md +1 -0
  26. package/AIKB/integrity.md +27 -0
  27. package/AIKB/kiss-page.md +1 -0
  28. package/AIKB/kiss.md +5 -2
  29. package/AIKB/links.md +1 -1
  30. package/AIKB/markdown-copy.md +1 -1
  31. package/AIKB/page-registry.md +3 -0
  32. package/AIKB/rebuild.md +16 -1
  33. package/AIKB/redirects.md +1 -1
  34. package/AIKB/robots.md +1 -1
  35. package/AIKB/speed.md +51 -0
  36. package/AIKB/staging.md +4 -2
  37. package/AIKB/testing.md +7 -4
  38. package/AIKB/upstream.md +30 -8
  39. package/AIKB/zip.md +1 -1
  40. package/CHANGELOG.md +213 -0
  41. package/GUIDE.md +365 -60
  42. package/README.md +18 -4
  43. package/bin/kiss-ssg.js +98 -0
  44. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  45. package/examples/11-blog/AIKB/last-build.json +4 -1
  46. package/examples/14-consent/README.md +4 -2
  47. package/examples/14-consent/src/pages/roasting.hbs +1 -1
  48. package/examples/9-migrated-from-v1/AIKB/last-build.json +6 -3
  49. package/examples/9-migrated-from-v1/router.js +1 -1
  50. package/examples/9-migrated-from-v1/src/pages/index.hbs +1 -1
  51. package/examples/9-migrated-from-v1/src/pages/smaller-changes.hbs +3 -3
  52. package/lib/asset-copy.js +1 -0
  53. package/lib/asset-manifest.js +21 -2
  54. package/lib/asset-minify.js +95 -0
  55. package/lib/assets.js +21 -0
  56. package/lib/audit.js +344 -15
  57. package/lib/build-finish.js +258 -0
  58. package/lib/build-report.js +71 -4
  59. package/lib/cache-headers.js +363 -0
  60. package/lib/check.js +11 -0
  61. package/lib/config.js +229 -2
  62. package/lib/consent-client.js +17 -3
  63. package/lib/consent-gate.js +13 -81
  64. package/lib/consent-ui.js +9 -3
  65. package/lib/consent.js +239 -12
  66. package/lib/css-audit.js +765 -0
  67. package/lib/css-purge.js +211 -0
  68. package/lib/embed.js +442 -0
  69. package/lib/handlebars-helpers.js +213 -18
  70. package/lib/image-encode.js +244 -0
  71. package/lib/image-header.js +683 -0
  72. package/lib/images-sweep.js +551 -0
  73. package/lib/images.js +445 -0
  74. package/lib/init.js +28 -7
  75. package/lib/integrity.js +150 -0
  76. package/lib/kiss-page.js +22 -0
  77. package/lib/kiss.js +139 -4
  78. package/lib/links.js +48 -3
  79. package/lib/markdown-copy.js +27 -3
  80. package/lib/page-registry.js +64 -1
  81. package/lib/rebuild.js +42 -2
  82. package/lib/redirects.js +11 -2
  83. package/lib/speed.js +739 -0
  84. package/lib/staging.js +4 -3
  85. package/llms.txt +25 -21
  86. package/package.json +27 -14
  87. package/types/asset-manifest.d.ts +8 -0
  88. package/types/asset-minify.d.ts +35 -0
  89. package/types/assets.d.ts +1 -0
  90. package/types/audit.d.ts +64 -2
  91. package/types/build-finish.d.ts +4 -0
  92. package/types/build-report.d.ts +87 -1
  93. package/types/cache-headers.d.ts +183 -0
  94. package/types/check.d.ts +1 -1
  95. package/types/config.d.ts +167 -6
  96. package/types/consent-gate.d.ts +0 -18
  97. package/types/consent.d.ts +150 -17
  98. package/types/css-audit.d.ts +346 -0
  99. package/types/css-purge.d.ts +124 -0
  100. package/types/embed.d.ts +71 -0
  101. package/types/handlebars-helpers.d.ts +39 -3
  102. package/types/image-encode.d.ts +87 -0
  103. package/types/image-header.d.ts +95 -0
  104. package/types/images-sweep.d.ts +226 -0
  105. package/types/images.d.ts +277 -0
  106. package/types/init.d.ts +1 -1
  107. package/types/integrity.d.ts +50 -0
  108. package/types/kiss-page.d.ts +11 -0
  109. package/types/kiss.d.ts +63 -1
  110. package/types/links.d.ts +8 -3
  111. package/types/page-registry.d.ts +26 -0
  112. package/types/rebuild.d.ts +1 -0
  113. package/types/redirects.d.ts +6 -2
  114. package/types/speed.d.ts +325 -0
@@ -8,7 +8,8 @@ registrations a replay re-runs, and the copies a released page claim can restore
8
8
  is the copy itself and knows nothing of the instance; this module is what ties a copy to a build.
9
9
 
10
10
  `copyAssetFolder(kiss, …)` takes the `Kiss` instance and reads its state at call time. The state
11
- stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `_restorableAssets`,
11
+ stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `_copySources` (every
12
+ source folder asked for, resolved at the call, which `kiss-ssg images` reads), `_restorableAssets`,
12
13
  `_failures`, `_promises` — and `Kiss._copyAssets()` is a one-line delegator to it. Calls back
13
14
  into the instance go through it (`kiss._stagedPath()`, `kiss._carry()`, `kiss._refreshReport()`).
14
15
 
@@ -12,6 +12,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
12
12
  - `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
13
13
  - `createAssetManifest()` → `{ lookup, toObject, hasOwner, owns, previous, reconcile, reconcileSass, unrecorded, urlRevision }`, a fresh manifest.
14
14
  - `lookup(urlPath)` — the emitted path, or `null`.
15
+ - `retarget(name, output)` — points a name at a new output after the copy, and every copy's map that held the old output follows it, so the next reconcile deletes the old file rather than keeping it. It counts a URL change for a hashable name. The settle's CSS purge (`AIKB/css-purge.md`) is its only caller: a purged stylesheet is re-hashed, so its name moves.
15
16
  - `toObject()` — a plain object copy of the map, for debugging.
16
17
 
17
18
  - `hasOwner(owner)` — whether a source/target copy has an inventory, or has written outputs it never recorded (below).
@@ -19,7 +20,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
19
20
  - `unrecorded(owner, outputs)` — remembers build-relative outputs a copy wrote, or failed to remove, before it stopped partway. They are in no map and `lookup` never sees them; the copy's next `reconcile` reports them stale unless they are live again, then forgets them.
20
21
  - `owns(owner, output)` — whether `output` (build-relative, keyed by output not by name) is this copy's, recorded or unrecorded. Never another copy's. `lib/assets.js` asks it only when no output registry was passed; with a registry, the registry is the authority.
21
22
 
22
- - `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
23
+ - `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. That does not mean an image edit never needs one: a page whose `{{image}}` serves the image carries its size (and, outside dev, its variants' names), and the watch path finds those pages from the image index (`lib/rebuild.js` `imagePagesFor`), not from this revision. The source comment said otherwise until workstream F. `reconcile` is the only mapping writer.
23
24
 
24
25
  ## Depends on
25
26
 
@@ -0,0 +1,31 @@
1
+ # asset-minify.js
2
+
3
+ ## Responsibility
4
+
5
+ Minify a stylesheet or script the asset copy wrote, in place, before it is hashed. A production build then ships its copied `.css` and `.js` the way it already ships its HTML. The minifiers are the ones the page minifier already loads (`html-minifier-terser` → `terser` and `clean-css`), promoted to direct dependencies so this module can import them. Pure helpers plus one file rewrite. It knows nothing about the manifest, hashing, dev mode or config: `lib/assets.js` decides when to call it.
6
+
7
+ ## Public interface
8
+
9
+ - `shouldMinify(relative)` → `true` for a `.css`, `.js` or `.mjs` that is not `*.min.*`.
10
+ - `minifyText(relative, text)` → the minified text. CSS goes through `clean-css` at level 1, and its errors throw. JS goes through `terser` with `compress`, `mangle` and no comments, as a classic script unless the file is `.mjs`. A `.js` that does not parse as a classic script is tried again as a module, so `import`, `export` and top-level `await` survive. Throws when the file cannot be parsed either way.
11
+ - `minifyFile(file, relative)` → `{ ok: true, before, after }` or `{ ok: false, error }`. Rewrites the file only when the result is smaller. **Never throws**: a file the minifier cannot read is left exactly as it was.
12
+
13
+ ## Depends on
14
+
15
+ `fs-extra`, `node:path`, `terser`, `clean-css`.
16
+
17
+ ## Depended on by
18
+
19
+ - `lib/assets.js` `recordEmitted`, in the loop over claimed outputs, just before the hash is taken. It runs when `config.assets.minify !== false`, outside dev, when the winning source is not Sass (already `compressed`), and when `shouldMinify` agrees. A failure becomes one `warn` naming the file, the copy carries on, and the build does not fail.
20
+ - `test/integration/examples.test.js` (example 12) compares the built stylesheet with `minifyText` of its source.
21
+
22
+ ## Non-obvious behavior
23
+
24
+ - **A clean-css warning keeps the original.** clean-css 5 does not understand native CSS nesting: it dropped a nested `.child { … }` and a nested `@media`, and hoisted `&:hover` out of its parent, reporting seven warnings and no error, while this module read only the errors (Codex at the close, 2026-11-05, reproduced here). `minifyText` now throws on any warning, and `minifyFile` leaves the file as written and says why. The fleet's own stylesheets gave none (a1k9training, pro-plumbing, k9-solutions), so they are minified as before.
25
+ - **The served bytes no longer match an author's `integrity` hash** once minified. `lib/integrity.js` turns that into a build failure (`AIKB/integrity.md`).
26
+ - **Before the hash, never after.** The hash is the served bytes', so a minified file gets a new name exactly when its minified bytes change. Minifying after hashing would serve bytes the name does not describe.
27
+ - **On by default, because it is cheap and never worse.** Measured on the fleet (2026-11-04, `planning/research/2026-11-04-static-site-performance.md` § Measured — CSS and JS): after Brotli, an unminified file was about 17% smaller once minified, and an already-minified one about 1–2%. A result no smaller than the input is discarded, so a tiny file with a licence banner is never grown.
28
+ - **A failure never fails the build.** A minifier rejecting a file is either the file's bug, which the browser may tolerate, or the minifier's, which kiss should not turn into a broken deploy. The original is served and the warning names it, with the two ways out: `assets: { minify: false }`, or naming the file `*.min.*`.
29
+ - **clean-css changes text, not meaning.** Measured on example 12, it removes empty rules (`.x{}`), writes `background:none` as `background:0 0`, and sorts a selector list. A test asserting copied CSS byte for byte compares against `minifyText` of the source, or sets `minify: false`, as `test/integration/packages.test.js` does where it counts reads.
30
+ - **Pipeline outputs are not minified here.** A step's `outputs` are written straight into the build folder, never by the copy. A step that writes into the assets folder is copied, and minified, like any other file. Tailwind's own `--minify` is the usual choice there.
31
+ - **Removing unused code is not this module's job.** It needs the site's judgement (a safelist for classes added at run time), so it is a pipeline recipe in `kiss-performance`, not an engine default.
package/AIKB/assets.md CHANGED
@@ -11,7 +11,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
11
11
 
12
12
  ## Depends on
13
13
 
14
- `fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
14
+ `fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./asset-minify.js` (`minifyFile`, `shouldMinify`: outside dev and unless `assets.minify` is `false`, `recordEmitted` minifies each claimed `.css`/`.js`/`.mjs` that is not Sass or `*.min.*`, in place, **before** hashing, so a hashed name follows the served bytes; a file the minifier cannot read is copied as it is, with one `warn` — `AIKB/asset-minify.md`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
15
15
 
16
16
  ## Depended on by
17
17
 
package/AIKB/audit.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  ## Responsibility
4
4
 
5
- The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident, and the three privacy checks: a form asking for an email address or phone number with no link to the privacy page, a `<mark>`/`[to confirm` placeholder left in the copy, a pre-ticked marketing checkbox. It also derives the build's third-party origins (`thirdPartyOrigins`), the fact behind `report().thirdParty`. Pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
5
+ The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident, and the three privacy checks: a form asking for an email address or phone number with no link to the privacy page, a `<mark>`/`[to confirm` placeholder left in the copy, a pre-ticked marketing checkbox, and the five speed checks: an image with no size, a lazy first image, too many `fetchpriority="high"` or font preloads, a script that blocks the head. It also derives the build's third-party origins (`thirdPartyOrigins`), the fact behind `report().thirdParty`. Pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
6
6
 
7
7
  The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
8
8
 
9
9
  ## Public interface
10
10
 
11
- - `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
11
+ - `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`, the three image checks below (`image-oversized`, `image-location-metadata`, `img-not-responsive`), and `id-slug-mismatch`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
12
12
  - `MARKETING_WORDS` — the frozen word list `checkbox-prechecked` matches a label against (offer(s), news, newsletter(s), marketing, promotion(s), promotional, partner(s), third party/parties), whole-word and case-insensitive, a space also matching a hyphen.
13
- - `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind }]` (every absolute or `//host` origin a fetching element names, sorted); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
13
+ - `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind, service?, tracker? }]` (every absolute or `//host` origin a fetching element names, sorted; `tracker: true` when one of its URLs is on `TRACKER_ORIGINS`); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
14
14
  - `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess, trailingSlash, privacyPage })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess` and `trailingSlash` `config.links.trailingSlash`, passed so a cross-page fragment, and a link to the privacy page, resolves to the page the link scan would resolve the same href to (`servedAs()`, which keeps a learning package's page on its file's folder, as `checkLinks` does). `privacyPage` is `config.audit.privacyPage`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl, id, package }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
15
15
  - `thirdPartyOrigins({ pages, siteUrl })` → `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, or `null` when no page wrote HTML facts. Called by `lib/build-finish.js`'s `reportInputs()` on every report assembly; `lib/build-report.js` maps each `page` out of staging and nulls the key on a failed build.
16
16
  - The `CheckId`, `PageFacts`, `PageOrigin`, `PageConsent`, `PageForm`, `FormField`, `AuditFinding`, `AuditResult`, `AuditPage` and `ThirdPartyOrigins` typedefs.
@@ -19,7 +19,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
19
19
  - **`PageFacts.consent`** is `{ head, banner, settings, gated, signatures }`. `signatures` is `adsbygoogle`, `googletag.cmd`, `gtm.js` or `gtag/js` found in an ungated executable inline script, and `data-ad-client` on an `<ins>`. A script inside a comment or a `<template>` is not read, since a browser never runs it; until 2026-11-03 a commented-out snippet was reported as a tracker (round 6 of the Codex review). The `data-kiss-consent-head` script is never scanned, because its inlined config may name `gtm.js`. The field is absent from facts parked by an older engine.
20
20
  - **`thirdPartyOrigins`** puts a sorted `services` beside `kinds` on an origin a service gates. Every other entry keeps its old shape.
21
21
  - **`auditBuild`** takes `consent` (the resolved `config.consent`, or `null`) and runs eight checks, appended to `CHECKS`:
22
- - `consent-missing`: no consent manager, and a page loads from `TRACKER_ORIGINS` or carries `gtm.js`/`gtag/js` inline;
22
+ - `consent-missing`: no consent manager, and a page loads a script or frame from `TRACKER_ORIGINS` (matched on the whole URL, so a path-qualified entry counts) or carries `gtm.js`/`gtag/js` inline;
23
23
  - `consent-ungated`: a third-party script or iframe no service gates, and no `necessary` service lists in its `origins`, a `<noscript>` GTM frame included;
24
24
  - `consent-ads-cmp`: any page, consent or not: an AdSense or Ad Manager loader, an `adsbygoogle`/`googletag.cmd` signature, or `<ins data-ad-client>`;
25
25
  - `consent-objection-third-party`, site-wide;
@@ -27,10 +27,23 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
27
27
  - `consent-google-advanced`, site-wide;
28
28
  - `consent-records-missing`, site-wide: a Google marketing tag with no `endpoint`;
29
29
  - `consent-history-missing`: the page whose `id` is `ui.history` has no `{{consentRecord}}` section (the page fact `consent.record`, read from a bare `data-kiss-consent-record` attribute, never its `-id`/`-rows` parts). Needed because the dialog's link to `#kiss-consent-history` sits in a `<template>`, which the fragment check never reads (measured 2026-11-03).
30
+ - **The speed checks** (contract `planning/plans/2026-11-04-kiss-performance.md`, "Amendment 2026-11-04: after the critique", "Audit checks"). Five `PageFacts` fields, extracted by `extractSpeed()`, and five checks, appended to `CHECKS`, all per page:
31
+ - `img-dimensions-missing`, from `imgNoSize`: an `<img>` lacking `width` or `height`, with no inline `aspect-ratio` other than `auto`. One finding per image per page, `detail` the src, as `img-alt-missing`.
32
+ - `img-first-lazy`, from `imgFirstLazy`: an `<img>` both `loading="lazy"` and `fetchpriority="high"` (certain, found anywhere); failing one, the first `<img>` after `<main>`'s opening tag and before its first `<h2>` (or `</main>`), when that image is lazy (approximate: it may be the first image seen). `detail` the src.
33
+ - `fetchpriority-excess`, from `fetchpriorityHigh`: more than two elements with `fetchpriority="high"`. `detail` the count.
34
+ - `font-preload-excess`, from `fontPreloads`: more than two `<link rel="preload" as="font">`. `detail` the count.
35
+ - `script-blocking`, from `blockingScripts`: a `<script src>` in the head that `scriptKind` calls `classic` (so not inert, not a module) with neither `defer` nor `async`. One finding per script, `detail` the src.
36
+ - **The image checks** (workstream F, `planning/plans/2026-11-04-kiss-performance.md`). Two new facts, extracted from `strip()` with the speed facts: `imageRefs`, every URL an `<img src>` or an `<img>`/`<source>` `srcset` names (through `srcsetUrls`, the link scan's reading), sorted and deduplicated; and `imgPlain`, the src of each `<img>` with no `srcset` outside any `<picture>` (the `<picture>` blocks are cut before the scan), in document order.
37
+ - **`image-oversized`**: per page, each `imageRefs` URL that `classifyReference` calls internal and `resolveTarget` (with the link scan's `extensionLess`/`trailingSlash`, and a package page's own rule) lands on a raster (`RASTER_EXTENSION`) over `IMAGE_OVERSIZED_BYTES`, 200 KB, **provisional** until measured on the fleet. `detail` is `<build-relative file> (<n> KB)`, and the summary groups by that value (`VALUE_CHECKS`). Only files a page's markup names: a CSS background image is a documented false negative. Sizes are `statSync`ed once per file per audit.
38
+ - **`image-location-metadata`**: from the build-folder walk, every raster whose header (`lib/image-header.js`, the reader `{{image}}` uses: the EXIF GPS IFD and XMP) names a latitude or longitude, referenced or not. `page` is the walked file and the summary counts files (`FILE_CHECKS`). The fix is in `src/`: the original is still copied, so `{{image}}` alone does not remove it.
39
+ - **`img-not-responsive`**: per page, each distinct `imgPlain` src that resolves to a raster in the build: the worklist for migrating to `{{image}}` (D-F3). An SVG, a missing file and an external URL are not named.
40
+ - The first two read files in the build folder, so they join `WALK_CHECKS` and are skipped, and listed in `skipped`, under `cleanBuild: false`. The audit as a whole is skipped in dev, where `{{image}}` writes a plain `<img>` that `img-not-responsive` would otherwise name.
41
+ - **`id-slug-mismatch`**, per page, from each page's `idSlug`: the detail `lib/page-registry.js` `idSlugMismatch` decided in `runAudit` (an explicit id built from a slug `toSlug` changed). Reported for **every** written page, not only the HTML ones the other per-page checks read, because identity belongs to the registry and a JSON or feed page has one too.
42
+ - **`host-unset`**, site-wide, from the `hostUnset` option: the concerns (`'cache headers'`, `'redirects'`) whose writer had something for a host to read and found neither `config.host` nor that concern's `format` set. One finding, `page: null`, `detail` the concerns sorted and joined. It is the only check fed by another module's result rather than by page facts or the walk: `lib/build-finish.js` `runAudit` reads `_cacheHeadersResult.unset` and `_redirectsUnset`, so it runs after both writers. The redirects writer's `warn` line is unchanged; this is the same fact, kept with the rest of the audit.
30
43
 
31
44
  ## Depends on
32
45
 
33
- `node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `srcsetUrls` and `browserReference`, so a `srcset` and a reference are read as the link scan reads them; `classifyReference` and `resolveTarget`, so a cross-page fragment and a privacy link land on the page the link scan resolves their path to), `./utils.js` (`posixPath`) and `./consent.js` (`TRACKER_ORIGINS`, `ADS_CMP_SIGNATURES`, `matchesHost`). No HTML parser, no logger, no config.
46
+ `node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `srcsetUrls` and `browserReference`, so a `srcset` and a reference are read as the link scan reads them; `classifyReference` and `resolveTarget`, so a cross-page fragment and a privacy link land on the page the link scan resolves their path to), `./utils.js` (`posixPath`) and `./consent.js` (`TRACKER_ORIGINS`, `ADS_CMP_SIGNATURES`, `matchesHost`, and `scriptKind`, the one answer to whether a browser runs a script, read by the consent facts and `script-blocking` alike). No HTML parser, no logger, no config.
34
47
 
35
48
  ## Depended on by
36
49
 
@@ -62,11 +75,18 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
62
75
  - **Matching:** the raw fragment is compared first, then its percent-decoded form, both case-sensitively. A text directive is cut off at `:~:`, and an empty fragment is skipped. `#top` with no `id="top"` is skipped in any case, because the spec scrolls to the top.
63
76
  - **The target page:** an empty path or a query-only path (`?q=1#x`) is the page itself. Anything else goes through `classifyReference` and then `resolveTarget`, with `exists: () => false`, so only pages this build wrote can be named. A fragment on an asset, another origin, an earlier build's file or another instance's page has no ids to check against and is skipped. So is every fragment on a page with a `<base href>`.
64
77
  - **`resolveTarget` tries `known` across every candidate before the disk** (`AIKB/links.md`). Asked candidate by candidate, `/about` on a real filesystem named the folder `about`, which holds `about/index.html`, and every extension-less cross-page fragment would have been skipped without a word. A fresh-context critique of the plan caught this before the code existed (2026-10-20).
65
- - **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields.
78
+ - **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields and the five speed fields (`?? []`, `?? null`, `?? 0`); facts are extracted in memory and never parked across engines, so in practice only a hand-built test lacks them.
66
79
  - **The privacy facts read quote-aware** (`ATTRS`, `TAG_END`), for the fragment checks' reason: a misread form, label or checkbox is a finding invented, and Alpine-style `x-show="step > 1"` values are common on exactly the forms these read. `origins` reads from the ids' strip (opening tags kept, so `<script src>` is seen; script, style and template contents gone). `forms`, `anchors`, `placeholders` and `prechecked` read from the counts' wider strip, so a `<mark>` in a script string or a comment never fires.
67
80
  - **`origins` is its own kind-aware fact, not derived from `KissPage.links`** (2026-11-02 critique, blocker 1). `links` is a flat, sorted string list with no tag kind, and it includes every `<a>`, which a browser does not fetch. Kinds: `script`, `iframe`, `img` (`<img>` `src`/`srcset`, `<source srcset>`, `<video poster>`), `media` (`<video>`/`<audio>` `src`, `<source src>`), `form-action`, `stylesheet`, `preconnect`, `dns-prefetch`, and `preload` or `preload:<as>`. Never an `<a>`, a canonical or an alternate link. Only absolute `http(s)` and `//host` references are kept, since a relative one can only be the site's own. Static markup only: an iframe a script injects (a1k9's facade) and a CSS `@import` are invisible, so an empty list proves nothing.
81
+ - **Whether an origin is a tracker is decided at extraction, from the whole URL** (2026-11-05). A `TRACKER_ORIGINS` entry can name a path (`www.google.com` under `/maps`), and the kept `origin` has none, so matching it later can never see the path: 2.12.0 and earlier compared the origin, and a raw Google Maps frame raised no `consent-missing` (a1k9training's `/contact/`). `extractOrigins` runs `matchesHost` on the URL and records `tracker: true` on the `{ origin, kind, service }` entry, which stays one entry per page however many URLs share it; `checkConsent` reads the flag. The ads check is different on purpose: it strips its hosts' paths and matches the origin, so any script from an ad host counts.
68
82
  - **First-party is the host, not the origin** (`thirdPartyOrigins`). An absolute URL whose host equals `siteUrl`'s, with a leading `www.` dropped on both sides, is the site's own, whatever its scheme or port. A subdomain (`cdn.site.example`) is not. With `siteUrl` unset, unparseable or local (`isLocalSiteUrl`, the `site-url-local` rule), every absolute origin is listed and `siteUrl` is `null`: the audit does not guess which one is the site.
69
83
  - **`privacy-link-missing` finds the privacy page by id, then by path, and never by link text.** The page whose `id` is `privacy` (or `config.audit.privacyPage`), else, with no override, the page written to `privacy.html` or `privacy/index.html`. A page counts when one of its forms has a field that is not `hidden` (or another non-typed input), not a honeypot (`bot-field`, or the field the form's `netlify-honeypot` names), and has `type` `email`/`tel`, a `name` of `email`/`e-mail`/`tel`/`phone`/`telephone`, or an `autocomplete` token `email` or `tel…`. It is satisfied by an `<a>` anywhere on the page whose href resolves, through `classifyReference` and `resolveTarget` with `exists: () => false`, to the privacy page. An external href never counts, and the privacy page needs no link to itself. A page with a `<base href>` is not checked at all: its relative links resolve somewhere the build cannot see, so a link a browser follows to the privacy page was reported missing, and one leaving the site could pass (a Codex review, 2026-11-02). The fragment checks skip such a page for the same reason. The `detail` is that field's name (or type). No privacy page at all is one more site-wide finding, said only when some page collects; a `privacyPage` no audited page has is a site-wide finding too, said whatever the forms, rather than a config error, because only the build knows which ids exist.
70
84
  - **`placeholder-left` is `<mark>` or the bracketed `[to confirm`, never the bare phrase.** "to confirm" appeared twelve times as prose in the fleet's sources (2026-11-02 critique). A `[to confirm` inside a `<mark>` is one placeholder, not two. The bracketed text inside a `<code>` or `<pre>` element is not read at all: it is the marker quoted, and kiss's own guide page for this check fired on itself until it was excluded (2026-11-02). A real `<mark>` element still counts wherever it is, because an escaped one (`&lt;mark&gt;`) is not an element.
71
85
  - **`checkbox-prechecked` is a presence test for `checked` on quote-blanked attributes**, like `ALT`, so `data-checked`, `aria-checked` and a `checked` in another attribute's value never match, and a radio never counts. The label is the union of `aria-label`, the `aria-labelledby` targets' text, every `<label for>` naming the box's id and the `<label>` wrapping it. The decision (`MARKETING`) is made at audit time from the stored label, so changing the word list needs no re-extraction.
86
+ - **The speed facts take two readings, chosen by what each one has to see.**
87
+ - **Images from the counts' strip** (`strip()`): an `<img>` inside a `<template>`, an `<svg>`, a script string or a comment is not fetched as the page loads, so it is not judged. kiss's own consent banner logo (`lib/consent-ui.js`, no `width`/`height`) waits in a `<template>` and is never reported. An image inside `<noscript>` is judged, deliberately. An `<img>` inside `<picture>` is judged on its own attributes, because a `<source>`'s `width`/`height` do not size the fallback.
88
+ - **`fetchpriority`, font preloads and head scripts from the opening tags** (`stripForIds()`). The contract named `strip()` for `fetchpriority`, but `strip()` removes a `<script>` element whole, opening tag included, so `<script fetchpriority="high">` would never be counted. Template contents and comments are gone from both readings.
89
+ - **The speed facts read attributes as a tokenizer does** (`attributesOf()`), not with `attribute()`'s first-match-anywhere regex: each is a presence test, and `alt="width=10"` or `data-note="defer"` read as the attribute would hide a finding or invent none where one is due. Names lowercased; the first of two same-named attributes wins, as in HTML. The script's `type`/`language` still go through `rawValue()` and `scriptKind()`, so the audit and the consent gate cannot disagree about which scripts run.
90
+ - **`aspect-ratio: auto` alone reserves nothing.** It waits for the image's own ratio, so an image whose only sizing is that is still reported. `auto 4/3` reserves. The last `aspect-ratio` declaration in the style is the one read.
91
+ - **The head ends at the first `</head>` or `<body`.** The minifier keeps both (no `removeOptionalTags` in `lib/kiss-page.js`), so a kiss page always has one. A page with neither has no head the audit can find, and none of its scripts is judged. A script injected at run time (the consent head's `kiss-consent.<hash>.js`, appended by its inline script) is not in the markup and is not judged; the injected script runs async anyway.
72
92
  - **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
@@ -30,6 +30,10 @@ Each is the body of the `Kiss` private method named beside it:
30
30
  - `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
31
31
  `_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
32
32
  - `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
33
+ - `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
34
+ - `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
35
+ - `encodeImages(kiss)` — `_encodeImages()`: the variants this build's `{{image}}` calls named (`kiss._imageIndex.variants()`), encoded or copied from the committed cache by `lib/image-encode.js` (`AIKB/image-encode.md`), **straight after the purge** and before the redirects, the cache headers (which mark the variants `immutable`), the packages (which pack them, since they are claimed outputs), the link scan and the audit. Latched as `_imagesRun`, reset by the replay, skipped in dev (where `{{image}}` names no variant) and on a failed build. Each written variant is claimed as `images` in the output registry; a variant the previous encode of this instance wrote (`_imageOutputs`) that no page asks for now is removed and released. A source that cannot be encoded is a `_failures` entry `<images: img/x.jpg>` whose message names the pages that asked for it (`pagesUsing`); any other throw is `<images>`. The result is `_imagesResult` (`{ requested, encoded, cached, cache }`, `null` when no variant was asked for), logged as `Images: N variants, H from the cache, M encoded`, and a build with misses warns once: `N image variants were encoded that are not in the committed cache. Run \`npx kiss-ssg images <script>\` and commit <cache>`(not under`kiss-ssg images`itself, and not with`cache: false`). Under `KISS_IMAGES` (`lib/images-sweep.js` `sweepRequest`) it also appends one `sweepLine`to that file, with no variant asked for too, so the sweep sees every instance;`KISS_IMAGES_REFRESH`makes it ignore the cache. It never prunes the cache. The cache folder is`kiss._imageCacheDir`, resolved in the constructor where `resolveConfig`checked it, never against the working directory at the settle: a script that changed directory before`complete()`otherwise wrote and reported the other folder's cache, and the sweep emptied it against this site's pages (measured; Codex adversarial review, 2026-11-05). A render after such a`chdir`also fails`{{image}}`, which reads its source relative to the working directory: that fails loudly and is not changed here.
36
+ - `writeHostCacheHeaders(kiss)` — `_writeCacheHeaders()`: the cache-header files for the hashed files (`lib/cache-headers.js`, `AIKB/cache-headers.md`), written after the consent client, whose file they name. It is latched as `_cacheHeadersRun` and reset by the replay. **Skipped in dev and on a build that has already failed**: a watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash. The result becomes `_cacheHeadersResult`, then `report().cacheHeaders`. It warns once per host with no encoding (`vercel`) and once per Firebase lookalike. A failed write fails the build (`<cache-headers>`). Its `unset`, and the redirects writer's (`_redirectsUnset`), feed the audit's `host-unset` through `runAudit`'s `hostUnset`.
33
37
  - `writeConsentClient(kiss)` — `_writeConsentClient()`: `<build>/kiss-consent.<hash>.js`, the consent client's cached part, whenever `config.consent` is set, dev included; minified with the pages' minifier outside dev; latched as `_consentClientRun`, reset by the replay; claimed; a failed write fails the build (`<consent-client>`), since every consent page loads it. Written before the link scan, so each page's reference to it resolves.
34
38
 
35
39
  Three are named apart from their method because the method's own name is a function this module
@@ -50,6 +54,7 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
50
54
  ## Non-obvious behavior
51
55
 
52
56
  - **`runLinkCheck()` and `runAudit()` both pass `config.links.trailingSlash`**, so a relative reference on a directory index resolves against the URL the host serves it at, the same policy `{{link}}` was written under; `runAudit()` passes each page's `package` too, which keeps a learning package's page on its file's folder (Codex round 6, 2026-11-03).
57
+ - **`runAudit()` decides each explicit-id page's `idSlug`** with `page-registry.js` `idSlugMismatch` (the slug as `options` gave it against `entry.page.slug`, the one written) and hands it to `auditBuild` as data, so the identity rule stays in the registry and the audit only reports it as `id-slug-mismatch`.
53
58
  - **`runAudit()` threads each page's `id` and `config.audit.privacyPage` into `auditBuild`**, so `privacy-link-missing` can find the privacy page by id. The pages come from `_stackForRecord()`, not the raw stack, so the ids are the ones the report shows: a default `privacy` that two pages arrived at, which `{{link}}` would refuse, finds no privacy page either.
54
59
  - **`reportInputs()` derives `thirdParty` on every assembly**, from the audit facts each written page parked (`thirdPartyOrigins`, `lib/audit.js`), against the instance's `config.siteUrl` (a page's own `siteUrl` override is not consulted for this key). It reads no file, so it costs a walk over the stack and is current under `_refreshReport()` too; `buildReport` nulls it on a failed build. It comes after `packages`, as the report's key order fixes it.
55
60
  - **`reportInputs()` derives `consent` last, the same way:** `consentReport` (`lib/consent.js`) from `config.consent` and each written HTML page's parked `audit.consent`. Pages carrying gated markup are passed as `gatedPages`, from which `pagesWithoutHead` is derived. Paths stay as written (staging under check) for `buildReport` to map. It is `null` without `config.consent`. `runAudit` passes `config.consent` to `auditBuild` for the seven consent checks.
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
9
+ - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images, ids })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
10
10
  - `ok` — `failures.length === 0`.
11
11
  - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
12
  - `buildDir` — the folder the site asked for, never the staging sibling.
@@ -23,7 +23,10 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
23
23
  - `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
24
24
  - `audit` — after `outputs` (then `packages`, then `thirdParty`): `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
25
25
  - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON. After the packages, one ` third-party origins (N): <the first five>, …` line when `thirdParty` lists any, then one ` consent: version <v>, banner <hash>, N services` line when `consent` is set.
26
- - **The `consent` key, last** (2.11.0). It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
26
+ - **The `ids` key, last, after `images`** (2026-11-05, path ids): `config.ids`, `'view'` or `'path'`, always present. It is in the report so it reaches `last-build.json`, where the next build's `redirectFindings` reads it back and pairs `moved` by id only within one convention (`AIKB/redirects.md`). Every recorded `last-build.json` gains `"ids": "view"` at its next `kiss-ssg aikb`; examples 9 and 11 were re-recorded for it.
27
+ - **The `images` key, after `cacheHeaders`** (workstream F). `null` when no page rendered an `{{image}}`, in dev, and on a failed build; otherwise `{ requested, encoded, cached, cache }` from `lib/build-finish.js` `encodeImages`: counts only, so `last-build.json` does not grow by a line per variant, and `cache` is `assets.images.cache` as configured. The variants stay out of `assets`, so `report().assets` and the `check` baseline do not move. Every recorded `last-build.json` gains `"images": null` at its next `kiss-ssg aikb` (examples 9 and 11 were re-recorded for it). `image-oversized` joins `VALUE_CHECKS` (the line names the file and its size) and `image-location-metadata` joins `FILE_CHECKS`.
28
+ - **The `cacheHeaders` key, after `consent`.** It is `null` when nothing was written: no hashed file, a dev build, or a failed build. Otherwise it is `{ rules: [{ path, headers }], files, formats, unset, merged }`. `files` are mapped out of staging; `rules` holds URL paths, which carry no staging prefix. It moves every recorded `last-build.json`, which gains `"cacheHeaders": null` at its next `kiss-ssg aikb`.
29
+ - **The `consent` key** (2.11.0), just before `cacheHeaders`. It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
27
30
  - Every page path goes through `real()` and is then sorted.
28
31
  - It carries **no timestamp**, so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
29
32
  - `thirdParty`'s origins carry `services` beside `kinds` when a consent service gates them.
@@ -42,7 +45,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
42
45
 
43
46
  - **`thirdParty` is the last key**, after `packages`: `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, the origins other than the site's own that the written pages fetch from (`thirdPartyOrigins` in `lib/audit.js`, `AIKB/audit.md`). A fact, not a finding: `audit.check: false` does not turn it off and it never moves `ok`. `null` when no page wrote HTML facts, and **`null` on any failure** — the audit's stance, because a page that failed fetched nothing on record and a partial inventory reads as a complete one; the rule is here rather than upstream so `_refreshReport()` applies it too. Each `page` goes through `real()`; origins are URLs and are not mapped. `siteUrl` is `null` when it was unset or local and every absolute origin is therefore listed. Present in dev builds, unlike `audit`: it reads only the facts the pages parked, so a scoped re-render's page is current. `AIKB/last-build.json` gains it at a site's next `kiss-ssg aikb`; `diffReports` does not read it.
44
47
  - **`packages`** comes after `audit`: one `BuildPackage` per learning package and format (`id`, `format`, `file`, `title`, `hash`, `pages`, `files`, `outside`), sorted by id then format, or `null` when no page is in a package, packaging is off, the build failed, and in a dev build. `file`, every `pages` entry and every `outside[].page` are mapped out of staging like every other path. `formatReport` ends with one `package:` line per package and up to five `package outside:` lines each, then a count of the rest.
45
- - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` does the same thing for the failure _message_; this is the same rule somewhere a unit test can reach it, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
48
+ - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` (`lib/staging.js` `toReportedPath`) calls this same function for the failure _message_, so the two cannot name a file differently; this is the rule somewhere a unit test can reach it. A path joined with `/` or, on Windows, with `\` (a writer that used `path.join`) is mapped, and always written with `/`: `cacheHeaders.files` named the discarded staging folder until the fleet check of 2026-11-05. The rule lives here, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
46
49
  - **A failure's `Error` is reduced to its `message` on purpose.** `JSON.stringify(new Error('x'))` is `{}`, so a report carrying the object loses exactly the text that says what went wrong. The object itself stays on the `AggregateError` `complete()` rejects with — the report is the serialisable view of the same build, not a replacement for it.
47
50
  - **`assets` is the manifest, so it is keyed by the path a template writes**, not by the source file: a `.scss` appears under its compiled `css/site.css` name, and `target` is where cache busting put it (`css/site.a1b2c3d4.css`). See `AIKB/asset-manifest.md`.
48
51
  - **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
@@ -0,0 +1,45 @@
1
+ # cache-headers.js
2
+
3
+ ## Responsibility
4
+
5
+ Cache headers for the files kiss content-hashed: say, in the encoding the site's host reads, that each one can be cached for a year (`public, max-age=31536000, immutable`). Pure functions plus one writer (`writeCacheHeaders`), the shape of `lib/redirects.js`. The module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs: `lib/build-finish.js` `writeHostCacheHeaders` does that.
6
+
7
+ **Only hashed files, and never HTML.** A hashed file's bytes are its name, so a year is simply true for it. An unhashed file's right lifetime is the owner's decision: a photograph replaced under its old name would stay stale for a year. That is what the fleet's hand-written rules got wrong on two sites (measured, `planning/research/2026-11-04-static-site-performance.md` § Measured). HTML gets no rule on any host, because a page answers at several URLs (`/about`, `/about.html`, `/courses/index.html`) and a rule written for one misses the others. Netlify's own HTML default (`max-age=0, must-revalidate`) is already right.
8
+
9
+ **The list first, then the encodings.** `cache-headers.json` (`{ version, rules: [{ path, headers }] }`) is written whenever a hashed file exists, whatever the format says, like `redirects.json`. `cacheHeaders.format` then picks the host files, and `config.host` sets it when the site did not (`lib/config.js` `HOSTS`).
10
+
11
+ ## Public interface
12
+
13
+ - `IMMUTABLE` — the one `Cache-Control` value written.
14
+ - `CONSENT_HASH_LENGTH` — `10`, the hex characters in `kiss-consent.<hash>.js` (`lib/consent-client.js`).
15
+ - `BLOCK_START` / `BLOCK_END` — the two marker lines around kiss's block in `_headers`.
16
+ - `hashedFiles({ manifest, consentClient, images, root })` → the `/`-rooted, sorted paths of every hashed file that is on disk under `root`. The input is `assetManifest.toObject()` filtered to `isHashable(name) && name !== output`, plus the consent client, plus `images`: `{{image}}`'s variants (`img/hero-960.<hash>.avif`), which are content-named whatever `assets.hash` says and stay out of the manifest, so `lib/build-finish.js` hands them over from the image index. A variant whose encode failed is not on disk and is dropped like any other. `writeCacheHeaders` takes the same `images` and passes it through. The Firebase pattern (`FIREBASE_HASHED`) matches kiss's hash shape before `.css`, `.js`, `.avif`, `.webp`, `.jpg` and `.png`, so a variant is `immutable` there too, and an unhashed `photo.deadbeef.jpg` is now a lookalike the build warns about. An entry not on disk under `root` is dropped: that is a copy whose target is outside the build folder, which no host serves from here. A path with whitespace in it is dropped too, because it would write two lines.
17
+ - `cacheHeaderRules(files)` → `[{ path, headers: { 'Cache-Control': IMMUTABLE } }]`.
18
+ - `renderCacheHeadersJson(rules)` → the list, pretty-printed and newline-terminated.
19
+ - `renderHeadersBlock(rules)` → kiss's block for a `_headers` file (Netlify, Cloudflare Pages), between the markers.
20
+ - `mergeHeadersFile(existing, block)` → the site's file with the block appended. A block an earlier build left is replaced, never repeated. `existing` of `null` returns the block alone.
21
+ - `renderFirebaseCacheHeaders({ consent })` → a fragment to merge into `firebase.json`: `{ headers: [{ regex, headers: [{ key, value }] }] }`, one pattern for kiss's hash shape (`.<8 hex>.css|js`) and one for the consent client.
22
+ - `firebaseLookalikes(built, hashed)` → the files in the build the hash pattern matches that kiss did not hash.
23
+ - `CACHE_HEADER_FORMATS` — `netlify` and `cloudflare` (both `_headers`), `firebase` (`cache-headers.firebase.json`). The single table of which formats exist; `lib/config.js` derives its accepted names from it.
24
+ - `resolveCacheHeaderFormats(format)` → the formats to run: `'none'` dropped, a bare name or function as a list of one, `netlify` and `cloudflare` together writing `_headers` once.
25
+ - `writeCacheHeaders({ config, root, manifest, consentClient, outputs })` → `{ status, rules, files, formats, unset, merged, lookalikes, uncovered }`. `status: 'none'` (nothing written) when no file is hashed. `unset` is true when hashed files exist and neither `host` nor `cacheHeaders.format` was set. `uncovered` lists the hosts with no encoding yet (`vercel`). A writer function receives `(rules, { buildDir, config })` and returns `[{ file, contents }]`, resolved under the build folder; an absolute path or one climbing out throws.
26
+ - The `CacheHeaderRule` and `CacheHeadersWriteResult` typedefs.
27
+
28
+ ## Depends on
29
+
30
+ `fs-extra`, `node:path`, `./asset-manifest.js` (`HASH_LENGTH`, `isHashable`), `./utils.js` (`posixPath`).
31
+
32
+ ## Depended on by
33
+
34
+ - `lib/build-finish.js` `writeHostCacheHeaders`, which `Kiss._writeCacheHeaders()` calls at the top of `complete()`'s settle path, after the consent client and before the packages. It is latched on `_cacheHeadersRun` and reset in `lib/rebuild.js` per build. Its result becomes `report().cacheHeaders`, appended after `consent` (`AIKB/build-report.md`).
35
+ - `lib/config.js`: `CACHE_HEADER_FORMATS`, for `CACHE_HEADER_FORMAT_NAMES` and the format check.
36
+ - `lib/kiss.js` re-exports `renderCacheHeadersJson`, `renderHeadersBlock` and `renderFirebaseCacheHeaders`, so a custom writer composes an encoding rather than reimplementing one: the redirect renderers' reason (`AIKB/redirects.md`).
37
+
38
+ ## Non-obvious behavior
39
+
40
+ - **A site's own `_headers` is kept and appended to, a deliberate exception to "kiss never merges".** Sites keep security headers (CSP, `X-Frame-Options`) in `src/assets/_headers`. Overwriting it, the way generated output beats a copied asset everywhere else (`lib/output-registry.js`), would remove them silently. Unlike `firebase.json`, this file is build output rebuilt on every deploy, so appending to it touches no source. The merged file is **not** claimed as generated: it stays the asset copy's, so no collision is reported for a write that lost nothing. The operator's decision, 2026-11-04 (`planning/plans/2026-11-04-kiss-performance.md`).
41
+ - **Firebase gets patterns, every other host gets exact paths.** The Firebase fragment is merged into `firebase.json` by hand, once. Exact paths in it would go stale at the next stylesheet edit, because the hash changes. `_headers` is rebuilt on every deploy, so exact paths there never go stale and never match a file they should not.
42
+ - **A pattern can match a file kiss did not hash**, such as a vendored `lib.deadbeef.js`. That file would be cached for a year. The writer reports such files as `lookalikes`, and the build **warns** rather than fails: the operator's decision at the 2026-11-04 pulse, "It's an optimisation rather than a critical issue".
43
+ - **Not written in dev, nor on a failed build** (`writeHostCacheHeaders`). A watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash until the next one. A failed build publishes nothing.
44
+ - **`'github-pages'` is silent and `'vercel'` warns.** GitHub Pages cannot be configured (it sends `max-age=600` on everything, measured), so the host implies `'none'`: a fact, not a gap. Vercel has a header encoding kiss has not written yet, so the build names it. The difference is one cell in `HOSTS`, `'none'` against `null`.
45
+ - **Host behaviour that is not yet measured** and that GUIDE.md therefore does not state: whether `_headers` or `netlify.toml` wins on Netlify when both set `Cache-Control` for a path; whether Firebase applies `regex` header rules, and in what order against `**` globs. Both are measured on deploy previews before the docs say more (`planning/plans/2026-11-04-kiss-performance.md` § Must measure).
package/AIKB/check.md CHANGED
@@ -14,7 +14,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
14
14
  published output. The engine's side of the protocol the bin sets up; they moved here from
15
15
  `lib/kiss.js` (2026-10-02) when the method that reads `KISS_AIKB` left it for
16
16
  `lib/build-finish.js`, so that no module has to import `kiss.js`.
17
- - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line. `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
17
+ - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line, as it names `images` (`lib/images-sweep.js`, which the bin dispatches before `parseArgs`, with its own `IMAGES_HELP`). `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
18
18
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
19
19
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
20
20
  - `readReportsFile(text)` → the same `BuildReport[]`, from any of the four shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, the `{ reports, diff }` object it prints whenever it diffs, or a single report object. The wrapper was once read as a single report with no pages, so a check's own output handed back as `--against` diffed every page as added (found upgrading K9-Solutions, 2026-09-27). What `--against` reads.
package/AIKB/config.md CHANGED
@@ -7,14 +7,17 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
7
7
  ## Public interface
8
8
 
9
9
  - `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
10
- - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
10
+ - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
11
11
  - `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
12
- - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
12
+ - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
14
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
15
15
  - `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), `ignore: []`, the check ids not to run, and `privacyPage: null`, the override of the convention `privacy-link-missing` uses to find the privacy page (id `privacy`, else `privacy.html` or `privacy/index.html`).
16
16
  - `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
17
17
  - `resolveMarkdownCopies(value)` → `{ write, selector }`. The `resolveAudit` shape, except that `true` is a statement rather than the default: `undefined` is the default block, `true` is `{ write: true, … }` (always), `false` is `{ write: false, selector: 'main' }`, a plain object is merged one level deep over the default; anything else throws, and so does a `write` other than `true`, `false` or `'llms'`, or an empty `selector`. Exported because a page's own `config.markdownCopies` arrives as the page wrote it — `false` on one page — and the page registry resolves it with this. An already-resolved block resolves to itself.
18
+ - `HOSTS` — frozen table, `host` → `{ redirects, cacheHeaders }`: the format each concern takes when the site names that host and leaves the concern's own `format` unset. `null` in a cell (`vercel`'s cache headers) means kiss has no encoding yet, and the writer warns. `'none'` (`github-pages`) means the host cannot be configured, which is a fact, and silent. `config.host` is resolved to a deduplicated list (`resolveHost`), and a name not in the table throws, with every known name listed. `formatsForHosts(hosts, concern)` gives the list a concern derives. **`host` is never read for `links.trailingSlash`**: Firebase's slash policy is set in `firebase.json`, not by the host's name.
19
+ - `DEFAULT_CACHE_HEADERS` — frozen object, the cache-header block: `format: null`, which follows `host`, or writes the list alone when `host` is unset too. `CACHE_HEADER_FORMAT_NAMES` comes from `lib/cache-headers.js` `CACHE_HEADER_FORMATS`, plus `'none'`. An unknown name throws at `resolveConfig`, as the redirects stance does.
20
+ - **A concern the site set keeps its value; one it left unset follows `host`** (`resolveConfig`, after both formats are checked). The derived value is a list, so a derived `[]` or `['none']` is a decision, and the redirects writer's `unset` warning stays silent for a `github-pages` site.
18
21
  - `DEFAULT_REDIRECTS` — frozen object, the redirect block: `format: null`, meaning no host encoding — the IR alone. It also takes a **list**, so `['netlify', 'firebase']` emits both host files. `REDIRECT_FORMATS` is the frozen list of accepted names (`'netlify'`, `'firebase'`, `'vercel'`, `'none'`); a function is also accepted, as a custom writer (`AIKB/redirects.md`).
19
22
  - `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
20
23
  - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
@@ -64,6 +64,7 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
64
64
  - at `DOMContentLoaded` for the stored choice or an `objection` category;
65
65
  - on the click for a new grant.
66
66
  - **What scripts miss:** revived scripts never see `DOMContentLoaded` (`AIKB/upstream.md`).
67
+ - **Only a Load click asks a video to play.** `reveal(box, clicked)` sets `autoplay=1` on the cloned iframe's `src` (through `URL`, before the frame is in the page, so the player loads once) when `clicked` is true and the box carries `data-kiss-embed-autoplay` (`lib/embed.js`, a video service's box). The `load` action passes `true`. `revealAllowed()`, the reveal from a stored choice or an Always allow grant, passes nothing, so a page load or a grant never starts a video with sound on its own. The browser suite reads the frame's `src` both ways, and was red without this change (2.13.0).
67
68
  - **Withdrawal reloads.** A choice that refuses a category active on this page, or a service this page started, takes the withdrawal path. A service counts as started when its embed was revealed, a gated script of it was revived, or a `when()` callback for it ran, each recorded by `started()`. Until 2026-11-03 only a revealed embed counted, so a service started under an "always allow" grant by `when()` or its own scripts, with its category refused, kept running after Reject all, and its cookies stayed (found by the Codex review):
68
69
  1. write the cookie, send the `update`, append to the log and send the POST;
69
70
  2. delete the first-party cookies and storage of every denied service, on no domain, the host and each parent domain (S7). A `__Secure-` or `__Host-` cookie is deleted with `Secure`, which a browser requires, and a `__Host-` one on no domain only, since it cannot carry one (found by the Codex review, 2026-11-03);
@@ -4,10 +4,10 @@
4
4
 
5
5
  The consent manager's server side:
6
6
 
7
- - the markup that holds a service back until the visitor allows it: gated scripts and click-to-load embeds;
7
+ - the markup that holds a script back until the visitor allows it;
8
8
  - the check that a rendered page is wired so that it can be.
9
9
 
10
- The helpers in `lib/handlebars-helpers.js` call the first; `lib/kiss-page.js` calls the second on every rendered HTML page. The attribute vocabulary is the contract's (`planning/plans/2026-11-02-consent-manager.md` §3), and `lib/consent-client.js` reads the same names.
10
+ `{{consentScript}}` in `lib/handlebars-helpers.js` calls the first; `lib/kiss-page.js` calls the second on every rendered HTML page. An embed's markup is `lib/embed.js`'s (`AIKB/embed.md`). The attribute vocabulary is the contract's (`planning/plans/2026-11-02-consent-manager.md` §3), and `lib/consent-client.js` reads the same names.
11
11
 
12
12
  ## Public interface
13
13
 
@@ -28,17 +28,10 @@ The helpers in `lib/handlebars-helpers.js` call the first; `lib/kiss-page.js` ca
28
28
  - **Throws** on:
29
29
  - a missing `{param}` value;
30
30
  - `document.write` or `writeln` in an inline body;
31
- - an `<iframe>` in a block or partial;
31
+ - an `<iframe>` in a block or partial (the message points at `{{embed}}`);
32
32
  - a service with nothing to load.
33
- - **`embedMarkup(name, service, { consent, params, src, title })` → `string`.** What `{{consentEmbed}}` writes:
34
- - a `div.kiss-embed[data-kiss-embed][data-kiss-consent]` holding a `[data-kiss-consent-ui]` notice with **Load it** and **Always allow** buttons (wording from `ui.text`, filled in one pass: `{provider}` with the provider, `{service}` with the label. The default buttons name the service, since the grant is for it alone: "Always allow Google" on a YouTube video read as all of Google, 2026-11-04);
35
- - the provider's own link (`embed.watch`), omitted when it has none or a placeholder cannot be filled;
36
- - the iframe inside `<template data-kiss-embed-frame>`.
37
-
38
- A `necessary` service gets the plain iframe. **Throws** with no title, or no src.
39
-
40
33
  - **`consentWiringProblems(html)` → `string[]`.** One sentence per problem:
41
- 1. gated markup, or a settings control, with no consent head;
34
+ 1. gated markup, or a settings control, with no consent head. A facade embed (`data-kiss-embed-facade`, `{{embed}}` with no `config.consent`) is not gated markup: it wires itself and remembers nothing;
42
35
  2. a head with no banner, or more than one head or banner;
43
36
  3. an executable script before the head's;
44
37
  4. one service's script `src` twice on a page.
@@ -46,11 +39,11 @@ The helpers in `lib/handlebars-helpers.js` call the first; `lib/kiss-page.js` ca
46
39
 
47
40
  ## Depends on
48
41
 
49
- `htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `gatingOf`).
42
+ `htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `gatingOf`, `scriptKind`).
50
43
 
51
44
  ## Depended on by
52
45
 
53
- `lib/handlebars-helpers.js` (`consentScript`, `consentEmbed`); `lib/kiss-page.js` (`consentWiringProblems`). Tests: `test/unit/consent-gate.test.js`, and `test/integration/consent.test.js`, which builds `test/helpers/consent-site.js`.
46
+ `lib/handlebars-helpers.js` (`consentScript`); `lib/kiss-page.js` (`consentWiringProblems`). Tests: `test/unit/consent-gate.test.js`, and `test/integration/consent.test.js`, which builds `test/helpers/consent-site.js`.
54
47
 
55
48
  ## Non-obvious behavior
56
49
 
@@ -61,4 +54,4 @@ The helpers in `lib/handlebars-helpers.js` call the first; `lib/kiss-page.js` ca
61
54
  - It fails the page in a production build, with `consent: <problems> (<view>)`, and warns in dev, the `{{link}}` stance. A gated service with no consent head would never run, and a script ahead of the head would run before the Consent Mode default is set.
62
55
  - JSON-LD and other data blocks before the head are fine.
63
56
  - A page with no `data-kiss-` attribute at all costs one `includes` and is never parsed.
64
- - **A service loaded twice on one page is a problem,** because the client would run it twice. Two `{{consentEmbed}}` of one service are not: each embed is its own frame.
57
+ - **A service loaded twice on one page is a problem,** because the client would run it twice. Two `{{embed}}` of one service are not: each embed is its own frame.
@@ -25,13 +25,13 @@ No Handlebars and no browser. The markup speaks the attribute vocabulary of cont
25
25
  - **`__head`:** its `h2` (`id="kiss-consent-dialog-title"`) and a ✕ (`close-dialog`).
26
26
  - **`__body`:** one `__category` per category: the `necessary` one first, then each in `inUse`. Each has a `__row` holding a `<label for>` and a **switch**, then the description, then `<ul class="kiss-consent-dialog__services" aria-label="{servicesLabel}">` with one `<li>` per service of that category in name order, "label (provider)", the provider always, even when it repeats the label. A category with no services, `necessary` included, has no list. A switch is `<input type="checkbox" role="switch" class="kiss-consent-switch" id="…">`: `disabled checked` for `necessary`, and `data-kiss-consent-category="…"` otherwise. Under a category, a hidden `__grant` row with a `data-kiss-consent-service` switch for each of its services.
27
27
  - **`__foot`:** Accept all, Reject all and Save choices, the three choices together and alike, Accept first as in the banner. Under them, a `__links` line holding the policy link and the history link, each only when given, and no line when neither is (the operator, 2026-11-04).
28
- - **`tableHtml(resolved)`** → one `<table class="kiss-consent-table">` for `necessary`, then one per category in `inUse`, each inside a `<div class="kiss-consent-table-wrap" role="region" tabindex="0">` that scrolls sideways on a phone. Columns: Service, Provider, Purpose, Name, Kind (Cookie / Local storage), Duration, Party (First / Third). The provider is linked to the service's `policy`, its privacy policy, when it has one, in the table and in the dialog's lists alike (`providerHtml`). Cookiebot's declaration does the same, and kiss had the URL and showed it nowhere (the operator, 2026-11-04). `resolveConsent` allows only an http(s) URL there, so the href needs only escaping. A linked provider reads "( Google )" in `textOf`, which turns every tag into a space. So the registry's text and the hash take that spacing.
28
+ - **`tableHtml(resolved)`** → one `<table class="kiss-consent-table">` for `necessary`, then one per category in `inUse`, each inside a `<div class="kiss-consent-table-wrap" role="region" tabindex="0">` that scrolls sideways on a phone. Columns: Service, Provider, Purpose, Name, Kind (Cookie, or the storage item's `kind` as `STORAGE_KINDS` names it: Local storage, Session storage, IndexedDB), Duration, Party (First / Third). The provider is linked to the service's `policy`, its privacy policy, when it has one, in the table and in the dialog's lists alike (`providerHtml`). Cookiebot's declaration does the same, and kiss had the URL and showed it nowhere (the operator, 2026-11-04). `resolveConsent` allows only an http(s) URL there, so the href needs only escaping. A linked provider reads "( Google )" in `textOf`, which turns every tag into a space. So the registry's text and the hash take that spacing.
29
29
  - **`settingsControlHtml(resolved)`** → `<button type="button" class="kiss-consent-settings" data-kiss-consent-action="settings">`.
30
30
  - **`themeCss(ui)`** → the CSS, or `''` under `ui.theme: false`.
31
31
 
32
32
  ## Depends on
33
33
 
34
- `lib/consent.js`, for types only.
34
+ `lib/consent.js`: its types, and `STORAGE_KINDS` for the table's Kind column.
35
35
 
36
36
  ## Depended on by
37
37
 
@@ -44,7 +44,7 @@ No Handlebars and no browser. The markup speaks the attribute vocabulary of cont
44
44
  - **Equal weight is structural.** Accept and Reject share one class and the CSS has one rule for it, with no primary variant (the ICO: "equally prominent"). A test pins both. Choose cookies is a different thing, `kiss-consent__more`: a text button in the accent colour, as Google's own consent screen sets "More options" beside its two equal buttons (the operator's call, 2026-11-03). The ICO's equal-prominence rule is about accepting and refusing; a way to choose in detail may look different.
45
45
  - **The dialog names the services, so the hash does.** The ICO: name every controller relying on the consent, because consent for categories of third-party controllers is not specific enough. `lib/handlebars-helpers.js` hashes the banner plus this dialog, so the list is in every banner hash and in `consent-banners.json`'s `text`. A provider changed in config moves the hash, which is the point: a record names what the visitor read. Before 2026-11-04 the text held each label once, from the hidden grant rows, and no provider. The list's rule shares the description's selector to save bytes. The footer's links are a `__links` line, ruled off from the choices and centred. That took the CSS to 7,204 bytes, and the operator raised the ceiling from 7,168 to 7,424.
46
46
  - **The switches carry no `checked`** (except `necessary`'s). The client sets each from the stored choice, and from an `objection` category's default, so the markup is the same for every visitor and the banner hash does not depend on state.
47
- - **The grant toggles are hidden in the markup.** The client unhides the one for a service with an "always allow" grant, so a grant can be withdrawn where it is shown. Every service of a category in use gets one, not only a service with an `embed` block: `{{consentEmbed}}` takes any service with `src=` and offers "Always allow" for it, and until 2026-11-03 such a grant had no switch, so Save kept it with its category unticked (round 7 of the Codex review).
47
+ - **The grant toggles are hidden in the markup.** The client unhides the one for a service with an "always allow" grant, so a grant can be withdrawn where it is shown. Every service of a category in use gets one, not only a service with an `embed` block: `{{embed}}` takes any service with `src=` and offers "Always allow" for it, and until 2026-11-03 such a grant had no switch, so Save kept it with its category unticked (round 7 of the Codex review).
48
48
  - **The table always starts with kiss's own three items:** `cookie.name` (`{days} days`), `<cookie.name>_closed` (Session; `kiss_consent_closed` by default) and the consent log, `<cookie.name>_log` (Local storage, Until cleared; D11), all first-party, both derived names being the ones `lib/consent-client.js` derives. A service that stores nothing gets one row with `—` for name, kind, duration and party, so every declared service is listed. Services are in name order; a stored item with no description takes its service's.
49
49
  - **What the client does instead.** The confirmation after a choice and the bottom banner's `scroll-padding-bottom` are the client's (contract §3). The CSS styles `[data-kiss-consent-confirmation]` by the `data-position` the client copies onto it.
50
50
  - **A top banner is `position:relative`, not `static`.** It stays in the flow as the contract says, and anchors the absolutely placed close button. A bottom banner is fixed, `z-index:2147483000`, `max-height:80vh` and scrolls.
package/AIKB/consent.md CHANGED
@@ -12,7 +12,7 @@ The cookie consent manager's config, and every decision made from it. It covers:
12
12
  - what the browser is handed;
13
13
  - what the build report records.
14
14
 
15
- It writes no markup and runs nothing in a browser. The gated tags are `lib/consent-gate.js`'s job, the default banner `lib/consent-ui.js`'s, and the page's own behaviour `lib/consent-client.js`'s. The contract is `planning/plans/2026-11-02-consent-manager.md`.
15
+ It writes no markup and runs nothing in a browser. The gated tags are `lib/consent-gate.js`'s job, an embed `lib/embed.js`'s, the default banner `lib/consent-ui.js`'s, and the page's own behaviour `lib/consent-client.js`'s. The contract is `planning/plans/2026-11-02-consent-manager.md`.
16
16
 
17
17
  ## Public interface
18
18
 
@@ -36,10 +36,12 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
36
36
  - **`originOf(url)`** and **`matchesHost(url, list)`** → URL helpers for the audit.
37
37
  - **Frozen tables:**
38
38
  - `CATEGORY_NAMES`, `CONSENT_TYPES`;
39
- - `PRESETS` (gtm, gtag, youtube, vimeo, google-maps);
39
+ - `PRESETS` (gtm, gtag, youtube, vimeo, google-maps, instagram, soundcloud; soundcloud's origins measured 2026-11-04, nothing stored logged out);
40
+ - `STORAGE_KINDS` (a storage item's `kind`, as the cookie table names it);
40
41
  - `TRACKER_ORIGINS`, `ADS_CMP_SIGNATURES`;
41
42
  - the `DEFAULT_*` blocks.
42
- - **Typedefs:** `KissConsent`, `KissConsentInput`, `ConsentService`, `ConsentStored`, `BuildConsent`, `PageConsentFacts`.
43
+ - **`presetService(name)`** → `ConsentService | null`, a preset resolved alone (below).
44
+ - **Typedefs:** `KissConsent`, `KissConsentInput`, `ConsentService`, `ConsentEmbed`, `ConsentStored`, `BuildConsent`, `PageConsentFacts`.
43
45
 
44
46
  ## Depends on
45
47
 
@@ -48,9 +50,9 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
48
50
  ## Depended on by
49
51
 
50
52
  - `lib/config.js` (`resolveConfig`).
51
- - `lib/consent-ui.js` (the resolved shape it renders; `clientConfig` feeds `lib/consent-client.js`'s `consentHeadScript`).
52
- - Planned on this branch (contract §9): `lib/consent-gate.js`, `lib/handlebars-helpers.js`, `lib/audit.js` and `lib/build-finish.js`.
53
- - `test/unit/consent.test.js`.
53
+ - `lib/consent-ui.js` (the resolved shape it renders, and `STORAGE_KINDS`; `clientConfig` feeds `lib/consent-client.js`'s `consentHeadScript`).
54
+ - `lib/consent-gate.js` (`fill`, `gatingOf`, `scriptKind`), `lib/embed.js` (`PRESETS`, `fill`, `gatingOf`, `presetService`), `lib/handlebars-helpers.js`, `lib/audit.js` and `lib/build-finish.js`.
55
+ - `test/unit/consent.test.js`, `test/unit/embed.test.js`.
54
56
 
55
57
  ## Non-obvious behavior
56
58
 
@@ -69,7 +71,12 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
69
71
  - **Where it shows:** `resolved.google.map` is the rebased map, and `consentModeDefault`, `clientConfig` and the client all read it.
70
72
  - **A site's `cookies` and `storage` add to a preset's,** as `origins` do; an entry with a preset entry's name replaces it in place (`mergeStored`). Until slice 6's documentation found the two rules disagreed, a site's list replaced the preset's, and a `gtm` that declared `_fbp` silently dropped `_ga`.
71
73
  - **A preset's provider is part of `version`.** On 2026-11-04 the `youtube` preset's provider became Google (the operator: "YouTube is Google"). The provider names the controller relying on the consent, so the change moved the purposes version of every site using the preset, and their visitors are asked again on upgrade. That is the rule working as written above, not a side effect. The changelog's upgrade note says so. The same day, the embed wording's defaults moved from `{provider}` to `{service}` (the label) for its two buttons, so a YouTube embed does not offer "Always allow Google" for a grant of YouTube alone. `servicesLabel` names the dialog's service lists. A service's `policy` became an href that day, so `policyUrl` accepts only an absolute http(s) URL or null, and fails the build on anything else (a page id, `javascript:`, a bare host). It is not part of `version`.
72
- - **First- and third-party storage.** A stored item (a cookie, or a `localStorage` key under `storage`) with a `domain` is third-party unless `party: 'first'` says otherwise. `clientConfig` hands the browser first-party names only, because a page can delete nothing else. The presets' YouTube storage and Vimeo cookies are third-party: listed in the cookie table, never deleted.
74
+ - **First- and third-party storage.** A stored item (a cookie, or an item under `storage`) with a `domain` is third-party unless `party: 'first'` says otherwise. `clientConfig` hands the browser first-party names only, because a page can delete nothing else. The presets' YouTube storage and Vimeo cookies are third-party: listed in the cookie table, never deleted.
75
+ - **A storage item has a `kind`:** `local` (the default), `session` or `indexeddb`; a cookie's is `cookie`, and `kind` on a cookie entry is refused. The cookie table names it (`STORAGE_KINDS`: Local storage, Session storage, IndexedDB), and `clientConfig` sends only first-party `local` names, since the client deletes `localStorage` keys and nothing else. Added 2026-11-04 with the YouTube preset's IndexedDB database `YtIdbMeta`: before it, every storage item was labelled "Local storage", which would have put a false row in every YouTube site's table. `kind` is not part of `version`.
76
+ - **`embed.video`** (a flag, `false` by default; `true` on the `youtube` and `vimeo` presets) marks a video player. `lib/embed.js` reads it to put a play mark on a postered Load button, to label the button with `embedPlay` rather than `embedLoad`, and to mark the box for autoplay on Load.
77
+ - **`DEFAULT_TEXT.embedPlay` ("Play video") and `embedWording(text, own)`.** A video's button has its own key. `embedWording` makes it follow a site's `embedLoad` when the site set that and not `embedPlay`, so a site that translated `embedLoad` before 2.13.0 does not gain an English word on its videos. `resolveUi` applies it to `ui.text`, and `lib/embed.js` `resolveEmbeds` to `embeds.text`. It is not in the banner hash, which is taken over the banner's and dialog's rendered HTML only (`bannerHash`), so a new text key changes no site's records.
78
+ - **An embed's frame sizing is config** (`embed.ratio`, `width`, `height`, `measure`), resolved here and written by `lib/embed.js` (`AIKB/embed.md`). `ratio` defaults to `'16/9'`; `null` means the frame's content decides its height, starting at `height` (600 px when unset), and `measure: true` follows the height the frame reports. The `instagram` preset is the one that uses them (2026-11-04).
79
+ - **`presetService(name)`** resolves a preset alone, as a service with no site settings: what `{{embed}}` loads with no `config.consent`. `null` for a name with no preset.
73
80
  - **A service may not take a category's name.** `kissConsent.has()` and `when()` take a category or a service by one name and answer for the category first, so a service named `necessary` would have run before any choice (found by the Codex review, 2026-11-03).
74
81
  - **A required placeholder is a render error, not a config error.** A preset needs `{id}`, but the value may come from the helper's hash per page (`{{consentScript "gtm" id=gtmId}}`, as diploma-msc passes it), so `resolveConsent` cannot know it is missing. `fill` throws when it is.
75
82
  - **`consentReport` holds no timestamp,** so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
@@ -0,0 +1,45 @@
1
+ # css-audit.js
2
+
3
+ ## Responsibility
4
+
5
+ `kiss-ssg css`: a reproducible, browser-free audit of how a built site partitions its CSS. For each same-origin stylesheet the pages link: its bytes, its Brotli size, how many pages link it, and the share PurgeCSS could remove against the pages that link it. Then the distinct inline `<style>` blocks, the third-party stylesheets, and four advisory findings. Every decision is a pure function here; `auditCss` is the walk and the reads, and `runCss` is the whole command for `bin/kiss-ssg.js` to print.
6
+
7
+ ## Public interface
8
+
9
+ - `CSS_HELP` — the command's help text.
10
+ - `parseCssArgs(argv)` → `{ target, out, help, error }`. One target; `--out <file>`; anything else starting with `-` is an error.
11
+ - `resolveCssTarget(target, { cwd, isDirectory, exists })` → `{ folder }` or `{ error }` (a file, or a path that does not exist).
12
+ - `extractStyles(html)` → `{ links, inline }`: the raw `href` of every `<link>` whose `rel` tokens include `stylesheet` (entities decoded, a link with no `href` skipped), and the raw text of every `<style>` that carries no `data-kiss-` attribute.
13
+ - `resolveHref(href, page)` → `{ kind: 'local', path }` (build-relative, query and hash dropped, percent-decoded), `{ kind: 'third-party', url }` (`http(s):` or `//`), or `{ kind: 'skip' }` (any other scheme).
14
+ - `normaliseCss(css)` (whitespace collapsed, trimmed), `hashText(text)` (16 hex digits of SHA-256), `brotliSize(bytes)` (quality 11), `removableShare(before, after)` (0 to 1, three places).
15
+ - `styleDirective(policy)` → the directive governing a `<style>` element, or `null`; `inlineStyleBlockers(headersText)` → the distinct blocking directives in a `_headers` file, sorted.
16
+ - `findDuplicateRules(sheets, { limit })` → `{ total, listed, errors }`. `DUPLICATE_LIMIT` is 50.
17
+ - `buildFindings({ pages, stylesheets, inline, duplicates, blockers })` → the findings, sorted by check then detail.
18
+ - `formatCssAudit(audit)` — the stdout text.
19
+ - `auditCss(root, { purge })` → the `CssAudit` record, `{ version: 1, pages, stylesheets, thirdParty, inline, findings }` — what `--out` writes.
20
+ - `runCss(argv, { cwd, purge })` → `{ code, out, err }`, the whole command. `purge` is injectable for tests (default `purgeCss`).
21
+ - `PAGE_SAMPLE` (3, the page paths an inline entry or a finding names) and `QUOTE_LENGTH` (60, the characters of a block a finding quotes).
22
+
23
+ ## Depends on
24
+
25
+ `node:crypto`, `node:fs`, `node:path`, `node:zlib`; `postcss` (a direct dependency, the version PurgeCSS already brings) for the rule comparison; `lib/css-purge.js` (`purgeCss`) for the removable share; `lib/links.js` (`decodeEntities`) for attribute values.
26
+
27
+ ## Depended on by
28
+
29
+ `bin/kiss-ssg.js` (`kiss-ssg css`), which imports it inside its own branch so no other command loads PostCSS.
30
+
31
+ ## Non-obvious behavior
32
+
33
+ - **One left-to-right pass reads the page.** A single regex alternation of comment, `<script>`, `<template>`, `<style>` and `<link>` consumes the page in order, so a `<style>` or `<link>` inside a comment, a script string or a template is never taken for one, and a `<style>`'s text runs to its own closing tag whatever it contains. A `<link>` inside `<noscript>` is counted: it is usually the fallback for a deferred load of the same sheet.
34
+ - **kiss's own `<style>` is never reported.** Any `<style>` carrying an attribute that starts `data-kiss-` (today `data-kiss-consent-style`, `lib/handlebars-helpers.js` `consentHead`) is left out of the inline blocks, their findings and the CSP finding.
35
+ - **An href resolves the way a browser resolves it,** with `new URL(href, <a base>/<page>)`: a relative href against the page's own folder, `..` clamped at the root, the query and hash dropped. A local stylesheet is keyed by that path, so `/css/a.css`, `../css/a.css` and `a.css?v=2` from the right pages are one sheet.
36
+ - **The removable share is per stylesheet, against the pages that link it** plus every `.js`/`.mjs` in the build, with an empty safelist: what the page that loads it never uses. It is measured on the Brotli size, before and after. A stylesheet PurgeCSS cannot parse gets `error` and no share; a link to a file the build lacks is `missing: true`, reported and not fatal.
37
+ - **Two rules are one rule** when their selector list, their declarations in order and their enclosing at-rules (and, under CSS nesting, rules) match after normalising whitespace. Declaration values and at-rule params also drop the whitespace beside `, : ( )`, so a minified sheet and a formatted one compare equal; selectors keep it, because `.a :hover` and `.a:hover` differ. A rule written twice in one sheet is not a duplicate across sheets, and an empty rule is ignored. The context shown is the first sheet's, in path order.
38
+ - **The CSP check reads `_headers` simply:** every `Content-Security-Policy:` line under any path, without matching the path to the pages. The directive that governs a `<style>` is `style-src-elem`, else `style-src`, else `default-src` (CSP Level 3's fallback), and it blocks when it has no `'unsafe-inline'`. A policy that allows particular blocks by hash or nonce is still reported as blocking: the hashes are not checked against the blocks. Only the build root's `_headers` is read (not `firebase.json`).
39
+ - **Stable output.** Every list is sorted by code-unit order, never the locale's: stylesheets by path, third-party by URL, inline blocks by pages, then bytes (largest first), then hash, findings by check then detail. Two runs over the same folder write identical bytes.
40
+ - **Exit codes.** 0 when the audit ran, whatever it found; 2 on a usage error or a target that is not a folder; 1 when `--out` could not be written or the audit threw.
41
+ - **Tests.** `test/unit/css-audit.test.js` covers each decision, and `auditCss` with a fake purge; `test/integration/css-audit.test.js` drives the bin over a build folder with the real PurgeCSS and asserts each finding and the JSON.
42
+
43
+ ## Types
44
+
45
+ `CssArgs`, `DuplicateRule`, `StylesheetEntry`, `InlineEntry`, `Finding`, `CssAudit` (JSDoc typedefs in the module).