kiss-ssg 2.8.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 (147) hide show
  1. package/AIKB/aikb.md +2 -2
  2. package/AIKB/audit.md +29 -8
  3. package/AIKB/base.md +34 -0
  4. package/AIKB/build-finish.md +9 -1
  5. package/AIKB/build-report.md +13 -8
  6. package/AIKB/check.md +6 -1
  7. package/AIKB/config.md +5 -3
  8. package/AIKB/consent-client.md +96 -0
  9. package/AIKB/consent-gate.md +64 -0
  10. package/AIKB/consent-record.md +37 -0
  11. package/AIKB/consent-ui.md +62 -0
  12. package/AIKB/consent.md +76 -0
  13. package/AIKB/feed.md +2 -2
  14. package/AIKB/git.md +32 -0
  15. package/AIKB/handlebars-helpers.md +16 -7
  16. package/AIKB/init.md +10 -3
  17. package/AIKB/kiss-page.md +1 -0
  18. package/AIKB/kiss.md +2 -1
  19. package/AIKB/links.md +5 -4
  20. package/AIKB/llms.md +3 -2
  21. package/AIKB/markdown-copy.md +1 -1
  22. package/AIKB/partials.md +1 -0
  23. package/AIKB/rebuild.md +2 -0
  24. package/AIKB/robots.md +4 -2
  25. package/AIKB/scorm-bridge.md +4 -3
  26. package/AIKB/sessions.md +44 -0
  27. package/AIKB/sitemap.md +4 -3
  28. package/AIKB/testing.md +13 -5
  29. package/AIKB/upgrade.md +53 -0
  30. package/AIKB/upstream.md +110 -0
  31. package/CHANGELOG.md +625 -34
  32. package/GUIDE.md +513 -43
  33. package/README.md +26 -17
  34. package/bin/kiss-ssg.js +272 -14
  35. package/examples/1-scan/src/layouts/layout.hbs +5 -3
  36. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  37. package/examples/10-asset-pipeline/src/layouts/layout.hbs +5 -3
  38. package/examples/11-blog/AIKB/last-build.json +133 -73
  39. package/examples/11-blog/AIKB/notes/controllers/post.md +5 -5
  40. package/examples/11-blog/AIKB/notes/controllers/tag.md +5 -5
  41. package/examples/11-blog/AIKB/site-map.json +111 -111
  42. package/examples/11-blog/AIKB/site-map.md +32 -32
  43. package/examples/11-blog/README.md +88 -57
  44. package/examples/11-blog/router.js +63 -26
  45. package/examples/11-blog/src/controllers/post.js +4 -4
  46. package/examples/11-blog/src/controllers/tag.js +1 -1
  47. package/examples/11-blog/src/layouts/layout.hbs +7 -4
  48. package/examples/11-blog/src/models/posts/2025-12-15-a-year-of-bench-lots.json +1 -0
  49. package/examples/11-blog/src/models/posts/2026-01-06-grind-size-is-the-dial.json +2 -1
  50. package/examples/11-blog/src/models/posts/2026-01-20-why-we-roast-on-wednesdays.json +1 -0
  51. package/examples/11-blog/src/models/posts/2026-02-03-guji-uraga-has-landed.json +2 -1
  52. package/examples/11-blog/src/models/posts/2026-02-18-the-cascara-experiment.json +3 -3
  53. package/examples/11-blog/src/models/posts/2026-03-02-pour-over-at-home.json +2 -1
  54. package/examples/11-blog/src/pages/404.hbs +2 -2
  55. package/examples/11-blog/src/pages/index.hbs +14 -13
  56. package/examples/11-blog/src/pages/{blog → journal}/listing.hbs +4 -4
  57. package/examples/11-blog/src/pages/{blog → journal}/post.hbs +13 -12
  58. package/examples/11-blog/src/pages/{blog → journal}/tag.hbs +1 -1
  59. package/examples/11-blog/src/pages/{blog → journal}/tags.hbs +3 -3
  60. package/examples/11-blog/src/partials/post-card.hbs +2 -2
  61. package/examples/13-course-packages/README.md +24 -6
  62. package/examples/13-course-packages/router.js +6 -4
  63. package/examples/13-course-packages/src/assets/css/course.css +178 -6
  64. package/examples/13-course-packages/src/layouts/lesson.hbs +5 -3
  65. package/examples/13-course-packages/src/models/milk.json +44 -0
  66. package/examples/13-course-packages/src/pages/espresso/extraction.hbs +1 -1
  67. package/examples/13-course-packages/src/pages/espresso/grind.hbs +1 -1
  68. package/examples/13-course-packages/src/pages/milk/index.hbs +66 -35
  69. package/examples/13-course-packages/src/partials/question.hbs +35 -0
  70. package/examples/13-course-packages/src/partials/quiz-script.hbs +155 -0
  71. package/examples/14-consent/README.md +42 -0
  72. package/examples/14-consent/router.js +127 -0
  73. package/examples/14-consent/src/assets/css/site.scss +521 -0
  74. package/examples/14-consent/src/assets/img/icon.png +0 -0
  75. package/examples/14-consent/src/assets/img/logo.svg +4 -0
  76. package/examples/14-consent/src/assets/img/og-card.png +0 -0
  77. package/examples/14-consent/src/assets/js/visits.js +17 -0
  78. package/examples/14-consent/src/layouts/layout.hbs +74 -0
  79. package/examples/14-consent/src/pages/404.hbs +18 -0
  80. package/examples/14-consent/src/pages/cookies.hbs +28 -0
  81. package/examples/14-consent/src/pages/index.hbs +47 -0
  82. package/examples/14-consent/src/pages/privacy.hbs +55 -0
  83. package/examples/14-consent/src/pages/roasting.hbs +23 -0
  84. package/examples/2-page/src/layouts/layout.hbs +5 -3
  85. package/examples/3-pages/src/layouts/layout.hbs +5 -3
  86. package/examples/4-layouts-and-partials/src/partials/site/header.hbs +1 -1
  87. package/examples/5-helpers/src/layouts/layout.hbs +5 -3
  88. package/examples/6-sitemap/README.md +7 -5
  89. package/examples/6-sitemap/router.js +11 -10
  90. package/examples/6-sitemap/src/layouts/layout.hbs +5 -3
  91. package/examples/6-sitemap/src/models/stockists.json +2 -1
  92. package/examples/6-sitemap/src/pages/index.hbs +9 -8
  93. package/examples/7-versioned-outputs/src/layouts/layout.hbs +5 -3
  94. package/examples/8-data-fed-site/src/layouts/layout.hbs +5 -3
  95. package/examples/9-migrated-from-v1/AIKB/last-build.json +21 -14
  96. package/examples/9-migrated-from-v1/src/layouts/layout.hbs +5 -3
  97. package/examples/README.md +5 -4
  98. package/lib/aikb.js +1 -1
  99. package/lib/audit.js +878 -13
  100. package/lib/base.js +216 -0
  101. package/lib/build-finish.js +139 -3
  102. package/lib/build-report.js +88 -1
  103. package/lib/check.js +99 -8
  104. package/lib/config.js +25 -4
  105. package/lib/consent-client.js +1142 -0
  106. package/lib/consent-gate.js +396 -0
  107. package/lib/consent-record.js +255 -0
  108. package/lib/consent-ui.js +455 -0
  109. package/lib/consent.js +1432 -0
  110. package/lib/feed.js +7 -3
  111. package/lib/git.js +84 -0
  112. package/lib/handlebars-helpers.js +334 -10
  113. package/lib/init.js +113 -11
  114. package/lib/kiss-page.js +15 -0
  115. package/lib/kiss.js +43 -8
  116. package/lib/links.js +142 -31
  117. package/lib/markdown-copy.js +4 -1
  118. package/lib/rebuild.js +3 -0
  119. package/lib/robots.js +48 -4
  120. package/lib/scorm-bridge.js +12 -10
  121. package/lib/sessions.js +311 -0
  122. package/lib/sitemap.js +52 -9
  123. package/lib/upgrade.js +746 -0
  124. package/llms.txt +24 -12
  125. package/package.json +3 -2
  126. package/types/audit.d.ts +207 -2
  127. package/types/base.d.ts +50 -0
  128. package/types/build-finish.d.ts +2 -0
  129. package/types/build-report.d.ts +62 -3
  130. package/types/check.d.ts +48 -1
  131. package/types/config.d.ts +21 -4
  132. package/types/consent-client.d.ts +77 -0
  133. package/types/consent-gate.d.ts +68 -0
  134. package/types/consent-record.d.ts +35 -0
  135. package/types/consent-ui.d.ts +62 -0
  136. package/types/consent.d.ts +764 -0
  137. package/types/feed.d.ts +10 -0
  138. package/types/git.d.ts +53 -0
  139. package/types/handlebars-helpers.d.ts +8 -2
  140. package/types/init.d.ts +17 -0
  141. package/types/kiss.d.ts +35 -11
  142. package/types/links.d.ts +30 -8
  143. package/types/robots.d.ts +18 -4
  144. package/types/scorm-bridge.d.ts +5 -4
  145. package/types/sessions.d.ts +130 -0
  146. package/types/sitemap.d.ts +2 -2
  147. package/types/upgrade.d.ts +366 -0
package/AIKB/aikb.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Responsibility
4
4
 
5
- Writes the site's own knowledge base — the folder `config.folders.aikb` names, default `./AIKB` — from one settled build. The third of the three "describe the site" writers: `sitemap.js` writes it for a crawler, `llms.js` for an answer engine, this one for the **next developer**, who is usually the same developer two years later with none of the context left.
5
+ Writes the site's own knowledge base — the folder `config.folders.aikb` names, default `./AIKB` — from one settled build. The third of the three "describe the site" writers: `sitemap.js` writes it for a crawler, `llms.js` for an agent pointed at the site, this one for the **next developer**, who is usually the same developer two years later with none of the context left.
6
6
 
7
7
  Unlike the other two it has no public API call behind it. The only thing that writes this folder is `npx kiss-ssg aikb <script>` — a ceremony performed once at the end of a piece of work — which sets `KISS_AIKB` for the build it spawns. Every other build reads the folder and reports on it, and writes nothing. That is the whole point: `last-build.json` is the baseline `kiss-ssg check` diffs against, so it must move when a piece of work closes and at no other moment, or "what have I changed" silently means "since I last ran a build".
8
8
 
@@ -35,7 +35,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
35
35
 
36
36
  ## Depended on by
37
37
 
38
- `lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by the `kiss-memory` plugin's `kiss-site-brief`, `kiss-branch-pulse` and `kiss-branch-close` skills, and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
38
+ `lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by every skill in the `kiss-memory` plugin: `kiss-site-brief`, `kiss-branch-open`, `kiss-branch-pulse`, `kiss-branch-close`, `kiss-branch-merged` (whose step 4 checks the merged base against `last-build.json`) and `kiss-memory-consolidate` (a grep for `last-build.json` and `kiss-ssg check` across `plugins/kiss-memory/skills/` finds all six), and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
39
39
 
40
40
  ## Non-obvious behavior
41
41
 
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.
package/AIKB/base.md ADDED
@@ -0,0 +1,34 @@
1
+ # base.js
2
+
3
+ ## Responsibility
4
+
5
+ `kiss-ssg base`: the branch a site's piece of work merges into, and the ref to diff against or start new work from. The kiss-memory skills call it rather than ask git themselves.
6
+
7
+ ## Public interface
8
+
9
+ - `parseLsRemote(text)` — the branch `git ls-remote --symref origin HEAD` names, or `null`.
10
+ - `chooseDiffRef(found, behind)` — of the local base and origin's, the one with the fewest commits to HEAD; a tie keeps the local branch.
11
+ - `chooseStart(facts, name)` — `{ ref, reason }`: where new work starts.
12
+ - `runBase(argv, { cwd })` — the whole command: `{ out, err, code }`.
13
+ - `BASE_HELP` — the command's help text.
14
+
15
+ ## Depends on
16
+
17
+ `lib/git.js`, `lib/sessions.js`.
18
+
19
+ ## Depended on by
20
+
21
+ `bin/kiss-ssg.js` (`kiss-ssg base`), and through it the `kiss-memory` skills.
22
+
23
+ ## Non-obvious behavior
24
+
25
+ - **The base is the session log's `base:` line first**, then the remote's default branch. The default comes from the clone's record (`refs/remotes/origin/HEAD`), else from asking origin (`ls-remote --symref`), because a remote added by hand has no record. With neither, the command exits 1 and says to ask: it never assumes `main`. A merge skill that knows better (a pull request's `baseRefName`, which a retargeted request changes) uses that instead; the skill says so.
26
+ - **Where the name came from is printed on stderr**, and stdout carries the bare answer, so a skill can read either.
27
+ - **`--ref` picks the ref this work started from.** `git fetch` never moves a local branch, so a stale local base would put everyone else's newer commits in the diff, while a local base with unpushed commits is the one the work came from. It measures from the work's own commits: the named branch's tip when a branch is named (the local branch, or origin's copy), and `HEAD` only for the current one. Counting to `HEAD` for a named branch measured whatever this checkout stood on, and named a stale local base from a checkout on it (code review at the close, 2026-10-31).
28
+ - **`--start` starts from origin's base** unless only the local one exists or it has commits origin lacks; when the two have diverged it exits 1, because which to start from is the person's call.
29
+ - **`--base <name>` skips the lookup.** `kiss-branch-open` creates a worktree before it writes the session log, so at that moment no log can name the base the person chose; without the option, `--start` would answer for the remote's default instead. It does not combine with `--from-branch`, which only says where to look the base up.
30
+ - **Every ref is printed in full**, so a tag of the same name (`v2`) is never read in its place. With the short name, `git pull origin v2` fetched the tag and said "Already up to date" while the base stayed behind (measured, 2026-10-31).
31
+
32
+ ## Types
33
+
34
+ Returns `import('./sessions.js').CommandResult`.
@@ -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.
@@ -18,17 +18,21 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
18
18
  - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
19
19
  - `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
20
20
  - `aikb` — `null` when the site has no knowledge base to report on, and otherwise `{ folder, written, notes: { missing, dead, stale, dangling }, subjects }` as `lib/aikb.js` evaluated it (`AIKB/aikb.md`). Appended after `llms`, for the same reason. It is passed through **unmapped**: the knowledge base is source-side and committed, so it is never staged and its paths are already the ones a person would type.
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 }] }` 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.
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 four link/redirect findings: ` broken link: <page> -> <href>`, ` 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`.
@@ -49,7 +54,7 @@ Nothing.
49
54
  - **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
50
55
  - **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
51
56
  - **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
52
- - **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. `lib/robots.js` has the rest.
57
+ - **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. Since 2.8.6 `disallowAll` means `*` or a search engine's crawler is blocked, and `blocked` (appended after it) names every agent kept out of the whole site; an older record has no `blocked`. `lib/robots.js` has the rest.
53
58
 
54
59
  - **The four derived-output keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
55
60
  - **`redirects.file`, `redirects.json`, every `redirects.files[]`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved`/`rules` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map. `rules` is URL paths on both sides for the same reason.
package/AIKB/check.md CHANGED
@@ -25,6 +25,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
25
25
  - `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
26
26
 
27
27
  - `describeEngine({ cwd, from = cwd })` → `{ version, dir, linked, target } | null` — the nearest `node_modules/kiss-ssg` walking up from `from`, which the bin sets to the **script's** folder because that is where Node resolves its imports (a caller standing elsewhere would otherwise be told about its own copy — Codex, on review), with `dir` relative to `cwd`: its `package.json` version, its path relative to `cwd`, whether `lstat` says it is a symbolic link (a Windows junction counts), and the link's real target when it is. `null` when there is none above `cwd`.
28
+ - `isWorkingTreeLink(engine)` → whether that engine is a link to a working tree: a link whose target is outside any `node_modules`. The one statement of the rule: `engineLine` warns on it, and `lib/upgrade.js` refuses to start from it and fails an install that leaves it.
28
29
  - `engineLine(engine)` → the line the bin prints on stderr before the build, in both output modes: `kiss-ssg 2.5.0 from node_modules/kiss-ssg`, or `… — a link to C:/Code/kiss-ssg, not the registry package` for a target outside any `node_modules` (a working tree), or the neutral `… — a link into <target>` for one inside (pnpm lays every package out as a link into `node_modules/.pnpm/…`, and that is the registry package — Codex, on the close's review), or the honest `kiss-ssg: no node_modules/kiss-ssg found from here — the script resolves the package some other way`.
29
30
 
30
31
  ## Depends on
@@ -33,7 +34,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
33
34
 
34
35
  ## Depended on by
35
36
 
36
- `bin/kiss-ssg.js`. And the engine, for the other half of the protocol the bin starts:
37
+ `bin/kiss-ssg.js`; `lib/upgrade.js` (`isWorkingTreeLink`). And the engine, for the other half of the protocol the bin starts:
37
38
  `lib/kiss.js` reads `checkModeRequested()` in its constructor, and `lib/build-finish.js` reads
38
39
  `aikbRecordRequested()` and `readReportsFile()` (the redirects baseline, read by the same code
39
40
  `check --against` uses).
@@ -54,6 +55,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
54
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.
55
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.
56
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.
57
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.
58
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`.
59
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.