kiss-ssg 2.13.3 → 2.14.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.
@@ -7,12 +7,13 @@ Minify a stylesheet or script the asset copy wrote, in place, before it is hashe
7
7
  ## Public interface
8
8
 
9
9
  - `shouldMinify(relative)` → `true` for a `.css`, `.js` or `.mjs` that is not `*.min.*`.
10
- - `minifyText(relative, text)` → the minified text. CSS goes through `clean-css` at level 1, and its errors throw. JS goes through `terser` with `compress`, `mangle` and no comments, as a classic script unless the file is `.mjs`. A `.js` that does not parse as a classic script is tried again as a module, so `import`, `export` and top-level `await` survive. Throws when the file cannot be parsed either way.
10
+ - `compactCss(text)` → the stylesheet with its formatting removed (whitespace between nodes, and comments between rules and declarations but `/*!` ones; a comment inside a selector, value or at-rule params stays), through PostCSS; throws when PostCSS cannot parse it. No space is added after an at-rule's name when its params open with `(`, so `@media(x)` never grows.
11
+ - `minifyText(relative, text)` → the minified text. CSS goes through `clean-css` at level 1; on any clean-css error or warning, `compactCss` instead, and only when both fail does it throw. JS goes through `terser` with `compress`, `mangle` and no comments, as a classic script unless the file is `.mjs`. A `.js` that does not parse as a classic script is tried again as a module, so `import`, `export` and top-level `await` survive. Throws when the file cannot be parsed either way.
11
12
  - `minifyFile(file, relative)` → `{ ok: true, before, after }` or `{ ok: false, error }`. Rewrites the file only when the result is smaller. **Never throws**: a file the minifier cannot read is left exactly as it was.
12
13
 
13
14
  ## Depends on
14
15
 
15
- `fs-extra`, `node:path`, `terser`, `clean-css`.
16
+ `fs-extra`, `node:path`, `terser`, `clean-css`, `postcss` (already a dependency, for the purge).
16
17
 
17
18
  ## Depended on by
18
19
 
@@ -21,7 +22,7 @@ Minify a stylesheet or script the asset copy wrote, in place, before it is hashe
21
22
 
22
23
  ## Non-obvious behavior
23
24
 
24
- - **A clean-css warning keeps the original.** clean-css 5 does not understand native CSS nesting: it dropped a nested `.child { … }` and a nested `@media`, and hoisted `&:hover` out of its parent, reporting seven warnings and no error, while this module read only the errors (Codex at the close, 2026-11-05, reproduced here). `minifyText` now throws on any warning, and `minifyFile` leaves the file as written and says why. The fleet's own stylesheets gave none (a1k9training, pro-plumbing, k9-solutions), so they are minified as before.
25
+ - **A stylesheet clean-css cannot read is compacted, never handed its output.** clean-css 5 does not understand native CSS nesting: it dropped a nested `.child { … }` and a nested `@media`, and hoisted `&:hover` out of its parent, reporting seven warnings and no error, while this module read only the errors (Codex at the close, 2026-11-05, reproduced here). So an error or a warning means its output is discarded. Until 2026-11-08 the file was then kept as written. The clean room showed the cost: Tailwind v4 nests, so a Tailwind sheet built without `--minify` shipped with every space, and so did its purged copy (12,072 bytes raw, 10,613 purged, against 9,825 for Tailwind's own `--minify`). Now `compactCss` takes those: PostCSS reads nesting, and only its formatting raws are cleared (whitespace between nodes, and every comment node but a `/*!` one). The last statement at-rule of a file loses its semicolon (`@layer a, b` at the end), which the CSS Syntax end-of-file rule allows; checked in Chromium only (review at the close, 2026-11-08). No selector, at-rule parameter or value is touched, and the purged sheet's declarations compared equal after it. Measured: 12,072 → 9,666 bytes unpurged, and 10,613 → 8,492 purged. The purged sheet as written and compacted gave 0 computed-style differences in Chromium (3 pages, 375 and 1440 wide, 4 states, 600 elements). Only a sheet PostCSS cannot parse either is left as written, with the reason. The fleet's own stylesheets gave clean-css no warning (a1k9training, pro-plumbing, k9-solutions), so they are minified as before.
25
26
  - **The served bytes no longer match an author's `integrity` hash** once minified. `lib/integrity.js` turns that into a build failure (`AIKB/integrity.md`).
26
27
  - **Before the hash, never after.** The hash is the served bytes', so a minified file gets a new name exactly when its minified bytes change. Minifying after hashing would serve bytes the name does not describe.
27
28
  - **On by default, because it is cheap and never worse.** Measured on the fleet (2026-11-04, `planning/research/2026-11-04-static-site-performance.md` § Measured — CSS and JS): after Brotli, an unminified file was about 17% smaller once minified, and an already-minified one about 1–2%. A result no smaller than the input is discarded, so a tiny file with a licence banner is never grown.
package/AIKB/audit.md CHANGED
@@ -35,6 +35,7 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
35
35
  - `script-blocking`, from `blockingScripts`: a `<script src>` in the head that `scriptKind` calls `classic` (so not inert, not a module) with neither `defer` nor `async`. One finding per script, `detail` the src.
36
36
  - **The image checks** (workstream F, `planning/plans/2026-11-04-kiss-performance.md`). Two new facts, extracted from `strip()` with the speed facts: `imageRefs`, every URL an `<img src>` or an `<img>`/`<source>` `srcset` names (through `srcsetUrls`, the link scan's reading), sorted and deduplicated; and `imgPlain`, the src of each `<img>` with no `srcset` outside any `<picture>` (the `<picture>` blocks are cut before the scan), in document order.
37
37
  - **`image-oversized`**: per page, each `imageRefs` URL that `classifyReference` calls internal and `resolveTarget` (with the link scan's `extensionLess`/`trailingSlash`, and a package page's own rule) lands on a raster (`RASTER_EXTENSION`) over `IMAGE_OVERSIZED_BYTES`, 200 KB, **provisional** until measured on the fleet. `detail` is `<build-relative file> (<n> KB)`, and the summary groups by that value (`VALUE_CHECKS`). Only files a page's markup names: a CSS background image is a documented false negative. Sizes are `statSync`ed once per file per audit.
38
+ - **A format-switching `<picture>` counts its first `<source>` only** (the operator's rule, 2026-11-08, item 5 of #78's reporter). `extractSpeed` takes a `<picture>` whose first image element is a `<source>` with a type every current browser decodes (`DECODED_EVERYWHERE`: AVIF, WebP, JPEG, PNG, GIF), a `srcset` and no `media` (what `{{image}}` writes) as loaded from that source: the later sources and the `<img>` are fallbacks, left out of `imageRefs`. Any other `<picture>` is taken whole: an art-directed one (`media`), whose `<img>` a screen the query misses loads; one whose first `<source>` has no type, or one some browsers skip (`image/jxl`); and one whose first `<source>` has no `srcset`, which every browser skips (third review, 2026-11-08). `imgPlain` reads the same picture-free text the rule leaves, rather than stripping the page a second time. Measured on a1k9training with heroes at 1600 wide: the old rule gave 10 findings, all WebP (205–221 KB) or JPEG (202–251 KB) fallbacks beside AVIFs of 103–147 KB, and that was why the site capped its widths at 1440. Accepted cost: an oversized fallback for a browser without AVIF is never reported. The art-directed test was seen red under a mutation that dropped the `media` condition.
38
39
  - **`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
40
  - **`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
41
  - 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.
@@ -27,7 +27,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
27
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`.
28
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`.
29
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`.
30
- - Every page path goes through `real()` and is then sorted.
30
+ - Every page path goes through `real()` and is then sorted. `real()` maps a staged path in any spelling to the build folder's, and an absolute path under the build folder likewise; a relative path that was never staged is left as written (`docs\x.html` on Windows stays so). Mapping it too rewrote every plain build's pages as `./docs/x.html`, which the fourth review caught before release: a site's first `kiss-ssg check` after upgrading would have seen every page move against its record.
31
31
  - It carries **no timestamp**, so `last-build.json` stays byte-identical across identical builds. The time a banner version was first shown is the commit time of the `kiss-ssg aikb` record that first holds its hash.
32
32
  - `thirdParty`'s origins carry `services` beside `kinds` when a consent service gates them.
33
33
  - `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the five `VALUE_CHECKS` instead print one line per value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters. For the two duplicate checks, that is because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27). For `viewport-zoom-lock` and `fragment-missing`, the value (the viewport content, the href) is what to grep the templates for; a page list alone sends you reading every link (2026-10-20). For `checkbox-prechecked` the value is the box's label, the string to grep for in the partial the box lives in (2026-11-02) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
@@ -45,7 +45,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
45
45
 
46
46
  - **`thirdParty` is the last key**, after `packages`: `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, the origins other than the site's own that the written pages fetch from (`thirdPartyOrigins` in `lib/audit.js`, `AIKB/audit.md`). A fact, not a finding: `audit.check: false` does not turn it off and it never moves `ok`. `null` when no page wrote HTML facts, and **`null` on any failure** — the audit's stance, because a page that failed fetched nothing on record and a partial inventory reads as a complete one; the rule is here rather than upstream so `_refreshReport()` applies it too. Each `page` goes through `real()`; origins are URLs and are not mapped. `siteUrl` is `null` when it was unset or local and every absolute origin is therefore listed. Present in dev builds, unlike `audit`: it reads only the facts the pages parked, so a scoped re-render's page is current. `AIKB/last-build.json` gains it at a site's next `kiss-ssg aikb`; `diffReports` does not read it.
47
47
  - **`packages`** comes after `audit`: one `BuildPackage` per learning package and format (`id`, `format`, `file`, `title`, `hash`, `pages`, `files`, `outside`), sorted by id then format, or `null` when no page is in a package, packaging is off, the build failed, and in a dev build. `file`, every `pages` entry and every `outside[].page` are mapped out of staging like every other path. `formatReport` ends with one `package:` line per package and up to five `package outside:` lines each, then a count of the rest.
48
- - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` (`lib/staging.js` `toReportedPath`) calls this same function for the failure _message_, so the two cannot name a file differently; this is the rule somewhere a unit test can reach it. A path joined with `/` or, on Windows, with `\` (a writer that used `path.join`) is mapped, and always written with `/`: `cacheHeaders.files` named the discarded staging folder until the fleet check of 2026-11-05. The rule lives here, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
48
+ - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` (`lib/staging.js` `toReportedPath`) calls this same function for the failure _message_, so the two cannot name a file differently; this is the rule somewhere a unit test can reach it. **Compared by where the paths point (`path.resolve` both, then `path.relative`), never by their text**, and written as `<buildDir>/<rel>` with `/`. A text prefix test was tried twice and missed a spelling each time. The first missed a `\`-joined path on Windows: `cacheHeaders.files` named the discarded staging folder until the fleet check of 2026-11-05. The second (2.13.3) took `\` and still missed a build folder written `./docs`. `stagingSiblings` makes `./docs.kiss-staging-…`, but `path.join` drops the `./`, so a1k9training's `last-build.json` changed on every `aikb` run. That second miss was not Windows-only. The unit tests had passed a staging folder with no `./`, which the engine never builds; they now take it from `stagingSiblings`, and `test/integration/aikb.test.js` records twice from a `./`-relative build folder and compares the bytes. The rule lives here, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
49
49
  - **A failure's `Error` is reduced to its `message` on purpose.** `JSON.stringify(new Error('x'))` is `{}`, so a report carrying the object loses exactly the text that says what went wrong. The object itself stays on the `AggregateError` `complete()` rejects with — the report is the serialisable view of the same build, not a replacement for it.
50
50
  - **`assets` is the manifest, so it is keyed by the path a template writes**, not by the source file: a `.scss` appears under its compiled `css/site.css` name, and `target` is where cache busting put it (`css/site.a1b2c3d4.css`). See `AIKB/asset-manifest.md`.
51
51
  - **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
package/AIKB/check.md CHANGED
@@ -14,7 +14,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
14
14
  published output. The engine's side of the protocol the bin sets up; they moved here from
15
15
  `lib/kiss.js` (2026-10-02) when the method that reads `KISS_AIKB` left it for
16
16
  `lib/build-finish.js`, so that no module has to import `kiss.js`.
17
- - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line, as it names `images` (`lib/images-sweep.js`, which the bin dispatches before `parseArgs`, with its own `IMAGES_HELP`). `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
17
+ - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line, as it names `images` (`lib/images-sweep.js`, which the bin dispatches before `parseArgs`, with its own `IMAGES_HELP`) and `issue` (`lib/issue.js`, dispatched the same way, with `ISSUE_HELP`). `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
18
18
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
19
19
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
20
20
  - `readReportsFile(text)` → the same `BuildReport[]`, from any of the four shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, the `{ reports, diff }` object it prints whenever it diffs, or a single report object. The wrapper was once read as a single report with no pages, so a check's own output handed back as `--against` diffed every page as added (found upgrading K9-Solutions, 2026-09-27). What `--against` reads.
package/AIKB/config.md CHANGED
@@ -11,7 +11,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
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
  - `resolveFetch(fetch)` → the merged fetch block in the form the engine reads: a `retries` or `timeout` given as a string of digits (`'5000'`, `'1.5'`; what an environment variable is, and what 2.13.2 took through `setTimeout`'s coercion) becomes the number, and the block is then held to its shape by `assertFetchIsValid`. Called by `resolveConfig`, whose result carries the numbers, and again by `lib/fetch-policy.js`'s `fetchModel`, for a caller that never went through `resolveConfig`.
13
13
  - `assertFetchIsValid(fetch)` → `void`, throws on a merged fetch block whose key is not its shape: `retries` a whole number of 0 or more, `timeout` a finite number of 0 or more (`0` is no timer), `headers` a plain object (a null prototype counts; not a `Headers` instance or pairs), `cache` a non-empty string or `false` (not `null` or `''`). The message names the key (`config.fetch.retries must be …`) and never the value.
14
- - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
14
+ - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 768, 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.
15
15
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
16
16
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
17
17
  - `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), `ignore: []`, the check ids not to run, and `privacyPage: null`, the override of the convention `privacy-link-missing` uses to find the privacy page (id `privacy`, else `privacy.html` or `privacy/index.html`).
package/AIKB/css-audit.md CHANGED
@@ -17,7 +17,7 @@
17
17
  - `buildFindings({ pages, stylesheets, inline, duplicates, blockers })` → the findings, sorted by check then detail.
18
18
  - `formatCssAudit(audit)` — the stdout text.
19
19
  - `auditCss(root, { purge })` → the `CssAudit` record, `{ version: 1, pages, stylesheets, thirdParty, inline, findings }` — what `--out` writes.
20
- - `runCss(argv, { cwd, purge })` → `{ code, out, err }`, the whole command. `purge` is injectable for tests (default `purgeCss`).
20
+ - `runCss(argv, { cwd, purge })` → `{ code, out, err }`, the whole command. `purge` is injectable for tests (default `purgeCss`). `auditCss` makes one `purgeReads()` and passes it to every purge as `reads`, so a page linked by several stylesheets is read once (`AIKB/css-purge.md`). The "Removable" share therefore comes from the same purge a build runs, exact markup classes included (issue #78: it read 29% on a site whose real saving was 11%).
21
21
  - `PAGE_SAMPLE` (3, the page paths an inline entry or a finding names) and `QUOTE_LENGTH` (60, the characters of a block a finding quotes).
22
22
 
23
23
  ## Depends on
package/AIKB/css-purge.md CHANGED
@@ -7,7 +7,22 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
7
7
  ## Public interface
8
8
 
9
9
  - `resolvePurge(purge)` → `KissPurge | null`. `null`, `false` and absent purge nothing. An object needs a non-empty `files` list (the paths a template asks for; a leading `/` is dropped). `safelist` is strings and RegExps, default `[]`. `scripts` is a boolean, default `true`. Anything else throws, naming the key. `lib/config.js` calls it, so a typo fails at `new Kiss()`.
10
- - `purgeCss(css, { html, scripts, safelist })` → the purged CSS: `new PurgeCSS().purge()` over raw HTML and JS content, `safelist` as PurgeCSS's `standard` list. PurgeCSS is **imported when this runs**, not at module load: `lib/config.js` imports this module on every build, and PurgeCSS brings PostCSS with it.
10
+ - `purgeCss(css, { html, scripts, safelist, reads })` → the purged CSS. It calls `new PurgeCSS().purge()` over:
11
+ - the raw HTML and JS;
12
+ - **one more content item, holding the pages' exact `class` attribute values** (`attributeClasses`, one per line, extension `kiss-classes`), read by an extractor that only splits lines.
13
+
14
+ The options: `classTokens` as the `defaultExtractor`, `DYNAMIC_ATTRIBUTES` as `dynamicAttributes`, and `safelist` as PurgeCSS's **`greedy`** list only (a string becomes the exact-name RegExp; `greedy` keeps everything `standard` would). PurgeCSS is **imported when this runs**, not at module load: `lib/config.js` imports this module on every build, and PurgeCSS brings PostCSS with it.
15
+
16
+ - `purgeReads()` → a fresh `PurgeReads` (`{ tokens, classes, exact }`: two `Map`s keyed by page or script text, and a `WeakMap` from a page list to its exact classes, deduplicated and joined, since a build purges every stylesheet against the same list; third review, 2026-11-08) for one run of purges: `purgeStylesheets` makes one per build and `lib/css-audit.js` `auditCss` one per audit. Passed to every `purgeCss` of the run as `reads`, so each page and script is tokenised and parsed once however many stylesheets it meets. PurgeCSS runs its extractor over every content string for every stylesheet. Keyed by text, not by list: the audit hands each stylesheet a different list. The first version kept a module-level `WeakMap` on the list's identity, which the audit never hit (review, 2026-11-08).
17
+ - `attributeClasses(html)` → the classes in real `class` attributes, parsed with htmlparser2 (decoded, never matched in text), each once.
18
+ - `classTokens(content)` → every candidate class a script, or text, might name. It is the union of three sets:
19
+ - whole tokens: up to whitespace, quotes, backticks or `<>`, except inside `[…]`, where quotes and `>` are allowed (one nested group, `[&[open]]`); a trailing `:` is trimmed;
20
+ - the value after the first `=` of an unquoted attribute;
21
+ - the `[A-Za-z0-9_-]+` pieces PurgeCSS's default found.
22
+
23
+ A token holding a character reference is read again decoded, with `lib/links.js` `decodeEntities`.
24
+
25
+ - `DYNAMIC_ATTRIBUTES`: the ARIA states plus `disabled`, `hidden` and `inert`. Attribute selectors on these are kept whatever value the markup holds, as PurgeCSS already does for `value`, `checked`, `selected` and `open`.
11
26
  - `purgeStylesheets({ purge, root, manifest, pages, hash, minify, outputs })` → `PurgeResult[]`, one per file: `{ name, from, to, before, after }`. It:
12
27
  - reads every written page and, under `scripts`, every `.js`/`.mjs` in the build;
13
28
  - purges each file, then minifies it again when `minify` is on (a minifier failure keeps the purged text);
@@ -20,7 +35,7 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
20
35
 
21
36
  ## Depends on
22
37
 
23
- `fs-extra`, `node:path`, `purgecss` (dynamic import), `./asset-manifest.js` (`contentHash`, `hashedName`), `./asset-minify.js` (`minifyText`), `./utils.js` (`posixPath`).
38
+ `fs-extra`, `node:path`, `htmlparser2` (`Parser`, for `attributeClasses`), `purgecss` (dynamic import), `./links.js` (`decodeEntities`), `./asset-manifest.js` (`contentHash`, `hashedName`), `./asset-minify.js` (`minifyText`), `./utils.js` (`posixPath`).
24
39
 
25
40
  ## Depended on by
26
41
 
@@ -29,6 +44,18 @@ Remove the CSS no written page uses from the stylesheets a site names in `config
29
44
 
30
45
  ## Non-obvious behavior
31
46
 
47
+ - **Whole class tokens, and nothing the old extractor kept is lost** (issue #78, reported against 2.13.3; the fix ships in the release after it). PurgeCSS's default extractor, `/[A-Za-z0-9_-]+/g`, split `bg-ink-900/80`, `min-h-[60vh]`, `p-1.5`, `hover:bg-brand-700` and every variant into pieces that are not the class. Every such rule was removed from a site that used it: a1k9training lost its hero height and nav padding, with 25,360 computed-style differences across 20 pages. Nothing reported it, and `kiss-ssg css` overstated "Removable" (29% against a real 11%) for the same reason. `classTokens` adds the whole tokens and keeps the old pieces, so the new candidate set is a superset of the old one. An extra candidate can only keep a rule that is not needed.
48
+ - **A class in the markup cannot be lost: it reaches PurgeCSS exactly, as htmlparser2 read it.** `classTokens` stays a heuristic for scripts and text, and it still cuts some legal classes (`a>b`). Each rule a page could match is still PurgeCSS's decision, ids, tags and attributes included. **There is no guard.** A first version checked the purged CSS afterwards and failed the build when a used class lost every rule. The code review of that slice (2026-11-08) showed it was unsound in both directions:
49
+ - it judged a selector by its classes alone, so `#sidebar .card` with no `#sidebar`, `ul.menu` with no `<ul>`, and `.btn[data-size=xl]` failed builds whose purge was right;
50
+ - it judged by the same tokens that had missed a class, so it could never report a miss.
51
+
52
+ The operator chose to make the failure impossible rather than detect it. The one hole left, a class a script assembles from pieces (`'modal-' + name`), was never visible to any check, which is why the safelist exists.
53
+
54
+ - **`classTokens` is linear on any input, and the shape of `WHOLE_TOKEN` is why.** A bracket group never runs past whitespace or the next `[` and holds at most one nested group, and the trailing `:` is trimmed by a loop after the match. The first version refused a trailing `:` inside the pattern and let a group run to any `]`. Measured on this branch: 50 to 53 s on 200,000 characters of `:`, of `[`, or of `a[` repeated. `/:+$/` as the trim cost 21 s on 200,000 `:` followed by a letter. A minified bundle can hold such a run. `test/unit/css-purge.test.js` times all four at 200,000 characters against a 250 ms budget.
55
+ - **A safelist entry keeps every rule that names it.** `standard` alone only exempts the matching selector part. The rest is still checked, so `safelist: ['btn']` lost `.btn[data-size=xl]` on a page with no `data-size="xl"`. `greedy` keeps a rule when any part matches (the operator's decision, 2026-11-08). A site with a safelist may keep more CSS than it did before. "Any part" is every name PurgeCSS 8.0.0 reads from a selector (`getSelectorValue`): class, id, tag, attribute and pseudo-class. So `'active'` keeps `.btn:active`, `/open/` keeps `[open]` rules and `/^nav/` keeps `nav …` rules, measured through `purgeCss` in the fourth review (2026-11-08). GUIDE says so and tells a site to anchor its patterns.
56
+ - **A selector that matches every element is kept** (`:focus-visible { … }`, `:-moz-focusring:where(…)`). PurgeCSS 8.0.0 removes one made of pseudo-classes alone (`AIKB/upstream.md`). `PSEUDO_PART` in its `standard` safelist keeps it, and leaves every other part of a selector to be checked. Found by the a1k9training clean room: with no safelist, every keyboard focus ring went (62 computed-style differences, all focus states).
57
+ - **ARIA state selectors are kept whatever the markup holds.** A disclosure button written `aria-expanded="false"` lost its `aria-expanded:rotate-180` rule (`[aria-expanded=true]`), because PurgeCSS also wants the attribute's value in the content and a script supplies it.
58
+
32
59
  - **Every `.html` file in the build is read, not only the rendered pages.** An HTML file copied from the assets folder, or a page kept from an earlier build under `cleanBuild: false`, links the stylesheet too, and its classes were purged when only `kiss._stack` was read (Codex at the close, 2026-11-05; the integration test was red before). The rendered pages are still the only ones relinked to a re-hashed name: a copied page's own `<link>` is its author's.
33
60
  - **Purged first, minified once, after.** `lib/assets.js` skips the copy-time minify for a file `assets.purge` names, and this module minifies it after purging. Minifying first strips comments, PurgeCSS's `/* purgecss start ignore */` included, and the protected block would be purged. Measured: with the old order, `.kept-by-comment` was removed (`test/integration/css-purge.test.js`, red before the fix). The minify after the purge is also the one that matters: it clears what the purge leaves behind, and the hash is taken from those final bytes.
34
61
 
package/AIKB/embed.md CHANGED
@@ -44,6 +44,7 @@ It exists because of the 2026-11-04 clean room (`planning/reviews/2026-11-04-emb
44
44
  - **`resolveEmbeds(input, consent)` → `{ text }|null`**, for `config.embeds` (`lib/config.js`). It is the wording of a site with no `config.consent`, under the same four keys `ui.text` uses (`EMBED_TEXT_KEYS`: `embedNotice`, `embedLoad`, `embedPlay`, `embedWatch`; not `embedAllow`, which only the consent manager offers). It throws on `embeds` beside an active `consent`, so there is one home for the words and no precedence rule to learn. "Active" is the **resolved** consent: `lib/config.js` calls it after `resolveConsent`, so `consent: false` (switched off) takes `embeds`. Until Codex's slice-4 review it read the raw input and refused `false`. It also throws on any key other than `text`, on an unknown wording key, and on a value that is not a string.
45
45
  - **Autoplay is asked for by the click, never written into the frame.** A `video` service's box carries `data-kiss-embed-autoplay` unless the call said `autoplay=false`. The facade's click handler, and the consent client's `load` action (`AIKB/consent-client.md`), set `autoplay=1` on the cloned iframe's `src` before inserting it. The `<template>`'s own `src` never has it, because the consent client also reveals a frame from a stored choice, with no click, and that must not start a video. Measured in headless Chromium 156 with a real YouTube video (2026-11-05, `aqz-KE-bpKQ`): playing unmuted at 6.4 s, eight seconds after the click; the `autoplay=false` control paused at 0. iOS Safari is not measured.
46
46
  - **A poster (`poster`, markup the helper already wrote with the `{{image}}` pipeline) is the one case where kiss writes colour** (the operator, 2026-11-05). The 2026-11-04 geometry-only rule assumed a plain box. Text on a photo is unreadable without a backing, so the operator chose a readable default over geometry only. With a poster, the box gains the class `kiss-embed--poster` and `position:relative;justify-content:flex-end`. The still is a `.kiss-embed__poster` wrapper, absolute at `inset:0`, carrying the same `data-kiss-embed-ui` / `data-kiss-consent-ui` attribute as the notice, so both reveal paths hide it with no client change. The notice becomes a compact full-width strip: padded, `.875em`, tight paragraph margins, and the watch link beside the button rather than under it. This came from the operator's look at `/branch-close` (2026-11-05). On an unstyled site at 375 px the first strip covered 69% of the box with its text against the edges; the compact one covers 44% at 375 px (52% at 320, 9% at 1440), with the text 12 px in. The browser suite holds it under 50% at 375 px and under 15% at 1440, and was red without it. It is all inline, because a `<style>` per embed would be the CSS audit's `inline-style-repeated`, so no media query is possible. Its colours are **custom properties with fallbacks** (`--kiss-embed-poster-scrim`, `--kiss-embed-poster-text`), because a site cannot beat an inline style without `!important` but can set a variable. The notice's paragraphs and the watch link get `color:inherit` inline. That beats any rule on them: the consent theme's `.kiss-embed__notice p` is muted, which measured `color(srgb .3 .3 .3)` on the strip in Chromium (Codex, slice 3), and a site's own link colour (a1k9training's is `text-brand-700`) would be as bad. The browser suite checks every `p` and `a` in the strip, in the facade and under the consent theme. A service whose `embed.video` is set (youtube, vimeo) gets `PLAY_MARK` on its Load button: an `aria-hidden` SVG, not `▶`, which iOS draws as a colour emoji. Without a poster the markup is byte-identical to before.
47
+ - **Every button carries its words in `data-kiss-embed-label`**: Load in both the facade and the consent box, and the consent box's Always allow (2026-11-08, #78's reporter; Always allow after the fourth review). a1k9training's poster restyle sets the button's text node to `font-size:0` and redraws a backed label with `::after { content: 'Play video' }`, because a bare text node cannot carry a backing, and a `load=` change would silently disagree with that CSS. With the attribute, a site writes `content: attr(data-kiss-embed-label)`. The words are escaped as an attribute. Measured in Chromium against `embedMarkup`'s own output: `::after` shows the words, `load=` changes them with no CSS edit, and the accessible name (`aria-label`) is unchanged. Placed after `aria-label`, on every embed, poster or not.
47
48
  - **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.
48
49
  - **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.
49
50
  - **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`.
package/AIKB/images.md CHANGED
@@ -7,7 +7,7 @@ Everything about a responsive image that is decided at render: the recipe (`conf
7
7
  ## Public interface
8
8
 
9
9
  - `resolveImages(images)` → `KissImages`: `{ widths, formats, quality, cache }`, filled from `DEFAULT_IMAGES` one level deep (`quality` merges per format). Throws, naming the key, on an unknown key, an empty or non-integer `widths`, a format other than `avif`/`webp` (or one twice), a quality outside 1–100, or a `cache` that is neither a non-empty string nor `false`. `widths` come back sorted and deduplicated. `lib/config.js` calls it.
10
- - `DEFAULT_IMAGES`: widths `[480, 960, 1440, 1920]`, formats `['avif', 'webp']`, quality AVIF 50, WebP 75, JPEG 80, cache `.kiss-cache/images`. The qualities were checked by eye by the operator on 2026-11-05: four fleet images at 960 px (fur, dark fur, black-and-white artwork, a dark PNG), each flicked against a lossless reference and the qualities either side (AVIF 40/60, WebP 65/85, JPEG 70/90). The widths are still provisional.
10
+ - `DEFAULT_IMAGES`: widths `[480, 768, 960, 1440, 1920]`, formats `['avif', 'webp']`, quality AVIF 50, WebP 75, JPEG 80, cache `.kiss-cache/images`. The qualities were checked by eye by the operator on 2026-11-05: four fleet images at 960 px (fur, dark fur, black-and-white artwork, a dark PNG), each flicked against a lossless reference and the qualities either side (AVIF 40/60, WebP 65/85, JPEG 70/90). The widths are still provisional. 768 was added on 2026-11-08 (#78's reporter, a1k9training): a full-width image on Lighthouse's mobile device (412 px at 1.75x) wants 721 px and was sent the 960. Measured in a clean room of that site, 20 pages at 412 px and 1.75x: the image bytes a phone downloads went 1,642,657 → 1,315,851 (−19.9%, 16 of 20 pages lighter), and the deployed and cached image bytes 20.3 MB → 23.5 MB (+72 variants, +16%).
11
11
  - `variantWidths(sourceWidth, widths)`: every width narrower than the source, plus the source's own width when it is not wider than the largest. Never enlarges.
12
12
  - `fallbackFormat(sourceFormat)`: `png` for a PNG, `jpeg` for the rest.
13
13
  - `recipeString({ format, width, quality })`: `kiss-image/1 <format> w<width> q<quality>`.
package/AIKB/issue.md ADDED
@@ -0,0 +1,38 @@
1
+ # issue.js
2
+
3
+ ## Responsibility
4
+
5
+ `kiss-ssg issue`: a client site drafts an issue for kiss-ssg, and later asks whether the kiss-ssg it has installed shipped the fix. It never posts, labels or comments: it writes the draft outside the site, prints the `gh issue create` command for the operator, and reads. The contract is `planning/plans/2026-11-08-feedback-loop.md`.
6
+
7
+ ## Public interface
8
+
9
+ - `issueRepo(root?)` — `owner/name` from this package's own `bugs.url`, never the site's.
10
+ - `BUG_HEADINGS`, `FEATURE_HEADINGS` — a draft's `###` headings, in order. `.github/ISSUE_TEMPLATE/bug.yml` and `feature.yml` are held to them by `test/unit/changelog.test.js`.
11
+ - `findTokens(text)` — `[{ line, kind }]` for every token-shaped value, never the value. The shapes: GitHub, npm, Slack, AWS, Stripe (`sk_`/`rk_` live or test), Google API keys (`AIza` plus 35), JSON Web Tokens, `sk-` keys and private-key headers, plus a long value assigned to a secret's name. The security review at the close (2026-11-08) added Stripe, Google and JWT. It is a pattern list, so a token split across lines still gets through; the operator reads the draft before posting it.
12
+ - `issueNumber(ref, repo)` — a number from a URL into `repo`'s issues, `#n` or `n` (what `--status` takes); `null` otherwise, including another repository's URL.
13
+ - `fixesLink(repo)` — the one CHANGELOG form of a shipped issue, `Fixes [#n](https://github.com/<repo>/issues/n)`, as a global RegExp (both numbers captured). Any whitespace may follow `Fixes`, a line break included: prettier wraps `CHANGELOG.md` and broke the 2.14.0 entry's line there, which `--status` would have read as not shipped (found at that close). `test/unit/changelog.test.js` holds every issue link in `CHANGELOG.md` to it.
14
+ - `shippedIn(changelog, n, repo)` — the version whose entry carries that link with `n` twice, or `null`.
15
+ - `parseFiled(text, repo)` — the `filed: <URL>` lines a site's logs or `AIKB/site.md` carry that name an issue in `repo`. Another project's URL, or a bare number ("Bugs filed: 2"), is not one (fifth review, 2026-11-08: both were read as kiss-ssg issues).
16
+ - `buildSummary(reports, source)` — a draft's "The build": per report `ok`, mode, page and failure counts, up to ten failed views, and the audit's check ids each with a count derived from `findings[]`.
17
+ - `draftIssue({ kind, notes, facts, anonymous })` — the draft's Markdown.
18
+ - `createCommand({ repo, title, file })` — `! gh issue create …`, one single-quoted form for bash and PowerShell alike: every quote-like character (`'` and U+2018–U+201B, which PowerShell also reads as a quote) becomes ʼ (U+02BC). On Windows the `!` line may run in Git Bash or PowerShell, which escape a quote differently, and an agent's ’ ended a PowerShell string early (fifth review). The title on GitHub shows ʼ where it had an apostrophe.
19
+ - `parseIssueArgs(argv)`, `runIssue(argv, { cwd, gh, platform, tmpdir, repo, write })` — the whole command, `{ out, err, code }`; `bin/kiss-ssg.js` only prints it. `runGh(args, cwd)` is the default `gh`. `ISSUE_HELP` is the help text.
20
+
21
+ ## Depends on
22
+
23
+ `lib/check.js` (`readReportsFile`, for `--report` and the record), `lib/sessions.js` (`SESSIONS`, `readSessions`, for `--filed`); `node:fs`, `node:os`, `node:path`, `node:child_process` (`gh`, spawned with no shell).
24
+
25
+ ## Depended on by
26
+
27
+ `bin/kiss-ssg.js` (`kiss-ssg issue`); the `kiss-memory` skills `kiss-branch-close` (drafts) and `kiss-branch-open` (`--filed`); `test/unit/changelog.test.js` (`fixesLink`, the heading lists).
28
+
29
+ ## Non-obvious behavior
30
+
31
+ - **It never posts.** The operator's decision (2026-11-08): the repository is public and a site agent acting alone should not publish there. The command is printed with a leading `! ` so that, run inside a Claude Code session, `gh`'s output (the new issue's URL) lands in the conversation for the agent to record as `filed: <URL>`. The unit test's injected `gh` and the integration test's fake `gh` (POSIX) record every call, and both assert none is `create`; each was seen red with a `create` call added.
32
+ - **`gh` is spawned with no shell**, so the title is one argument whatever it holds. On Windows only a real `gh.exe` answers that way, which is why the fake-`gh` integration case is POSIX-only; the no-`gh` case runs everywhere. `gh` missing or signed out is said ("could not search for duplicates") and the draft goes ahead.
33
+ - **The token rule passes an honest config excerpt.** A value after `token`, `secret`, `password`, `apikey` or `authorization` counts only when it is 20 or more characters of letters and digits both, with no dot or space, so `process.env.API_TOKEN`, `'Bearer ' + token` and `API_TOKEN_FOR_THE_STAGING_SERVER` pass (the critique found the first draft refused exactly the `fetch.headers` excerpt a bug report would carry). The title is scanned too. A refusal writes nothing and names the line and kind, never the value.
34
+ - **The build is described from `--report` first.** `AIKB/last-build.json` is written only by a passing `kiss-ssg aikb`, so it can never show the failure being reported; it is the fallback and labelled "the last recorded passing build". The config is never read (that would run the build script).
35
+ - **`--status` needs no network for its answer**: it reads the installed `node_modules/kiss-ssg/CHANGELOG.md` (else this package's) for the `Fixes` link. `gh`, when signed in, only adds the issue's state. A bare mention, or a link whose text and URL numbers differ, is not a shipment.
36
+ - **`--filed` reads every log**, not the three a read-back reads, and `AIKB/site.md`, because a fix usually ships several sessions after it was filed, and consolidation moves Feedback into `site.md`.
37
+ - **The site URL** comes from the report's `thirdParty.siteUrl`, which is `null` when `siteUrl` is unset or local, or the build failed; then it is left out. `--anonymous` leaves the name and URL out, and the failing views too (their paths can name a client), keeping the counts.
38
+ - **Nothing throws out of `runIssue`.** A site `package.json` that is not JSON costs the draft its name (the folder's is used); an `--out` folder that is not there, or a `--report` that is not a report, is exit 2 with a sentence. The bin has no try/catch, so an escape would print a stack and exit 1, which means "refused" (fifth review).
package/AIKB/kiss.md CHANGED
@@ -76,13 +76,13 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
76
76
  - **A `generate()`/`sitemap()` callback that throws is a build failure**, recorded by `_runCallback` as `{ view: '<generate callback>' | '<sitemap callback>', buildTo: null, error }` — not swallowed by the enclosing `.catch()`, which would log it as `Error generating site` or, worse, `Error creating sitemap.xml` for a sitemap that had in fact been written correctly (review finding A-04). The two catches are now distinct: the enclosing one covers the engine's own work, `_runCallback` covers the consumer's. The callback's return value is still not awaited by the `generate()` run itself (see the `complete()`-inside-a-callback bullet below), so a promise it returns gets its own `.catch` — but that caught promise is now recorded on `_callbacks`, which `_drain()` waits for (review finding A-03). So an async callback that rejects is reported by the `complete()` that was already draining, not deferred to a later one; the only rejection still reported late is one from a callback registered after every `complete()` has finished.
77
77
  - **A dev server that cannot bind is a build failure**, recorded as `{ view: '<dev server>', buildTo: null, error }` — the same shape as a callback failure, since there is no output path to name it by (review findings F-E2/F-E3). It used to be non-fatal and mislabelled: the constructor's `ready.catch` logged `Error running live reload server` for the _static_ server, a second copy of the same fault had already been logged inside `lib/dev-server.js`, and the build then ran to completion with the process left up serving nothing behind an already-printed `Serving …` line. Now `dev-server.js` logs nothing for a bind failure and `ready` carries the whole message; the constructor logs it once, records the failure so `complete()` rejects, and then `await this.close()`s — unlike live reload, which is optional, there is nothing useful to do without the HTTP server, and the watcher plus the (successfully bound) livereload server would otherwise hold the process open long after the consumer's rejection handler had run. The `ready.catch` promise is pushed onto `_generating` so the drain waits for the bind result: a small site would otherwise race the `'error'` event and `complete()` could resolve before the failure was recorded. A synchronous throw out of `startDevServer` takes the same path, and `watch()` is never reached in that case.
78
78
 
79
- - **A model that cannot be resolved is a build failure, and so is a controller that fails** (2.14.0; until then a model failure was logged and the page skipped, and the report said `ok: true` with the page missing). A missing `.json` file (`Model not found: <file>`), an empty or missing model folder (`Model folder empty or missing: <folder>`, a `.pages()` fan-out included), a URL model whose fetch fails, a controller that throws, a controller file that does not exist, a controller module that does not export a function, and an unrecognised `controller` type (review finding F-01) all fail the page: `page()` runs the model, the controller, slug/path derivation and `_preparePage` inside **one** `try` (`registerPage`, `AIKB/page-registry.md`), whose one `catch` pushes `{ view, buildTo: null, error }` onto `_failures` and resolves the chain to `{ id, data: null, error }` — so `_promises` never rejects _and_ `complete()` reports it. One `try` rather than one per stage is what keeps it to one failure per page: a controller failure pushed by an inner catch and rethrown would be pushed again by an outer one. A `.pages()` fan-out item never reaches it — it records its own per-item entry and keeps going. A failed page's previous output is removed by the next whole-site replay, and the failure is not carried across it (`_carriedFailures`), so restoring the model file clears it.
79
+ - **A model that cannot be resolved is a build failure, and so is a controller that fails** (2.13.3; until then a model failure was logged and the page skipped, and the report said `ok: true` with the page missing). A missing `.json` file (`Model not found: <file>`), an empty or missing model folder (`Model folder empty or missing: <folder>`, a `.pages()` fan-out included), a URL model whose fetch fails, a controller that throws, a controller file that does not exist, a controller module that does not export a function, and an unrecognised `controller` type (review finding F-01) all fail the page: `page()` runs the model, the controller, slug/path derivation and `_preparePage` inside **one** `try` (`registerPage`, `AIKB/page-registry.md`), whose one `catch` pushes `{ view, buildTo: null, error }` onto `_failures` and resolves the chain to `{ id, data: null, error }` — so `_promises` never rejects _and_ `complete()` reports it. One `try` rather than one per stage is what keeps it to one failure per page: a controller failure pushed by an inner catch and rethrown would be pushed again by an outer one. A `.pages()` fan-out item never reaches it — it records its own per-item entry and keeps going. A failed page's previous output is removed by the next whole-site replay, and the failure is not carried across it (`_carriedFailures`), so restoring the model file clears it.
80
80
  - `_promises` must only ever contain _handled_ (caught) promises — a bad model rejecting unhandled crashed the process in v1. The `.page()` chain catches every failure and resolves to `{ id, data: null, error }` instead of rejecting; the failure reaches the caller through `_failures`, not through the chain.
81
81
  - `_partialNames` holds what the last `registerPartials()` pass registered, and is handed back to the next one. Handlebars never forgets a registration on its own, so without that set a partial deleted from disk would keep rendering its last-known content for the life of the process (review finding B1). The state lives here, not in `partials.js`, because the Handlebars env is per-instance — see `AIKB/partials.md` for why the diff is by name across all four passes rather than per file. A page that still references a deleted partial now fails on the replay: `complete()` collects it in `_failures` and the rebuild queue logs `Error rebuilding site`, and the page's stale output is left alone because it was never rewritten.
82
82
  - Handlebars and Remarkable are created fresh per `Kiss` instance so partials/helpers from one instance never leak into another (matters for tests that build multiple sites in one process).
83
83
  - **Remarkable is constructed from `config.markdown`, and _where_ in the constructor is load-bearing.** `.md` partials are rendered to HTML by `registerPartials()` at registration time, not at page render, so the renderer has to exist in its final configuration before that call. Building it from the resolved config a few lines earlier is what lets a site set `markdown: { breaks: true }` and have it reach partials — a consumer mutating `kiss.remarkable` after construction changes the `{{markdown}}` helper (which renders per page) but silently misses every partial unless they also call `kiss.registerPartials()`. See `AIKB/config.md` and `AIKB/partials.md`.
84
84
  - `export { Kiss as 'module.exports' }` exists purely so CJS `require('kiss-ssg')` returns the class itself on Node ≥22.12, not `{ default: Kiss }`.
85
- - `config.logger` is the one dependency-injection seam — pass `silentLogger` (from `lib/logger.js`) in tests to keep output quiet.
85
+ - `config.logger` is the one dependency-injection seam — pass `silentLogger` (from `lib/logger.js`) in tests to keep output quiet. **`kiss.logger` is not that object**: the constructor wraps it in `lib/staging.js` `stagingLogger`, a Proxy that, while a build is staged, writes the build folder back into the paths its `success` and `info` lines name (a1k9training's log named `./docs.kiss-staging-…/_redirects`, 2.13.3). Every other method and property reaches the injected logger unchanged, so a test spying on `config.logger.warn` still sees each call; a spy on `success` or `info` sees the mapped string.
86
86
  - Calling `complete()` from inside a `generate()` callback is safe, not a deadlock: `generate()` invokes the callback (through `_runCallback`, which calls it as `callback.call(this, data)`) and never awaits whatever the callback returns, so the `_generating` entry for that `generate()` run settles as soon as the callback returns (its return value or any promise it returns is not part of what `_drain()` waits on — which is also why `_runCallback` gives a returned promise its own `.catch` rather than pushing it onto `_generating`). `complete()`'s `_drain()` therefore still sees that run resolve and proceeds normally. It is safe on a _failing_ build too, which it was not before A-01: the report-once latch means only one of the two concurrent `complete()` calls rejects, so the one the consumer did not attach a handler to resolves instead of taking the process down. A real downstream site (diploma-msc's `generate.js`) relies on this pattern, calling `complete()` from within a `generate()` callback. Since A-03 the callback's own promise _is_ waited on — by `_drain()`, not by the `generate()` run — so the re-entrancy guard moved there: `complete()` reads `_drainDepth` **synchronously** on entry, and a call that finds it above zero (i.e. one made from inside a callback while another `complete()` is draining) drains `_promises`/`_generating` only, skipping `_callbacks`. Without that skip an `async` callback that awaits `this.complete()` would wait on a drain that is waiting on the callback. A **depth counter, not a flag**: each call increments on entry and decrements in a `finally`, so the answer does not depend on which of the two calls settles first. A flag saved and restored per call got this wrong whenever the nested call settled _last_ — it restored the value it had captured (`true`), leaving the instance permanently "draining", so every later `complete()`, including the one `_replay()` runs on each watch rebuild, silently skipped `_callbacks`. The guard covers the case that exists in practice — the outer `complete()` is what makes the callback run — and not the exotic one where a callback registered while nothing is draining awaits `complete()`: that call would wait on its own callback entry, so the pattern is "await `complete()` inside a callback of a build somebody is already awaiting".
87
87
  - **The asset manifest is instance state that survives a replay.** `_assetManifest` (`AIKB/asset-manifest.md`) is created in the constructor, before the first `copyAssets()`, and handed to both that copy and `registerHandlebarsHelpers` — so the `asset` helper reads what the copy emitted. Ordering is already guaranteed by the queues rather than by anything new: the constructor's copy is the **first** promise on `_promises`, and `generate()`/`_settle()` await `_promises` before rendering a page, so no page can render against an empty manifest. `_replay()` resets `_assetQueue` and `_promises` but never re-copies assets and never clears the manifest, which is exactly right — the files are still in the build under the names the manifest holds.
88
88
  - `_replaying` is read _synchronously_ at the top of `page()` — both to skip re-logging the registration and to capture `fresh` for the async chain that runs later. That is why `_replay()` can reset the flag in a `finally` immediately after the registration loop, even though the chains it started are still pending.
package/AIKB/staging.md CHANGED
@@ -24,10 +24,62 @@ direct call would step around the patch.
24
24
  getters: the folder this build writes into, and `config` with `folders.build` pointed at it (the
25
25
  same object as `config` when nothing is staged).
26
26
  - `sweepStaleSiblings(kiss)` — `_sweepStaleSiblings()`.
27
- - `stagedPath(kiss, target)` — `_stagedPath()`. `toReportedPath(kiss, target)` —
27
+ - `stagedPath(kiss, target)` — `_stagedPath()`. It decides "inside the build folder" by
28
+ `path.relative`, as `reportedPath` does, so on Windows a target in another case still follows
29
+ the build into staging; until the third review (2026-11-08) it was a case-sensitive text prefix,
30
+ and such a copy landed in the live folder, which the promotion then replaced.
31
+ `toReportedPath(kiss, target)` —
28
32
  `_reportedPath()`, which delegates to `build-report.js`'s `reportedPath` so the report and
29
33
  the failure messages map a staged path by one rule (they were two copies, and both missed
30
34
  a `\`-joined path on Windows until 2026-11-05).
35
+ - `stagingLogger(logger, kiss)` → the logger `Kiss` keeps as `this.logger`: the site's own,
36
+ behind a Proxy. While `_stagingDir` is set, it passes the string arguments of **`success` and
37
+ `info` only** through `unstageText`. `unstageText(text, stagingDir, buildDir)` finds each path
38
+ **under** the staging folder, in any of its spellings (as made, as `path.join` writes it,
39
+ absolute), and hands the whole path to `build-report.js`'s `reportedPath`. So the log and the
40
+ report name a file by **one rule** (`./docs/x/y.html`, with `/`). The first version swapped the
41
+ prefix by text and kept `\`, and the second review (2026-11-08) measured the two disagreeing.
42
+ `\` is a separator in the pattern only on Windows, as in `reportedPath`. The pattern is compiled
43
+ once per staging folder and kept in one slot (a map gained a pattern per build, each folder
44
+ having a new suffix), and the replacement is a function, so a `$&` in the build folder's name
45
+ is text. After the third review (2026-11-08) a match starts only where a path can start (not
46
+ after a word character, `.`, `~`, `-` or a separator), so the shorter relative spelling is never
47
+ matched partway through a longer path. It needs a name character after the separator, so
48
+ `…staging/,` in a sentence is the folder, not a file (it was rewritten to `./docs`). On Windows it
49
+ ignores case, as `path.relative` does (a lowercase drive letter was spliced into `c:\…\./docs/x`). Every writer logs the file it wrote through
50
+ `success`, and under staging that named the sibling (`./docs.kiss-staging-…/_redirects`,
51
+ `sitemap.xml`; a1k9training, 2.13.3). The mapping sits at the logger so that a writer added
52
+ later cannot bring the leak back.
53
+ - **`warn`, `error` and `debug` are passed through untouched.** They carry diagnostics, an
54
+ `err.stack` or a file the staging folder could not remove, whose real path is the one to act
55
+ on. The first version mapped every method, and the code review (2026-11-08) showed it telling a
56
+ user to delete a locked file in the live site.
57
+ - The folder named on its own is never rewritten either: "could not remove the staging folder …,
58
+ delete it by hand" has to name it (`test/integration/staging.test.js`).
59
+ - **Every method runs on the site's logger, never on the Proxy.** The mapped ones are applied to it
60
+ and the rest are bound to it, so a logger class whose methods read private fields (`this.#out`)
61
+ still works; the first version returned them unbound. One function per method while the method is
62
+ the same one, so `kiss.logger.warn === kiss.logger.warn` holds and a spy installed later is still
63
+ reached. `kiss.logger` is not `config.logger`.
64
+ - **The Proxy stands over the logger itself**, so `kiss.logger` has the logger's keys and class,
65
+ and a spy or override defined on it (`vi.spyOn(kiss.logger, 'warn')`) is reached. A Proxy's
66
+ `get` must return a read-only, non-configurable property as it is, so a **frozen** logger's
67
+ methods pass through unmapped and unbound: it works, but its write lines keep the staging name.
68
+ The third review found the first version threw on a frozen logger; the fix put the Proxy over
69
+ an empty object, which the fourth review found lost spies, `Object.keys` and `instanceof`.
70
+ This version keeps the common case whole and gives up the mapping only for the rare one.
71
+ - **The pattern also matches a `./` spelling** of a folder configured without one (`docs` →
72
+ `./docs.kiss-staging-…`), for a writer that logs `./<path>` (fourth review).
73
+ - **Recorded, not changed:** the pattern cache is one slot, so two instances staging at once in
74
+ one process rebuild it on alternate lines (a cost, never a wrong result); a path that mixes `\`
75
+ and `/` on Windows is not matched.
76
+ - **Under check and aikb the write lines name the build folder too** (`./docs/sitemap.xml`), though
77
+ nothing is written there; the closing line says so. The operator chose this over a
78
+ `(not written)` marker or the staging name (2026-11-08): stable, and the same as the record.
79
+ - **Recorded, not changed:** a writer's own `error` line under staging (`Error writing
80
+ ./public.kiss-staging-…/consent-banners.json`) names a folder the failed build then discards. It
81
+ read the same way before this logger existed, and changing it was outside the branch that added
82
+ the logger (second review, 2026-11-08).
31
83
  - `renameForPromote(from, to, folder)` — `_renameForPromote()`.
32
84
  - `restorePrevious`, `promote`, `swapIn`, `discardStaging`, `abandonStaging`, `removeStaging` —
33
85
  the `_`-prefixed method of the same name on `Kiss`.
package/AIKB/testing.md CHANGED
@@ -16,6 +16,7 @@ Cross-cutting notes on how tests in `test/` are organized and written — not a
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.
19
+ - `test/integration/issue.test.js` runs `kiss-ssg issue` with no `gh` on `PATH` everywhere, and, on POSIX only, with a fake `gh` script it writes first on `PATH` that records every call. Node spawns `gh` with no shell, and on Windows only a real `gh.exe` answers that way, so the fake cannot stand in there. The test asserts `gh` is never asked to create anything.
19
20
  - `test/fixtures/consumer/` — a plain-JS site (`site.js`, `// @ts-check`) plus a deliberately broken one (`bad.js`), each with its own tsconfig mapping `kiss-ssg` to the repo's `types/kiss.d.ts`. `test/unit/types.test.js` runs `tsc --noEmit` over both through `child_process`: `site.js` must be clean, `bad.js` must report exactly three named errors. Not a unit test of any module — it is the published type surface checked from the outside, the way a consumer meets it.
20
21
  - `test/aikb.test.js` — **not** the unit test for `lib/aikb.js` (that is `test/unit/aikb.test.js`, with `test/integration/aikb.test.js` beside it); this one is the repo's docs-sync test, named for the `AIKB/` folder it guards. It is covered by this doc's own CLAUDE.md table row: fails if a `lib/` module has no `AIKB/` doc, a doc isn't listed in `CLAUDE.md`'s lookup table, a doc has no matching module, or a doc drops one of the five required headings. It also caps `llms.txt` at 16,000 characters (`kiss-ssg init` imports it into every session of every site, so it stays an index and the rules live in `GUIDE.md`), and holds the two shipped-docs parity checks: every non-underscore `Kiss.prototype` method is named in `llms.txt`, and the config-defaults block in both `llms.txt` and `GUIDE.md` evaluates to exactly `resolveConfig({})` — so a new default cannot land in `config.js` alone.
21
22
  - `test/unit/changelog.test.js` — the other version-sync net: `CHANGELOG.md`'s first `## <version> — <date>` heading must be the version `package.json` declares. The manifests are carried by `npm version` and need no hand; the changelog is the one version-bearing file a person writes, so this is what catches a bump with no entry or an entry with no bump — and it runs at the gates, which `prepublishOnly` runs too. Since 2.9.0 it also holds the changelog as the one home of upgrade notes, through `lib/upgrade.js`'s own parser rather than a second regex: versions sort newest first, every note is `### Upgrading` or `### Upgrading: <topic>` (no older spelling, no `### ⚠️`), every minor from 2.5.0 has a note or `### Nothing for a site to change`, and `CLAUDE.md` and the `branch-close` skill no longer send a note into `kiss-site-migrate` (those two sit outside `skill-coverage.test.js`'s `CONTRADICTIONS` walk).
package/AIKB/upstream.md CHANGED
@@ -278,6 +278,21 @@ shim `.d.ts`. **Re-check:** `head node_modules/@mixmark-io/domino/lib/index.d.ts
278
278
  after an upgrade — if it exports rather than declares, import the string
279
279
  literal again and run `npm run typecheck`.
280
280
 
281
+ ### PurgeCSS removes a selector made of pseudo-classes alone, and splits Tailwind class names
282
+
283
+ **Observed:** `purgecss` 8.0.0 (2026-11-08, issue #78 and the a1k9training clean room).
284
+
285
+ **Effect, part 1:** `shouldKeepSelector` keeps a selector whose only parts are pseudo-classes when it is `:where`, `:is`, `:has` or `:not` (`isPseudoClassAtRootLevel`). Any other pseudo-class part falls through to `continue`, so the selector counts as absent and is removed. That includes `:focus-visible { outline: … }` and `:-moz-focusring:where(…)`. Such a selector means `*:focus-visible` and matches every element. On a1k9training, purged with no safelist, every keyboard focus ring went: 62 computed-style differences in focus states. A catch-all safelist (`/[^a-zA-Z0-9_-]/`) had kept it by accident.
286
+
287
+ **Effect, part 2:** its default extractor, `/[A-Za-z0-9_-]+/g`, cuts every class holding `/ [ ] . :` into pieces that are not the class, so Tailwind sites lost every such rule (#78).
288
+
289
+ **What kiss does:** two cheap guards in `lib/css-purge.js` `purgeCss`.
290
+
291
+ - For part 1, `PSEUDO_PART` in PurgeCSS's own `standard` safelist: a pseudo part counts as present, and every tag, class, id and attribute part is still checked, so `.unused:focus-visible` still goes.
292
+ - For part 2, its own extractor, plus the pages' exact `class` values as a content item.
293
+
294
+ Both use PurgeCSS options, not patches. **Re-check:** after a PurgeCSS upgrade, comment out `standard: [PSEUDO_PART]` and run `npx vitest run test/unit/css-purge.test.js -t "every element"`. If it stays green, PurgeCSS keeps these itself and the guard can go. The extractor is kiss's for good: Tailwind's own class syntax is not PurgeCSS's to follow.
295
+
281
296
  ### Remarkable never starts an HTML block with `<h1>`–`<h6>`
282
297
 
283
298
  **Observed:** `remarkable` 2.0.1 (2026-10-31). Its HTML-block rule matches an
package/AIKB/utils.md CHANGED
@@ -22,6 +22,7 @@ Small string/path helpers shared across the engine: slugification, title-casing,
22
22
  - `redactUrl(url)` → a URL as kiss **shows** it, never as it fetches it: scheme, host, port and path kept (as the WHATWG parser writes them: host lowercased, a default port dropped), no `user:pass@`, no fragment, each query parameter's name kept and its value replaced by `[redacted]` (`?access_token=SECRET&type=course` → `?access_token=[redacted]&type=[redacted]`: kiss cannot know which value is the secret, and the names are what tell one model from another), a parameter with no `=` replaced whole (`?[redacted]`), an empty query dropped. A secret in the **path** cannot be told from a path segment and is shown as written; `GUIDE.md` § Remote models sends a credential to `fetch.headers`. A string the parser rejects, **or one it accepts with a scheme that is not `http:` or `https:`** (the parser gives such a scheme no authority, so `httpx:user:pass@host/x` parses with the credentials as its path, and was shown whole until 2026-11-06), cannot be split into credentials, path and query (a password may hold a raw `/`, `?`, `#` or `@`), so it goes by the position of its **last** `@` against its first `?` or `#`: no `@`, it is cut at the first `?` or `#` and given `?[redacted]` when it had a query; the last `@` before that cut, everything between the scheme and that `@` is dropped and then the same cut applies (`https://user:abc/def==@api.example.com/data?x=1` → `https://api.example.com/data?[redacted]`); the last `@` after it, only the scheme is kept (`https://user:pa?ss@api.example.com/data` → `https://[redacted]`). "The scheme" is `scheme:` with whatever run of `/` or `\` follows it, as written, because the parser opens an authority after `http:`, `http:/` and `http:\\` alike; a string with no scheme keeps nothing before its `@`. **Named export only, not on the default object** — it is engine-internal, and the default object is the public `utils` a site imports.
23
23
  - `REDACTED` → the string `'[redacted]'`, the one word every redacted value becomes: `redactUrl`'s query values and `lib/config.js`'s `redactFetchHeaders`. Named export only, not on the default object.
24
24
  - `isUrlModel(model)` → `true` for a string whose scheme is `http:` or `https:`, in any case and after any leading space (`/^\s*https?:/i`, both of which `fetch` takes), `false` for anything else, a non-string included. The one test of whether a page's `model` is a URL kiss fetches, made by `lib/model-resolver.js` (`resolveModel`, which fetches it), `lib/aikb.js` (`classifyModel`, which records it) and `lib/page-registry.js` (`registerPage`'s `No view specified` log, which redacts it). Until `b8be845` it was the prefix test those three copies made (`startsWith('http')`), which had two faults the code review of that commit named: `HTTPS://api.example.com/x?token=…` and a URL with a leading space were taken for a folder, never fetched, and printed whole by `Invalid model …`, past every redaction; and `httpdata.json`, a file, was sent to the network. `httpx://…` is not a URL model either: it is a string `resolveModel` rejects, shown through `redactUrl`. Named export only, not on the default object.
25
+ - `escapeRegExp(text)` → `text` with every character a `RegExp` reads specially escaped, for building a pattern from a path or a name. Named export only, not on the default object. Used by `lib/css-purge.js`, `lib/staging.js` and `lib/issue.js`; `lib/audit.js` and `lib/links.js` still carry older copies of their own (recorded 2026-11-08, not merged).
25
26
  - `export default { trimLines, toSlug, toTitleCase, trimPath, sanitizePath, posixPath, globFiles, hashId, toURLKey, toAbsoluteUrl, toCanonicalPath, servedPathFor, relativePathFor }` (also each function is a named export).
26
27
 
27
28
  ## Depends on
@@ -30,7 +31,7 @@ Small string/path helpers shared across the engine: slugification, title-casing,
30
31
 
31
32
  ## Depended on by
32
33
 
33
- `lib/aikb.js` (`redactUrl`, `isUrlModel` and others), `lib/config.js` (`isInside`, `REDACTED`), `lib/assets.js`, `lib/fetch-policy.js`, `lib/handlebars-helpers.js`, `lib/kiss.js`, `lib/asset-copy.js`, `lib/build-finish.js`, `lib/page-registry.js` (`isUrlModel`, `redactUrl` and the default object), `lib/rebuild.js`, `lib/kiss-page.js`, `lib/model-resolver.js` (`isUrlModel`, `redactUrl` and others), `lib/partials.js`, `lib/sitemap.js`, `lib/watcher.js`.
34
+ `lib/aikb.js` (`redactUrl`, `isUrlModel` and others), `lib/config.js` (`isInside`, `REDACTED`), `lib/assets.js`, `lib/fetch-policy.js`, `lib/handlebars-helpers.js`, `lib/kiss.js`, `lib/asset-copy.js`, `lib/build-finish.js`, `lib/page-registry.js` (`isUrlModel`, `redactUrl` and the default object), `lib/rebuild.js`, `lib/kiss-page.js`, `lib/model-resolver.js` (`isUrlModel`, `redactUrl` and others), `lib/partials.js`, `lib/sitemap.js`, `lib/watcher.js`, `lib/css-purge.js`, `lib/staging.js` and `lib/issue.js` (`escapeRegExp`).
34
35
 
35
36
  ## Non-obvious behavior
36
37
 
package/CHANGELOG.md CHANGED
@@ -3,6 +3,87 @@
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.14.0 — 2026-11-08
7
+
8
+ The purge keeps Tailwind's classes, and three changes a client site asked for. A site
9
+ can now report a kiss bug or gap from its own folder. Read the upgrading notes: the
10
+ default image widths and two audit and CSS outputs change.
11
+
12
+ - **`assets.purge` keeps every class a page carries, exactly as written.** Fixes
13
+ [#78](https://github.com/cprobert/kiss-ssg/issues/78). PurgeCSS's default reading split
14
+ `md:hover:bg-ink-900/80`, `min-h-[60vh]` and `p-1.5` on `/ [ ] . :`, so a Tailwind site
15
+ lost used rules with nothing reported. Each class in a page's `class` attribute now
16
+ reaches the purge whole, and `kiss-ssg css` reads through the same purge. Rules on ARIA
17
+ and other script-flipped state attributes are kept. So is a selector made only of
18
+ pseudo-classes, `:focus-visible { … }`, which PurgeCSS 8 removed (every keyboard focus
19
+ ring went). GUIDE.md § Purged CSS says what counts as used, and the one hole: a class a
20
+ script builds from pieces.
21
+ - **A safelist entry matches every name in a selector.** Classes, ids, tags, attributes
22
+ and pseudo-classes all count, so `'active'` keeps `.btn:active` as well as `.active`.
23
+ Anchor a pattern (`/^modal-/`) to keep only what you mean.
24
+ - **The build report names no staging folder.** A build folder written `./docs` under
25
+ `cleanBuild: 'atomic'` put `docs.kiss-staging-…` paths into `AIKB/last-build.json`,
26
+ the report and the log. Every path is now named in the build folder.
27
+ - **768 joins the default image widths**: `[480, 768, 960, 1440, 1920]`. A full-width
28
+ image on a phone (412 px at 1.75x wants 721 px) was sent the 960. Measured on a real
29
+ site of 20 pages at that size, a phone downloads 19.9% fewer image bytes. The cost is
30
+ three more variants per image in the cache and the deploy. The fallback `src` is the
31
+ lower middle width: 960 for a source wider than 1440, 768 for one between 960 and 1440.
32
+ - **`image-oversized` judges the file a browser loads from a `<picture>`.** In a
33
+ `<picture>` whose first `<source>` switches format (a `type`, no `media`, a `srcset`,
34
+ which is what `{{image}}` writes), every current browser takes that source, so only it
35
+ counts. The fallbacks are no longer reported. An art-directed `<picture>` (`media`) is
36
+ still judged whole.
37
+ - **The embed Load button carries its words in `data-kiss-embed-label`.** The facade,
38
+ the consent box and Always allow all have it, so a restyle can draw the label with
39
+ `content: attr(data-kiss-embed-label)`. A `load=` or `ui.text` change then reaches the
40
+ page with no CSS edit.
41
+ - **A stylesheet clean-css cannot read is compacted, not shipped as written.** clean-css 5
42
+ does not understand native CSS nesting, which Tailwind v4 writes. kiss already threw its
43
+ output away for such a sheet, rightly, and copied the file with every space. PostCSS now
44
+ takes the formatting out and touches no selector, value or at-rule parameter. Measured:
45
+ a purged Tailwind sheet went from 10,613 to 8,492 bytes, with no computed-style
46
+ difference.
47
+ - **`npx kiss-ssg issue bug|feature --title …` drafts a report to this repository.** It
48
+ fills in the version, Node, OS, site and the last build's summary (`--anonymous` drops
49
+ the site's name, URL and failing views). It adds the agent's `--notes` under the issue
50
+ form's headings, and searches for a similar open issue. It refuses a draft holding a
51
+ token-shaped value, writes the draft outside the site, and prints the `gh issue create`
52
+ command for you to run. It never posts. `--status <n>` says whether the installed
53
+ kiss-ssg ships a fix for an issue, and `--filed` does that for every `filed:` line in
54
+ your session logs. GUIDE.md § Reporting to kiss-ssg. `llms.txt` now tells an agent to
55
+ report a kiss bug, gap or wrong doc rather than work round it quietly. The kiss-memory
56
+ branch skills draft one at a close and check filed ones at the next open.
57
+
58
+ Measured: a real Tailwind site purged with no safelist gave 35,217 computed-style
59
+ differences under 2.13.3, and 0 under this release over 69,428 elements and 246 states.
60
+ A second site of 683 pages lost no selector 2.13.3 kept. Built from the packed 2.13.3
61
+ and from this release, the first site gives the same 499 files, byte for byte, except
62
+ the one page with an embed, which gains its `data-kiss-embed-label`.
63
+
64
+ ### Upgrading
65
+
66
+ - **A safelist you added because the purge split Tailwind's classes can go.** Build
67
+ without it and open each interactive component. Keep entries for classes a script
68
+ builds from pieces.
69
+ - **Check your safelist patterns.** An unanchored pattern now keeps every rule naming a
70
+ match anywhere in the selector. That keeps more than before and never less, but
71
+ `/^…/` says what you mean.
72
+ - **Run `npx kiss-ssg images <script>` and commit `.kiss-cache/images`.** The 768
73
+ variants are new, so the first build after upgrading encodes them, and a deploy that
74
+ does not have them in the committed cache encodes them too. A site that sets
75
+ `images.widths` is unaffected.
76
+ - **Hashed stylesheet names change once** where the compaction applies, a sheet with
77
+ native nesting (an unminified Tailwind v4 build, or its purged copy). The pages link
78
+ the new names themselves. Nothing changes for a sheet clean-css already read.
79
+ - **`image-oversized` may report fewer findings.** If you capped `images.widths` to
80
+ silence findings about WebP or JPEG fallbacks, the cap can go.
81
+ - **An embed restyle that hides the button's text and writes the words in CSS** can
82
+ read `attr(data-kiss-embed-label)` instead. Nothing changes for a site that does not
83
+ restyle the button.
84
+ - **`kiss-ssg issue` needs `gh` only to search and to file.** Without `gh`, the draft
85
+ and the printed command still come out.
86
+
6
87
  ## 2.13.3 — 2026-11-07
7
88
 
8
89
  The rest of the 2.13.2 security review: every finding its reviews listed and left is now