kiss-ssg 2.13.0 → 2.14.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 (90) hide show
  1. package/AIKB/aikb.md +9 -7
  2. package/AIKB/asset-minify.md +4 -3
  3. package/AIKB/audit.md +1 -0
  4. package/AIKB/build-finish.md +1 -1
  5. package/AIKB/build-report.md +3 -3
  6. package/AIKB/cache-headers.md +4 -1
  7. package/AIKB/check.md +1 -1
  8. package/AIKB/config.md +9 -5
  9. package/AIKB/consent-client.md +10 -3
  10. package/AIKB/consent-gate.md +5 -3
  11. package/AIKB/consent.md +3 -2
  12. package/AIKB/css-audit.md +1 -1
  13. package/AIKB/css-purge.md +29 -2
  14. package/AIKB/embed.md +12 -3
  15. package/AIKB/fetch-policy.md +18 -6
  16. package/AIKB/images.md +1 -1
  17. package/AIKB/issue.md +38 -0
  18. package/AIKB/kiss-page.md +5 -3
  19. package/AIKB/kiss.md +5 -5
  20. package/AIKB/model-resolver.md +9 -6
  21. package/AIKB/page-registry.md +13 -8
  22. package/AIKB/pipeline.md +3 -2
  23. package/AIKB/rebuild.md +1 -1
  24. package/AIKB/security-headers.md +37 -0
  25. package/AIKB/staging.md +53 -1
  26. package/AIKB/testing.md +1 -0
  27. package/AIKB/upstream.md +42 -0
  28. package/AIKB/utils.md +5 -1
  29. package/CHANGELOG.md +265 -0
  30. package/GUIDE.md +127 -47
  31. package/README.md +4 -2
  32. package/bin/kiss-ssg.js +14 -2
  33. package/examples/1-scan/router.js +0 -1
  34. package/examples/10-asset-pipeline/router.js +3 -4
  35. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  36. package/examples/11-blog/router.js +2 -3
  37. package/examples/14-consent/router.js +0 -1
  38. package/examples/2-page/router.js +9 -2
  39. package/examples/3-pages/router.js +2 -3
  40. package/examples/4-layouts-and-partials/router.js +0 -1
  41. package/examples/5-helpers/router.js +0 -1
  42. package/examples/6-sitemap/router.js +0 -1
  43. package/examples/7-versioned-outputs/router.js +0 -1
  44. package/examples/8-data-fed-site/router.js +0 -1
  45. package/examples/9-migrated-from-v1/router.js +2 -3
  46. package/lib/aikb.js +45 -25
  47. package/lib/asset-minify.js +56 -10
  48. package/lib/audit.js +52 -13
  49. package/lib/build-finish.js +69 -16
  50. package/lib/build-report.js +24 -23
  51. package/lib/cache-headers.js +89 -17
  52. package/lib/check.js +3 -0
  53. package/lib/config.js +121 -6
  54. package/lib/consent-client.js +45 -7
  55. package/lib/consent-gate.js +5 -2
  56. package/lib/consent.js +52 -4
  57. package/lib/css-audit.js +5 -1
  58. package/lib/css-purge.js +238 -5
  59. package/lib/embed.js +50 -3
  60. package/lib/fetch-policy.js +262 -28
  61. package/lib/images.js +5 -2
  62. package/lib/init.js +1 -1
  63. package/lib/issue.js +728 -0
  64. package/lib/kiss-page.js +28 -3
  65. package/lib/kiss.js +14 -6
  66. package/lib/model-resolver.js +18 -6
  67. package/lib/page-registry.js +106 -84
  68. package/lib/pipeline.js +22 -8
  69. package/lib/security-headers.js +233 -0
  70. package/lib/staging.js +143 -4
  71. package/lib/utils.js +114 -0
  72. package/llms.txt +27 -25
  73. package/package.json +1 -1
  74. package/types/aikb.d.ts +15 -8
  75. package/types/asset-minify.d.ts +15 -0
  76. package/types/audit.d.ts +4 -1
  77. package/types/cache-headers.d.ts +55 -2
  78. package/types/check.d.ts +1 -1
  79. package/types/config.d.ts +66 -9
  80. package/types/consent.d.ts +24 -1
  81. package/types/css-purge.d.ts +81 -2
  82. package/types/embed.d.ts +16 -1
  83. package/types/images.d.ts +2 -2
  84. package/types/issue.d.ts +184 -0
  85. package/types/kiss-page.d.ts +18 -0
  86. package/types/page-registry.d.ts +2 -1
  87. package/types/pipeline.d.ts +12 -2
  88. package/types/security-headers.d.ts +116 -0
  89. package/types/staging.d.ts +37 -0
  90. package/types/utils.d.ts +46 -0
package/AIKB/aikb.md CHANGED
@@ -15,14 +15,14 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
15
15
  ## Public interface
16
16
 
17
17
  - `isRecorded(folder)` → whether `<folder>/site-map.json` exists. The opt-in test, and synchronous: `lib/kiss.js` asks it once per settled build.
18
- - `classifyModel(model)` → `file:<name>` / `folder:<name>` / `url:<url>` / `inline` / `none`, from the **registered** model option. The same three string tests `resolveModel` makes, in the same order.
18
+ - `classifyModel(model)` → `file:<name>` / `folder:<name>` / `url:<url>` / `inline` / `none`, from the **registered** model option. The same three string tests `resolveModel` makes, in the same order; the URL test is the one both import, `isUrlModel` (`lib/utils.js`). The `<url>` is written by `redactUrl` (`lib/utils.js`): no credentials, no fragment, each query value `[redacted]` — `https://cdn.example.com/entries?access_token=SECRET&type=course` is recorded as `url:https://cdn.example.com/entries?access_token=[redacted]&type=[redacted]`.
19
19
  - `classifyController(controller)` → `file:<name>` / `inline` / `none`, from the registered controller option.
20
20
  - `pageOrigin(options)` → `{ model, controller }`. What `lib/kiss.js` calls in `page()`, before the chain replaces `options.model` with the resolved data.
21
- - `buildSiteMap({ stack, graph, config, pipeline, buildDir, stagingDir, readSubject })` → `SiteMap`: `{ site, pages[], partials{}, models[], controllers[], pipeline[], subjects[] }`, every list sorted. `pipeline` defaults to `config.assets.pipeline`; `readSubject(kind, id)` defaults to a reader that reads `folders.controllers/<id>` off disk.
21
+ - `buildSiteMap({ stack, graph, config, pipeline, buildDir, stagingDir, readSubject })` → `SiteMap`: `{ site, pages[], partials{}, models[], controllers[], pipeline[], subjects[] }`, every list sorted. A `pipeline[]` row is `{ name, program }`: the step's name (or the first word of its `run`) and the first word of its `run`, never the command (until 2026-11-07 it was `{ name, run }`, the whole command). `pipeline` defaults to `config.assets.pipeline`, and is also what a step's subject hash is taken from; `readSubject(kind, id)` defaults to a reader that reads `folders.controllers/<id>` off disk.
22
22
  - `renderSiteMap(map)` → the markdown of `site-map.md`, one trailing newline. Its `## Pages` table is `Output | Id | View | Model | Controller | Partials & layouts`, one row per page, `none` in any cell the page has nothing for.
23
23
  - `renderAikbReadme()` → the markdown of `AIKB/README.md`.
24
24
  - `noteSubjects(map)` → `{ kind, id }[]`, sorted by kind then id: every controller **file**, every **URL** model, every pipeline step. `map.subjects` is this list with `note` and `hash` added.
25
- - `notePathFor(subject, notesDir = 'notes')` → the note's path. `stockist.js` → `notes/controllers/stockist.md`; `https://api.example.com/v2/events?x=1` → `notes/models/api.example.com-v2-events.md`; `tailwind` → `notes/pipeline/tailwind.md`.
25
+ - `notePathFor(subject, notesDir = 'notes')` → the note's path. `stockist.js` → `notes/controllers/stockist.md`; `https://api.example.com/v2/events?x=[redacted]` → `notes/models/api.example.com-v2-events.md`; `tailwind` → `notes/pipeline/tailwind.md`.
26
26
  - `readFrontmatter(text)` → the `---` block's `key: value` lines as an object, or `null` when there is none. Not a YAML parser — the same handful of lines `test/unit/plugin-manifests.test.js` reads out of a `SKILL.md` header. `subject-hash` is the only key this module reads, and it never writes one.
27
27
  - `evaluateNotes(map, notesDir, { aikbDir })` → `{ missing, dead, stale, dangling }`, all four sorted. The first three are note paths built from `notesDir`, so they are the paths a person would type; `dangling` entries are `<note path>: <token>`. `aikbDir` is what lets `site.md` be scanned and lets a note cite its neighbours.
28
28
  - `async writeAikb({ map, folder, logger, write = true })` → `AikbResult` (`{ folder, written, notes, subjects }`). Writes `README.md` (only if absent), `site-map.md` and `site-map.json`. `write: false` evaluates and writes nothing — which is every build but a passing record. A write failure is logged and reported as `written: false`.
@@ -31,7 +31,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
31
31
 
32
32
  ## Depends on
33
33
 
34
- `fs-extra`; `./utils.js` (`globFiles`, `hashId`, `posixPath`); `./build-report.js` (`reportedPath`, `reportedView` — both exported for this module).
34
+ `fs-extra`; `./utils.js` (`globFiles`, `hashId`, `isUrlModel`, `posixPath`, `redactUrl`); `./build-report.js` (`reportedPath`, `reportedView` — both exported for this module); `./pipeline.js` (`stepName`, `programOf`: the map's pipeline rows carry the same name the report and the log use, and the same program).
35
35
 
36
36
  ## Depended on by
37
37
 
@@ -48,12 +48,14 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
48
48
  - **`site-map.json` is the opt-in marker, not the folder.** `isRecorded()` looks for that one file because only a record ever writes it. A folder that merely exists proves nothing: this repository's own `AIKB/` is hand-written module notes, and `docs.js` builds a site inside it with the default `folders.aikb` — if existence were the test, the docs build would adopt those notes as a site map and start reporting every one of them as `dead`.
49
49
  - **The map is written after the build settles, not on the promise queue.** `.sitemap()` and `.llms()` need only the registry, which is complete once `_promises` has drained. The partial-per-page index is learned from _rendering_ (`lib/dependency-graph.js`), so a map built at that point would list no partials at all. `Kiss._finishBuild()` is the one place where every page has rendered — which is also where the report is assembled, so the report can carry the verdict.
50
50
  - **Each page row carries its `id`, second, because the map is read as the `{{link}}` autocomplete.** An agent writing a template reads the identity straight off the row for the page it can see, which is why the column sits immediately after the output path rather than at the end. It is copied from the stack entry (`entry.id ?? null`) exactly as the report copies it, and a page with no id renders the module's own `NONE` (`none`), the same word the Model and Controller columns use — so an idless page reads as "nothing to link", not as a blank cell. `Kiss` withdraws a default id two pages arrived at before it hands over the stack, so an id on the map is always one `{{link}}` will resolve (`AIKB/kiss.md`).
51
- - **The model classification comes from the registration, not the stack.** By the time a page is on the stack, `options.model` _is_ the resolved data: a map read off the stack alone would say `inline` for every page in the site. `Kiss.page()` calls `pageOrigin(options)` after its two auto-mappings and hands the result to `_preparePage`, which parks it on the stack entry as `entry.origin`. It is deliberately not a key on `options`: every own key of a registration reaches the render context and the dev-mode `.json` sibling.
51
+ - **The model classification comes from the registration, not the stack.** By the time a page is on the stack, `options.model` _is_ the resolved data: a map read off the stack alone would say `inline` for every page in the site. `Kiss.page()` calls `pageOrigin(options)` after its two auto-mappings and hands the result to `_preparePage`, which parks it on the stack entry as `entry.origin`. It is deliberately not a key on `options`: every own key of a registration reaches the render context and (every key but `config`) the dev-mode `.json` sibling.
52
52
  - **The dependency graph is keyed on the path a page was written to, which under `cleanBuild: 'atomic'` is the staging path.** A promotion rewrites each stack entry's `buildTo` to the real folder but leaves the graph alone, so `graph.usesOf(entry.buildTo)` returns `[]` after a successful atomic build. `buildSiteMap` therefore inverts `graph.toJSON()` and maps **both** sides through `reportedPath`, and `Kiss` passes `_stagingDir ?? _promotedFrom` so the prefix survives the promotion.
53
53
  - **`README.md` is written once and never overwritten.** It is the one file in the folder that asks the reader to write something; a build that rewrote it every time would be punishing them for doing what it asked.
54
54
  - **Nothing under `notes/` is ever written, moved or deleted by the engine.** The four rules are findings on the report and nothing more: they never change `ok`, and `exitCodeFor` does not read them. A strict mode is a deliberate non-goal — a rule that fails a build for a missing paragraph gets a paragraph of filler, not a paragraph of judgement.
55
- - **A subject's hash is a dependency, not a file read.** Everything else in the module is a pure function of what it was handed, and one subject's hash is the bytes of a file nobody passed in. `buildSiteMap` therefore takes `readSubject(kind, id)` with a default that reads the controller off disk: the engine gets the disk, a unit test gets whatever it says, and the module never reaches for a path on its own initiative. The reader is asked about **controllers only** — a pipeline step's hash is sha1 of its `run` string, which is already in the map, and a URL model has no hash at all.
55
+ - **A subject's hash is a dependency, not a file read.** Everything else in the module is a pure function of what it was handed, and one subject's hash is the bytes of a file nobody passed in. `buildSiteMap` therefore takes `readSubject(kind, id)` with a default that reads the controller off disk: the engine gets the disk, a unit test gets whatever it says, and the module never reaches for a path on its own initiative. The reader is asked about **controllers only** — a pipeline step's hash is sha1 of its whole `run` string, read from the `pipeline` steps `buildSiteMap` was handed (the map itself holds only the program), and a URL model has no hash at all.
56
+ - **A pipeline step is recorded by its program, and its hash is of its whole command.** The map, `site-map.md` and the report's `aikb.subjects` are committed or printed, and a command's arguments are where a token typed on the command line sits, so `site-map.json`'s `pipeline[]` and `site-map.md`'s "Asset pipeline" table carry `program`, the first word of `run`, and never `run` (until 2026-11-07 both carried the whole command). Change detection still needs the whole command, so the subject hash stays sha1 of it, taken from the config's steps rather than the map. A sha1 of a command is committed: it does not reveal a token, but it is a fixed function of one, so a token belongs in the environment (`process.env`, which the step's child inherits), not on the command line. The program is split on whitespace, the same fallback the step's name uses, so a quoted program path with a space in it is recorded up to the space (`"C:\Program`). The generated `README.md`'s "The stamp" paragraph says the same, but `README.md` is written only when absent, so an existing site keeps its older wording. Covered by `test/unit/aikb.test.js` and `test/integration/aikb.test.js` (a recorded site whose folder is walked for a token typed into `run`).
56
57
  - **A URL model's hash is `null`, and that is not an omission.** Its id _is_ the subject: an id that changed is a different subject, with a different note path, which the `missing`/`dead` pair already reports. So a URL model can never be `stale`, and nothing is lost by it.
58
+ - **A URL model's id and its `url:` classification carry no query value, no credentials and no fragment.** The map, `last-build.json` and the report's `aikb.subjects` are committed or printed, and the query string is where an access token sits: until 2026-11-06 a token in a model URL was written into all three by `kiss-ssg aikb`, and from there into `kiss-ssg check`'s output (measured, 2026-11-06). Every value is replaced by `[redacted]` because kiss cannot know which one is the secret and a rule that guesses is a deny-list; the names are kept, because they are what tells one model from another in a log line or a site-map row (`?page=` against `?type=`). Two consequences. **Two models that differ only in a query value are one row** in the models table and **one subject**, listing every page that used either; two that differ in a parameter's name are two. They already shared one note, since the note path drops the query. And a site's next `kiss-ssg aikb` rewrites the `url:` rows and subject ids of every URL model with a query — the note paths do not move, so no note becomes `missing` or `dead`. Nothing is keyed on the id's spelling: note findings follow the note path, and `check`'s `diffReports` never compares subjects. What kiss fetches, the cache key, and the `{ id }` `.generate()`/`.complete()`/`.getModelByID()` hand the author keep the real URL — that is the author's own handle, not a record. A secret in the **path** is not caught; `GUIDE.md` says a credential belongs in `fetch.headers`. Covered by `test/unit/aikb.test.js` and `test/integration/aikb.test.js` (a recorded site whose folder is walked for the token).
57
59
  - **An unreadable controller hashes to `null` rather than to anything.** No controllers folder, a file that was deleted, a directory in its place — all `null`, which is the same answer as "nothing to hash", and a subject with no hash is never stale. Guessing would be worse than saying nothing: a wrong hash would mark every note about it stale for ever.
58
60
  - **The engine never writes a stamp.** `subject-hash` is copied into a note's frontmatter by the person (or the `kiss-branch-close` / `kiss-memory-consolidate` skill) who wrote or reviewed it. An unstamped note is the state every note starts in and most notes stay in, and it is never reported stale — only a stamp that _disagrees_ is a finding, which is what makes stamping opt-in per note rather than a burden on everyone.
59
61
  - **The subjects are on the report as well as in `site-map.json`, because at the one moment that matters the two disagree.** A piece of work closes with the map on disk still describing the _previous_ record — so the hash in that file is the hash the note is already stamped with, and the hash it wants restamping with exists nowhere but the report of the build that just found the finding. Hence `aikb.subjects` beside `aikb.notes`: a plain `kiss-ssg check` can say both "this note is stale" and "here is what to stamp it with", before any record is made.
@@ -63,7 +65,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
63
65
  - **A subject hash is over LF-normalised bytes, so a checkout that rewrites line endings does NOT restamp every note.** `subjectHash` (`lib/aikb.js:320`) replaces CRLF with LF before hashing: a CRLF checkout of an LF repository is the same controller, and git agrees, so a Windows clone reads the same stamps as the Linux clone that wrote them. The first version of this hashed the raw bytes and said normalising would make the hash disagree with git — measured the other way round: git normalises too, and the raw hash was what disagreed, restamping every controller note on every Windows clone. The generated `AIKB/README.md` and `llms.txt` say "LF-normalised" for the same reason.
64
66
  - **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
65
67
  - **A note's filename is derived from the subject's id, so two subjects can collide.** `a-x.js` and `a/x.js` both slug to `a-x`. Rare, and the alternative — a hash in the filename — would make the rule impossible to apply by hand, which is the property that matters more.
66
- - **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes.
68
+ - **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes. The id it is derived from is already redacted (above), and redaction keeps host and path, so for any URL the platform parses the note path is the same whether it is derived from the real URL or the redacted one (pinned in `test/unit/aikb.test.js`). For one it does not parse, any `user:pass@` is now dropped from the note's filename too.
67
69
  - **Every `.md` under `notes/` that is not an expected note is `dead`,** including one filed under a folder the engine does not recognise. Silently ignoring it would hide it for ever, which is the failure mode the rule exists to catch.
68
70
  - **`last-build.json` drops every duration, not just the top-level one.** The plan called for "the report minus `duration`"; a `pipeline[].duration` is timing too, and leaving it in would make the file churn on every build of any site with an asset pipeline — which is exactly the property the folder is committed for. Nothing reads those durations: `diffReports` pairs reports by `buildDir` and matches pages by `buildTo` and `hash`.
69
71
  - **A recorded snapshot says `mode: 'check'`.** `kiss-ssg aikb` sets `KISS_CHECK` too — it stages the build and throws it away, because recording must not publish — so the report it snapshots is a check's. Nothing reads that field to diff: `diffReports` pairs by `buildDir` and compares `hash`. It is worth knowing when reading `last-build.json` by eye.
@@ -7,12 +7,13 @@ Minify a stylesheet or script the asset copy wrote, in place, before it is hashe
7
7
  ## Public interface
8
8
 
9
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.
10
+ - `compactCss(text)` → the stylesheet with its formatting removed (whitespace between nodes, and comments between rules and declarations but `/*!` ones; a comment inside a selector, value or at-rule params stays), through PostCSS; throws when PostCSS cannot parse it. No space is added after an at-rule's name when its params open with `(`, so `@media(x)` never grows.
11
+ - `minifyText(relative, text)` → the minified text. CSS goes through `clean-css` at level 1; on any clean-css error or warning, `compactCss` instead, and only when both fail does it 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
12
  - `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
 
13
14
  ## Depends on
14
15
 
15
- `fs-extra`, `node:path`, `terser`, `clean-css`.
16
+ `fs-extra`, `node:path`, `terser`, `clean-css`, `postcss` (already a dependency, for the purge).
16
17
 
17
18
  ## Depended on by
18
19
 
@@ -21,7 +22,7 @@ Minify a stylesheet or script the asset copy wrote, in place, before it is hashe
21
22
 
22
23
  ## Non-obvious behavior
23
24
 
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
+ - **A stylesheet clean-css cannot read is compacted, never handed its output.** 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). So an error or a warning means its output is discarded. Until 2026-11-08 the file was then kept as written. The clean room showed the cost: Tailwind v4 nests, so a Tailwind sheet built without `--minify` shipped with every space, and so did its purged copy (12,072 bytes raw, 10,613 purged, against 9,825 for Tailwind's own `--minify`). Now `compactCss` takes those: PostCSS reads nesting, and only its formatting raws are cleared (whitespace between nodes, and every comment node but a `/*!` one). The last statement at-rule of a file loses its semicolon (`@layer a, b` at the end), which the CSS Syntax end-of-file rule allows; checked in Chromium only (review at the close, 2026-11-08). No selector, at-rule parameter or value is touched, and the purged sheet's declarations compared equal after it. Measured: 12,072 → 9,666 bytes unpurged, and 10,613 → 8,492 purged. The purged sheet as written and compacted gave 0 computed-style differences in Chromium (3 pages, 375 and 1440 wide, 4 states, 600 elements). Only a sheet PostCSS cannot parse either is left as written, with the reason. The fleet's own stylesheets gave clean-css no warning (a1k9training, pro-plumbing, k9-solutions), so they are minified as before.
25
26
  - **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
27
  - **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
28
  - **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.
package/AIKB/audit.md CHANGED
@@ -35,6 +35,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
35
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
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
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
+ - **A format-switching `<picture>` counts its first `<source>` only** (the operator's rule, 2026-11-08, item 5 of #78's reporter). `extractSpeed` takes a `<picture>` whose first image element is a `<source>` with a type every current browser decodes (`DECODED_EVERYWHERE`: AVIF, WebP, JPEG, PNG, GIF), a `srcset` and no `media` (what `{{image}}` writes) as loaded from that source: the later sources and the `<img>` are fallbacks, left out of `imageRefs`. Any other `<picture>` is taken whole: an art-directed one (`media`), whose `<img>` a screen the query misses loads; one whose first `<source>` has no type, or one some browsers skip (`image/jxl`); and one whose first `<source>` has no `srcset`, which every browser skips (third review, 2026-11-08). `imgPlain` reads the same picture-free text the rule leaves, rather than stripping the page a second time. Measured on a1k9training with heroes at 1600 wide: the old rule gave 10 findings, all WebP (205–221 KB) or JPEG (202–251 KB) fallbacks beside AVIFs of 103–147 KB, and that was why the site capped its widths at 1440. Accepted cost: an oversized fallback for a browser without AVIF is never reported. The art-directed test was seen red under a mutation that dropped the `media` condition.
38
39
  - **`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
40
  - **`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
41
  - 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.
@@ -33,7 +33,7 @@ Each is the body of the `Kiss` private method named beside it:
33
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
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
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`.
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`. In the same latched run, straight after, it writes the security headers (`lib/security-headers.js`, `AIKB/security-headers.md`) when `securityHeaders` is on: a notice for each header left to the site's `_headers`, a warning when no host is set or the host has no encoding, a Firebase merge notice, and a failed write fails the build (`<security-headers>`). For both writers, `warnStale` warns about kiss's header output in a kept folder (`cleanBuild: false`) that the run did not write, and never deletes it: another `Kiss` instance may share the folder.
37
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.
38
38
 
39
39
  Three are named apart from their method because the method's own name is a function this module
@@ -27,7 +27,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
27
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
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
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`.
30
- - Every page path goes through `real()` and is then sorted.
30
+ - Every page path goes through `real()` and is then sorted. `real()` maps a staged path in any spelling to the build folder's, and an absolute path under the build folder likewise; a relative path that was never staged is left as written (`docs\x.html` on Windows stays so). Mapping it too rewrote every plain build's pages as `./docs/x.html`, which the fourth review caught before release: a site's first `kiss-ssg check` after upgrading would have seen every page move against its record.
31
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.
32
32
  - `thirdParty`'s origins carry `services` beside `kinds` when a consent service gates them.
33
33
  - `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the five `VALUE_CHECKS` instead print one line per value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters. For the two duplicate checks, that is because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27). For `viewport-zoom-lock` and `fragment-missing`, the value (the viewport content, the href) is what to grep the templates for; a page list alone sends you reading every link (2026-10-20). For `checkbox-prechecked` the value is the box's label, the string to grep for in the partial the box lives in (2026-11-02) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
@@ -45,13 +45,13 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
45
45
 
46
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.
47
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.
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.
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. **Compared by where the paths point (`path.resolve` both, then `path.relative`), never by their text**, and written as `<buildDir>/<rel>` with `/`. A text prefix test was tried twice and missed a spelling each time. The first missed a `\`-joined path on Windows: `cacheHeaders.files` named the discarded staging folder until the fleet check of 2026-11-05. The second (2.13.3) took `\` and still missed a build folder written `./docs`. `stagingSiblings` makes `./docs.kiss-staging-…`, but `path.join` drops the `./`, so a1k9training's `last-build.json` changed on every `aikb` run. That second miss was not Windows-only. The unit tests had passed a staging folder with no `./`, which the engine never builds; they now take it from `stagingSiblings`, and `test/integration/aikb.test.js` records twice from a `./`-relative build folder and compares the bytes. 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.
49
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.
50
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`.
51
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.
52
52
  - **`pipeline` is appended, not slotted in beside `assets`.** It reports work that happens _before_ the copy, so its natural home would be above `assets` — but a consumer diffing two reports (or eyeballing one in a terminal) should see one new key rather than a reshuffle of the eight that were already there. A step's `Error` is dropped on the way in for the same reason a failure's is: the message is already in `failures`, under `<pipeline: <name>>`.
53
53
  - **`aikb` is `null` on most builds, and that is not the same as "no knowledge base".** The key carries a verdict only when the site is opted in — `folders.aikb` set, and either this build is a record (`KISS_AIKB`) or `<folder>/site-map.json` is already there. A site that has never recorded reports `null`; once it has, every build reports `{ folder, written, notes }` with `written: false` unless this one was the record itself. See `AIKB/aikb.md`.
54
- - **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
54
+ - **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and (every key but `config`) the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
55
55
  - **`aikb.subjects` is the report's answer to a question `site-map.json` cannot answer yet.** It is `{ kind, id, note, hash }` per subject of _this_ build. The map on disk still describes the previous record, so the hash in that file is the one a note is already stamped with; the hash a note wants restamping with lives only here until the next record is made. A plain `kiss-ssg check` can therefore say both "this note is stale" and "here is what to stamp it with". Appended after `notes` for the reason every other key was appended after the one before it.
56
56
  - **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
57
57
  - **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
@@ -17,7 +17,9 @@ Cache headers for the files kiss content-hashed: say, in the encoding the site's
17
17
  - `cacheHeaderRules(files)` → `[{ path, headers: { 'Cache-Control': IMMUTABLE } }]`.
18
18
  - `renderCacheHeadersJson(rules)` → the list, pretty-printed and newline-terminated.
19
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.
20
+ - `mergeHeadersFile(existing, block, markers?)` → 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. `markers` defaults to the cache block's; the security headers pass their own.
21
+ - `removeHeadersBlock(existing, markers?)` → the file with kiss's block, and the blank line before it, taken out: the inverse of `mergeHeadersFile`, which uses it.
22
+ - `staleHeaders({ root, markers, files, written, outputs })` → the build-relative names of kiss's output in the folder that this run did not write: `_headers` when it holds the marked block, and each named generated file nothing claimed this build. It deletes nothing. `writeCacheHeaders` and `writeSecurityHeaders` return it as `stale`, and `lib/build-finish.js` warns. Under `cleanBuild: false` an earlier build's block stays live after its feature is turned off; but several `Kiss` instances can share one folder, each with its own output registry, so another instance's output looks the same, and deleting it would silently drop its headers (Codex adversarial reviews, 2026-11-06).
21
23
  - `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
24
  - `firebaseLookalikes(built, hashed)` → the files in the build the hash pattern matches that kiss did not hash.
23
25
  - `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.
@@ -37,6 +39,7 @@ Cache headers for the files kiss content-hashed: say, in the encoding the site's
37
39
 
38
40
  ## Non-obvious behavior
39
41
 
42
+ - **Whatever `_headers` holds is merged into, never replaced:** the site's copied lines, a security block, or a block another `Kiss` instance sharing a kept folder left. Only kiss's cache block is replaced. The file is claimed only when nothing else claimed it this build (Codex review, 2026-11-06).
40
43
  - **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
44
  - **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
45
  - **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".
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, 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'`.
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`) and `issue` (`lib/issue.js`, dispatched the same way, with `ISSUE_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
@@ -9,7 +9,9 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
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
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`), `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.
12
+ - `resolveFetch(fetch)` → the merged fetch block in the form the engine reads: a `retries` or `timeout` given as a string of digits (`'5000'`, `'1.5'`; what an environment variable is, and what 2.13.2 took through `setTimeout`'s coercion) becomes the number, and the block is then held to its shape by `assertFetchIsValid`. Called by `resolveConfig`, whose result carries the numbers, and again by `lib/fetch-policy.js`'s `fetchModel`, for a caller that never went through `resolveConfig`.
13
+ - `assertFetchIsValid(fetch)` → `void`, throws on a merged fetch block whose key is not its shape: `retries` a whole number of 0 or more, `timeout` a finite number of 0 or more (`0` is no timer), `headers` a plain object (a null prototype counts; not a `Headers` instance or pairs), `cache` a non-empty string or `false` (not `null` or `''`). The message names the key (`config.fetch.retries must be …`) and never the value.
14
+ - `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, 768, 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
15
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
16
  - `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
17
  - `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`).
@@ -20,17 +22,18 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
20
22
  - **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.
21
23
  - `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`).
22
24
  - `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.
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.
25
+ - `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 a `fetch` key that is not its shape (`assertFetchIsValid`), 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.
24
26
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
25
27
  - `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
28
+ - `redactFetchHeaders(config)` → a shallow copy of a config whose `fetch.headers` keeps every header name and has every value replaced by `REDACTED` (`'[redacted]'`, from `lib/utils.js`), except a value that is `undefined`, `null` or `''`, which is kept as it is: that is what `process.env.API_TOKEN` gives when the variable is unset, and `'[redacted]'` there would tell an author reading the `verbose` log that a token is set when it is not. Any other value is redacted, `0`, `false` and `'Bearer undefined'` included. The top level and `fetch` are new objects; every other nested value is shared. A config with no `fetch`, or a `fetch` with no header object, is copied as it stands. Never mutates its input. The one redaction kiss has: `lib/page-registry.js` runs each page's `options.config` through it, and `lib/kiss.js` the `verbose` log line.
26
29
 
27
30
  ## Depends on
28
31
 
29
- `node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids); `./consent.js` (`resolveConsent`, the `consent` block).
32
+ `node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./utils.js` (`isInside`, the folder containment test; `REDACTED`, the word `redactFetchHeaders` writes); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids); `./consent.js` (`resolveConsent`, the `consent` block).
30
33
 
31
34
  ## Depended on by
32
35
 
33
- `lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`); `lib/page-registry.js` (`resolveMarkdownCopies`, for a page's own `config.markdownCopies`).
36
+ `lib/kiss.js` (and `redactFetchHeaders`, for the `verbose` log line); `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`); `lib/page-registry.js` (`resolveMarkdownCopies`, for a page's own `config.markdownCopies`; `redactFetchHeaders`, for each page's copy of the config).
34
37
 
35
38
  ## Non-obvious behavior
36
39
 
@@ -53,7 +56,8 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
53
56
  - `foldersToEnsure` fixes a v1 copy-paste bug where most folders were only created conditionally on `assets` being set; here each folder stands on its own regardless of the others.
54
57
  - **The three dev-server keys are defaulted here and nowhere else.** `lib/dev-server.js` takes `port`, `livereloadPort` and `host` as required parameters, so a reader has one place to look for their values. `livereloadPort` exists so two sites can run in dev mode at once (a clash used to kill the process — review finding D-01) and is also the port `lib/kiss-page.js` injects into the dev-mode reload `<script>`. `devHost` defaults to loopback: dev mode used to bind every interface while logging `localhost` (D-13); `'0.0.0.0'` is the documented opt-out for previewing on another device.
55
58
  - `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
56
- - **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy.
59
+ - **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy. Fetching reads the instance's own `config.fetch` (`lib/page-registry.js` hands `kiss.config.fetch` to the model resolver); a page's `config: { fetch: … }` changes only that page's copy, which nothing fetches with. **Every key of the merged block has a shape, read by `resolveFetch` and checked once by `assertFetchIsValid`** (2026-11-07; a string of digits for `retries` or `timeout` is read as the number, because an environment variable is a string and 2.13.2 accepted it): before, `retries: NaN` (a `Number(process.env.X)` of an unset variable) ran the attempt loop no times and every URL model failed with `undefined`, and a `null` header object with a `cache` set threw a `TypeError` from the cache key's `Object.entries(null)` (inferred from the code, not run). The message names the key and never the value, because a value in this block can be read from the environment and `headers` is where a token goes. Header _values_ are not checked here: an unset `process.env.TOKEN` is `undefined`, which `redactFetchHeaders` shows as it is, and a value the fetch would refuse is `lib/fetch-policy.js`'s `checkHeaders`.
60
+ - **A page's copy of the config never holds a header value.** `GUIDE.md` tells a site to put an API token in `fetch.headers`, and each page's `options.config` is its render context, so `{{{stringify this}}}` — the guide's own way to look at a page's data — or a controller returning `{ ...options, site: options.config }` would print it into the built page and, in dev, into the `.json` sibling. `lib/page-registry.js` therefore makes each copy through `redactFetchHeaders`: the header names stay (a template can still ask which headers a site sends), every value reads `'[redacted]'`, and a page's own override that supplies `fetch.headers` is redacted the same way. `kiss.config` itself is never redacted, because it is what the fetch sends. Only `fetch.headers`: it is the one place kiss documents for a secret, and anything else a site puts in its config reaches its templates by design (`{{config.x}}`, below).
57
61
  - **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
58
62
  - **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
59
63
  - **`assets.pipeline` is validated, like `cleanBuild`, because every step is a command this process will spawn.** It must be an array; each entry an object with a non-empty string `run`; `name`, `watch` and `cwd`, when present, strings. Anything else throws out of `resolveConfig` (so out of `new Kiss`) naming the index and the value — `{ pipeline: 'npx tailwindcss' }` (a bare string) and `{ pipeline: [{ watch: '…' }] }` (a watch with nothing to run first) are the two shapes that would otherwise be discovered as a `spawn` of `undefined` half way through a build. Being an array, it **replaces** the default rather than merging into it, which is what the one-level-deep merge means for an array value. What the steps then do is `lib/pipeline.js`'s business (`AIKB/pipeline.md`); this module only refuses a shape that cannot be run.
@@ -13,7 +13,7 @@ The contract is `planning/plans/2026-11-02-consent-manager.md` §3–§5, and fo
13
13
 
14
14
  ## Public interface
15
15
 
16
- - **`consentHeadScript(cfg, client)` → `string`.** It returns `<script data-kiss-consent-head>(<the head part>)(window,<cfg JSON>)</script>` and nothing else, where `cfg` is `clientConfig()` from `lib/consent.js` with `client: { name, page, root }` added (`ConsentClientFile`): the cached file's name, the page's build-relative file (`guide/index.html`, or `null`), and the root `siteUrl` names as a path (`/`, `/site/`). The head part adds `<script src="…" data-kiss-consent-client>` to `<head>` itself, at `<root>` + `name`, where the root is whatever `location.pathname` (decoded) has in front of the page's file in one of its forms: the file, and for `x/index.html` also `x/` and `x`, for `x.html` also `x`, each preceded by a `/`. None matching (a 404 page served anywhere) uses `client.root`. Every build-time URL was right at one address and wrong at another: root-relative or `siteUrl`-absolute missed a prefixed site's preview (the stopgap review, 2026-11-03), and relative-to-the-served-path missed `/guide/` and `/guide/index.html` under `trailingSlash: false`, which kiss's own dev server redirects to (Codex at the close, 2026-11-04; red in Chromium at all four addresses). A dynamically added script is not `defer`red, which the cached part does not need: it waits for `DOMContentLoaded` itself. The JSON escapes `<` as `<` and U+2028/U+2029, so no site text can end the script.
16
+ - **`consentHeadScript(cfg, client)` → `string`.** It returns `<script data-kiss-consent-head>(<the head part>)(window,<cfg JSON>)</script>` and nothing else, where `cfg` is `clientConfig()` from `lib/consent.js` with `client: { name, page, root }` added (`ConsentClientFile`): the cached file's name, the page's build-relative file (`guide/index.html`, or `null`), and the root `siteUrl` names as a path (`/`, `/site/`). The head part adds `<script src="…" data-kiss-consent-client>` to `<head>` itself, at `<root>` + `name`, where the root is whatever `location.pathname` (decoded) has in front of the page's file in one of its forms: the file, and for `x/index.html` also `x/` and `x`, for `x.html` also `x`, each preceded by a `/`. None matching (a 404 page served anywhere) uses `client.root`, and so does a derived root that would put the file on another protocol or host, and a derived root whose load fails is tried once more from `client.root` (both under "The derived root stays on the page's host", below). Every build-time URL was right at one address and wrong at another: root-relative or `siteUrl`-absolute missed a prefixed site's preview (the stopgap review, 2026-11-03), and relative-to-the-served-path missed `/guide/` and `/guide/index.html` under `trailingSlash: false`, which kiss's own dev server redirects to (Codex at the close, 2026-11-04; red in Chromium at all four addresses). A dynamically added script is not `defer`red, which the cached part does not need: it waits for `DOMContentLoaded` itself. The JSON escapes `<` as `<` and U+2028/U+2029, so no site text can end the script.
17
17
  - **`consentClientSource()` → `string`.** The cached file's source, `(<kissConsentClient>)(window);`. It holds no site config, so it is the same file for every site on one kiss version.
18
18
  - **`consentClientName()` → `string`.** `kiss-consent.<10 hex of the source's sha256>.js`, memoised: it changes only when the client's code does, so the file can be cached for good.
19
19
  - **`inlineJson(value)` → `string`.** That escaping, as a function, shared with `lib/consent-record.js` so the viewer's config cannot end its script either.
@@ -35,10 +35,17 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
35
35
 
36
36
  ## Depended on by
37
37
 
38
- `lib/handlebars-helpers.js` (`{{consentHead}}`, `consentClientName`), `lib/build-finish.js` (`writeConsentClient`: `consentClientName`, `consentClientSource`). `test/unit/consent-client.test.js` runs the head part then the client in Node's `vm` against a domino DOM with a fake cookie jar, clock, storage and beacon, both as rendered and terser-minified, and runs them apart for the stub's queue, the fail-closed case and a second copy of either. `test/browser/consent.test.js` runs it in Chromium on built sites (`test/helpers/consent-site.js`), with every off-host request stubbed. It covers: before a choice, Reject, Accept, a returning visitor, document order, statistics only, withdrawal, a version bump, Close, an embed, focus under the bar, no JavaScript, the endpoint, the log across a withdrawal's reload, a cached file held back (the banner shows first, an early Accept takes effect when the file arrives), an override partial, and the top position.
38
+ `lib/handlebars-helpers.js` (`{{consentHead}}`, `consentClientName`), `lib/build-finish.js` (`writeConsentClient`: `consentClientName`, `consentClientSource`). `test/unit/consent-client.test.js` runs the head part then the client in Node's `vm` against a domino DOM with a fake cookie jar, clock, storage and beacon, both as rendered and terser-minified, and runs them apart for the stub's queue, the fail-closed case and a second copy of either. `test/browser/consent.test.js` runs it in Chromium on built sites (`test/helpers/consent-site.js`), with every off-host request stubbed. It covers: the cached file loaded at every address a page is reached by (`/missing/404`, through the fallback, included), the fallback on a host that answers the wrong path with a `200` page instead of the file, and never from another host whatever the address says (five hostile spellings on a 404 page), a page opened by `file://`, before a choice, Reject, Accept, a returning visitor, document order, statistics only, withdrawal, a version bump, Close, an embed, focus under the bar, no JavaScript, the endpoint, the log across a withdrawal's reload, a cached file held back (the banner shows first, an early Accept takes effect when the file arrives), an override partial, and the top position.
39
39
 
40
40
  ## Non-obvious behavior
41
41
 
42
+ - **The derived root stays on the page's host.** The address is the visitor's, and the root is a slice of it re-read as a URL. `at` is `decodeURI(location.pathname)`, so `/%5Cevil.example/404` decodes to `/\evil.example/404`; on a site whose `404.html` carries the consent head, the form `404` matches, the root is `/\evil.example/`, and a browser reads `/\` as `//`: the client came from another host and ran in the site's origin (the security review, 2026-11-06). Measured in Chromium before the fix, on a built site served by `serveFolder` (which answers an unknown path with `404.html`): `/%5Cevil.example/404`, `/%5C%5Cevil.example/404`, `//evil.example/404`, `/%09/evil.example/404` and `/%5Cevil.example/404.html` each made one request, `GET evil.example/kiss-consent.<hash>.js`, and no other. A root `index.html` reached at `/%5Cevil.example/` derives the same root (unit suite). So after the root is derived, the head part resolves the page's address (`new win.URL(win.location.href)`) and the file's (`new win.URL(root + client.name, here)`) and keeps the derived root only when `protocol` and `host` match; otherwise, or when either throws (a base that cannot be resolved against, such as `about:srcdoc`), it uses `client.root`.
43
+ - **The browser's URL algorithm, not a list of characters,** so the backslash, the doubled slash, a tab or newline the parser strips, and any spelling not yet thought of are all decided by the parser that will fetch the file.
44
+ - **`host`, not `hostname`:** `hostname` lets another port on the same name through. **Not `origin`:** every `file:` URL has the same origin whatever its host, so two different hosts compare equal; `protocol` + `host` tells `file:///C:/…` (host empty) from `file://evil/…` (host `evil`). Measured in Chromium 158.0.8127.18 on 2026-11-07 (`test/browser/consent.test.js`, the `file://` row): a built page opened from the disk and `file://evil/kiss-consent.js` both have the origin `file://` (Chromium's spelling; it is not the string `null`), the page loads the cached client from its own folder (`<build>/kiss-consent.<hash>.js`), the client starts, and the page requests nothing outside that folder.
45
+ - **Read from the parsed `here`, never from `win.location`**, and through `win.URL`, so the unit suite's `vm` harness (no global `URL`, a `location` with no `host`) runs it unchanged.
46
+ - **A derived root that fails is tried once from `client.root`.** A benign `/missing/404` on a 404 page derives `/missing/`, where there is no file, and every consent control on the page was dead. The tag for a derived root other than `client.root` has an `error` listener and a `load` listener, both running `again`, which appends a second tag from `client.root` unless the file has announced itself (`S.loaded`, the cached part's first act); the second tag has neither listener, so it is once only, and a tag already at `client.root` is never retried. **`load` is not trusted on its own** because a host that answers an unknown path with status 200 and HTML fires `load`, not `error`, on the script, with an `Unexpected token '<'` error on `window` (measured in Chromium 158 on 2026-11-06, a script element whose `src` a local server answered with `200` and `text/html`; with `X-Content-Type-Options: nosniff` on that answer the tag gets `error` instead, same measurement). Until 2.14.0 only `error` was listened for, so on such a host (a single-page-app rewrite to `200`; kiss's own `serveFolder`, Netlify and Firebase answer 404, measured with `curl` on 2026-11-06 per `planning/plans/2026-11-06-security-hardening.md`) that page's consent controls stayed dead unless `securityHeaders: true` sent `nosniff`. The browser row "falls back to the root when a wrong path answers 200 with a page" serves the wrong-root file as a `200` page and sees the root asked next and the choice stored; it was red before the `load` listener. One limit, recorded rather than engineered around:
47
+ - **A 404 page still loads the client from whatever same-host folder its address names** when that address ends in the page's own file name (`/anything/404`). It is the page's own origin, so on a site that has the origin to itself it is a dead page at worst. On an origin shared with other publishers (`example.edu/~someone/`) a file one of them placed at `/~them/kiss-consent.<hash>.js` would run, but a shared origin already gives every publisher on it the others' cookies and storage, so it is outside what this guard can promise.
48
+
42
49
  - **Order in the head.** The inline part runs these steps in order:
43
50
  1. the `dataLayer` and `gtag` stub (it never replaces an existing `gtag`);
44
51
  2. `gtag('consent', 'default', …)`;
@@ -92,6 +99,6 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
92
99
  - **A link in the dialog closes it**, the policy link and the history link alike, so a jump within the same page (the history page's own "See your consent history") does not leave the history behind the modal (Codex round 5, P2; red in the unit and Chromium tests). It closes on a `setTimeout(…, 0)` after the click, not during it: a link removed from the document during its own click is not followed. Not for a click that opens a new tab or window (Ctrl, Cmd, Shift, Alt, a non-primary button, a `target`) or that something cancelled, so the visitor keeps any switches they had moved. For a same-page fragment, focus then goes to the section it names (given `tabindex="-1"` if it has none): `dialog.close()` would otherwise return focus to the control that opened it (the stopgap review, P3s, 2026-11-03; red in Chromium on `document.activeElement`).
93
100
  - **A replayed click on a control no longer on the page is skipped**, so a double click on Accept made before the file arrived is one choice, one log entry and one POST, not two (the stopgap review, P3).
94
101
  - **The dialog** is cloned from its template on first open, gets `data-kiss-consent-dialog`, and opens with `showModal()` (falling back to `open`). Its switches (styled checkboxes) are set from the current state each time it opens.
95
- - **Size.** Measured 2026-11-03 for a gtm + youtube + vimeo site, terser-minified. **Inline, on every page:** 4,839 bytes, 2,092 gzipped, the config included. **The cached file, once:** 8,939 bytes, 3,421 gzipped. Before the split the one inline script was 12,600 (4,618), under a 13,312 ceiling the operator had raised twice that day; 10,932 when first measured, where the contract had estimated 5–7 kB. `test/unit/consent-client.test.js` holds the inline part under 5,376 and the file under 9,728, about 10% over each measurement; raising one is a decision, not a fix. The inline part still needs `'unsafe-inline'` or a hash under a Content Security Policy; the file is `'self'`.
102
+ - **Size.** Measured 2026-11-06 for a gtm + youtube + vimeo site, terser-minified as the unit suite does it, gzipped with Node's `zlib.gzipSync` at its default level. **Inline, on every page:** 5,698 bytes, 2,488 gzipped, the config included; it was 5,483 (2,401) the same day before the origin guard and the fallback. **The cached file, once:** 10,050 bytes, 3,859 gzipped. History, not re-measurable: at the split (2026-11-03) the inline part was 4,839 (2,092) and the file 8,939 (3,421); before the split the one inline script was 12,600 (4,618), under a 13,312 ceiling the operator had raised twice that day; 10,932 when first measured, where the contract had estimated 5–7 kB. `test/unit/consent-client.test.js` holds the inline part under 6,144 and the file under 10,752 (`INLINE_CEILING`, `FILE_CEILING`; the test's comments say when and why each moved); raising one is a decision, not a fix. The inline part still needs `'unsafe-inline'` or a hash under a Content Security Policy; the file is `'self'`.
96
103
  - **The cached file is minified by the pages' own minifier** (`html-minifier-terser` over `<script>…</script>`, `minifyJS`), so it needs no dependency of its own, and is unminified in dev as pages are. Its name hashes the unminified source, so the same code is the same name whatever minifies it.
97
104
  - **Written in ES5 style (`var`, `function`)**, except optional `catch` bindings (ES2019), so the inlined source runs on the browsers a static site serves with no transpile step. It is minified with the page in production.
@@ -12,7 +12,7 @@ The consent manager's server side:
12
12
  ## Public interface
13
13
 
14
14
  - **`gatedScript(name, service, { consent, params, body, partialHtml, src })` → `string`.** What `{{consentScript}}` writes.
15
- - **No body or partial:** the service's `inline` script, then its `src` (or `src=` from the call). Each is `<script type="text/plain" data-kiss-service data-kiss-consent [data-src]>`.
15
+ - **No body or partial:** the service's `inline` script, then its `src` (or `src=` from the call). Each is `<script type="text/plain" data-kiss-service data-kiss-consent [data-src]>`. The `inline` script's placeholders are filled by `fillScript` (each value URL-encoded, then every character of that outside `[A-Za-z0-9_.-]` written as `\uXXXX`), the `src`'s by `fill` (URL-encoded).
16
16
  - **A body (block form) or a rendered partial of a service that is not gated** (necessary, or a Google tag under advanced mode) is written exactly as the author wrote it. Until 2026-11-03 it went through the rewrite below, which drops `defer` because the queue keeps order; a plain script never enters the queue, so a `defer` head script lost it and ran before `<body>` existed (found by the Codex review).
17
17
  - **A body (block form) or a rendered partial of a gated service:** every `<script>` in it is rewritten into that form:
18
18
  - `src` becomes `data-src`, `async` becomes `data-async`, and `type="module"` becomes `data-type="module"`;
@@ -39,7 +39,7 @@ The consent manager's server side:
39
39
 
40
40
  ## Depends on
41
41
 
42
- `htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `gatingOf`, `scriptKind`).
42
+ `htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `fillScript`, `gatingOf`, `scriptKind`).
43
43
 
44
44
  ## Depended on by
45
45
 
@@ -48,7 +48,9 @@ The consent manager's server side:
48
48
  ## Non-obvious behavior
49
49
 
50
50
  - **The block rewrite uses a tokenizer, not a regex.** `htmlparser2`'s `startIndex` and `endIndex` locate each `<script>` in the source, and only those ranges are replaced. Getting a script tag wrong here would ship a third party ungated, which a regex that misreads one attribute could do silently. The audit's tolerant regex reading is a different job, because a missed fact there is only a missed finding.
51
- - **`document.write` is refused** because a script revived after consent runs after the page has parsed. There `document.write` replaces the whole document, and in the serial queue `load` never fires (contract Appendix A, S3; `AIKB/upstream.md`). A `src` script's body cannot be seen, so the guide states that limit.
51
+ - **`document.write` is refused** because a script revived after consent runs after the page has parsed. There `document.write` replaces the whole document, and in the serial queue `load` never fires (contract Appendix A, S3; `AIKB/upstream.md`). A `src` script's body cannot be seen, so the guide states that limit. The check runs on the filled `inline` text, so a placeholder value that holds `document.write(` is now inert (its parenthesis is written as a `\u` escape) and does not trip it, which is right: it calls nothing. A service's own `inline` that calls it is still refused.
52
+ - **An `inline` placeholder is filled as a script body, not a URL** (security hardening, 2026-11-06). Under `fill`, `encodeURIComponent` left `'`, `(` and `)` alone, so `{{consentScript "gtag" id=model.gaId}}` with `G-X'-alert(1)-'` closed the preset's string literal and called `alert`. `fillScript` (`AIKB/consent.md`) closes that while keeping the URL-encoding, so a placeholder the site's `inline` puts inside a URL or an HTML string stays encoded and inert. The unit tests evaluate the emitted script, gated and (under advanced mode, through `html-minifier-terser` with `minifyJS`) ungated, and compare the value gtag was handed with `encodeURIComponent` of the original; two more evaluate a placeholder inside an `innerHTML` string and inside a URL (one parameter, no markup from the value), and one pins an unquoted `init()` as a syntax error that calls nothing.
53
+ - **No scheme check on a script's `src`** (`src=` from the call, or the service's `src`): a script URL from model data is code whatever its scheme, so a check there would promise something it cannot keep (the security-hardening contract, 2026-11-06). A `src` from the call goes in as written, escaped for the attribute only.
52
54
  - **The wiring check reads the rendered bytes.** It runs on HTML outputs only, before minifying and before the dev live-reload script is added.
53
55
  - It catches a hand-written gated tag as surely as a helper's.
54
56
  - 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.
package/AIKB/consent.md CHANGED
@@ -22,7 +22,7 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
22
22
  - **The resolved block:** `{ version, categories, inUse, services, cookie, google, endpoint, ui }`. `ui` is `{ position, logo, partial, policy, history, text, theme }`: `policy` (default `'cookies'`) and `history` (default `null`, the page with `{{consentRecord}}`) are page ids or `null`, and `text` holds the banner's, the dialog's, the embed's and the history page's (`history…`) wording.
23
23
  - **`categoriesInUse(resolved)`** → the categories at least one service belongs to, `necessary` aside, in `CATEGORY_NAMES` order. Decided once per site (`inUse`), so every page offers the same set.
24
24
  - **`purposesVersion(resolved)`** → 12 hex characters. The default `version`; see Non-obvious behavior.
25
- - **`serviceParams(service, hash)`** and **`fill(template, params, where)`.** Placeholder values come from the helper's hash over the service's own. `fill` URL-encodes each value and throws `<where>: no value for {x}`.
25
+ - **`serviceParams(service, hash)`**, **`fill(template, params, where)`** and **`fillScript(template, params, where)`.** Placeholder values come from the helper's hash over the service's own. `fill` is for a URL template (`src`, `embed.src`, `embed.watch`) and URL-encodes each value. `fillScript` is for a script body (a service's `inline`): it URL-encodes each value exactly as `fill` does, then writes every character of that result outside `[A-Za-z0-9_.-]` as a `\uXXXX` escape. Both run one shared placeholder loop (`fillWith`, private: the regex, the own-key check, the error), differing only in the encoder, so the placeholder syntax cannot drift between the two. Both throw `<where>: no value for {x}`, and both throw `URIError` on a value holding a lone surrogate, as `fill` always has.
26
26
  - **`consentModeDefault(resolved)`** and **`consentModeState(granted, map)`** → the Consent Mode `default` and `update` arguments.
27
27
  - **`textOf(html)`** and **`bannerHash(html)`** → the visible text, and 12 hex characters of its sha256.
28
28
  - **`bannerRegistry({ consent, hashes, texts })`** → `consent-banners.json`'s contents, or `null` without a consent manager or without an `endpoint` (no server to read it): `{ format: 1, version, banners: [{ hash, text, categories: [{ key, label, description, mode }], services: [{ name, label, provider, category }] }] }` (`provider` because the ICO asks for every controller relying on the consent by name, and a label is not always one, 2026-11-04), one entry per distinct hash the written pages carry, sorted. `text` is the `textOf` the hash was taken from, so `sha256(text)` begins with the hash and a server can check it; `null` for a hash with no recorded text. Necessary services are left out, as they are of the dialog's choices.
@@ -51,7 +51,7 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
51
51
 
52
52
  - `lib/config.js` (`resolveConfig`).
53
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`.
54
+ - `lib/consent-gate.js` (`fill`, `fillScript`, `gatingOf`, `scriptKind`), `lib/embed.js` (`PRESETS`, `fill`, `gatingOf`, `presetService`), `lib/handlebars-helpers.js`, `lib/audit.js` and `lib/build-finish.js`.
55
55
  - `test/unit/consent.test.js`, `test/unit/embed.test.js`.
56
56
 
57
57
  ## Non-obvious behavior
@@ -79,5 +79,6 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
79
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.
80
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).
81
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.
82
+ - **A placeholder in a script body is string content, not a URL** (security hardening, 2026-11-06). `inline` once went through `fill`, and `encodeURIComponent` leaves `'`, `(` and `)` alone, so the `gtag` preset's `window.gtag('config','{id}')` with an id of `G-X'-alert(1)-'` closed the string and called `alert`. `fillScript` URL-encodes the value as `fill` does, then writes every character of the result outside `[A-Za-z0-9_.-]` as `\uXXXX`; after URL-encoding those are only `%` and the six characters `encodeURIComponent` leaves alone, `'`, `(`, `)`, `!`, `~` and `*`. Inside any string literal the script reads `encodeURIComponent(value)`, exactly what it read when `inline` went through `fill` (the unit tests evaluate the emitted script and compare the value, for a quote, a backtick, `\`, a newline, `</script>`, U+2028 and a non-BMP character). **The URL-encoding is kept on purpose.** The first version of this fix wrote the raw value, escaped; that closed the breakout and opened another hole, because a site's `inline` that puts a placeholder inside a URL or an HTML string (`new Image().src='https://t.example/p?site={site}'`, `el.innerHTML='<a href="/x?id={site}">x</a>'`) had been getting an encoded, inert value and then got the raw one (the code review measured an injection through `innerHTML`). Unit tests evaluate both cases: the URL keeps one parameter, the HTML string holds no `<`, `>` or `"` from the value. Outside a literal a value cannot close one, call a function or end the `<script>` element, but it is still read as code: a value made only of safe characters (`x--`, a variable's name) is an expression. An id of letters, digits, `_`, `-` and `.` is written byte for byte as `fill` wrote it (`G-ABC123`, `AW-123`, `1234567890`, `0.5`, measured against `main`'s `fill`). **The dot is kept on purpose:** a site's own `inline` may hold a number outside quotes (`window.r={rate};` with `0.5`), which worked under `fill`; escaping the dot would make it a syntax error only a browser reports. An unquoted placeholder holding any other character than `! ~ * ' ( )` was already broken under URL-encoding (`%3A` is not JavaScript). One holding one of those six was not: `encodeURIComponent` passed them through, so `window.q={q};` with `init()` called `init` (measured on `main`). That was code injection, and it is now a syntax error in the browser, on purpose: a placeholder is data, never code. A value holding `document.write(` is inert now, so `findDocumentWrite` no longer trips on it, and is right not to. The page minifier (terser, under `minifyJS`) rewrites an ungated inline script; a unit test runs the hostile value through `html-minifier-terser` with `minifyJS` and still reads it back intact.
82
83
  - **`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.
83
84
  - **Theme values refuse `;`, `{`, `}`, `<` and `>`,** so a site's colour cannot end the declaration or the `<style>` element it is written into.
package/AIKB/css-audit.md CHANGED
@@ -17,7 +17,7 @@
17
17
  - `buildFindings({ pages, stylesheets, inline, duplicates, blockers })` → the findings, sorted by check then detail.
18
18
  - `formatCssAudit(audit)` — the stdout text.
19
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`).
20
+ - `runCss(argv, { cwd, purge })` → `{ code, out, err }`, the whole command. `purge` is injectable for tests (default `purgeCss`). `auditCss` makes one `purgeReads()` and passes it to every purge as `reads`, so a page linked by several stylesheets is read once (`AIKB/css-purge.md`). The "Removable" share therefore comes from the same purge a build runs, exact markup classes included (issue #78: it read 29% on a site whose real saving was 11%).
21
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
22
 
23
23
  ## Depends on
package/AIKB/css-purge.md CHANGED
@@ -7,7 +7,22 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
7
7
  ## Public interface
8
8
 
9
9
  - `resolvePurge(purge)` → `KissPurge | null`. `null`, `false` and absent purge nothing. An object needs a non-empty `files` list (the paths a template asks for; a leading `/` is dropped). `safelist` is strings and RegExps, default `[]`. `scripts` is a boolean, default `true`. Anything else throws, naming the key. `lib/config.js` calls it, so a typo fails at `new Kiss()`.
10
- - `purgeCss(css, { html, scripts, safelist })` → the purged CSS: `new PurgeCSS().purge()` over raw HTML and JS content, `safelist` as PurgeCSS's `standard` list. PurgeCSS is **imported when this runs**, not at module load: `lib/config.js` imports this module on every build, and PurgeCSS brings PostCSS with it.
10
+ - `purgeCss(css, { html, scripts, safelist, reads })` → the purged CSS. It calls `new PurgeCSS().purge()` over:
11
+ - the raw HTML and JS;
12
+ - **one more content item, holding the pages' exact `class` attribute values** (`attributeClasses`, one per line, extension `kiss-classes`), read by an extractor that only splits lines.
13
+
14
+ The options: `classTokens` as the `defaultExtractor`, `DYNAMIC_ATTRIBUTES` as `dynamicAttributes`, and `safelist` as PurgeCSS's **`greedy`** list only (a string becomes the exact-name RegExp; `greedy` keeps everything `standard` would). PurgeCSS is **imported when this runs**, not at module load: `lib/config.js` imports this module on every build, and PurgeCSS brings PostCSS with it.
15
+
16
+ - `purgeReads()` → a fresh `PurgeReads` (`{ tokens, classes, exact }`: two `Map`s keyed by page or script text, and a `WeakMap` from a page list to its exact classes, deduplicated and joined, since a build purges every stylesheet against the same list; third review, 2026-11-08) for one run of purges: `purgeStylesheets` makes one per build and `lib/css-audit.js` `auditCss` one per audit. Passed to every `purgeCss` of the run as `reads`, so each page and script is tokenised and parsed once however many stylesheets it meets. PurgeCSS runs its extractor over every content string for every stylesheet. Keyed by text, not by list: the audit hands each stylesheet a different list. The first version kept a module-level `WeakMap` on the list's identity, which the audit never hit (review, 2026-11-08).
17
+ - `attributeClasses(html)` → the classes in real `class` attributes, parsed with htmlparser2 (decoded, never matched in text), each once.
18
+ - `classTokens(content)` → every candidate class a script, or text, might name. It is the union of three sets:
19
+ - whole tokens: up to whitespace, quotes, backticks or `<>`, except inside `[…]`, where quotes and `>` are allowed (one nested group, `[&[open]]`); a trailing `:` is trimmed;
20
+ - the value after the first `=` of an unquoted attribute;
21
+ - the `[A-Za-z0-9_-]+` pieces PurgeCSS's default found.
22
+
23
+ A token holding a character reference is read again decoded, with `lib/links.js` `decodeEntities`.
24
+
25
+ - `DYNAMIC_ATTRIBUTES`: the ARIA states plus `disabled`, `hidden` and `inert`. Attribute selectors on these are kept whatever value the markup holds, as PurgeCSS already does for `value`, `checked`, `selected` and `open`.
11
26
  - `purgeStylesheets({ purge, root, manifest, pages, hash, minify, outputs })` → `PurgeResult[]`, one per file: `{ name, from, to, before, after }`. It:
12
27
  - reads every written page and, under `scripts`, every `.js`/`.mjs` in the build;
13
28
  - purges each file, then minifies it again when `minify` is on (a minifier failure keeps the purged text);
@@ -20,7 +35,7 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
20
35
 
21
36
  ## Depends on
22
37
 
23
- `fs-extra`, `node:path`, `purgecss` (dynamic import), `./asset-manifest.js` (`contentHash`, `hashedName`), `./asset-minify.js` (`minifyText`), `./utils.js` (`posixPath`).
38
+ `fs-extra`, `node:path`, `htmlparser2` (`Parser`, for `attributeClasses`), `purgecss` (dynamic import), `./links.js` (`decodeEntities`), `./asset-manifest.js` (`contentHash`, `hashedName`), `./asset-minify.js` (`minifyText`), `./utils.js` (`posixPath`).
24
39
 
25
40
  ## Depended on by
26
41
 
@@ -29,6 +44,18 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
29
44
 
30
45
  ## Non-obvious behavior
31
46
 
47
+ - **Whole class tokens, and nothing the old extractor kept is lost** (issue #78, reported against 2.13.3; the fix ships in the release after it). PurgeCSS's default extractor, `/[A-Za-z0-9_-]+/g`, split `bg-ink-900/80`, `min-h-[60vh]`, `p-1.5`, `hover:bg-brand-700` and every variant into pieces that are not the class. Every such rule was removed from a site that used it: a1k9training lost its hero height and nav padding, with 25,360 computed-style differences across 20 pages. Nothing reported it, and `kiss-ssg css` overstated "Removable" (29% against a real 11%) for the same reason. `classTokens` adds the whole tokens and keeps the old pieces, so the new candidate set is a superset of the old one. An extra candidate can only keep a rule that is not needed.
48
+ - **A class in the markup cannot be lost: it reaches PurgeCSS exactly, as htmlparser2 read it.** `classTokens` stays a heuristic for scripts and text, and it still cuts some legal classes (`a>b`). Each rule a page could match is still PurgeCSS's decision, ids, tags and attributes included. **There is no guard.** A first version checked the purged CSS afterwards and failed the build when a used class lost every rule. The code review of that slice (2026-11-08) showed it was unsound in both directions:
49
+ - it judged a selector by its classes alone, so `#sidebar .card` with no `#sidebar`, `ul.menu` with no `<ul>`, and `.btn[data-size=xl]` failed builds whose purge was right;
50
+ - it judged by the same tokens that had missed a class, so it could never report a miss.
51
+
52
+ The operator chose to make the failure impossible rather than detect it. The one hole left, a class a script assembles from pieces (`'modal-' + name`), was never visible to any check, which is why the safelist exists.
53
+
54
+ - **`classTokens` is linear on any input, and the shape of `WHOLE_TOKEN` is why.** A bracket group never runs past whitespace or the next `[` and holds at most one nested group, and the trailing `:` is trimmed by a loop after the match. The first version refused a trailing `:` inside the pattern and let a group run to any `]`. Measured on this branch: 50 to 53 s on 200,000 characters of `:`, of `[`, or of `a[` repeated. `/:+$/` as the trim cost 21 s on 200,000 `:` followed by a letter. A minified bundle can hold such a run. `test/unit/css-purge.test.js` times all four at 200,000 characters against a 250 ms budget.
55
+ - **A safelist entry keeps every rule that names it.** `standard` alone only exempts the matching selector part. The rest is still checked, so `safelist: ['btn']` lost `.btn[data-size=xl]` on a page with no `data-size="xl"`. `greedy` keeps a rule when any part matches (the operator's decision, 2026-11-08). A site with a safelist may keep more CSS than it did before. "Any part" is every name PurgeCSS 8.0.0 reads from a selector (`getSelectorValue`): class, id, tag, attribute and pseudo-class. So `'active'` keeps `.btn:active`, `/open/` keeps `[open]` rules and `/^nav/` keeps `nav …` rules, measured through `purgeCss` in the fourth review (2026-11-08). GUIDE says so and tells a site to anchor its patterns.
56
+ - **A selector that matches every element is kept** (`:focus-visible { … }`, `:-moz-focusring:where(…)`). PurgeCSS 8.0.0 removes one made of pseudo-classes alone (`AIKB/upstream.md`). `PSEUDO_PART` in its `standard` safelist keeps it, and leaves every other part of a selector to be checked. Found by the a1k9training clean room: with no safelist, every keyboard focus ring went (62 computed-style differences, all focus states).
57
+ - **ARIA state selectors are kept whatever the markup holds.** A disclosure button written `aria-expanded="false"` lost its `aria-expanded:rotate-180` rule (`[aria-expanded=true]`), because PurgeCSS also wants the attribute's value in the content and a script supplies it.
58
+
32
59
  - **Every `.html` file in the build is read, not only the rendered pages.** An HTML file copied from the assets folder, or a page kept from an earlier build under `cleanBuild: false`, links the stylesheet too, and its classes were purged when only `kiss._stack` was read (Codex at the close, 2026-11-05; the integration test was red before). The rendered pages are still the only ones relinked to a re-hashed name: a copied page's own `<link>` is its author's.
33
60
  - **Purged first, minified once, after.** `lib/assets.js` skips the copy-time minify for a file `assets.purge` names, and this module minifies it after purging. Minifying first strips comments, PurgeCSS's `/* purgecss start ignore */` included, and the protected block would be purged. Measured: with the old order, `.kept-by-comment` was removed (`test/integration/css-purge.test.js`, red before the fix). The minify after the purge is also the one that matters: it clears what the purge leaves behind, and the hash is taken from those final bytes.
34
61