kiss-ssg 2.3.0 → 2.4.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.
@@ -19,7 +19,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
19
19
  - `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
20
20
  - `aikb` — `null` when the site has no knowledge base to report on, and otherwise `{ folder, written, notes: { missing, dead, stale, dangling }, subjects }` as `lib/aikb.js` evaluated it (`AIKB/aikb.md`). Appended after `llms`, for the same reason. It is passed through **unmapped**: the knowledge base is source-side and committed, so it is never staged and its paths are already the ones a person would type.
21
21
  - `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, broken: [{ page, href }] }` 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.
22
- - `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved }` 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`.
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
24
  - `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 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 four link/redirect findings: ` broken link: <page> -> <href>`, ` 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. What `kiss-ssg check --summary` prints instead of the JSON.
25
25
  - `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.
@@ -46,8 +46,10 @@ Nothing.
46
46
  - **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
47
47
  - **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
48
48
  - **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
49
- - **The last three keys are appended, and `null` is not the same as empty.** `links`, `redirects` and `feed` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
50
- - **`redirects.file`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map.
49
+ - **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. `lib/robots.js` has the rest.
50
+
51
+ - **The last four keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
52
+ - **`redirects.file`, `redirects.json`, every `redirects.files[]`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved`/`rules` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map. `rules` is URL paths on both sides for the same reason.
51
53
  - **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
52
54
 
53
55
  ## Types
package/AIKB/config.md CHANGED
@@ -7,11 +7,12 @@ 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`), `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`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
11
11
  - `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
12
12
  - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) 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
- - `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), and `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`).
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_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`).
15
16
  - `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, trailing slashes stripped.
16
17
  - `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. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on a build folder that contains the source folder (all three below).
17
18
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
@@ -53,6 +54,12 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
53
54
 
54
55
  The typedefs that describe the config to a consumer live at the top of this module, beside the defaults they describe: `KissFolders` / `KissFoldersInput`, `KissFetch`, `KissAssets` (whose `pipeline` refers to `lib/pipeline.js`'s `PipelineStep`, so the shape a site writes and the shape that is run cannot drift apart), `KissSettings`, and the two the public API is written in terms of — `KissConfig` (resolved: every key present) and `KissConfigInput` (what `new Kiss(config)` takes: every key optional). They are one JSDoc block, not a module of their own, so the shape and the resolver cannot drift apart.
55
56
 
57
+ - **`format: null` is the default and is not the same as `'none'`, though they emit the same files.** `null` means "never chose", and a build with aliases under it gets one notice naming the fix — because every version before this one always wrote `_redirects`, and losing a host's redirects in silence on upgrade is the failure the block exists to abolish. `'none'` and `[]` mean "chose the IR alone" and say nothing. The distinction lives in the config rather than in a hidden flag precisely so a site can express it.
58
+
59
+ - **`redirects.format` is validated, `markdown` is not, and the difference is the failure mode.** An unknown Remarkable option is a legitimate renderer option kiss has not heard of; an unknown redirect format is a typo that would write no host file, report success, and leave every old URL 404ing. `assertRedirectFormatIsValid` therefore refuses it with the offending value in the message, the way `cleanBuild` is refused — checking **every entry of a list**, so one typo in an array of four is named rather than silently dropped — while still carrying unknown _sibling_ keys through the one-level merge, so a later `statusCode` is an addition rather than a breaking change.
60
+
61
+ - **`links.trailingSlash` defaults to `true` because a default cannot be neutral here.** Two mainstream hosts serve directory indexes at contradictory URLs (measured; the matrix is in `AIKB/utils.md`), so the key is not a preference but a statement of fact about a deployment. `true` is 2.3's behaviour, and a1k9training's live sitemap returns 200 on all 18 entries under it — moving the default would have turned six of them into redirects on a site that was already correct.
62
+
56
63
  - **`markdown` is a passthrough, not a schema.** The three keys in `DEFAULT_MARKDOWN` are the ones kiss has an opinion about, not the ones it accepts: `resolveConfig` merges the block one level deep and `lib/kiss.js` spreads the result straight into `new Remarkable(...)`, so `typographer`, `langPrefix` or any other Remarkable option reaches the renderer without kiss knowing the name. That is why the typedef is `KissMarkdown & Record<string, any>` rather than a closed shape — unlike `folders`, where a typo must be an error, an unknown key here is a legitimate renderer option.
57
64
  - **`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.
58
65
  - **`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.
package/AIKB/feed.md CHANGED
@@ -28,5 +28,5 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
28
28
  - **The `url` is the sitemap's `<loc>`, character for character** — a home page (`https://e.com/`), a file page (`/about`) and a directory index (`/courses/`) alike, because the derivation is the same two `utils` functions. An RSS `<guid isPermaLink="true">` that disagreed with the sitemap would be a second identity for one page, and a feed reader would show the post twice after a rename.
29
29
  - **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. `lib/sitemap.js` escapes nothing at all, which is a standing bug there rather than a precedent here.
30
30
  - `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap` and `writeLlms`.
31
- - **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt` and `_redirects` are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
31
+ - **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt`, `redirects.json` and the redirect host file are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
32
32
  - `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing feed left to skip.
@@ -42,7 +42,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
42
42
  - The prototype guard is **delegated, never reimplemented**: the value comes from `options.lookupProperty(obj, field)`, the container function Handlebars passes into every helper, which is what refuses `__proto__`/`constructor` access. A raw `obj[field]` here would quietly reopen that hole, so the unit test asserts kiss's output against a plain Handlebars instance's for those keys rather than against a literal.
43
43
  - The warning is deduplicated **per page per key** through a `WeakMap` keyed on `options.data.root` — the page's own options object, a fresh one per page. A partial used twice on a page logs once; a `.pages()` fan-out of 100 pages missing the same key logs 100 lines, one each, which is the point (each names its own page). No dedupe is applied when the template was compiled without `data`.
44
44
  - `canonical` and `absUrl` do **not** own their normalisation: both go through `toAbsoluteUrl` in `utils.js`, which `lib/sitemap.js` also uses to build every `<loc>`. That is the point of the pair — a page's `<link rel="canonical">` and its own sitemap entry are produced by one function, so they cannot drift apart (review gap O5). `canonical` additionally reduces the page URL with `toCanonicalPath`, which strips the `.html` and leaves the `index` segment for `toAbsoluteUrl` to turn into a trailing slash; the sitemap does exactly the same, hence the agreement, and an integration test builds a site both ways round (`extensionLess` on and off) and asserts the two lists are equal.
45
- - **A directory index canonicalises with a trailing slash.** `courses/index.html` is served at `/courses/`; Netlify, GitHub Pages and nginx answer the bare `/courses` with a 301, and a canonical must be the URL that returns 200 — so `{{canonical}}` on that page is `https://site/courses/`, and `{{absUrl '/courses/'}}` now keeps the slash it was given rather than trimming it. It used to canonicalise through `toURLKey`, which drops the `index` segment outright and produced the redirecting URL; do not put that back. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html`, so **every** page but the home page is a directory index and its canonical ends in `/` (`courses/bronze` → `https://site/courses/bronze/`) — correct, because that is the URL the host serves, and the one place where the two `extensionLess` settings deliberately emit different URLs.
45
+ - **A directory index canonicalises with a trailing slash by default, and `config.links.trailingSlash` is what says otherwise.** `courses/index.html` is served at `/courses/` on Netlify, which answers the bare `/courses` with a 301, and a canonical must be the URL that returns 200; Firebase with `trailingSlash: false` is the exact reverse (the measured matrix is in `AIKB/utils.md`). `canonical`, `absUrl` and `link` all read the policy through one `trailingSlashPolicy()` closure, at call time rather than at registration, because a watch replay rebuilds the config object — so `{{canonical}}` on that page is `https://site/courses/`, and `{{absUrl '/courses/'}}` now keeps the slash it was given rather than trimming it. It used to canonicalise through `toURLKey`, which drops the `index` segment outright and produced the redirecting URL; do not put that back. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html`, so **every** page but the home page is a directory index and its canonical ends in `/` (`courses/bronze` → `https://site/courses/bronze/`) — correct, because that is the URL the host serves, and the one place where the two `extensionLess` settings deliberately emit different URLs.
46
46
  - **`absUrl` keeps a file extension, `canonical` does not.** `toCanonicalPath` would turn `css/site.css` into `css/site`, so `absUrl` passes its path to `toAbsoluteUrl` directly — that strips a leading slash, collapses repeated slashes and turns a trailing `index` segment into a trailing one, and otherwise leaves the path alone. A page URL is a page (its extension is a build detail); an arbitrary path is a path.
47
47
  - The `siteUrl` read is the **page's**, `root.config.siteUrl`, falling back to the instance config only when the template was compiled without a page context. `Kiss.page()` gives every page its own shallow copy of the config, so a page built for another domain (`.page({ view, config: { siteUrl } })`) gets a canonical URL on that domain. `sitemap.js` still reads the instance `siteUrl` for every `<loc>`, so such a page is the one case where the two differ — deliberately: it is the page that has been pointed elsewhere.
48
48
  - No `siteUrl` **degrades, like every other helper**: one warning and an empty string, never a throw and never a guessed domain. The warning is deduplicated per page through a `WeakSet` keyed on `options.data.root` (the `lookup` pattern), so a layout with a canonical link and a dozen `absUrl` links logs one line for that page, not thirteen. A missing `pageURL` warns separately and also renders nothing: the same canonical URL on every page is worse for a search engine than no canonical link at all.
package/AIKB/kiss.md CHANGED
@@ -51,14 +51,20 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
51
51
  - **Two explicit ids collide like two output paths; two default ids withdraw instead.** An explicit id already claimed by another entry is logged as `Page id already claimed: <id>`, recorded on `_failures` and the page is not pushed — the exact treatment `Page already processed` gives a duplicate `buildTo`, because two pages claiming one identity means a `{{link}}` to it could only ever have meant one of them. A **default** id two pages arrived at must not fail: one view rendered by two `.page()` calls is how pagination is written today, and every site that builds must keep building. So neither page keeps it, one `notice` names both views and asks for an explicit id, and a `{{link}}` to it fails the page that asked. Explicit beats default: the explicit page keeps the id, the default withdraws with a notice naming both — deterministic, and fixable by naming one page.
52
52
  - **The id index is lazy, memoised against the live stack, and never a snapshot.** `_idIndexFor()` builds `{ byId, withdrawn }` from `_stack` on demand; `_preparePage` sets `_idIndex = null` after every push, and `_replay()` resets it (and `_idNoticed`) beside `_report`. Two things make that necessary rather than tidy. Pages are pushed one at a time and can be registered after a render pass has begun, so an id stamped onto an entry at push time would let the first page keep an id the second is about to collide with — and any page rendered in between would have linked it successfully. And `_replay()` replaces `_stack` with a **new array**, so anything holding the old one (or an index built at helper-registration time, which happens once per instance in the constructor and is never re-run) would serve the first build's registry for the life of the watch session. The helper therefore gets `lookupPage: (id) => this._lookupPage(id)` — a bound function, resolved per call. `_idNoticed` keeps a collision notice to one line per id per build, because the index is rebuilt on every registration.
53
53
  - **What can be linked is "any page registered before the first `.generate()` call".** `generate()` captures `Promise.all(this._promises)` synchronously and iterates `_stack` inside the `.then`, so a page registered afterwards is seen only if its model chain happened to settle in the microtasks before that `.then` ran: a page registered synchronously after the call resolves, and one registered a macrotask later does not (it is still _built_, by `_generatePending()` in `_settle`). That is order-dependent and **unsupported** rather than a rule, and it fails loudly — the linking page throws rather than shipping a guessed href. Both halves are pinned by tests in `test/integration/link-helper.test.js`.
54
+ - **The module re-exports the redirect renderers, and that is a deliberate widening of the public surface.** `renderRedirects`, `renderRedirectsJson`, `renderFirebaseRedirects`, `renderVercelRedirects` and `renderHtaccessRedirects` pass straight through from `lib/redirects.js`. The `exports` map has one entry by design, so a named export here is the _only_ way anything reaches a consumer — and a `config.redirects.format` writer that cannot reuse an encoding kiss already has is an extension point with nothing to extend. Each name is now a semver promise; `test/unit/types.test.js` asserts all five survive into `types/kiss.d.ts`, and `test/fixtures/consumer/site.js` uses one under `tsc --noEmit` so a typed consumer proves it rather than the source claiming it.
55
+
56
+ - **`_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.
57
+
58
+ - **`.robots()` reads `_sitemapRequest` inside its queued `.then()`, not at call time.** That is what makes `.robots().sitemap()` and `.sitemap().robots()` produce the same file: both requests are recorded synchronously while the chain is being written, and the check runs after `Promise.all(this._promises)` has drained. Reading it at call time would make the `Sitemap:` line depend on the order a site happened to chain two methods in, which is not a thing an author should have to know. `_robotsRequest` is re-issued by `_replay()` beside the other three standing requests; `_robotsResult` resets with the rest, because it is a result rather than a request.
59
+
54
60
  - **A withdrawn default id is `null` on the record, not the string.** `_stackForRecord()` projects the stack before `buildReport` and `buildSiteMap` see it, nulling the id of any non-explicit entry whose id was withdrawn. `site-map.json`'s rows are read as the `{{link}}` autocomplete, so an id on the map must be one the helper will resolve; the alternative — reporting an id two pages share — would also hand `lib/redirects.js`'s id pairing an ambiguous key. The projection is a shallow copy per affected entry, so the live stack keeps its ids and a later registration can still change the answer.
55
61
  - **After a promotion the instance is an ordinary one.** `_promote()` points `config.folders.build` back at the real folder and re-points every stack entry's `buildDir`/`buildTo`, so a second build on the same instance — or a watch rebuild — writes into the promoted folder instead of a staging sibling nothing will ever swap in again. `_stagedPath()` covers the one write that does not read `folders.build`: a `copyAssets()` the consumer aimed explicitly at the build folder (or a subfolder of it, the metacarpus idiom) is rewritten into staging, or the promotion's `fs.remove(target)` would delete the files that copy had just made. A copy aimed anywhere else is left where it was asked for.
56
62
  - **`'atomic'` is a one-shot-build feature, and dev mode says so.** With `dev: true` the promotion point is the wrong shape — the dev server serves `folders.build` from construction and every rebuild would have to promote — so `_setupFolders()` degrades `'atomic'` to `true` (wipe at construction, build in place) and logs one `notice` saying that atomic promotion applies to one-shot builds. A **non-dev** `.watch()` is the one seam left: it stages, promotes at the first successful `complete()`, and behaves as an ordinary watch after that. A build that never calls `complete()` never promotes, and `close()` removes the staging folder — which is also what clears a discarded one that a later write recreated (`_discardStaging` deliberately keeps `_stagingDir` set for exactly that).
57
- - **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`, `_redirectsPath`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` with `_failures`, 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`.
63
+ - **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`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` with `_failures`, 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`.
58
64
  - **Three report keys are wired here before the modules that fill them exist.** `_finishBuild()` passes `links: this._links`, `redirects: this._redirectsResult` and `feed: this._feedPath` into `buildReport`, `links` is filled by `_checkLinks()`, `redirects` by `_writeRedirects()`/`_redirectFindings()` (both below) and `feed` by `.feed()` — the three modules that fill the seam have all landed, and each key is still `null` on a build that did not do that piece of work, which is not the same as "it did it and found nothing". The fields are the whole seam: `_links` and `_redirectsResult` are the findings, `_redirectsPath` and `_feedPath` are the files as written — staging paths under `'atomic'` and under check, mapped into the report by `buildReport` exactly as `_sitemapPath` and `_llmsPath` are — and `_feedRequest` is the standing request a replay re-issues. That last one is the one field `_replay()` deliberately does **not** reset: like `_sitemapRequest` and `_llmsRequest` it is the request rather than the result, and clearing it would leave the re-issue block with nothing to re-issue.
59
65
  - **A production build starts loading the HTML minifier in the constructor.** `preloadMinifier()` (`AIKB/kiss-page.md`) is called, unawaited, as soon as the config is resolved and `config.dev` is known to be false — before `_setupFolders()`, the asset copy and partial registration — so the minifier's ~155ms import overlaps that work and model resolution instead of sitting on the critical path at the first render. Check mode forces `dev: false` first, so a check preloads too. Dev never minifies and never loads it.
60
66
  - **The link scan runs in `complete()`'s settle path, above the branch — not in `_finishBuild()`.** `_checkLinks()` is the only consumer of `lib/links.js` here, and where it is called is the whole design. By the time `_finishBuild()` runs, two of `complete()`'s three branches have already called `_discardStaging()` (check mode, and any failed build) and the third has already called `_promote()` — so a scan that resolved against `config.folders.build` there would have found an empty or renamed folder and reported `checked: 0, broken: []` in silence, under exactly the command the finding exists to serve. Called before the branch instead, the written pages are still where they were written, whichever way the build is about to settle; one call site dominating all three arms is also what keeps a fourth from quietly forgetting it. It needs no ordering against `sitemap.xml`, `llms.txt` or a feed: those are written on the promise queue, which `_settle()` has already drained. The references themselves were extracted at **write** time (`KissPage.links`, `AIKB/kiss-page.md`), so nothing is read back from disk; the filesystem is consulted only for the rest of the build folder — assets, those generated files, anything a pipeline step wrote straight into it. The scan is skipped entirely when `config.links.check` is false or `config.dev` is on (and therefore on every watch rebuild: a scoped re-render has not rewritten every page, so a scan would be describing a half-built site), leaving `_links` `null` — which the report distinguishes from "it ran and found nothing". The result is latched on `_links` for the reason `_report` is latched: the nested-`complete()` pattern settles one build twice, and the second pass would be scanning a folder that is no longer there. `buildDir` is `config.folders.build` **as it is at that moment** — the staging sibling under `'atomic'` and under check — and the pages are recorded as the staging paths they were written to, which is why `buildReport` maps `broken[].page` back through `reportedPath` and the report names the folder the site asked for. Pages are taken from `_stack` entries whose `page.hash !== null`: that is the "these bytes are on disk" promise, and `generate !== false` is not (a page can also have failed to render). A build that failed is scanned too — its surviving pages are on disk and their dead links are real — and the finding moves neither `ok` nor the exit code. One `notice` per broken link, in `formatReport`'s own wording, so the build log and `check --summary` say the same thing; one `info` line when there is nothing to report.
61
- - **`_redirects` is written in the same settle path, one line above the scan, and the findings are not.** There is no `.redirects()` method: an alias is a property of a page, not a file the author asks for, so the write has to be automatic — and the only two honest places for it are the promise queue and here. The queue (`Promise.all(this._promises)` pushed onto `_generating`, the `.sitemap()` shape) needs a call site, and the only one that exists on every build is the constructor, where `_promises` holds the asset copy and not one page: the write would run against an empty stack. So `_writeRedirects()` sits at the top of `complete()`'s settle path instead, above the three-way branch, where `_settle()` has already drained the queue (`sitemap.xml`, `llms.txt` and the feed are on disk) and nothing has yet been discarded or promoted. That ordering is load-bearing twice over: written after `_promote()` the file would land in the **promoted** folder, breaking the "the whole folder appears at once" promise with a window where the site is live with none of its redirects; written after `_discardStaging()` it would _resurrect_ the staging folder `fs.outputFile` creates parents for, leaving a stray `<build>.kiss-staging-…` beside published output under `kiss-ssg check`. It also comes free on replay, because `_replay()` ends in `complete()` — which is why `_redirectsRun`, the per-build promise latch that stops the nested-`complete()` pattern writing the file twice, is reset there beside `_redirectsPath`. Unlike the link scan it runs in dev too: nothing serves `_redirects` locally, but it costs one small file and keeps "what the build folder holds" from depending on the mode, where a scoped re-render genuinely has not rewritten every page. A write that rejects is a **build failure**, not an advisory: the `.catch` pushes `{ view: '<redirects>', buildTo: null, error }` onto `_failures`, one line above the settle branch that reads it, so the staging folder is discarded and the previous deployment stays live. That is where `_redirects` parts from `sitemap.xml`, `llms.txt` and the feed, whose write failures are logged and swallowed — a site published without its sitemap is a site search engines index a little later, but a site published without its redirects is a site whose old URLs 404, which is the loss the aliases exist to prevent. The **findings** are pure data and stay in `_finishBuild()`, before `buildReport`: `_redirectFindings()` re-collects the rules from the stack (the same pure function over the same stack, so the file and the count cannot disagree), reads the baseline through `_lastBuildRecord()` and latches `{ file: _redirectsPath, aliases, removed, collisions, moved }` on `_redirectsResult`, or `null` when there is nothing to say — no alias and no finding, which since `moved` landed means `rules.length === 0 && removed.length === 0 && moved.length === 0` (a move is the one finding a site with no alias anywhere can still have). The pages it hands `redirectFindings` are `_stackForRecord()`'s, not the raw stack's, so the `id` the pairing keys on is the same projected id the report and the site map show — a default id withdrawn from two pages is `null` on both sides and cannot pair with an explicit id of that name in the baseline. Each move logs one `notice` carrying its fix (`moved without redirect: <from> -> <to> (<id>) — add "<from>" to that page's aliases`), where the summary line prints the finding without the fix. That last rule is what keeps `last-build.json` byte-identical across two identical records: an empty verdict issued whenever a record merely _exists_ would make a site's first record (written before there is a baseline) differ from its second (written after) with nothing about the site having changed. The baseline is gated on `<folders.aikb>/last-build.json` **existing**, never on `isRecorded()` — that answers "is there a `site-map.json`", which `_buildAikb()` wrote one line earlier, so on a site's first `kiss-ssg aikb` it is true while the record is still absent — and it is read _before_ `writeLastBuild()` replaces it, so a recording run is measured against the previous record rather than against itself. An unreadable record is no baseline rather than a build failure. One `notice` per finding, in `formatReport`'s wording; neither moves `ok` or the exit code. `AIKB/redirects.md` has the derivation half.
67
+ - **The redirect files are written in the same settle path, one line above the scan, and the findings are not.** There is no `.redirects()` method: an alias is a property of a page, not a file the author asks for, so the write has to be automatic — and the only two honest places for it are the promise queue and here. The queue (`Promise.all(this._promises)` pushed onto `_generating`, the `.sitemap()` shape) needs a call site, and the only one that exists on every build is the constructor, where `_promises` holds the asset copy and not one page: the write would run against an empty stack. So `_writeRedirects()` sits at the top of `complete()`'s settle path instead, above the three-way branch, where `_settle()` has already drained the queue (`sitemap.xml`, `llms.txt` and the feed are on disk) and nothing has yet been discarded or promoted. That ordering is load-bearing twice over: written after `_promote()` the file would land in the **promoted** folder, breaking the "the whole folder appears at once" promise with a window where the site is live with none of its redirects; written after `_discardStaging()` it would _resurrect_ the staging folder `fs.outputFile` creates parents for, leaving a stray `<build>.kiss-staging-…` beside published output under `kiss-ssg check`. It also comes free on replay, because `_replay()` ends in `complete()` — which is why `_redirectsRun`, the per-build promise latch that stops the nested-`complete()` pattern writing the file twice, is reset there beside `_redirectsPath`. Unlike the link scan it runs in dev too: nothing serves `_redirects` locally, but it costs one small file and keeps "what the build folder holds" from depending on the mode, where a scoped re-render genuinely has not rewritten every page. A write that rejects is a **build failure**, not an advisory: the `.catch` pushes `{ view: '<redirects>', buildTo: null, error }` onto `_failures`, one line above the settle branch that reads it, so the staging folder is discarded and the previous deployment stays live. That is where `_redirects` parts from `sitemap.xml`, `llms.txt` and the feed, whose write failures are logged and swallowed — a site published without its sitemap is a site search engines index a little later, but a site published without its redirects is a site whose old URLs 404, which is the loss the aliases exist to prevent. The **findings** are pure data and stay in `_finishBuild()`, before `buildReport`: `_redirectFindings()` re-collects the rules from the stack (the same pure function over the same stack, so the file and the count cannot disagree), reads the baseline through `_lastBuildRecord()` and latches `{ file: _redirectsPath, aliases, rules, json: _redirectsJsonPath, format: _redirectsFormat, files: _redirectsFiles, removed, collisions, moved }` on `_redirectsResult`, or `null` when there is nothing to say — no alias and no finding, which since `moved` landed means `rules.length === 0 && removed.length === 0 && moved.length === 0` (a move is the one finding a site with no alias anywhere can still have). The pages it hands `redirectFindings` are `_stackForRecord()`'s, not the raw stack's, so the `id` the pairing keys on is the same projected id the report and the site map show — a default id withdrawn from two pages is `null` on both sides and cannot pair with an explicit id of that name in the baseline. Each move logs one `notice` carrying its fix (`moved without redirect: <from> -> <to> (<id>) — add "<from>" to that page's aliases`), where the summary line prints the finding without the fix. That last rule is what keeps `last-build.json` byte-identical across two identical records: an empty verdict issued whenever a record merely _exists_ would make a site's first record (written before there is a baseline) differ from its second (written after) with nothing about the site having changed. The baseline is gated on `<folders.aikb>/last-build.json` **existing**, never on `isRecorded()` — that answers "is there a `site-map.json`", which `_buildAikb()` wrote one line earlier, so on a site's first `kiss-ssg aikb` it is true while the record is still absent — and it is read _before_ `writeLastBuild()` replaces it, so a recording run is measured against the previous record rather than against itself. An unreadable record is no baseline rather than a build failure. One `notice` per finding, in `formatReport`'s wording; neither moves `ok` or the exit code. `AIKB/redirects.md` has the derivation half.
62
68
  - **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs/router.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` below). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
63
69
  - **A staged build's `folders.build` is genuinely the staging path while it runs**, so a template rendering `{{config.folders.build}}` (or a controller reading it) sees the staging name, not the target. Nothing in the engine surfaces it — `{{asset}}` renders build-relative paths and `{{canonical}}`/`sitemap.xml` are built from `siteUrl` — but a site that prints the raw folder into its output should not use `'atomic'` for that page.
64
70
  - Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N page(s) failed to build: <buildTo paths>"` — `buildTo`, not `view`, because `.pages()` fan-out gives every failed entry the same `view` and a message built from `view` would repeat it; `err.failures[]` still carries `view` per entry). Entries with no output path — a controller failure, or a callback failure — fall back to `view` in that message. `generate()`'s own callback still fires either way — callbacks are not the render error channel; `complete()` is. `complete()` does not invoke its callback on failure (same rule as `sitemap()`'s callback).
package/AIKB/llms.md CHANGED
@@ -24,7 +24,7 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
24
24
 
25
25
  ## Non-obvious behavior
26
26
 
27
- - **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
27
+ - **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three, or loses it in all three under `config.links.trailingSlash: false`. `writeLlms` reads that policy off the config and passes it to `buildLlmsEntries`; an entry here emitting the slashed form while the canonical emitted the bare one would be the same page advertised at two URLs, one of them a redirect. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
28
28
  - **`ignoreSitemap` excludes a page from `llms.txt` too.** The AI index is a curated subset of the site the sitemap describes, never a superset: a page the author has already said should not be crawled has no business being recommended to an answer engine. `ignoreLlms` is the one-way opt-out — out of `llms.txt`, still in the sitemap.
29
29
  - **An untitled page arrives here titled `Index`.** `KissPage`'s constructor computes its default title from the _placeholder_ slug (`index`) before the real slug is set, so `prepare()` fills every untitled page's `title` with `'Index'`. That value is treated as unset and the page's own slug is title-cased instead (`puppy-classes` → `Puppy Classes`). It costs nothing for a page that really is the index — its slug title-cases to the same word — and it costs a page genuinely titled "Index" its title, which is the cheaper of the two mistakes.
30
30
  - Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
package/AIKB/redirects.md CHANGED
@@ -2,26 +2,37 @@
2
2
 
3
3
  ## Responsibility
4
4
 
5
- Server-side redirects from page `aliases`: turn every old URL a page claims into one `301` line of `<build>/_redirects`, and say what a rename left behind. Pure functions throughout plus one writer (`fs.outputFile`) — the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
5
+ Server-side redirects from page `aliases`: turn every old URL a page claims into a `301`, in the encoding the site's host reads, and say what a rename left behind. Pure functions throughout plus one writer (`fs.outputFile`) — the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
6
6
 
7
7
  All three findings are **advisory**. None of them moves `ok`, an exit code, or a page. They are things the build knows and a person cannot see: a page that vanished with no redirect, a page that changed address with no redirect, and a redirect the host is about to ignore.
8
8
 
9
- The format is Netlify's and Cloudflare Pages' `_redirects`, and only that. No `.htaccess`, no `vercel.json`, no nginx, no meta-refresh — one file, one deliberate choice.
9
+ **An alias is a portable fact; `_redirects` is one vendor's encoding of it.** Until 2.4 the module emitted only that file, which meant a site on Firebase Hosting — which ignores `_redirects` entirely and reads a `redirects` array in `firebase.json` — got a file no host reads while `report().redirects` said the redirects were written. A feature whose whole purpose is "an old URL must not 404" was reporting success while the old URLs 404ed.
10
+
11
+ So the rules come first and the encoding second. `redirects.json` — `{ version, rules: [{ from, to, status }] }` — is written on **every** build that has aliases, whatever `config.redirects.format` says, and the same array is on `report().redirects.rules`. A site whose host kiss does not encode has the fact, in a file and in the report, and needs nothing from this module to act on it.
12
+
13
+ `format` then picks the host encodings beside it, and it is **unset by default and takes a list**. Unset means the IR alone: kiss does not guess where a site is deployed. That is a change from v2.3, whose only behaviour was `_redirects`, so `writeRedirects` returns `unset: true` for a build that has aliases and chose nothing and `Kiss` turns that into one notice — an explicit `'none'` or `[]` is a decision and is silent. A list because a site can deploy to more than one host (`['netlify', 'firebase']` emits both files from one build); a bare string or function is a list of one, duplicates collapse, and order is kept. The names are: `'netlify'` (the default, `_redirects`, byte-identical to 2.3), `'firebase'`, `'vercel'` and `'htaccess'` (a **fragment to merge** — `redirects.firebase.json`, `redirects.vercel.json`, `redirects.htaccess`, never the site's own config file), `'none'`, or a writer function. kiss never reads or rewrites `firebase.json`, `vercel.json` or `.htaccess`: those files hold hosting targets, headers, rewrites and authentication, and one real site keeps 227 redirects in `firebase.json` by hand — a build step that rewrote one is a build step that can lose a site's deploy config. The `.htaccess` case is the sharpest of the three, because it is the live server config rather than a deploy manifest.
10
14
 
11
15
  ## Public interface
12
16
 
13
- - `canonicalPathFor(buildTo, buildDir)` → the page's origin-less canonical path: `/`, `/courses/`, `/about`. It is `toAbsoluteUrl('', toCanonicalPath(rel))` over the same naive `buildTo.slice(buildDir.length)` the sitemap makes.
17
+ - `canonicalPathFor(buildTo, buildDir, { trailingSlash })` → the page's origin-less canonical path: `/`, `/courses/`, `/about`. It is `toAbsoluteUrl('', toCanonicalPath(rel))` over the same naive `buildTo.slice(buildDir.length)` the sitemap makes.
14
18
  - `normaliseAlias(alias)` → the alias as it will be written: leading `/` added, `?query` and `#fragment` removed, repeated slashes collapsed, trailing slash **preserved as written**. `null` for an alias with no path in it (`''`, `'?utm=1'`) and for one with whitespace _inside_ it (`'/old post'`, `'/a\n/b'`): the file is space-separated columns and newline-separated rules, so such an alias would write more than the one rule it stands for — and on a fan-out the alias came from the record, which may have been fetched rather than typed. Dropped silently, like an empty one.
15
- - `collectAliases(stack, { buildDir })` → `RedirectRule[]` (`{ from, to }`), sorted by `from` then `to`. Reads `entry.page.options.aliases` — an array, or a bare string — from every stack entry that is not `generate: false`; `to` is that entry's `canonicalPathFor`. One page listing an alias twice yields one rule; two pages claiming one `from` yield two.
16
- - `renderRedirects(rules)` → the file's text: one `<from> <to> 301` per line, sorted again, newline-terminated, `''` for no rules.
17
- - `coveredByAlias(rel, fromSet)` → whether an alias already answers for a build-relative old **file** path. The match is made **in canonical form** (`/old-post.html` → `/old-post`, `/old/index.html` → `/old/`), which is the form a rule's source and target are both written in; an alias spelled as the file (`/old-post.html`) covers nothing. One predicate, used by `removed` and by `moved`, so the two can never disagree about one page.
18
- - `redirectFindings({ rules, currentPages, previousPages, buildDir })` → `{ removed, collisions, moved }`, all three sorted. `currentPages` is `[{ buildTo, id }]` for the pages this build writes; `previousPages` is the last record (`{ buildDir, pages: [{ buildTo, hash, id }] }`) or `null`. `removed` and `moved`'s `from`/`to` are **served** paths (`servedPathFor`), not file paths.
19
- - `writeRedirects(stack, { config, logger, overwrite })` → `{ status: 'none' | 'skipped' | 'written', rules }`. `none` means no page has an alias: **nothing is written**, not even an empty file.
19
+ - `collectAliases(stack, { buildDir, trailingSlash })` → `RedirectRule[]` (`{ from, to }`), sorted by `from` then `to`. Reads `entry.page.options.aliases` — an array, or a bare string — from every stack entry that is not `generate: false`; `to` is that entry's `canonicalPathFor`. One page listing an alias twice yields one rule; two pages claiming one `from` yield two.
20
+ - `renderRedirects(rules)` → the Netlify/Cloudflare Pages text: one `<from> <to> 301` per line, sorted again, newline-terminated, `''` for no rules.
21
+ - `renderRedirectsJson(rules)` → the host-neutral IR: `{ version: 1, rules: [{ from, to, status: 301 }] }`, pretty-printed and sorted, so two identical builds write identical bytes.
22
+ - `renderHtaccessRedirects(rules)` → the Apache fragment: one `Redirect 301 <from> <to>` per line, sorted. mod_alias `Redirect`, **not** `RewriteRule` — a rewrite's left-hand side is a regex, so an alias containing a `.` or a `+` would match paths it does not name.
23
+ - `renderFirebaseRedirects(rules)` / `renderVercelRedirects(rules)` → a fragment to merge: `{ redirects: [{ source, destination, type: 301 }] }` and `{ redirects: [{ source, destination, permanent: true }] }`. Vercel's `permanent: true` is a 308 — the permanent redirect that platform offers, and the closest honest equivalent of the `301` every other format writes.
24
+ - `normaliseWriterOutput(returned, buildDir)` → a custom writer's return value as `[{ file, contents }]`, with `file` resolved under the build folder. A writer returning nothing writes nothing. An absolute path, or one climbing out with `..`, **throws**: a redirect writer is site code running in a build, and "write any file on this machine" is not the capability it asked for.
25
+ - `coveredByAlias(rel, fromSet, { trailingSlash })` → whether an alias already answers for a build-relative old **file** path. The match is made **in canonical form** (`/old-post.html` → `/old-post`, `/old/index.html` → `/old/`), which is the form a rule's source and target are both written in; an alias spelled as the file (`/old-post.html`) covers nothing. One predicate, used by `removed` and by `moved`, so the two can never disagree about one page.
26
+ - `redirectFindings({ rules, currentPages, previousPages, buildDir, trailingSlash })` → `{ removed, collisions, moved }`, all three sorted. `currentPages` is `[{ buildTo, id }]` for the pages this build writes; `previousPages` is the last record (`{ buildDir, pages: [{ buildTo, hash, id }] }`) or `null`. `removed` and `moved`'s `from`/`to` are **served** paths (`servedPathFor`), not file paths.
27
+ - `resolveRedirectFormats(format)` → the host encodings to emit, as a list: `[]` for `undefined`, `null` and `'none'`; a bare string or function as a list of one; an array kept in order with duplicates collapsed and every `'none'` dropped.
28
+ - `writeRedirects(stack, { config, logger, overwrite })` → `{ status: 'none' | 'skipped' | 'written', rules, files, formats, unset }`. `none` means no page has an alias: **nothing is written**, not even an empty IR. `files` is every path written, `redirects.json` first; `formats` is the list of names that ran, `'custom'` for each writer function — a machine-readable report should not carry a source-code detail — and `[]` when only the IR was written. `unset` is the upgrade flag: aliases exist, no host file was emitted, and the site never chose. `overwrite: false` protects each target independently, so an existing `_redirects` a project keeps in `src/assets/` survives while the IR beside it is still written.
20
29
  - The `RedirectRule`, `RedirectFindings` and `RedirectWriteResult` typedefs. `RedirectFindings.moved` is `{ id, from, to }[]`, the same shape `BuildRedirects.moved` carries.
21
30
 
22
31
  ## Depends on
23
32
 
24
- `fs-extra` (`outputFile`/`existsSync`, in `writeRedirects` alone) and `./utils.js` (`toAbsoluteUrl`, `toCanonicalPath`, `servedPathFor`). Nothing else — no logger beyond the one it is handed, no config beyond `folders.build`, no knowledge of staging, check mode or the report.
33
+ `fs-extra` (`outputFile`/`existsSync`, in `writeRedirects` alone) and `./utils.js` (`toAbsoluteUrl`, `toCanonicalPath`, `servedPathFor`). Nothing else — no logger beyond the one it is handed, no config beyond `folders.build`, `links.trailingSlash` and `redirects.format`, no knowledge of staging, check mode or the report.
34
+
35
+ Every renderer here is **re-exported from `lib/kiss.js`**, so a custom writer composes a shipped encoding instead of reimplementing one. That is not a convenience: the package's `exports` map has a single entry, so `kiss-ssg/lib/redirects.js` is an `ERR_PACKAGE_PATH_NOT_EXPORTED` and a site literally could not reach them — an extension point nobody can build on, against this package's own stated rule that everything public is reachable from `'kiss-ssg'` itself (llms.txt). They are exported flat rather than under a `redirects` namespace because a site reads `config.redirects` in the same file and `redirects.renderRedirects(...)` inside `redirects: { format: ... }` is a sentence nobody should have to parse.
25
36
 
26
37
  ## Depended on by
27
38
 
@@ -29,6 +40,12 @@ The format is Netlify's and Cloudflare Pages' `_redirects`, and only that. No `.
29
40
 
30
41
  ## Non-obvious behavior
31
42
 
43
+ - **Every redirect is a `301`, and the IR's `status` field does not change that.** The value is written into `redirects.json` so a consumer has the code in the data rather than hardcoding it, and it sits on the rule rather than on the file because that is where a `302`, `308` or `410` would belong if the surface grew one. It has not: `aliases` is a bare array of path strings, `normaliseAlias` takes a string, and all four encoders hardcode their permanent form (`301`, `type: 301`, `permanent: true`, `Redirect 301`). The field is a place, not a capability — read it as documentation of what kiss emits, never as a knob. Netlify's forced (`!`) rules, wildcards and splats are absent for the same reason: nothing in the page surface can express them.
44
+
45
+ - **A custom writer that throws fails the build, deliberately.** `writeRedirects` awaits it inside the promise `Kiss._writeRedirects()` catches, so a rejection lands on `_failures` as `<redirects>` and the staging folder is discarded. Swallowing it would rebuild the exact trap this block was written to close — a redirect feature reporting success while doing nothing — one level up, in the site's own code.
46
+
47
+ - **An unknown `format` name throws at `resolveConfig`, not at write time.** `'firbase'` would otherwise match no built-in, write no host file, and leave every old URL 404ing while the report looked healthy. `lib/config.js` refuses it with the offending value in the message, the way `cleanBuild` is refused.
48
+
32
49
  - **The alias target is `toAbsoluteUrl('', toCanonicalPath(rel))`, never `toCanonicalPath` alone.** `toCanonicalPath` only drops the last segment's extension; the `index` collapse and the trailing slash live in `toAbsoluteUrl`. Without it a directory index targets `/courses/index` and the home page `/index`, and both 404 (or 301-chain) on Netlify and Cloudflare Pages. With an empty `siteUrl` the same join the sitemap, `llms.txt`, the feed and `{{canonical}}` all make returns the origin-less path — so a redirect can never point at a URL the sitemap does not list. There are four copies of this arithmetic in the engine and they are all one function call; do not write a fifth.
33
50
  - **A fan-out's aliases come from the record, not from the registration.** `Kiss._prepareMultiplePages` deletes the `.pages()` registration's own `aliases` from each item and promotes `model.aliases` in its place, where `slug` and `model` are promoted. Inherited, one `.pages({ aliases: ['/old'] })` would emit N rules with the same source and N different targets — a redirect whose destination depends on stack order, and a file that is not byte-stable. The reader here only ever looks at `entry.page.options.aliases`, so `.page()` and `.scan()` pass theirs through untouched. Being an ordinary page option, `aliases` also reaches the render context and the dev-mode `.json` sibling: accepted, and occasionally useful — a template may legitimately want to say "formerly at …".
34
51
  - **A non-forced rule is silently ignored when a real file exists at its source**, on both Netlify and Cloudflare Pages. That is what `collisions` is: an alias equal to a live page's canonical path (`/about`) **or** to its build-relative file path (`/about.html`) is a line that will never fire, and saying "collides" understates it — nothing happens at all, with no error anywhere. A `from` two pages both claim is the same class of nothing: only the first matching line can win, and which one that is depends on sort order. Both are reported in the one list.
package/AIKB/robots.md ADDED
@@ -0,0 +1,45 @@
1
+ # robots.js
2
+
3
+ ## Responsibility
4
+
5
+ `robots.txt`: the crawl policy a site states once, and the `Sitemap:` line that points a crawler at the sitemap this build actually wrote. Pure functions plus one writer (`fs.outputFile`) — no state, no knowledge of `Kiss`, no decision about when it runs.
6
+
7
+ **The `Sitemap:` line is the reason the module exists.** The crawler blocks above it are boilerplate a site can hand-write and usually does. What a hand-written file cannot do is stay in step with the build — and this repo was its own proof: `examples/_shared/assets/robots.txt` was a static `User-agent: *` / `Allow: /`, copied into six examples, and it never mentioned the sitemap those same examples generate. kiss shipped examples that wrote a sitemap and then failed to advertise it. That is the gap `.llms()` and `.feed()` were built for, one file further out.
8
+
9
+ **That static file is gone as of v2.4.0, and its absence is the point.** `examples/6-sitemap` and `examples/11-blog` call `.robots()` and emit a `Sitemap:` line generated from the registry; the examples that merely copied the file and have no sitemap of their own (`4`, `5`, `7`, `9`, `10`) now write no `robots.txt` at all — which is the honest outcome, because nothing in them was ever a crawl policy. `test/integration/examples.test.js` asserts the generated bytes for both, so the demonstration cannot quietly rot back into a copied file. Shipping the feature while leaving every example still not advertising its sitemap was the first version of this change, and it is the thing to not do again.
10
+
11
+ Unlike `_redirects` there **is** a method to call. A crawl policy is a statement about the whole site, not a property of a page, so nothing in the stack could imply it — and that is also what keeps a `robots.txt` a site copies from `src/assets/` safe: the file is only written when `.robots()` asks for it.
12
+
13
+ ## Public interface
14
+
15
+ - `normaliseRobotsPath(value)` → the path with a leading `/`, or `null` for an empty one or one containing whitespace. A leading `*` is left alone (a wildcard pattern, not a path).
16
+ - `collectRobotsAgents(options)` → `[{ userAgent, allow, disallow, crawlDelay }]`, always at least one block. `options.agents` wins; failing that the `userAgent`/`allow`/`disallow` shorthand builds a single block; failing that the default is one `*` block allowing `/`. Paths go through `normaliseRobotsPath`, a non-finite `crawlDelay` becomes `null`.
17
+ - `disallowsEverything(agents)` → whether any block carries `Disallow: /`.
18
+ - `collectSitemapUrls(sitemap, { siteUrl, hasSitemap, trailingSlash })` → the absolute sitemap URLs to advertise. `false` or no `siteUrl` gives none; a string or array is taken at its word (a value carrying a scheme is passed through, anything else is joined to `siteUrl`); `true`/absent gives this build's own `sitemap.xml` **only when `hasSitemap`**.
19
+ - `renderRobotsTxt(agents, sitemaps)` → the file's text: one block per agent separated by a blank line, `Sitemap:` lines last, newline-terminated.
20
+ - `writeRobots({ config, logger, options, hasSitemap, overwrite })` → `{ status: 'written' | 'skipped', text, agents, disallowAll, sitemaps }`.
21
+ - The `RobotsAgent`, `RobotsOptions` and `RobotsWriteResult` typedefs.
22
+
23
+ ## Depends on
24
+
25
+ `fs-extra` (`outputFile`/`existsSync`, in `writeRobots` alone) and `./utils.js` (`toAbsoluteUrl`). No logger beyond the one it is handed, no config beyond `folders.build`, `siteUrl` and `links.trailingSlash`.
26
+
27
+ ## Depended on by
28
+
29
+ `lib/kiss.js` only: `writeRobots` from `Kiss.robots()`, queued on `_generating` behind `Promise.all(this._promises)` like `.llms()` and `.feed()`. The result is latched on `_robotsResult` and handed to `buildReport` as the report's `robots` key (`AIKB/build-report.md`). `_robotsRequest` is the standing request `_replay()` re-issues, the same shape as `_sitemapRequest`, `_llmsRequest` and `_feedRequest`.
30
+
31
+ ## Non-obvious behavior
32
+
33
+ - **`ignoreSitemap` deliberately does NOT imply `Disallow`, and must never be wired to it.** It is the obvious-looking connection and it is actively harmful: blocking a crawler stops it fetching the page, which stops it seeing a `noindex` meta tag, so the URL can stay indexed with no snippet — worse than leaving it crawlable. Excluded-from-the-sitemap and blocked-from-crawling are different intents, and this module takes the second only from an explicit `disallow`.
34
+
35
+ - **`Disallow: /` is the sharpest edge in the package, so it is reported rather than merely written.** It removes a site from search, it is one character from the bare `Disallow:` that means the opposite, and nothing about the build looks wrong afterwards. `disallowsEverything` is what lets `Kiss.robots()` log a `notice` on **every** build that emits it and put `disallowAll` on `report().robots`, so a staging policy promoted to production shows up in a `kiss-ssg check` diff rather than in Search Console a month later. That is also why the report's `robots` key is an object where `sitemap`, `llms` and `feed` are bare path strings: a path cannot carry the fact.
36
+
37
+ - **`Sitemap:` is gated on `.sitemap()` having been called, not on the option alone.** `sitemap: true` is the default, but `hasSitemap` is what decides: advertising a `sitemap.xml` that was never written is a fetch error in every crawler that reads the line, and a site that removed `.sitemap()` should stop claiming one. `Kiss.robots()` reads `_sitemapRequest !== null` **inside** the queued `.then()` rather than at call time, so `.robots().sitemap()` and `.sitemap().robots()` give the same file — both requests are recorded synchronously on the chain, and the check runs after the queue drains.
38
+
39
+ - **The URL goes through `toAbsoluteUrl`, never a string join.** It is the one join every emitted URL in the package makes, so the sitemap a crawler is pointed at is character-for-character the one `<loc>` lives in, and it follows `config.links.trailingSlash` with the rest of them (`AIKB/utils.md`). A fifth copy of this arithmetic is the thing to not write.
40
+
41
+ - **A path with whitespace in it is dropped, not escaped.** `robots.txt` is one directive per line and there is no escape for a newline, so a `disallow` carrying one would write more rules than the one it stands for — the same injection `normaliseAlias` drops an alias for (`AIKB/redirects.md`), and the same reasoning: a `disallow` may have been built from a model rather than typed by a person. Writing a _different_ rule than the one asked for is worse than writing none.
42
+
43
+ - **A block with no rules still writes a bare `Disallow:`.** That is the format's way of saying "nothing is disallowed"; a `User-agent:` line with no directive under it is undefined in the original spec and is read inconsistently. `Disallow:` and `Disallow: /` differ by one character and mean opposite things, which is the whole reason the empty case is written explicitly rather than left out.
44
+
45
+ - **A write failure is logged, not fatal** — the opposite of `_redirects`, and deliberately. This is discovery, like `sitemap.xml` and `llms.txt`: a site published without its `robots.txt` is crawled with default assumptions. A site published without its redirects has old URLs 404ing for readers who already hold the links, which is why that one fails the build (`AIKB/kiss.md`).
package/AIKB/sitemap.md CHANGED
@@ -21,7 +21,7 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
21
21
  ## Non-obvious behavior
22
22
 
23
23
  - URL derivation from `buildTo`: strip the leading `buildDir` (`entry.buildTo.slice(buildDir.length)`), then `toCanonicalPath` (the file extension dropped, the `index` segment kept) and `toAbsoluteUrl` (joined to `siteUrl`, a trailing `index` segment becoming a trailing `/`, an empty path giving the site root with one trailing slash). **Those two functions are shared with the `canonical` helper on purpose** — a page's `<loc>` and its own `<link rel="canonical">` are built by the same code, so they cannot drift apart (`AIKB/handlebars-helpers.md`). Changing the derivation here changes both; an integration test asserts they stay equal (it builds a site with a section index under `extensionLess` on and off). The one deliberate exception is a page that overrides `config.siteUrl` for itself: its canonical URL follows the override, its `<loc>` keeps the site's own domain.
24
- - **A directory index gets a trailing slash in its `<loc>`** — `out/courses/index.html` is `https://site/courses/`, not `https://site/courses`. A static host (Netlify, GitHub Pages, nginx) serves the directory at the slashed URL and answers the bare one with a 301, so the old shape listed a redirect for every section index; the sitemap now lists the URL that returns 200. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html` and so ends in `/` too — again the URL the host actually serves. This is why the derivation goes through `toCanonicalPath` rather than `toURLKey`: the identity key drops the `index` segment outright, which is exactly what produced the redirecting URL.
24
+ - **A directory index gets a trailing slash in its `<loc>` by default** — `out/courses/index.html` is `https://site/courses/`, not `https://site/courses`. Netlify serves the directory at the slashed URL and answers the bare one with a 301, so the old shape listed a redirect for every section index; the sitemap lists the URL that returns 200. **Which one that is depends on the host**, so `buildSitemapEntries` takes `trailingSlash` from `config.links` and a Firebase-style site lists the bare form instead — see the measured matrix in `AIKB/utils.md`. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html` and so ends in `/` too — again the URL the host actually serves. This is why the derivation goes through `toCanonicalPath` rather than `toURLKey`: the identity key drops the `index` segment outright, which is exactly what produced the redirecting URL.
25
25
  - `overwrite: false` only has any observable effect when `config.cleanBuild: false` too — the default `cleanBuild: true` already empties the whole build directory (any prior `sitemap.xml` included) in the `Kiss` constructor, before `.sitemap()` ever runs, so there is nothing left to "skip" in the default configuration.
26
26
  - `writeSitemap` deliberately does **not** catch its own write errors (`fs.outputFile` can reject) — they propagate up to `Kiss.sitemap()`'s `.catch()`, which logs the error and does **not** invoke the callback. This matches v1 semantics: a sitemap write failure is silent-to-the-caller beyond the logged error.
27
27
  - `status` values and what `Kiss.sitemap()` does with each: `'no-site-url'` returns early _without_ invoking the callback (`if (status === 'no-site-url') return`); `'skipped'` and `'written'` both fall through to invoke the callback with `urls` (`null` for `'skipped'`, the array for `'written'`).
package/AIKB/testing.md CHANGED
@@ -37,12 +37,14 @@ npm run types # regenerate types/ from the JSDoc in lib/
37
37
  - `test/integration/bench-watch.test.js` is the one exception to "the I/O wrapper below the decision core stays untested": `scripts/bench.mjs`'s `--dev` watch reading spawns a real dev-mode site, drives it over a websocket and edits its source files, and none of that can be asserted from a pure function. It generates a three-file site under the OS temp dir, links this repo into its `node_modules` with a **junction** (`fs.symlinkSync(…, 'junction')` — no elevation on Windows, and it is what lets `resolveKissFrom` prove which engine was measured), picks both ports with `net.createServer().listen(0)` because the engine's defaults collide with any dev server the operator left open, and afterwards asserts the three touched files are byte-identical and the livereload port binds again — the proof that no child was left running. 60s timeout, like the other child-process suites; it runs in about three seconds.
38
38
  - **Red-first without stashing.** When more than one agent works in the tree, `git stash` to prove a new test red would clobber the other's work. The recipe the link-check branch settled on: `git archive HEAD | tar -x -C <scratch>/red-<name>`, symlink `node_modules`, copy the new test files in, run them there against HEAD (they must fail), then delete the copy. Every workstream on that branch recorded its red-first evidence this way.
39
39
  - `test/unit/skill-coverage.test.js` is the agent-facing counterpart of `test/aikb.test.js`: a table of public feature → the plugin skills that must name it (`{{link`, `links: { canonical: true }`, `aliases`, `.feed(`, the check's finding lines, `kiss-ssg aikb`, the site map's `Id` column). A feature documented in `llms.txt` but never taught to the skill an agent follows is what this catches — add a row when a public feature lands.
40
- - `test/integration/{links,link-helper,redirects,feed}.test.js` build real sites through the public API and assert on the report keys those modules fill; `test/unit/{links,redirects,feed,utils}.test.js` cover the pure derivations (reference extraction and resolution, alias coverage, served paths, RSS rendering) with injected `exists`/readers so no test depends on a fixture on disk it did not write.
40
+ - `test/integration/{links,link-helper,redirects,feed,robots,trailing-slash}.test.js` build real sites through the public API and assert on the report keys those modules fill; `test/unit/{links,redirects,feed,robots,utils}.test.js` cover the pure derivations (reference extraction and resolution, alias coverage, served paths, RSS rendering, each redirect encoding, robots directive assembly) with injected `exists`/readers so no test depends on a fixture on disk it did not write.
41
41
  - A skill's helper script under `.claude/skills/*/scripts/` gets a `test/unit/` test when it carries logic of its own: `test/unit/corpse-collector.test.js` exercises the scanner's AIKB-staleness check on a temporary git repository (`git init`, commits driven by `execFileSync` with an explicit `cwd`, never a shell string), which is why `scan.mjs` keeps its scan body under `main()` and exports the check.
42
42
  - `test/integration/examples.test.js` builds every example under `examples/` (1–11) as a child process, **each from its own folder** (`examples/3-pages/`, running `router.js`) the way `npm run egN` and a real site both run it, and asserts its exit code, its page count, and each example's distinctive behaviour (example 8's controlled failure, example 9's deduped `lookup` warning and dedupe recipe, example 10's pipeline-generated stylesheet). Every example writes into the one shared repo-root `public/`, so its tests stay in one plain (non-`concurrent`) `describe` — Vitest's default declaration order is the only sequencing this needs — and each test clears its own example's output folder immediately before building, so a page count can never be a leftover from a previous run. Skips (rather than fails) if `examples/` is absent. Example 9 is the exception to "every example writes into `public/`": `examples/9-migrated-from-v1/AIKB/` is committed source, recorded by `npx kiss-ssg aikb`, so one test runs the published bin from that example's own folder and asserts the four generated files come back byte-identical to the committed ones. That is the guard on the property the whole feature rests on: recording an unchanged site twice must leave `git status` clean. Example 8 is the counterpart — it fails on purpose, so it can never be recorded, and its test asserts no `AIKB/` appears beside it.
43
43
 
44
44
  ## Gotchas
45
45
 
46
+ - **`examples.test.js`'s byte-identical AIKB assertions self-heal, so a report-shape change fails exactly once and then passes.** Examples 9 and 11 are recorded **in place**, in the committed `examples/*/AIKB/` folders, and the test asserts the files are unchanged before and after. `last-build.json` is the whole `BuildReport`, so adding any report key rewrites both files: the first run fails and leaves them matching, and every run after it is green. Read a green re-run as evidence of nothing — the first run already healed it. The correct response is never to re-run: it is `npx kiss-ssg aikb router.js` in `examples/9-migrated-from-v1/` and `examples/11-blog/`, then commit the re-recorded files as part of the change. (Found the hard way twice on 2026-09-16, once per report key added; the first instance was committed by a `git add -A` before anyone understood what it was.)
47
+
46
48
  - `npm run lint`'s `@eslint/js` (v10) requires Node ≥22.13, even though the package's own runtime floor (`package.json`'s `engines.node`) is 22.12 — on Node 22.12.x exactly, `npm test` passes but `npm run lint` may refuse to run.
47
49
  - **`types/` is generated — never hand-edit it.** `test/unit/types.test.js` re-emits it and byte-compares, so a JSDoc change in `lib/` fails the suite until `npm run types` has been run and the result committed. The failure names the file and the command.
48
50
  - The examples (`npm run eg1` … `eg11`) and `node docs` build and exit by default, so they are safe to run bare — except example 8, which exits 1 by design. It is the `--dev` flag that starts a server and **never exits on its own** — `node docs --dev`, or `npm run eg1 -- --dev` (examples 1–6, 8, 9, 10; example 7 takes a season slug instead). Use `timeout 10 node <script> --dev` in Git Bash (or equivalent) to smoke-run that path and let the timeout kill the process.
package/AIKB/utils.md CHANGED
@@ -40,9 +40,18 @@ Small string/path helpers shared across the engine: slugification, title-casing,
40
40
  - **The directory is taken separately from the pattern so it can be escaped** (`escape()` from `glob`). A project path is a literal name, not a pattern: a folder called `site[old]` interpolated straight into a pattern is read as a character class and matches nothing, so `scan()` found zero pages and Sass compiled nothing while the build still reported success (review finding A-07). Callers therefore pass `globFiles(folder, '**/*.hbs')`, never a pre-joined string — joining it yourself puts the folder back inside the pattern and reopens the bug.
41
41
 
42
42
  - **`toCanonicalPath` and `toAbsoluteUrl` live here because two modules need them, not because they are string helpers.** `lib/sitemap.js` builds every `<loc>` with them and `lib/handlebars-helpers.js` builds `{{canonical}}` with them, so a page's canonical URL and its sitemap entry are produced by one pair of functions and cannot disagree (review gap O5). Putting the join in either caller would have made the other import a module whose job is something else.
43
- - **A directory index canonicalises with a trailing slash, and that is the whole point of `toAbsoluteUrl` keeping one.** `courses/index.html` is served by Netlify, GitHub Pages and nginx at `/courses/`; those hosts answer the bare `/courses` with a 301, and Google's guidance is that a canonical must be the URL that returns 200. `toAbsoluteUrl` therefore replaces a trailing `index(.ext)?` segment with an empty last segment (`courses/index.html` → `courses/`) instead of dropping it, and no longer trims a trailing slash it was given — `{{absUrl '/courses/'}}` is `https://site/courses/` while the bare `{{absUrl '/courses'}}` stays `https://site/courses`. Repeated slashes are collapsed on the way through. The defect this fixes was found on a real site (`a1k9training`), which was regex-patching its own sitemap after every build.
43
+ - **A directory index's trailing slash is the host's policy, and `config.links.trailingSlash` is where a site states it.** Measured live on 2026-09-16, not remembered:
44
+
45
+ | Host | Directory index | File page |
46
+ | ----------------------------------------------------------------------- | --------------- | ------------------- |
47
+ | Netlify (`www.a1k9training.co.uk`) | `/courses/` 200 | `/x` 200, `/x/` 301 |
48
+ | Firebase Hosting, `cleanUrls` + `trailingSlash: false` (`learna.ac.uk`) | `/courses` 200 | `/x` 200, `/x/` 301 |
49
+
50
+ They agree on file pages and contradict each other on directory indexes, so one has to be the default and the other has to be sayable. The default stays Netlify's (`trailingSlash: true`): all 18 `<loc>` entries in a1k9training's live sitemap return 200 under it, and moving it would turn six of them into redirects. Cloudflare Pages, GitHub Pages and bare nginx are **unverified** — check before claiming either way rather than reasoning from the three host names this doc used to list. `toAbsoluteUrl` and `servedPathFor` each take the policy as an option and **must be given the same one**: the first is every absolute URL kiss emits, the second is `{{link}}`'s href, and a site that splits them links `/courses/` while canonicalising `/courses` — taking the redirect the setting exists to avoid on every internal click. The site root is the one URL both conventions agree on: its whole path is the `index` segment, so either replacement empties it and the join returns `${siteUrl}/`.
51
+
52
+ - **A directory index canonicalises with a trailing slash by default, and that is the whole point of `toAbsoluteUrl` keeping one.** `courses/index.html` is served by Netlify at `/courses/`; that host answers the bare `/courses` with a 301, and Google's guidance is that a canonical must be the URL that returns 200. `toAbsoluteUrl` therefore replaces a trailing `index(.ext)?` segment with an empty last segment (`courses/index.html` → `courses/`) instead of dropping it, and no longer trims a trailing slash it was given — `{{absUrl '/courses/'}}` is `https://site/courses/` while the bare `{{absUrl '/courses'}}` stays `https://site/courses`. Repeated slashes are collapsed on the way through. The defect this fixes was found on a real site (`a1k9training`), which was regex-patching its own sitemap after every build.
44
53
  - **`servedPathFor` is the fourth, and it is the only one that describes what the _host_ does.** The other three are about the URL a site _advertises_; this one is the path the file is actually fetched from, which is what an in-page `href` needs: `about.html` is served at `/about.html`, not at the pretty `/about`, unless the site is extension-less (where the two coincide). Two rules make it distinct. It collapses **only `index.html`**, never another `index.<ext>` — `toAbsoluteUrl`'s `index(\.[^./]*)?` strips any extension, which would advertise an `ext: 'json'` index page at `/data/`, a directory with no `index.html` in it, and `lib/links.js` would rightly call that broken. And it is **root-relative, always**: a link to a page is a path from the site root, and the absolute form is a separate, explicit ask (`{{link … absolute=true}}`, which string-joins `siteUrl`'s origin and prefix to this) rather than something the derivation guesses. Its output is a path `resolveReference` accepts by construction — file, `<dir>/`, or `/` — which is what keeps `{{link}}` and the link checker from ever disagreeing (`AIKB/links.md`).
45
- - The three URL functions are **three, not one, and the differences matter**: `toURLKey` is page _identity_ — it drops the extension _and_ the trailing `index`, so `/courses`, `courses/` and `courses/index.html` are one key, which is what `isActive` compares and is deliberately unchanged by the trailing-slash fix; `toCanonicalPath` drops only the extension, leaving the `index` in place; `toAbsoluteUrl` keeps whatever extension it is handed — an asset URL passed to `{{absUrl "css/site.css"}}` has to survive — and turns that surviving `index` into the trailing slash. Canonicalising through `toURLKey` is exactly what produced the redirecting URL, so do not put it back.
54
+ - The three URL functions are **three, not one, and the differences matter**: `toURLKey` is page _identity_ — it drops the extension _and_ the trailing `index`, so `/courses`, `courses/` and `courses/index.html` are one key, which is what `isActive` compares, is deliberately unchanged by the trailing-slash fix, and **takes no `trailingSlash` option at all**: a nav highlight must not depend on where the site is deployed, and "unifying the three URL functions" is how that breaks; `toCanonicalPath` drops only the extension, leaving the `index` in place; `toAbsoluteUrl` keeps whatever extension it is handed — an asset URL passed to `{{absUrl "css/site.css"}}` has to survive — and turns that surviving `index` into the trailing slash. Canonicalising through `toURLKey` is exactly what produced the redirecting URL, so do not put it back.
46
55
 
47
56
  ## Types
48
57