kiss-ssg 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AIKB/audit.md +5 -3
- package/AIKB/build-finish.md +1 -0
- package/AIKB/build-report.md +3 -2
- package/AIKB/config.md +1 -1
- package/AIKB/consent-client.md +1 -0
- package/AIKB/consent.md +2 -0
- package/AIKB/design.md +2 -2
- package/AIKB/embed.md +4 -0
- package/AIKB/handlebars-helpers.md +2 -2
- package/AIKB/kiss.md +1 -0
- package/AIKB/page-registry.md +3 -0
- package/AIKB/rebuild.md +1 -1
- package/AIKB/redirects.md +1 -1
- package/AIKB/speed.md +1 -1
- package/AIKB/testing.md +2 -3
- package/AIKB/upstream.md +30 -8
- package/AIKB/zip.md +1 -1
- package/CHANGELOG.md +122 -0
- package/GUIDE.md +58 -12
- package/README.md +3 -3
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/11-blog/AIKB/last-build.json +2 -1
- package/examples/9-migrated-from-v1/AIKB/last-build.json +4 -3
- package/examples/9-migrated-from-v1/router.js +1 -1
- package/examples/9-migrated-from-v1/src/pages/index.hbs +1 -1
- package/examples/9-migrated-from-v1/src/pages/smaller-changes.hbs +3 -3
- package/lib/audit.js +32 -13
- package/lib/build-finish.js +10 -0
- package/lib/build-report.js +7 -0
- package/lib/config.js +19 -0
- package/lib/consent-client.js +17 -3
- package/lib/consent.js +26 -1
- package/lib/embed.js +157 -14
- package/lib/handlebars-helpers.js +82 -25
- package/lib/kiss.js +23 -0
- package/lib/page-registry.js +64 -1
- package/lib/redirects.js +11 -2
- package/llms.txt +9 -7
- package/package.json +14 -14
- package/types/audit.d.ts +13 -1
- package/types/build-report.d.ts +8 -1
- package/types/config.d.ts +16 -2
- package/types/consent.d.ts +21 -0
- package/types/embed.d.ts +31 -4
- package/types/kiss.d.ts +29 -0
- package/types/page-registry.d.ts +26 -0
- package/types/redirects.d.ts +6 -2
package/AIKB/audit.md
CHANGED
|
@@ -8,9 +8,9 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`,
|
|
11
|
+
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`, the three image checks below (`image-oversized`, `image-location-metadata`, `img-not-responsive`), and `id-slug-mismatch`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
12
|
- `MARKETING_WORDS` — the frozen word list `checkbox-prechecked` matches a label against (offer(s), news, newsletter(s), marketing, promotion(s), promotional, partner(s), third party/parties), whole-word and case-insensitive, a space also matching a hyphen.
|
|
13
|
-
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind }]` (every absolute or `//host` origin a fetching element names, sorted); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
|
|
13
|
+
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind, service?, tracker? }]` (every absolute or `//host` origin a fetching element names, sorted; `tracker: true` when one of its URLs is on `TRACKER_ORIGINS`); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
|
|
14
14
|
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess, trailingSlash, privacyPage })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess` and `trailingSlash` `config.links.trailingSlash`, passed so a cross-page fragment, and a link to the privacy page, resolves to the page the link scan would resolve the same href to (`servedAs()`, which keeps a learning package's page on its file's folder, as `checkLinks` does). `privacyPage` is `config.audit.privacyPage`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl, id, package }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
15
15
|
- `thirdPartyOrigins({ pages, siteUrl })` → `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, or `null` when no page wrote HTML facts. Called by `lib/build-finish.js`'s `reportInputs()` on every report assembly; `lib/build-report.js` maps each `page` out of staging and nulls the key on a failed build.
|
|
16
16
|
- The `CheckId`, `PageFacts`, `PageOrigin`, `PageConsent`, `PageForm`, `FormField`, `AuditFinding`, `AuditResult`, `AuditPage` and `ThirdPartyOrigins` typedefs.
|
|
@@ -19,7 +19,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
19
19
|
- **`PageFacts.consent`** is `{ head, banner, settings, gated, signatures }`. `signatures` is `adsbygoogle`, `googletag.cmd`, `gtm.js` or `gtag/js` found in an ungated executable inline script, and `data-ad-client` on an `<ins>`. A script inside a comment or a `<template>` is not read, since a browser never runs it; until 2026-11-03 a commented-out snippet was reported as a tracker (round 6 of the Codex review). The `data-kiss-consent-head` script is never scanned, because its inlined config may name `gtm.js`. The field is absent from facts parked by an older engine.
|
|
20
20
|
- **`thirdPartyOrigins`** puts a sorted `services` beside `kinds` on an origin a service gates. Every other entry keeps its old shape.
|
|
21
21
|
- **`auditBuild`** takes `consent` (the resolved `config.consent`, or `null`) and runs eight checks, appended to `CHECKS`:
|
|
22
|
-
- `consent-missing`: no consent manager, and a page loads from `TRACKER_ORIGINS` or carries `gtm.js`/`gtag/js` inline;
|
|
22
|
+
- `consent-missing`: no consent manager, and a page loads a script or frame from `TRACKER_ORIGINS` (matched on the whole URL, so a path-qualified entry counts) or carries `gtm.js`/`gtag/js` inline;
|
|
23
23
|
- `consent-ungated`: a third-party script or iframe no service gates, and no `necessary` service lists in its `origins`, a `<noscript>` GTM frame included;
|
|
24
24
|
- `consent-ads-cmp`: any page, consent or not: an AdSense or Ad Manager loader, an `adsbygoogle`/`googletag.cmd` signature, or `<ins data-ad-client>`;
|
|
25
25
|
- `consent-objection-third-party`, site-wide;
|
|
@@ -38,6 +38,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
38
38
|
- **`image-location-metadata`**: from the build-folder walk, every raster whose header (`lib/image-header.js`, the reader `{{image}}` uses: the EXIF GPS IFD and XMP) names a latitude or longitude, referenced or not. `page` is the walked file and the summary counts files (`FILE_CHECKS`). The fix is in `src/`: the original is still copied, so `{{image}}` alone does not remove it.
|
|
39
39
|
- **`img-not-responsive`**: per page, each distinct `imgPlain` src that resolves to a raster in the build: the worklist for migrating to `{{image}}` (D-F3). An SVG, a missing file and an external URL are not named.
|
|
40
40
|
- The first two read files in the build folder, so they join `WALK_CHECKS` and are skipped, and listed in `skipped`, under `cleanBuild: false`. The audit as a whole is skipped in dev, where `{{image}}` writes a plain `<img>` that `img-not-responsive` would otherwise name.
|
|
41
|
+
- **`id-slug-mismatch`**, per page, from each page's `idSlug`: the detail `lib/page-registry.js` `idSlugMismatch` decided in `runAudit` (an explicit id built from a slug `toSlug` changed). Reported for **every** written page, not only the HTML ones the other per-page checks read, because identity belongs to the registry and a JSON or feed page has one too.
|
|
41
42
|
- **`host-unset`**, site-wide, from the `hostUnset` option: the concerns (`'cache headers'`, `'redirects'`) whose writer had something for a host to read and found neither `config.host` nor that concern's `format` set. One finding, `page: null`, `detail` the concerns sorted and joined. It is the only check fed by another module's result rather than by page facts or the walk: `lib/build-finish.js` `runAudit` reads `_cacheHeadersResult.unset` and `_redirectsUnset`, so it runs after both writers. The redirects writer's `warn` line is unchanged; this is the same fact, kept with the rest of the audit.
|
|
42
43
|
|
|
43
44
|
## Depends on
|
|
@@ -77,6 +78,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
77
78
|
- **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields and the five speed fields (`?? []`, `?? null`, `?? 0`); facts are extracted in memory and never parked across engines, so in practice only a hand-built test lacks them.
|
|
78
79
|
- **The privacy facts read quote-aware** (`ATTRS`, `TAG_END`), for the fragment checks' reason: a misread form, label or checkbox is a finding invented, and Alpine-style `x-show="step > 1"` values are common on exactly the forms these read. `origins` reads from the ids' strip (opening tags kept, so `<script src>` is seen; script, style and template contents gone). `forms`, `anchors`, `placeholders` and `prechecked` read from the counts' wider strip, so a `<mark>` in a script string or a comment never fires.
|
|
79
80
|
- **`origins` is its own kind-aware fact, not derived from `KissPage.links`** (2026-11-02 critique, blocker 1). `links` is a flat, sorted string list with no tag kind, and it includes every `<a>`, which a browser does not fetch. Kinds: `script`, `iframe`, `img` (`<img>` `src`/`srcset`, `<source srcset>`, `<video poster>`), `media` (`<video>`/`<audio>` `src`, `<source src>`), `form-action`, `stylesheet`, `preconnect`, `dns-prefetch`, and `preload` or `preload:<as>`. Never an `<a>`, a canonical or an alternate link. Only absolute `http(s)` and `//host` references are kept, since a relative one can only be the site's own. Static markup only: an iframe a script injects (a1k9's facade) and a CSS `@import` are invisible, so an empty list proves nothing.
|
|
81
|
+
- **Whether an origin is a tracker is decided at extraction, from the whole URL** (2026-11-05). A `TRACKER_ORIGINS` entry can name a path (`www.google.com` under `/maps`), and the kept `origin` has none, so matching it later can never see the path: 2.12.0 and earlier compared the origin, and a raw Google Maps frame raised no `consent-missing` (a1k9training's `/contact/`). `extractOrigins` runs `matchesHost` on the URL and records `tracker: true` on the `{ origin, kind, service }` entry, which stays one entry per page however many URLs share it; `checkConsent` reads the flag. The ads check is different on purpose: it strips its hosts' paths and matches the origin, so any script from an ad host counts.
|
|
80
82
|
- **First-party is the host, not the origin** (`thirdPartyOrigins`). An absolute URL whose host equals `siteUrl`'s, with a leading `www.` dropped on both sides, is the site's own, whatever its scheme or port. A subdomain (`cdn.site.example`) is not. With `siteUrl` unset, unparseable or local (`isLocalSiteUrl`, the `site-url-local` rule), every absolute origin is listed and `siteUrl` is `null`: the audit does not guess which one is the site.
|
|
81
83
|
- **`privacy-link-missing` finds the privacy page by id, then by path, and never by link text.** The page whose `id` is `privacy` (or `config.audit.privacyPage`), else, with no override, the page written to `privacy.html` or `privacy/index.html`. A page counts when one of its forms has a field that is not `hidden` (or another non-typed input), not a honeypot (`bot-field`, or the field the form's `netlify-honeypot` names), and has `type` `email`/`tel`, a `name` of `email`/`e-mail`/`tel`/`phone`/`telephone`, or an `autocomplete` token `email` or `tel…`. It is satisfied by an `<a>` anywhere on the page whose href resolves, through `classifyReference` and `resolveTarget` with `exists: () => false`, to the privacy page. An external href never counts, and the privacy page needs no link to itself. A page with a `<base href>` is not checked at all: its relative links resolve somewhere the build cannot see, so a link a browser follows to the privacy page was reported missing, and one leaving the site could pass (a Codex review, 2026-11-02). The fragment checks skip such a page for the same reason. The `detail` is that field's name (or type). No privacy page at all is one more site-wide finding, said only when some page collects; a `privacyPage` no audited page has is a site-wide finding too, said whatever the forms, rather than a config error, because only the build knows which ids exist.
|
|
82
84
|
- **`placeholder-left` is `<mark>` or the bracketed `[to confirm`, never the bare phrase.** "to confirm" appeared twelve times as prose in the fleet's sources (2026-11-02 critique). A `[to confirm` inside a `<mark>` is one placeholder, not two. The bracketed text inside a `<code>` or `<pre>` element is not read at all: it is the marker quoted, and kiss's own guide page for this check fired on itself until it was excluded (2026-11-02). A real `<mark>` element still counts wherever it is, because an escaped one (`<mark>`) is not an element.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -54,6 +54,7 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
|
54
54
|
## Non-obvious behavior
|
|
55
55
|
|
|
56
56
|
- **`runLinkCheck()` and `runAudit()` both pass `config.links.trailingSlash`**, so a relative reference on a directory index resolves against the URL the host serves it at, the same policy `{{link}}` was written under; `runAudit()` passes each page's `package` too, which keeps a learning package's page on its file's folder (Codex round 6, 2026-11-03).
|
|
57
|
+
- **`runAudit()` decides each explicit-id page's `idSlug`** with `page-registry.js` `idSlugMismatch` (the slug as `options` gave it against `entry.page.slug`, the one written) and hands it to `auditBuild` as data, so the identity rule stays in the registry and the audit only reports it as `id-slug-mismatch`.
|
|
57
58
|
- **`runAudit()` threads each page's `id` and `config.audit.privacyPage` into `auditBuild`**, so `privacy-link-missing` can find the privacy page by id. The pages come from `_stackForRecord()`, not the raw stack, so the ids are the ones the report shows: a default `privacy` that two pages arrived at, which `{{link}}` would refuse, finds no privacy page either.
|
|
58
59
|
- **`reportInputs()` derives `thirdParty` on every assembly**, from the audit facts each written page parked (`thirdPartyOrigins`, `lib/audit.js`), against the instance's `config.siteUrl` (a page's own `siteUrl` override is not consulted for this key). It reads no file, so it costs a walk over the stack and is current under `_refreshReport()` too; `buildReport` nulls it on a failed build. It comes after `packages`, as the report's key order fixes it.
|
|
59
60
|
- **`reportInputs()` derives `consent` last, the same way:** `consentReport` (`lib/consent.js`) from `config.consent` and each written HTML page's parked `audit.consent`. Pages carrying gated markup are passed as `gatedPages`, from which `pagesWithoutHead` is derived. Paths stay as written (staging under check) for `buildReport` to map. It is `null` without `config.consent`. `runAudit` passes `config.consent` to `auditBuild` for the seven consent checks.
|
package/AIKB/build-report.md
CHANGED
|
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
|
|
9
|
+
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images, ids })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
|
|
10
10
|
- `ok` — `failures.length === 0`.
|
|
11
11
|
- `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
@@ -23,7 +23,8 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
24
|
- `audit` — after `outputs` (then `packages`, then `thirdParty`): `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
25
|
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON. After the packages, one ` third-party origins (N): <the first five>, …` line when `thirdParty` lists any, then one ` consent: version <v>, banner <hash>, N services` line when `consent` is set.
|
|
26
|
-
- **The `
|
|
26
|
+
- **The `ids` key, last, after `images`** (2026-11-05, path ids): `config.ids`, `'view'` or `'path'`, always present. It is in the report so it reaches `last-build.json`, where the next build's `redirectFindings` reads it back and pairs `moved` by id only within one convention (`AIKB/redirects.md`). Every recorded `last-build.json` gains `"ids": "view"` at its next `kiss-ssg aikb`; examples 9 and 11 were re-recorded for it.
|
|
27
|
+
- **The `images` key, after `cacheHeaders`** (workstream F). `null` when no page rendered an `{{image}}`, in dev, and on a failed build; otherwise `{ requested, encoded, cached, cache }` from `lib/build-finish.js` `encodeImages`: counts only, so `last-build.json` does not grow by a line per variant, and `cache` is `assets.images.cache` as configured. The variants stay out of `assets`, so `report().assets` and the `check` baseline do not move. Every recorded `last-build.json` gains `"images": null` at its next `kiss-ssg aikb` (examples 9 and 11 were re-recorded for it). `image-oversized` joins `VALUE_CHECKS` (the line names the file and its size) and `image-location-metadata` joins `FILE_CHECKS`.
|
|
27
28
|
- **The `cacheHeaders` key, after `consent`.** It is `null` when nothing was written: no hashed file, a dev build, or a failed build. Otherwise it is `{ rules: [{ path, headers }], files, formats, unset, merged }`. `files` are mapped out of staging; `rules` holds URL paths, which carry no staging prefix. It moves every recorded `last-build.json`, which gains `"cacheHeaders": null` at its next `kiss-ssg aikb`.
|
|
28
29
|
- **The `consent` key** (2.11.0), just before `cacheHeaders`. It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
|
|
29
30
|
- Every page path goes through `real()` and is then sorted.
|
package/AIKB/config.md
CHANGED
|
@@ -7,7 +7,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
11
|
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
12
|
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
13
13
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
package/AIKB/consent-client.md
CHANGED
|
@@ -64,6 +64,7 @@ In the browser, nothing but `window`. In Node, `node:crypto` (`consentClientName
|
|
|
64
64
|
- at `DOMContentLoaded` for the stored choice or an `objection` category;
|
|
65
65
|
- on the click for a new grant.
|
|
66
66
|
- **What scripts miss:** revived scripts never see `DOMContentLoaded` (`AIKB/upstream.md`).
|
|
67
|
+
- **Only a Load click asks a video to play.** `reveal(box, clicked)` sets `autoplay=1` on the cloned iframe's `src` (through `URL`, before the frame is in the page, so the player loads once) when `clicked` is true and the box carries `data-kiss-embed-autoplay` (`lib/embed.js`, a video service's box). The `load` action passes `true`. `revealAllowed()`, the reveal from a stored choice or an Always allow grant, passes nothing, so a page load or a grant never starts a video with sound on its own. The browser suite reads the frame's `src` both ways, and was red without this change (2.13.0).
|
|
67
68
|
- **Withdrawal reloads.** A choice that refuses a category active on this page, or a service this page started, takes the withdrawal path. A service counts as started when its embed was revealed, a gated script of it was revived, or a `when()` callback for it ran, each recorded by `started()`. Until 2026-11-03 only a revealed embed counted, so a service started under an "always allow" grant by `when()` or its own scripts, with its category refused, kept running after Reject all, and its cookies stayed (found by the Codex review):
|
|
68
69
|
1. write the cookie, send the `update`, append to the log and send the POST;
|
|
69
70
|
2. delete the first-party cookies and storage of every denied service, on no domain, the host and each parent domain (S7). A `__Secure-` or `__Host-` cookie is deleted with `Secure`, which a browser requires, and a `__Host-` one on no domain only, since it cannot carry one (found by the Codex review, 2026-11-03);
|
package/AIKB/consent.md
CHANGED
|
@@ -73,6 +73,8 @@ It writes no markup and runs nothing in a browser. The gated tags are `lib/conse
|
|
|
73
73
|
- **A preset's provider is part of `version`.** On 2026-11-04 the `youtube` preset's provider became Google (the operator: "YouTube is Google"). The provider names the controller relying on the consent, so the change moved the purposes version of every site using the preset, and their visitors are asked again on upgrade. That is the rule working as written above, not a side effect. The changelog's upgrade note says so. The same day, the embed wording's defaults moved from `{provider}` to `{service}` (the label) for its two buttons, so a YouTube embed does not offer "Always allow Google" for a grant of YouTube alone. `servicesLabel` names the dialog's service lists. A service's `policy` became an href that day, so `policyUrl` accepts only an absolute http(s) URL or null, and fails the build on anything else (a page id, `javascript:`, a bare host). It is not part of `version`.
|
|
74
74
|
- **First- and third-party storage.** A stored item (a cookie, or an item under `storage`) with a `domain` is third-party unless `party: 'first'` says otherwise. `clientConfig` hands the browser first-party names only, because a page can delete nothing else. The presets' YouTube storage and Vimeo cookies are third-party: listed in the cookie table, never deleted.
|
|
75
75
|
- **A storage item has a `kind`:** `local` (the default), `session` or `indexeddb`; a cookie's is `cookie`, and `kind` on a cookie entry is refused. The cookie table names it (`STORAGE_KINDS`: Local storage, Session storage, IndexedDB), and `clientConfig` sends only first-party `local` names, since the client deletes `localStorage` keys and nothing else. Added 2026-11-04 with the YouTube preset's IndexedDB database `YtIdbMeta`: before it, every storage item was labelled "Local storage", which would have put a false row in every YouTube site's table. `kind` is not part of `version`.
|
|
76
|
+
- **`embed.video`** (a flag, `false` by default; `true` on the `youtube` and `vimeo` presets) marks a video player. `lib/embed.js` reads it to put a play mark on a postered Load button, to label the button with `embedPlay` rather than `embedLoad`, and to mark the box for autoplay on Load.
|
|
77
|
+
- **`DEFAULT_TEXT.embedPlay` ("Play video") and `embedWording(text, own)`.** A video's button has its own key. `embedWording` makes it follow a site's `embedLoad` when the site set that and not `embedPlay`, so a site that translated `embedLoad` before 2.13.0 does not gain an English word on its videos. `resolveUi` applies it to `ui.text`, and `lib/embed.js` `resolveEmbeds` to `embeds.text`. It is not in the banner hash, which is taken over the banner's and dialog's rendered HTML only (`bannerHash`), so a new text key changes no site's records.
|
|
76
78
|
- **An embed's frame sizing is config** (`embed.ratio`, `width`, `height`, `measure`), resolved here and written by `lib/embed.js` (`AIKB/embed.md`). `ratio` defaults to `'16/9'`; `null` means the frame's content decides its height, starting at `height` (600 px when unset), and `measure: true` follows the height the frame reports. The `instagram` preset is the one that uses them (2026-11-04).
|
|
77
79
|
- **`presetService(name)`** resolves a preset alone, as a service with no site settings: what `{{embed}}` loads with no `config.consent`. `null` for a name with no preset.
|
|
78
80
|
- **A service may not take a category's name.** `kissConsent.has()` and `when()` take a category or a service by one name and answer for the category first, so a service named `necessary` would have run before any choice (found by the Codex review, 2026-11-03).
|
package/AIKB/design.md
CHANGED
|
@@ -105,8 +105,8 @@ keeps the documented window: the client is livereload-js's, not ours, and a huma
|
|
|
105
105
|
it.
|
|
106
106
|
|
|
107
107
|
**The watcher.** Chokidar is the Node norm. Eleventy uses it outright; Vite still uses chokidar 3.6 for files
|
|
108
|
-
outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar
|
|
109
|
-
none of the three problems above is the watcher's.
|
|
108
|
+
outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar 6.0.0 (taken
|
|
109
|
+
2026-11-06, the latest then), and none of the three problems above is the watcher's.
|
|
110
110
|
|
|
111
111
|
Sources, as checked on 2026-09-23: Jekyll PR jekyll/jekyll#8459 and `docs/configuration/options` and
|
|
112
112
|
`docs/configuration/incremental-regeneration`; Eleventy issue 11ty/eleventy#3001, discussion #2294,
|
package/AIKB/embed.md
CHANGED
|
@@ -32,6 +32,10 @@ It exists because of the 2026-11-04 clean room (`planning/reviews/2026-11-04-emb
|
|
|
32
32
|
## Non-obvious behavior
|
|
33
33
|
|
|
34
34
|
- **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
|
+
- **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
|
+
- **`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.
|
|
37
|
+
- **Autoplay is asked for by the click, never written into the frame.** A `video` service's box carries `data-kiss-embed-autoplay` unless the call said `autoplay=false`. The facade's click handler, and the consent client's `load` action (`AIKB/consent-client.md`), set `autoplay=1` on the cloned iframe's `src` before inserting it. The `<template>`'s own `src` never has it, because the consent client also reveals a frame from a stored choice, with no click, and that must not start a video. Measured in headless Chromium 156 with a real YouTube video (2026-11-05, `aqz-KE-bpKQ`): playing unmuted at 6.4 s, eight seconds after the click; the `autoplay=false` control paused at 0. iOS Safari is not measured.
|
|
38
|
+
- **A poster (`poster`, markup the helper already wrote with the `{{image}}` pipeline) is the one case where kiss writes colour** (the operator, 2026-11-05). The 2026-11-04 geometry-only rule assumed a plain box. Text on a photo is unreadable without a backing, so the operator chose a readable default over geometry only. With a poster, the box gains the class `kiss-embed--poster` and `position:relative;justify-content:flex-end`. The still is a `.kiss-embed__poster` wrapper, absolute at `inset:0`, carrying the same `data-kiss-embed-ui` / `data-kiss-consent-ui` attribute as the notice, so both reveal paths hide it with no client change. The notice becomes a compact full-width strip: padded, `.875em`, tight paragraph margins, and the watch link beside the button rather than under it. This came from the operator's look at `/branch-close` (2026-11-05). On an unstyled site at 375 px the first strip covered 69% of the box with its text against the edges; the compact one covers 44% at 375 px (52% at 320, 9% at 1440), with the text 12 px in. The browser suite holds it under 50% at 375 px and under 15% at 1440, and was red without it. It is all inline, because a `<style>` per embed would be the CSS audit's `inline-style-repeated`, so no media query is possible. Its colours are **custom properties with fallbacks** (`--kiss-embed-poster-scrim`, `--kiss-embed-poster-text`), because a site cannot beat an inline style without `!important` but can set a variable. The notice's paragraphs and the watch link get `color:inherit` inline. That beats any rule on them: the consent theme's `.kiss-embed__notice p` is muted, which measured `color(srgb .3 .3 .3)` on the strip in Chromium (Codex, slice 3), and a site's own link colour (a1k9training's is `text-brand-700`) would be as bad. The browser suite checks every `p` and `a` in the strip, in the facade and under the consent theme. A service whose `embed.video` is set (youtube, vimeo) gets `PLAY_MARK` on its Load button: an `aria-hidden` SVG, not `▶`, which iOS draws as a colour emoji. Without a poster the markup is byte-identical to before.
|
|
35
39
|
- **The box holds the frame's space, so a video's frame loads without moving the page** (the operator, 2026-11-04, after the after-run's screenshot showed a bare paragraph, button and link). The box's inline style is geometry only: a centred flex column, `box-sizing:content-box`, the frame's `aspect-ratio` (or `min-height` of its starting height), and the same width cap. No colour or border, so the site's CSS or the consent theme decides the look. `content-box` because the ratio is the frame's: a site's `* { box-sizing: border-box }` would take the theme's padding out of it and the loaded frame would push the box taller. A box with more text than room grows, since an aspect ratio gives way to its content (a narrow phone with the consent notice's two buttons). The browser suite measures it: at 320 px the box is 16:9 before the click and the same height after, and the next embed does not move. For a measured frame, `kissEmbedMeasure` clears the box's `min-height` when it sets the reported height, so an Instagram box does move once: measured on the real reel `DfQCqMnhpsG` through `{{embed}}` (2026-11-04), 288 × 600 → 288 × 568 at 320 px wide, and 540 × 600 → 540 × 883 at 1440. The height is about the width × 1.25 + 208 px for a 4:5 post (both measurements fit it), which CSS cannot express on the box without a container query; left as the 600 px estimate.
|
|
36
40
|
- **Instagram reports its height without `embed.js`.** Measured on 2026-11-04: a plain `/p/{id}/embed` frame posts `MEASURE` (568 px at 320 wide, 883 px at 1440), and `/p/{id}` serves a reel as well as a post. The listener checks `e.origin` and matches `e.source` against each iframe's `contentWindow`, so another frame's message cannot resize anything. It is written once per measuring embed, so two Instagram embeds add two listeners, each of which sizes only the frame that spoke.
|
|
37
41
|
- **The wiring check ignores a facade.** `consentWiringProblems` (`lib/consent-gate.js`) counts a `data-kiss-embed` box as gated markup that needs a consent head, unless it carries `data-kiss-embed-facade`.
|
|
@@ -17,10 +17,10 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
17
17
|
- `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
|
|
18
18
|
- `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A reference that is not a path into this build is returned unchanged: any scheme (`https:`, and also `data:`/`mailto:`, which carry no `//`) and the protocol-relative `//cdn/x.css`. The original test required `://`, so the last two fell through to a manifest lookup they could never satisfy — harmless while the helper warned, a failed build once it threw. A `?query`/`#fragment` is split off before the lookup and re-appended after, because the manifest is keyed by the path a copy emitted: `img/logo.svg#symbol` is an SVG sprite reference to a file that IS in the build, and looking the whole string up failed the build while telling the author the file was missing. `lib/links.js` splits references the same way, for the same reason. The failure message names the reference **as the template wrote it**, and the key underneath it when the two differ (`asset: '/css/x.css#a' … no .copyAssets() emitted 'css/x.css'`, `emitted it` when they are the same string); it named the key alone until an independent review pointed out that the key — slash stripped, suffix gone — is a string the author never typed and cannot search for, while the dev warning had always named the reference. A path the manifest does not know **fails the build**, the same shape as `link` on an id no page claims — they are the two halves of "never hand-write an internal URL" and were checked to different depths until R25. Warning and rendering meant the build passed, `report().ok` stayed true and the site shipped a 404; it also stripped the leading slash first, so `{{asset "/missing.css"}}` became `missing.css`, which on a nested page is a different URL. In **dev** it warns once per page per path (deduped on the page's root data frame, so a shared layout does not warn per page render) and renders the path **as written** rather than the stripped form, so the browser's 404 names what the template asked for. A non-string path logs a warning and renders `''`.
|
|
19
19
|
- `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/journal/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form. **On a page of a learning package** (the asking page's root context carries a string `package` and `config.packages` is not `false`) the link is `relativePathFor(asker.pageURL, pageURL)` instead: the file, relative to the page, with no collapse, canonical shape or trailing-slash policy, because an LMS serves a package's files as they are from wherever it unpacks them. `relative=` on the call decides it either way for any page; `absolute=true` always wins (code review, 2026-10-25).
|
|
20
|
-
- `image(path, options)` — a copied raster as a responsive `<picture>` (`AIKB/images.md`): `imageArguments` checks the hash, `deps.images.locate(name)` finds the copied file under the write root, `readImageHeader` reads its size, orientation and animation, `fileDigest` and `planImage` name the variants, `deps.images.index.record` notes them and the asking page's `pageURL`, and `pictureHtml` writes the markup. In dev it records the page only and writes `plainImgHtml`, the original at its own size. **Fails the page** in a real build and warns in dev, rendering the plain `<img>`, on: no path, an `.svg` or `.gif` (refused by extension before anything is read), a path no copy emitted, a file the header reader cannot read or a non-raster extension, an animated source, a missing `alt` or `sizes`, and a pass-through of `src`/`srcset`/`width`/`height`/`loading`/`fetchpriority`. A `?query`/`#fragment` on the path is dropped. `deps.images` is `{ locate, recipe, index }`; the default locates nothing, so a caller without an asset copy gets an `image` that fails every call the way an unknown path does.
|
|
20
|
+
- `image(path, options)` — a copied raster as a responsive `<picture>` (`AIKB/images.md`), its whole decision in the closure `imageMarkup(asked, hash, options, label)`, which `embed`'s poster calls too: `imageArguments` checks the hash, `deps.images.locate(name)` finds the copied file under the write root, `readImageHeader` reads its size, orientation and animation, `fileDigest` and `planImage` name the variants, `deps.images.index.record` notes them and the asking page's `pageURL`, and `pictureHtml` writes the markup. In dev it records the page only and writes `plainImgHtml`, the original at its own size. **Fails the page** in a real build and warns in dev, rendering the plain `<img>`, on: no path, an `.svg` or `.gif` (refused by extension before anything is read), a path no copy emitted, a file the header reader cannot read or a non-raster extension, an animated source, a missing `alt` or `sizes`, and a pass-through of `src`/`srcset`/`width`/`height`/`loading`/`fetchpriority`. A `?query`/`#fragment` on the path is dropped. `deps.images` is `{ locate, recipe, index }`; the default locates nothing, so a caller without an asset copy gets an `image` that fails every call the way an unknown path does.
|
|
21
21
|
- `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
|
|
22
22
|
- `scormBridge(options)` — the SCORM 1.2 bridge as one inline `<script>`, from `scormBridgeScript()` (`AIKB/scorm-bridge.md`). `complete="manual"` (the default) leaves completion to `kissScorm.status()`; `complete="seen"` marks the page complete when it loads in an LMS, which in Moodle opens every later launch in review mode, where nothing is saved (`AIKB/upstream.md`). The default was `seen` until 2026-10-26 (`CHANGELOG.md`). Any other value is an error: it logs and returns `''`, so a typo never ships a page that tracks differently from what the author asked.
|
|
23
|
-
- `embed(name, options)` — a click-to-load frame, through `embedService` and `embedMarkup` (`AIKB/embed.md`), on any site. Under `config.consent` it is the site's declared service of that name, an undeclared one failing the page, and the visitor's choice governs it; with no `config.consent` it is the preset alone (`youtube`, `vimeo`, `google-maps`, `instagram`, `soundcloud`) as a self-wired facade, worded by `DEFAULT_TEXT`. `title=` is required; `src=` overrides the service's `embed.src`; any other hash key fills a `{param}`. Fails the page like `link` (warns in dev). On a learning package's page it is always the facade, even under `config.consent`, since a facade stores and remembers nothing (the operator, 2026-11-04; it failed there until then). It was `consentEmbed` until 2.12.0, which existed only under `config.consent`; on a site with no banner, an agent hand-built a facade instead (`planning/reviews/2026-11-04-embed-clean-room.md`).
|
|
23
|
+
- `embed(name, options)` — a click-to-load frame, through `embedService` and `embedMarkup` (`AIKB/embed.md`), on any site. Under `config.consent` it is the site's declared service of that name, an undeclared one failing the page, and the visitor's choice governs it; with no `config.consent` it is the preset alone (`youtube`, `vimeo`, `google-maps`, `instagram`, `soundcloud`) as a self-wired facade, worded by `DEFAULT_TEXT`. `title=` is required; `src=` overrides the service's `embed.src`; `poster=` (with `sizes=`, default `100vw`, `eager=` and `root=`, all four of `{{image}}`'s own keys with `alt` fixed at `""`; `root` was dropped until Codex's slice-3 review) is a still drawn by `posterMarkup`, which refuses any URL with a scheme or `//` and otherwise calls `imageMarkup`, the same function `image` is, with `alt=""` and a cover-fit `style`, so a poster is checked, encoded, cached and named exactly as an image is, and its messages start `embed "<name>" poster=:`; `load=`, `notice=` and `open=` are one embed's words (`own`), `autoplay=false` (or `'false'`) turns off a video's autoplay on Load, and the site's words are `consent.ui.text`, else `config.embeds.text`, else `DEFAULT_TEXT`; any other hash key fills a `{param}`. Fails the page like `link` (warns in dev). On a learning package's page it is always the facade, even under `config.consent`, since a facade stores and remembers nothing (the operator, 2026-11-04; it failed there until then). It was `consentEmbed` until 2.12.0, which existed only under `config.consent`; on a site with no banner, an agent hand-built a facade instead (`planning/reviews/2026-11-04-embed-clean-room.md`).
|
|
24
24
|
- **The consent manager's six helpers** (`AIKB/consent.md`; contract `planning/plans/2026-11-02-consent-manager.md` §3, and for `consentRecord` and `ui.history` `planning/specs/2026-11-03-kiss-consent.md`). Each reads `config.consent` by closure. Like `link`, a mistake **fails the page** in a real build and warns in dev, with the message ending `(asked by <view> / <path>)`. The mistakes are: `config.consent` not set, an unknown service, a missing `{param}` value, `document.write` in a gated script, an iframe inside `consentScript`, a `ui.policy` or `ui.history` id no page claims (one `pageUrlFor(consent, key, …)` for both, D12), and a `ui.logo` no copy emitted. On a page that names a `package` (with packaging on), `consentHead`, `consentBanner` and `consentSettings` render nothing, and `consentScript` fails, because the LMS deals with consent (operator, 2026-11-03); `embed` writes its facade there instead.
|
|
25
25
|
- `consentHead(options)` — early in `<head>`, after `<meta charset>`. It renders `consentHeadScript(clientConfig(consent, { bannerHash }), { name, page, root })` from `lib/consent-client.js`: the cached file's name (written at the build's root by `writeConsentClient` in `lib/build-finish.js`), the page's own `pageURL` without its leading slash (`null` for a page with none), and `sitePathPrefix()` + `/`, the root `siteUrl` names as a path. The browser works the file's URL out from those and its own address (`AIKB/consent-client.md`, `consentHeadScript`), because no URL written here was right at every address a page is reached by: root-relative or `siteUrl`-absolute missed a prefixed site's preview (the stopgap review, P2, 2026-11-03), and relative to the served path missed `/guide/` and `/guide/index.html` under `trailingSlash: false` (Codex at the close, P2, 2026-11-04). Both were red in Chromium first. The links (policy, history, logo) keep the prefix rule, since a wrong link costs a navigation and not the consent UI. then `<style data-kiss-consent-style>` with `themeCss` (omitted under `ui.theme: false`). The banner hash is computed here as well as in `consentBanner`, so the client and the template agree.
|
|
26
26
|
- `consentBanner(options)` — first in `<body>`. It renders `<template data-kiss-consent-banner data-kiss-consent-hash>` holding the site's `ui.partial` (default `consent-banner`) when `hbs.partials` has it, else `bannerHtml`. Then comes `<template data-kiss-consent-settings>` with `settingsHtml`, then an inline `kissConsent.mount()`. An override partial is rendered with `{ text, position, logoUrl, policyUrl, categories, services }`. `policyUrl` is `ui.policy` looked up with `lookupPage` and `servedPathFor`, under the same `links.trailingSlash` as `link`. `logoUrl` is `ui.logo` looked up in the asset manifest. Both go through `consentUrl`: root-relative, unless the page's (or the site's) `siteUrl` has a path prefix, when it is string-joined to `siteUrl` as `link absolute=true` joins, so a site at `https://example.com/site/` links `https://example.com/site/cookies/` rather than another site's `/cookies/` (round-8 P2 of the consent-manager branch; `test/integration/consent.test.js`, "carries siteUrl's path prefix"). When no category is in use (every service `necessary`) it writes the two templates **empty**: the page is still wired, and the client shows nothing. Rendering nothing failed the wiring check's "head with no banner".
|
package/AIKB/kiss.md
CHANGED
|
@@ -9,6 +9,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
11
|
- `class Kiss` (default export), also re-exported as `export { Kiss as 'module.exports' }` so `require('kiss-ssg')` on Node ≥22.12 returns the class, not `{ default: Kiss }`.
|
|
12
|
+
- **Every named export is also a static on the class** (`Kiss.utils`, `Kiss.renderRedirects`, …, `Kiss.findTag`), because `require()` returns the class and nothing else: without the statics a CommonJS build script cannot reach `toSlug` at all (diploma-msc, 2026-11-05, which had copied it). `test/integration/esm.test.js` compares the ES module's named exports against the statics through a real `require`, so a new named export without its static fails there.
|
|
12
13
|
- `new Kiss(config)` — resolves config, starts loading the HTML minifier when not in dev mode (`preloadMinifier()`, unawaited — see the bullet below), sets up Handlebars/Remarkable and the instance's asset manifest, ensures folders, creates the asset pipeline and queues its steps ahead of the asset copy, queues that copy, registers helpers and partials, and (in dev mode) starts the dev server and watcher. A dev server that cannot bind is a **build failure** — see the `<dev server>` bullet below. `_setupFolders()` is also where `cleanBuild` is applied: `true` empties `folders.build` here, `'atomic'` redirects it to a staging sibling — see the staged-build bullets below.
|
|
13
14
|
- `.copyAssets(sourceDir, targetDir)` — puts `targetDir` through `_stagedPath()` (a staged build's copies have to follow it into staging) and chains `copyAssets()` onto `_assetQueue` (passing `_assetManifest`, so every copy records into the one manifest the `asset` helper reads) (a `Promise` that serializes calls in registration order) and pushes the chained promise onto `_promises`; returns `this`. Sequential rather than concurrent because one copy's target can be a subdirectory of another's source (e.g. a consumer copying into its own assets folder), and concurrent `fs.copy` walks racing there produce `ENOENT` failures. `_assetQueue` is reset to `Promise.resolve()` alongside `_promises`/`_generating` (and the `_failuresReported` latch) in `_replay()`, which sweeps `_failures` rather than emptying it (`carriedAcrossReplay`, below). Each copy also refreshes its own Sass failures on `_failures`, and **owns them by object identity** through `_sassFailures`, a `Map` keyed on the copy's resolved `(source, target)` pair. That key was first a prefix of the view string and the string was the wrong place for it: `src/assets` is a prefix of `src/assets/nested`, so a parent root's copy cleared a nested root's unresolved failure — and the parent then compiled that same file successfully to a different target, so nothing raised it again. Measured: a nested stylesheet that could not be written at all, on a build reporting `ok: true`. Resolving both paths also takes the working directory out of it, so two roots that are `src/assets` under different cwds stay distinct. The label in the view is display only. A copy clears exactly the set it recorded last time before recording this time, which is what lets a DELETED stylesheet lose its entry — it is absent from the new results, so a per-file clear could never reach it.
|
|
14
15
|
- `.registerPartials()` — re-runs `registerPartials()` against the current Handlebars env, passing the names the previous pass registered so any that have since left disk are unregistered; stores and returns the new set.
|
package/AIKB/page-registry.md
CHANGED
|
@@ -30,6 +30,8 @@ that patches one on the instance is seen.
|
|
|
30
30
|
closure. `stackForRecord(kiss)` — the stack with withdrawn default ids nulled.
|
|
31
31
|
- `scanPages(kiss)`, `logViewStats(kiss)` — the bodies of `scan()` and `viewStats()`.
|
|
32
32
|
- `defaultIdForView(view)`, `explicitIdOf(options)` — pure.
|
|
33
|
+
- `defaultIdForPath(pageURL)` — pure: the default id under `config.ids: 'path'`, from `KissPage.pageURL()`. It removes `.html`, then a `/index$` anchored on the slash, and gives `index` for the home page. It is **not** `utils.toURLKey`, whose `(^|\/)index$` reduces the root to `''`, an id `explicitIdOf` and `lookupPage` refuse, and which removes every extension, so `about.html` and `about.json` would compete. Design and critique: `planning/plans/2026-11-05-path-ids.md`.
|
|
34
|
+
- `idSlugMismatch(id, given, written)` — pure: the detail of the audit's `id-slug-mismatch`, or `null`. It fires only when `toSlug` changed the slug as given (`given !== written`) **and** the explicit id is that slug or ends in `/<given>`, so it names an id built from the raw slug and nothing else: an id that merely differs from the slug (`id: 'home'` on `index`) is a choice. The id is not rewritten. A `{{link}}` built from the same raw data still resolves against it, and silently changing a page's identity would move its `last-build.json` pairing and a package's learner records. `lib/build-finish.js` `runAudit` calls it for each explicit-id page.
|
|
33
35
|
|
|
34
36
|
## Depends on
|
|
35
37
|
|
|
@@ -50,6 +52,7 @@ that patches one on the instance is seen.
|
|
|
50
52
|
- `_preparePage` takes `extLess` from **the page's** resolved config (`options.config?.extensionLess`), not the instance's, falling back to the instance when a page has none. `page()` merges the global config with the page's overrides before the page is prepared, so this is the resolved value for that page. It matters because a site that is `extensionLess` everywhere still owes its host one literal `404.html` — Netlify and Cloudflare Pages look for that exact filename and do not fall back to `404/index.html`. Before this, an `options.config` override naming `extensionLess` was accepted and silently ignored, which is the worst shape of bug: the config is documented as per-page, the page took it, and the output path came out the other way. Covered by `test/integration/canonical.test.js` § per-page extensionLess.
|
|
51
53
|
- **A `generate: false` page claims no output path.** `_preparePage`'s `buildTo` dedupe only counts entries that will be written, on both sides — a skipped page neither fails a real page registered after it nor fails itself when one came first — and the replay sweep's `current` set excludes them, so a page that turns `generate: false` between builds has its stale output removed. It still sits on `_stack` (so `.scan()`'s by-view dedupe and the `complete()` data array are unchanged) and is skipped by the sitemap.
|
|
52
54
|
- **A page's identity is computed in `_preparePage`, after `prepare()`, and nowhere else.** `id` is what `{{link "<id>"}}` resolves, and the default has two halves that are known at two different times: the view route is known synchronously at `page()` entry, but the page's **normalised** slug only exists once `KissPage`'s setter has run it through `toSlug` — `options.slug` is still raw (`'Hello World'`, or the `${base}-${i}` a fan-out seeds). `_preparePage` after `prepare()` and before the push is the one point where both are in hand, which is also why the `{{link "blog/post" slug=…}}` sugar slugs its argument: both sides of that comparison go through `toSlug` exactly once. The rules: a `.page()`/`.scan()` page defaults to the view's route without its extension (`blog/listing.hbs` → `blog/listing`, leading `./` or `/` stripped); a `.pages()` item defaults to `<prefix>/<normalised slug>`, where the prefix is the registration's own `id` if it has one and the view route otherwise — which is how two fan-outs of one view into different folders get distinct ids; an inline template (a view that is not a `.hbs` filename) gets none, because it has no route; and a `generate: false` page claims none either, on the same reasoning as the output-path exemption above — it cannot be linked, so it must not take an id off a page that can.
|
|
55
|
+
- **Under `config.ids: 'path'` the default id is `defaultIdForPath(preparedPage.pageURL())`**, for a `.page()` page and a fan-out item alike. The registration prefix is not used, and `prepareMultiplePages` logs one notice per registration that gives an `id` (once per whole-site replay too, since `_replay()` re-runs it). The mode is read from **`kiss.config.ids`**, never `options.config.ids`: `options.config` carries a page's own overrides (`extLess` is read from it on purpose), and a per-page `ids` would split one id namespace into two conventions. **An engine-invented slug claims no path id.** An inline template given no `slug` gets `snippet-<random>`, which changes on every build and replay, so `page()` adds its options object to the module's `INVENTED_SLUG` WeakSet. That is a WeakSet rather than a flag, because every own key of `options` reaches the render context and the dev `.json` sibling. Two outputs that reduce to one path id (`courses.html` and `courses/index.html`, no `extensionLess` needed) fall into the ordinary default-id withdrawal.
|
|
53
56
|
- **A registration's `id` is a prefix, a record's `id` is a claim — the `aliases` rule, one level over.** `_prepareMultiplePages` does `delete item.id` beside `delete item.aliases` and then promotes the record's own (`model.id`) onto the item, where `slug`, `model` and `aliases` are promoted. Broadcast instead, the registration's id would be claimed by item 1 and fail the build on item 2. Only a **non-empty string** is promoted: a record whose `id` field is a database integer is data about the record, not a claim on a page's identity, and `explicitIdOf()` applies the same test everywhere so "explicit" means one thing. A controller-returned `id` is explicit too — it is just `options.id` by the time `_preparePage` sees it.
|
|
54
57
|
- **Two explicit ids collide like two output paths; two default ids withdraw instead.** An explicit id already claimed by another entry is logged as `Page id already claimed: <id>`, recorded on `_failures` and the page is not pushed — the exact treatment `Page already processed` gives a duplicate `buildTo`, because two pages claiming one identity means a `{{link}}` to it could only ever have meant one of them. A **default** id two pages arrived at must not fail: one view rendered by two `.page()` calls is how pagination is written today, and every site that builds must keep building. So neither page keeps it, one `notice` names both views and asks for an explicit id, and a `{{link}}` to it fails the page that asked. Explicit beats default: the explicit page keeps the id, the default withdraws with a notice naming both — deterministic, and fixable by naming one page.
|
|
55
58
|
- **The id index is lazy, memoised against the live stack, and never a snapshot.** `_idIndexFor()` builds `{ byId, withdrawn }` from `_stack` on demand; `_preparePage` sets `_idIndex = null` after every push, and `_replay()` resets it (and `_idNoticed`) beside `_report`. Two things make that necessary rather than tidy. Pages are pushed one at a time and can be registered after a render pass has begun, so an id stamped onto an entry at push time would let the first page keep an id the second is about to collide with — and any page rendered in between would have linked it successfully. And `_replay()` replaces `_stack` with a **new array**, so anything holding the old one (or an index built at helper-registration time, which happens once per instance in the constructor and is never re-run) would serve the first build's registry for the life of the watch session. The helper therefore gets `lookupPage: (id) => this._lookupPage(id)` — a bound function, resolved per call. `_idNoticed` keeps a collision notice to one line per id per build, because the index is rebuilt on every registration.
|
package/AIKB/rebuild.md
CHANGED
|
@@ -123,7 +123,7 @@ Each entry: **symptom** · **trigger** · **what clears it** · **how to re-chec
|
|
|
123
123
|
- **How to re-check:** run `npx vitest run` five times. If it fails twice in five, read the test's timing assumption before deciding anything.
|
|
124
124
|
- **One stylesheet save reloads the browser twice.** · A loaded Windows machine: `test/integration/watch.test.js` "reloads once for a stylesheet edit" saw two `refresh` calls on the GitHub Windows runner once in three runs of one commit (2026-10-17, after the assets watcher moved to the folder's parent — but 10 of 10 local Windows runs and the next runner run passed, and a Windows job needed the same kind of rerun on 2026-10-01). The likely shape is chokidar's per-file and per-directory `fs.watch` both reporting one write more than the 30ms `awaitWriteFinish` apart, which is two `change` events and two rebuilds; not measured. · Nothing to clear: the second reload is idempotent. · Re-run that test ten times on a Windows machine under load; if it fails, capture the two `change` events' timestamps from the watcher's `Asset change:` log lines before deciding anything.
|
|
125
125
|
- **A deleted assets folder's outputs stay in the build for more than five seconds.** · The GitHub Ubuntu runner, and nothing else found: `test/integration/watch.test.js` "copies new assets…" timed out at the step after `fs.remove(src/assets)` twice in three runs of `main` at `bd92519` (2026-10-18), while the same bytes passed four of four on the PR's runs, and on this machine's WSL (CI's Node 22.23.3) passed 20 single runs on one core, 3 full suites on two cores, and 40 timed rounds of the real path (`probe-kiss2.mjs`, worst removal 32ms under a CPU hog), with bare chokidar on the parent root delivering every delete event in 60 of 60. The earlier Ubuntu failures of this test were at a different step (the re-created folder, fixed in 2.7.8). Not measured on the runner: which asset events reached kiss. · A restart; and outside the test, the next edit to anything in `src/assets` re-copies and reconciles. · Read the `kiss log for "copies new assets…"` block the test now prints on failure (`AIKB/testing.md`): it names each `Asset <event>:` the watcher delivered with a millisecond offset, and a `Watcher error` line if chokidar reported one. The next red run on Ubuntu decides between a missed event and a slow copy; until one is read, this entry is current. The first four runner samples with the trace in place passed this step four of four; the one red among them was a different test reading `debug.json` mid-write (`AIKB/testing.md`), not the watcher.
|
|
126
|
-
- **A hashed stylesheet is briefly missing when an asset and a page are edited together.** · This Windows machine, a full `npm test` on `feat/consent-log-viewer` (2026-11-03): `test/integration/watch.test.js` "settles rapid asset and page edits together" failed once with `ENOENT … public/style.7067b21e.css`, a hashed output read while the rapid edits were still settling. The test uses no consent code. · A re-run: it passed 5 of 5 alone and the next full suite was green (3,754). · Run that test alone ten times, then two full suites; if it fails twice, read which `style.<hash>.css` the test expects against what the asset copy wrote, before deciding anything. **Seen again** in a full `npm run gates` on `feat/review-groups-and-performance` (2026-11-05, `ENOENT … public/style.b1d1d73f.css`), on a change to `lib/images-sweep.js` only; then 10 of 10 alone and two full suites green (4,270 each).
|
|
126
|
+
- **A hashed stylesheet is briefly missing when an asset and a page are edited together.** · This Windows machine, a full `npm test` on `feat/consent-log-viewer` (2026-11-03): `test/integration/watch.test.js` "settles rapid asset and page edits together" failed once with `ENOENT … public/style.7067b21e.css`, a hashed output read while the rapid edits were still settling. The test uses no consent code. · A re-run: it passed 5 of 5 alone and the next full suite was green (3,754). · Run that test alone ten times, then two full suites; if it fails twice, read which `style.<hash>.css` the test expects against what the asset copy wrote, before deciding anything. **Seen again** in a full `npm run gates` on `feat/review-groups-and-performance` (2026-11-05, `ENOENT … public/style.b1d1d73f.css`), on a change to `lib/images-sweep.js` only; then 10 of 10 alone and two full suites green (4,270 each). **Seen a third time** on `feat/page-ids-and-embed-poster` (2026-11-05, `ENOENT … public/style.b1d1d73f.css` again), in a full `npm run gates` on a change to page identity only, and then **once in 5 runs alone**: the first failure outside a full suite. That meets this entry's "fails twice" trigger. It was put to the operator before any investigation, per CLAUDE.md § Rules (the dev watcher is best-effort).
|
|
127
127
|
- **Seen again 2026-11-04**, on `feat/review-groups-and-performance`, once in a full suite: 3 of 3 passed alone. The next two failures on that branch were not this flake but a real change: `assets.minify` writes `color: blue` as `#00f`, and the test now uses a string marker.
|
|
128
128
|
- **An `{{image}}` added by one view edit names variants that are not there yet, under a non-dev watch.** · A non-dev `.watch()`: a page-view edit is a scoped re-render, which writes the `<picture>` but never settles, so nothing encodes the new variants (a source _image_ edit replays and is unaffected). Dev writes the original and is unaffected. · A whole-site rebuild (an edit to a model or controller), or a restart. · Under a non-dev watch, add an `{{image}}` to a page view and look for its variants in the build folder.
|
|
129
129
|
- **A class added by one edit has no CSS until the next whole build, under `assets.purge`.** · A non-dev `.watch()` with `assets.purge`: a scoped re-render after the settle links the purged stylesheet's current name, but the purge ran against the pages as they were (`AIKB/css-purge.md`). Dev never purges, so `--dev` is unaffected. · A whole-site rebuild (an edit to a model or controller), or a restart. · Build once with a class only the edited page uses, edit that page under a non-dev watch, and look for the class in the served CSS.
|
package/AIKB/redirects.md
CHANGED
|
@@ -23,7 +23,7 @@ So the rules come first and the encoding second. `redirects.json` — `{ version
|
|
|
23
23
|
- `renderFirebaseRedirects(rules)` / `renderVercelRedirects(rules)` → a fragment to merge: `{ redirects: [{ source, destination, type: 301 }] }` and `{ redirects: [{ source, destination, permanent: true }] }`. Vercel's `permanent: true` is a 308 — the permanent redirect that platform offers, and the closest honest equivalent of the `301` every other format writes.
|
|
24
24
|
- `normaliseWriterOutput(returned, buildDir)` → a custom writer's return value as `[{ file, contents }]`, with `file` resolved under the build folder. A writer returning nothing writes nothing. An absolute path, or one climbing out with `..`, **throws**: a redirect writer is site code running in a build, and "write any file on this machine" is not the capability it asked for.
|
|
25
25
|
- `coveredByAlias(rel, fromSet, { trailingSlash })` → whether an alias already answers for a build-relative old **file** path. The match is made **in canonical form** (`/old-post.html` → `/old-post`, `/old/index.html` → `/old/`), which is the form a rule's source and target are both written in; an alias spelled as the file (`/old-post.html`) covers nothing. One predicate, used by `removed` and by `moved`, so the two can never disagree about one page.
|
|
26
|
-
- `redirectFindings({ rules, currentPages, previousPages, buildDir, trailingSlash })` → `{ removed, collisions, moved }`, all three sorted. `currentPages` is `[{ buildTo, id }]` for the pages this build writes; `previousPages` is the last record (`{ buildDir, pages: [{ buildTo, hash, id }] }`) or `null`. `removed` and `moved`'s `from`/`to` are **served** paths (`servedPathFor`), not file paths.
|
|
26
|
+
- `redirectFindings({ rules, currentPages, previousPages, buildDir, trailingSlash, ids })` → `{ removed, collisions, moved }`, all three sorted. `moved` pairs by id only when the record's `ids` (absent: `'view'`) equals this build's `ids` (`config.ids`). Across a switch, the same id string can name a different page (`index` was `landing.hbs` at `home.html` and is now the home page), so pairing would report a move of two live pages. `removed` runs on paths either way. `currentPages` is `[{ buildTo, id }]` for the pages this build writes; `previousPages` is the last record (`{ buildDir, pages: [{ buildTo, hash, id }] }`) or `null`. `removed` and `moved`'s `from`/`to` are **served** paths (`servedPathFor`), not file paths.
|
|
27
27
|
- `resolveRedirectFormats(format)` → the host encodings to emit, as a list: `[]` for `undefined`, `null` and `'none'`; a bare string or function as a list of one; an array kept in order with duplicates collapsed and every `'none'` dropped.
|
|
28
28
|
- `writeRedirects(stack, { config, logger, overwrite })` → `{ status: 'none' | 'skipped' | 'written', rules, files, formats, unset }`. `none` means no page has an alias: **nothing is written**, not even an empty IR. `files` is every path written, `redirects.json` first; `formats` is the list of names that ran, `'custom'` for each writer function — a machine-readable report should not carry a source-code detail — and `[]` when only the IR was written. `unset` is the upgrade flag: aliases exist, no host file was emitted, and the site never chose. `overwrite: false` protects each target independently, so an existing `_redirects` a project keeps in `src/assets/` survives while the IR beside it is still written.
|
|
29
29
|
- The `RedirectRule`, `RedirectFindings` and `RedirectWriteResult` typedefs. `RedirectFindings.moved` is `{ id, from, to }[]`, the same shape `BuildRedirects.moved` carries.
|
package/AIKB/speed.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- `SPEED_HELP` — the command's help text.
|
|
10
10
|
- `parseSpeedArgs(argv)` → `{ target, pages, runs, out, against, help, error }`. `--page` repeats and must start with `/`; `pages` is `null` when none was given. `--runs` is a whole number from `MIN_RUNS` (1) to `MAX_RUNS` (9), `DEFAULT_RUNS` (3) when absent. One target.
|
|
11
11
|
- `resolveTarget(target, { cwd, isDirectory, isFile })` → `{ kind: 'url', base, page }` (an `http(s)` URL: `base` its origin, `page` its path and query, the default page), `{ kind: 'folder', folder }` (resolved against `cwd`), or `{ kind: 'error', error }` — a file (a site's build script) or a path that does not exist.
|
|
12
|
-
- `nodeAtLeast(version, floor = MIN_NODE)` — `MIN_NODE` is `22.19.0`, Lighthouse 13's own `engines.node`. kiss-ssg's floor stays
|
|
12
|
+
- `nodeAtLeast(version, floor = MIN_NODE)` — `MIN_NODE` is `22.19.0`, Lighthouse 13's own `engines.node`. kiss-ssg's own floor (22.23.4) is above it, so on a supported Node the check passes; it stays for a site run on an older Node regardless.
|
|
13
13
|
- `kpisFrom(lhr)` → `{ version, values }` or `{ error }`. `values` has one entry per `METRICS` key: `score` (the performance category ×100, rounded, as Lighthouse prints it), the `numericValue` of `largest-contentful-paint`, `cumulative-layout-shift`, `total-blocking-time`, `first-contentful-paint`, `speed-index` and `total-byte-weight`, and `requests` (the length of `network-requests`' `details.items`).
|
|
14
14
|
- `METRICS` — `{ key, label, unit }` per KPI, in table order. The keys are Lighthouse's audit ids, so the JSON can be looked up against Lighthouse's docs.
|
|
15
15
|
- `median(values)`, `summarise(runs)` → `{ median, spread }`, a `{ min, max }` spread for every metric.
|
package/AIKB/testing.md
CHANGED
|
@@ -12,7 +12,7 @@ Cross-cutting notes on how tests in `test/` are organized and written — not a
|
|
|
12
12
|
- **CI installs Chromium on both legs** (`.github/workflows/ci.yml`). Under `CI`, a missing browser fails the suite.
|
|
13
13
|
- **Locally,** a missing browser skips the suite with a warning naming `npx playwright-core install chromium`. A machine that publishes (`prepublishOnly` runs the gates) should have Chromium installed, or the suite never runs there.
|
|
14
14
|
- `test/integration/` — cross-module behavior driven through the public `Kiss` API: lifecycle (`generate`/`complete`), dedupe, config resolution, watch mode, ESM/CJS interop, and a broader "characterization" suite. This is where `lib/kiss.js` itself is covered — end-to-end through its public API rather than in a dedicated unit file.
|
|
15
|
-
- `test/helpers/site.js` — `makeSite(files)` creates an isolated temp-dir site (under `os.tmpdir()`) and returns `{ root, src, build, folders, read, exists, touch, cleanup }`; `waitFor(predicate, { timeout, interval })` polls until a predicate is true (used for watch-mode assertions) or throws after `timeout`. Paths are returned with forward slashes because the engine compares and slices paths as posix throughout (`utils.posixPath`).
|
|
15
|
+
- `test/helpers/site.js` — `makeSite(files)` creates an isolated temp-dir site (under `os.tmpdir()`). A file's content is written as given when it is a string or a `Buffer` (a JPEG the browser suite serves), and as JSON otherwise (a model). It returns `{ root, src, build, folders, read, exists, touch, cleanup }`; `waitFor(predicate, { timeout, interval })` polls until a predicate is true (used for watch-mode assertions) or throws after `timeout`. Paths are returned with forward slashes because the engine compares and slices paths as posix throughout (`utils.posixPath`).
|
|
16
16
|
- A test that spawns a child process of its own — `test/unit/pipeline.test.js` runs `node -e` commands through `createPipeline` — uses `process.execPath`, quoted, rather than a bare `node`, and must `close()` the pipeline in `afterEach`: a watch process left running keeps the worker (and the temp folder) alive.
|
|
17
17
|
- `test/helpers/kiss.js` — re-exports `Kiss`/`utils` from `lib/kiss.js` and `ENTRY` (`path.resolve('lib/kiss.js')`), so integration tests import one thing instead of reaching into `lib/` directly.
|
|
18
18
|
- `test/helpers/fake-npm.mjs` — a stand-in for npm that `test/integration/upgrade.test.js` puts first on `PATH` through `npm`/`npm.cmd` shims it writes into a temp folder, so `kiss-ssg upgrade` runs end to end with no network. It records every call, answers `config get registry` (with `ENOWORKSPACES` under an ancestor declaring `workspaces`, as npm 11.9 does), and makes the change a real `npm install kiss-ssg@<version>` makes. Environment variables drive its failures (`FAKE_NPM_KEEP_LINK`, `FAKE_NPM_FAIL`, `FAKE_NPM_REGISTRY_FAIL`). The real npm's behaviour, which the fake models, is checked by that file's one **opt-in** block (`KISS_NETWORK_TESTS=1`, against the registry and the published 2.8.0): the suite's only test that touches the network, skipped in the gates and in CI, so its red and green runs were recorded by hand in the branch's session log.
|
|
@@ -49,7 +49,7 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
49
49
|
- **The SCORM bridge is tested in Node's `vm`, against a scripted LMS, and the LMS model is what the tests are worth.** `test/unit/scorm-bridge.test.js` runs the bridge's own inlined source in a fresh context per window, twice per test (as rendered and terser-minified), with fake frames (`parent`, `opener`, a document that is still loading or can be hidden, and the window's own `setTimeout`/`clearTimeout` on a clock only `advance(ms)` moves) and a fake `Date` (`load(win, { clock })`) that drives both `Date.now()` and `new Date()`. `load(win, { complete })` renders the script with that option, and leaving it out renders the default (`manual`), so a test that depends on completion on load says `complete: 'seen'`. The fake `lms()` keeps what it **buffers** (`data`) apart from what it **persisted** (`durable`): only a commit or a finish persists. It can refuse a write once or for good (`refuseNext`, `refuse`), refuse a read once (`refuseNextRead`), fail commits (`failCommits`), end the session underneath the page and rebuild its buffer from what was persisted on restart (`end`, `resets`, as Moodle's `LMSInitialize` does), send only what changed since the last send with the baseline moved before the request (`delta`, Moodle's `CollectData`), and block every request (`block`, a browser at dismissal). A test of persistence asserts on `durable`, never on a call count: the first version of the checkpoint was all call counts against an LMS that persisted its whole buffer on every commit, and seven Codex rounds found what that model could not show (2026-10-25, `AIKB/scorm-bridge.md`).
|
|
50
50
|
- **`test/helpers/zip.js` reads a stored zip back** (`readZipEntries`), so a package test asserts on the archive's entries and the manifest inside it, not on files beside it.
|
|
51
51
|
- **Red-first without stashing.** When more than one agent works in the tree, `git stash` to prove a new test red would clobber the other's work. The recipe the link-check branch settled on: `git archive HEAD | tar -x -C <scratch>/red-<name>`, symlink `node_modules`, copy the new test files in, run them there against HEAD (they must fail), then delete the copy. Every workstream on that branch recorded its red-first evidence this way.
|
|
52
|
-
- `test/unit/home-quickstart.test.js` holds the set-up commands to one source across four places that a newcomer may meet first: the docs-site homepage's Claude Code and Codex boxes (`src/models/home.json`; a third, "Other agent", stops at `init` and is pinned on its own, as is the selector that shows one box at a time), the README's `**Claude Code**` / `**Codex**` quick-start blocks (found by their label line, not by position), `lib/init.js`'s `CLAUDE_STEPS` / `CODEX_STEPS` (what `nextSteps()` prints), and `llms.txt`. Change the install lines in one place and it fails until the other three agree. It also pins the showcase cards
|
|
52
|
+
- `test/unit/home-quickstart.test.js` holds the set-up commands to one source across four places that a newcomer may meet first: the docs-site homepage's Claude Code and Codex boxes (`src/models/home.json`; a third, "Other agent", stops at `init` and is pinned on its own, as is the selector that shows one box at a time), the README's `**Claude Code**` / `**Codex**` quick-start blocks (found by their label line, not by position), `lib/init.js`'s `CLAUDE_STEPS` / `CODEX_STEPS` (what `nextSteps()` prints), and `llms.txt`. Change the install lines in one place and it fails until the other three agree. It also pins the showcase cards: each screenshot is drawn with `{{image}}`, carries the model's `alt` and a `sizes`, and stays lazy (no `eager=`). `{{image}}` writes the width and height from the file, and `lib/images.js`'s own tests hold that.
|
|
53
53
|
- `test/unit/skill-code-blocks.test.js` holds every shell block in `plugins/*/skills/` and `.claude/skills/` to an allow-list: a listed command with its arguments, `timeout` in front of one, or `NAME=$(…) || exit 1`. It reads the blocks with Remarkable, as `plugin-manifests.test.js` does, treats quoted text as an argument (so a `|` in a grep pattern is not a pipe), and sets heredoc bodies aside. A shape is added to the allow-list only when it decides nothing; anything that decides belongs in `lib/` (CLAUDE.md, Rules).
|
|
54
54
|
- `test/unit/skill-coverage.test.js` is the agent-facing counterpart of `test/aikb.test.js`: a table of public feature → the plugin skills that must name it (`{{link`, `links: { canonical: true }`, `aliases`, `.feed(`, the check's finding lines, `kiss-ssg aikb`, the site map's `Id` column). A feature documented in `GUIDE.md` and indexed in `llms.txt` but never taught to the skill an agent follows is what this catches — add a row when a public feature lands. Rows also pin a practice a skill must keep that is not an API feature at all. An example is `kiss-site-review`'s craft pass, or `kiss-site-new`'s "never invent a fact" floor (2026-10-12). Nothing else would notice such a section being edited away. A row can pin **structure** as well as wording, because prettier can damage a list without breaking any pattern. In 2026-10-21, an unindented paragraph inside a numbered list item ended the list. Prettier then folded the ten checks after it into one 5,800-character line, and every pattern still matched. The row "the twenty craft checks are each their own list item" requires each number to start a line (`m` flag). Use the same shape for any numbered sequence an agent is told to find by number. Its `CONTRADICTIONS` half walks `plugins/`, `examples/` and `AIKB/`, skipping every path `scripts/gates.mjs`'s `FORBIDDEN_PACKED` names: an example's gitignored `public/` never ships, and since every built page has a `.md` copy beside it (2.7.5), walking it read rendered page text as if it were a doc and tripped the bans. Its "a task skill links the SKILL.md of every domain skill it names" block holds every task skill (each plugin skill not in its `DOMAIN_SKILLS` list) to linking `../<domain skill>/SKILL.md` wherever it names a domain skill, because headless agents on real sites opened linked files and skipped named ones (2026-10-31). A new domain skill joins `DOMAIN_SKILLS`, or the test treats it as a task skill.
|
|
55
55
|
- `test/integration/watch-failure-lifecycle.test.js` is where the verdict's honesty under `.watch()` lives: what a replay carries and what it drops, which copy owns a Sass failure, whether `report()` tracks the failure list between settles, and whether a page that starts failing is recorded at all. Almost every case in it was a defect first — it is the regression net for the whole "a build that says ok while something is broken" family, so read its comments before changing the behaviour it pins.
|
|
@@ -73,7 +73,6 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
73
73
|
|
|
74
74
|
- **`examples.test.js`'s byte-identical AIKB assertions self-heal, so a report-shape change fails exactly once and then passes.** Examples 9 and 11 are recorded **in place**, in the committed `examples/*/AIKB/` folders, and the test asserts the files are unchanged before and after. `last-build.json` is the whole `BuildReport`, so adding any report key rewrites both files: the first run fails and leaves them matching, and every run after it is green. Read a green re-run as evidence of nothing — the first run already healed it. The correct response is never to re-run: it is `npx kiss-ssg aikb router.js` in `examples/9-migrated-from-v1/` and `examples/11-blog/`, then commit the re-recorded files as part of the change. (Found the hard way twice on 2026-09-16, once per report key added; the first instance was committed by a `git add -A` before anyone understood what it was.)
|
|
75
75
|
|
|
76
|
-
- `npm run lint`'s `@eslint/js` (v10) requires Node ≥22.13, even though the package's own runtime floor (`package.json`'s `engines.node`) is 22.12 — on Node 22.12.x exactly, `npm test` passes but `npm run lint` may refuse to run.
|
|
77
76
|
- **`types/` is generated — never hand-edit it.** `test/unit/types.test.js` re-emits it and byte-compares, so a JSDoc change in `lib/` fails the suite until `npm run types` has been run and the result committed. The failure names the file and the command.
|
|
78
77
|
- The examples (`npm run eg1` … `eg11`) and `node docs` build and exit by default, so they are safe to run bare — except example 8, which exits 1 by design. It is the `--dev` flag that starts a server and **never exits on its own** — `node docs --dev`, or `npm run eg1 -- --dev` (examples 1–6, 8, 9, 10; example 7 takes a season slug instead). Use `timeout 10 node <script> --dev` in Git Bash (or equivalent) to smoke-run that path and let the timeout kill the process.
|
|
79
78
|
- `docs/` is emptied by `docs.js` on every run (`cleanBuild: true`, the default) — nothing hand-written can live there. Specs and implementation plans belong in `planning/` (`planning/specs/`, `planning/plans/`), never under `docs/`.
|
package/AIKB/upstream.md
CHANGED
|
@@ -71,6 +71,12 @@ as `open`/`close`. A local guard in one function. **Re-check:** parse
|
|
|
71
71
|
`Parser` and see whether every byte has an event and `<P>` is a sibling of
|
|
72
72
|
`<IMG>`.
|
|
73
73
|
|
|
74
|
+
**Re-checked 2026-11-06 on htmlparser2 12.0.0** (its HTML mode now follows the
|
|
75
|
+
WHATWG spec): `</ x>` arrives as a comment, so that gap is closed. A stray
|
|
76
|
+
`</span>` and an unfinished `<b` at the end still produce no event, and with
|
|
77
|
+
`lowerCaseTags: false` an `<IMG>` still swallows the `<P>` after it (its close
|
|
78
|
+
is reported as implied, at the end). The guard stays.
|
|
79
|
+
|
|
74
80
|
### handlebars renders a missing zero-argument helper as empty
|
|
75
81
|
|
|
76
82
|
**Observed:** handlebars 4.7. **Effect:** `{{shout "hi"}}` on a helper that is
|
|
@@ -214,14 +220,30 @@ await `ready`, `fs.remove(dir)`, `fs.outputFile(dir/restored.txt)`, wait up to
|
|
|
214
220
|
is current while it misses. The session scratchpad's `probe.mjs` was that
|
|
215
221
|
script, run from the repo so its chokidar is the one under test.
|
|
216
222
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
**chokidar 6.0.0 (taken 2026-11-06) says it fixes this**: "a deleted watched
|
|
224
|
+
path … is now awaited from its nearest existing parent", and "persistent
|
|
225
|
+
watching stopping after a watched directory was removed" (#1463). That is
|
|
226
|
+
chokidar's claim, not a measurement. The parent-watch above stays until the
|
|
227
|
+
re-check passes on Linux with chokidar 6; if it does, watching the assets root
|
|
228
|
+
directly is the simpler shape.
|
|
229
|
+
|
|
230
|
+
### eslint holds minimatch on 10, which a Dependabot advisory covers
|
|
231
|
+
|
|
232
|
+
**Observed** (2026-11-06, eslint 10.10.0, latest 10.14.0, `@eslint/config-array`
|
|
233
|
+
0.23.5): eslint and `@eslint/config-array` both depend on `minimatch` `^10`.
|
|
234
|
+
GHSA-g3hv-75wg-m249 (moderate, a denial of service through pattern
|
|
235
|
+
amplification) covers minimatch 7.0.0 to 11.0.0 and is fixed only in 11.0.1.
|
|
236
|
+
**Effect:** `npm audit` reports four moderate findings, all one chain
|
|
237
|
+
(minimatch → `@eslint/config-array` → eslint → `@eslint/js`). Its suggested
|
|
238
|
+
fix is a downgrade to eslint 9. **What kiss does:** nothing. eslint is a
|
|
239
|
+
`devDependency`, it lints this repository's own files and never reaches a
|
|
240
|
+
site's install. The runtime copy of minimatch, under glob 14, is 11.x. The
|
|
241
|
+
Dependabot alert was dismissed as tolerable risk (dev-only, no upstream fix).
|
|
242
|
+
An `overrides` entry was considered and rejected, because it would run eslint
|
|
243
|
+
on a major version it does not declare. **Re-check:**
|
|
244
|
+
`npm view eslint dependencies.minimatch` and
|
|
245
|
+
`npm view @eslint/config-array dependencies.minimatch`. When either says `^11`,
|
|
246
|
+
take that eslint and expect `npm audit` to report 0.
|
|
225
247
|
|
|
226
248
|
### npm redacts a UUID-like path segment in `npm prefix`, and `npm config` refuses a workspace member
|
|
227
249
|
|
package/AIKB/zip.md
CHANGED
|
@@ -19,7 +19,7 @@ The module is pure: it takes entries and returns a `Buffer`. It reads no files,
|
|
|
19
19
|
|
|
20
20
|
## Depends on
|
|
21
21
|
|
|
22
|
-
`node:zlib` for `crc32` alone (in Node since 22.2; `engines` is `>=22.
|
|
22
|
+
`node:zlib` for `crc32` alone (in Node since 22.2; `engines` is `>=22.23.4`). Nothing else.
|
|
23
23
|
|
|
24
24
|
## Depended on by
|
|
25
25
|
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,128 @@
|
|
|
3
3
|
Written for people building a site with kiss-ssg, not for people maintaining it.
|
|
4
4
|
Newest first. `/branch-close` adds an entry alongside each version bump.
|
|
5
5
|
|
|
6
|
+
## 2.13.0 — 2026-11-06
|
|
7
|
+
|
|
8
|
+
kiss-ssg now needs **Node 22.23.4 or later**, the current Node 22 LTS release, and takes
|
|
9
|
+
the first round of dependency updates since Dependabot was switched on.
|
|
10
|
+
|
|
11
|
+
- **Node `>=22.23.4`** (`engines.node`). The dev watcher's new chokidar needs 22.22, and
|
|
12
|
+
22.23.4 is the Node 22 security release of 2026-10-20.
|
|
13
|
+
- **chokidar 6**, under `--dev`. The rewritten watcher confirms each change against the
|
|
14
|
+
file system before reporting it, which its release notes say fixes missed and doubled
|
|
15
|
+
events on Windows. Nothing in your site changes, and a live reload still follows a save.
|
|
16
|
+
- **htmlparser2 12**, which kiss uses to convert a page you import (`splitDocument`) and
|
|
17
|
+
to check consent and learning-package markup. Its HTML parsing now follows the browser
|
|
18
|
+
standard. No built page changed in this repository's examples or on a real site
|
|
19
|
+
rebuilt from this release.
|
|
20
|
+
- **sass 1.108**, and smaller updates to fs-extra and kiss-ssg's development tools.
|
|
21
|
+
|
|
22
|
+
Measured: this repository's docs site and all 14 examples build byte-identical before and
|
|
23
|
+
after, apart from the pages that state the Node version. A real site built from the
|
|
24
|
+
packed release gives the same 7 pages, the same audit and no broken links.
|
|
25
|
+
|
|
26
|
+
### Upgrading
|
|
27
|
+
|
|
28
|
+
Update Node first. Check with `node --version`: anything below `v22.23.4` needs the
|
|
29
|
+
current Node 22 LTS, or Node 24, from nodejs.org or your version manager. If your
|
|
30
|
+
project pins Node in `.nvmrc`, `.node-version` or your host's build settings (Netlify's
|
|
31
|
+
`NODE_VERSION`, a GitHub Actions `node-version`), raise it there too, or the host builds
|
|
32
|
+
on the old one. Then:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx kiss-ssg@latest upgrade
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
On an older Node, npm warns that kiss-ssg needs a newer one and installs it anyway (unless
|
|
39
|
+
your npm sets `engine-strict`), so take the warning as the reason to update.
|
|
40
|
+
|
|
41
|
+
## 2.12.3 — 2026-11-06
|
|
42
|
+
|
|
43
|
+
A security release: kiss-ssg's own dependencies, with nothing in the API changed.
|
|
44
|
+
|
|
45
|
+
- **handlebars `^4.7.10`.** Two critical advisories ("JavaScript Injection via AST Type
|
|
46
|
+
Confusion in compile", a bypass of CVE-2026-33937, and "JavaScript Injection via Own Property
|
|
47
|
+
Check Bypass") and one moderate ("Unsafe Inline Embedding of Precompiled Templates") are
|
|
48
|
+
fixed in 4.7.10. kiss
|
|
49
|
+
compiles your templates with it on every build. kiss now requires that version, so a site
|
|
50
|
+
that installs kiss-ssg cannot resolve an older one.
|
|
51
|
+
- **glob 14** (from 13), with the patched `minimatch` and `brace-expansion` under it. kiss
|
|
52
|
+
calls it in one place, with fixed patterns, so which files a build picks up is unchanged.
|
|
53
|
+
- **`source-map-js` 1.2.2**, through postcss, for a denial-of-service advisory.
|
|
54
|
+
|
|
55
|
+
Measured: this repository's docs site and all 14 examples build byte-identical before and
|
|
56
|
+
after the update.
|
|
57
|
+
|
|
58
|
+
### Upgrading
|
|
59
|
+
|
|
60
|
+
Take this release, for the handlebars fix:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx kiss-ssg@latest upgrade
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Nothing in a site changes. `npx kiss-ssg check <script>` should report the same pages, with
|
|
67
|
+
no diff against your last record. A site whose own `package.json` pins `handlebars` or `glob`
|
|
68
|
+
directly can drop that pin, or raise it to these versions.
|
|
69
|
+
|
|
70
|
+
## 2.12.2 — 2026-11-05
|
|
71
|
+
|
|
72
|
+
Page ids and `{{embed}}`, from what two sites on 2.12 asked for.
|
|
73
|
+
|
|
74
|
+
- **A CommonJS build script can reach `toSlug`.** `require('kiss-ssg')` returns the `Kiss` class,
|
|
75
|
+
and every named export is now a static on it: `Kiss.utils.toSlug(slug)`, `Kiss.renderRedirects`
|
|
76
|
+
and the rest.
|
|
77
|
+
- **The audit's new `id-slug-mismatch`** names a page whose explicit `id` was built from its slug
|
|
78
|
+
as the data gave it (`` id: `blog/${slug}` ``), when kiss cleaned that slug up before writing the
|
|
79
|
+
page (a trailing space, a leading `-`). The id is kept as written. Build it from
|
|
80
|
+
`utils.toSlug(slug)` instead.
|
|
81
|
+
- **`ids: 'path'` makes a page's default id where it is served**: `c/acute.html` is `c/acute`,
|
|
82
|
+
`courses/index.html` is `courses`, and the home page is `index`. A site whose pages share views,
|
|
83
|
+
such as pagination or a catalog that picks the view per item, then needs no explicit ids. The
|
|
84
|
+
default is still `'view'`. The build report gains `ids`, and the rename finding `moved` pairs
|
|
85
|
+
pages only when the last record used the same setting.
|
|
86
|
+
- **`{{embed … poster='img/still.jpg'}}`** shows a still from your own site, drawn by the
|
|
87
|
+
`{{image}}` pipeline, under a dark strip with white text and a play mark on a video, so the box
|
|
88
|
+
looks like a video. Restyle it with `--kiss-embed-poster-scrim` and `--kiss-embed-poster-text`.
|
|
89
|
+
A URL from another site is refused, since fetching it before the click is the request the box
|
|
90
|
+
exists to prevent.
|
|
91
|
+
- **A video plays on Load.** A `youtube` or `vimeo` embed's button says "Play video", its
|
|
92
|
+
screen-reader name adds the `title=`, and pressing it starts the video (`autoplay=false` turns
|
|
93
|
+
that off). Measured in desktop Chromium; iOS Safari may still ask for a tap.
|
|
94
|
+
- **Embed wording without `config.consent`**: `load=`, `notice=` and `open=` on one embed, or
|
|
95
|
+
`embeds: { text }` in the config for every embed, with the keys `ui.text` uses.
|
|
96
|
+
|
|
97
|
+
### Upgrading: what a site may notice
|
|
98
|
+
|
|
99
|
+
Nothing fails, and only an `{{embed 'youtube'}}` or `{{embed 'vimeo'}}` page changes:
|
|
100
|
+
|
|
101
|
+
- its button now says **Play video** rather than **Load it**, and the video starts on that press.
|
|
102
|
+
Put `autoplay=false` on an embed to keep the old two-press behaviour. A site that translated
|
|
103
|
+
`ui.text.embedLoad` keeps its own word on videos too; set `ui.text.embedPlay` to word them apart.
|
|
104
|
+
- Every Load button gains an `aria-label`, `<its words>: <title=>`, so check that each embed's
|
|
105
|
+
`title=` says what it is.
|
|
106
|
+
- `AIKB/last-build.json` gains `"ids": "view"` at the next `npx kiss-ssg aikb`. Commit it.
|
|
107
|
+
|
|
108
|
+
`ids: 'path'` is opt-in. Before switching an existing site to it, read the checklist in GUIDE.md
|
|
109
|
+
§ .pages(): links by view route, the `cookies` and `privacy` page ids, and any learning-package
|
|
110
|
+
page relying on a default id.
|
|
111
|
+
|
|
112
|
+
## 2.12.1 — 2026-11-05
|
|
113
|
+
|
|
114
|
+
The audit's `consent-missing` now reports a Google Maps frame on a site without `config.consent`.
|
|
115
|
+
The guide always said it would, but kiss compared only the frame's origin
|
|
116
|
+
(`https://www.google.com`) with its tracker list, and the Maps entry there is `www.google.com`
|
|
117
|
+
under `/maps`, so it never matched. kiss now matches the frame's whole address. A `www.google.com`
|
|
118
|
+
frame outside `/maps`, such as reCAPTCHA, is still not reported, and nor is `{{embed 'google-maps'}}`.
|
|
119
|
+
|
|
120
|
+
### Upgrading: a raw Google Maps frame now raises `consent-missing`
|
|
121
|
+
|
|
122
|
+
Only a site with no `config.consent` and an `<iframe>` from `www.google.com/maps` sees anything new:
|
|
123
|
+
one advisory `consent-missing` finding per page, naming `https://www.google.com`. Nothing fails.
|
|
124
|
+
The fix is to load the map when the visitor asks for it, with
|
|
125
|
+
`{{embed 'google-maps' src='…' title='…'}}`, which needs no `config.consent`. A site that already
|
|
126
|
+
has `config.consent` is unaffected: there, an ungated frame was already `consent-ungated`.
|
|
127
|
+
|
|
6
128
|
## 2.12.0 — 2026-11-05
|
|
7
129
|
|
|
8
130
|
A release about speed. The new `kiss-performance` skill builds and reviews how fast a site loads,
|