kiss-ssg 2.9.0 → 2.11.2

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 (70) hide show
  1. package/AIKB/audit.md +29 -8
  2. package/AIKB/build-finish.md +9 -1
  3. package/AIKB/build-report.md +11 -6
  4. package/AIKB/check.md +4 -0
  5. package/AIKB/config.md +5 -3
  6. package/AIKB/consent-client.md +96 -0
  7. package/AIKB/consent-gate.md +64 -0
  8. package/AIKB/consent-record.md +37 -0
  9. package/AIKB/consent-ui.md +62 -0
  10. package/AIKB/consent.md +76 -0
  11. package/AIKB/handlebars-helpers.md +11 -3
  12. package/AIKB/kiss-page.md +1 -0
  13. package/AIKB/kiss.md +2 -1
  14. package/AIKB/links.md +5 -4
  15. package/AIKB/markdown-copy.md +1 -1
  16. package/AIKB/rebuild.md +2 -0
  17. package/AIKB/testing.md +7 -1
  18. package/AIKB/upstream.md +48 -0
  19. package/CHANGELOG.md +193 -0
  20. package/GUIDE.md +421 -21
  21. package/README.md +4 -2
  22. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  23. package/examples/11-blog/AIKB/last-build.json +7 -1
  24. package/examples/14-consent/README.md +42 -0
  25. package/examples/14-consent/router.js +127 -0
  26. package/examples/14-consent/src/assets/css/site.scss +521 -0
  27. package/examples/14-consent/src/assets/img/icon.png +0 -0
  28. package/examples/14-consent/src/assets/img/logo.svg +4 -0
  29. package/examples/14-consent/src/assets/img/og-card.png +0 -0
  30. package/examples/14-consent/src/assets/js/visits.js +17 -0
  31. package/examples/14-consent/src/layouts/layout.hbs +74 -0
  32. package/examples/14-consent/src/pages/404.hbs +18 -0
  33. package/examples/14-consent/src/pages/cookies.hbs +28 -0
  34. package/examples/14-consent/src/pages/index.hbs +47 -0
  35. package/examples/14-consent/src/pages/privacy.hbs +55 -0
  36. package/examples/14-consent/src/pages/roasting.hbs +23 -0
  37. package/examples/9-migrated-from-v1/AIKB/last-build.json +7 -1
  38. package/examples/README.md +5 -4
  39. package/lib/audit.js +878 -13
  40. package/lib/build-finish.js +134 -2
  41. package/lib/build-report.js +76 -0
  42. package/lib/check.js +50 -0
  43. package/lib/config.js +25 -4
  44. package/lib/consent-client.js +1142 -0
  45. package/lib/consent-gate.js +396 -0
  46. package/lib/consent-record.js +255 -0
  47. package/lib/consent-ui.js +455 -0
  48. package/lib/consent.js +1432 -0
  49. package/lib/handlebars-helpers.js +309 -2
  50. package/lib/init.js +2 -1
  51. package/lib/kiss-page.js +15 -0
  52. package/lib/kiss.js +29 -0
  53. package/lib/links.js +107 -26
  54. package/lib/markdown-copy.js +4 -1
  55. package/lib/rebuild.js +3 -0
  56. package/llms.txt +12 -3
  57. package/package.json +3 -2
  58. package/types/audit.d.ts +207 -2
  59. package/types/build-finish.d.ts +2 -0
  60. package/types/build-report.d.ts +50 -1
  61. package/types/check.d.ts +20 -0
  62. package/types/config.d.ts +21 -4
  63. package/types/consent-client.d.ts +77 -0
  64. package/types/consent-gate.d.ts +68 -0
  65. package/types/consent-record.d.ts +35 -0
  66. package/types/consent-ui.d.ts +62 -0
  67. package/types/consent.d.ts +764 -0
  68. package/types/handlebars-helpers.d.ts +8 -2
  69. package/types/kiss.d.ts +10 -0
  70. package/types/links.d.ts +19 -6
package/AIKB/audit.md CHANGED
@@ -2,24 +2,39 @@
2
2
 
3
3
  ## Responsibility
4
4
 
5
- The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident. Two pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
5
+ The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident, and the three privacy checks: a form asking for an email address or phone number with no link to the privacy page, a `<mark>`/`[to confirm` placeholder left in the copy, a pre-ticked marketing checkbox. It also derives the build's third-party origins (`thirdPartyOrigins`), the fact behind `report().thirdParty`. Pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
6
6
 
7
7
  The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
8
8
 
9
9
  ## Public interface
10
10
 
11
- - `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
12
- - `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`.
13
- - `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess`, passed so a cross-page fragment resolves to the page the link scan would resolve the same href to. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
14
- - The `CheckId`, `PageFacts`, `AuditFinding`, `AuditResult` and `AuditPage` typedefs.
11
+ - `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
12
+ - `MARKETING_WORDS` — the frozen word list `checkbox-prechecked` matches a label against (offer(s), news, newsletter(s), marketing, promotion(s), promotional, partner(s), third party/parties), whole-word and case-insensitive, a space also matching a hyphen.
13
+ - `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind }]` (every absolute or `//host` origin a fetching element names, sorted); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
14
+ - `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess, trailingSlash, privacyPage })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess` and `trailingSlash` `config.links.trailingSlash`, passed so a cross-page fragment, and a link to the privacy page, resolves to the page the link scan would resolve the same href to (`servedAs()`, which keeps a learning package's page on its file's folder, as `checkLinks` does). `privacyPage` is `config.audit.privacyPage`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl, id, package }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
15
+ - `thirdPartyOrigins({ pages, siteUrl })` → `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, or `null` when no page wrote HTML facts. Called by `lib/build-finish.js`'s `reportInputs()` on every report assembly; `lib/build-report.js` maps each `page` out of staging and nulls the key on a failed build.
16
+ - The `CheckId`, `PageFacts`, `PageOrigin`, `PageConsent`, `PageForm`, `FormField`, `AuditFinding`, `AuditResult`, `AuditPage` and `ThirdPartyOrigins` typedefs.
17
+ - **The consent manager's part** (2.11.0, contract `planning/plans/2026-11-02-consent-manager.md` §6).
18
+ - **Gated origins.** `PageOrigin` gains an optional `service`. It is set for a gated `<script type="text/plain" data-kiss-service>`, read from its `data-src`, and for a consent embed's frame. Gated means inert (`scriptKind` says a browser does not run it): a hand-written `data-kiss-service` script of a running type is read as live, its `src` and its inline signatures with it; until 2026-11-03 the attribute alone hid it from the inventory and `consent-ungated` (round 7 of the Codex review). The frame is read from the raw bytes, because `stripForIds` empties the `<template data-kiss-embed-frame>` it waits in. The dedup key includes the service, so `gtm` and `gtag` on one origin stay two entries.
19
+ - **`PageFacts.consent`** is `{ head, banner, settings, gated, signatures }`. `signatures` is `adsbygoogle`, `googletag.cmd`, `gtm.js` or `gtag/js` found in an ungated executable inline script, and `data-ad-client` on an `<ins>`. A script inside a comment or a `<template>` is not read, since a browser never runs it; until 2026-11-03 a commented-out snippet was reported as a tracker (round 6 of the Codex review). The `data-kiss-consent-head` script is never scanned, because its inlined config may name `gtm.js`. The field is absent from facts parked by an older engine.
20
+ - **`thirdPartyOrigins`** puts a sorted `services` beside `kinds` on an origin a service gates. Every other entry keeps its old shape.
21
+ - **`auditBuild`** takes `consent` (the resolved `config.consent`, or `null`) and runs eight checks, appended to `CHECKS`:
22
+ - `consent-missing`: no consent manager, and a page loads from `TRACKER_ORIGINS` or carries `gtm.js`/`gtag/js` inline;
23
+ - `consent-ungated`: a third-party script or iframe no service gates, and no `necessary` service lists in its `origins`, a `<noscript>` GTM frame included;
24
+ - `consent-ads-cmp`: any page, consent or not: an AdSense or Ad Manager loader, an `adsbygoogle`/`googletag.cmd` signature, or `<ins data-ad-client>`;
25
+ - `consent-objection-third-party`, site-wide;
26
+ - `consent-settings-missing`: a page with the head and no settings control, only when some category is in use. With every service `necessary`, `{{consentSettings}}` renders nothing, so no page could satisfy it (round 6 of the Codex review);
27
+ - `consent-google-advanced`, site-wide;
28
+ - `consent-records-missing`, site-wide: a Google marketing tag with no `endpoint`;
29
+ - `consent-history-missing`: the page whose `id` is `ui.history` has no `{{consentRecord}}` section (the page fact `consent.record`, read from a bare `data-kiss-consent-record` attribute, never its `-id`/`-rows` parts). Needed because the dialog's link to `#kiss-consent-history` sits in a `<template>`, which the fragment check never reads (measured 2026-11-03).
15
30
 
16
31
  ## Depends on
17
32
 
18
- `node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `classifyReference` and `resolveTarget`, so a cross-page fragment lands on the page the link scan resolves its path to) and `./utils.js` (`posixPath`). No HTML parser, no logger, no config.
33
+ `node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `srcsetUrls` and `browserReference`, so a `srcset` and a reference are read as the link scan reads them; `classifyReference` and `resolveTarget`, so a cross-page fragment and a privacy link land on the page the link scan resolves their path to), `./utils.js` (`posixPath`) and `./consent.js` (`TRACKER_ORIGINS`, `ADS_CMP_SIGNATURES`, `matchesHost`). No HTML parser, no logger, no config.
19
34
 
20
35
  ## Depended on by
21
36
 
22
- `lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's last key, `audit` — `AIKB/build-report.md`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
37
+ `lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's `audit` key — `AIKB/build-report.md`; and `thirdPartyOrigins`, from `reportInputs()`, as the report's last key, `thirdParty`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
23
38
 
24
39
  ## Non-obvious behavior
25
40
 
@@ -47,5 +62,11 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
47
62
  - **Matching:** the raw fragment is compared first, then its percent-decoded form, both case-sensitively. A text directive is cut off at `:~:`, and an empty fragment is skipped. `#top` with no `id="top"` is skipped in any case, because the spec scrolls to the top.
48
63
  - **The target page:** an empty path or a query-only path (`?q=1#x`) is the page itself. Anything else goes through `classifyReference` and then `resolveTarget`, with `exists: () => false`, so only pages this build wrote can be named. A fragment on an asset, another origin, an earlier build's file or another instance's page has no ids to check against and is skipped. So is every fragment on a page with a `<base href>`.
49
64
  - **`resolveTarget` tries `known` across every candidate before the disk** (`AIKB/links.md`). Asked candidate by candidate, `/about` on a real filesystem named the folder `about`, which holds `about/index.html`, and every extension-less cross-page fragment would have been skipped without a word. A fresh-context critique of the plan caught this before the code existed (2026-10-20).
50
- - **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw.
65
+ - **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields.
66
+ - **The privacy facts read quote-aware** (`ATTRS`, `TAG_END`), for the fragment checks' reason: a misread form, label or checkbox is a finding invented, and Alpine-style `x-show="step > 1"` values are common on exactly the forms these read. `origins` reads from the ids' strip (opening tags kept, so `<script src>` is seen; script, style and template contents gone). `forms`, `anchors`, `placeholders` and `prechecked` read from the counts' wider strip, so a `<mark>` in a script string or a comment never fires.
67
+ - **`origins` is its own kind-aware fact, not derived from `KissPage.links`** (2026-11-02 critique, blocker 1). `links` is a flat, sorted string list with no tag kind, and it includes every `<a>`, which a browser does not fetch. Kinds: `script`, `iframe`, `img` (`<img>` `src`/`srcset`, `<source srcset>`, `<video poster>`), `media` (`<video>`/`<audio>` `src`, `<source src>`), `form-action`, `stylesheet`, `preconnect`, `dns-prefetch`, and `preload` or `preload:<as>`. Never an `<a>`, a canonical or an alternate link. Only absolute `http(s)` and `//host` references are kept, since a relative one can only be the site's own. Static markup only: an iframe a script injects (a1k9's facade) and a CSS `@import` are invisible, so an empty list proves nothing.
68
+ - **First-party is the host, not the origin** (`thirdPartyOrigins`). An absolute URL whose host equals `siteUrl`'s, with a leading `www.` dropped on both sides, is the site's own, whatever its scheme or port. A subdomain (`cdn.site.example`) is not. With `siteUrl` unset, unparseable or local (`isLocalSiteUrl`, the `site-url-local` rule), every absolute origin is listed and `siteUrl` is `null`: the audit does not guess which one is the site.
69
+ - **`privacy-link-missing` finds the privacy page by id, then by path, and never by link text.** The page whose `id` is `privacy` (or `config.audit.privacyPage`), else, with no override, the page written to `privacy.html` or `privacy/index.html`. A page counts when one of its forms has a field that is not `hidden` (or another non-typed input), not a honeypot (`bot-field`, or the field the form's `netlify-honeypot` names), and has `type` `email`/`tel`, a `name` of `email`/`e-mail`/`tel`/`phone`/`telephone`, or an `autocomplete` token `email` or `tel…`. It is satisfied by an `<a>` anywhere on the page whose href resolves, through `classifyReference` and `resolveTarget` with `exists: () => false`, to the privacy page. An external href never counts, and the privacy page needs no link to itself. A page with a `<base href>` is not checked at all: its relative links resolve somewhere the build cannot see, so a link a browser follows to the privacy page was reported missing, and one leaving the site could pass (a Codex review, 2026-11-02). The fragment checks skip such a page for the same reason. The `detail` is that field's name (or type). No privacy page at all is one more site-wide finding, said only when some page collects; a `privacyPage` no audited page has is a site-wide finding too, said whatever the forms, rather than a config error, because only the build knows which ids exist.
70
+ - **`placeholder-left` is `<mark>` or the bracketed `[to confirm`, never the bare phrase.** "to confirm" appeared twelve times as prose in the fleet's sources (2026-11-02 critique). A `[to confirm` inside a `<mark>` is one placeholder, not two. The bracketed text inside a `<code>` or `<pre>` element is not read at all: it is the marker quoted, and kiss's own guide page for this check fired on itself until it was excluded (2026-11-02). A real `<mark>` element still counts wherever it is, because an escaped one (`&lt;mark&gt;`) is not an element.
71
+ - **`checkbox-prechecked` is a presence test for `checked` on quote-blanked attributes**, like `ALT`, so `data-checked`, `aria-checked` and a `checked` in another attribute's value never match, and a radio never counts. The label is the union of `aria-label`, the `aria-labelledby` targets' text, every `<label for>` naming the box's id and the `<label>` wrapping it. The decision (`MARKETING`) is made at audit time from the stored label, so changing the word list needs no re-extraction.
51
72
  - **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
@@ -29,6 +29,8 @@ Each is the body of the `Kiss` private method named beside it:
29
29
  - `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
30
30
  - `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
31
31
  `_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
32
+ - `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
33
+ - `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.
32
34
 
33
35
  Three are named apart from their method because the method's own name is a function this module
34
36
  imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
@@ -37,7 +39,7 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
37
39
  ## Depends on
38
40
 
39
41
  `fs-extra`, `./utils.js`, `./build-report.js` (`buildReport`, `auditLines`), `./links.js`,
40
- `./audit.js`, `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
42
+ `./audit.js` (`auditBuild`, `thirdPartyOrigins`), `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
41
43
  `./check.js` (`readReportsFile`, `aikbRecordRequested`), `./aikb.js` (`buildSiteMap`,
42
44
  `isRecorded`, `writeAikb`, `writeLastBuild`).
43
45
 
@@ -47,6 +49,12 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
47
49
 
48
50
  ## Non-obvious behavior
49
51
 
52
+ - **`runLinkCheck()` and `runAudit()` both pass `config.links.trailingSlash`**, so a relative reference on a directory index resolves against the URL the host serves it at, the same policy `{{link}}` was written under; `runAudit()` passes each page's `package` too, which keeps a learning package's page on its file's folder (Codex round 6, 2026-11-03).
53
+ - **`runAudit()` threads each page's `id` and `config.audit.privacyPage` into `auditBuild`**, so `privacy-link-missing` can find the privacy page by id. The pages come from `_stackForRecord()`, not the raw stack, so the ids are the ones the report shows: a default `privacy` that two pages arrived at, which `{{link}}` would refuse, finds no privacy page either.
54
+ - **`reportInputs()` derives `thirdParty` on every assembly**, from the audit facts each written page parked (`thirdPartyOrigins`, `lib/audit.js`), against the instance's `config.siteUrl` (a page's own `siteUrl` override is not consulted for this key). It reads no file, so it costs a walk over the stack and is current under `_refreshReport()` too; `buildReport` nulls it on a failed build. It comes after `packages`, as the report's key order fixes it.
55
+ - **`reportInputs()` derives `consent` last, the same way:** `consentReport` (`lib/consent.js`) from `config.consent` and each written HTML page's parked `audit.consent`. Pages carrying gated markup are passed as `gatedPages`, from which `pagesWithoutHead` is derived. Paths stay as written (staging under check) for `buildReport` to map. It is `null` without `config.consent`. `runAudit` passes `config.consent` to `auditBuild` for the seven consent checks.
56
+
57
+ - **`writeBannerRegistry()` runs between `_writeRedirects()` and the packages, for the redirects' reasons**: `consent-banners.json` is a file this build publishes, so it is in the staging folder before the swap. Written whenever `config.consent` has an `endpoint`, dev included (its one reader is the server the records go to; the operator chose endpoint-only over always-on at the 2026-11-03 pulse), as `bannerRegistry()` (`lib/consent.js`) of the hashes the written pages' parked facts carry (`audit.consent.banner`) and the texts the helper recorded by hash in `kiss._bannerTexts`. That map only grows, and a text no written page's hash names is never written. Latched as `_bannerRegistryRun`, reset by the replay; a failed write is a build failure (`<consent-banners>`), because the file is the record of the words a hash stands for. Claimed in the output registry. Not in the build report: the report already lists each hash with its pages.
50
58
  - **`writeLearningPackages()` runs just after `_writeRedirects()`, and for its reasons**: the zips are files this build publishes, so they are written into the staging folder before the swap, from the pages where they were written. It is latched as `_packagesRun` (reset by the replay) and skipped with nothing to report when no page names a `package`, when `config.packages` is `false`, and when the build has already failed, so a failed page is reported once and not again as a package that could not be made. A dev build skips it with one notice per instance: a scoped re-render re-runs no writer. A package that cannot be made fails the build as `<packages>`, and no zip is written at all. `written(file)` is a lookup in the output registry, never the disk, which is what keeps a stale file out of a package (`AIKB/packages.md`). `reportInputs()` carries `_packagesResult` as the report's last key. After writing, it logs one `Package: <build folder>/<dir>/<id>.<format>.zip <title> (<n> pages, <m> files)` info line per zip, beside `Links:` and `Audit:`, named by `config.folders.build` rather than the staging folder an atomic build wrote it in, and a `package outside: <page> -> <href> (<reason>)` notice per reference left out. Before 2026-10-25 only `check --summary` listed packages, and a clean-room run found GUIDE's claim that the build did.
51
59
  - **`_writeRedirects()` now writes up to two files, and only one of them is the host's.** `redirects.json` — the host-neutral IR — goes out on every build that has an alias, whatever `config.redirects.format` says; the format picks what goes beside it. `_redirectsPath` stays the **host** file, so the report's `redirects.file` keeps the meaning it had in 2.3, and it is `null` under `format: 'none'`: the honest answer for a build that deliberately wrote no host file, rather than a path that would read as a redirect some host is serving. A custom writer runs inside the same promise the `.catch` below wraps, so a writer that throws fails the build exactly as a failed write does — swallowing it would rebuild, in the site's own code, the silent no-op the format block exists to end.
52
60
  - **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_audit`, `_debugWritten`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit })` → `BuildReport`, whose keys are always in this order:
9
+ - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
10
10
  - `ok` — `failures.length === 0`.
11
11
  - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
12
  - `buildDir` — the folder the site asked for, never the staging sibling.
@@ -21,14 +21,18 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
21
21
  - `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }], orphans }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder. `orphans` is the sorted HTML pages no other page links to, never the home page, `404.html` or a page in a learning package, each mapped out of the staging folder the same way (`AIKB/links.md`).
22
22
  - `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved, rules, json, format, files }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`. The last four keys are the format block: `rules` is the resolved `[{ from, to }]` — the host-neutral fact, handed over so a site whose host kiss does not encode can write its own redirects from `kiss.report()` rather than by parsing a file kiss wrote — `json` is the `redirects.json` IR (mapped), `formats` is the list of names that ran, in order (`'custom'` per writer function, `[]` for IR-only), and `files` is every redirects file written (mapped). `file` is the **first** host file in `formats` order, and `null` when no host format ran at all — the honest answer rather than a claim about a redirect no host serves. With more than one format it names one of several, so `files` is the authoritative list; that is the one place `file` is a lead rather than the whole answer, and it is why `files` exists.
23
23
  - `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
24
- - `audit` — the **last** key, after `outputs`: `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
25
- - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
26
- - `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 four `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) — 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.
24
+ - `audit` — after `outputs` (then `packages`, then `thirdParty`): `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
25
+ - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON. After the packages, one ` third-party origins (N): <the first five>, …` line when `thirdParty` lists any, then one ` consent: version <v>, banner <hash>, N services` line when `consent` is set.
26
+ - **The `consent` key, last** (2.11.0). It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
27
+ - Every page path goes through `real()` and is then sorted.
28
+ - It carries **no timestamp**, so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
29
+ - `thirdParty`'s origins carry `services` beside `kinds` when a consent service gates them.
30
+ - `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.
27
31
  - `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
28
32
 
29
33
  ## Depends on
30
34
 
31
- Nothing.
35
+ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `CheckId` and `ThirdPartyOrigins`.
32
36
 
33
37
  ## Depended on by
34
38
 
@@ -36,7 +40,8 @@ Nothing.
36
40
 
37
41
  ## Non-obvious behavior
38
42
 
39
- - **`packages` is the last key**, 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.
43
+ - **`thirdParty` is the last key**, after `packages`: `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, the origins other than the site's own that the written pages fetch from (`thirdPartyOrigins` in `lib/audit.js`, `AIKB/audit.md`). A fact, not a finding: `audit.check: false` does not turn it off and it never moves `ok`. `null` when no page wrote HTML facts, and **`null` on any failure** — the audit's stance, because a page that failed fetched nothing on record and a partial inventory reads as a complete one; the rule is here rather than upstream so `_refreshReport()` applies it too. Each `page` goes through `real()`; origins are URLs and are not mapped. `siteUrl` is `null` when it was unset or local and every absolute origin is therefore listed. Present in dev builds, unlike `audit`: it reads only the facts the pages parked, so a scoped re-render's page is current. `AIKB/last-build.json` gains it at a site's next `kiss-ssg aikb`; `diffReports` does not read it.
44
+ - **`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.
40
45
  - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` does the same thing for the failure _message_; this is the same rule somewhere a unit test can reach it, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
41
46
  - **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.
42
47
  - **`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`.
package/AIKB/check.md CHANGED
@@ -55,6 +55,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
55
55
  - **The diff never touches the exit code.** `exitCodeFor` does not see it. A build that is `ok` but changed 40 pages is a passing check with a loud description; whether that is what the operator meant is a judgement no exit code can make for them.
56
56
  - **The bin reads the `--against` file before it spawns the build**, so a mistyped filename costs nothing and prints as the usage error it is (`kiss-ssg: cannot read --against file <path>`, then `HELP`, exit 1) rather than after a minute of building.
57
57
  - **`check` diffs without being asked, and `aikb` does not.** With no `--against`, the bin asks `defaultBaseline(reports)` where the site's own snapshot would be and diffs against it if the file is there — so the ordinary question ("what have I changed since this work opened") is answered by the ordinary command, and the answer is measured from the last _record_, not from the last build anybody happened to run. A check writes nothing, so the file it reads is the one the last record left. `aikb` skips the fallback for the opposite reason: by the time it could read that file it has just overwritten it, and a diff against yourself is `= N unchanged` every time. Naming `--against` explicitly always wins, under either command.
58
+ - **The diff names a consent change first** (2.11.0).
59
+ - **Shape:** `consent` is `{ version: { from, to } | null, banners: { added, removed } }`, from the reports' `consent` keys. It is present only when the version or a banner hash differs; a site with no consent manager never gets it.
60
+ - **What `formatDiff` prints, above the page rows:** `! consent banner <hash> (was …): record it with kiss-ssg aikb and commit before deploying`, and `! consent version a -> b: every visitor is asked again`.
61
+ - **Why:** the banner hash recorded by `kiss-ssg aikb` is the dated proof of what a visitor saw, and it holds only if it is recorded before deploying. A wording change otherwise showed up only as every page changed.
58
62
  - **The first report with a folder wins the baseline.** A script that builds several sites writes one report each, but they share a working tree and therefore one knowledge base; `diffReports` pairs the file's contents with this run's reports by `buildDir`, so a single baseline covers all of them.
59
63
  - **The asset rows exist because a page diff can have no cause in any page source.** Under `assets.hash` a stylesheet's filename _is_ the hash of its bytes, so one changed asset rewrites the `<link href>` of every page that references it: the diff reads `~ <every page>, = 0 unchanged`, and a reader who trusts it goes looking through page sources, models and controllers for a change that is not there. Naming the asset that moved turns that into a one-line read — and the rows go **above** the page list, because a cause printed after two hundred rows of consequence is reached only by the reader who already scrolled past the thing it explains. Verified in situ on a 20-page site with a 91-entry manifest before the order was settled. Both sides of the comparison were already on disk — `BuildReport.assets` is the manifest (`AIKB/build-report.md`), and `last-build.json` keeps it — so this needed no record-format change and no new build work. Three deliberate limits: only assets **both** builds emitted are rows (a new one has no previous name to have moved from, and is the asset copy working rather than a fact that explains anything); nothing prints when no page moved, because the rows explain a diff rather than being one; and the list caps at ten as a safety valve rather than a display limit — only `.css`/`.js` are hashable at all, and under `assets.version` nothing is renamed so every entry records `target === source` and no row can ever fire, which means a real site moves one or two and the cap only bounds a site that bundles per page. **The limitation worth knowing rather than fixing:** because it compares targets, an _unhashed_ asset whose bytes changed — an edited image — is not a row, since its `source` and `target` are both unchanged. That is the right trade: an unhashed asset is not referenced by a name that appears in every page's HTML, so it never produces the all-pages-changed symptom these rows exist to explain. Found by a consuming site whose whole-site diffs were being caused by its stylesheet — see the Tailwind notes in `AIKB/pipeline.md`.
60
64
  - **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
package/AIKB/config.md CHANGED
@@ -7,12 +7,12 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
7
7
  ## Public interface
8
8
 
9
9
  - `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
10
- - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
10
+ - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
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
12
  - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
14
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
15
- - `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`), and `ignore: []`, the check ids not to run.
15
+ - `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), `ignore: []`, the check ids not to run, and `privacyPage: null`, the override of the convention `privacy-link-missing` uses to find the privacy page (id `privacy`, else `privacy.html` or `privacy/index.html`).
16
16
  - `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
17
17
  - `resolveMarkdownCopies(value)` → `{ write, selector }`. The `resolveAudit` shape, except that `true` is a statement rather than the default: `undefined` is the default block, `true` is `{ write: true, … }` (always), `false` is `{ write: false, selector: 'main' }`, a plain object is merged one level deep over the default; anything else throws, and so does a `write` other than `true`, `false` or `'llms'`, or an empty `selector`. Exported because a page's own `config.markdownCopies` arrives as the page wrote it — `false` on one page — and the page registry resolves it with this. An already-resolved block resolves to itself.
18
18
  - `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`).
@@ -23,7 +23,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
23
23
 
24
24
  ## Depends on
25
25
 
26
- `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).
26
+ `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).
27
27
 
28
28
  ## Depended on by
29
29
 
@@ -32,6 +32,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
32
32
  ## Non-obvious behavior
33
33
 
34
34
  - **`packages` is a block or `false`.** `resolvePackages` merges `config.packages` over `DEFAULT_PACKAGES` (`{ format: 'imscp', dir: 'packages', title: null, only: null, schemas: false }`) and throws for an unknown format, a `dir` that is not a string, a `title` that is not a function, an `only` that is not a list of strings, and a `schemas` that is not `true` or `false`: each would otherwise be found only when the packages are written, after every page. `false` is kept as `false`, not merged, so the writer can tell "off" from "on with defaults"; `true` and nothing are the defaults. Any other value that is not a plain object (a string, an array, a number) throws rather than being spread: `packages: 'scorm12'` spread as the string's character indices, validated, and wrote IMS content packages for an author who asked for SCORM (code review, 2026-10-25). Packaging itself is by convention: the block only applies when a page names a `package` (`AIKB/packages.md`).
35
+ - **`consent` is resolved in its own module, and is `null` by default.** `resolveConsent` (`lib/consent.js`, `AIKB/consent.md`) owns every rule of the block: presets, categories in use, the version, and refusing unknown keys at every level. That is a deliberate exception to this module's unknown-keys-pass-through rule, because inside `consent` a typo such as `endpont` would silently mean no consent records (operator, 2026-11-03). `DEFAULT_CONFIG.consent` is `null`, which is what `llms.txt` and `GUIDE.md` show in their default blocks.
35
36
  - **`cleanBuild` has three values, and the two that clean do it at different moments.** `true` (the default) calls `fs.emptyDirSync(folders.build)` in `_setupFolders`, i.e. **in the `Kiss` constructor** — before any model resolves and before any page renders — and nothing rolls it back if the build then fails. So a re-run of a build folder that holds published output destroys it first and, on a failure, leaves it empty or half-built (consumer review finding B-3). `false` never cleans. `'atomic'` builds into a staging sibling and swaps it in only when `complete()` resolves; the old output is untouched until that moment and survives a failed build. See `AIKB/kiss.md` for the staging mechanics — this module only carries the value.
36
37
  - **The value is validated, not coerced.** Anything but `true`, `false` or `'atomic'` throws out of `resolveConfig` (so out of `new Kiss`), including `null` and `0`. A silently-falsy value is exactly the D-07 failure mode in a new coat: `{ cleanBuild: opts.clean }` with an unrecognised flag would quietly stop cleaning, or quietly stop staging.
37
38
  - **Source/output safety is checked before any filesystem mutation**, for every `cleanBuild` value. `folders.src` cannot resolve to the working project root or a filesystem root. Neither can `folders.build`, regardless of whether `src` is disabled or elsewhere. The build must not equal or contain any configured source folder (`src`, its six derived folders, `helpers`, `aikb`), nor be nested inside an individual content folder. Only `src` allows output below it: `src/public` stays valid if separate from every content folder. Null folders are excluded.
@@ -70,6 +71,7 @@ The typedefs that describe the config to a consumer live at the top of this modu
70
71
  - **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
71
72
  - **`links.canonical` is off by default, and that is the whole design decision.** Turning it on changes the URL every bare `{{link}}` emits, which is a site's entire internal link graph and every `href` in its published output — a major-version change, not a default. It exists because the alternative discipline does not scale: a site that is not `extensionLess`, served by a host that redirects `/about.html` to `/about`, needs `canonical=true` on every single call or it ships a redirect hop per link, and one forgotten call is silent in dev and visible only on the deployed site. A real consuming site was writing `canonical=true` sixty-six times and carrying a `CLAUDE.md` paragraph explaining that it was mandatory. One key here replaces that. The `extensionLess: true` route solves the same problem and is better greenfield, but it changes every output path and therefore the relationship between the built files and the `aliases` a site carries for its old URLs — which is why this is the key and not a nudge towards that flag. Landing it needed no new config surface: the block was already shaped for exactly this by the comment above it.
72
73
  - **`audit` takes a boolean shorthand, and an unknown check id throws.** `audit: false` is the one-word way to turn a whole advisory pass off, so it is accepted where `links` is not; anything else that is not a plain object is refused rather than read as truthy. The ignore list is validated against `lib/audit.js`'s `CHECKS` for the `redirects.format` reason: a misspelled id that ignored nothing would leave the finding on every build while the author believed it switched off. The ids are imported, never repeated here, so a check cannot be accepted by the config and unknown to the audit.
74
+ - **`audit.privacyPage` is type-checked here, and its existence is checked by the audit.** `null` or a non-empty string; anything else throws out of `resolveConfig`, naming the value. Whether a page has that id is only known once the build has run, so an id no page claims is not a config error: it is a site-wide `privacy-link-missing` finding (`AIKB/audit.md`).
73
75
  - **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `markdown`, `links`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
74
76
  - **The index signature is on the config and never on `folders`.** An unknown config key is legitimate — anything a site passes through reaches its views as `{{config.x}}` — so it types as `any` rather than an error. A `folders` key is not: `{ folders: { biuld: './out' } }` is a typo that silently builds to the default folder, so `KissFoldersInput` stays closed and TypeScript reports it. `test/fixtures/consumer/bad.js` pins that difference.
75
77
  - `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
@@ -0,0 +1,96 @@
1
+ # consent-client.js
2
+
3
+ ## Responsibility
4
+
5
+ The consent manager's browser side, in two parts that share one object, `window.__kissConsent` (spec `planning/specs/2026-11-03-kiss-consent.md`, "the inline part and the cached file"):
6
+
7
+ - **`kissConsentHead(win, cfg)`**, inlined into every page by `{{consentHead}}` with the site's config. It sets the Google Consent Mode default (and the `update` for a stored choice), reads the stored choice, answers `has()` and `choice`, mounts the banner before first paint with the bottom bar's padding, and puts a `window.kissConsent` stub in place that queues `show()`, `when()`, `on()` and any click on a consent control.
8
+ - **`kissConsentClient(win)`**, the rest, in one cached file every consent page loads (`<build>/kiss-consent.<hash>.js`, written by `writeConsentClient` in `lib/build-finish.js`). It clears what denied services left behind, answers the buttons, writes the choice cookie, records each choice (the browser's own log, then the endpoint), starts allowed scripts in document order, reveals allowed embeds, withdraws a choice, replaces the stub with the full `window.kissConsent`, and replays what the stub queued.
9
+
10
+ The head part keeps the state (`stored`, `choice`, `grants`) and every function both parts need on the shared object, and the client reads and writes it there, so no rule is written twice.
11
+
12
+ The contract is `planning/plans/2026-11-02-consent-manager.md` §3–§5, and for the record format, the log and the id, `planning/specs/2026-11-03-kiss-consent.md` (D1, D8, D10, D13). Every behavioural choice was first measured in Chromium (its Appendix A).
13
+
14
+ ## Public interface
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.
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
+ - **`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
+ - **`inlineJson(value)` → `string`.** That escaping, as a function, shared with `lib/consent-record.js` so the viewer's config cannot end its script either.
20
+ - **`kissConsentHead`** and **`kissConsentClient`** are exported for the tests.
21
+ - **In the browser, `window.kissConsent`:**
22
+ - `has(name)`: `necessary` is always true, and so is a `necessary` service. A category is true when it is granted. Any other service is true when its category is granted or it has an "always allow" grant. Only own properties answer, so `constructor` and the rest of `Object.prototype` never do.
23
+ - `choice`: `null`, or a frozen `{ id, version, at, categories, services }`.
24
+ - `show()`: opens the settings dialog.
25
+ - `when(name, fn)`: runs `fn` now if `name` is granted, otherwise once on the change that grants it.
26
+ - `on('ready' | 'change', fn)`: `ready` replays to a listener added after it fired.
27
+ - `mount()`: called by `{{consentBanner}}`'s inline script.
28
+ - **DOM events on `document`:** `kiss-consent:ready` (detail `{ choice }`) and `kiss-consent:change` (detail `{ choice, granted, revoked }`).
29
+ - **`dataLayer`:** each change also pushes `{ event: 'kiss_consent', kiss_consent: { <category>: bool } }`.
30
+ - **Actions** are read by delegation from `data-kiss-consent-action`: `accept`, `reject`, `settings`, `save`, `close`, `close-dialog`, `hide`, `load`, `allow`.
31
+
32
+ ## Depends on
33
+
34
+ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName`). `consentHeadScript` serialises what `lib/consent.js`'s `clientConfig` built.
35
+
36
+ ## Depended on by
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.
39
+
40
+ ## Non-obvious behavior
41
+
42
+ - **Order in the head.** The inline part runs these steps in order:
43
+ 1. the `dataLayer` and `gtag` stub (it never replaces an existing `gtag`);
44
+ 2. `gtag('consent', 'default', …)`;
45
+ 3. the two `set` calls;
46
+ 4. a synchronous cookie read;
47
+ 5. `gtag('consent', 'update', …)` when a valid choice is stored.
48
+
49
+ That is all before any other script, which the wiring check enforces. Clearing denied services' first-party cookies and storage moved to the cached file's start at the split (2026-11-03): nothing gated runs until that file has started, so it still runs before anything it protects.
50
+
51
+ - **The stub and the replay.** Before the cached file starts, `window.kissConsent.has()` and `.choice` answer from the shared state; `show()`, `when()` and `on()` are queued; a click on a `data-kiss-consent-action` control is queued by a listener that stops at `S.ready`. **Nothing goes live until `start()`**: the file sets `S.loaded` when it runs, but `S.ready` and `S.api` are set inside `start()`, after it has taken the queues, and the client's own click listener ignores clicks until then. The inline part adds the file's tag, so the file can run while the page is still loading, before `DOMContentLoaded`; handing calls to the live API then ran a `when()` under an `objection` category while the Reject made before it was still queued, and a click made then went live ahead of the queued one before it (Codex round 10, P2, 2026-11-04; red in the unit suite with a loading document). `start()` then applies what was queued **before anything starts**: the queued `on()` listeners (which start nothing), then the queued clicks, so every choice the visitor made holds; only then the first activation, `ready`, and the queued `when()` and `show()`, which can start or show something. Replaying after the activation let a Reject queued during the download run an `objection` category's script first, and reload to withdraw it (Codex round 2, P1, reproduced in Chromium and red in the unit and browser tests on that order). A unit test with the replay removed fails. **`window.kissConsent` is one object for the page's life**: the client puts its API on `S.api` and never replaces the global, and the stub's `show`, `when` and `on` hand each call to `S.api` once it is set. Replacing the global stranded a script that had kept the object or a method from before the file arrived: its calls were queued after the queue was drained (Codex round 3, P2). **If the file never arrives** (blocked, offline), the banner shows and nothing is written or started: it fails closed. Each part guards against running twice (`win.kissConsent` for the head, `S.loaded` for the client).
52
+
53
+ - **A stored record counts only when all of these hold,** decided once for the category choice and the "always allow" grants alike:
54
+ - the format is `1` and the version matches;
55
+ - it is younger than `cookie.days`.
56
+
57
+ The category choice also needs a category answer `c`. An "always allow" grant made before the banner was answered is stored with `c: null`: it is honoured while the record is current, but the banner still asks. An expired or old-version record honours neither. Until 2026-11-03 the grants were checked against the version only, so a site that shortened `cookie.days` left old grants working after their choice had expired (found by the Codex review).
58
+
59
+ - **The activation queue is serial.**
60
+ - **Why:** S2 measured inline and module scripts running before an earlier external when everything was inserted at once.
61
+ - **How it runs:** each gated script is replaced by a live clone in document order. An external one holds the queue until `load` or `error`, or a 10-second timer. An inline module holds it too: it fires no `load`, so kiss appends `;window["__kissConsentRan<n>"]()` as its last statement, and the call, `error` (a failed import) or the timer releases the queue. A module that throws before its end waits out the timer. A `data-async` one never holds it.
62
+ - **No double runs:** the original is replaced, and queued elements are marked, so no later pass revives one twice.
63
+ - **When:**
64
+ - at `DOMContentLoaded` for the stored choice or an `objection` category;
65
+ - on the click for a new grant.
66
+ - **What scripts miss:** revived scripts never see `DOMContentLoaded` (`AIKB/upstream.md`).
67
+ - **Withdrawal reloads.** A choice that refuses a category active on this page, or a service this page started, takes the withdrawal path. A service counts as started when its embed was revealed, a gated script of it was revived, or a `when()` callback for it ran, each recorded by `started()`. Until 2026-11-03 only a revealed embed counted, so a service started under an "always allow" grant by `when()` or its own scripts, with its category refused, kept running after Reject all, and its cookies stayed (found by the Codex review):
68
+ 1. write the cookie, send the `update`, append to the log and send the POST;
69
+ 2. delete the first-party cookies and storage of every denied service, on no domain, the host and each parent domain (S7). A `__Secure-` or `__Host-` cookie is deleted with `Secure`, which a browser requires, and a `__Host-` one on no domain only, since it cannot carry one (found by the Codex review, 2026-11-03);
70
+ 3. `location.reload()`, because a script that ran cannot be un-run.
71
+
72
+ "Reject all" denies `objection` categories too, so it withdraws one that started at load. Every load also repeats the deletion in the head, which catches a cookie rewritten on `pagehide` before the reload, and a withdrawal made in another tab.
73
+
74
+ - **A cookie or storage key an allowed service also declares is never deleted.** `gtag` and `gtm` both declare the Google cookies, so with statistics allowed and marketing refused, deleting gtm's `_ga` would reset gtag's visitor on every load. `keptBy()` checks every allowed service, necessary ones included, which is why `clientConfig` hands the client a necessary service's names too (round 5 of the Codex review, 2026-11-03).
75
+ - **Lookups by name read own properties only** (`own()`), so `constructor` or any other `Object.prototype` name never answers as a category, a service or a grant (round 5).
76
+ - **"Always allow" starts the service's gated scripts as well as its embeds** (`activate()` after the grant), as a category choice does (round 5).
77
+ - **Reject all clears every "always allow" grant.** It commits with no grants at all, so a video allowed while marketing was refused stops with the rest, and a page showing it reloads.
78
+ - **Saving the dialog clears the grants of a category it changes from granted to denied.** A grant made while its category stays denied survives a Save, because the dialog shows that grant's own switch and the visitor left it on. Before 2026-11-03 Reject all went through this same rule and so kept such a grant (found by the Codex review).
79
+ - **Close is not consent.** It sets `<cookie.name>_closed=1` with no `Max-Age` (a session cookie) and removes the banner. Nothing else is written, so the banner returns in the next browser session.
80
+ - **The visitor id is always made** (D1), at the first choice: `crypto.randomUUID()`, else `getRandomValues` hex, kept in the cookie and carried by every record. Until 2026-11-03 it was made only with an endpoint; the log and the history page need it either way. It leaves the browser only in a POST.
81
+ - **One record, two copies, the log first.** `logChoice(stored, action)` builds the format-2 record (`format`, `action`, then format 1's `id`, `version`, `timestamp`, `categories`, `services`, `bannerHash`, `page`). `services` is `servicesAgreed(stored)`: every non-necessary service, `true` or `false`, by the rule `has()` uses (the category's answer, before one the category's `objection` mode, else the service's own grant), so a record names what was agreed to at the time, whatever is later added to a category (the operator, 2026-11-04); the cookie keeps its compact `s` of single grants. It then appends the record to `localStorage[<cookie.name>_log]`, and then hands the same JSON to `post()`, so the browser's copy and the server's are identical (the Chromium suite compares them across a withdrawal's reload). `action` is `accept`, `reject`, `save` (from `commit()`) or `allow` (an embed's grant); Close, Load, the dialog and a page load record nothing. The log keeps the last 50; anything that is not a JSON array is started again; if storage throws on both tries, the choice stands unlogged (D10). The POST is `navigator.sendBeacon` with a `text/plain` Blob (it survived an immediate reload, S6), falling back to `fetch` with `keepalive` and `mode: 'no-cors'`. A failure never blocks the choice.
82
+ - **The log survives withdrawal** (D8). `clearServices()` skips `<cookie.name>_log` whatever a service's pattern matches (a `kiss_*` storage pattern took it before), and `resolveConsent` refuses a service that declares the name outright.
83
+ - **The client renders the confirmation itself** (`role="status"`, `tabindex="-1"`, focused, with a Hide button). An override partial therefore needs none.
84
+ - **A bottom-positioned banner or confirmation keeps the page reachable under it** (WCAG 2.2 SC 2.4.11). All three measures are measured by `test/browser/consent.test.js`, which was red before them:
85
+ - **A spacer at the end of `<body>`:** `[data-kiss-consent-spacer]`, `aria-hidden`, the bar's height. It is moved to the end again at `DOMContentLoaded`, because it is placed while the page is still parsing. Without it the page's last content, the footer's Cookie settings control included, could never scroll clear of the bar, and a click on it failed.
86
+ - **`scroll-padding-bottom` on `<html>`**, for scrolling to a fragment.
87
+ - **A `focusin` check** that scrolls a focused element clear of the bar. Chromium does not scroll an element that is inside the viewport but under the bar when focus moves to it, so `scroll-padding` alone left links hidden under the banner.
88
+
89
+ All three follow the bar's height through `ResizeObserver`, and are removed with the bar. They apply only while the bar's computed `position` is `fixed`. A site's own banner partial may sit in the flow whatever `ui.position` says, and then nothing is added (the browser suite checks the override partial gets no spacer).
90
+
91
+ - **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`).
92
+ - **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).
93
+ - **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.
94
+ - **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'`.
95
+ - **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.
96
+ - **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.
@@ -0,0 +1,64 @@
1
+ # consent-gate.js
2
+
3
+ ## Responsibility
4
+
5
+ The consent manager's server side:
6
+
7
+ - the markup that holds a service back until the visitor allows it: gated scripts and click-to-load embeds;
8
+ - the check that a rendered page is wired so that it can be.
9
+
10
+ The helpers in `lib/handlebars-helpers.js` call the first; `lib/kiss-page.js` calls the second on every rendered HTML page. The attribute vocabulary is the contract's (`planning/plans/2026-11-02-consent-manager.md` §3), and `lib/consent-client.js` reads the same names.
11
+
12
+ ## Public interface
13
+
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]>`.
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
+ - **A body (block form) or a rendered partial of a gated service:** every `<script>` in it is rewritten into that form:
18
+ - `src` becomes `data-src`, `async` becomes `data-async`, and `type="module"` becomes `data-type="module"`;
19
+ - `defer` is dropped, because the queue already runs in document order;
20
+ - other attributes are kept;
21
+ - attribute values are read decoded, as a browser reads them, and escaped once on the way out, so `src="/s.js?a=1&amp;b=2"` stays exactly that. Until 2026-11-03 they were read raw and escaped again, which wrote `&amp;amp;` and changed the URL loaded after consent (found by the Codex review);
22
+ - everything around the scripts is kept byte for byte, and a data block (`application/ld+json`) is left alone;
23
+ - which scripts are rewritten is `scriptKind` (`lib/consent.js`): every type a browser runs, `application/x-javascript`, `text/javascript1.5`, a padded `text/javascript` and `language="javascript"` included, and `language` is dropped with `type`. Until 2026-11-03 a hand-written list of six types let those through ungated, and the wiring check and the audit each had a copy of it (found by the Codex review).
24
+
25
+ A body with no `<script>` tag is taken as one script's text.
26
+
27
+ - **Not gated:** a `necessary` service, or a Google tag under `google.mode: 'advanced'`, is written as a plain `<script>`.
28
+ - **Throws** on:
29
+ - a missing `{param}` value;
30
+ - `document.write` or `writeln` in an inline body;
31
+ - an `<iframe>` in a block or partial;
32
+ - a service with nothing to load.
33
+ - **`embedMarkup(name, service, { consent, params, src, title })` → `string`.** What `{{consentEmbed}}` writes:
34
+ - a `div.kiss-embed[data-kiss-embed][data-kiss-consent]` holding a `[data-kiss-consent-ui]` notice with **Load it** and **Always allow** buttons (wording from `ui.text`, filled in one pass: `{provider}` with the provider, `{service}` with the label. The default buttons name the service, since the grant is for it alone: "Always allow Google" on a YouTube video read as all of Google, 2026-11-04);
35
+ - the provider's own link (`embed.watch`), omitted when it has none or a placeholder cannot be filled;
36
+ - the iframe inside `<template data-kiss-embed-frame>`.
37
+
38
+ A `necessary` service gets the plain iframe. **Throws** with no title, or no src.
39
+
40
+ - **`consentWiringProblems(html)` → `string[]`.** One sentence per problem:
41
+ 1. gated markup, or a settings control, with no consent head;
42
+ 2. a head with no banner, or more than one head or banner;
43
+ 3. an executable script before the head's;
44
+ 4. one service's script `src` twice on a page.
45
+ - **`findDocumentWrite(js)` → `boolean`.**
46
+
47
+ ## Depends on
48
+
49
+ `htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `gatingOf`).
50
+
51
+ ## Depended on by
52
+
53
+ `lib/handlebars-helpers.js` (`consentScript`, `consentEmbed`); `lib/kiss-page.js` (`consentWiringProblems`). Tests: `test/unit/consent-gate.test.js`, and `test/integration/consent.test.js`, which builds `test/helpers/consent-site.js`.
54
+
55
+ ## Non-obvious behavior
56
+
57
+ - **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.
58
+ - **`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.
59
+ - **The wiring check reads the rendered bytes.** It runs on HTML outputs only, before minifying and before the dev live-reload script is added.
60
+ - It catches a hand-written gated tag as surely as a helper's.
61
+ - It fails the page in a production build, with `consent: <problems> (<view>)`, and warns in dev, the `{{link}}` stance. A gated service with no consent head would never run, and a script ahead of the head would run before the Consent Mode default is set.
62
+ - JSON-LD and other data blocks before the head are fine.
63
+ - A page with no `data-kiss-` attribute at all costs one `includes` and is never parsed.
64
+ - **A service loaded twice on one page is a problem,** because the client would run it twice. Two `{{consentEmbed}}` of one service are not: each embed is its own frame.
@@ -0,0 +1,37 @@
1
+ # consent-record.js
2
+
3
+ ## Responsibility
4
+
5
+ The visitor's consent history: what `{{consentRecord}}` writes on a site's privacy page. The build writes everything that does not depend on the visitor (the heading, what the history is, what each choice sends when the site has an endpoint, an empty table, and the states it can be in). A small script of its own, inlined after it, fills the table from `<cookie.name>_log`, the log `lib/consent-client.js` appends every choice to. The spec is `planning/specs/2026-11-03-kiss-consent.md` (D2–D7, D9, D10).
6
+
7
+ ## Public interface
8
+
9
+ - **`RECORD_ID`**, `'kiss-consent-history'`: the section's `id`, which the settings dialog's `ui.history` link carries as its fragment, so a visitor lands on the history, not the top of a long privacy page (the clean room, 2026-11-03). The audit's `consent-history-missing` reports a `ui.history` page without the section.
10
+ - **`recordHtml(resolved)` → `string`.** `<section class="kiss-consent-record" id="kiss-consent-history" data-kiss-consent-record>`:
11
+ - `<h2>` `historyTitle`, a `<p>` `historyIntro`, and with an `endpoint` a `<p>` `historySent`;
12
+ - `<div data-kiss-consent-ui>` holding everything that changes with the visitor: a `<noscript>` note, the id line (`[data-kiss-consent-record-id]`), the empty and unavailable states (`[data-kiss-consent-record-empty]`, `[data-kiss-consent-record-unavailable]`), and the table in the cookie table's focusable scrolling region (`[data-kiss-consent-record-rows]`), all `hidden`;
13
+ - the table's head: When, Choice, one column per category in use (its label), Services allowed, Services refused, Purposes version; `<tbody>` empty;
14
+ - `<script data-kiss-consent-record-script>(kissConsentRecord)(window, recordConfig)</script>`.
15
+ - **`recordConfig(resolved)` → `{ log, categories: [name, label][], services: { name: label }, text }`.** What the script is handed: the log's name, the labels it prints (D2), and the seven `ui.text` strings it writes in the browser. No URL.
16
+ - **`kissConsentRecord(win, cfg)`**, exported for the tests: the browser side.
17
+
18
+ ## Depends on
19
+
20
+ `./consent-ui.js` (`escapeHtml`), `./consent-client.js` (`inlineJson`, the same script-safe JSON the head client's config uses). `lib/handlebars-helpers.js`'s `consentRecord` helper calls `recordHtml` and returns nothing on a packaged page.
21
+
22
+ ## Depended on by
23
+
24
+ `lib/handlebars-helpers.js` (`{{consentRecord}}`). `test/unit/consent-record.test.js` runs the markup and the script in a domino DOM with a fake `localStorage`, as rendered and terser-minified. `test/integration/consent.test.js` builds a site with the viewer on its privacy page (its own script on that page alone, the Markdown copy), and `test/browser/consent.test.js` follows the dialog's `ui.history` link in Chromium and reads the rows.
25
+
26
+ ## Non-obvious behavior
27
+
28
+ - **The labels are in the viewer's config, not the head's.** D2 as agreed put them in `clientConfig()`, inlined on every page; the operator moved them here at the viewer slice (2026-11-03, the spec's Amendments), because only this page prints them, and the head script stays the size it was.
29
+ - **Rows are built with DOM calls and `textContent`, never HTML.** The log is the visitor's to edit, so an `action`, a `timestamp` or a service name that holds markup is printed as text (a unit test feeds it `<img onerror>` and `<script>`). An unknown action, a missing category or a category-less record prints `—`, and so does a category whose answer is not a boolean; a service is listed as allowed only for `true` and as refused only for `false`, what the client writes (a tampered `'<b>x</b>'` read as "Allowed" until a Chromium walk of example 14 found it, 2026-11-03). A service is named by the label the site gives it now, or by the record's name for one the site no longer declares; a log that is not JSON, not an array, or holds non-objects is read as empty or skipped.
30
+ - **The date locale is the visitor's, for the page's language.** D6 said "the page's `lang`", and Chromium showed that alone gives an en-GB visitor on a `lang="en"` page US dates. The script picks the first of `navigator.languages` whose language is the page's, else the page's `lang`, and formats with `dateStyle`/`timeStyle: 'medium'` (seconds included) inside `<time datetime="<UTC ISO>">`.
31
+ - **States:** storage that throws shows `unavailable` and nothing else (D10); an empty log shows `empty`; JavaScript off shows only the `<noscript>` note, since every state starts `hidden`.
32
+ - **The id is the newest record's, and always shown** once the log holds one. The operator's rule (2026-11-03): the visitor's copy is as close to the server's record as it can be, a self-service data takeout, and the id is what a visitor quotes in a data request. It was first built to show only with an endpoint, on the reading that a site with none holds nothing to look up; the operator overruled that. `historyId`'s wording ("carried by every record of your choices") is true with or without an endpoint. Only `historySent` depends on one, because it states what is sent.
33
+ - **What it says is sent, and only that.** `historySent` lists the fields kiss posts, a fact kiss knows. What the site's server keeps (its web server logs included) is the site's to say in its privacy notice, so nothing here claims it.
34
+ - **A version change between two rows** inserts `[data-kiss-consent-record-changed]`, one cell spanning every column (D7).
35
+ - **No delete control** (D9).
36
+ - **It redraws on `kiss-consent:change`**, so a choice made from this page's own footer shows without a reload. A withdrawal reloads the page anyway.
37
+ - **It finds its section from `document.currentScript`**, falling back to the first `[data-kiss-consent-record]`.
@@ -0,0 +1,62 @@
1
+ # consent-ui.js
2
+
3
+ ## Responsibility
4
+
5
+ The consent manager's default presentation, as pure string functions over the resolved `config.consent` (`KissConsent`, `lib/consent.js`):
6
+
7
+ - the banner `{{consentBanner}}` puts in its template when the site has no override partial;
8
+ - the settings dialog;
9
+ - the cookie declaration `{{consentTable}}` writes;
10
+ - the control `{{consentSettings}}` writes;
11
+ - the theme CSS `{{consentHead}}` inlines.
12
+
13
+ No Handlebars and no browser. The markup speaks the attribute vocabulary of contract §3 (`planning/plans/2026-11-02-consent-manager.md`), which `lib/consent-client.js` acts on.
14
+
15
+ ## Public interface
16
+
17
+ - **`escapeHtml(s)`** → `s` with `&`, `<`, `>`, `"` and `'` escaped. Every interpolated string in this module goes through it.
18
+ - **`bannerHtml(resolved, { logoUrl, policyUrl })`** → `<div class="kiss-consent" data-kiss-consent-root data-position="…" role="region" aria-label="{title}">`, the full-width bar. Inside it, `<div class="kiss-consent__inner">` is the centred column (max 72rem) holding:
19
+ - the logo (`<img alt="">`), only with `logoUrl`;
20
+ - the `h2` title and the body text;
21
+ - the policy link, only with `policyUrl`;
22
+ - Accept all and Reject all (`data-kiss-consent-action` `accept`, `reject`), both `class="kiss-consent__button"`, then Choose cookies (`settings`), `class="kiss-consent__more"`, a text button;
23
+ - the ✕ (`class="kiss-consent__close"`, `action="close"`, `aria-label="{close}"`) at the column's corner.
24
+ - **`settingsHtml(resolved, { policyUrl, historyUrl })`** → the `<dialog class="kiss-consent-dialog">`, in three parts (`historyUrl`, `ui.history`'s page, is linked as `<a class="kiss-consent__history">` `historyLink` right after the policy link, only when given; D12):
25
+ - **`__head`:** its `h2` (`id="kiss-consent-dialog-title"`) and a ✕ (`close-dialog`).
26
+ - **`__body`:** one `__category` per category: the `necessary` one first, then each in `inUse`. Each has a `__row` holding a `<label for>` and a **switch**, then the description, then `<ul class="kiss-consent-dialog__services" aria-label="{servicesLabel}">` with one `<li>` per service of that category in name order, "label (provider)", the provider always, even when it repeats the label. A category with no services, `necessary` included, has no list. A switch is `<input type="checkbox" role="switch" class="kiss-consent-switch" id="…">`: `disabled checked` for `necessary`, and `data-kiss-consent-category="…"` otherwise. Under a category, a hidden `__grant` row with a `data-kiss-consent-service` switch for each of its services.
27
+ - **`__foot`:** Accept all, Reject all and Save choices, the three choices together and alike, Accept first as in the banner. Under them, a `__links` line holding the policy link and the history link, each only when given, and no line when neither is (the operator, 2026-11-04).
28
+ - **`tableHtml(resolved)`** → one `<table class="kiss-consent-table">` for `necessary`, then one per category in `inUse`, each inside a `<div class="kiss-consent-table-wrap" role="region" tabindex="0">` that scrolls sideways on a phone. Columns: Service, Provider, Purpose, Name, Kind (Cookie / Local storage), Duration, Party (First / Third). The provider is linked to the service's `policy`, its privacy policy, when it has one, in the table and in the dialog's lists alike (`providerHtml`). Cookiebot's declaration does the same, and kiss had the URL and showed it nowhere (the operator, 2026-11-04). `resolveConsent` allows only an http(s) URL there, so the href needs only escaping. A linked provider reads "( Google )" in `textOf`, which turns every tag into a space. So the registry's text and the hash take that spacing.
29
+ - **`settingsControlHtml(resolved)`** → `<button type="button" class="kiss-consent-settings" data-kiss-consent-action="settings">`.
30
+ - **`themeCss(ui)`** → the CSS, or `''` under `ui.theme: false`.
31
+
32
+ ## Depends on
33
+
34
+ `lib/consent.js`, for types only.
35
+
36
+ ## Depended on by
37
+
38
+ - `lib/handlebars-helpers.js` (the `consentHead`, `consentBanner`, `consentSettings` and `consentTable` helpers) and `lib/consent-record.js` (`escapeHtml`).
39
+ - `test/unit/consent-ui.test.js`.
40
+
41
+ ## Non-obvious behavior
42
+
43
+ - **Nothing to ask, nothing rendered.** With no category in use, `bannerHtml` and `settingsHtml` return `''`. `tableHtml` still writes the `necessary` table, because kiss's own cookie is set either way.
44
+ - **Equal weight is structural.** Accept and Reject share one class and the CSS has one rule for it, with no primary variant (the ICO: "equally prominent"). A test pins both. Choose cookies is a different thing, `kiss-consent__more`: a text button in the accent colour, as Google's own consent screen sets "More options" beside its two equal buttons (the operator's call, 2026-11-03). The ICO's equal-prominence rule is about accepting and refusing; a way to choose in detail may look different.
45
+ - **The dialog names the services, so the hash does.** The ICO: name every controller relying on the consent, because consent for categories of third-party controllers is not specific enough. `lib/handlebars-helpers.js` hashes the banner plus this dialog, so the list is in every banner hash and in `consent-banners.json`'s `text`. A provider changed in config moves the hash, which is the point: a record names what the visitor read. Before 2026-11-04 the text held each label once, from the hidden grant rows, and no provider. The list's rule shares the description's selector to save bytes. The footer's links are a `__links` line, ruled off from the choices and centred. That took the CSS to 7,204 bytes, and the operator raised the ceiling from 7,168 to 7,424.
46
+ - **The switches carry no `checked`** (except `necessary`'s). The client sets each from the stored choice, and from an `objection` category's default, so the markup is the same for every visitor and the banner hash does not depend on state.
47
+ - **The grant toggles are hidden in the markup.** The client unhides the one for a service with an "always allow" grant, so a grant can be withdrawn where it is shown. Every service of a category in use gets one, not only a service with an `embed` block: `{{consentEmbed}}` takes any service with `src=` and offers "Always allow" for it, and until 2026-11-03 such a grant had no switch, so Save kept it with its category unticked (round 7 of the Codex review).
48
+ - **The table always starts with kiss's own three items:** `cookie.name` (`{days} days`), `<cookie.name>_closed` (Session; `kiss_consent_closed` by default) and the consent log, `<cookie.name>_log` (Local storage, Until cleared; D11), all first-party, both derived names being the ones `lib/consent-client.js` derives. A service that stores nothing gets one row with `—` for name, kind, duration and party, so every declared service is listed. Services are in name order; a stored item with no description takes its service's.
49
+ - **What the client does instead.** The confirmation after a choice and the bottom banner's `scroll-padding-bottom` are the client's (contract §3). The CSS styles `[data-kiss-consent-confirmation]` by the `data-position` the client copies onto it.
50
+ - **A top banner is `position:relative`, not `static`.** It stays in the flow as the contract says, and anchors the absolutely placed close button. A bottom banner is fixed, `z-index:2147483000`, `max-height:80vh` and scrolls.
51
+ - **The look was restyled on 2026-11-03,** when the operator judged diploma-msc's Cookiebot banner better looking. The changes:
52
+ - a centred inner column on the full-width bar, with a shadow and a top border;
53
+ - quieter body text, mixed from the theme's own `text` and `background` (`color-mix`), so dark schemes follow with no third colour;
54
+ - full-width stacked choices under 40rem;
55
+ - a dialog with a header, divided rows, real switches and a footer;
56
+ - a calmer embed notice.
57
+
58
+ Two things were deliberately not taken from Cookiebot: its filled "Allow all" beside an outlined "Deny" (the ICO calls unequal buttons nudging), and its category switches on the first layer (the operator kept them in the dialog). The CSS was about 6.7 kB after it, from 4 kB before. It is 7,251 bytes since the 2026-11-04 dialog changes and the provider policy links, under a 7,424-byte ceiling in the test.
59
+
60
+ - **The switches are styled checkboxes**, so the client reads and sets `.checked` as before, and assistive technology announces them as switches (`role="switch"`, each with a `<label for>`). Off and on differ in colour (the softened text colour against the accent) and in where the knob sits.
61
+ - **Every element styled with a theme variable defines them.** The `--kiss-consent-*` properties are set on the banner, the dialog, the embeds, the confirmation, and also the footer's settings button and the cookie table's scrolling region, which sit outside the other four. Until 2026-11-03 those two did not, so their focus outline read an undefined accent and the whole declaration fell away: no outline for a keyboard user (found by the Codex review; `test/browser/consent.test.js` measures it).
62
+ - **The CSS takes theme values verbatim.** `resolveConsent` has already refused `;`, `{`, `}`, `<` and `>` in them, so no value can end a declaration or the `<style>`. The CSS has no `!important`, `@import` or `url(`, and switches transitions and animations off under `prefers-reduced-motion`.