kiss-ssg 2.13.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/build-finish.md +1 -1
- package/AIKB/build-report.md +1 -1
- package/AIKB/cache-headers.md +4 -1
- package/AIKB/config.md +8 -4
- package/AIKB/consent-client.md +10 -3
- package/AIKB/consent-gate.md +5 -3
- package/AIKB/consent.md +3 -2
- package/AIKB/embed.md +11 -3
- package/AIKB/fetch-policy.md +18 -6
- package/AIKB/kiss-page.md +5 -3
- package/AIKB/kiss.md +4 -4
- package/AIKB/model-resolver.md +9 -6
- package/AIKB/page-registry.md +13 -8
- package/AIKB/pipeline.md +3 -2
- package/AIKB/rebuild.md +1 -1
- package/AIKB/security-headers.md +37 -0
- package/AIKB/upstream.md +27 -0
- package/AIKB/utils.md +4 -1
- package/CHANGELOG.md +184 -0
- package/GUIDE.md +47 -13
- package/README.md +1 -0
- 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/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/router.js +2 -3
- package/lib/aikb.js +45 -25
- package/lib/build-finish.js +69 -16
- package/lib/cache-headers.js +89 -17
- package/lib/config.js +121 -6
- package/lib/consent-client.js +45 -7
- package/lib/consent-gate.js +5 -2
- package/lib/consent.js +52 -4
- package/lib/embed.js +43 -1
- package/lib/fetch-policy.js +262 -28
- package/lib/init.js +1 -1
- package/lib/kiss-page.js +28 -3
- package/lib/kiss.js +7 -4
- package/lib/model-resolver.js +18 -6
- package/lib/page-registry.js +106 -84
- package/lib/pipeline.js +22 -8
- package/lib/security-headers.js +233 -0
- package/lib/utils.js +102 -0
- package/llms.txt +24 -22
- package/package.json +1 -1
- package/types/aikb.d.ts +15 -8
- package/types/cache-headers.d.ts +55 -2
- package/types/config.d.ts +66 -9
- package/types/consent.d.ts +24 -1
- package/types/embed.d.ts +16 -1
- package/types/kiss-page.d.ts +18 -0
- package/types/page-registry.d.ts +2 -1
- package/types/pipeline.d.ts +12 -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/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
|
package/AIKB/build-report.md
CHANGED
|
@@ -51,7 +51,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
|
|
|
51
51
|
- **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
|
|
52
52
|
- **`pipeline` is appended, not slotted in beside `assets`.** It reports work that happens _before_ the copy, so its natural home would be above `assets` — but a consumer diffing two reports (or eyeballing one in a terminal) should see one new key rather than a reshuffle of the eight that were already there. A step's `Error` is dropped on the way in for the same reason a failure's is: the message is already in `failures`, under `<pipeline: <name>>`.
|
|
53
53
|
- **`aikb` is `null` on most builds, and that is not the same as "no knowledge base".** The key carries a verdict only when the site is opted in — `folders.aikb` set, and either this build is a record (`KISS_AIKB`) or `<folder>/site-map.json` is already there. A site that has never recorded reports `null`; once it has, every build reports `{ folder, written, notes }` with `written: false` unless this one was the record itself. See `AIKB/aikb.md`.
|
|
54
|
-
- **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
|
|
54
|
+
- **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and (every key but `config`) the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
|
|
55
55
|
- **`aikb.subjects` is the report's answer to a question `site-map.json` cannot answer yet.** It is `{ kind, id, note, hash }` per subject of _this_ build. The map on disk still describes the previous record, so the hash in that file is the one a note is already stamped with; the hash a note wants restamping with lives only here until the next record is made. A plain `kiss-ssg check` can therefore say both "this note is stale" and "here is what to stamp it with". Appended after `notes` for the reason every other key was appended after the one before it.
|
|
56
56
|
- **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
|
|
57
57
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
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
|
@@ -9,6 +9,8 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
10
|
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
11
|
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
|
+
- `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', …)`;
|
|
@@ -92,6 +99,6 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
|
|
|
92
99
|
- **A link in the dialog closes it**, the policy link and the history link alike, so a jump within the same page (the history page's own "See your consent history") does not leave the history behind the modal (Codex round 5, P2; red in the unit and Chromium tests). It closes on a `setTimeout(…, 0)` after the click, not during it: a link removed from the document during its own click is not followed. Not for a click that opens a new tab or window (Ctrl, Cmd, Shift, Alt, a non-primary button, a `target`) or that something cancelled, so the visitor keeps any switches they had moved. For a same-page fragment, focus then goes to the section it names (given `tabindex="-1"` if it has none): `dialog.close()` would otherwise return focus to the control that opened it (the stopgap review, P3s, 2026-11-03; red in Chromium on `document.activeElement`).
|
|
93
100
|
- **A replayed click on a control no longer on the page is skipped**, so a double click on Accept made before the file arrived is one choice, one log entry and one POST, not two (the stopgap review, P3).
|
|
94
101
|
- **The dialog** is cloned from its template on first open, gets `data-kiss-consent-dialog`, and opens with `showModal()` (falling back to `open`). Its switches (styled checkboxes) are set from the current state each time it opens.
|
|
95
|
-
- **Size.** Measured 2026-11-
|
|
102
|
+
- **Size.** Measured 2026-11-06 for a gtm + youtube + vimeo site, terser-minified as the unit suite does it, gzipped with Node's `zlib.gzipSync` at its default level. **Inline, on every page:** 5,698 bytes, 2,488 gzipped, the config included; it was 5,483 (2,401) the same day before the origin guard and the fallback. **The cached file, once:** 10,050 bytes, 3,859 gzipped. History, not re-measurable: at the split (2026-11-03) the inline part was 4,839 (2,092) and the file 8,939 (3,421); before the split the one inline script was 12,600 (4,618), under a 13,312 ceiling the operator had raised twice that day; 10,932 when first measured, where the contract had estimated 5–7 kB. `test/unit/consent-client.test.js` holds the inline part under 6,144 and the file under 10,752 (`INLINE_CEILING`, `FILE_CEILING`; the test's comments say when and why each moved); raising one is a decision, not a fix. The inline part still needs `'unsafe-inline'` or a hash under a Content Security Policy; the file is `'self'`.
|
|
96
103
|
- **The cached file is minified by the pages' own minifier** (`html-minifier-terser` over `<script>…</script>`, `minifyJS`), so it needs no dependency of its own, and is unminified in dev as pages are. Its name hashes the unminified source, so the same code is the same name whatever minifies it.
|
|
97
104
|
- **Written in ES5 style (`var`, `function`)**, except optional `catch` bindings (ES2019), so the inlined source runs on the browsers a static site serves with no transpile step. It is minified with the page in production.
|
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
|
|
@@ -79,5 +79,6 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
|
|
|
79
79
|
- **`presetService(name)`** resolves a preset alone, as a service with no site settings: what `{{embed}}` loads with no `config.consent`. `null` for a name with no preset.
|
|
80
80
|
- **A service may not take a category's name.** `kissConsent.has()` and `when()` take a category or a service by one name and answer for the category first, so a service named `necessary` would have run before any choice (found by the Codex review, 2026-11-03).
|
|
81
81
|
- **A required placeholder is a render error, not a config error.** A preset needs `{id}`, but the value may come from the helper's hash per page (`{{consentScript "gtm" id=gtmId}}`, as diploma-msc passes it), so `resolveConsent` cannot know it is missing. `fill` throws when it is.
|
|
82
|
+
- **A placeholder in a script body is string content, not a URL** (security hardening, 2026-11-06). `inline` once went through `fill`, and `encodeURIComponent` leaves `'`, `(` and `)` alone, so the `gtag` preset's `window.gtag('config','{id}')` with an id of `G-X'-alert(1)-'` closed the string and called `alert`. `fillScript` URL-encodes the value as `fill` does, then writes every character of the result outside `[A-Za-z0-9_.-]` as `\uXXXX`; after URL-encoding those are only `%` and the six characters `encodeURIComponent` leaves alone, `'`, `(`, `)`, `!`, `~` and `*`. Inside any string literal the script reads `encodeURIComponent(value)`, exactly what it read when `inline` went through `fill` (the unit tests evaluate the emitted script and compare the value, for a quote, a backtick, `\`, a newline, `</script>`, U+2028 and a non-BMP character). **The URL-encoding is kept on purpose.** The first version of this fix wrote the raw value, escaped; that closed the breakout and opened another hole, because a site's `inline` that puts a placeholder inside a URL or an HTML string (`new Image().src='https://t.example/p?site={site}'`, `el.innerHTML='<a href="/x?id={site}">x</a>'`) had been getting an encoded, inert value and then got the raw one (the code review measured an injection through `innerHTML`). Unit tests evaluate both cases: the URL keeps one parameter, the HTML string holds no `<`, `>` or `"` from the value. Outside a literal a value cannot close one, call a function or end the `<script>` element, but it is still read as code: a value made only of safe characters (`x--`, a variable's name) is an expression. An id of letters, digits, `_`, `-` and `.` is written byte for byte as `fill` wrote it (`G-ABC123`, `AW-123`, `1234567890`, `0.5`, measured against `main`'s `fill`). **The dot is kept on purpose:** a site's own `inline` may hold a number outside quotes (`window.r={rate};` with `0.5`), which worked under `fill`; escaping the dot would make it a syntax error only a browser reports. An unquoted placeholder holding any other character than `! ~ * ' ( )` was already broken under URL-encoding (`%3A` is not JavaScript). One holding one of those six was not: `encodeURIComponent` passed them through, so `window.q={q};` with `init()` called `init` (measured on `main`). That was code injection, and it is now a syntax error in the browser, on purpose: a placeholder is data, never code. A value holding `document.write(` is inert now, so `findDocumentWrite` no longer trips on it, and is right not to. The page minifier (terser, under `minifyJS`) rewrites an ungated inline script; a unit test runs the hostile value through `html-minifier-terser` with `minifyJS` and still reads it back intact.
|
|
82
83
|
- **`consentReport` holds no timestamp,** so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
|
|
83
84
|
- **Theme values refuse `;`, `{`, `}`, `<` and `>`,** so a site's colour cannot end the declaration or the `<style>` element it is written into.
|
package/AIKB/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,10 +28,17 @@ 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`.
|
|
35
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.
|
|
36
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.
|
package/AIKB/fetch-policy.md
CHANGED
|
@@ -6,11 +6,11 @@ Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `async fetchModel(url, { fetchImpl = globalThis.fetch, logger, headers, timeout, retries, cache })` → `Promise<data>` — the parsed JSON body of a 2xx response. Every policy key is optional and falls back to `DEFAULT_FETCH` from `config.js
|
|
9
|
+
- `async fetchModel(url, { fetchImpl = globalThis.fetch, logger, headers, timeout, retries, cache })` → `Promise<data>` — the parsed JSON body of a 2xx response. Every policy key is optional and falls back to `DEFAULT_FETCH` from `config.js`; a key passed as `undefined` takes its default too, as `resolveConfig` does (a spread let it replace the default, and `retries: undefined` ran the loop no times and rejected with `undefined`). The merged policy goes through `config.js`'s `resolveFetch` before anything else (a `retries` or `timeout` given as a string of digits is read as the number, then every key is held to its shape), for a caller that never went through `resolveConfig`, so a `retries` or `timeout` that is not its shape is kiss's own error naming the key. Rejects with a fresh `Error` (no `cause`, no own properties) on a URL that cannot be fetched, a non-2xx response, a network failure, a 2xx body that is not JSON or a timeout, whose message kiss writes from parts that cannot carry a secret (below), and on a header the fetch would refuse, whose message names the header and never its value (below). A failed cache write does not reject: it warns and the data is returned.
|
|
10
10
|
|
|
11
11
|
## Depends on
|
|
12
12
|
|
|
13
|
-
`fs-extra`; `./utils.js` (`hashId`, `posixPath`); `./config.js` (`DEFAULT_FETCH`).
|
|
13
|
+
`fs-extra`; `node:http` (`STATUS_CODES`); `node:net` (`isIP`); `./utils.js` (`hashId`, `posixPath`, `redactUrl`); `./config.js` (`DEFAULT_FETCH`, `resolveFetch`).
|
|
14
14
|
|
|
15
15
|
## Depended on by
|
|
16
16
|
|
|
@@ -18,10 +18,22 @@ Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to
|
|
|
18
18
|
|
|
19
19
|
## Non-obvious behavior
|
|
20
20
|
|
|
21
|
-
- **
|
|
21
|
+
- **A header the fetch would refuse is refused first, by name.** Node's `fetch` quotes a header value in the error it raises for one it cannot send (`Headers.append: "<value>" is an invalid header value.`), and `GUIDE.md` tells a site to put its token in `fetch.headers`: a token with a line break or a NUL in it, as a value read from a file can have, was printed twice by the failed-model log line (the review of `7c527a3`, 2026-11-06; measured on Node 24.14.0 through a whole build). The message is kiss's own now in any case (below), but refusing first also keeps the value out of anything Node might print on the way. So `checkHeaders(headers)` runs after the cache read and before the first attempt: it builds the same `Headers` the fetch would, and on a refusal throws kiss's own error, `fetch.headers: "Authorization" cannot be sent — … The value is not shown.`, naming each refused header of a plain record and nothing of what it holds (any other shape is refused without a name). It is outside the attempt loop, so it is never retried and the fetch is never called. `Headers` and the fetch's `Request` refuse the same inputs (measured on Node 24.14.0 for `null`, a record, pairs, a `Headers`, a `Map`, a bad name and a string), so no header set that worked is refused; a value that only ends in a line break is trimmed by both and still sent. A character above 255 is refused by Node with a message that does not quote the value; it gets kiss's message too. `test/unit/fetch-policy.test.js` drives it with the real `fetch`, counted.
|
|
22
|
+
- **Order of operations: policy check → cache read → URL check → header check → each attempt (fetch and parse, under one timer) → non-2xx guard → retry decision → cache write.** The cache is consulted before the network, so a cached model costs no request at all; the body is written only after a 2xx response has parsed, so an error envelope can never become a cache entry (the guard that `model-resolver.js` gained for the same reason — review finding C2 — stays authoritative over the cache). The URL check (`URL.canParse`, then credentials) and the header check are outside the attempt loop: neither can pass on a second attempt, so no fetch is made and the message carries no attempt count (2026-11-07; before, an unparseable URL was fetched `retries + 1` times and reported from whatever the fetch threw). The cache write is outside the attempt's `catch`: a disk that refuses it is a `logger.warn`, `Model cache write failed: <cache file> → <code>; the model is used without caching` (the file is the hashed name, never the URL), and the model is returned. It is not retried, since fetching again cannot make the disk accept it, and it does not fail the page, since the model arrived (2026-11-07; before, it rejected).
|
|
22
23
|
- **The cache key is a hash of the URL _and_ the header set**, with the header entries sorted so key order cannot fork the identity. The same URL fetched with two different bearer tokens is two different identities upstream, and one build's body must never be served to the other's request.
|
|
23
24
|
- **The cache has no TTL and no expiry sweep.** A consumer clears the directory (or a single `<hash>.json` inside it) to refetch. That also makes stale-on-error unreachable rather than merely unimplemented: an entry is only ever written after a success, and once written it is hit before any request is made, so there is no state in which a fetch fails _and_ a cached body exists. Stale-on-error only becomes a real option if a TTL is added — which is why `cache` is a plain directory string, not a `{ dir, staleOnError }` object.
|
|
24
25
|
- **An unreadable cache entry is a miss, not a failure**: a truncated or hand-edited file logs one warning and the request goes to the network. A half-written file (a build killed mid-write) must not poison every later build.
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
26
|
+
- **One timer per attempt covers the headers and the body.** Until 2026-11-07 the race ended when the headers arrived and `response.json()` ran outside it, so a server that sent `200` and its headers and then nothing hung the build past any timeout (the regression row, "times out a body that never arrives", is a real local server doing exactly that). Now `attemptWithin` races the whole attempt, fetch and parse (`attempt`), against the timer. The timer is per attempt, so `retries` gives each attempt the full `timeout`. `timeout: 0` is no timer at all: the attempt waits for ever.
|
|
27
|
+
- **The timeout is a `Promise.race` as well as an `AbortController.abort()`.** The abort is what a real `fetch` needs, and it reaches undici's body stream: measured on Node 24.14.0, an abort after the headers rejects `response.json()` with an `AbortError` whose `code` is the **number** `20`. The race is what makes the timeout hold for a `fetchImpl` that ignores its signal — the injected seam is a test stub or a polyfill as often as it is Node's `fetch`, and one that hangs would otherwise hang the whole build with nothing to report. The timer rejects the race **before** it aborts, so kiss's own timeout message wins over that `AbortError`; and should one ever reach `failureOf`, the code shape is string-only, so `20` is not shown. The losing attempt gets a no-op `.catch()` so the abort's own rejection is never unhandled. The timeout error is the one error kiss builds that can reach `failureOf` (a timeout during the body read arrives there with a response already seen), so it is the one marked in the `ours` `WeakSet` and passed on as it is.
|
|
28
|
+
- **A 4xx is never retried, a 5xx and a network error are.** A 4xx is the server rejecting _this_ request — repeating it cannot change the answer, and hammering an auth failure can get a key locked. A status that is not a whole number from 100 to 599 (a stub's `'TOKENX'`) is not a 5xx either, so it is not retried: the decision reads the same checked status as the message (`statusOf`). Backoff is linear (`200ms × attempts`), deliberately short: this is a build, not a daemon.
|
|
29
|
+
- **The attempt count is named in the rejection only when more than one attempt was made** (`… (after 3 attempts)`). With the default `retries: 0` the message is the single attempt's message as it is.
|
|
30
|
+
- **The URL is fetched as written and printed redacted.** Every message this module builds names the URL through `redactUrl` (`lib/utils.js`): no `user:pass@`, no fragment, each query value `[redacted]` — `Model fetch timed out after 20ms: <shown>`, `Model fetch failed: <shown> → 404 Not Found`, and `logger.debug('Model cache hit', <shown>)`. The query string is where an access token sits, and these messages land on stderr, in CI logs and in pasted bug reports; before 2026-11-06 all three carried it (measured). The request, the cache key and the resolver's `{ id }` keep the real URL.
|
|
31
|
+
- **No text from outside kiss reaches a failure's message.** Not an error's `message`, not its cause's, not the response's `statusText`, not the body. Node's `fetch` quotes the URL in its own errors (`Failed to parse URL from <url>`, `Request cannot be constructed from a URL that includes credentials: <url>`) and again in the cause's `input`; a server writes its status text and body (`403 Bad key <token>`, `Unexpected token 'S', "<token>" is not valid JSON` from `response.json()`); an injected `fetchImpl` can throw anything. Until 2026-11-06 a `scrubbed()` step edited that text to take the URL's spellings out, and each of three reviews found a shape it missed: a `localhost` failure whose `AggregateError` cause has an empty message (printed as a bare `fetch failed`), kiss's own timeout message put through a second redaction (`https://api.example.com//`, `?[redacted][redacted][redacted][redacted]`), and an error quoting a raw query the parser re-encodes (`?q=sec ret`). It was replaced, not patched: kiss decides the failure from facts it has (does the URL parse, does it hold credentials, did a 2xx response arrive) and never from an error's text, and every `Model fetch failed: <redactUrl(url)> → <what>` line is written by one function, `fails(url, what)`, from these parts only:
|
|
32
|
+
- a non-2xx: the status and its standard phrase from `node:http`'s `STATUS_CODES` (`403 Forbidden`, whatever the server's own status text said). The status is shown only when it is a whole number from 100 to 599 (`statusOf`); otherwise `a status kiss cannot read`;
|
|
33
|
+
- a network failure: the cause's (or, for a fetch with no cause, the error's own) `code`, `address`, `port` and `hostname`, **each only when it has the shape Node's value has** (2026-11-07; before, one pattern, `[A-Za-z0-9_.:-]`, served all four, and a token has that alphabet: `code: 'sk-live-ABC123.def_456'` was printed, measured in the code review of `b8be845`). A code is a string of `/^[A-Z][A-Z0-9_]*$/` (`ECONNREFUSED`, `UND_ERR_SOCKET`, `DEPTH_ZERO_SELF_SIGNED_CERT`), which also keeps out the `AbortError`'s numeric code `20`; an address is a string `net.isIP` accepts; a port is a whole number from 1 to 65535; a host name is `/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i`. So a replaced `fetch` can put a secret in a message only by putting it in a field as a value of that field's shape: a token that looks like a code, or like a host name. Node's own fetch puts only its constants there. An IPv6 address is bracketed so its colons are not read as the port's (`ECONNREFUSED 127.0.0.1:62059`, `ENOTFOUND no-such-host.invalid`). An `AggregateError` cause (`localhost`, which is `::1` and `127.0.0.1`) gives each entry of its `errors`, comma-separated: `ECONNREFUSED [::1]:51942, ECONNREFUSED 127.0.0.1:51942`;
|
|
34
|
+
- a cause message only when it is exactly one of `CONSTANT_CAUSES`: `redirect count exceeded` (a redirect loop), `other side closed` (a socket closed before the headers or in the middle of the body, shown with its code: `other side closed (UND_ERR_SOCKET)`), `bad port` (a port fetch refuses to use, such as 1 or 6000), `unknown scheme` (a model that begins `http` and is not `http:` or `https:`, such as `httpx://…`), and `self-signed certificate; if the root CA is installed locally, try running Node.js with --use-system-ca` (shown with `DEPTH_ZERO_SELF_SIGNED_CERT`). The first four were measured on Node 24.14.0 on 2026-11-06, the fifth on 2026-11-07; exact equality cannot carry a secret. The table of every cause measured on 2026-11-07 is in `AIKB/upstream.md` § Node's fetch. **A cause that is neither on the list nor described by its parts reads `an error kiss cannot describe (<name>)`**, with the error's name when it is one word of letters: the sentence says it is a gap, and the name is the lead. Two of the four were found that way, in a whole-build run after the list was first written, and the next one will look the same until it is measured and added. The other cause messages measured either carry an address, a port or a host name (`connect ECONNREFUSED 127.0.0.1:<port>`, `getaddrinfo ENOTFOUND <host>`) and are said from those parts instead, are empty (the `AggregateError`), or belong to a failure kiss names from its own facts (`Invalid URL`, the cause of `Failed to parse URL from <url>`);
|
|
35
|
+
- a 2xx whose `response.json()` threw a `SyntaxError`: `200, but the body is not JSON (text/html)`, the media type only when it is `type/subtype` in `[\w.+-]` after its parameters are cut; any other error from `json()` is the body failing to arrive, `200, but the body could not be read: <code>` (a socket closed mid-body: `other side closed (UND_ERR_SOCKET)`);
|
|
36
|
+
- a URL `URL.canParse` refuses: `not a URL that can be fetched`; a URL with `user:pass@`: `the URL holds a user name or password, which fetch will not send: put the credential in fetch.headers`. Both are decided before the first attempt (`checkUrl`);
|
|
37
|
+
- otherwise `an error kiss cannot describe (<name>)`, or `an error kiss cannot describe` when the name is not one word of letters.
|
|
38
|
+
|
|
39
|
+
The timeout error is the one kiss builds that reaches `failureOf`, so it alone is kept in the `ours` `WeakSet` and passed on as it is, never rebuilt. Until 2026-11-07 the non-2xx error and the cache-write error were marked too, though neither could reach `failureOf`. Measured on Node 24.14.0 (2026-11-06) through the real `fetch` against a local server, with a token in the query, the status text, the body and the password: a closed port on `127.0.0.1` and on `localhost`, an unknown host, a dropped socket, a redirect loop, 401, 403 with its own status text, 500, 500 retried, an HTML body, a body that is the token, a media type that is not one, a real timeout, two unparseable URLs and a URL with credentials. No token reached a message, a stack or a logger call. `test/unit/fetch-policy.test.js` holds each shape, through the real `fetch` where it can be made locally and through a stub shaped as Node's errors were measured where it cannot (an unknown host, the `AggregateError` on a machine whose `localhost` has one address).
|
package/AIKB/kiss-page.md
CHANGED
|
@@ -24,7 +24,8 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
24
24
|
- `markdown` — the copy `generate()` last wrote, in `buildTo`'s spelling, or `null`. `BuildPage.markdown` is this value.
|
|
25
25
|
- `async replaceInOutput(from, to)` → whether the page held `from`. It rewrites one exact string in the bytes this page wrote, then re-derives `hash`, `links` and `audit` from the new bytes, so everything that describes the page still describes what is on disk. A page that wrote nothing is left alone. Only the settle's CSS purge calls it, to follow a re-hashed stylesheet (`AIKB/css-purge.md`). The Markdown copy is not touched: it is converted from `<main>`, where no stylesheet link lives.
|
|
26
26
|
- `async writeMarkdownCopy()` — writes the copy of a page already written without one, from the HTML on disk, with a render's own conversion and claim: a page that rendered before the build called `.llms()` (from a `generate()` callback), whose `markdownTo` now names a copy `llms.txt` will link. A no-op for a page not written, one with its copy, or one that should have none. `Kiss._writeMissingCopies()` calls it for every page before `llms.txt` is written (Codex review, 2026-10-25: such a page was linked to a copy never written).
|
|
27
|
-
- `async generate()` — compiles the template, renders with `this.options`, injects the livereload script in dev, minifies in production (dev skips minification entirely), asserts the resolved `buildTo` is inside the resolved `buildDir`, converts the bytes to Markdown when `markdownTo` is set, writes the file (records its `hash`), then the copy beside it, and, in dev, the debug `.json
|
|
27
|
+
- `async generate()` — compiles the template, renders with `this.options`, injects the livereload script in dev, minifies in production (dev skips minification entirely), asserts the resolved `buildTo` is inside the resolved `buildDir`, converts the bytes to Markdown when `markdownTo` is set, writes the file (records its `hash`), then the copy beside it, and, in dev, the debug `.json` (the options without `config`); returns `this.buildTo`. Logs _and rethrows_ render/minify/escape/output-write errors.
|
|
28
|
+
- `withoutConfig(options)` → a shallow copy of a page's options with `config` removed: what both debug files write (the dev `.json` sibling here, `debug.json` in `lib/page-registry.js`). Never mutates `options`. It removes the one key by name; the config's header values are kept out of a page by the redaction where its copy is made, below.
|
|
28
29
|
- `loadMinifier()` — the one shared `Promise` for the `html-minifier-terser` import, resolving to its `minify`; every render awaits it.
|
|
29
30
|
- `preloadMinifier()` — starts that load without awaiting it, swallowing a failure that `loadMinifier()` will re-surface at the render that needs it. Called by the `Kiss` constructor for non-dev builds.
|
|
30
31
|
- **The consent wiring check.** `generate()` runs `consentWiringProblems` (`lib/consent-gate.js`) on every HTML page's rendered output, before the live-reload script is added and before minifying. Any problem fails the page in a real build, with `consent: <problems> (<view>)`, and warns in dev. It catches a gated service with no `{{consentHead}}`, a script ahead of the head, a missing or doubled banner, and a service loaded twice (`AIKB/consent-gate.md`).
|
|
@@ -35,7 +36,7 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
35
36
|
|
|
36
37
|
## Depended on by
|
|
37
38
|
|
|
38
|
-
`lib/page-registry.js` (`_preparePage` constructs and stacks a `KissPage`), and `lib/kiss.js` (`preloadMinifier`).
|
|
39
|
+
`lib/page-registry.js` (`_preparePage` constructs and stacks a `KissPage`; `logViewStats` writes `debug.json` through `withoutConfig`), and `lib/kiss.js` (`preloadMinifier`).
|
|
39
40
|
|
|
40
41
|
## Non-obvious behavior
|
|
41
42
|
|
|
@@ -54,7 +55,8 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
54
55
|
- `fs.outputFile`/`fs.outputJson` writes are `await`ed — `generate()` does not resolve until the file (and, in dev, the debug JSON) is actually on disk.
|
|
55
56
|
- **The injected livereload `<script>` resolves its host at runtime, in the browser.** It is a short inline script that builds `http://<location.hostname || 'localhost'>:<livereloadPort>/livereload.js?snipver=1` and appends a `<script>` element via `document.createElement` + `document.body.appendChild`. Two reasons for the shape: the URL used to hardcode `localhost`, so with `devHost: '0.0.0.0'` a page opened from another device asked _that device's_ localhost and live reload silently did nothing; and the element is appended rather than written with `document.write` (livereload's own README snippet), which browsers warn about for injected scripts. `?snipver=1` is livereload's protocol marker and must stay. The script survives verbatim because dev mode skips `minifyJS`.
|
|
56
57
|
- The injected livereload `<script>` points at `livereloadPort`, a plain field `Kiss._preparePage` sets from `config.livereloadPort` (the same value `startDevServer` binds). It is a field rather than a render option deliberately: page options reach the template context and the dev-mode `.json` sibling, and the port is engine plumbing, not page data. The port used to be hardcoded, so a second site's pages polled the first site's livereload server (review finding D-01).
|
|
57
|
-
- Dev mode (`isDev`) injects the livereload `<script>` tag immediately before `</body>` and writes a sibling `.json` file (same path with only its _trailing_ extension swapped, via `/\.[^.]+$/` — a plain `replace(ext, 'json')` would also hit an earlier occurrence, e.g. a folder named `html`) containing the fully-resolved `options` — useful for inspecting what a page rendered with.
|
|
58
|
+
- Dev mode (`isDev`) injects the livereload `<script>` tag immediately before `</body>` and writes a sibling `.json` file (same path with only its _trailing_ extension swapped, via `/\.[^.]+$/` — a plain `replace(ext, 'json')` would also hit an earlier occurrence, e.g. a folder named `html`) containing the fully-resolved `options` **without `config`** (`withoutConfig`, below) — useful for inspecting what a page rendered with.
|
|
59
|
+
- **Neither debug file carries `config`** (security review, 2026-11-06). Every page's `options.config` is the site's whole resolved config, and `GUIDE.md` names `config.fetch.headers` as where an API token goes; the dev sibling sits in the served build folder, and `viewStats()`'s `debug.json` (`lib/page-registry.js`) is not gated on dev, so it can be deployed. Both serialise `withoutConfig(options)`, one function so the two cannot drift. `config` is left out whole: it is the site's own file, not what these dumps are for, and what is kept is the page's own options (model included, which is what the files are for). That drops one key by name and nothing more: a controller that copies the config under another name (`{ ...options, site: options.config }`) gets it into both files, and `{{{stringify this}}}` prints it into the page itself. What keeps the token out of every one of those is the redaction at the source: each page's `options.config` is made by `lib/page-registry.js` through `redactFetchHeaders` (`lib/config.js`), so it carries the header names with every value `'[redacted]'` (`AIKB/page-registry.md`). `withoutConfig` is the second line of defence. The copy is shallow and taken at write time, so `options.config` itself is untouched and the render, controllers and helpers still read it. Pinned in `test/unit/kiss-page.test.js` and, for a whole dev build walked file by file, `test/integration/view-stats.test.js`.
|
|
58
60
|
- `generate()` logs an error and then **rethrows** it, so `Kiss.generate()` can collect the failure and `Kiss.complete()` can reject with it (v1 logged and resolved, so a site could "build" with a page missing). Four failure paths rethrow: a render throw (e.g. `Missing helper`), a minify throw, the outside-the-build-folder assertion, and a failed write of the _output_ file (its inner `catch` logs `Error creating <buildTo>` and rethrows, so the error is logged twice — once specific, once by the outer `Error processing view <view>` handler). The dev-mode debug `.json` sibling is the exception: it is a debug artifact, so a failure to write it stays log-only and the page still counts as built.
|
|
59
61
|
- A view that ends with `.hbs` is a **filename** and must be read from `pagesDir`; anything else is an inline template string. When the read fails, `_getTemplate` throws `Error reading view: <path>` (with the fs error as `cause`), so the failure reaches `Kiss.generate()`'s catch, lands in `_failures` and makes `complete()` reject. It used to log the error and fall through with `viewText` still set to the view filename, which compiled the _filename_ as the page body — a deleted or misspelled view "built successfully" with `about.hbs` as its content (review finding B3).
|
|
60
62
|
- A template that fails to _compile_ is still log-only: `_getTemplate` returns `null` and `generate()` takes the "Skipping page generation" branch instead of throwing — so an unparseable template is a skipped page, not a build failure.
|
package/AIKB/kiss.md
CHANGED
|
@@ -16,7 +16,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
16
16
|
- `.page(options)` — attaches a per-page copy of the config, resolves the page's model, runs its controller, prepares a `KissPage`, gives it an identity (`options.id`, or the default derived in `_preparePage` — see the identity bullets in `AIKB/page-registry.md`), pushes it onto `_stack`; returns `this`.
|
|
17
17
|
- `.pages(options)` — same as `.page()` with `options.dynamic = true` (one page per model in an array); returns `this`. A bad item fails only its own page and is reported on its own — see the `_prepareMultiplePages` bullet below.
|
|
18
18
|
- `.scan()` — globs every `.hbs` under `config.folders.pages` (via `utils.globFiles`, so the list is sorted and posix-normalised) and calls `.page()` for any view not already in `_registrations`; sets `_scanRequested` so watch rebuilds scan again; returns `this`.
|
|
19
|
-
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
19
|
+
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder — each page's `{ view, buildTo, runCount, options }`, the options without `config` (`AIKB/page-registry.md`) — and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
20
20
|
- `.generate(callback)` — sets the `_generateRequested` latch, awaits `_promises`, renders every stack entry with `runCount === 0` once (each render caught, failures collected on `_failures`), awaits the writes, invokes `callback` through `_runCallback` (so a callback that throws, or returns a rejecting promise, is collected on `_failures` too); returns `this`.
|
|
21
21
|
- `.complete(callback)` — awaits `_settle()` (drain, then render whatever the drain left unrendered, repeated until the stack is stable), then **rejects with an `AggregateError`** if `_failures` is non-empty and `_failuresReported` is not yet set; otherwise invokes `callback` with the resolved data (never on a failed build) and returns that data (a `Promise`, unlike the other chainable methods). Under `cleanBuild: 'atomic'` this is also the promotion point: a settle with no failures promotes the staging folder, a settle with failures discards it — and in check mode a settle with no failures discards it too. It is also where the build report is assembled (`_finishBuild()`), so `report()` and `err.report` are set by the time either outcome reaches the caller.
|
|
22
22
|
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`.
|
|
@@ -43,6 +43,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
43
43
|
- **`complete()`'s settle path encodes the image variants straight after the CSS purge** (`_encodeImages()` → `encodeImages()` in `lib/build-finish.js`, `AIKB/image-encode.md`), before the redirects, the cache headers, the packages, the link scan and the audit, all of which need the files on disk. Four fields hold its state: `_imageIndex` (one `createImageIndex()` for the life of the instance, because the helpers hold it; reset, not replaced, by the replay), `_imagesRun` (the latched promise), `_imagesResult` (the report's `images`) and `_imageOutputs` (the variants the last encode wrote, so the next removes the ones no page asks for). The constructor hands `registerHandlebarsHelpers` an `images` dependency of functions, never snapshots: `locate(name)` joins the manifest's output to `_writeRoot` at the call, so `{{image}}` reads the staged copy under `'atomic'` and check.
|
|
44
44
|
- **`complete()`'s settle path writes the learning packages straight after `_redirects`** and the banner registry (`_writePackages()` → `writeLearningPackages()` in `lib/build-finish.js`), above the link scan, the audit and the three-way branch, for the redirects' reasons. Three fields hold its state: `_packagesRun` (the latched promise), `_packagesResult` (the report's `packages`) and `_packagesDevNoticed`; `_registrationSeq` numbers page registrations for `order` (`AIKB/page-registry.md`).
|
|
45
45
|
- **The watch session — the event dispatch, the serial rebuild queue, the replay and its orphan sweep, scoped re-renders and live reload — is `lib/rebuild.js`** (`AIKB/rebuild.md`, where the notes on all of those, including the dispatch and live-reload tables, now live). This file keeps `watch()` as the entry point and `close()`, which drains the queue.
|
|
46
|
+
- **The constructor's `verbose` log line prints the config with every `fetch.headers` value replaced by `'[redacted]'`**, the header names kept (security review, 2026-11-06). It is a redacted copy, so the live config still sends the headers. A single-key redaction is a decision, unlike the debug files' allow-list (`withoutConfig`, `AIKB/kiss-page.md`): those two files are served or deployed, so they leave `config` out whole; this line goes to the developer's own terminal or CI log, `fetch.headers` is the one place kiss documents for a secret, and anything else a site puts in its config is the site's to keep out of it. The line is handed to the logger whatever `verbose` says — `createLogger` gates `debug` on it — so an injected logger receives it either way; `test/integration/view-stats.test.js` records every logger call and searches them for the token.
|
|
46
47
|
- **Registration, page preparation and page identity — `_page`, `_prepareMultiplePages`, `_preparePage`, the id index, `_stackForRecord`, and the bodies of `scan()` and `viewStats()` — are `lib/page-registry.js`** (`AIKB/page-registry.md`, where the notes on output-path and id collisions, the fan-out, the per-page config and `scan()`'s dedupe now live). This file keeps the public methods and the order things run in.
|
|
47
48
|
|
|
48
49
|
- Pipeline: `.page()`/`.pages()`/`.scan()` queue work (model → controller → `_preparePage`) as one caught promise per page on `_promises`; nothing renders until `.generate()`. `.generate()` awaits `_promises`, renders every stack entry whose `runCount` is 0, awaits the writes, then fires the callback. `_generating` tracks `generate()`/`sitemap()` runs, `_callbacks` the promises callbacks themselves returned. `complete()` drains all three lists repeatedly because callbacks may queue more work, and then renders anything the drain left unrendered.
|
|
@@ -75,9 +76,8 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
75
76
|
- **A `generate()`/`sitemap()` callback that throws is a build failure**, recorded by `_runCallback` as `{ view: '<generate callback>' | '<sitemap callback>', buildTo: null, error }` — not swallowed by the enclosing `.catch()`, which would log it as `Error generating site` or, worse, `Error creating sitemap.xml` for a sitemap that had in fact been written correctly (review finding A-04). The two catches are now distinct: the enclosing one covers the engine's own work, `_runCallback` covers the consumer's. The callback's return value is still not awaited by the `generate()` run itself (see the `complete()`-inside-a-callback bullet below), so a promise it returns gets its own `.catch` — but that caught promise is now recorded on `_callbacks`, which `_drain()` waits for (review finding A-03). So an async callback that rejects is reported by the `complete()` that was already draining, not deferred to a later one; the only rejection still reported late is one from a callback registered after every `complete()` has finished.
|
|
76
77
|
- **A dev server that cannot bind is a build failure**, recorded as `{ view: '<dev server>', buildTo: null, error }` — the same shape as a callback failure, since there is no output path to name it by (review findings F-E2/F-E3). It used to be non-fatal and mislabelled: the constructor's `ready.catch` logged `Error running live reload server` for the _static_ server, a second copy of the same fault had already been logged inside `lib/dev-server.js`, and the build then ran to completion with the process left up serving nothing behind an already-printed `Serving …` line. Now `dev-server.js` logs nothing for a bind failure and `ready` carries the whole message; the constructor logs it once, records the failure so `complete()` rejects, and then `await this.close()`s — unlike live reload, which is optional, there is nothing useful to do without the HTTP server, and the watcher plus the (successfully bound) livereload server would otherwise hold the process open long after the consumer's rejection handler had run. The `ready.catch` promise is pushed onto `_generating` so the drain waits for the bind result: a small site would otherwise race the `'error'` event and `complete()` could resolve before the failure was recorded. A synchronous throw out of `startDevServer` takes the same path, and `watch()` is never reached in that case.
|
|
77
78
|
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
- `_promises` must only ever contain _handled_ (caught) promises — a bad model rejecting unhandled crashed the process in v1. The `.page()` chain's `.catch()` always resolves to `{ id, data: null, error }` instead of rejecting.
|
|
79
|
+
- **A model that cannot be resolved is a build failure, and so is a controller that fails** (2.14.0; until then a model failure was logged and the page skipped, and the report said `ok: true` with the page missing). A missing `.json` file (`Model not found: <file>`), an empty or missing model folder (`Model folder empty or missing: <folder>`, a `.pages()` fan-out included), a URL model whose fetch fails, a controller that throws, a controller file that does not exist, a controller module that does not export a function, and an unrecognised `controller` type (review finding F-01) all fail the page: `page()` runs the model, the controller, slug/path derivation and `_preparePage` inside **one** `try` (`registerPage`, `AIKB/page-registry.md`), whose one `catch` pushes `{ view, buildTo: null, error }` onto `_failures` and resolves the chain to `{ id, data: null, error }` — so `_promises` never rejects _and_ `complete()` reports it. One `try` rather than one per stage is what keeps it to one failure per page: a controller failure pushed by an inner catch and rethrown would be pushed again by an outer one. A `.pages()` fan-out item never reaches it — it records its own per-item entry and keeps going. A failed page's previous output is removed by the next whole-site replay, and the failure is not carried across it (`_carriedFailures`), so restoring the model file clears it.
|
|
80
|
+
- `_promises` must only ever contain _handled_ (caught) promises — a bad model rejecting unhandled crashed the process in v1. The `.page()` chain catches every failure and resolves to `{ id, data: null, error }` instead of rejecting; the failure reaches the caller through `_failures`, not through the chain.
|
|
81
81
|
- `_partialNames` holds what the last `registerPartials()` pass registered, and is handed back to the next one. Handlebars never forgets a registration on its own, so without that set a partial deleted from disk would keep rendering its last-known content for the life of the process (review finding B1). The state lives here, not in `partials.js`, because the Handlebars env is per-instance — see `AIKB/partials.md` for why the diff is by name across all four passes rather than per file. A page that still references a deleted partial now fails on the replay: `complete()` collects it in `_failures` and the rebuild queue logs `Error rebuilding site`, and the page's stale output is left alone because it was never rewritten.
|
|
82
82
|
- Handlebars and Remarkable are created fresh per `Kiss` instance so partials/helpers from one instance never leak into another (matters for tests that build multiple sites in one process).
|
|
83
83
|
- **Remarkable is constructed from `config.markdown`, and _where_ in the constructor is load-bearing.** `.md` partials are rendered to HTML by `registerPartials()` at registration time, not at page render, so the renderer has to exist in its final configuration before that call. Building it from the resolved config a few lines earlier is what lets a site set `markdown: { breaks: true }` and have it reach partials — a consumer mutating `kiss.remarkable` after construction changes the `{{markdown}}` helper (which renders per page) but silently misses every partial unless they also call `kiss.registerPartials()`. See `AIKB/config.md` and `AIKB/partials.md`.
|