kiss-ssg 2.13.3 → 2.15.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/asset-manifest.md +1 -0
- package/AIKB/asset-minify.md +4 -3
- package/AIKB/assets.md +1 -0
- package/AIKB/audit.md +1 -0
- package/AIKB/build-finish.md +4 -0
- package/AIKB/build-report.md +4 -3
- package/AIKB/check.md +1 -1
- package/AIKB/config.md +1 -1
- package/AIKB/css-audit.md +1 -1
- package/AIKB/css-purge.md +29 -2
- package/AIKB/embed.md +1 -0
- package/AIKB/feed.md +2 -2
- package/AIKB/fetch-policy.md +1 -0
- package/AIKB/handlebars-helpers.md +1 -1
- package/AIKB/images.md +1 -1
- package/AIKB/issue.md +38 -0
- package/AIKB/kiss.md +9 -6
- package/AIKB/llms.md +1 -1
- package/AIKB/package-manifest.md +1 -1
- package/AIKB/pipeline.md +1 -1
- package/AIKB/redirects.md +2 -2
- package/AIKB/robots.md +1 -1
- package/AIKB/sitemap.md +4 -4
- package/AIKB/staging.md +65 -4
- package/AIKB/testing.md +3 -0
- package/AIKB/upstream.md +15 -0
- package/AIKB/utils.md +3 -1
- package/CHANGELOG.md +144 -0
- package/GUIDE.md +123 -54
- package/README.md +5 -3
- package/bin/kiss-ssg.js +14 -2
- package/examples/11-blog/AIKB/last-build.json +2 -1
- package/lib/asset-manifest.js +2 -1
- package/lib/asset-minify.js +56 -10
- package/lib/assets.js +65 -1
- package/lib/audit.js +52 -13
- package/lib/build-finish.js +21 -3
- package/lib/build-report.js +37 -23
- package/lib/check.js +3 -0
- package/lib/css-audit.js +5 -1
- package/lib/css-purge.js +238 -5
- package/lib/embed.js +7 -2
- package/lib/feed.js +1 -13
- package/lib/fetch-policy.js +66 -4
- package/lib/images.js +5 -2
- package/lib/issue.js +728 -0
- package/lib/kiss.js +86 -22
- package/lib/package-manifest.js +1 -20
- package/lib/pipeline.js +2 -1
- package/lib/redirects.js +104 -15
- package/lib/sitemap.js +6 -5
- package/lib/staging.js +180 -10
- package/lib/utils.js +31 -0
- package/llms.txt +5 -5
- package/package.json +11 -8
- package/types/asset-minify.d.ts +15 -0
- package/types/audit.d.ts +4 -1
- package/types/build-finish.d.ts +5 -0
- package/types/build-report.d.ts +9 -0
- package/types/check.d.ts +1 -1
- package/types/css-purge.d.ts +81 -2
- package/types/images.d.ts +2 -2
- package/types/issue.d.ts +184 -0
- package/types/kiss.d.ts +35 -21
- package/types/redirects.d.ts +31 -2
- package/types/staging.d.ts +37 -0
- package/types/utils.d.ts +10 -0
- package/examples/10-asset-pipeline/src/assets/css/generated.css +0 -19
package/AIKB/asset-manifest.md
CHANGED
|
@@ -33,6 +33,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
33
33
|
## Non-obvious behavior
|
|
34
34
|
|
|
35
35
|
- **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
|
|
36
|
+
- **Hashable is necessary, not sufficient.** `lib/assets.js` also leaves a `.css`/`.js` unrenamed when another hashed asset imports it by path, so the manifest maps it to itself like an image (`AIKB/assets.md`). This module only says which extensions may be renamed.
|
|
36
37
|
- **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
|
|
37
38
|
- **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
|
|
38
39
|
- **A map is only written by a copy that finished, so a copy that stopped needs a second record.** `reconcile` runs at the end of a copy; a copy that fails partway has already put files in the build that no map holds, and before `unrecorded` nothing ever removed them (a `git checkout` during `--dev` left a stale asset until restart). They are kept apart from the name map rather than merged into it, because a name has one output: merging a half-copied plain `css/site.css` under a name whose map says `css/site.a1b2c3d4.css` would have reported the last-good hashed file stale and deleted it. A copy with unrecorded outputs counts for `hasOwner`, because its root demonstrably existed, so that root disappearing is a deletion to reconcile, not the typo a missing first root is.
|
package/AIKB/asset-minify.md
CHANGED
|
@@ -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
|
-
- `
|
|
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
|
|
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/assets.md
CHANGED
|
@@ -34,6 +34,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
34
34
|
- **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
|
|
35
35
|
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
36
36
|
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
37
|
+
- **Under `assets.hash`, a file another hashed asset imports by path is not hashed (`importedNames`).** The import sits in the importer's bytes and nothing rewrites it, so a renamed target 404s on a green build, and no link scan or audit reads asset bodies (case built in `planning/reviews/2026-11-10-js-hash-imports.md`; the operator chose this over rewriting imports or a warning, 2026-11-10). Before the hashing loop, every claimed `.js`/`.css` is read and scanned by regular expression (no parser) for `import … from`, `export … from`, `import '…'`, `import('…')` and CSS `@import` (string and `url()`); a relative specifier resolves against the importer's name, a root-relative one against the build root, and a hit on a claimed name keeps that name, so the manifest maps it to itself and `lib/cache-headers.js` (which writes headers for `name !== output`) gives it none. A URL, a protocol-relative specifier and a JavaScript bare specifier (an import map's) name nothing; a CSS bare one is relative. One `info` line names the files left unhashed. The scan reads the file as emitted before minifying, and minifying keeps specifiers. The consequence a site carries: a changed imported file is fetched fresh only when the host's default cache lets it, while its importer's hashed name stays cached. **Seen only within one copy**: `claimed` is the one copy's emitted names (`recordEmitted` runs per `assets` source), so an import of a file another source writes (a second `assets` source, a vendor folder copied separately) is not seen, and a root-relative specifier is joined to the root as written, with no `siteUrl` path prefix stripped, so `import '/docs/js/util.js'` on a site under `/docs/` names `docs/js/util.js`, not `js/util.js`. In both the target is hashed and the import breaks; the operator chose to document that boundary and change no code (2026-11-11): a module graph imports relatively within one assets folder (stated in `GUIDE.md`'s `assets.hash` passage). Not covered: `new URL('./x.js', import.meta.url)`, `sourceMappingURL`, a computed dynamic specifier, and an imported stylesheet that `assets.purge` names, which the settle re-hashes (`lib/css-purge.js`). The scan adds one read of each hashable file per copy.
|
|
37
38
|
- **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims, plus outputs an earlier failed copy wrote and never recorded; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
|
|
38
39
|
- **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
|
|
39
40
|
- `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
|
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.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -29,6 +29,10 @@ Each is the body of the `Kiss` private method named beside it:
|
|
|
29
29
|
- `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
|
|
30
30
|
- `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
|
|
31
31
|
`_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
|
|
32
|
+
`findRedirectChanges` also records every alias `normaliseAlias` refused, from
|
|
33
|
+
its own `collectAliases` call (the warning is `writeRedirects`', said once),
|
|
34
|
+
as `dropped: [{ alias, page, reason }]`, and a site whose only news is a
|
|
35
|
+
dropped alias reports a `redirects` block rather than `null`.
|
|
32
36
|
- `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
|
|
33
37
|
- `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
|
|
34
38
|
- `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
|
package/AIKB/build-report.md
CHANGED
|
@@ -12,7 +12,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
13
13
|
- `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
|
|
14
14
|
- `pages` — `{ view, buildTo, ok, hash, id, canonical, markdown }` per stack entry, in registration order; `markdown` is the Markdown copy the page wrote beside itself (`KissPage.markdown`, mapped to the real build folder like `buildTo`), `null` when it wrote none; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
15
|
-
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
|
|
15
|
+
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report. An output the build asked for that could not be written or lacked an input is one (`<sitemap>`, `<llms>`, `<feed>`, `<robots>`), so `ok` is false and `sitemap`/`llms`/`feed` stay `null` beside it; `Kiss._recordOutputFailure` re-derives the report in place when it lands after a settle (`AIKB/kiss.md`).
|
|
16
16
|
- `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
|
|
17
17
|
- `sitemap` — the `sitemap.xml` this build wrote, or `null`.
|
|
18
18
|
- `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
|
|
@@ -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.
|
|
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.
|
|
@@ -56,6 +56,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
|
|
|
56
56
|
- **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
|
|
57
57
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
|
58
58
|
- **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
|
|
59
|
+
- **`redirects.dropped` is the last key of `redirects`**, `[]` when none: `{ alias, page, reason }` for each alias the engine refused (`reason` is `whitespace`, `pattern` or `query-only`; `page` is the view, else the output path, mapped like a path). Advisory, and `formatReport` (so `kiss-ssg check --summary`) prints one `alias dropped: <alias> on <page> (<reason>)` line each, beside the collisions. Before it the refusal was a `logger.warn` and nothing else.
|
|
59
60
|
- **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
|
|
60
61
|
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. Since 2.8.6 `disallowAll` means `*` or a search engine's crawler is blocked, and `blocked` (appended after it) names every agent kept out of the whole site; an older record has no `blocked`. `lib/robots.js` has the rest.
|
|
61
62
|
|
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
|
|
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/feed.md
CHANGED
|
@@ -28,7 +28,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
|
|
|
28
28
|
- **A date is read from the page option first and the resolved model second, under one field name.** `options[dateField] ?? options.model?.[dateField]` — `dateField` governs both lookups, so a site that dates a post in its `models/posts/*.json` and a site that dates it in the `.page()` call read the same key and neither has to know which the other used. By settle time `options.model` **is** the resolved data (the `.pages()` fan-out has already given each item its own), which is what makes the model lookup possible at all. It is guarded for `null` (a page with no model) and for an array (a non-`dynamic` page handed an array model, which has no date of its own); both are undated, neither is an error.
|
|
29
29
|
- **A page with no date is silent; a date that cannot be read warns once.** Most of a site is undated — an About page is not a post — so warning per undated page would bury the feed's own output in noise on every build. A value that was _given_ and cannot be parsed is a typo the author wants to hear about, so it gets one `logger.warn` naming the page and the value, and the page joins the undated count. Neither ever throws: a feed is not the render error channel, and a build minus one item is still the build the author asked for. `writeFeed` logs the total as one `info` line, which is the only place the count surfaces — `report().feed` is the path, not the tally.
|
|
30
30
|
- **The `url` is the sitemap's `<loc>`, character for character** — a home page (`https://e.com/`), a file page (`/about`) and a directory index (`/courses/`) alike, because the derivation is the same two `utils` functions. An RSS `<guid isPermaLink="true">` that disagreed with the sitemap would be a second identity for one page, and a feed reader would show the post twice after a rename.
|
|
31
|
-
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. `lib/
|
|
32
|
-
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it
|
|
31
|
+
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. The function is `escapeXml` in `lib/utils.js` (a named export, moved there from this file), shared with `lib/sitemap.js`, which escapes every value it writes since work order 06.
|
|
32
|
+
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it, does not invoke the callback and records a `<feed>` build failure (`_recordOutputFailure`). A missing `siteUrl` or `title` (the writer's `'no-site-url'`/`'no-title'`) is the same failure, worded `feed.xml needs config.siteUrl: …, or remove .feed()` (the file name is `feedFileName(options)`). Same contract as `writeSitemap` and `writeLlms`.
|
|
33
33
|
- **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt`, `redirects.json` and the redirect host file are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
|
|
34
34
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing feed left to skip.
|
package/AIKB/fetch-policy.md
CHANGED
|
@@ -20,6 +20,7 @@ Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to
|
|
|
20
20
|
|
|
21
21
|
- **A header the fetch would refuse is refused first, by name.** Node's `fetch` quotes a header value in the error it raises for one it cannot send (`Headers.append: "<value>" is an invalid header value.`), and `GUIDE.md` tells a site to put its token in `fetch.headers`: a token with a line break or a NUL in it, as a value read from a file can have, was printed twice by the failed-model log line (the review of `7c527a3`, 2026-11-06; measured on Node 24.14.0 through a whole build). The message is kiss's own now in any case (below), but refusing first also keeps the value out of anything Node might print on the way. So `checkHeaders(headers)` runs after the cache read and before the first attempt: it builds the same `Headers` the fetch would, and on a refusal throws kiss's own error, `fetch.headers: "Authorization" cannot be sent — … The value is not shown.`, naming each refused header of a plain record and nothing of what it holds (any other shape is refused without a name). It is outside the attempt loop, so it is never retried and the fetch is never called. `Headers` and the fetch's `Request` refuse the same inputs (measured on Node 24.14.0 for `null`, a record, pairs, a `Headers`, a `Map`, a bad name and a string), so no header set that worked is refused; a value that only ends in a line break is trimmed by both and still sent. A character above 255 is refused by Node with a message that does not quote the value; it gets kiss's message too. `test/unit/fetch-policy.test.js` drives it with the real `fetch`, counted.
|
|
22
22
|
- **Order of operations: policy check → cache read → URL check → header check → each attempt (fetch and parse, under one timer) → non-2xx guard → retry decision → cache write.** The cache is consulted before the network, so a cached model costs no request at all; the body is written only after a 2xx response has parsed, so an error envelope can never become a cache entry (the guard that `model-resolver.js` gained for the same reason — review finding C2 — stays authoritative over the cache). The URL check (`URL.canParse`, then credentials) and the header check are outside the attempt loop: neither can pass on a second attempt, so no fetch is made and the message carries no attempt count (2026-11-07; before, an unparseable URL was fetched `retries + 1` times and reported from whatever the fetch threw). The cache write is outside the attempt's `catch`: a disk that refuses it is a `logger.warn`, `Model cache write failed: <cache file> → <code>; the model is used without caching` (the file is the hashed name, never the URL), and the model is returned. It is not retried, since fetching again cannot make the disk accept it, and it does not fail the page, since the model arrived (2026-11-07; before, it rejected).
|
|
23
|
+
- **A redirect never carries custom headers to another origin.** With `fetch.headers` non-empty, `attempt` passes `redirect: 'manual'` and follows a 3xx itself (the `location` header is readable on a manual response: measured on Node 24.14.0 by the redirect tests, which use two real servers on two ports). A same-origin redirect is followed with the headers, at most 5 times (`MAX_REDIRECTS`; the sixth is `more than 5 redirects, with fetch.headers set`); another origin fails the model: `Model fetch failed: <url> → redirected from <origin> to <origin>, and fetch.headers is set, which is never sent to another origin: fetch the final URL directly, or remove the header`. The origins are parsed (`URL.origin`), the header values are never in the message, and each of these errors is marked in `ours`. Each of these (the cross-origin refusal, the sixth redirect and an unparseable `location`) is also marked in `refusals`, and the retry loop ends at once on one, as on a 4xx: the same request meets the same policy, so with `retries: 3` the first server sees one request and the message has no `(after N attempts)` (2026-11-11; before, it was retried with backoff). With no headers nothing is passed and the fetch follows redirects as before (its own limit, `redirect count exceeded`). The response is not size-capped: the per-attempt timeout bounds a response, and that is the limit. A stub passed as `fetchImpl` sees `redirect: 'manual'` in its options only when headers are set.
|
|
23
24
|
- **The cache key is a hash of the URL _and_ the header set**, with the header entries sorted so key order cannot fork the identity. The same URL fetched with two different bearer tokens is two different identities upstream, and one build's body must never be served to the other's request.
|
|
24
25
|
- **The cache has no TTL and no expiry sweep.** A consumer clears the directory (or a single `<hash>.json` inside it) to refetch. That also makes stale-on-error unreachable rather than merely unimplemented: an entry is only ever written after a success, and once written it is hit before any request is made, so there is no state in which a fetch fails _and_ a cached body exists. Stale-on-error only becomes a real option if a TTL is added — which is why `cache` is a plain directory string, not a `{ dir, staleOnError }` object.
|
|
25
26
|
- **An unreadable cache entry is a miss, not a failure**: a truncated or hand-edited file logs one warning and the request goes to the network. A half-written file (a build killed mid-write) must not poison every later build.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
5
|
+
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `image`, `embed`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
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
|
@@ -19,9 +19,11 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
19
19
|
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder — each page's `{ view, buildTo, runCount, options }`, the options without `config` (`AIKB/page-registry.md`) — and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
20
20
|
- `.generate(callback)` — sets the `_generateRequested` latch, awaits `_promises`, renders every stack entry with `runCount === 0` once (each render caught, failures collected on `_failures`), awaits the writes, invokes `callback` through `_runCallback` (so a callback that throws, or returns a rejecting promise, is collected on `_failures` too); returns `this`.
|
|
21
21
|
- `.complete(callback)` — awaits `_settle()` (drain, then render whatever the drain left unrendered, repeated until the stack is stable), then **rejects with an `AggregateError`** if `_failures` is non-empty and `_failuresReported` is not yet set; otherwise invokes `callback` with the resolved data (never on a failed build) and returns that data (a `Promise`, unlike the other chainable methods). Under `cleanBuild: 'atomic'` this is also the promotion point: a settle with no failures promotes the staging folder, a settle with failures discards it — and in check mode a settle with no failures discards it too. It is also where the build report is assembled (`_finishBuild()`), so `report()` and `err.report` are set by the time either outcome reaches the caller.
|
|
22
|
-
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`.
|
|
23
|
-
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary
|
|
24
|
-
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title
|
|
22
|
+
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`. No `siteUrl`, or a write error, is a `<sitemap>` build failure (see the output-failures entry below).
|
|
23
|
+
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary`, or a write error, is a `<llms>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
24
|
+
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title`, or a write error, is a `<feed>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
25
|
+
- `.robots(options, callback)` — the fourth file from the same chain, writing `robots.txt` (`AIKB/robots.md`): records `_robotsRequest` (so a watch rebuild re-issues it), awaits `_promises`, writes the file via `writeRobots()`, records `_robotsResult` (`file`, `agents`, `disallowAll`, `blocked`, `sitemaps`) for the report's `robots` field, logs a `notice` when the whole site is disallowed and an `info` line when a named agent is, invokes `callback` (with the rendered text) through `_runCallback` unless an existing file was left in place under `overwrite: false`; returns `this`. Its `Sitemap:` line is emitted only when `.sitemap()` was called — the `.then()` bullet below says why that is read late.
|
|
26
|
+
- `config`, `logger`, `verbose`, `handlebars` and `remarkable` are public instance fields rather than methods: the resolved config, the (staging-wrapped) logger, the `verbose` flag, and the per-instance Handlebars and Remarkable objects a site may register against.
|
|
25
27
|
- There is **no** `.aikb()` method, and deliberately so: the site's knowledge base is written by `npx kiss-ssg aikb <script>`, which sets `KISS_AIKB` for the build it spawns. `_finishBuild()` reads that variable, and no chainable call turns it on (see `_buildAikb()` in `AIKB/build-finish.md` and `AIKB/aikb.md`).
|
|
26
28
|
- `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
|
|
27
29
|
- `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and queued asset reconciliation (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is in `AIKB/rebuild.md` — and every rebuild it asks for goes through the serial queue.
|
|
@@ -31,7 +33,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
31
33
|
|
|
32
34
|
## Depends on
|
|
33
35
|
|
|
34
|
-
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`.
|
|
36
|
+
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./cache-headers.js` (the renderers it re-exports), `./images.js` (`createImageIndex`), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`. It also names `./build-report.js`, `./aikb.js`, `./packages.js` and `./css-purge.js` in JSDoc types only, which load nothing at run time.
|
|
35
37
|
|
|
36
38
|
## Depended on by
|
|
37
39
|
|
|
@@ -69,6 +71,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
69
71
|
- **The redirect files are written in the same settle path, one line above the scan, and the findings are not.** There is no `.redirects()` method: an alias is a property of a page, not a file the author asks for, so the write has to be automatic — and the only two honest places for it are the promise queue and here. The queue (`Promise.all(this._promises)` pushed onto `_generating`, the `.sitemap()` shape) needs a call site, and the only one that exists on every build is the constructor, where `_promises` holds the asset copy and not one page: the write would run against an empty stack. So `_writeRedirects()` sits at the top of `complete()`'s settle path instead, above the three-way branch, where `_settle()` has already drained the queue (`sitemap.xml`, `llms.txt` and the feed are on disk) and nothing has yet been discarded or promoted. That ordering is load-bearing twice over: written after `_promote()` the file would land in the **promoted** folder, breaking the "the whole folder appears at once" promise with a window where the site is live with none of its redirects; written after `_discardStaging()` it would _resurrect_ the staging folder `fs.outputFile` creates parents for, leaving a stray `<build>.kiss-staging-…` beside published output under `kiss-ssg check`. It also comes free on replay, because `_replay()` ends in `complete()` — which is why `_redirectsRun`, the per-build promise latch that stops the nested-`complete()` pattern writing the file twice, is reset there beside `_redirectsPath`. Unlike the link scan it runs in dev too: nothing serves `_redirects` locally, but it costs one small file and keeps "what the build folder holds" from depending on the mode, where a scoped re-render genuinely has not rewritten every page. A write that rejects is a **build failure**, not an advisory: the `.catch` pushes `{ view: '<redirects>', buildTo: null, error }` onto `_failures`, one line above the settle branch that reads it, so the staging folder is discarded and the previous deployment stays live. That is where `_redirects` parts from `sitemap.xml`, `llms.txt` and the feed, whose write failures are logged and swallowed — a site published without its sitemap is a site search engines index a little later, but a site published without its redirects is a site whose old URLs 404, which is the loss the aliases exist to prevent. The **findings** are pure data and stay in `_finishBuild()`, before `buildReport`: `_redirectFindings()` re-collects the rules from the stack (the same pure function over the same stack, so the file and the count cannot disagree), reads the baseline through `_lastBuildRecord()` and latches `{ file: _redirectsPath, aliases, rules, json: _redirectsJsonPath, format: _redirectsFormat, files: _redirectsFiles, removed, collisions, moved }` on `_redirectsResult`, or `null` when there is nothing to say — no alias and no finding, which since `moved` landed means `rules.length === 0 && removed.length === 0 && moved.length === 0` (a move is the one finding a site with no alias anywhere can still have). The pages it hands `redirectFindings` are `_stackForRecord()`'s, not the raw stack's, so the `id` the pairing keys on is the same projected id the report and the site map show — a default id withdrawn from two pages is `null` on both sides and cannot pair with an explicit id of that name in the baseline. Each move logs one `notice` carrying its fix (`moved without redirect: <from> -> <to> (<id>) — add "<from>" to that page's aliases`), where the summary line prints the finding without the fix. That last rule is what keeps `last-build.json` byte-identical across two identical records: an empty verdict issued whenever a record merely _exists_ would make a site's first record (written before there is a baseline) differ from its second (written after) with nothing about the site having changed. The baseline is gated on `<folders.aikb>/last-build.json` **existing**, never on `isRecorded()` — that answers "is there a `site-map.json`", which `_buildAikb()` wrote one line earlier, so on a site's first `kiss-ssg aikb` it is true while the record is still absent — and it is read _before_ `writeLastBuild()` replaces it, so a recording run is measured against the previous record rather than against itself. An unreadable record is no baseline rather than a build failure. One `notice` per finding, in `formatReport`'s wording; neither moves `ok` or the exit code. `AIKB/redirects.md` has the derivation half.
|
|
70
72
|
- **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`, `lib/check.js`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs/router.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` in `AIKB/build-finish.md`). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
|
|
71
73
|
- **`config.folders.build` is the author's folder throughout a staged build; the staging path is the engine's `_writeRoot`.** It used to be the other way round — `_setupFolders()` repointed `config.folders.build` at the staging sibling and `_promote()` pointed it back — which routed every write in one move and also handed the staging name to everything that reads config: a template rendering `{{config.folders.build}}`, a controller, and a site's `complete()` callback under check, where nothing ever pointed it back because a check never promotes. Measured on student-handbooks during the 2.5.0 fleet upgrade: `kiss-ssg check` made its callback print `Handbooks generated to: ./handbooks/test.kiss-staging-…`. Now the write sites read `_writeRoot` and the writer modules get `_writeConfig`, config is never touched, and `test/integration/staging.test.js` pins it three ways (the callback under check, the template under `'atomic'`, and a gate that walks every written file, the report, the `KISS_REPORT` line and `last-build.json` for the staging name). The one reader that sees the staging path on purpose is a pipeline step's `KISS_BUILD`: a tool's output has to land in the build being staged, or the swap would leave it behind.
|
|
74
|
+
- **An output the build asked for that fails is a build failure: `<sitemap>`, `<llms>`, `<feed>`, `<robots>`.** Each of `.sitemap()`, `.llms()`, `.robots()` and `.feed()` pushes its run onto `_generating`, which `_drain()` awaits, so the failure is on `_failures` before `complete()`'s settle reads it: the write's `.catch` and the missing-input branch (a writer status other than `written`/`skipped`: `no-site-url`, `no-title`, `no-summary`, turned into an error by `outputInputError`) both call `_recordOutputFailure(view, error)`, which logs, pushes `{ view, buildTo: null, error }` and calls `_refreshReport()`. The refresh is the existing in-place mechanism (a no-op before the first settle, since `_report` is null then); it is what makes an output requested after a build settled (a call in a `generate()` callback that fires late) still move `report().ok`. Not `_carry`ed: a whole-site replay clears it and re-issues the request, which fails again if it still must. Before this change these only logged, and `report().sitemap/feed/llms` was `null` exactly as for an output never asked for. The Markdown copy beside them was already a failure (`_writeMissingCopies`).
|
|
72
75
|
- Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N build failure(s): <buildTo paths>"` — `buildTo`, not `view`, because `.pages()` fan-out gives every failed entry the same `view` and a message built from `view` would repeat it; `err.failures[]` still carries `view` per entry). Entries with no output path — a controller failure, or a callback failure — fall back to `view` in that message, and so does any `<pseudo-view>` even when it _has_ a `buildTo`. **The count noun used to be "page(s)", and that was a lie for every pseudo-view on `_failures`** — `<site helpers>`, `<pipeline>`, `<redirects>`, `<dev server>`, `<sass: <source folder>/<file>>`.
|
|
73
76
|
- **A Sass failure is identified by the COPY it came from**, not by the file's name within it: `<sass: src/assets/css/theme.scss>`, project-relative when the source folder is inside the project and absolute when it is not. Two asset roots can each hold `css/theme.scss`, and identifying a failure by the source-relative name alone meant a second `.copyAssets()` whose own copy compiled **cleared the first root's failure** — a green verdict with the primary stylesheet missing. Each copy clears the set of failures it recorded last time (by object identity, `AIKB/asset-copy.md`) rather than by the files it just returned, which is also what lets a **deleted or renamed** broken stylesheet lose its entry: it is absent from the new results, so a per-file clear could never reach it, and `_failures` is otherwise cleared only by `_replay()`, which an asset re-copy is not. A helpers module reported as `1 page(s) failed to build: .../helpers/index.js` sends the author to look at their pages; the label is the useful half, and the path is already in the error logged above it. `generate()`'s own callback still fires either way — callbacks are not the render error channel; `complete()` is. `complete()` does not invoke its callback on failure (same rule as `sitemap()`'s callback).
|
|
74
77
|
- **A replay sweeps `_failures`, it does not empty it** (`carriedAcrossReplay`). A whole-site replay re-runs every page, every controller, every callback and the redirects, so clearing those and letting the rebuild say them again is right. It does not re-import the helpers entry, it does not restart the dev server, and it recompiles the stylesheets only when an `assets.pipeline` step is configured — which the ordinary site has not. Emptying the list dropped all three, and each was still true: a site whose `site.scss` would not compile reported `ok` on the next controller save with nothing fixed, no CSS in the build, and the red compile line already scrolled past. So `<sass: …>`, `<site helpers>` and `<dev server>` are carried across a replay and everything else is dropped. Carrying cannot strand a fixed one, because each is cleared by its own producer when that producer runs again (`copyAssets` by the copy that owns it, `_loadHelpers` by name) and something has to run that producer again. **That last clause is where the first version of this was wrong**: it was true only of `config.folders.assets`, because the replay's pipeline branch and the watcher's `assetsChanged` both re-run THAT copy and no other. A root the site registered itself with `kiss.copyAssets(other, dest)` was never re-run, so its failure could not be cleared by anything — fixed on disk, full replay, still failed, for the rest of the session, on a build whose files are all correct. The mirror of the bug the carrying fixed, and the worse half to leave: an author who fixes a file and watches the build stay red stops believing the verdict. So a replay now also re-runs **the copies it has something to re-check** — the ones still holding an unresolved failure, looked up through `_assetCopies` (the copies with an outstanding stylesheet failure, and only those, keyed the same way, holding the caller's UNSTAGED target so a replay re-applies staging; every source folder copied is `_copySources`, which `kiss-ssg images` reads). A healthy root is not re-copied: a model or controller edit cannot have changed it, and copying every asset root on every keystroke is the cost that guard exists to avoid. Note the standing limit this works around rather than removes — **only `folders.assets` is watched**, so an edit under a second root raises no event and is picked up by the next whole-site replay rather than by itself. `<dev server>` has no second attempt at all, which is exactly why dropping it was wrong. **Carriage is recorded, not matched.** The first version tested `view.startsWith('<sass: ')`, and `view` is only an engine sentinel for engine failures: for an inline template it is the TEMPLATE TEXT, which the author writes, so a page registered as `view: '<sass: x> {{custom "y"}}'` recorded a failure no replay would ever drop — permanent, while the page rendered perfectly. The producers now mark their own on `_carriedFailures`, a `WeakSet` of the failure objects: decided at the one place that knows, needing no pruning, and leaving `_failures` entries exactly the `{ view, buildTo, error }` shape `err.failures` hands out. The copy key canonicalises **the deepest existing ancestor** and re-appends the rest, for the same reason in the other direction — `resolve` preserves the spelling, and on a case-insensitive filesystem `./src/assets` and `./src/Assets` are one directory and were two owners, so the owning copy could not clear its own failure. A plain `realpath` with a `resolve` fallback looks equivalent and is not: it throws ENOENT for a target the copy is about to create, falls back to the spelling, and then returns the canonical spelling once the folder exists — one copy, two keys, and the same unclearable failure by a new route (measured with a target inside a symlinked parent). Walking to the deepest existing ancestor makes the key **stable across the folder's creation** for a path whose ancestor exists — which is most of them, and not the leaf itself: two spellings of a destination that does not exist AT ALL yet (the ordinary case on a fresh build) each keep their own tail, and on a case-insensitive filesystem that is one directory under two keys. So the key is computed **after the copy has run**, when the target is there and both spellings resolve to it. Keying there also puts the registration into queue order, written by the run that owns it rather than synchronously by a caller whose copy has not happened yet — which is what let a copy that SUCCEEDED evict a registration a later queued copy still needed. The paths are **resolved at registration** — in the caller's working directory, at the moment they asked — and it is those the copy runs against, not the caller's raw arguments re-resolved when the queue drains. Both legs had the bug: the replayed copy (measured first) and the INITIAL one, where anything moving the working directory between the call and the drain copied a different folder, wrote it somewhere else, and recorded the result under the original's key. `copyAssets` takes a `display` pair so the id and the log line stay the strings the author wrote, and the `<sass: …>` **label is decided at registration too**. It was derived at execution time from the caller's raw argument against whatever the working directory was by then, so one copy's one failure came out relative or absolute depending on where the process happened to be when the queue drained — and a relative label re-read from a different directory resolves to a DIFFERENT file, which may exist and may compile perfectly, sending an author to a stylesheet with nothing wrong with it. It rides on `err.failures`, `report().failures` and the KISS_REPORT line, so it outlives the process that made it — resolving for correctness must not turn every build's asset line into an absolute path nobody typed. Both maps are pruned together when a copy records nothing — a replay re-runs a copy only to re-check a failure, so a copy with none needs no registration. A copy that records nothing deletes its map entry rather than storing an empty set, so a caller copying into a fresh destination each run does not accumulate one entry per success. `<pipeline…>` is deliberately not carried: it exists only on a site with configured steps, and that is the site whose replay re-runs them.
|
|
@@ -76,13 +79,13 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
76
79
|
- **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
80
|
- **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
81
|
|
|
79
|
-
- **A model that cannot be resolved is a build failure, and so is a controller that fails** (2.
|
|
82
|
+
- **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
83
|
- `_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
84
|
- `_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
85
|
- 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
86
|
- **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
87
|
- `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.
|
|
88
|
+
- `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
89
|
- 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
90
|
- **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
91
|
- `_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/llms.md
CHANGED
|
@@ -33,5 +33,5 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
|
|
|
33
33
|
- **A page's title is its own `title`, else its slug title-cased with hyphens as spaces** (`puppy-classes` → `Puppy Classes`) — the same rule `KissPage.prepare()` uses for the default, so an entry and the page's `<title>` agree. Until the launch-readiness release there was also an `UNTITLED = 'Index'` guard, because `KissPage` filled every untitled page's title with `'Index'` (from the placeholder slug); once that default was fixed the guard only rewrote a title somebody had written, and the operator removed it (2026-09-28). An explicit "Index" is now kept as written.
|
|
34
34
|
- Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
|
|
35
35
|
- `writeLlms` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.llms()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap`.
|
|
36
|
-
- Missing `siteUrl`, `title` or `summary` are reported as a status, never thrown
|
|
36
|
+
- Missing `siteUrl`, `title` or `summary` are reported by the writer as a status, never thrown; `Kiss.llms()` turns any status other than `'written'`/`'skipped'` into a `<llms>` build failure (`llms.txt needs options.title: pass { title } to .llms(), or remove .llms()`, and the like) via `_recordOutputFailure`, and a write error from its `.catch` the same way. It fires the callback for `'written'` and `'skipped'` only. The rest of the site still builds.
|
|
37
37
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing `llms.txt` left to skip.
|
package/AIKB/package-manifest.md
CHANGED
|
@@ -20,7 +20,7 @@ The module is pure: it takes a package's description and returns a string. It kn
|
|
|
20
20
|
|
|
21
21
|
## Depends on
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`node:crypto`; `./utils.js` (`escapeXml`, the one shared escape, which this module carried a private copy of until 2026-11-11; a test holds the manifest's escaping of the five characters).
|
|
24
24
|
|
|
25
25
|
## Depended on by
|
|
26
26
|
|
package/AIKB/pipeline.md
CHANGED
|
@@ -6,7 +6,7 @@ Runs the external commands of `config.assets.pipeline` — a CSS toolchain, an i
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `programOf(run)` → the program a command runs: its first token, whole when it is quoted (`"C:\Program Files\nodejs\node.exe" -e …` is one program). The one part of a command kiss prints or records: the log line (`pipeline: <name> — <program>`), the failure message (`pipeline step "<name>" failed (<how>): <program>`) and the site map's `program` (`lib/aikb.js`) all use it, never `run` itself, because a command's arguments are where a token typed on the command line sits, and the log and the report are not the place for it.
|
|
9
|
+
- `programOf(run)` → the program a command runs: its first token, whole when it is quoted (`"C:\Program Files\nodejs\node.exe" -e …` is one program). The one part of a command kiss prints or records: the log line (`pipeline: <name> — <program>`), the line a watch process starts under (`pipeline: watching with <program> [<name>]`, and never the `watch` command itself), the failure message (`pipeline step "<name>" failed (<how>): <program>`) and the site map's `program` (`lib/aikb.js`) all use it, never `run` itself, because a command's arguments are where a token typed on the command line sits, and the log and the report are not the place for it.
|
|
10
10
|
- `stepName(step)` → the name a step is logged and reported under: its own `name`, or `programOf(step.run)` (`npx`, `node`, `sass`), or `'pipeline'` if there is neither.
|
|
11
11
|
- `createPipeline({ steps, logger, dev })` → `{ run, close, watching }`. One per `Kiss` instance, created in the constructor and closed in `close()`.
|
|
12
12
|
- `run(env)` → `Promise<PipelineResult[]>`, one `{ name, ok, duration, error? }` per step, in order. Each step's `run` is spawned with `shell: true` in `step.cwd` (default `process.cwd()`) with `{ ...process.env, ...env }`, and awaited before the next starts. **Never rejects**; a failed step is a result carrying its `error`. In `dev` mode a step that has a `watch` command and whose `run` succeeded also gets that watch process started, once.
|
package/AIKB/redirects.md
CHANGED
|
@@ -15,12 +15,12 @@ So the rules come first and the encoding second. `redirects.json` — `{ version
|
|
|
15
15
|
## Public interface
|
|
16
16
|
|
|
17
17
|
- `canonicalPathFor(buildTo, buildDir, { trailingSlash })` → the page's origin-less canonical path: `/`, `/courses/`, `/about`. It is `toAbsoluteUrl('', toCanonicalPath(rel))` over the same naive `buildTo.slice(buildDir.length)` the sitemap makes.
|
|
18
|
-
- `normaliseAlias(alias)` → the alias as it will be written: leading `/` added, `?query` and `#fragment` removed, repeated slashes collapsed, trailing slash **preserved as written**. `null` for an alias with no path in it (`''`, `'?utm=1'`) and for one with whitespace _inside_ it (`'/old post'`, `'/a\n/b'`): the file is space-separated columns and newline-separated rules, so such an alias would write more than the one rule it stands for — and on a fan-out the alias came from the record, which may have been fetched rather than typed.
|
|
18
|
+
- `normaliseAlias(alias)` → the alias as it will be written: leading `/` added, `?query` and `#fragment` removed, repeated slashes collapsed, trailing slash **preserved as written**. `null` for an alias with no path in it (`''`, `'?utm=1'`) and for one with whitespace _inside_ it (`'/old post'`, `'/a\n/b'`): the file is space-separated columns and newline-separated rules, so such an alias would write more than the one rule it stands for — and on a fan-out the alias came from the record, which may have been fetched rather than typed. Also `null` for anything outside an **allow-list** (`PLAIN_ALIAS`, applied once the query and fragment are stripped): unreserved characters (`A-Za-z0-9-._~`), a `%XX` escape, `/`, the sub-delimiters no host reads as a pattern (`$ & ' , ; =`), `:` inside a segment (`/a:b`, never at a segment's start) and any non-ASCII character (a raw `/café`). The alias is written unchanged into four formats that read far more than `*` and `:x` as patterns (Netlify `*`/`:name`; Firebase globs `{a,b}`, `[…]`, `@(…)`, `?`; Vercel path-to-regexp `(…)`, `{…}`, `+`), so a deny-list of the shapes seen so far would lose to the next one; a fetched record could otherwise write a site-wide rule, and it is refused wherever it came from, because the function cannot tell the source. Internally `checkAlias` returns `{ path }` or `{ reason }`, `reason` one of `whitespace`, `pattern`, `query-only` (an alias that is only a query or fragment); `normaliseAlias` is its path-or-`null` view and is silent. `collectAliases` takes an optional `onDrop(alias, entry, reason)` and calls it for each non-empty alias refused. Two callers hand one over: `writeRedirects` (the warning, once per build) via `describeDroppedAlias(alias, page, reason)` — `Alias "<alias>" on <view or output path> dropped: ` plus `an alias is one old path, and this one has whitespace in it`, `… has characters a host reads as a pattern`, or `an alias is a path, and this one is only a query or a fragment` — and `findRedirectChanges` (the record in `report().redirects.dropped`, `AIKB/build-finish.md`). `droppedAliasPage` names the page the same way for both. An empty alias is dropped without a word, and is not in the list.
|
|
19
19
|
- `collectAliases(stack, { buildDir, trailingSlash })` → `RedirectRule[]` (`{ from, to }`), sorted by `from` then `to`. Reads `entry.page.options.aliases` — an array, or a bare string — from every stack entry that is not `generate: false`; `to` is that entry's `canonicalPathFor`. One page listing an alias twice yields one rule; two pages claiming one `from` yield two.
|
|
20
20
|
- `renderRedirects(rules)` → the Netlify/Cloudflare Pages text: one `<from> <to> 301` per line, sorted again, newline-terminated, `''` for no rules.
|
|
21
21
|
- `renderRedirectsJson(rules)` → the host-neutral IR: `{ version: 1, rules: [{ from, to, status: 301 }] }`, pretty-printed and sorted, so two identical builds write identical bytes.
|
|
22
22
|
- `renderHtaccessRedirects(rules)` → the Apache fragment: one `Redirect 301 <from> <to>` per line, sorted. mod_alias `Redirect`, **not** `RewriteRule` — a rewrite's left-hand side is a regex, so an alias containing a `.` or a `+` would match paths it does not name.
|
|
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.
|
|
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. The Vercel `source` escapes each `:` as `\:`: its path-to-regexp reads `:name` as a parameter **anywhere** in a path, not only at a segment's start as Netlify does, so the colon the allow-list keeps inside a segment (`/wiki/Category:News`) would otherwise also match `/wiki/Category:Sports`. The other three formats read it literally and write it unchanged (found by the close's Codex review, 2026-11-11).
|
|
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
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.
|