kiss-ssg 2.9.0 → 2.12.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-copy.md +2 -1
- package/AIKB/asset-manifest.md +2 -1
- package/AIKB/asset-minify.md +31 -0
- package/AIKB/assets.md +1 -1
- package/AIKB/audit.md +47 -8
- package/AIKB/build-finish.md +13 -1
- package/AIKB/build-report.md +14 -7
- package/AIKB/cache-headers.md +45 -0
- package/AIKB/check.md +5 -1
- package/AIKB/config.md +9 -4
- package/AIKB/consent-client.md +96 -0
- package/AIKB/consent-gate.md +57 -0
- package/AIKB/consent-record.md +37 -0
- package/AIKB/consent-ui.md +62 -0
- package/AIKB/consent.md +81 -0
- package/AIKB/css-audit.md +45 -0
- package/AIKB/css-purge.md +41 -0
- package/AIKB/embed.md +40 -0
- package/AIKB/handlebars-helpers.md +12 -3
- package/AIKB/html-split.md +1 -1
- package/AIKB/image-encode.md +31 -0
- package/AIKB/image-header.md +35 -0
- package/AIKB/images-sweep.md +34 -0
- package/AIKB/images.md +40 -0
- package/AIKB/init.md +1 -0
- package/AIKB/integrity.md +27 -0
- package/AIKB/kiss-page.md +2 -0
- package/AIKB/kiss.md +6 -3
- package/AIKB/links.md +5 -4
- package/AIKB/markdown-copy.md +1 -1
- package/AIKB/rebuild.md +17 -0
- package/AIKB/robots.md +1 -1
- package/AIKB/speed.md +51 -0
- package/AIKB/staging.md +4 -2
- package/AIKB/testing.md +11 -1
- package/AIKB/upstream.md +48 -0
- package/CHANGELOG.md +284 -0
- package/GUIDE.md +687 -28
- package/README.md +20 -4
- package/bin/kiss-ssg.js +98 -0
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/11-blog/AIKB/last-build.json +9 -1
- package/examples/14-consent/README.md +44 -0
- package/examples/14-consent/router.js +127 -0
- package/examples/14-consent/src/assets/css/site.scss +521 -0
- package/examples/14-consent/src/assets/img/icon.png +0 -0
- package/examples/14-consent/src/assets/img/logo.svg +4 -0
- package/examples/14-consent/src/assets/img/og-card.png +0 -0
- package/examples/14-consent/src/assets/js/visits.js +17 -0
- package/examples/14-consent/src/layouts/layout.hbs +74 -0
- package/examples/14-consent/src/pages/404.hbs +18 -0
- package/examples/14-consent/src/pages/cookies.hbs +28 -0
- package/examples/14-consent/src/pages/index.hbs +47 -0
- package/examples/14-consent/src/pages/privacy.hbs +55 -0
- package/examples/14-consent/src/pages/roasting.hbs +23 -0
- package/examples/9-migrated-from-v1/AIKB/last-build.json +9 -1
- package/examples/README.md +5 -4
- package/lib/asset-copy.js +1 -0
- package/lib/asset-manifest.js +21 -2
- package/lib/asset-minify.js +95 -0
- package/lib/assets.js +21 -0
- package/lib/audit.js +1190 -15
- package/lib/build-finish.js +382 -2
- package/lib/build-report.js +140 -4
- package/lib/cache-headers.js +363 -0
- package/lib/check.js +61 -0
- package/lib/config.js +234 -5
- package/lib/consent-client.js +1142 -0
- package/lib/consent-gate.js +328 -0
- package/lib/consent-record.js +255 -0
- package/lib/consent-ui.js +461 -0
- package/lib/consent.js +1634 -0
- package/lib/css-audit.js +765 -0
- package/lib/css-purge.js +211 -0
- package/lib/embed.js +299 -0
- package/lib/handlebars-helpers.js +447 -2
- package/lib/image-encode.js +244 -0
- package/lib/image-header.js +683 -0
- package/lib/images-sweep.js +551 -0
- package/lib/images.js +445 -0
- package/lib/init.js +28 -6
- package/lib/integrity.js +150 -0
- package/lib/kiss-page.js +37 -0
- package/lib/kiss.js +145 -4
- package/lib/links.js +155 -29
- package/lib/markdown-copy.js +28 -1
- package/lib/rebuild.js +45 -2
- package/lib/speed.js +739 -0
- package/lib/staging.js +4 -3
- package/llms.txt +28 -17
- package/package.json +16 -2
- package/types/asset-manifest.d.ts +8 -0
- package/types/asset-minify.d.ts +35 -0
- package/types/assets.d.ts +1 -0
- package/types/audit.d.ts +257 -2
- package/types/build-finish.d.ts +6 -0
- package/types/build-report.d.ts +129 -1
- package/types/cache-headers.d.ts +183 -0
- package/types/check.d.ts +21 -1
- package/types/config.d.ts +170 -6
- package/types/consent-client.d.ts +77 -0
- package/types/consent-gate.d.ts +50 -0
- package/types/consent-record.d.ts +35 -0
- package/types/consent-ui.d.ts +62 -0
- package/types/consent.d.ts +876 -0
- package/types/css-audit.d.ts +346 -0
- package/types/css-purge.d.ts +124 -0
- package/types/embed.d.ts +44 -0
- package/types/handlebars-helpers.d.ts +44 -2
- package/types/image-encode.d.ts +87 -0
- package/types/image-header.d.ts +95 -0
- package/types/images-sweep.d.ts +226 -0
- package/types/images.d.ts +277 -0
- package/types/init.d.ts +1 -1
- package/types/integrity.d.ts +50 -0
- package/types/kiss-page.d.ts +11 -0
- package/types/kiss.d.ts +44 -1
- package/types/links.d.ts +26 -8
- package/types/rebuild.d.ts +1 -0
- package/types/speed.d.ts +325 -0
package/AIKB/asset-copy.md
CHANGED
|
@@ -8,7 +8,8 @@ registrations a replay re-runs, and the copies a released page claim can restore
|
|
|
8
8
|
is the copy itself and knows nothing of the instance; this module is what ties a copy to a build.
|
|
9
9
|
|
|
10
10
|
`copyAssetFolder(kiss, …)` takes the `Kiss` instance and reads its state at call time. The state
|
|
11
|
-
stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `
|
|
11
|
+
stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `_copySources` (every
|
|
12
|
+
source folder asked for, resolved at the call, which `kiss-ssg images` reads), `_restorableAssets`,
|
|
12
13
|
`_failures`, `_promises` — and `Kiss._copyAssets()` is a one-line delegator to it. Calls back
|
|
13
14
|
into the instance go through it (`kiss._stagedPath()`, `kiss._carry()`, `kiss._refreshReport()`).
|
|
14
15
|
|
package/AIKB/asset-manifest.md
CHANGED
|
@@ -12,6 +12,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
12
12
|
- `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
|
|
13
13
|
- `createAssetManifest()` → `{ lookup, toObject, hasOwner, owns, previous, reconcile, reconcileSass, unrecorded, urlRevision }`, a fresh manifest.
|
|
14
14
|
- `lookup(urlPath)` — the emitted path, or `null`.
|
|
15
|
+
- `retarget(name, output)` — points a name at a new output after the copy, and every copy's map that held the old output follows it, so the next reconcile deletes the old file rather than keeping it. It counts a URL change for a hashable name. The settle's CSS purge (`AIKB/css-purge.md`) is its only caller: a purged stylesheet is re-hashed, so its name moves.
|
|
15
16
|
- `toObject()` — a plain object copy of the map, for debugging.
|
|
16
17
|
|
|
17
18
|
- `hasOwner(owner)` — whether a source/target copy has an inventory, or has written outputs it never recorded (below).
|
|
@@ -19,7 +20,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
19
20
|
- `unrecorded(owner, outputs)` — remembers build-relative outputs a copy wrote, or failed to remove, before it stopped partway. They are in no map and `lookup` never sees them; the copy's next `reconcile` reports them stale unless they are live again, then forgets them.
|
|
20
21
|
- `owns(owner, output)` — whether `output` (build-relative, keyed by output not by name) is this copy's, recorded or unrecorded. Never another copy's. `lib/assets.js` asks it only when no output registry was passed; with a registry, the registry is the authority.
|
|
21
22
|
|
|
22
|
-
- `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
|
|
23
|
+
- `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. That does not mean an image edit never needs one: a page whose `{{image}}` serves the image carries its size (and, outside dev, its variants' names), and the watch path finds those pages from the image index (`lib/rebuild.js` `imagePagesFor`), not from this revision. The source comment said otherwise until workstream F. `reconcile` is the only mapping writer.
|
|
23
24
|
|
|
24
25
|
## Depends on
|
|
25
26
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# asset-minify.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Minify a stylesheet or script the asset copy wrote, in place, before it is hashed. A production build then ships its copied `.css` and `.js` the way it already ships its HTML. The minifiers are the ones the page minifier already loads (`html-minifier-terser` → `terser` and `clean-css`), promoted to direct dependencies so this module can import them. Pure helpers plus one file rewrite. It knows nothing about the manifest, hashing, dev mode or config: `lib/assets.js` decides when to call it.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `shouldMinify(relative)` → `true` for a `.css`, `.js` or `.mjs` that is not `*.min.*`.
|
|
10
|
+
- `minifyText(relative, text)` → the minified text. CSS goes through `clean-css` at level 1, and its errors throw. JS goes through `terser` with `compress`, `mangle` and no comments, as a classic script unless the file is `.mjs`. A `.js` that does not parse as a classic script is tried again as a module, so `import`, `export` and top-level `await` survive. Throws when the file cannot be parsed either way.
|
|
11
|
+
- `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
|
+
## Depends on
|
|
14
|
+
|
|
15
|
+
`fs-extra`, `node:path`, `terser`, `clean-css`.
|
|
16
|
+
|
|
17
|
+
## Depended on by
|
|
18
|
+
|
|
19
|
+
- `lib/assets.js` `recordEmitted`, in the loop over claimed outputs, just before the hash is taken. It runs when `config.assets.minify !== false`, outside dev, when the winning source is not Sass (already `compressed`), and when `shouldMinify` agrees. A failure becomes one `warn` naming the file, the copy carries on, and the build does not fail.
|
|
20
|
+
- `test/integration/examples.test.js` (example 12) compares the built stylesheet with `minifyText` of its source.
|
|
21
|
+
|
|
22
|
+
## Non-obvious behavior
|
|
23
|
+
|
|
24
|
+
- **A clean-css warning keeps the original.** clean-css 5 does not understand native CSS nesting: it dropped a nested `.child { … }` and a nested `@media`, and hoisted `&:hover` out of its parent, reporting seven warnings and no error, while this module read only the errors (Codex at the close, 2026-11-05, reproduced here). `minifyText` now throws on any warning, and `minifyFile` leaves the file as written and says why. The fleet's own stylesheets gave none (a1k9training, pro-plumbing, k9-solutions), so they are minified as before.
|
|
25
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
28
|
+
- **A failure never fails the build.** A minifier rejecting a file is either the file's bug, which the browser may tolerate, or the minifier's, which kiss should not turn into a broken deploy. The original is served and the warning names it, with the two ways out: `assets: { minify: false }`, or naming the file `*.min.*`.
|
|
29
|
+
- **clean-css changes text, not meaning.** Measured on example 12, it removes empty rules (`.x{}`), writes `background:none` as `background:0 0`, and sorts a selector list. A test asserting copied CSS byte for byte compares against `minifyText` of the source, or sets `minify: false`, as `test/integration/packages.test.js` does where it counts reads.
|
|
30
|
+
- **Pipeline outputs are not minified here.** A step's `outputs` are written straight into the build folder, never by the copy. A step that writes into the assets folder is copied, and minified, like any other file. Tailwind's own `--minify` is the usual choice there.
|
|
31
|
+
- **Removing unused code is not this module's job.** It needs the site's judgement (a safelist for classes added at run time), so it is a pipeline recipe in `kiss-performance`, not an engine default.
|
package/AIKB/assets.md
CHANGED
|
@@ -11,7 +11,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
11
11
|
|
|
12
12
|
## Depends on
|
|
13
13
|
|
|
14
|
-
`fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
|
|
14
|
+
`fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./asset-minify.js` (`minifyFile`, `shouldMinify`: outside dev and unless `assets.minify` is `false`, `recordEmitted` minifies each claimed `.css`/`.js`/`.mjs` that is not Sass or `*.min.*`, in place, **before** hashing, so a hashed name follows the served bytes; a file the minifier cannot read is copied as it is, with one `warn` — `AIKB/asset-minify.md`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
|
|
15
15
|
|
|
16
16
|
## Depended on by
|
|
17
17
|
|
package/AIKB/audit.md
CHANGED
|
@@ -2,24 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident.
|
|
5
|
+
The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident, and the three privacy checks: a form asking for an email address or phone number with no link to the privacy page, a `<mark>`/`[to confirm` placeholder left in the copy, a pre-ticked marketing checkbox, and the five speed checks: an image with no size, a lazy first image, too many `fetchpriority="high"` or font preloads, a script that blocks the head. It also derives the build's third-party origins (`thirdPartyOrigins`), the fact behind `report().thirdParty`. Pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
|
|
6
6
|
|
|
7
7
|
The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
14
|
-
-
|
|
11
|
+
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`, then, appended, `privacy-link-missing`, `placeholder-left`, `checkbox-prechecked`, the eight consent checks below, the five speed checks below, `host-unset`, and the three image checks below (`image-oversized`, `image-location-metadata`, `img-not-responsive`). **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change and a new one is appended, never slotted in. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
|
+
- `MARKETING_WORDS` — the frozen word list `checkbox-prechecked` matches a label against (offer(s), news, newsletter(s), marketing, promotion(s), promotional, partner(s), third party/parties), whole-word and case-insensitive, a space also matching a hyphen.
|
|
13
|
+
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks, origins, forms, anchors, placeholders, prechecked, imgNoSize, imgFirstLazy, fetchpriorityHigh, fontPreloads, blockingScripts, consent }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`. The five privacy fields: `origins` `[{ origin, kind }]` (every absolute or `//host` origin a fetching element names, sorted); `forms` `[{ action, honeypot, fields: [{ type, name, autocomplete }] }]`; `anchors` (followable `<a>`/`<area>` hrefs, sorted); `placeholders` (each `<mark>`'s text, then each `[to confirm…` outside one, capped at 80 characters); `prechecked` (the label text of each checked checkbox, `''` when unlabelled).
|
|
14
|
+
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess, trailingSlash, privacyPage })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess` and `trailingSlash` `config.links.trailingSlash`, passed so a cross-page fragment, and a link to the privacy page, resolves to the page the link scan would resolve the same href to (`servedAs()`, which keeps a learning package's page on its file's folder, as `checkLinks` does). `privacyPage` is `config.audit.privacyPage`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl, id, package }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
15
|
+
- `thirdPartyOrigins({ pages, siteUrl })` → `{ siteUrl, pages: [{ page, origins: [{ origin, kinds }] }], origins }`, or `null` when no page wrote HTML facts. Called by `lib/build-finish.js`'s `reportInputs()` on every report assembly; `lib/build-report.js` maps each `page` out of staging and nulls the key on a failed build.
|
|
16
|
+
- The `CheckId`, `PageFacts`, `PageOrigin`, `PageConsent`, `PageForm`, `FormField`, `AuditFinding`, `AuditResult`, `AuditPage` and `ThirdPartyOrigins` typedefs.
|
|
17
|
+
- **The consent manager's part** (2.11.0, contract `planning/plans/2026-11-02-consent-manager.md` §6).
|
|
18
|
+
- **Gated origins.** `PageOrigin` gains an optional `service`. It is set for a gated `<script type="text/plain" data-kiss-service>`, read from its `data-src`, and for a consent embed's frame. Gated means inert (`scriptKind` says a browser does not run it): a hand-written `data-kiss-service` script of a running type is read as live, its `src` and its inline signatures with it; until 2026-11-03 the attribute alone hid it from the inventory and `consent-ungated` (round 7 of the Codex review). The frame is read from the raw bytes, because `stripForIds` empties the `<template data-kiss-embed-frame>` it waits in. The dedup key includes the service, so `gtm` and `gtag` on one origin stay two entries.
|
|
19
|
+
- **`PageFacts.consent`** is `{ head, banner, settings, gated, signatures }`. `signatures` is `adsbygoogle`, `googletag.cmd`, `gtm.js` or `gtag/js` found in an ungated executable inline script, and `data-ad-client` on an `<ins>`. A script inside a comment or a `<template>` is not read, since a browser never runs it; until 2026-11-03 a commented-out snippet was reported as a tracker (round 6 of the Codex review). The `data-kiss-consent-head` script is never scanned, because its inlined config may name `gtm.js`. The field is absent from facts parked by an older engine.
|
|
20
|
+
- **`thirdPartyOrigins`** puts a sorted `services` beside `kinds` on an origin a service gates. Every other entry keeps its old shape.
|
|
21
|
+
- **`auditBuild`** takes `consent` (the resolved `config.consent`, or `null`) and runs eight checks, appended to `CHECKS`:
|
|
22
|
+
- `consent-missing`: no consent manager, and a page loads from `TRACKER_ORIGINS` or carries `gtm.js`/`gtag/js` inline;
|
|
23
|
+
- `consent-ungated`: a third-party script or iframe no service gates, and no `necessary` service lists in its `origins`, a `<noscript>` GTM frame included;
|
|
24
|
+
- `consent-ads-cmp`: any page, consent or not: an AdSense or Ad Manager loader, an `adsbygoogle`/`googletag.cmd` signature, or `<ins data-ad-client>`;
|
|
25
|
+
- `consent-objection-third-party`, site-wide;
|
|
26
|
+
- `consent-settings-missing`: a page with the head and no settings control, only when some category is in use. With every service `necessary`, `{{consentSettings}}` renders nothing, so no page could satisfy it (round 6 of the Codex review);
|
|
27
|
+
- `consent-google-advanced`, site-wide;
|
|
28
|
+
- `consent-records-missing`, site-wide: a Google marketing tag with no `endpoint`;
|
|
29
|
+
- `consent-history-missing`: the page whose `id` is `ui.history` has no `{{consentRecord}}` section (the page fact `consent.record`, read from a bare `data-kiss-consent-record` attribute, never its `-id`/`-rows` parts). Needed because the dialog's link to `#kiss-consent-history` sits in a `<template>`, which the fragment check never reads (measured 2026-11-03).
|
|
30
|
+
- **The speed checks** (contract `planning/plans/2026-11-04-kiss-performance.md`, "Amendment 2026-11-04: after the critique", "Audit checks"). Five `PageFacts` fields, extracted by `extractSpeed()`, and five checks, appended to `CHECKS`, all per page:
|
|
31
|
+
- `img-dimensions-missing`, from `imgNoSize`: an `<img>` lacking `width` or `height`, with no inline `aspect-ratio` other than `auto`. One finding per image per page, `detail` the src, as `img-alt-missing`.
|
|
32
|
+
- `img-first-lazy`, from `imgFirstLazy`: an `<img>` both `loading="lazy"` and `fetchpriority="high"` (certain, found anywhere); failing one, the first `<img>` after `<main>`'s opening tag and before its first `<h2>` (or `</main>`), when that image is lazy (approximate: it may be the first image seen). `detail` the src.
|
|
33
|
+
- `fetchpriority-excess`, from `fetchpriorityHigh`: more than two elements with `fetchpriority="high"`. `detail` the count.
|
|
34
|
+
- `font-preload-excess`, from `fontPreloads`: more than two `<link rel="preload" as="font">`. `detail` the count.
|
|
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
|
+
- **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
|
+
- **`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
|
+
- **`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
|
+
- **`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
|
+
- The first two read files in the build folder, so they join `WALK_CHECKS` and are skipped, and listed in `skipped`, under `cleanBuild: false`. The audit as a whole is skipped in dev, where `{{image}}` writes a plain `<img>` that `img-not-responsive` would otherwise name.
|
|
41
|
+
- **`host-unset`**, site-wide, from the `hostUnset` option: the concerns (`'cache headers'`, `'redirects'`) whose writer had something for a host to read and found neither `config.host` nor that concern's `format` set. One finding, `page: null`, `detail` the concerns sorted and joined. It is the only check fed by another module's result rather than by page facts or the walk: `lib/build-finish.js` `runAudit` reads `_cacheHeadersResult.unset` and `_redirectsUnset`, so it runs after both writers. The redirects writer's `warn` line is unchanged; this is the same fact, kept with the rest of the audit.
|
|
15
42
|
|
|
16
43
|
## Depends on
|
|
17
44
|
|
|
18
|
-
`node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `classifyReference` and `resolveTarget`, so a cross-page fragment
|
|
45
|
+
`node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `srcsetUrls` and `browserReference`, so a `srcset` and a reference are read as the link scan reads them; `classifyReference` and `resolveTarget`, so a cross-page fragment and a privacy link land on the page the link scan resolves their path to), `./utils.js` (`posixPath`) and `./consent.js` (`TRACKER_ORIGINS`, `ADS_CMP_SIGNATURES`, `matchesHost`, and `scriptKind`, the one answer to whether a browser runs a script, read by the consent facts and `script-blocking` alike). No HTML parser, no logger, no config.
|
|
19
46
|
|
|
20
47
|
## Depended on by
|
|
21
48
|
|
|
22
|
-
`lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's
|
|
49
|
+
`lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's `audit` key — `AIKB/build-report.md`; and `thirdPartyOrigins`, from `reportInputs()`, as the report's last key, `thirdParty`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
|
|
23
50
|
|
|
24
51
|
## Non-obvious behavior
|
|
25
52
|
|
|
@@ -47,5 +74,17 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
47
74
|
- **Matching:** the raw fragment is compared first, then its percent-decoded form, both case-sensitively. A text directive is cut off at `:~:`, and an empty fragment is skipped. `#top` with no `id="top"` is skipped in any case, because the spec scrolls to the top.
|
|
48
75
|
- **The target page:** an empty path or a query-only path (`?q=1#x`) is the page itself. Anything else goes through `classifyReference` and then `resolveTarget`, with `exists: () => false`, so only pages this build wrote can be named. A fragment on an asset, another origin, an earlier build's file or another instance's page has no ids to check against and is skipped. So is every fragment on a page with a `<base href>`.
|
|
49
76
|
- **`resolveTarget` tries `known` across every candidate before the disk** (`AIKB/links.md`). Asked candidate by candidate, `/about` on a real filesystem named the folder `about`, which holds `about/index.html`, and every extension-less cross-page fragment would have been skipped without a word. A fresh-context critique of the plan caught this before the code existed (2026-10-20).
|
|
50
|
-
- **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw.
|
|
77
|
+
- **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw. That includes the five privacy fields and the five speed fields (`?? []`, `?? null`, `?? 0`); facts are extracted in memory and never parked across engines, so in practice only a hand-built test lacks them.
|
|
78
|
+
- **The privacy facts read quote-aware** (`ATTRS`, `TAG_END`), for the fragment checks' reason: a misread form, label or checkbox is a finding invented, and Alpine-style `x-show="step > 1"` values are common on exactly the forms these read. `origins` reads from the ids' strip (opening tags kept, so `<script src>` is seen; script, style and template contents gone). `forms`, `anchors`, `placeholders` and `prechecked` read from the counts' wider strip, so a `<mark>` in a script string or a comment never fires.
|
|
79
|
+
- **`origins` is its own kind-aware fact, not derived from `KissPage.links`** (2026-11-02 critique, blocker 1). `links` is a flat, sorted string list with no tag kind, and it includes every `<a>`, which a browser does not fetch. Kinds: `script`, `iframe`, `img` (`<img>` `src`/`srcset`, `<source srcset>`, `<video poster>`), `media` (`<video>`/`<audio>` `src`, `<source src>`), `form-action`, `stylesheet`, `preconnect`, `dns-prefetch`, and `preload` or `preload:<as>`. Never an `<a>`, a canonical or an alternate link. Only absolute `http(s)` and `//host` references are kept, since a relative one can only be the site's own. Static markup only: an iframe a script injects (a1k9's facade) and a CSS `@import` are invisible, so an empty list proves nothing.
|
|
80
|
+
- **First-party is the host, not the origin** (`thirdPartyOrigins`). An absolute URL whose host equals `siteUrl`'s, with a leading `www.` dropped on both sides, is the site's own, whatever its scheme or port. A subdomain (`cdn.site.example`) is not. With `siteUrl` unset, unparseable or local (`isLocalSiteUrl`, the `site-url-local` rule), every absolute origin is listed and `siteUrl` is `null`: the audit does not guess which one is the site.
|
|
81
|
+
- **`privacy-link-missing` finds the privacy page by id, then by path, and never by link text.** The page whose `id` is `privacy` (or `config.audit.privacyPage`), else, with no override, the page written to `privacy.html` or `privacy/index.html`. A page counts when one of its forms has a field that is not `hidden` (or another non-typed input), not a honeypot (`bot-field`, or the field the form's `netlify-honeypot` names), and has `type` `email`/`tel`, a `name` of `email`/`e-mail`/`tel`/`phone`/`telephone`, or an `autocomplete` token `email` or `tel…`. It is satisfied by an `<a>` anywhere on the page whose href resolves, through `classifyReference` and `resolveTarget` with `exists: () => false`, to the privacy page. An external href never counts, and the privacy page needs no link to itself. A page with a `<base href>` is not checked at all: its relative links resolve somewhere the build cannot see, so a link a browser follows to the privacy page was reported missing, and one leaving the site could pass (a Codex review, 2026-11-02). The fragment checks skip such a page for the same reason. The `detail` is that field's name (or type). No privacy page at all is one more site-wide finding, said only when some page collects; a `privacyPage` no audited page has is a site-wide finding too, said whatever the forms, rather than a config error, because only the build knows which ids exist.
|
|
82
|
+
- **`placeholder-left` is `<mark>` or the bracketed `[to confirm`, never the bare phrase.** "to confirm" appeared twelve times as prose in the fleet's sources (2026-11-02 critique). A `[to confirm` inside a `<mark>` is one placeholder, not two. The bracketed text inside a `<code>` or `<pre>` element is not read at all: it is the marker quoted, and kiss's own guide page for this check fired on itself until it was excluded (2026-11-02). A real `<mark>` element still counts wherever it is, because an escaped one (`<mark>`) is not an element.
|
|
83
|
+
- **`checkbox-prechecked` is a presence test for `checked` on quote-blanked attributes**, like `ALT`, so `data-checked`, `aria-checked` and a `checked` in another attribute's value never match, and a radio never counts. The label is the union of `aria-label`, the `aria-labelledby` targets' text, every `<label for>` naming the box's id and the `<label>` wrapping it. The decision (`MARKETING`) is made at audit time from the stored label, so changing the word list needs no re-extraction.
|
|
84
|
+
- **The speed facts take two readings, chosen by what each one has to see.**
|
|
85
|
+
- **Images from the counts' strip** (`strip()`): an `<img>` inside a `<template>`, an `<svg>`, a script string or a comment is not fetched as the page loads, so it is not judged. kiss's own consent banner logo (`lib/consent-ui.js`, no `width`/`height`) waits in a `<template>` and is never reported. An image inside `<noscript>` is judged, deliberately. An `<img>` inside `<picture>` is judged on its own attributes, because a `<source>`'s `width`/`height` do not size the fallback.
|
|
86
|
+
- **`fetchpriority`, font preloads and head scripts from the opening tags** (`stripForIds()`). The contract named `strip()` for `fetchpriority`, but `strip()` removes a `<script>` element whole, opening tag included, so `<script fetchpriority="high">` would never be counted. Template contents and comments are gone from both readings.
|
|
87
|
+
- **The speed facts read attributes as a tokenizer does** (`attributesOf()`), not with `attribute()`'s first-match-anywhere regex: each is a presence test, and `alt="width=10"` or `data-note="defer"` read as the attribute would hide a finding or invent none where one is due. Names lowercased; the first of two same-named attributes wins, as in HTML. The script's `type`/`language` still go through `rawValue()` and `scriptKind()`, so the audit and the consent gate cannot disagree about which scripts run.
|
|
88
|
+
- **`aspect-ratio: auto` alone reserves nothing.** It waits for the image's own ratio, so an image whose only sizing is that is still reported. `auto 4/3` reserves. The last `aspect-ratio` declaration in the style is the one read.
|
|
89
|
+
- **The head ends at the first `</head>` or `<body`.** The minifier keeps both (no `removeOptionalTags` in `lib/kiss-page.js`), so a kiss page always has one. A page with neither has no head the audit can find, and none of its scripts is judged. A script injected at run time (the consent head's `kiss-consent.<hash>.js`, appended by its inline script) is not in the markup and is not judged; the injected script runs async anyway.
|
|
51
90
|
- **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -29,6 +29,12 @@ 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
|
+
- `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
|
|
33
|
+
- `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
|
|
34
|
+
- `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
|
|
35
|
+
- `encodeImages(kiss)` — `_encodeImages()`: the variants this build's `{{image}}` calls named (`kiss._imageIndex.variants()`), encoded or copied from the committed cache by `lib/image-encode.js` (`AIKB/image-encode.md`), **straight after the purge** and before the redirects, the cache headers (which mark the variants `immutable`), the packages (which pack them, since they are claimed outputs), the link scan and the audit. Latched as `_imagesRun`, reset by the replay, skipped in dev (where `{{image}}` names no variant) and on a failed build. Each written variant is claimed as `images` in the output registry; a variant the previous encode of this instance wrote (`_imageOutputs`) that no page asks for now is removed and released. A source that cannot be encoded is a `_failures` entry `<images: img/x.jpg>` whose message names the pages that asked for it (`pagesUsing`); any other throw is `<images>`. The result is `_imagesResult` (`{ requested, encoded, cached, cache }`, `null` when no variant was asked for), logged as `Images: N variants, H from the cache, M encoded`, and a build with misses warns once: `N image variants were encoded that are not in the committed cache. Run \`npx kiss-ssg images <script>\` and commit <cache>`(not under`kiss-ssg images`itself, and not with`cache: false`). Under `KISS_IMAGES` (`lib/images-sweep.js` `sweepRequest`) it also appends one `sweepLine`to that file, with no variant asked for too, so the sweep sees every instance;`KISS_IMAGES_REFRESH`makes it ignore the cache. It never prunes the cache. The cache folder is`kiss._imageCacheDir`, resolved in the constructor where `resolveConfig`checked it, never against the working directory at the settle: a script that changed directory before`complete()`otherwise wrote and reported the other folder's cache, and the sweep emptied it against this site's pages (measured; Codex adversarial review, 2026-11-05). A render after such a`chdir`also fails`{{image}}`, which reads its source relative to the working directory: that fails loudly and is not changed here.
|
|
36
|
+
- `writeHostCacheHeaders(kiss)` — `_writeCacheHeaders()`: the cache-header files for the hashed files (`lib/cache-headers.js`, `AIKB/cache-headers.md`), written after the consent client, whose file they name. It is latched as `_cacheHeadersRun` and reset by the replay. **Skipped in dev and on a build that has already failed**: a watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash. The result becomes `_cacheHeadersResult`, then `report().cacheHeaders`. It warns once per host with no encoding (`vercel`) and once per Firebase lookalike. A failed write fails the build (`<cache-headers>`). Its `unset`, and the redirects writer's (`_redirectsUnset`), feed the audit's `host-unset` through `runAudit`'s `hostUnset`.
|
|
37
|
+
- `writeConsentClient(kiss)` — `_writeConsentClient()`: `<build>/kiss-consent.<hash>.js`, the consent client's cached part, whenever `config.consent` is set, dev included; minified with the pages' minifier outside dev; latched as `_consentClientRun`, reset by the replay; claimed; a failed write fails the build (`<consent-client>`), since every consent page loads it. Written before the link scan, so each page's reference to it resolves.
|
|
32
38
|
|
|
33
39
|
Three are named apart from their method because the method's own name is a function this module
|
|
34
40
|
imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
@@ -37,7 +43,7 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
|
37
43
|
## Depends on
|
|
38
44
|
|
|
39
45
|
`fs-extra`, `./utils.js`, `./build-report.js` (`buildReport`, `auditLines`), `./links.js`,
|
|
40
|
-
`./audit.js`, `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
|
|
46
|
+
`./audit.js` (`auditBuild`, `thirdPartyOrigins`), `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
|
|
41
47
|
`./check.js` (`readReportsFile`, `aikbRecordRequested`), `./aikb.js` (`buildSiteMap`,
|
|
42
48
|
`isRecorded`, `writeAikb`, `writeLastBuild`).
|
|
43
49
|
|
|
@@ -47,6 +53,12 @@ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
|
47
53
|
|
|
48
54
|
## Non-obvious behavior
|
|
49
55
|
|
|
56
|
+
- **`runLinkCheck()` and `runAudit()` both pass `config.links.trailingSlash`**, so a relative reference on a directory index resolves against the URL the host serves it at, the same policy `{{link}}` was written under; `runAudit()` passes each page's `package` too, which keeps a learning package's page on its file's folder (Codex round 6, 2026-11-03).
|
|
57
|
+
- **`runAudit()` threads each page's `id` and `config.audit.privacyPage` into `auditBuild`**, so `privacy-link-missing` can find the privacy page by id. The pages come from `_stackForRecord()`, not the raw stack, so the ids are the ones the report shows: a default `privacy` that two pages arrived at, which `{{link}}` would refuse, finds no privacy page either.
|
|
58
|
+
- **`reportInputs()` derives `thirdParty` on every assembly**, from the audit facts each written page parked (`thirdPartyOrigins`, `lib/audit.js`), against the instance's `config.siteUrl` (a page's own `siteUrl` override is not consulted for this key). It reads no file, so it costs a walk over the stack and is current under `_refreshReport()` too; `buildReport` nulls it on a failed build. It comes after `packages`, as the report's key order fixes it.
|
|
59
|
+
- **`reportInputs()` derives `consent` last, the same way:** `consentReport` (`lib/consent.js`) from `config.consent` and each written HTML page's parked `audit.consent`. Pages carrying gated markup are passed as `gatedPages`, from which `pagesWithoutHead` is derived. Paths stay as written (staging under check) for `buildReport` to map. It is `null` without `config.consent`. `runAudit` passes `config.consent` to `auditBuild` for the seven consent checks.
|
|
60
|
+
|
|
61
|
+
- **`writeBannerRegistry()` runs between `_writeRedirects()` and the packages, for the redirects' reasons**: `consent-banners.json` is a file this build publishes, so it is in the staging folder before the swap. Written whenever `config.consent` has an `endpoint`, dev included (its one reader is the server the records go to; the operator chose endpoint-only over always-on at the 2026-11-03 pulse), as `bannerRegistry()` (`lib/consent.js`) of the hashes the written pages' parked facts carry (`audit.consent.banner`) and the texts the helper recorded by hash in `kiss._bannerTexts`. That map only grows, and a text no written page's hash names is never written. Latched as `_bannerRegistryRun`, reset by the replay; a failed write is a build failure (`<consent-banners>`), because the file is the record of the words a hash stands for. Claimed in the output registry. Not in the build report: the report already lists each hash with its pages.
|
|
50
62
|
- **`writeLearningPackages()` runs just after `_writeRedirects()`, and for its reasons**: the zips are files this build publishes, so they are written into the staging folder before the swap, from the pages where they were written. It is latched as `_packagesRun` (reset by the replay) and skipped with nothing to report when no page names a `package`, when `config.packages` is `false`, and when the build has already failed, so a failed page is reported once and not again as a package that could not be made. A dev build skips it with one notice per instance: a scoped re-render re-runs no writer. A package that cannot be made fails the build as `<packages>`, and no zip is written at all. `written(file)` is a lookup in the output registry, never the disk, which is what keeps a stale file out of a package (`AIKB/packages.md`). `reportInputs()` carries `_packagesResult` as the report's last key. After writing, it logs one `Package: <build folder>/<dir>/<id>.<format>.zip <title> (<n> pages, <m> files)` info line per zip, beside `Links:` and `Audit:`, named by `config.folders.build` rather than the staging folder an atomic build wrote it in, and a `package outside: <page> -> <href> (<reason>)` notice per reference left out. Before 2026-10-25 only `check --summary` listed packages, and a clean-room run found GUIDE's claim that the build did.
|
|
51
63
|
- **`_writeRedirects()` now writes up to two files, and only one of them is the host's.** `redirects.json` — the host-neutral IR — goes out on every build that has an alias, whatever `config.redirects.format` says; the format picks what goes beside it. `_redirectsPath` stays the **host** file, so the report's `redirects.file` keeps the meaning it had in 2.3, and it is `null` under `format: 'none'`: the honest answer for a build that deliberately wrote no host file, rather than a path that would read as a redirect some host is serving. A custom writer runs inside the same promise the `.catch` below wraps, so a writer that throws fails the build exactly as a failed write does — swallowing it would rebuild, in the site's own code, the silent no-op the format block exists to end.
|
|
52
64
|
- **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_audit`, `_debugWritten`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
|
package/AIKB/build-report.md
CHANGED
|
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit })` → `BuildReport`, whose keys are always in this order:
|
|
9
|
+
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit, packages, thirdParty, consent, cacheHeaders, images })` → `BuildReport`, whose keys are always in this order (the last, `consent`, is described after `thirdParty` below):
|
|
10
10
|
- `ok` — `failures.length === 0`.
|
|
11
11
|
- `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
@@ -21,14 +21,20 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
21
21
|
- `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }], orphans }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder. `orphans` is the sorted HTML pages no other page links to, never the home page, `404.html` or a page in a learning package, each mapped out of the staging folder the same way (`AIKB/links.md`).
|
|
22
22
|
- `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved, rules, json, format, files }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`. The last four keys are the format block: `rules` is the resolved `[{ from, to }]` — the host-neutral fact, handed over so a site whose host kiss does not encode can write its own redirects from `kiss.report()` rather than by parsing a file kiss wrote — `json` is the `redirects.json` IR (mapped), `formats` is the list of names that ran, in order (`'custom'` per writer function, `[]` for IR-only), and `files` is every redirects file written (mapped). `file` is the **first** host file in `formats` order, and `null` when no host format ran at all — the honest answer rather than a claim about a redirect no host serves. With more than one format it names one of several, so `files` is the authoritative list; that is the one place `file` is a lead rather than the whole answer, and it is why `files` exists.
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
|
-
- `audit` —
|
|
25
|
-
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
26
|
-
- `
|
|
24
|
+
- `audit` — after `outputs` (then `packages`, then `thirdParty`): `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
|
+
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON. After the packages, one ` third-party origins (N): <the first five>, …` line when `thirdParty` lists any, then one ` consent: version <v>, banner <hash>, N services` line when `consent` is set.
|
|
26
|
+
- **The `images` key, last, after `cacheHeaders`** (workstream F). `null` when no page rendered an `{{image}}`, in dev, and on a failed build; otherwise `{ requested, encoded, cached, cache }` from `lib/build-finish.js` `encodeImages`: counts only, so `last-build.json` does not grow by a line per variant, and `cache` is `assets.images.cache` as configured. The variants stay out of `assets`, so `report().assets` and the `check` baseline do not move. Every recorded `last-build.json` gains `"images": null` at its next `kiss-ssg aikb` (examples 9 and 11 were re-recorded for it). `image-oversized` joins `VALUE_CHECKS` (the line names the file and its size) and `image-location-metadata` joins `FILE_CHECKS`.
|
|
27
|
+
- **The `cacheHeaders` key, after `consent`.** It is `null` when nothing was written: no hashed file, a dev build, or a failed build. Otherwise it is `{ rules: [{ path, headers }], files, formats, unset, merged }`. `files` are mapped out of staging; `rules` holds URL paths, which carry no staging prefix. It moves every recorded `last-build.json`, which gains `"cacheHeaders": null` at its next `kiss-ssg aikb`.
|
|
28
|
+
- **The `consent` key** (2.11.0), just before `cacheHeaders`. It is `null` without `config.consent` and on a failed build. Otherwise it is `{ version, purposes, mode, banners: [{ hash, pages }], categories, services: [{ name, category, gated, origins, cookies }], pagesWithoutHead }` from `lib/consent.js`'s `consentReport`.
|
|
29
|
+
- Every page path goes through `real()` and is then sorted.
|
|
30
|
+
- 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.
|
|
31
|
+
- `thirdParty`'s origins carry `services` beside `kinds` when a consent service gates them.
|
|
32
|
+
- `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.
|
|
27
33
|
- `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
|
|
28
34
|
|
|
29
35
|
## Depends on
|
|
30
36
|
|
|
31
|
-
Nothing.
|
|
37
|
+
Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `CheckId` and `ThirdPartyOrigins`.
|
|
32
38
|
|
|
33
39
|
## Depended on by
|
|
34
40
|
|
|
@@ -36,8 +42,9 @@ Nothing.
|
|
|
36
42
|
|
|
37
43
|
## Non-obvious behavior
|
|
38
44
|
|
|
39
|
-
- **`
|
|
40
|
-
-
|
|
45
|
+
- **`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.
|
|
46
|
+
- **`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.
|
|
47
|
+
- **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` (`lib/staging.js` `toReportedPath`) calls this same function for the failure _message_, so the two cannot name a file differently; this is the rule somewhere a unit test can reach it. A path joined with `/` or, on Windows, with `\` (a writer that used `path.join`) is mapped, and always written with `/`: `cacheHeaders.files` named the discarded staging folder until the fleet check of 2026-11-05. The rule lives here, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
|
|
41
48
|
- **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.
|
|
42
49
|
- **`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`.
|
|
43
50
|
- **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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# cache-headers.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Cache headers for the files kiss content-hashed: say, in the encoding the site's host reads, that each one can be cached for a year (`public, max-age=31536000, immutable`). Pure functions plus one writer (`writeCacheHeaders`), the shape of `lib/redirects.js`. The module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs: `lib/build-finish.js` `writeHostCacheHeaders` does that.
|
|
6
|
+
|
|
7
|
+
**Only hashed files, and never HTML.** A hashed file's bytes are its name, so a year is simply true for it. An unhashed file's right lifetime is the owner's decision: a photograph replaced under its old name would stay stale for a year. That is what the fleet's hand-written rules got wrong on two sites (measured, `planning/research/2026-11-04-static-site-performance.md` § Measured). HTML gets no rule on any host, because a page answers at several URLs (`/about`, `/about.html`, `/courses/index.html`) and a rule written for one misses the others. Netlify's own HTML default (`max-age=0, must-revalidate`) is already right.
|
|
8
|
+
|
|
9
|
+
**The list first, then the encodings.** `cache-headers.json` (`{ version, rules: [{ path, headers }] }`) is written whenever a hashed file exists, whatever the format says, like `redirects.json`. `cacheHeaders.format` then picks the host files, and `config.host` sets it when the site did not (`lib/config.js` `HOSTS`).
|
|
10
|
+
|
|
11
|
+
## Public interface
|
|
12
|
+
|
|
13
|
+
- `IMMUTABLE` — the one `Cache-Control` value written.
|
|
14
|
+
- `CONSENT_HASH_LENGTH` — `10`, the hex characters in `kiss-consent.<hash>.js` (`lib/consent-client.js`).
|
|
15
|
+
- `BLOCK_START` / `BLOCK_END` — the two marker lines around kiss's block in `_headers`.
|
|
16
|
+
- `hashedFiles({ manifest, consentClient, images, root })` → the `/`-rooted, sorted paths of every hashed file that is on disk under `root`. The input is `assetManifest.toObject()` filtered to `isHashable(name) && name !== output`, plus the consent client, plus `images`: `{{image}}`'s variants (`img/hero-960.<hash>.avif`), which are content-named whatever `assets.hash` says and stay out of the manifest, so `lib/build-finish.js` hands them over from the image index. A variant whose encode failed is not on disk and is dropped like any other. `writeCacheHeaders` takes the same `images` and passes it through. The Firebase pattern (`FIREBASE_HASHED`) matches kiss's hash shape before `.css`, `.js`, `.avif`, `.webp`, `.jpg` and `.png`, so a variant is `immutable` there too, and an unhashed `photo.deadbeef.jpg` is now a lookalike the build warns about. An entry not on disk under `root` is dropped: that is a copy whose target is outside the build folder, which no host serves from here. A path with whitespace in it is dropped too, because it would write two lines.
|
|
17
|
+
- `cacheHeaderRules(files)` → `[{ path, headers: { 'Cache-Control': IMMUTABLE } }]`.
|
|
18
|
+
- `renderCacheHeadersJson(rules)` → the list, pretty-printed and newline-terminated.
|
|
19
|
+
- `renderHeadersBlock(rules)` → kiss's block for a `_headers` file (Netlify, Cloudflare Pages), between the markers.
|
|
20
|
+
- `mergeHeadersFile(existing, block)` → the site's file with the block appended. A block an earlier build left is replaced, never repeated. `existing` of `null` returns the block alone.
|
|
21
|
+
- `renderFirebaseCacheHeaders({ consent })` → a fragment to merge into `firebase.json`: `{ headers: [{ regex, headers: [{ key, value }] }] }`, one pattern for kiss's hash shape (`.<8 hex>.css|js`) and one for the consent client.
|
|
22
|
+
- `firebaseLookalikes(built, hashed)` → the files in the build the hash pattern matches that kiss did not hash.
|
|
23
|
+
- `CACHE_HEADER_FORMATS` — `netlify` and `cloudflare` (both `_headers`), `firebase` (`cache-headers.firebase.json`). The single table of which formats exist; `lib/config.js` derives its accepted names from it.
|
|
24
|
+
- `resolveCacheHeaderFormats(format)` → the formats to run: `'none'` dropped, a bare name or function as a list of one, `netlify` and `cloudflare` together writing `_headers` once.
|
|
25
|
+
- `writeCacheHeaders({ config, root, manifest, consentClient, outputs })` → `{ status, rules, files, formats, unset, merged, lookalikes, uncovered }`. `status: 'none'` (nothing written) when no file is hashed. `unset` is true when hashed files exist and neither `host` nor `cacheHeaders.format` was set. `uncovered` lists the hosts with no encoding yet (`vercel`). A writer function receives `(rules, { buildDir, config })` and returns `[{ file, contents }]`, resolved under the build folder; an absolute path or one climbing out throws.
|
|
26
|
+
- The `CacheHeaderRule` and `CacheHeadersWriteResult` typedefs.
|
|
27
|
+
|
|
28
|
+
## Depends on
|
|
29
|
+
|
|
30
|
+
`fs-extra`, `node:path`, `./asset-manifest.js` (`HASH_LENGTH`, `isHashable`), `./utils.js` (`posixPath`).
|
|
31
|
+
|
|
32
|
+
## Depended on by
|
|
33
|
+
|
|
34
|
+
- `lib/build-finish.js` `writeHostCacheHeaders`, which `Kiss._writeCacheHeaders()` calls at the top of `complete()`'s settle path, after the consent client and before the packages. It is latched on `_cacheHeadersRun` and reset in `lib/rebuild.js` per build. Its result becomes `report().cacheHeaders`, appended after `consent` (`AIKB/build-report.md`).
|
|
35
|
+
- `lib/config.js`: `CACHE_HEADER_FORMATS`, for `CACHE_HEADER_FORMAT_NAMES` and the format check.
|
|
36
|
+
- `lib/kiss.js` re-exports `renderCacheHeadersJson`, `renderHeadersBlock` and `renderFirebaseCacheHeaders`, so a custom writer composes an encoding rather than reimplementing one: the redirect renderers' reason (`AIKB/redirects.md`).
|
|
37
|
+
|
|
38
|
+
## Non-obvious behavior
|
|
39
|
+
|
|
40
|
+
- **A site's own `_headers` is kept and appended to, a deliberate exception to "kiss never merges".** Sites keep security headers (CSP, `X-Frame-Options`) in `src/assets/_headers`. Overwriting it, the way generated output beats a copied asset everywhere else (`lib/output-registry.js`), would remove them silently. Unlike `firebase.json`, this file is build output rebuilt on every deploy, so appending to it touches no source. The merged file is **not** claimed as generated: it stays the asset copy's, so no collision is reported for a write that lost nothing. The operator's decision, 2026-11-04 (`planning/plans/2026-11-04-kiss-performance.md`).
|
|
41
|
+
- **Firebase gets patterns, every other host gets exact paths.** The Firebase fragment is merged into `firebase.json` by hand, once. Exact paths in it would go stale at the next stylesheet edit, because the hash changes. `_headers` is rebuilt on every deploy, so exact paths there never go stale and never match a file they should not.
|
|
42
|
+
- **A pattern can match a file kiss did not hash**, such as a vendored `lib.deadbeef.js`. That file would be cached for a year. The writer reports such files as `lookalikes`, and the build **warns** rather than fails: the operator's decision at the 2026-11-04 pulse, "It's an optimisation rather than a critical issue".
|
|
43
|
+
- **Not written in dev, nor on a failed build** (`writeHostCacheHeaders`). A watch re-copy renames a stylesheet without settling a build, so a file written once per settle would name the old hash until the next one. A failed build publishes nothing.
|
|
44
|
+
- **`'github-pages'` is silent and `'vercel'` warns.** GitHub Pages cannot be configured (it sends `max-age=600` on everything, measured), so the host implies `'none'`: a fact, not a gap. Vercel has a header encoding kiss has not written yet, so the build names it. The difference is one cell in `HOSTS`, `'none'` against `null`.
|
|
45
|
+
- **Host behaviour that is not yet measured** and that GUIDE.md therefore does not state: whether `_headers` or `netlify.toml` wins on Netlify when both set `Cache-Control` for a path; whether Firebase applies `regex` header rules, and in what order against `**` globs. Both are measured on deploy previews before the docs say more (`planning/plans/2026-11-04-kiss-performance.md` § Must measure).
|
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. `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`). `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.
|
|
@@ -55,6 +55,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
55
55
|
- **The diff never touches the exit code.** `exitCodeFor` does not see it. A build that is `ok` but changed 40 pages is a passing check with a loud description; whether that is what the operator meant is a judgement no exit code can make for them.
|
|
56
56
|
- **The bin reads the `--against` file before it spawns the build**, so a mistyped filename costs nothing and prints as the usage error it is (`kiss-ssg: cannot read --against file <path>`, then `HELP`, exit 1) rather than after a minute of building.
|
|
57
57
|
- **`check` diffs without being asked, and `aikb` does not.** With no `--against`, the bin asks `defaultBaseline(reports)` where the site's own snapshot would be and diffs against it if the file is there — so the ordinary question ("what have I changed since this work opened") is answered by the ordinary command, and the answer is measured from the last _record_, not from the last build anybody happened to run. A check writes nothing, so the file it reads is the one the last record left. `aikb` skips the fallback for the opposite reason: by the time it could read that file it has just overwritten it, and a diff against yourself is `= N unchanged` every time. Naming `--against` explicitly always wins, under either command.
|
|
58
|
+
- **The diff names a consent change first** (2.11.0).
|
|
59
|
+
- **Shape:** `consent` is `{ version: { from, to } | null, banners: { added, removed } }`, from the reports' `consent` keys. It is present only when the version or a banner hash differs; a site with no consent manager never gets it.
|
|
60
|
+
- **What `formatDiff` prints, above the page rows:** `! consent banner <hash> (was …): record it with kiss-ssg aikb and commit before deploying`, and `! consent version a -> b: every visitor is asked again`.
|
|
61
|
+
- **Why:** the banner hash recorded by `kiss-ssg aikb` is the dated proof of what a visitor saw, and it holds only if it is recorded before deploying. A wording change otherwise showed up only as every page changed.
|
|
58
62
|
- **The first report with a folder wins the baseline.** A script that builds several sites writes one report each, but they share a working tree and therefore one knowledge base; `diffReports` pairs the file's contents with this run's reports by `buildDir`, so a single baseline covers all of them.
|
|
59
63
|
- **The asset rows exist because a page diff can have no cause in any page source.** Under `assets.hash` a stylesheet's filename _is_ the hash of its bytes, so one changed asset rewrites the `<link href>` of every page that references it: the diff reads `~ <every page>, = 0 unchanged`, and a reader who trusts it goes looking through page sources, models and controllers for a change that is not there. Naming the asset that moved turns that into a one-line read — and the rows go **above** the page list, because a cause printed after two hundred rows of consequence is reached only by the reader who already scrolled past the thing it explains. Verified in situ on a 20-page site with a 91-entry manifest before the order was settled. Both sides of the comparison were already on disk — `BuildReport.assets` is the manifest (`AIKB/build-report.md`), and `last-build.json` keeps it — so this needed no record-format change and no new build work. Three deliberate limits: only assets **both** builds emitted are rows (a new one has no previous name to have moved from, and is the asset copy working rather than a fact that explains anything); nothing prints when no page moved, because the rows explain a diff rather than being one; and the list caps at ten as a safety valve rather than a display limit — only `.css`/`.js` are hashable at all, and under `assets.version` nothing is renamed so every entry records `target === source` and no row can ever fire, which means a real site moves one or two and the cap only bounds a site that bundles per page. **The limitation worth knowing rather than fixing:** because it compares targets, an _unhashed_ asset whose bytes changed — an edited image — is not a row, since its `source` and `target` are both unchanged. That is the right trade: an unhashed asset is not referenced by a name that appears in every page's HTML, so it never produces the all-pages-changed symptom these rows exist to explain. Found by a consuming site whose whole-site diffs were being caused by its stylesheet — see the Tailwind notes in `AIKB/pipeline.md`.
|
|
60
64
|
- **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
|
package/AIKB/config.md
CHANGED
|
@@ -7,14 +7,17 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
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
|
-
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
12
|
+
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
13
13
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
|
14
14
|
- `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`.
|
|
15
|
-
- `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`),
|
|
15
|
+
- `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`).
|
|
16
16
|
- `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
|
|
17
17
|
- `resolveMarkdownCopies(value)` → `{ write, selector }`. The `resolveAudit` shape, except that `true` is a statement rather than the default: `undefined` is the default block, `true` is `{ write: true, … }` (always), `false` is `{ write: false, selector: 'main' }`, a plain object is merged one level deep over the default; anything else throws, and so does a `write` other than `true`, `false` or `'llms'`, or an empty `selector`. Exported because a page's own `config.markdownCopies` arrives as the page wrote it — `false` on one page — and the page registry resolves it with this. An already-resolved block resolves to itself.
|
|
18
|
+
- `HOSTS` — frozen table, `host` → `{ redirects, cacheHeaders }`: the format each concern takes when the site names that host and leaves the concern's own `format` unset. `null` in a cell (`vercel`'s cache headers) means kiss has no encoding yet, and the writer warns. `'none'` (`github-pages`) means the host cannot be configured, which is a fact, and silent. `config.host` is resolved to a deduplicated list (`resolveHost`), and a name not in the table throws, with every known name listed. `formatsForHosts(hosts, concern)` gives the list a concern derives. **`host` is never read for `links.trailingSlash`**: Firebase's slash policy is set in `firebase.json`, not by the host's name.
|
|
19
|
+
- `DEFAULT_CACHE_HEADERS` — frozen object, the cache-header block: `format: null`, which follows `host`, or writes the list alone when `host` is unset too. `CACHE_HEADER_FORMAT_NAMES` comes from `lib/cache-headers.js` `CACHE_HEADER_FORMATS`, plus `'none'`. An unknown name throws at `resolveConfig`, as the redirects stance does.
|
|
20
|
+
- **A concern the site set keeps its value; one it left unset follows `host`** (`resolveConfig`, after both formats are checked). The derived value is a list, so a derived `[]` or `['none']` is a decision, and the redirects writer's `unset` warning stays silent for a `github-pages` site.
|
|
18
21
|
- `DEFAULT_REDIRECTS` — frozen object, the redirect block: `format: null`, meaning no host encoding — the IR alone. It also takes a **list**, so `['netlify', 'firebase']` emits both host files. `REDIRECT_FORMATS` is the frozen list of accepted names (`'netlify'`, `'firebase'`, `'vercel'`, `'none'`); a function is also accepted, as a custom writer (`AIKB/redirects.md`).
|
|
19
22
|
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
|
|
20
23
|
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
@@ -23,7 +26,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
23
26
|
|
|
24
27
|
## Depends on
|
|
25
28
|
|
|
26
|
-
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids).
|
|
29
|
+
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids); `./consent.js` (`resolveConsent`, the `consent` block).
|
|
27
30
|
|
|
28
31
|
## Depended on by
|
|
29
32
|
|
|
@@ -32,6 +35,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
32
35
|
## Non-obvious behavior
|
|
33
36
|
|
|
34
37
|
- **`packages` is a block or `false`.** `resolvePackages` merges `config.packages` over `DEFAULT_PACKAGES` (`{ format: 'imscp', dir: 'packages', title: null, only: null, schemas: false }`) and throws for an unknown format, a `dir` that is not a string, a `title` that is not a function, an `only` that is not a list of strings, and a `schemas` that is not `true` or `false`: each would otherwise be found only when the packages are written, after every page. `false` is kept as `false`, not merged, so the writer can tell "off" from "on with defaults"; `true` and nothing are the defaults. Any other value that is not a plain object (a string, an array, a number) throws rather than being spread: `packages: 'scorm12'` spread as the string's character indices, validated, and wrote IMS content packages for an author who asked for SCORM (code review, 2026-10-25). Packaging itself is by convention: the block only applies when a page names a `package` (`AIKB/packages.md`).
|
|
38
|
+
- **`consent` is resolved in its own module, and is `null` by default.** `resolveConsent` (`lib/consent.js`, `AIKB/consent.md`) owns every rule of the block: presets, categories in use, the version, and refusing unknown keys at every level. That is a deliberate exception to this module's unknown-keys-pass-through rule, because inside `consent` a typo such as `endpont` would silently mean no consent records (operator, 2026-11-03). `DEFAULT_CONFIG.consent` is `null`, which is what `llms.txt` and `GUIDE.md` show in their default blocks.
|
|
35
39
|
- **`cleanBuild` has three values, and the two that clean do it at different moments.** `true` (the default) calls `fs.emptyDirSync(folders.build)` in `_setupFolders`, i.e. **in the `Kiss` constructor** — before any model resolves and before any page renders — and nothing rolls it back if the build then fails. So a re-run of a build folder that holds published output destroys it first and, on a failure, leaves it empty or half-built (consumer review finding B-3). `false` never cleans. `'atomic'` builds into a staging sibling and swaps it in only when `complete()` resolves; the old output is untouched until that moment and survives a failed build. See `AIKB/kiss.md` for the staging mechanics — this module only carries the value.
|
|
36
40
|
- **The value is validated, not coerced.** Anything but `true`, `false` or `'atomic'` throws out of `resolveConfig` (so out of `new Kiss`), including `null` and `0`. A silently-falsy value is exactly the D-07 failure mode in a new coat: `{ cleanBuild: opts.clean }` with an unrecognised flag would quietly stop cleaning, or quietly stop staging.
|
|
37
41
|
- **Source/output safety is checked before any filesystem mutation**, for every `cleanBuild` value. `folders.src` cannot resolve to the working project root or a filesystem root. Neither can `folders.build`, regardless of whether `src` is disabled or elsewhere. The build must not equal or contain any configured source folder (`src`, its six derived folders, `helpers`, `aikb`), nor be nested inside an individual content folder. Only `src` allows output below it: `src/public` stays valid if separate from every content folder. Null folders are excluded.
|
|
@@ -70,6 +74,7 @@ The typedefs that describe the config to a consumer live at the top of this modu
|
|
|
70
74
|
- **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
|
|
71
75
|
- **`links.canonical` is off by default, and that is the whole design decision.** Turning it on changes the URL every bare `{{link}}` emits, which is a site's entire internal link graph and every `href` in its published output — a major-version change, not a default. It exists because the alternative discipline does not scale: a site that is not `extensionLess`, served by a host that redirects `/about.html` to `/about`, needs `canonical=true` on every single call or it ships a redirect hop per link, and one forgotten call is silent in dev and visible only on the deployed site. A real consuming site was writing `canonical=true` sixty-six times and carrying a `CLAUDE.md` paragraph explaining that it was mandatory. One key here replaces that. The `extensionLess: true` route solves the same problem and is better greenfield, but it changes every output path and therefore the relationship between the built files and the `aliases` a site carries for its old URLs — which is why this is the key and not a nudge towards that flag. Landing it needed no new config surface: the block was already shaped for exactly this by the comment above it.
|
|
72
76
|
- **`audit` takes a boolean shorthand, and an unknown check id throws.** `audit: false` is the one-word way to turn a whole advisory pass off, so it is accepted where `links` is not; anything else that is not a plain object is refused rather than read as truthy. The ignore list is validated against `lib/audit.js`'s `CHECKS` for the `redirects.format` reason: a misspelled id that ignored nothing would leave the finding on every build while the author believed it switched off. The ids are imported, never repeated here, so a check cannot be accepted by the config and unknown to the audit.
|
|
77
|
+
- **`audit.privacyPage` is type-checked here, and its existence is checked by the audit.** `null` or a non-empty string; anything else throws out of `resolveConfig`, naming the value. Whether a page has that id is only known once the build has run, so an id no page claims is not a config error: it is a site-wide `privacy-link-missing` finding (`AIKB/audit.md`).
|
|
73
78
|
- **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `markdown`, `links`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
|
|
74
79
|
- **The index signature is on the config and never on `folders`.** An unknown config key is legitimate — anything a site passes through reaches its views as `{{config.x}}` — so it types as `any` rather than an error. A `folders` key is not: `{ folders: { biuld: './out' } }` is a typo that silently builds to the default folder, so `KissFoldersInput` stays closed and TypeScript reports it. `test/fixtures/consumer/bad.js` pins that difference.
|
|
75
80
|
- `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
|