kiss-ssg 2.12.0 → 2.13.3
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.
- package/AIKB/aikb.md +9 -7
- package/AIKB/audit.md +5 -3
- package/AIKB/build-finish.md +2 -1
- package/AIKB/build-report.md +4 -3
- package/AIKB/cache-headers.md +4 -1
- package/AIKB/config.md +9 -5
- package/AIKB/consent-client.md +11 -3
- package/AIKB/consent-gate.md +5 -3
- package/AIKB/consent.md +5 -2
- package/AIKB/design.md +2 -2
- package/AIKB/embed.md +15 -3
- package/AIKB/fetch-policy.md +18 -6
- package/AIKB/handlebars-helpers.md +2 -2
- package/AIKB/kiss-page.md +5 -3
- package/AIKB/kiss.md +5 -4
- package/AIKB/model-resolver.md +9 -6
- package/AIKB/page-registry.md +15 -7
- package/AIKB/pipeline.md +3 -2
- package/AIKB/rebuild.md +2 -2
- package/AIKB/redirects.md +1 -1
- package/AIKB/security-headers.md +37 -0
- package/AIKB/speed.md +1 -1
- package/AIKB/testing.md +2 -3
- package/AIKB/upstream.md +57 -8
- package/AIKB/utils.md +4 -1
- package/AIKB/zip.md +1 -1
- package/CHANGELOG.md +306 -0
- package/GUIDE.md +104 -24
- package/README.md +4 -3
- package/examples/1-scan/router.js +0 -1
- package/examples/10-asset-pipeline/router.js +3 -4
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/11-blog/AIKB/last-build.json +2 -1
- package/examples/11-blog/router.js +2 -3
- package/examples/14-consent/router.js +0 -1
- package/examples/2-page/router.js +9 -2
- package/examples/3-pages/router.js +2 -3
- package/examples/4-layouts-and-partials/router.js +0 -1
- package/examples/5-helpers/router.js +0 -1
- package/examples/6-sitemap/router.js +0 -1
- package/examples/7-versioned-outputs/router.js +0 -1
- package/examples/8-data-fed-site/router.js +0 -1
- package/examples/9-migrated-from-v1/AIKB/last-build.json +4 -3
- package/examples/9-migrated-from-v1/router.js +3 -4
- package/examples/9-migrated-from-v1/src/pages/index.hbs +1 -1
- package/examples/9-migrated-from-v1/src/pages/smaller-changes.hbs +3 -3
- package/lib/aikb.js +45 -25
- package/lib/audit.js +32 -13
- package/lib/build-finish.js +79 -16
- package/lib/build-report.js +7 -0
- package/lib/cache-headers.js +89 -17
- package/lib/config.js +140 -6
- package/lib/consent-client.js +62 -10
- package/lib/consent-gate.js +5 -2
- package/lib/consent.js +78 -5
- package/lib/embed.js +200 -15
- package/lib/fetch-policy.js +262 -28
- package/lib/handlebars-helpers.js +82 -25
- package/lib/init.js +1 -1
- package/lib/kiss-page.js +28 -3
- package/lib/kiss.js +30 -4
- package/lib/model-resolver.js +18 -6
- package/lib/page-registry.js +166 -81
- package/lib/pipeline.js +22 -8
- package/lib/redirects.js +11 -2
- package/lib/security-headers.js +233 -0
- package/lib/utils.js +102 -0
- package/llms.txt +31 -27
- package/package.json +14 -14
- package/types/aikb.d.ts +15 -8
- package/types/audit.d.ts +13 -1
- package/types/build-report.d.ts +8 -1
- package/types/cache-headers.d.ts +55 -2
- package/types/config.d.ts +82 -11
- package/types/consent.d.ts +45 -1
- package/types/embed.d.ts +47 -5
- package/types/kiss-page.d.ts +18 -0
- package/types/kiss.d.ts +29 -0
- package/types/page-registry.d.ts +28 -1
- package/types/pipeline.d.ts +12 -2
- package/types/redirects.d.ts +6 -2
- package/types/security-headers.d.ts +116 -0
- package/types/utils.d.ts +37 -0
package/AIKB/aikb.md
CHANGED
|
@@ -15,14 +15,14 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
15
15
|
## Public interface
|
|
16
16
|
|
|
17
17
|
- `isRecorded(folder)` → whether `<folder>/site-map.json` exists. The opt-in test, and synchronous: `lib/kiss.js` asks it once per settled build.
|
|
18
|
-
- `classifyModel(model)` → `file:<name>` / `folder:<name>` / `url:<url>` / `inline` / `none`, from the **registered** model option. The same three string tests `resolveModel` makes, in the same order.
|
|
18
|
+
- `classifyModel(model)` → `file:<name>` / `folder:<name>` / `url:<url>` / `inline` / `none`, from the **registered** model option. The same three string tests `resolveModel` makes, in the same order; the URL test is the one both import, `isUrlModel` (`lib/utils.js`). The `<url>` is written by `redactUrl` (`lib/utils.js`): no credentials, no fragment, each query value `[redacted]` — `https://cdn.example.com/entries?access_token=SECRET&type=course` is recorded as `url:https://cdn.example.com/entries?access_token=[redacted]&type=[redacted]`.
|
|
19
19
|
- `classifyController(controller)` → `file:<name>` / `inline` / `none`, from the registered controller option.
|
|
20
20
|
- `pageOrigin(options)` → `{ model, controller }`. What `lib/kiss.js` calls in `page()`, before the chain replaces `options.model` with the resolved data.
|
|
21
|
-
- `buildSiteMap({ stack, graph, config, pipeline, buildDir, stagingDir, readSubject })` → `SiteMap`: `{ site, pages[], partials{}, models[], controllers[], pipeline[], subjects[] }`, every list sorted. `pipeline` defaults to `config.assets.pipeline
|
|
21
|
+
- `buildSiteMap({ stack, graph, config, pipeline, buildDir, stagingDir, readSubject })` → `SiteMap`: `{ site, pages[], partials{}, models[], controllers[], pipeline[], subjects[] }`, every list sorted. A `pipeline[]` row is `{ name, program }`: the step's name (or the first word of its `run`) and the first word of its `run`, never the command (until 2026-11-07 it was `{ name, run }`, the whole command). `pipeline` defaults to `config.assets.pipeline`, and is also what a step's subject hash is taken from; `readSubject(kind, id)` defaults to a reader that reads `folders.controllers/<id>` off disk.
|
|
22
22
|
- `renderSiteMap(map)` → the markdown of `site-map.md`, one trailing newline. Its `## Pages` table is `Output | Id | View | Model | Controller | Partials & layouts`, one row per page, `none` in any cell the page has nothing for.
|
|
23
23
|
- `renderAikbReadme()` → the markdown of `AIKB/README.md`.
|
|
24
24
|
- `noteSubjects(map)` → `{ kind, id }[]`, sorted by kind then id: every controller **file**, every **URL** model, every pipeline step. `map.subjects` is this list with `note` and `hash` added.
|
|
25
|
-
- `notePathFor(subject, notesDir = 'notes')` → the note's path. `stockist.js` → `notes/controllers/stockist.md`; `https://api.example.com/v2/events?x=
|
|
25
|
+
- `notePathFor(subject, notesDir = 'notes')` → the note's path. `stockist.js` → `notes/controllers/stockist.md`; `https://api.example.com/v2/events?x=[redacted]` → `notes/models/api.example.com-v2-events.md`; `tailwind` → `notes/pipeline/tailwind.md`.
|
|
26
26
|
- `readFrontmatter(text)` → the `---` block's `key: value` lines as an object, or `null` when there is none. Not a YAML parser — the same handful of lines `test/unit/plugin-manifests.test.js` reads out of a `SKILL.md` header. `subject-hash` is the only key this module reads, and it never writes one.
|
|
27
27
|
- `evaluateNotes(map, notesDir, { aikbDir })` → `{ missing, dead, stale, dangling }`, all four sorted. The first three are note paths built from `notesDir`, so they are the paths a person would type; `dangling` entries are `<note path>: <token>`. `aikbDir` is what lets `site.md` be scanned and lets a note cite its neighbours.
|
|
28
28
|
- `async writeAikb({ map, folder, logger, write = true })` → `AikbResult` (`{ folder, written, notes, subjects }`). Writes `README.md` (only if absent), `site-map.md` and `site-map.json`. `write: false` evaluates and writes nothing — which is every build but a passing record. A write failure is logged and reported as `written: false`.
|
|
@@ -31,7 +31,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
31
31
|
|
|
32
32
|
## Depends on
|
|
33
33
|
|
|
34
|
-
`fs-extra`; `./utils.js` (`globFiles`, `hashId`, `posixPath`); `./build-report.js` (`reportedPath`, `reportedView` — both exported for this module).
|
|
34
|
+
`fs-extra`; `./utils.js` (`globFiles`, `hashId`, `isUrlModel`, `posixPath`, `redactUrl`); `./build-report.js` (`reportedPath`, `reportedView` — both exported for this module); `./pipeline.js` (`stepName`, `programOf`: the map's pipeline rows carry the same name the report and the log use, and the same program).
|
|
35
35
|
|
|
36
36
|
## Depended on by
|
|
37
37
|
|
|
@@ -48,12 +48,14 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
48
48
|
- **`site-map.json` is the opt-in marker, not the folder.** `isRecorded()` looks for that one file because only a record ever writes it. A folder that merely exists proves nothing: this repository's own `AIKB/` is hand-written module notes, and `docs.js` builds a site inside it with the default `folders.aikb` — if existence were the test, the docs build would adopt those notes as a site map and start reporting every one of them as `dead`.
|
|
49
49
|
- **The map is written after the build settles, not on the promise queue.** `.sitemap()` and `.llms()` need only the registry, which is complete once `_promises` has drained. The partial-per-page index is learned from _rendering_ (`lib/dependency-graph.js`), so a map built at that point would list no partials at all. `Kiss._finishBuild()` is the one place where every page has rendered — which is also where the report is assembled, so the report can carry the verdict.
|
|
50
50
|
- **Each page row carries its `id`, second, because the map is read as the `{{link}}` autocomplete.** An agent writing a template reads the identity straight off the row for the page it can see, which is why the column sits immediately after the output path rather than at the end. It is copied from the stack entry (`entry.id ?? null`) exactly as the report copies it, and a page with no id renders the module's own `NONE` (`none`), the same word the Model and Controller columns use — so an idless page reads as "nothing to link", not as a blank cell. `Kiss` withdraws a default id two pages arrived at before it hands over the stack, so an id on the map is always one `{{link}}` will resolve (`AIKB/kiss.md`).
|
|
51
|
-
- **The model classification comes from the registration, not the stack.** By the time a page is on the stack, `options.model` _is_ the resolved data: a map read off the stack alone would say `inline` for every page in the site. `Kiss.page()` calls `pageOrigin(options)` after its two auto-mappings and hands the result to `_preparePage`, which parks it on the stack entry as `entry.origin`. It is deliberately not a key on `options`: every own key of a registration reaches the render context and the dev-mode `.json` sibling.
|
|
51
|
+
- **The model classification comes from the registration, not the stack.** By the time a page is on the stack, `options.model` _is_ the resolved data: a map read off the stack alone would say `inline` for every page in the site. `Kiss.page()` calls `pageOrigin(options)` after its two auto-mappings and hands the result to `_preparePage`, which parks it on the stack entry as `entry.origin`. It is deliberately not a key on `options`: every own key of a registration reaches the render context and (every key but `config`) the dev-mode `.json` sibling.
|
|
52
52
|
- **The dependency graph is keyed on the path a page was written to, which under `cleanBuild: 'atomic'` is the staging path.** A promotion rewrites each stack entry's `buildTo` to the real folder but leaves the graph alone, so `graph.usesOf(entry.buildTo)` returns `[]` after a successful atomic build. `buildSiteMap` therefore inverts `graph.toJSON()` and maps **both** sides through `reportedPath`, and `Kiss` passes `_stagingDir ?? _promotedFrom` so the prefix survives the promotion.
|
|
53
53
|
- **`README.md` is written once and never overwritten.** It is the one file in the folder that asks the reader to write something; a build that rewrote it every time would be punishing them for doing what it asked.
|
|
54
54
|
- **Nothing under `notes/` is ever written, moved or deleted by the engine.** The four rules are findings on the report and nothing more: they never change `ok`, and `exitCodeFor` does not read them. A strict mode is a deliberate non-goal — a rule that fails a build for a missing paragraph gets a paragraph of filler, not a paragraph of judgement.
|
|
55
|
-
- **A subject's hash is a dependency, not a file read.** Everything else in the module is a pure function of what it was handed, and one subject's hash is the bytes of a file nobody passed in. `buildSiteMap` therefore takes `readSubject(kind, id)` with a default that reads the controller off disk: the engine gets the disk, a unit test gets whatever it says, and the module never reaches for a path on its own initiative. The reader is asked about **controllers only** — a pipeline step's hash is sha1 of its `run` string,
|
|
55
|
+
- **A subject's hash is a dependency, not a file read.** Everything else in the module is a pure function of what it was handed, and one subject's hash is the bytes of a file nobody passed in. `buildSiteMap` therefore takes `readSubject(kind, id)` with a default that reads the controller off disk: the engine gets the disk, a unit test gets whatever it says, and the module never reaches for a path on its own initiative. The reader is asked about **controllers only** — a pipeline step's hash is sha1 of its whole `run` string, read from the `pipeline` steps `buildSiteMap` was handed (the map itself holds only the program), and a URL model has no hash at all.
|
|
56
|
+
- **A pipeline step is recorded by its program, and its hash is of its whole command.** The map, `site-map.md` and the report's `aikb.subjects` are committed or printed, and a command's arguments are where a token typed on the command line sits, so `site-map.json`'s `pipeline[]` and `site-map.md`'s "Asset pipeline" table carry `program`, the first word of `run`, and never `run` (until 2026-11-07 both carried the whole command). Change detection still needs the whole command, so the subject hash stays sha1 of it, taken from the config's steps rather than the map. A sha1 of a command is committed: it does not reveal a token, but it is a fixed function of one, so a token belongs in the environment (`process.env`, which the step's child inherits), not on the command line. The program is split on whitespace, the same fallback the step's name uses, so a quoted program path with a space in it is recorded up to the space (`"C:\Program`). The generated `README.md`'s "The stamp" paragraph says the same, but `README.md` is written only when absent, so an existing site keeps its older wording. Covered by `test/unit/aikb.test.js` and `test/integration/aikb.test.js` (a recorded site whose folder is walked for a token typed into `run`).
|
|
56
57
|
- **A URL model's hash is `null`, and that is not an omission.** Its id _is_ the subject: an id that changed is a different subject, with a different note path, which the `missing`/`dead` pair already reports. So a URL model can never be `stale`, and nothing is lost by it.
|
|
58
|
+
- **A URL model's id and its `url:` classification carry no query value, no credentials and no fragment.** The map, `last-build.json` and the report's `aikb.subjects` are committed or printed, and the query string is where an access token sits: until 2026-11-06 a token in a model URL was written into all three by `kiss-ssg aikb`, and from there into `kiss-ssg check`'s output (measured, 2026-11-06). Every value is replaced by `[redacted]` because kiss cannot know which one is the secret and a rule that guesses is a deny-list; the names are kept, because they are what tells one model from another in a log line or a site-map row (`?page=` against `?type=`). Two consequences. **Two models that differ only in a query value are one row** in the models table and **one subject**, listing every page that used either; two that differ in a parameter's name are two. They already shared one note, since the note path drops the query. And a site's next `kiss-ssg aikb` rewrites the `url:` rows and subject ids of every URL model with a query — the note paths do not move, so no note becomes `missing` or `dead`. Nothing is keyed on the id's spelling: note findings follow the note path, and `check`'s `diffReports` never compares subjects. What kiss fetches, the cache key, and the `{ id }` `.generate()`/`.complete()`/`.getModelByID()` hand the author keep the real URL — that is the author's own handle, not a record. A secret in the **path** is not caught; `GUIDE.md` says a credential belongs in `fetch.headers`. Covered by `test/unit/aikb.test.js` and `test/integration/aikb.test.js` (a recorded site whose folder is walked for the token).
|
|
57
59
|
- **An unreadable controller hashes to `null` rather than to anything.** No controllers folder, a file that was deleted, a directory in its place — all `null`, which is the same answer as "nothing to hash", and a subject with no hash is never stale. Guessing would be worse than saying nothing: a wrong hash would mark every note about it stale for ever.
|
|
58
60
|
- **The engine never writes a stamp.** `subject-hash` is copied into a note's frontmatter by the person (or the `kiss-branch-close` / `kiss-memory-consolidate` skill) who wrote or reviewed it. An unstamped note is the state every note starts in and most notes stay in, and it is never reported stale — only a stamp that _disagrees_ is a finding, which is what makes stamping opt-in per note rather than a burden on everyone.
|
|
59
61
|
- **The subjects are on the report as well as in `site-map.json`, because at the one moment that matters the two disagree.** A piece of work closes with the map on disk still describing the _previous_ record — so the hash in that file is the hash the note is already stamped with, and the hash it wants restamping with exists nowhere but the report of the build that just found the finding. Hence `aikb.subjects` beside `aikb.notes`: a plain `kiss-ssg check` can say both "this note is stale" and "here is what to stamp it with", before any record is made.
|
|
@@ -63,7 +65,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
63
65
|
- **A subject hash is over LF-normalised bytes, so a checkout that rewrites line endings does NOT restamp every note.** `subjectHash` (`lib/aikb.js:320`) replaces CRLF with LF before hashing: a CRLF checkout of an LF repository is the same controller, and git agrees, so a Windows clone reads the same stamps as the Linux clone that wrote them. The first version of this hashed the raw bytes and said normalising would make the hash disagree with git — measured the other way round: git normalises too, and the raw hash was what disagreed, restamping every controller note on every Windows clone. The generated `AIKB/README.md` and `llms.txt` say "LF-normalised" for the same reason.
|
|
64
66
|
- **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
|
|
65
67
|
- **A note's filename is derived from the subject's id, so two subjects can collide.** `a-x.js` and `a/x.js` both slug to `a-x`. Rare, and the alternative — a hash in the filename — would make the rule impossible to apply by hand, which is the property that matters more.
|
|
66
|
-
- **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes.
|
|
68
|
+
- **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes. The id it is derived from is already redacted (above), and redaction keeps host and path, so for any URL the platform parses the note path is the same whether it is derived from the real URL or the redacted one (pinned in `test/unit/aikb.test.js`). For one it does not parse, any `user:pass@` is now dropped from the note's filename too.
|
|
67
69
|
- **Every `.md` under `notes/` that is not an expected note is `dead`,** including one filed under a folder the engine does not recognise. Silently ignoring it would hide it for ever, which is the failure mode the rule exists to catch.
|
|
68
70
|
- **`last-build.json` drops every duration, not just the top-level one.** The plan called for "the report minus `duration`"; a `pipeline[].duration` is timing too, and leaving it in would make the file churn on every build of any site with an asset pipeline — which is exactly the property the folder is committed for. Nothing reads those durations: `diffReports` pairs reports by `buildDir` and matches pages by `buildTo` and `hash`.
|
|
69
71
|
- **A recorded snapshot says `mode: 'check'`.** `kiss-ssg aikb` sets `KISS_CHECK` too — it stages the build and throws it away, because recording must not publish — so the report it snapshots is a check's. Nothing reads that field to diff: `diffReports` pairs by `buildDir` and compares `hash`. It is worth knowing when reading `last-build.json` by eye.
|
package/AIKB/audit.md
CHANGED
|
@@ -8,9 +8,9 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`,
|
|
11
|
+
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`, the three image checks below (`image-oversized`, `image-location-metadata`, `img-not-responsive`), and `id-slug-mismatch`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
12
|
- `MARKETING_WORDS` — the frozen word list `checkbox-prechecked` matches a label against (offer(s), news, newsletter(s), marketing, promotion(s), promotional, partner(s), third party/parties), whole-word and case-insensitive, a space also matching a hyphen.
|
|
13
|
-
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind }]` (every absolute or `//host` origin a fetching element names, sorted); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
|
|
13
|
+
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind, service?, tracker? }]` (every absolute or `//host` origin a fetching element names, sorted; `tracker: true` when one of its URLs is on `TRACKER_ORIGINS`); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
|
|
14
14
|
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess, trailingSlash, privacyPage })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess` and `trailingSlash` `config.links.trailingSlash`, passed so a cross-page fragment, and a link to the privacy page, resolves to the page the link scan would resolve the same href to (`servedAs()`, which keeps a learning package's page on its file's folder, as `checkLinks` does). `privacyPage` is `config.audit.privacyPage`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl, id, package }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
15
15
|
- `thirdPartyOrigins({ pages, siteUrl })` → `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, or `null` when no page wrote HTML facts. Called by `lib/build-finish.js`'s `reportInputs()` on every report assembly; `lib/build-report.js` maps each `page` out of staging and nulls the key on a failed build.
|
|
16
16
|
- The `CheckId`, `PageFacts`, `PageOrigin`, `PageConsent`, `PageForm`, `FormField`, `AuditFinding`, `AuditResult`, `AuditPage` and `ThirdPartyOrigins` typedefs.
|
|
@@ -19,7 +19,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
19
19
|
- **`PageFacts.consent`** is `{ head, banner, settings, gated, signatures }`. `signatures` is `adsbygoogle`, `googletag.cmd`, `gtm.js` or `gtag/js` found in an ungated executable inline script, and `data-ad-client` on an `<ins>`. A script inside a comment or a `<template>` is not read, since a browser never runs it; until 2026-11-03 a commented-out snippet was reported as a tracker (round 6 of the Codex review). The `data-kiss-consent-head` script is never scanned, because its inlined config may name `gtm.js`. The field is absent from facts parked by an older engine.
|
|
20
20
|
- **`thirdPartyOrigins`** puts a sorted `services` beside `kinds` on an origin a service gates. Every other entry keeps its old shape.
|
|
21
21
|
- **`auditBuild`** takes `consent` (the resolved `config.consent`, or `null`) and runs eight checks, appended to `CHECKS`:
|
|
22
|
-
- `consent-missing`: no consent manager, and a page loads from `TRACKER_ORIGINS` or carries `gtm.js`/`gtag/js` inline;
|
|
22
|
+
- `consent-missing`: no consent manager, and a page loads a script or frame from `TRACKER_ORIGINS` (matched on the whole URL, so a path-qualified entry counts) or carries `gtm.js`/`gtag/js` inline;
|
|
23
23
|
- `consent-ungated`: a third-party script or iframe no service gates, and no `necessary` service lists in its `origins`, a `<noscript>` GTM frame included;
|
|
24
24
|
- `consent-ads-cmp`: any page, consent or not: an AdSense or Ad Manager loader, an `adsbygoogle`/`googletag.cmd` signature, or `<ins data-ad-client>`;
|
|
25
25
|
- `consent-objection-third-party`, site-wide;
|
|
@@ -38,6 +38,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
38
38
|
- **`image-location-metadata`**: from the build-folder walk, every raster whose header (`lib/image-header.js`, the reader `{{image}}` uses: the EXIF GPS IFD and XMP) names a latitude or longitude, referenced or not. `page` is the walked file and the summary counts files (`FILE_CHECKS`). The fix is in `src/`: the original is still copied, so `{{image}}` alone does not remove it.
|
|
39
39
|
- **`img-not-responsive`**: per page, each distinct `imgPlain` src that resolves to a raster in the build: the worklist for migrating to `{{image}}` (D-F3). An SVG, a missing file and an external URL are not named.
|
|
40
40
|
- The first two read files in the build folder, so they join `WALK_CHECKS` and are skipped, and listed in `skipped`, under `cleanBuild: false`. The audit as a whole is skipped in dev, where `{{image}}` writes a plain `<img>` that `img-not-responsive` would otherwise name.
|
|
41
|
+
- **`id-slug-mismatch`**, per page, from each page's `idSlug`: the detail `lib/page-registry.js` `idSlugMismatch` decided in `runAudit` (an explicit id built from a slug `toSlug` changed). Reported for **every** written page, not only the HTML ones the other per-page checks read, because identity belongs to the registry and a JSON or feed page has one too.
|
|
41
42
|
- **`host-unset`**, site-wide, from the `hostUnset` option: the concerns (`'cache headers'`, `'redirects'`) whose writer had something for a host to read and found neither `config.host` nor that concern's `format` set. One finding, `page: null`, `detail` the concerns sorted and joined. It is the only check fed by another module's result rather than by page facts or the walk: `lib/build-finish.js` `runAudit` reads `_cacheHeadersResult.unset` and `_redirectsUnset`, so it runs after both writers. The redirects writer's `warn` line is unchanged; this is the same fact, kept with the rest of the audit.
|
|
42
43
|
|
|
43
44
|
## Depends on
|
|
@@ -77,6 +78,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
77
78
|
- **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields and the five speed fields (`?? []`, `?? null`, `?? 0`); facts are extracted in memory and never parked across engines, so in practice only a hand-built test lacks them.
|
|
78
79
|
- **The privacy facts read quote-aware** (`ATTRS`, `TAG_END`), for the fragment checks' reason: a misread form, label or checkbox is a finding invented, and Alpine-style `x-show="step > 1"` values are common on exactly the forms these read. `origins` reads from the ids' strip (opening tags kept, so `<script src>` is seen; script, style and template contents gone). `forms`, `anchors`, `placeholders` and `prechecked` read from the counts' wider strip, so a `<mark>` in a script string or a comment never fires.
|
|
79
80
|
- **`origins` is its own kind-aware fact, not derived from `KissPage.links`** (2026-11-02 critique, blocker 1). `links` is a flat, sorted string list with no tag kind, and it includes every `<a>`, which a browser does not fetch. Kinds: `script`, `iframe`, `img` (`<img>` `src`/`srcset`, `<source srcset>`, `<video poster>`), `media` (`<video>`/`<audio>` `src`, `<source src>`), `form-action`, `stylesheet`, `preconnect`, `dns-prefetch`, and `preload` or `preload:<as>`. Never an `<a>`, a canonical or an alternate link. Only absolute `http(s)` and `//host` references are kept, since a relative one can only be the site's own. Static markup only: an iframe a script injects (a1k9's facade) and a CSS `@import` are invisible, so an empty list proves nothing.
|
|
81
|
+
- **Whether an origin is a tracker is decided at extraction, from the whole URL** (2026-11-05). A `TRACKER_ORIGINS` entry can name a path (`www.google.com` under `/maps`), and the kept `origin` has none, so matching it later can never see the path: 2.12.0 and earlier compared the origin, and a raw Google Maps frame raised no `consent-missing` (a1k9training's `/contact/`). `extractOrigins` runs `matchesHost` on the URL and records `tracker: true` on the `{ origin, kind, service }` entry, which stays one entry per page however many URLs share it; `checkConsent` reads the flag. The ads check is different on purpose: it strips its hosts' paths and matches the origin, so any script from an ad host counts.
|
|
80
82
|
- **First-party is the host, not the origin** (`thirdPartyOrigins`). An absolute URL whose host equals `siteUrl`'s, with a leading `www.` dropped on both sides, is the site's own, whatever its scheme or port. A subdomain (`cdn.site.example`) is not. With `siteUrl` unset, unparseable or local (`isLocalSiteUrl`, the `site-url-local` rule), every absolute origin is listed and `siteUrl` is `null`: the audit does not guess which one is the site.
|
|
81
83
|
- **`privacy-link-missing` finds the privacy page by id, then by path, and never by link text.** The page whose `id` is `privacy` (or `config.audit.privacyPage`), else, with no override, the page written to `privacy.html` or `privacy/index.html`. A page counts when one of its forms has a field that is not `hidden` (or another non-typed input), not a honeypot (`bot-field`, or the field the form's `netlify-honeypot` names), and has `type` `email`/`tel`, a `name` of `email`/`e-mail`/`tel`/`phone`/`telephone`, or an `autocomplete` token `email` or `tel…`. It is satisfied by an `<a>` anywhere on the page whose href resolves, through `classifyReference` and `resolveTarget` with `exists: () => false`, to the privacy page. An external href never counts, and the privacy page needs no link to itself. A page with a `<base href>` is not checked at all: its relative links resolve somewhere the build cannot see, so a link a browser follows to the privacy page was reported missing, and one leaving the site could pass (a Codex review, 2026-11-02). The fragment checks skip such a page for the same reason. The `detail` is that field's name (or type). No privacy page at all is one more site-wide finding, said only when some page collects; a `privacyPage` no audited page has is a site-wide finding too, said whatever the forms, rather than a config error, because only the build knows which ids exist.
|
|
82
84
|
- **`placeholder-left` is `<mark>` or the bracketed `[to confirm`, never the bare phrase.** "to confirm" appeared twelve times as prose in the fleet's sources (2026-11-02 critique). A `[to confirm` inside a `<mark>` is one placeholder, not two. The bracketed text inside a `<code>` or `<pre>` element is not read at all: it is the marker quoted, and kiss's own guide page for this check fired on itself until it was excluded (2026-11-02). A real `<mark>` element still counts wherever it is, because an escaped one (`<mark>`) is not an element.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -33,7 +33,7 @@ Each is the body of the `Kiss` private method named beside it:
|
|
|
33
33
|
- `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
|
|
34
34
|
- `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
|
|
35
35
|
- `encodeImages(kiss)` — `_encodeImages()`: the variants this build's `{{image}}` calls named (`kiss._imageIndex.variants()`), encoded or copied from the committed cache by `lib/image-encode.js` (`AIKB/image-encode.md`), **straight after the purge** and before the redirects, the cache headers (which mark the variants `immutable`), the packages (which pack them, since they are claimed outputs), the link scan and the audit. Latched as `_imagesRun`, reset by the replay, skipped in dev (where `{{image}}` names no variant) and on a failed build. Each written variant is claimed as `images` in the output registry; a variant the previous encode of this instance wrote (`_imageOutputs`) that no page asks for now is removed and released. A source that cannot be encoded is a `_failures` entry `<images: img/x.jpg>` whose message names the pages that asked for it (`pagesUsing`); any other throw is `<images>`. The result is `_imagesResult` (`{ requested, encoded, cached, cache }`, `null` when no variant was asked for), logged as `Images: N variants, H from the cache, M encoded`, and a build with misses warns once: `N image variants were encoded that are not in the committed cache. Run \`npx kiss-ssg images <script>\` and commit <cache>`(not under`kiss-ssg images`itself, and not with`cache: false`). Under `KISS_IMAGES` (`lib/images-sweep.js` `sweepRequest`) it also appends one `sweepLine`to that file, with no variant asked for too, so the sweep sees every instance;`KISS_IMAGES_REFRESH`makes it ignore the cache. It never prunes the cache. The cache folder is`kiss._imageCacheDir`, resolved in the constructor where `resolveConfig`checked it, never against the working directory at the settle: a script that changed directory before`complete()`otherwise wrote and reported the other folder's cache, and the sweep emptied it against this site's pages (measured; Codex adversarial review, 2026-11-05). A render after such a`chdir`also fails`{{image}}`, which reads its source relative to the working directory: that fails loudly and is not changed here.
|
|
36
|
-
- `writeHostCacheHeaders(kiss)` — `_writeCacheHeaders()`: the cache-header files for the hashed files (`lib/cache-headers.js`, `AIKB/cache-headers.md`), written after the consent client, whose file they name. It is latched as `_cacheHeadersRun` and reset by the replay. **Skipped in dev and on a build that has already failed**: a watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash. The result becomes `_cacheHeadersResult`, then `report().cacheHeaders`. It warns once per host with no encoding (`vercel`) and once per Firebase lookalike. A failed write fails the build (`<cache-headers>`). Its `unset`, and the redirects writer's (`_redirectsUnset`), feed the audit's `host-unset` through `runAudit`'s `hostUnset`.
|
|
36
|
+
- `writeHostCacheHeaders(kiss)` — `_writeCacheHeaders()`: the cache-header files for the hashed files (`lib/cache-headers.js`, `AIKB/cache-headers.md`), written after the consent client, whose file they name. It is latched as `_cacheHeadersRun` and reset by the replay. **Skipped in dev and on a build that has already failed**: a watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash. The result becomes `_cacheHeadersResult`, then `report().cacheHeaders`. It warns once per host with no encoding (`vercel`) and once per Firebase lookalike. A failed write fails the build (`<cache-headers>`). Its `unset`, and the redirects writer's (`_redirectsUnset`), feed the audit's `host-unset` through `runAudit`'s `hostUnset`. In the same latched run, straight after, it writes the security headers (`lib/security-headers.js`, `AIKB/security-headers.md`) when `securityHeaders` is on: a notice for each header left to the site's `_headers`, a warning when no host is set or the host has no encoding, a Firebase merge notice, and a failed write fails the build (`<security-headers>`). For both writers, `warnStale` warns about kiss's header output in a kept folder (`cleanBuild: false`) that the run did not write, and never deletes it: another `Kiss` instance may share the folder.
|
|
37
37
|
- `writeConsentClient(kiss)` — `_writeConsentClient()`: `<build>/kiss-consent.<hash>.js`, the consent client's cached part, whenever `config.consent` is set, dev included; minified with the pages' minifier outside dev; latched as `_consentClientRun`, reset by the replay; claimed; a failed write fails the build (`<consent-client>`), since every consent page loads it. Written before the link scan, so each page's reference to it resolves.
|
|
38
38
|
|
|
39
39
|
Three are named apart from their method because the method's own name is a function this module
|
|
@@ -54,6 +54,7 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
|
54
54
|
## Non-obvious behavior
|
|
55
55
|
|
|
56
56
|
- **`runLinkCheck()` and `runAudit()` both pass `config.links.trailingSlash`**, so a relative reference on a directory index resolves against the URL the host serves it at, the same policy `{{link}}` was written under; `runAudit()` passes each page's `package` too, which keeps a learning package's page on its file's folder (Codex round 6, 2026-11-03).
|
|
57
|
+
- **`runAudit()` decides each explicit-id page's `idSlug`** with `page-registry.js` `idSlugMismatch` (the slug as `options` gave it against `entry.page.slug`, the one written) and hands it to `auditBuild` as data, so the identity rule stays in the registry and the audit only reports it as `id-slug-mismatch`.
|
|
57
58
|
- **`runAudit()` threads each page's `id` and `config.audit.privacyPage` into `auditBuild`**, so `privacy-link-missing` can find the privacy page by id. The pages come from `_stackForRecord()`, not the raw stack, so the ids are the ones the report shows: a default `privacy` that two pages arrived at, which `{{link}}` would refuse, finds no privacy page either.
|
|
58
59
|
- **`reportInputs()` derives `thirdParty` on every assembly**, from the audit facts each written page parked (`thirdPartyOrigins`, `lib/audit.js`), against the instance's `config.siteUrl` (a page's own `siteUrl` override is not consulted for this key). It reads no file, so it costs a walk over the stack and is current under `_refreshReport()` too; `buildReport` nulls it on a failed build. It comes after `packages`, as the report's key order fixes it.
|
|
59
60
|
- **`reportInputs()` derives `consent` last, the same way:** `consentReport` (`lib/consent.js`) from `config.consent` and each written HTML page's parked `audit.consent`. Pages carrying gated markup are passed as `gatedPages`, from which `pagesWithoutHead` is derived. Paths stay as written (staging under check) for `buildReport` to map. It is `null` without `config.consent`. `runAudit` passes `config.consent` to `auditBuild` for the seven consent checks.
|
package/AIKB/build-report.md
CHANGED
|
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
|
|
9
|
+
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images, ids })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
|
|
10
10
|
- `ok` — `failures.length === 0`.
|
|
11
11
|
- `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
@@ -23,7 +23,8 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
24
|
- `audit` — after `outputs` (then `packages`, then `thirdParty`): `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
25
|
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON. After the packages, one ` third-party origins (N): <the first five>, …` line when `thirdParty` lists any, then one ` consent: version <v>, banner <hash>, N services` line when `consent` is set.
|
|
26
|
-
- **The `
|
|
26
|
+
- **The `ids` key, last, after `images`** (2026-11-05, path ids): `config.ids`, `'view'` or `'path'`, always present. It is in the report so it reaches `last-build.json`, where the next build's `redirectFindings` reads it back and pairs `moved` by id only within one convention (`AIKB/redirects.md`). Every recorded `last-build.json` gains `"ids": "view"` at its next `kiss-ssg aikb`; examples 9 and 11 were re-recorded for it.
|
|
27
|
+
- **The `images` key, after `cacheHeaders`** (workstream F). `null` when no page rendered an `{{image}}`, in dev, and on a failed build; otherwise `{ requested, encoded, cached, cache }` from `lib/build-finish.js` `encodeImages`: counts only, so `last-build.json` does not grow by a line per variant, and `cache` is `assets.images.cache` as configured. The variants stay out of `assets`, so `report().assets` and the `check` baseline do not move. Every recorded `last-build.json` gains `"images": null` at its next `kiss-ssg aikb` (examples 9 and 11 were re-recorded for it). `image-oversized` joins `VALUE_CHECKS` (the line names the file and its size) and `image-location-metadata` joins `FILE_CHECKS`.
|
|
27
28
|
- **The `cacheHeaders` key, after `consent`.** It is `null` when nothing was written: no hashed file, a dev build, or a failed build. Otherwise it is `{ rules: [{ path, headers }], files, formats, unset, merged }`. `files` are mapped out of staging; `rules` holds URL paths, which carry no staging prefix. It moves every recorded `last-build.json`, which gains `"cacheHeaders": null` at its next `kiss-ssg aikb`.
|
|
28
29
|
- **The `consent` key** (2.11.0), just before `cacheHeaders`. It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
|
|
29
30
|
- Every page path goes through `real()` and is then sorted.
|
|
@@ -50,7 +51,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
|
|
|
50
51
|
- **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
|
|
51
52
|
- **`pipeline` is appended, not slotted in beside `assets`.** It reports work that happens _before_ the copy, so its natural home would be above `assets` — but a consumer diffing two reports (or eyeballing one in a terminal) should see one new key rather than a reshuffle of the eight that were already there. A step's `Error` is dropped on the way in for the same reason a failure's is: the message is already in `failures`, under `<pipeline: <name>>`.
|
|
52
53
|
- **`aikb` is `null` on most builds, and that is not the same as "no knowledge base".** The key carries a verdict only when the site is opted in — `folders.aikb` set, and either this build is a record (`KISS_AIKB`) or `<folder>/site-map.json` is already there. A site that has never recorded reports `null`; once it has, every build reports `{ folder, written, notes }` with `written: false` unless this one was the record itself. See `AIKB/aikb.md`.
|
|
53
|
-
- **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
|
|
54
|
+
- **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and (every key but `config`) the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
|
|
54
55
|
- **`aikb.subjects` is the report's answer to a question `site-map.json` cannot answer yet.** It is `{ kind, id, note, hash }` per subject of _this_ build. The map on disk still describes the previous record, so the hash in that file is the one a note is already stamped with; the hash a note wants restamping with lives only here until the next record is made. A plain `kiss-ssg check` can therefore say both "this note is stale" and "here is what to stamp it with". Appended after `notes` for the reason every other key was appended after the one before it.
|
|
55
56
|
- **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
|
|
56
57
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
package/AIKB/cache-headers.md
CHANGED
|
@@ -17,7 +17,9 @@ Cache headers for the files kiss content-hashed: say, in the encoding the site's
|
|
|
17
17
|
- `cacheHeaderRules(files)` → `[{ path, headers: { 'Cache-Control': IMMUTABLE } }]`.
|
|
18
18
|
- `renderCacheHeadersJson(rules)` → the list, pretty-printed and newline-terminated.
|
|
19
19
|
- `renderHeadersBlock(rules)` → kiss's block for a `_headers` file (Netlify, Cloudflare Pages), between the markers.
|
|
20
|
-
- `mergeHeadersFile(existing, block)` → the site's file with the block appended. A block an earlier build left is replaced, never repeated. `existing` of `null` returns the block alone.
|
|
20
|
+
- `mergeHeadersFile(existing, block, markers?)` → the site's file with the block appended. A block an earlier build left is replaced, never repeated. `existing` of `null` returns the block alone. `markers` defaults to the cache block's; the security headers pass their own.
|
|
21
|
+
- `removeHeadersBlock(existing, markers?)` → the file with kiss's block, and the blank line before it, taken out: the inverse of `mergeHeadersFile`, which uses it.
|
|
22
|
+
- `staleHeaders({ root, markers, files, written, outputs })` → the build-relative names of kiss's output in the folder that this run did not write: `_headers` when it holds the marked block, and each named generated file nothing claimed this build. It deletes nothing. `writeCacheHeaders` and `writeSecurityHeaders` return it as `stale`, and `lib/build-finish.js` warns. Under `cleanBuild: false` an earlier build's block stays live after its feature is turned off; but several `Kiss` instances can share one folder, each with its own output registry, so another instance's output looks the same, and deleting it would silently drop its headers (Codex adversarial reviews, 2026-11-06).
|
|
21
23
|
- `renderFirebaseCacheHeaders({ consent })` → a fragment to merge into `firebase.json`: `{ headers: [{ regex, headers: [{ key, value }] }] }`, one pattern for kiss's hash shape (`.<8 hex>.css|js`) and one for the consent client.
|
|
22
24
|
- `firebaseLookalikes(built, hashed)` → the files in the build the hash pattern matches that kiss did not hash.
|
|
23
25
|
- `CACHE_HEADER_FORMATS` — `netlify` and `cloudflare` (both `_headers`), `firebase` (`cache-headers.firebase.json`). The single table of which formats exist; `lib/config.js` derives its accepted names from it.
|
|
@@ -37,6 +39,7 @@ Cache headers for the files kiss content-hashed: say, in the encoding the site's
|
|
|
37
39
|
|
|
38
40
|
## Non-obvious behavior
|
|
39
41
|
|
|
42
|
+
- **Whatever `_headers` holds is merged into, never replaced:** the site's copied lines, a security block, or a block another `Kiss` instance sharing a kept folder left. Only kiss's cache block is replaced. The file is claimed only when nothing else claimed it this build (Codex review, 2026-11-06).
|
|
40
43
|
- **A site's own `_headers` is kept and appended to, a deliberate exception to "kiss never merges".** Sites keep security headers (CSP, `X-Frame-Options`) in `src/assets/_headers`. Overwriting it, the way generated output beats a copied asset everywhere else (`lib/output-registry.js`), would remove them silently. Unlike `firebase.json`, this file is build output rebuilt on every deploy, so appending to it touches no source. The merged file is **not** claimed as generated: it stays the asset copy's, so no collision is reported for a write that lost nothing. The operator's decision, 2026-11-04 (`planning/plans/2026-11-04-kiss-performance.md`).
|
|
41
44
|
- **Firebase gets patterns, every other host gets exact paths.** The Firebase fragment is merged into `firebase.json` by hand, once. Exact paths in it would go stale at the next stylesheet edit, because the hash changes. `_headers` is rebuilt on every deploy, so exact paths there never go stale and never match a file they should not.
|
|
42
45
|
- **A pattern can match a file kiss did not hash**, such as a vendored `lib.deadbeef.js`. That file would be cached for a year. The writer reports such files as `lookalikes`, and the build **warns** rather than fails: the operator's decision at the 2026-11-04 pulse, "It's an optimisation rather than a critical issue".
|
package/AIKB/config.md
CHANGED
|
@@ -7,8 +7,10 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
11
|
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
|
+
- `resolveFetch(fetch)` → the merged fetch block in the form the engine reads: a `retries` or `timeout` given as a string of digits (`'5000'`, `'1.5'`; what an environment variable is, and what 2.13.2 took through `setTimeout`'s coercion) becomes the number, and the block is then held to its shape by `assertFetchIsValid`. Called by `resolveConfig`, whose result carries the numbers, and again by `lib/fetch-policy.js`'s `fetchModel`, for a caller that never went through `resolveConfig`.
|
|
13
|
+
- `assertFetchIsValid(fetch)` → `void`, throws on a merged fetch block whose key is not its shape: `retries` a whole number of 0 or more, `timeout` a finite number of 0 or more (`0` is no timer), `headers` a plain object (a null prototype counts; not a `Headers` instance or pairs), `cache` a non-empty string or `false` (not `null` or `''`). The message names the key (`config.fetch.retries must be …`) and never the value.
|
|
12
14
|
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
13
15
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
|
14
16
|
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
|
|
@@ -20,17 +22,18 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
20
22
|
- **A concern the site set keeps its value; one it left unset follows `host`** (`resolveConfig`, after both formats are checked). The derived value is a list, so a derived `[]` or `['none']` is a decision, and the redirects writer's `unset` warning stays silent for a `github-pages` site.
|
|
21
23
|
- `DEFAULT_REDIRECTS` — frozen object, the redirect block: `format: null`, meaning no host encoding — the IR alone. It also takes a **list**, so `['netlify', 'firebase']` emits both host files. `REDIRECT_FORMATS` is the frozen list of accepted names (`'netlify'`, `'firebase'`, `'vercel'`, `'none'`); a function is also accepted, as a custom writer (`AIKB/redirects.md`).
|
|
22
24
|
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
|
|
23
|
-
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
25
|
+
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on a `fetch` key that is not its shape (`assertFetchIsValid`), on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
24
26
|
- `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
|
|
25
27
|
- `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
|
|
28
|
+
- `redactFetchHeaders(config)` → a shallow copy of a config whose `fetch.headers` keeps every header name and has every value replaced by `REDACTED` (`'[redacted]'`, from `lib/utils.js`), except a value that is `undefined`, `null` or `''`, which is kept as it is: that is what `process.env.API_TOKEN` gives when the variable is unset, and `'[redacted]'` there would tell an author reading the `verbose` log that a token is set when it is not. Any other value is redacted, `0`, `false` and `'Bearer undefined'` included. The top level and `fetch` are new objects; every other nested value is shared. A config with no `fetch`, or a `fetch` with no header object, is copied as it stands. Never mutates its input. The one redaction kiss has: `lib/page-registry.js` runs each page's `options.config` through it, and `lib/kiss.js` the `verbose` log line.
|
|
26
29
|
|
|
27
30
|
## Depends on
|
|
28
31
|
|
|
29
|
-
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids); `./consent.js` (`resolveConsent`, the `consent` block).
|
|
32
|
+
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./utils.js` (`isInside`, the folder containment test; `REDACTED`, the word `redactFetchHeaders` writes); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids); `./consent.js` (`resolveConsent`, the `consent` block).
|
|
30
33
|
|
|
31
34
|
## Depended on by
|
|
32
35
|
|
|
33
|
-
`lib/kiss.js
|
|
36
|
+
`lib/kiss.js` (and `redactFetchHeaders`, for the `verbose` log line); `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`); `lib/page-registry.js` (`resolveMarkdownCopies`, for a page's own `config.markdownCopies`; `redactFetchHeaders`, for each page's copy of the config).
|
|
34
37
|
|
|
35
38
|
## Non-obvious behavior
|
|
36
39
|
|
|
@@ -53,7 +56,8 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
53
56
|
- `foldersToEnsure` fixes a v1 copy-paste bug where most folders were only created conditionally on `assets` being set; here each folder stands on its own regardless of the others.
|
|
54
57
|
- **The three dev-server keys are defaulted here and nowhere else.** `lib/dev-server.js` takes `port`, `livereloadPort` and `host` as required parameters, so a reader has one place to look for their values. `livereloadPort` exists so two sites can run in dev mode at once (a clash used to kill the process — review finding D-01) and is also the port `lib/kiss-page.js` injects into the dev-mode reload `<script>`. `devHost` defaults to loopback: dev mode used to bind every interface while logging `localhost` (D-13); `'0.0.0.0'` is the documented opt-out for previewing on another device.
|
|
55
58
|
- `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
|
|
56
|
-
- **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy.
|
|
59
|
+
- **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy. Fetching reads the instance's own `config.fetch` (`lib/page-registry.js` hands `kiss.config.fetch` to the model resolver); a page's `config: { fetch: … }` changes only that page's copy, which nothing fetches with. **Every key of the merged block has a shape, read by `resolveFetch` and checked once by `assertFetchIsValid`** (2026-11-07; a string of digits for `retries` or `timeout` is read as the number, because an environment variable is a string and 2.13.2 accepted it): before, `retries: NaN` (a `Number(process.env.X)` of an unset variable) ran the attempt loop no times and every URL model failed with `undefined`, and a `null` header object with a `cache` set threw a `TypeError` from the cache key's `Object.entries(null)` (inferred from the code, not run). The message names the key and never the value, because a value in this block can be read from the environment and `headers` is where a token goes. Header _values_ are not checked here: an unset `process.env.TOKEN` is `undefined`, which `redactFetchHeaders` shows as it is, and a value the fetch would refuse is `lib/fetch-policy.js`'s `checkHeaders`.
|
|
60
|
+
- **A page's copy of the config never holds a header value.** `GUIDE.md` tells a site to put an API token in `fetch.headers`, and each page's `options.config` is its render context, so `{{{stringify this}}}` — the guide's own way to look at a page's data — or a controller returning `{ ...options, site: options.config }` would print it into the built page and, in dev, into the `.json` sibling. `lib/page-registry.js` therefore makes each copy through `redactFetchHeaders`: the header names stay (a template can still ask which headers a site sends), every value reads `'[redacted]'`, and a page's own override that supplies `fetch.headers` is redacted the same way. `kiss.config` itself is never redacted, because it is what the fetch sends. Only `fetch.headers`: it is the one place kiss documents for a secret, and anything else a site puts in its config reaches its templates by design (`{{config.x}}`, below).
|
|
57
61
|
- **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
|
|
58
62
|
- **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
|
|
59
63
|
- **`assets.pipeline` is validated, like `cleanBuild`, because every step is a command this process will spawn.** It must be an array; each entry an object with a non-empty string `run`; `name`, `watch` and `cwd`, when present, strings. Anything else throws out of `resolveConfig` (so out of `new Kiss`) naming the index and the value — `{ pipeline: 'npx tailwindcss' }` (a bare string) and `{ pipeline: [{ watch: '…' }] }` (a watch with nothing to run first) are the two shapes that would otherwise be discovered as a `spawn` of `undefined` half way through a build. Being an array, it **replaces** the default rather than merging into it, which is what the one-level-deep merge means for an array value. What the steps then do is `lib/pipeline.js`'s business (`AIKB/pipeline.md`); this module only refuses a shape that cannot be run.
|
package/AIKB/consent-client.md
CHANGED
|
@@ -13,7 +13,7 @@ The contract is `planning/plans/2026-11-02-consent-manager.md` §3–§5, and fo
|
|
|
13
13
|
|
|
14
14
|
## Public interface
|
|
15
15
|
|
|
16
|
-
- **`consentHeadScript(cfg, client)` → `string`.** It returns `<script data-kiss-consent-head>(<the head part>)(window,<cfg JSON>)</script>` and nothing else, where `cfg` is `clientConfig()` from `lib/consent.js` with `client: { name, page, root }` added (`ConsentClientFile`): the cached file's name, the page's build-relative file (`guide/index.html`, or `null`), and the root `siteUrl` names as a path (`/`, `/site/`). The head part adds `<script src="…" data-kiss-consent-client>` to `<head>` itself, at `<root>` + `name`, where the root is whatever `location.pathname` (decoded) has in front of the page's file in one of its forms: the file, and for `x/index.html` also `x/` and `x`, for `x.html` also `x`, each preceded by a `/`. None matching (a 404 page served anywhere) uses `client.root
|
|
16
|
+
- **`consentHeadScript(cfg, client)` → `string`.** It returns `<script data-kiss-consent-head>(<the head part>)(window,<cfg JSON>)</script>` and nothing else, where `cfg` is `clientConfig()` from `lib/consent.js` with `client: { name, page, root }` added (`ConsentClientFile`): the cached file's name, the page's build-relative file (`guide/index.html`, or `null`), and the root `siteUrl` names as a path (`/`, `/site/`). The head part adds `<script src="…" data-kiss-consent-client>` to `<head>` itself, at `<root>` + `name`, where the root is whatever `location.pathname` (decoded) has in front of the page's file in one of its forms: the file, and for `x/index.html` also `x/` and `x`, for `x.html` also `x`, each preceded by a `/`. None matching (a 404 page served anywhere) uses `client.root`, and so does a derived root that would put the file on another protocol or host, and a derived root whose load fails is tried once more from `client.root` (both under "The derived root stays on the page's host", below). Every build-time URL was right at one address and wrong at another: root-relative or `siteUrl`-absolute missed a prefixed site's preview (the stopgap review, 2026-11-03), and relative-to-the-served-path missed `/guide/` and `/guide/index.html` under `trailingSlash: false`, which kiss's own dev server redirects to (Codex at the close, 2026-11-04; red in Chromium at all four addresses). A dynamically added script is not `defer`red, which the cached part does not need: it waits for `DOMContentLoaded` itself. The JSON escapes `<` as `<` and U+2028/U+2029, so no site text can end the script.
|
|
17
17
|
- **`consentClientSource()` → `string`.** The cached file's source, `(<kissConsentClient>)(window);`. It holds no site config, so it is the same file for every site on one kiss version.
|
|
18
18
|
- **`consentClientName()` → `string`.** `kiss-consent.<10 hex of the source's sha256>.js`, memoised: it changes only when the client's code does, so the file can be cached for good.
|
|
19
19
|
- **`inlineJson(value)` → `string`.** That escaping, as a function, shared with `lib/consent-record.js` so the viewer's config cannot end its script either.
|
|
@@ -35,10 +35,17 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
|
|
|
35
35
|
|
|
36
36
|
## Depended on by
|
|
37
37
|
|
|
38
|
-
`lib/handlebars-helpers.js` (`{{consentHead}}`, `consentClientName`), `lib/build-finish.js` (`writeConsentClient`: `consentClientName`, `consentClientSource`). `test/unit/consent-client.test.js` runs the head part then the client in Node's `vm` against a domino DOM with a fake cookie jar, clock, storage and beacon, both as rendered and terser-minified, and runs them apart for the stub's queue, the fail-closed case and a second copy of either. `test/browser/consent.test.js` runs it in Chromium on built sites (`test/helpers/consent-site.js`), with every off-host request stubbed. It covers: before a choice, Reject, Accept, a returning visitor, document order, statistics only, withdrawal, a version bump, Close, an embed, focus under the bar, no JavaScript, the endpoint, the log across a withdrawal's reload, a cached file held back (the banner shows first, an early Accept takes effect when the file arrives), an override partial, and the top position.
|
|
38
|
+
`lib/handlebars-helpers.js` (`{{consentHead}}`, `consentClientName`), `lib/build-finish.js` (`writeConsentClient`: `consentClientName`, `consentClientSource`). `test/unit/consent-client.test.js` runs the head part then the client in Node's `vm` against a domino DOM with a fake cookie jar, clock, storage and beacon, both as rendered and terser-minified, and runs them apart for the stub's queue, the fail-closed case and a second copy of either. `test/browser/consent.test.js` runs it in Chromium on built sites (`test/helpers/consent-site.js`), with every off-host request stubbed. It covers: the cached file loaded at every address a page is reached by (`/missing/404`, through the fallback, included), the fallback on a host that answers the wrong path with a `200` page instead of the file, and never from another host whatever the address says (five hostile spellings on a 404 page), a page opened by `file://`, before a choice, Reject, Accept, a returning visitor, document order, statistics only, withdrawal, a version bump, Close, an embed, focus under the bar, no JavaScript, the endpoint, the log across a withdrawal's reload, a cached file held back (the banner shows first, an early Accept takes effect when the file arrives), an override partial, and the top position.
|
|
39
39
|
|
|
40
40
|
## Non-obvious behavior
|
|
41
41
|
|
|
42
|
+
- **The derived root stays on the page's host.** The address is the visitor's, and the root is a slice of it re-read as a URL. `at` is `decodeURI(location.pathname)`, so `/%5Cevil.example/404` decodes to `/\evil.example/404`; on a site whose `404.html` carries the consent head, the form `404` matches, the root is `/\evil.example/`, and a browser reads `/\` as `//`: the client came from another host and ran in the site's origin (the security review, 2026-11-06). Measured in Chromium before the fix, on a built site served by `serveFolder` (which answers an unknown path with `404.html`): `/%5Cevil.example/404`, `/%5C%5Cevil.example/404`, `//evil.example/404`, `/%09/evil.example/404` and `/%5Cevil.example/404.html` each made one request, `GET evil.example/kiss-consent.<hash>.js`, and no other. A root `index.html` reached at `/%5Cevil.example/` derives the same root (unit suite). So after the root is derived, the head part resolves the page's address (`new win.URL(win.location.href)`) and the file's (`new win.URL(root + client.name, here)`) and keeps the derived root only when `protocol` and `host` match; otherwise, or when either throws (a base that cannot be resolved against, such as `about:srcdoc`), it uses `client.root`.
|
|
43
|
+
- **The browser's URL algorithm, not a list of characters,** so the backslash, the doubled slash, a tab or newline the parser strips, and any spelling not yet thought of are all decided by the parser that will fetch the file.
|
|
44
|
+
- **`host`, not `hostname`:** `hostname` lets another port on the same name through. **Not `origin`:** every `file:` URL has the same origin whatever its host, so two different hosts compare equal; `protocol` + `host` tells `file:///C:/…` (host empty) from `file://evil/…` (host `evil`). Measured in Chromium 158.0.8127.18 on 2026-11-07 (`test/browser/consent.test.js`, the `file://` row): a built page opened from the disk and `file://evil/kiss-consent.js` both have the origin `file://` (Chromium's spelling; it is not the string `null`), the page loads the cached client from its own folder (`<build>/kiss-consent.<hash>.js`), the client starts, and the page requests nothing outside that folder.
|
|
45
|
+
- **Read from the parsed `here`, never from `win.location`**, and through `win.URL`, so the unit suite's `vm` harness (no global `URL`, a `location` with no `host`) runs it unchanged.
|
|
46
|
+
- **A derived root that fails is tried once from `client.root`.** A benign `/missing/404` on a 404 page derives `/missing/`, where there is no file, and every consent control on the page was dead. The tag for a derived root other than `client.root` has an `error` listener and a `load` listener, both running `again`, which appends a second tag from `client.root` unless the file has announced itself (`S.loaded`, the cached part's first act); the second tag has neither listener, so it is once only, and a tag already at `client.root` is never retried. **`load` is not trusted on its own** because a host that answers an unknown path with status 200 and HTML fires `load`, not `error`, on the script, with an `Unexpected token '<'` error on `window` (measured in Chromium 158 on 2026-11-06, a script element whose `src` a local server answered with `200` and `text/html`; with `X-Content-Type-Options: nosniff` on that answer the tag gets `error` instead, same measurement). Until 2.14.0 only `error` was listened for, so on such a host (a single-page-app rewrite to `200`; kiss's own `serveFolder`, Netlify and Firebase answer 404, measured with `curl` on 2026-11-06 per `planning/plans/2026-11-06-security-hardening.md`) that page's consent controls stayed dead unless `securityHeaders: true` sent `nosniff`. The browser row "falls back to the root when a wrong path answers 200 with a page" serves the wrong-root file as a `200` page and sees the root asked next and the choice stored; it was red before the `load` listener. One limit, recorded rather than engineered around:
|
|
47
|
+
- **A 404 page still loads the client from whatever same-host folder its address names** when that address ends in the page's own file name (`/anything/404`). It is the page's own origin, so on a site that has the origin to itself it is a dead page at worst. On an origin shared with other publishers (`example.edu/~someone/`) a file one of them placed at `/~them/kiss-consent.<hash>.js` would run, but a shared origin already gives every publisher on it the others' cookies and storage, so it is outside what this guard can promise.
|
|
48
|
+
|
|
42
49
|
- **Order in the head.** The inline part runs these steps in order:
|
|
43
50
|
1. the `dataLayer` and `gtag` stub (it never replaces an existing `gtag`);
|
|
44
51
|
2. `gtag('consent', 'default', …)`;
|
|
@@ -64,6 +71,7 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
|
|
|
64
71
|
- at `DOMContentLoaded` for the stored choice or an `objection` category;
|
|
65
72
|
- on the click for a new grant.
|
|
66
73
|
- **What scripts miss:** revived scripts never see `DOMContentLoaded` (`AIKB/upstream.md`).
|
|
74
|
+
- **Only a Load click asks a video to play.** `reveal(box, clicked)` sets `autoplay=1` on the cloned iframe's `src` (through `URL`, before the frame is in the page, so the player loads once) when `clicked` is true and the box carries `data-kiss-embed-autoplay` (`lib/embed.js`, a video service's box). The `load` action passes `true`. `revealAllowed()`, the reveal from a stored choice or an Always allow grant, passes nothing, so a page load or a grant never starts a video with sound on its own. The browser suite reads the frame's `src` both ways, and was red without this change (2.13.0).
|
|
67
75
|
- **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
76
|
1. write the cookie, send the `update`, append to the log and send the POST;
|
|
69
77
|
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);
|
|
@@ -91,6 +99,6 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
|
|
|
91
99
|
- **A link in the dialog closes it**, the policy link and the history link alike, so a jump within the same page (the history page's own "See your consent history") does not leave the history behind the modal (Codex round 5, P2; red in the unit and Chromium tests). It closes on a `setTimeout(…, 0)` after the click, not during it: a link removed from the document during its own click is not followed. Not for a click that opens a new tab or window (Ctrl, Cmd, Shift, Alt, a non-primary button, a `target`) or that something cancelled, so the visitor keeps any switches they had moved. For a same-page fragment, focus then goes to the section it names (given `tabindex="-1"` if it has none): `dialog.close()` would otherwise return focus to the control that opened it (the stopgap review, P3s, 2026-11-03; red in Chromium on `document.activeElement`).
|
|
92
100
|
- **A replayed click on a control no longer on the page is skipped**, so a double click on Accept made before the file arrived is one choice, one log entry and one POST, not two (the stopgap review, P3).
|
|
93
101
|
- **The dialog** is cloned from its template on first open, gets `data-kiss-consent-dialog`, and opens with `showModal()` (falling back to `open`). Its switches (styled checkboxes) are set from the current state each time it opens.
|
|
94
|
-
- **Size.** Measured 2026-11-
|
|
102
|
+
- **Size.** Measured 2026-11-06 for a gtm + youtube + vimeo site, terser-minified as the unit suite does it, gzipped with Node's `zlib.gzipSync` at its default level. **Inline, on every page:** 5,698 bytes, 2,488 gzipped, the config included; it was 5,483 (2,401) the same day before the origin guard and the fallback. **The cached file, once:** 10,050 bytes, 3,859 gzipped. History, not re-measurable: at the split (2026-11-03) the inline part was 4,839 (2,092) and the file 8,939 (3,421); before the split the one inline script was 12,600 (4,618), under a 13,312 ceiling the operator had raised twice that day; 10,932 when first measured, where the contract had estimated 5–7 kB. `test/unit/consent-client.test.js` holds the inline part under 6,144 and the file under 10,752 (`INLINE_CEILING`, `FILE_CEILING`; the test's comments say when and why each moved); raising one is a decision, not a fix. The inline part still needs `'unsafe-inline'` or a hash under a Content Security Policy; the file is `'self'`.
|
|
95
103
|
- **The cached file is minified by the pages' own minifier** (`html-minifier-terser` over `<script>…</script>`, `minifyJS`), so it needs no dependency of its own, and is unminified in dev as pages are. Its name hashes the unminified source, so the same code is the same name whatever minifies it.
|
|
96
104
|
- **Written in ES5 style (`var`, `function`)**, except optional `catch` bindings (ES2019), so the inlined source runs on the browsers a static site serves with no transpile step. It is minified with the page in production.
|
package/AIKB/consent-gate.md
CHANGED
|
@@ -12,7 +12,7 @@ The consent manager's server side:
|
|
|
12
12
|
## Public interface
|
|
13
13
|
|
|
14
14
|
- **`gatedScript(name, service, { consent, params, body, partialHtml, src })` → `string`.** What `{{consentScript}}` writes.
|
|
15
|
-
- **No body or partial:** the service's `inline` script, then its `src` (or `src=` from the call). Each is `<script type="text/plain" data-kiss-service data-kiss-consent [data-src]>`.
|
|
15
|
+
- **No body or partial:** the service's `inline` script, then its `src` (or `src=` from the call). Each is `<script type="text/plain" data-kiss-service data-kiss-consent [data-src]>`. The `inline` script's placeholders are filled by `fillScript` (each value URL-encoded, then every character of that outside `[A-Za-z0-9_.-]` written as `\uXXXX`), the `src`'s by `fill` (URL-encoded).
|
|
16
16
|
- **A body (block form) or a rendered partial of a service that is not gated** (necessary, or a Google tag under advanced mode) is written exactly as the author wrote it. Until 2026-11-03 it went through the rewrite below, which drops `defer` because the queue keeps order; a plain script never enters the queue, so a `defer` head script lost it and ran before `<body>` existed (found by the Codex review).
|
|
17
17
|
- **A body (block form) or a rendered partial of a gated service:** every `<script>` in it is rewritten into that form:
|
|
18
18
|
- `src` becomes `data-src`, `async` becomes `data-async`, and `type="module"` becomes `data-type="module"`;
|
|
@@ -39,7 +39,7 @@ The consent manager's server side:
|
|
|
39
39
|
|
|
40
40
|
## Depends on
|
|
41
41
|
|
|
42
|
-
`htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `gatingOf`, `scriptKind`).
|
|
42
|
+
`htmlparser2` (`Parser`, tokenising with source indices); `./consent.js` (`fill`, `fillScript`, `gatingOf`, `scriptKind`).
|
|
43
43
|
|
|
44
44
|
## Depended on by
|
|
45
45
|
|
|
@@ -48,7 +48,9 @@ The consent manager's server side:
|
|
|
48
48
|
## Non-obvious behavior
|
|
49
49
|
|
|
50
50
|
- **The block rewrite uses a tokenizer, not a regex.** `htmlparser2`'s `startIndex` and `endIndex` locate each `<script>` in the source, and only those ranges are replaced. Getting a script tag wrong here would ship a third party ungated, which a regex that misreads one attribute could do silently. The audit's tolerant regex reading is a different job, because a missed fact there is only a missed finding.
|
|
51
|
-
- **`document.write` is refused** because a script revived after consent runs after the page has parsed. There `document.write` replaces the whole document, and in the serial queue `load` never fires (contract Appendix A, S3; `AIKB/upstream.md`). A `src` script's body cannot be seen, so the guide states that limit.
|
|
51
|
+
- **`document.write` is refused** because a script revived after consent runs after the page has parsed. There `document.write` replaces the whole document, and in the serial queue `load` never fires (contract Appendix A, S3; `AIKB/upstream.md`). A `src` script's body cannot be seen, so the guide states that limit. The check runs on the filled `inline` text, so a placeholder value that holds `document.write(` is now inert (its parenthesis is written as a `\u` escape) and does not trip it, which is right: it calls nothing. A service's own `inline` that calls it is still refused.
|
|
52
|
+
- **An `inline` placeholder is filled as a script body, not a URL** (security hardening, 2026-11-06). Under `fill`, `encodeURIComponent` left `'`, `(` and `)` alone, so `{{consentScript "gtag" id=model.gaId}}` with `G-X'-alert(1)-'` closed the preset's string literal and called `alert`. `fillScript` (`AIKB/consent.md`) closes that while keeping the URL-encoding, so a placeholder the site's `inline` puts inside a URL or an HTML string stays encoded and inert. The unit tests evaluate the emitted script, gated and (under advanced mode, through `html-minifier-terser` with `minifyJS`) ungated, and compare the value gtag was handed with `encodeURIComponent` of the original; two more evaluate a placeholder inside an `innerHTML` string and inside a URL (one parameter, no markup from the value), and one pins an unquoted `init()` as a syntax error that calls nothing.
|
|
53
|
+
- **No scheme check on a script's `src`** (`src=` from the call, or the service's `src`): a script URL from model data is code whatever its scheme, so a check there would promise something it cannot keep (the security-hardening contract, 2026-11-06). A `src` from the call goes in as written, escaped for the attribute only.
|
|
52
54
|
- **The wiring check reads the rendered bytes.** It runs on HTML outputs only, before minifying and before the dev live-reload script is added.
|
|
53
55
|
- It catches a hand-written gated tag as surely as a helper's.
|
|
54
56
|
- It fails the page in a production build, with `consent: <problems> (<view>)`, and warns in dev, the `{{link}}` stance. A gated service with no consent head would never run, and a script ahead of the head would run before the Consent Mode default is set.
|
package/AIKB/consent.md
CHANGED
|
@@ -22,7 +22,7 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
|
|
|
22
22
|
- **The resolved block:** `{ version, categories, inUse, services, cookie, google, endpoint, ui }`. `ui` is `{ position, logo, partial, policy, history, text, theme }`: `policy` (default `'cookies'`) and `history` (default `null`, the page with `{{consentRecord}}`) are page ids or `null`, and `text` holds the banner's, the dialog's, the embed's and the history page's (`history…`) wording.
|
|
23
23
|
- **`categoriesInUse(resolved)`** → the categories at least one service belongs to, `necessary` aside, in `CATEGORY_NAMES` order. Decided once per site (`inUse`), so every page offers the same set.
|
|
24
24
|
- **`purposesVersion(resolved)`** → 12 hex characters. The default `version`; see Non-obvious behavior.
|
|
25
|
-
- **`serviceParams(service, hash)`** and **`
|
|
25
|
+
- **`serviceParams(service, hash)`**, **`fill(template, params, where)`** and **`fillScript(template, params, where)`.** Placeholder values come from the helper's hash over the service's own. `fill` is for a URL template (`src`, `embed.src`, `embed.watch`) and URL-encodes each value. `fillScript` is for a script body (a service's `inline`): it URL-encodes each value exactly as `fill` does, then writes every character of that result outside `[A-Za-z0-9_.-]` as a `\uXXXX` escape. Both run one shared placeholder loop (`fillWith`, private: the regex, the own-key check, the error), differing only in the encoder, so the placeholder syntax cannot drift between the two. Both throw `<where>: no value for {x}`, and both throw `URIError` on a value holding a lone surrogate, as `fill` always has.
|
|
26
26
|
- **`consentModeDefault(resolved)`** and **`consentModeState(granted, map)`** → the Consent Mode `default` and `update` arguments.
|
|
27
27
|
- **`textOf(html)`** and **`bannerHash(html)`** → the visible text, and 12 hex characters of its sha256.
|
|
28
28
|
- **`bannerRegistry({ consent, hashes, texts })`** → `consent-banners.json`'s contents, or `null` without a consent manager or without an `endpoint` (no server to read it): `{ format: 1, version, banners: [{ hash, text, categories: [{ key, label, description, mode }], services: [{ name, label, provider, category }] }] }` (`provider` because the ICO asks for every controller relying on the consent by name, and a label is not always one, 2026-11-04), one entry per distinct hash the written pages carry, sorted. `text` is the `textOf` the hash was taken from, so `sha256(text)` begins with the hash and a server can check it; `null` for a hash with no recorded text. Necessary services are left out, as they are of the dialog's choices.
|
|
@@ -51,7 +51,7 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
|
|
|
51
51
|
|
|
52
52
|
- `lib/config.js` (`resolveConfig`).
|
|
53
53
|
- `lib/consent-ui.js` (the resolved shape it renders, and `STORAGE_KINDS`; `clientConfig` feeds `lib/consent-client.js`'s `consentHeadScript`).
|
|
54
|
-
- `lib/consent-gate.js` (`fill`, `gatingOf`, `scriptKind`), `lib/embed.js` (`PRESETS`, `fill`, `gatingOf`, `presetService`), `lib/handlebars-helpers.js`, `lib/audit.js` and `lib/build-finish.js`.
|
|
54
|
+
- `lib/consent-gate.js` (`fill`, `fillScript`, `gatingOf`, `scriptKind`), `lib/embed.js` (`PRESETS`, `fill`, `gatingOf`, `presetService`), `lib/handlebars-helpers.js`, `lib/audit.js` and `lib/build-finish.js`.
|
|
55
55
|
- `test/unit/consent.test.js`, `test/unit/embed.test.js`.
|
|
56
56
|
|
|
57
57
|
## Non-obvious behavior
|
|
@@ -73,9 +73,12 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
|
|
|
73
73
|
- **A preset's provider is part of `version`.** On 2026-11-04 the `youtube` preset's provider became Google (the operator: "YouTube is Google"). The provider names the controller relying on the consent, so the change moved the purposes version of every site using the preset, and their visitors are asked again on upgrade. That is the rule working as written above, not a side effect. The changelog's upgrade note says so. The same day, the embed wording's defaults moved from `{provider}` to `{service}` (the label) for its two buttons, so a YouTube embed does not offer "Always allow Google" for a grant of YouTube alone. `servicesLabel` names the dialog's service lists. A service's `policy` became an href that day, so `policyUrl` accepts only an absolute http(s) URL or null, and fails the build on anything else (a page id, `javascript:`, a bare host). It is not part of `version`.
|
|
74
74
|
- **First- and third-party storage.** A stored item (a cookie, or an item under `storage`) with a `domain` is third-party unless `party: 'first'` says otherwise. `clientConfig` hands the browser first-party names only, because a page can delete nothing else. The presets' YouTube storage and Vimeo cookies are third-party: listed in the cookie table, never deleted.
|
|
75
75
|
- **A storage item has a `kind`:** `local` (the default), `session` or `indexeddb`; a cookie's is `cookie`, and `kind` on a cookie entry is refused. The cookie table names it (`STORAGE_KINDS`: Local storage, Session storage, IndexedDB), and `clientConfig` sends only first-party `local` names, since the client deletes `localStorage` keys and nothing else. Added 2026-11-04 with the YouTube preset's IndexedDB database `YtIdbMeta`: before it, every storage item was labelled "Local storage", which would have put a false row in every YouTube site's table. `kind` is not part of `version`.
|
|
76
|
+
- **`embed.video`** (a flag, `false` by default; `true` on the `youtube` and `vimeo` presets) marks a video player. `lib/embed.js` reads it to put a play mark on a postered Load button, to label the button with `embedPlay` rather than `embedLoad`, and to mark the box for autoplay on Load.
|
|
77
|
+
- **`DEFAULT_TEXT.embedPlay` ("Play video") and `embedWording(text, own)`.** A video's button has its own key. `embedWording` makes it follow a site's `embedLoad` when the site set that and not `embedPlay`, so a site that translated `embedLoad` before 2.13.0 does not gain an English word on its videos. `resolveUi` applies it to `ui.text`, and `lib/embed.js` `resolveEmbeds` to `embeds.text`. It is not in the banner hash, which is taken over the banner's and dialog's rendered HTML only (`bannerHash`), so a new text key changes no site's records.
|
|
76
78
|
- **An embed's frame sizing is config** (`embed.ratio`, `width`, `height`, `measure`), resolved here and written by `lib/embed.js` (`AIKB/embed.md`). `ratio` defaults to `'16/9'`; `null` means the frame's content decides its height, starting at `height` (600 px when unset), and `measure: true` follows the height the frame reports. The `instagram` preset is the one that uses them (2026-11-04).
|
|
77
79
|
- **`presetService(name)`** resolves a preset alone, as a service with no site settings: what `{{embed}}` loads with no `config.consent`. `null` for a name with no preset.
|
|
78
80
|
- **A service may not take a category's name.** `kissConsent.has()` and `when()` take a category or a service by one name and answer for the category first, so a service named `necessary` would have run before any choice (found by the Codex review, 2026-11-03).
|
|
79
81
|
- **A required placeholder is a render error, not a config error.** A preset needs `{id}`, but the value may come from the helper's hash per page (`{{consentScript "gtm" id=gtmId}}`, as diploma-msc passes it), so `resolveConsent` cannot know it is missing. `fill` throws when it is.
|
|
82
|
+
- **A placeholder in a script body is string content, not a URL** (security hardening, 2026-11-06). `inline` once went through `fill`, and `encodeURIComponent` leaves `'`, `(` and `)` alone, so the `gtag` preset's `window.gtag('config','{id}')` with an id of `G-X'-alert(1)-'` closed the string and called `alert`. `fillScript` URL-encodes the value as `fill` does, then writes every character of the result outside `[A-Za-z0-9_.-]` as `\uXXXX`; after URL-encoding those are only `%` and the six characters `encodeURIComponent` leaves alone, `'`, `(`, `)`, `!`, `~` and `*`. Inside any string literal the script reads `encodeURIComponent(value)`, exactly what it read when `inline` went through `fill` (the unit tests evaluate the emitted script and compare the value, for a quote, a backtick, `\`, a newline, `</script>`, U+2028 and a non-BMP character). **The URL-encoding is kept on purpose.** The first version of this fix wrote the raw value, escaped; that closed the breakout and opened another hole, because a site's `inline` that puts a placeholder inside a URL or an HTML string (`new Image().src='https://t.example/p?site={site}'`, `el.innerHTML='<a href="/x?id={site}">x</a>'`) had been getting an encoded, inert value and then got the raw one (the code review measured an injection through `innerHTML`). Unit tests evaluate both cases: the URL keeps one parameter, the HTML string holds no `<`, `>` or `"` from the value. Outside a literal a value cannot close one, call a function or end the `<script>` element, but it is still read as code: a value made only of safe characters (`x--`, a variable's name) is an expression. An id of letters, digits, `_`, `-` and `.` is written byte for byte as `fill` wrote it (`G-ABC123`, `AW-123`, `1234567890`, `0.5`, measured against `main`'s `fill`). **The dot is kept on purpose:** a site's own `inline` may hold a number outside quotes (`window.r={rate};` with `0.5`), which worked under `fill`; escaping the dot would make it a syntax error only a browser reports. An unquoted placeholder holding any other character than `! ~ * ' ( )` was already broken under URL-encoding (`%3A` is not JavaScript). One holding one of those six was not: `encodeURIComponent` passed them through, so `window.q={q};` with `init()` called `init` (measured on `main`). That was code injection, and it is now a syntax error in the browser, on purpose: a placeholder is data, never code. A value holding `document.write(` is inert now, so `findDocumentWrite` no longer trips on it, and is right not to. The page minifier (terser, under `minifyJS`) rewrites an ungated inline script; a unit test runs the hostile value through `html-minifier-terser` with `minifyJS` and still reads it back intact.
|
|
80
83
|
- **`consentReport` holds no timestamp,** so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
|
|
81
84
|
- **Theme values refuse `;`, `{`, `}`, `<` and `>`,** so a site's colour cannot end the declaration or the `<style>` element it is written into.
|
package/AIKB/design.md
CHANGED
|
@@ -105,8 +105,8 @@ keeps the documented window: the client is livereload-js's, not ours, and a huma
|
|
|
105
105
|
it.
|
|
106
106
|
|
|
107
107
|
**The watcher.** Chokidar is the Node norm. Eleventy uses it outright; Vite still uses chokidar 3.6 for files
|
|
108
|
-
outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar
|
|
109
|
-
none of the three problems above is the watcher's.
|
|
108
|
+
outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar 6.0.0 (taken
|
|
109
|
+
2026-11-06, the latest then), and none of the three problems above is the watcher's.
|
|
110
110
|
|
|
111
111
|
Sources, as checked on 2026-09-23: Jekyll PR jekyll/jekyll#8459 and `docs/configuration/options` and
|
|
112
112
|
`docs/configuration/incremental-regeneration`; Eleventy issue 11ty/eleventy#3001, discussion #2294,
|
package/AIKB/embed.md
CHANGED
|
@@ -16,9 +16,10 @@ It exists because of the 2026-11-04 clean room (`planning/reviews/2026-11-04-emb
|
|
|
16
16
|
- **Under `config.consent`:** `div.kiss-embed[data-kiss-embed][data-kiss-consent]`, a `[data-kiss-consent-ui]` notice with **Load it** and **Always allow** (`data-kiss-consent-action="load"|"allow"`). A `necessary` service gets the plain iframe.
|
|
17
17
|
- **Without it:** `div.kiss-embed[data-kiss-embed][data-kiss-embed-facade]`, a `[data-kiss-embed-ui]` notice with one **Load it** (`data-kiss-embed-action="load"`), and `facadeScript()` after the box.
|
|
18
18
|
- **Both:** the box carries the frame's geometry inline (below). The wording is `text` (the site's `ui.text`, or `DEFAULT_TEXT`), filled in one pass: `{provider}` with the provider and `{service}` with the label. The provider's own link (`embed.watch`) is the notice's last paragraph, so it hides with the notice; it is omitted when there is none or a placeholder cannot be filled. The iframe sits inside `<template data-kiss-embed-frame>`, sized inline (below), and followed by `measureScript()` when `embed.measure` is set.
|
|
19
|
-
- **Throws** with no title, no src,
|
|
19
|
+
- **Throws** with no title, no src, a `{param}` with no value, or a frame src or filled `embed.watch` that `isHttpUrl` refuses (below).
|
|
20
|
+
- **`isHttpUrl(value)` → `boolean`.** Whether a URL is safe to frame or link: `new URL(value, 'https://kiss.invalid/')` resolves, and its protocol is `http:` or `https:`. Not a string, or a value the parser refuses, is `false`.
|
|
20
21
|
- **`facadeScript()` → `string`.** `kissEmbedFacade`, inlined by its source. It finds its box through `document.currentScript.previousElementSibling`, so an embed works wherever it is put with nothing in `<head>`. On a click it puts the template's frame in place, hides the notice and focuses the frame.
|
|
21
|
-
- **`measureScript(frameSrc)` → `string`.** `kissEmbedMeasure`, for a frame that reports its height. It listens for `{"type":"MEASURE","details":{"height":N}}` from the frame's own origin and sets the height of the iframe whose window sent it. Empty for a src with no origin.
|
|
22
|
+
- **`measureScript(frameSrc)` → `string`.** `kissEmbedMeasure`, for a frame that reports its height. It listens for `{"type":"MEASURE","details":{"height":N}}` from the frame's own origin and sets the height of the iframe whose window sent it. Empty for a src with no origin. `embedMarkup` calls it only after `isHttpUrl` has passed the src.
|
|
22
23
|
- **`embedPresetNames()` → `string[]`.** The presets with an `embed`, in preset order.
|
|
23
24
|
|
|
24
25
|
## Depends on
|
|
@@ -27,11 +28,22 @@ It exists because of the 2026-11-04 clean room (`planning/reviews/2026-11-04-emb
|
|
|
27
28
|
|
|
28
29
|
## Depended on by
|
|
29
30
|
|
|
30
|
-
`lib/handlebars-helpers.js` (`embed`). Tests: `test/unit/embed.test.js
|
|
31
|
+
`lib/handlebars-helpers.js` (`embed`). Tests: `test/unit/embed.test.js` (which also registers the real helpers to test the dev and build behaviour of a refused URL); `test/integration/consent.test.js` ("{{embed}} on a site with no config.consent", and the consent site from `test/helpers/consent-site.js`); `test/browser/consent.test.js` ("{{embed}} without config.consent, in Chromium").
|
|
31
32
|
|
|
32
33
|
## Non-obvious behavior
|
|
33
34
|
|
|
35
|
+
- **Every frame URL must resolve to http(s)** (security hardening, 2026-11-06). The frame has no `sandbox`, so a `javascript:` src runs in the page's origin, and a call-site `src=` from model data (`{{embed "google-maps" src=model.map}}`) was written into `<iframe src>` with attribute escaping alone. `isHttpUrl` checks three values: the call-site `src=`, the filled `embed.src`, and the filled `embed.watch`. It is an allow-list of two schemes decided by the URL algorithm, so leading whitespace, a tab inside the scheme, any case, `data:`, `vbscript:` and `blob:` are refused without being listed; a root-relative path, a bare relative path and a `//host/…` URL pass, because a browser resolves them to the page's own scheme. Where each check sits is load-bearing:
|
|
36
|
+
- **after `fill`**, because a `{placeholder}` in a template's host position does not parse until it is filled. A placeholder cannot bring a scheme in: `fill` URL-encodes `:`, so `{id}` as `javascript:alert(1)` fills to the relative path `javascript%3Aalert(1)`;
|
|
37
|
+
- **before the bare iframe** a `necessary` service returns;
|
|
38
|
+
- **outside the `try` around `embed.watch`'s `fill`**: a watch link with a placeholder that has no value is still left out, but one that fills to another scheme throws rather than silently disappearing.
|
|
39
|
+
|
|
40
|
+
A refusal throws `embed "<name>": the frame's src "<url>" is not an http(s) URL — …` (or `embed.watch "<url>"`), which the `embed` helper turns into a failed page in a build and a warning with no markup in dev (`consentFail`); `test/unit/embed.test.js` drives the real helper both ways. The value written is the author's string, not the parsed URL, so a valid embed's markup is unchanged. Not checked here: whether the frame's origin is one of the service's declared `origins` (content, not script execution, and it would change what a site may embed), and a `{{consentScript … src=}}` URL (`AIKB/consent-gate.md`). A service's `policy` keeps its own stricter rule in `lib/consent.js` (`policyUrl`: absolute http(s) only, because a relative path is wrong for another site's policy).
|
|
41
|
+
|
|
34
42
|
- **The size is on the iframe, not in the theme.** A 16:9 player and Instagram's frame need different CSS, and that is a property of the provider's frame, not the site's look. So `style` carries `display:block;width:100%;border:0`, then either `aspect-ratio:<ratio>;height:auto` or `aspect-ratio:auto;height:<height>px`, and `max-width:<width>px;margin:0 auto` when the preset caps the width. The consent theme's `.kiss-embed iframe` rule was removed with this module. A site that wants another size overrides it with `!important`, or with its own `ratio`, `width` and `height` under `config.consent`.
|
|
43
|
+
- **The Load button's words and name.** The words are, in order: one call's `own.load`, else `text.embedPlay` for a `video` service, else `text.embedLoad`. They are filled like every other word (`{provider}`, `{service}`). Its `aria-label` is `<words>: <frame title>`, words first so the name contains what is shown (WCAG 2.5.3 label in name), which tells a screen-reader user which of several embeds a button loads. `own.notice` and `own.open` replace the notice and the watch link's words the same way. `text` is the consent manager's `ui.text`, else the site's `embeds.text`, else `DEFAULT_TEXT`: the helper decides which, so `embedMarkup` never reads config.
|
|
44
|
+
- **`resolveEmbeds(input, consent)` → `{ text }|null`**, for `config.embeds` (`lib/config.js`). It is the wording of a site with no `config.consent`, under the same four keys `ui.text` uses (`EMBED_TEXT_KEYS`: `embedNotice`, `embedLoad`, `embedPlay`, `embedWatch`; not `embedAllow`, which only the consent manager offers). It throws on `embeds` beside an active `consent`, so there is one home for the words and no precedence rule to learn. "Active" is the **resolved** consent: `lib/config.js` calls it after `resolveConsent`, so `consent: false` (switched off) takes `embeds`. Until Codex's slice-4 review it read the raw input and refused `false`. It also throws on any key other than `text`, on an unknown wording key, and on a value that is not a string.
|
|
45
|
+
- **Autoplay is asked for by the click, never written into the frame.** A `video` service's box carries `data-kiss-embed-autoplay` unless the call said `autoplay=false`. The facade's click handler, and the consent client's `load` action (`AIKB/consent-client.md`), set `autoplay=1` on the cloned iframe's `src` before inserting it. The `<template>`'s own `src` never has it, because the consent client also reveals a frame from a stored choice, with no click, and that must not start a video. Measured in headless Chromium 156 with a real YouTube video (2026-11-05, `aqz-KE-bpKQ`): playing unmuted at 6.4 s, eight seconds after the click; the `autoplay=false` control paused at 0. iOS Safari is not measured.
|
|
46
|
+
- **A poster (`poster`, markup the helper already wrote with the `{{image}}` pipeline) is the one case where kiss writes colour** (the operator, 2026-11-05). The 2026-11-04 geometry-only rule assumed a plain box. Text on a photo is unreadable without a backing, so the operator chose a readable default over geometry only. With a poster, the box gains the class `kiss-embed--poster` and `position:relative;justify-content:flex-end`. The still is a `.kiss-embed__poster` wrapper, absolute at `inset:0`, carrying the same `data-kiss-embed-ui` / `data-kiss-consent-ui` attribute as the notice, so both reveal paths hide it with no client change. The notice becomes a compact full-width strip: padded, `.875em`, tight paragraph margins, and the watch link beside the button rather than under it. This came from the operator's look at `/branch-close` (2026-11-05). On an unstyled site at 375 px the first strip covered 69% of the box with its text against the edges; the compact one covers 44% at 375 px (52% at 320, 9% at 1440), with the text 12 px in. The browser suite holds it under 50% at 375 px and under 15% at 1440, and was red without it. It is all inline, because a `<style>` per embed would be the CSS audit's `inline-style-repeated`, so no media query is possible. Its colours are **custom properties with fallbacks** (`--kiss-embed-poster-scrim`, `--kiss-embed-poster-text`), because a site cannot beat an inline style without `!important` but can set a variable. The notice's paragraphs and the watch link get `color:inherit` inline. That beats any rule on them: the consent theme's `.kiss-embed__notice p` is muted, which measured `color(srgb .3 .3 .3)` on the strip in Chromium (Codex, slice 3), and a site's own link colour (a1k9training's is `text-brand-700`) would be as bad. The browser suite checks every `p` and `a` in the strip, in the facade and under the consent theme. A service whose `embed.video` is set (youtube, vimeo) gets `PLAY_MARK` on its Load button: an `aria-hidden` SVG, not `▶`, which iOS draws as a colour emoji. Without a poster the markup is byte-identical to before.
|
|
35
47
|
- **The box holds the frame's space, so a video's frame loads without moving the page** (the operator, 2026-11-04, after the after-run's screenshot showed a bare paragraph, button and link). The box's inline style is geometry only: a centred flex column, `box-sizing:content-box`, the frame's `aspect-ratio` (or `min-height` of its starting height), and the same width cap. No colour or border, so the site's CSS or the consent theme decides the look. `content-box` because the ratio is the frame's: a site's `* { box-sizing: border-box }` would take the theme's padding out of it and the loaded frame would push the box taller. A box with more text than room grows, since an aspect ratio gives way to its content (a narrow phone with the consent notice's two buttons). The browser suite measures it: at 320 px the box is 16:9 before the click and the same height after, and the next embed does not move. For a measured frame, `kissEmbedMeasure` clears the box's `min-height` when it sets the reported height, so an Instagram box does move once: measured on the real reel `DfQCqMnhpsG` through `{{embed}}` (2026-11-04), 288 × 600 → 288 × 568 at 320 px wide, and 540 × 600 → 540 × 883 at 1440. The height is about the width × 1.25 + 208 px for a 4:5 post (both measurements fit it), which CSS cannot express on the box without a container query; left as the 600 px estimate.
|
|
36
48
|
- **Instagram reports its height without `embed.js`.** Measured on 2026-11-04: a plain `/p/{id}/embed` frame posts `MEASURE` (568 px at 320 wide, 883 px at 1440), and `/p/{id}` serves a reel as well as a post. The listener checks `e.origin` and matches `e.source` against each iframe's `contentWindow`, so another frame's message cannot resize anything. It is written once per measuring embed, so two Instagram embeds add two listeners, each of which sizes only the frame that spoke.
|
|
37
49
|
- **The wiring check ignores a facade.** `consentWiringProblems` (`lib/consent-gate.js`) counts a `data-kiss-embed` box as gated markup that needs a consent head, unless it carries `data-kiss-embed-facade`.
|