kiss-ssg 2.2.1 → 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.
Files changed (72) hide show
  1. package/AIKB/aikb.md +4 -0
  2. package/AIKB/build-report.md +5 -3
  3. package/AIKB/check.md +3 -2
  4. package/AIKB/config.md +10 -2
  5. package/AIKB/feed.md +1 -1
  6. package/AIKB/handlebars-helpers.md +4 -2
  7. package/AIKB/kiss.md +12 -4
  8. package/AIKB/links.md +1 -1
  9. package/AIKB/llms.md +1 -1
  10. package/AIKB/partials.md +1 -1
  11. package/AIKB/pipeline.md +4 -0
  12. package/AIKB/redirects.md +26 -9
  13. package/AIKB/robots.md +45 -0
  14. package/AIKB/sitemap.md +1 -1
  15. package/AIKB/testing.md +8 -5
  16. package/AIKB/utils.md +11 -2
  17. package/README.md +209 -36
  18. package/examples/1-scan/router.js +32 -0
  19. package/examples/10-asset-pipeline/assets/css/generated.css +1 -1
  20. package/examples/10-asset-pipeline/pages/index.hbs +1 -1
  21. package/examples/{10-asset-pipeline.js → 10-asset-pipeline/router.js} +27 -13
  22. package/examples/10-asset-pipeline/tools/tokens.js +2 -2
  23. package/examples/11-blog/AIKB/last-build.json +44 -26
  24. package/examples/11-blog/AIKB/notes/controllers/tag.md +2 -2
  25. package/examples/11-blog/AIKB/site-map.json +82 -82
  26. package/examples/11-blog/AIKB/site-map.md +38 -38
  27. package/examples/11-blog/config/site.js +30 -0
  28. package/examples/{11-blog.js → 11-blog/router.js} +38 -22
  29. package/examples/{2-page.js → 2-page/router.js} +6 -9
  30. package/examples/{3-pages.js → 3-pages/router.js} +6 -9
  31. package/examples/4-layouts-and-partials/pages/index.hbs +0 -4
  32. package/examples/{4-layouts-and-partials.js → 4-layouts-and-partials/router.js} +7 -10
  33. package/examples/5-helpers/pages/index.hbs +1 -1
  34. package/examples/{5-helpers.js → 5-helpers/router.js} +9 -10
  35. package/examples/{6-sitemap.js → 6-sitemap/router.js} +16 -10
  36. package/examples/{7-versioned-outputs.js → 7-versioned-outputs/router.js} +9 -8
  37. package/examples/{8-data-fed-site.js → 8-data-fed-site/router.js} +8 -14
  38. package/examples/9-migrated-from-v1/AIKB/last-build.json +23 -26
  39. package/examples/9-migrated-from-v1/AIKB/site-map.json +46 -46
  40. package/examples/9-migrated-from-v1/AIKB/site-map.md +34 -34
  41. package/examples/9-migrated-from-v1/helpers/index.js +16 -0
  42. package/examples/9-migrated-from-v1/helpers/partials.js +33 -0
  43. package/examples/{9-migrated-from-v1.js → 9-migrated-from-v1/router.js} +19 -30
  44. package/examples/README.md +36 -19
  45. package/examples/_shared/layouts/layout.hbs +0 -4
  46. package/examples/_shared/site.js +6 -12
  47. package/lib/aikb.js +35 -5
  48. package/lib/build-report.js +36 -1
  49. package/lib/check.js +67 -0
  50. package/lib/config.js +137 -7
  51. package/lib/feed.js +15 -4
  52. package/lib/handlebars-helpers.js +50 -9
  53. package/lib/kiss.js +195 -6
  54. package/lib/llms.js +7 -2
  55. package/lib/redirects.js +320 -27
  56. package/lib/robots.js +237 -0
  57. package/lib/sitemap.js +11 -6
  58. package/lib/utils.js +41 -13
  59. package/llms.txt +80 -15
  60. package/package.json +13 -12
  61. package/types/build-report.d.ts +70 -3
  62. package/types/check.d.ts +34 -0
  63. package/types/config.d.ts +94 -4
  64. package/types/feed.d.ts +3 -1
  65. package/types/kiss.d.ts +53 -1
  66. package/types/llms.d.ts +3 -1
  67. package/types/redirects.d.ts +175 -10
  68. package/types/robots.d.ts +207 -0
  69. package/types/sitemap.d.ts +2 -1
  70. package/types/utils.d.ts +19 -6
  71. package/examples/1-scan.js +0 -33
  72. package/examples/_shared/assets/robots.txt +0 -2
package/AIKB/aikb.md CHANGED
@@ -39,6 +39,10 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
39
39
 
40
40
  ## Non-obvious behavior
41
41
 
42
+ - The dangling-reference check accepts **a page id** and rejects **a bare file extension**. A page id (`shelf/index`) has a slash and no extension, so it looked exactly like a path to a missing file — while being the single most natural thing for a note to cite, since it is the name the whole site links that page by and what the site map's `Id` column is for. A bare `` `.json` `` in a sentence like "one `.json` record per page" is prose about a kind of file and can never resolve. Four independent reports across three sites, every one of them a note written correctly and punished for it; a lint that fires on good prose trains people to stop reading it. Covered by `test/unit/aikb.test.js`.
43
+
44
+ - Note lookup is **case-tolerant on read, canonical on write**. `notePathFor` lower-cases the filename it derives, but the README the engine generates states the rule as "the controller's filename without `.js`" — so an author following it writes `jobList.md` for `jobList.js` while the engine looked for `joblist.md`, and `evaluateNotes` reported the same note `missing` _and_ `dead`. Two findings for one casing difference, and unactionable: renaming to clear either one creates the other. `evaluateNotes` now matches a note on disk case-insensitively, while a genuinely missing note is still reported at the lower-cased path, which stays the single spelling the engine writes and documents. Two notes differing only in case cannot coexist on a case-insensitive filesystem; `present` is sorted, so which one answers is stable. Covered by `test/unit/aikb.test.js`.
45
+
42
46
  - **A note may name the engine's own outputs.** `sitemap.xml`, `llms.txt`, `feed.xml` and `_redirects` are written into the build beside the pages and appear in no map list, so `dangling` accepts them by name; any other token is also accepted when it exists under `site.build` on disk. Without this every note that said "the feed" in backticks was a false positive, and a lint that fires on true statements is one people switch off.
43
47
  - **A record is the only writer, and it only ever records a build that worked.** `_buildAikb()` writes when `KISS_AIKB` is on, `config.dev` is false and `failures.length === 0`; anything else evaluates the notes and reports `written: false`. A refused record says so on the log — the operator is standing there waiting for the folder to change, and silence would read as success. The consequence to keep in mind is that the folder describes the _last build that passed_, not the working tree: a failed `kiss-ssg aikb` leaves the previous record in place, which is exactly what makes the next diff meaningful.
44
48
  - **`site-map.json` is the opt-in marker, not the folder.** `isRecorded()` looks for that one file because only a record ever writes it. A folder that merely exists proves nothing: this repository's own `AIKB/` is hand-written module notes, and `docs.js` builds a site inside it with the default `folders.aikb` — if existence were the test, the docs build would adopt those notes as a site map and start reporting every one of them as `dead`.
@@ -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/check.md CHANGED
@@ -12,8 +12,8 @@ Two commands share all of it. `check <script>` runs the site's build staged and
12
12
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
13
13
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
14
14
  - `readReportsFile(text)` → the same `BuildReport[]`, from any of the three shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, or a single report object. What `--against` reads.
15
- - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged }`, the three lists holding output paths, sorted. Pure.
16
- - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged`. Indented to sit under that site's `formatReport` line.
15
+ - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure.
16
+ - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line.
17
17
  - `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
18
18
  - `recordedLine(report)` → the line `aikb --summary` prints under a site: ` recorded <folder>`, ` not recorded — build failed`, ` not recorded — folders.aikb is null`, or ` not recorded`.
19
19
  - `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
@@ -42,6 +42,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
42
42
  - **The bin reads the `--against` file before it spawns the build**, so a mistyped filename costs nothing and prints as the usage error it is (`kiss-ssg: cannot read --against file <path>`, then `HELP`, exit 1) rather than after a minute of building.
43
43
  - **`check` diffs without being asked, and `aikb` does not.** With no `--against`, the bin asks `defaultBaseline(reports)` where the site's own snapshot would be and diffs against it if the file is there — so the ordinary question ("what have I changed since this work opened") is answered by the ordinary command, and the answer is measured from the last _record_, not from the last build anybody happened to run. A check writes nothing, so the file it reads is the one the last record left. `aikb` skips the fallback for the opposite reason: by the time it could read that file it has just overwritten it, and a diff against yourself is `= N unchanged` every time. Naming `--against` explicitly always wins, under either command.
44
44
  - **The first report with a folder wins the baseline.** A script that builds several sites writes one report each, but they share a working tree and therefore one knowledge base; `diffReports` pairs the file's contents with this run's reports by `buildDir`, so a single baseline covers all of them.
45
+ - **The asset rows exist because a page diff can have no cause in any page source.** Under `assets.hash` a stylesheet's filename _is_ the hash of its bytes, so one changed asset rewrites the `<link href>` of every page that references it: the diff reads `~ <every page>, = 0 unchanged`, and a reader who trusts it goes looking through page sources, models and controllers for a change that is not there. Naming the asset that moved turns that into a one-line read — and the rows go **above** the page list, because a cause printed after two hundred rows of consequence is reached only by the reader who already scrolled past the thing it explains. Verified in situ on a 20-page site with a 91-entry manifest before the order was settled. Both sides of the comparison were already on disk — `BuildReport.assets` is the manifest (`AIKB/build-report.md`), and `last-build.json` keeps it — so this needed no record-format change and no new build work. Three deliberate limits: only assets **both** builds emitted are rows (a new one has no previous name to have moved from, and is the asset copy working rather than a fact that explains anything); nothing prints when no page moved, because the rows explain a diff rather than being one; and the list caps at ten as a safety valve rather than a display limit — only `.css`/`.js` are hashable at all, and under `assets.version` nothing is renamed so every entry records `target === source` and no row can ever fire, which means a real site moves one or two and the cap only bounds a site that bundles per page. **The limitation worth knowing rather than fixing:** because it compares targets, an _unhashed_ asset whose bytes changed — an edited image — is not a row, since its `source` and `target` are both unchanged. That is the right trade: an unhashed asset is not referenced by a name that appears in every page's HTML, so it never produces the all-pages-changed symptom these rows exist to explain. Found by a consuming site whose whole-site diffs were being caused by its stylesheet — see the Tailwind notes in `AIKB/pipeline.md`.
45
46
  - **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
46
47
  - **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
47
48
  - **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples && node 9-migrated-from-v1.js`), so moving it would check a different set of paths from the ones it builds with.
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).
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,8 +54,15 @@ 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.
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.
58
66
  - **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `markdown`, `links`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
59
67
  - **The index signature is on the config and never on `folders`.** An unknown config key is legitimate — anything a site passes through reaches its views as `{{config.x}}` — so it types as `any` rather than an error. A `folders` key is not: `{ folders: { biuld: './out' } }` is a typo that silently builds to the default folder, so `KissFoldersInput` stays closed and TypeScript reports it. `test/fixtures/consumer/bad.js` pins that difference.
60
68
  - `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
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.
@@ -16,7 +16,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
16
16
  - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
17
17
  - `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
18
18
  - `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A path already carrying a scheme is returned unchanged. A path the manifest does not know renders as it was given and logs one warning per page per path. A non-string path logs a warning and renders `''`.
19
- - `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit, and it wins if both are set. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
19
+ - `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
20
20
  - `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
21
21
 
22
22
  ## Depends on
@@ -29,6 +29,8 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
29
29
 
30
30
  ## Non-obvious behavior
31
31
 
32
+ - `isActive`'s block context is **the surrounding context, then the hash, then the helper's own keys** (`href`, `folderMatch`, `active`, `pageURL`). It was the hash alone, which meant the natural data-driven nav — `{{#each nav}}{{#isActive ../page href=href}}{{label}}{{/isActive}}{{/each}}` — rendered an empty label for every item on a green build, and nothing could warn: the helper cannot tell a key the caller forgot from one it never wanted. Two independent agents building a fresh site hit it within a day of each other; one shipped a nav of blank links and found it only by reading the rendered HTML. Adding the parent underneath is additive — anything that already passed what it needed is unaffected, because the hash still wins. Covered by `test/unit/handlebars-helpers.test.js`.
33
+
32
34
  - Helpers register on whichever Handlebars environment (`hbs`) is passed in, not a global instance — each `Kiss` instance calls this against its own `this.handlebars`, so helpers never leak between instances.
33
35
  - The `sass` helper's file mode resolves a _relative_ path against `process.cwd()`, not against the view or the assets folder; an **absolute** path is passed to `sass.compile` untouched (`path.isAbsolute(context)` — an unconditional `path.join(process.cwd(), context)` mangled a path a controller had resolved into `<cwd>/abs/path/main.scss` and failed the build, review finding C7). Block mode compiles the block content directly with `sass.compileString` (no `process.cwd()` involved).
34
36
  - `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js` — page identity, and nothing emitted is built from it) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
@@ -40,7 +42,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
40
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.
41
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`.
42
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.
43
- - **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.
44
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.
45
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.
46
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
@@ -36,6 +36,8 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
36
36
 
37
37
  ## Non-obvious behavior
38
38
 
39
+ - `_preparePage` takes `extLess` from **the page's** resolved config (`options.config?.extensionLess`), not the instance's, falling back to the instance when a page has none. `page()` merges the global config with the page's overrides before the page is prepared, so this is the resolved value for that page. It matters because a site that is `extensionLess` everywhere still owes its host one literal `404.html` — Netlify and Cloudflare Pages look for that exact filename and do not fall back to `404/index.html`. Before this, an `options.config` override naming `extensionLess` was accepted and silently ignored, which is the worst shape of bug: the config is documented as per-page, the page took it, and the output path came out the other way. Covered by `test/integration/canonical.test.js` § per-page extensionLess.
40
+
39
41
  - Pipeline: `.page()`/`.pages()`/`.scan()` queue work (model → controller → `_preparePage`) as one caught promise per page on `_promises`; nothing renders until `.generate()`. `.generate()` awaits `_promises`, renders every stack entry whose `runCount` is 0, awaits the writes, then fires the callback. `_generating` tracks `generate()`/`sitemap()` runs, `_callbacks` the promises callbacks themselves returned. `complete()` drains all three lists repeatedly because callbacks may queue more work, and then renders anything the drain left unrendered.
40
42
  - **The build folder is written by four things, and `config.folders.build` is the single handle on all four.** Pages (`_preparePage` copies it onto each `KissPage.buildDir`, which `buildTo` is composed from — plus the dev-mode `.json` debug sibling beside each page), the asset copy (`copyAssets(folders.assets, folders.build)` at construction, and again on a watched asset change), `sitemap.xml` (`writeSitemap` reads `config.folders.build`), `llms.txt` (`writeLlms`, same route), and `viewStats()`'s `debug.json`. Nothing writes into the build by any other route, which is what makes the staged build below a one-line redirection rather than four separate detours.
41
43
  - **`config.assets.pipeline` is wired at three points, and all three are one line each** (`AIKB/pipeline.md` has the runner). The constructor creates the pipeline and calls `_queuePipeline()` immediately before the construction-time `copyAssets()`; `_replay()` re-runs it (and re-queues the copy) when — and only when — the site configured a step, so a whole-site watch rebuild re-runs the tools an edited model or controller may have changed the input to; `close()` ends the watch processes. **Ordering needs no mechanism of its own**: the steps are chained onto `_assetQueue`, the promise every copy already chains onto, so "steps first, copy second" is the queue doing what it was built to do. The run is deliberately **not** pushed onto `_promises`: those entries are the `data` array `generate()`/`complete()` hand back, whose first entry is documented as the construction-time asset copy, so a site adding a pipeline would have silently renumbered its own `data`. It goes on `_generating`, which `_drain()` waits for just the same. A step that exits non-zero (or cannot be spawned) is recorded as `{ view: '<pipeline: name>', buildTo: null, error }` — the same shape as a controller or dev-server failure, since there is no output path to name it by — so `complete()` rejects while the copy and every page still run and the report still names everything else that was wrong. Scoped re-renders (a page view, a partial, a layout) never re-run the steps: a tool that must see those edits is what a step's `watch` command is for.
@@ -49,15 +51,21 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
49
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.
50
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.
51
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
+
52
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.
53
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.
54
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).
55
- - **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`.
56
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.
57
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.
58
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.
59
- - **`_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.
60
- - **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.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.
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.
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.
61
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.
62
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).
63
71
  - **`complete()` reports a build's failures once** (`_failuresReported`, reset with `_failures` in `_replay()`): the first call with a non-empty `_failures` rejects, every later call in the same build resolves with the data instead. It used to re-reject, and the documented "`complete()` from inside a `generate` callback" pattern (below) has two calls draining the same build concurrently — the loser's rejection had no handler attached to it and killed the process after the consumer had already caught and reported the winner's (review finding A-01). The outer call wins in practice: its `_drain()` registered its reactions first, so it stays one microtask ahead of a call started from inside the callback.
@@ -89,7 +97,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
89
97
  - `_replaying` is read _synchronously_ at the top of `page()` — both to skip re-logging the registration and to capture `fresh` for the async chain that runs later. That is why `_replay()` can reset the flag in a `finally` immediately after the registration loop, even though the chains it started are still pending.
90
98
  - `_promises` and `_generating` are push-only _within a build_ — never spliced, only reassigned wholesale by `_replay()`. So repeated `complete()` calls inside one build re-await every promise ever queued (all already settled, so cheap), and a long-running `watch()` session no longer grows them without bound: each rebuild starts from empty arrays.
91
99
  - `_drain()` waits on `_promises`, `_generating` **and `_callbacks`** — the promises `_runCallback` collected from `async` callbacks — re-checking the combined length between rounds. Tracking the callback itself is what makes work queued after an `await` inside it part of the build (review finding A-03): before that, the `while` loop only saw array-length growth that had already happened, so `complete()` resolved while an awaiting callback had not yet queued its pages. What still escapes it is work deferred by a callback that returns nothing to track — a `setTimeout`-ed `.page()` from a _synchronous_ callback queues on a later macrotask with no promise the engine can see.
92
- - **`complete()` renders what the drain leaves unrendered.** `_settle()` alternates `_drain()` and `_generatePending()` — which renders every stack entry still at `runCount === 0`, incrementing the count as it picks each one so a concurrent pass cannot render it twice — and loops until a drain leaves nothing pending. A page prepared after the last `generate()` pass iterated the stack (a `scan()` from a `generate` callback, as `examples/3-pages.js` does; or a page queued after `generate()` whose model resolved after that run's `Promise.all` snapshot) used to be silently dropped from a build that then reported success — an outcome decided by model latency (review finding A-02). The implicit pass is gated on `_generateRequested`: `complete()` finishes a render pass the caller asked for, it never starts one that was not, so `.page(…).complete()` with no `generate()` still writes nothing. `_generatePending()`'s run is pushed onto `_generating` so a concurrent `complete()` and a later replay wait for it, and its per-page failures are collected on `_failures` exactly as `generate()`'s are. It fires no callback — `generate()`'s callback belongs to `generate()`.
100
+ - **`complete()` renders what the drain leaves unrendered.** `_settle()` alternates `_drain()` and `_generatePending()` — which renders every stack entry still at `runCount === 0`, incrementing the count as it picks each one so a concurrent pass cannot render it twice — and loops until a drain leaves nothing pending. A page prepared after the last `generate()` pass iterated the stack (a `scan()` from a `generate` callback, as `examples/3-pages/router.js` does; or a page queued after `generate()` whose model resolved after that run's `Promise.all` snapshot) used to be silently dropped from a build that then reported success — an outcome decided by model latency (review finding A-02). The implicit pass is gated on `_generateRequested`: `complete()` finishes a render pass the caller asked for, it never starts one that was not, so `.page(…).complete()` with no `generate()` still writes nothing. `_generatePending()`'s run is pushed onto `_generating` so a concurrent `complete()` and a later replay wait for it, and its per-page failures are collected on `_failures` exactly as `generate()`'s are. It fires no callback — `generate()`'s callback belongs to `generate()`.
93
101
  - The `modelId` used in `.page()`'s `.catch()` failure entry is captured _before_ the `.then()` chain runs, because `options.model` is reassigned to the resolved data inside `.then()` — reading `options.model` inside `.catch()` for a post-resolution failure would report the data, not the original id.
94
102
  - **Two pages claiming one `buildTo` is a build failure** (`_preparePage`): the second is still not stacked, but alongside the `Page already processed` log it now pushes `{ view, buildTo, error }` onto `_failures`, so `complete()` rejects. It used to log and return `null`, which meant a page the caller asked for was simply missing from a build that reported success — the same silent-success class as a dropped render (review finding C6). The dedupe applies to `.pages()` fan-out too, since each fanned-out page goes through `_preparePage` individually (v1 never deduped fan-out output); a fan-out whose controller derives the slug from model data is exactly where a collision now surfaces instead of hiding. The rebuild paths do not hit it: `scan()` dedupes against `_registrations` and `_replay()` replays a snapshot taken before its re-scan, both so the same view is never queued twice.
95
103
  - **The `slug` and `path` fallbacks in `page()` both guard on `.endsWith('.hbs')`.** Only a `.hbs` view is a filename: its folder is the page's path and its basename the slug. An inline template string gets a random `snippet-N` slug (with a logged error) and an _empty_ path — deriving one from the view would cut at the last `/` in the template's own markup, so `'<p>x</p>'` built into `public/-p-x-/…` and any closing tag became an output folder, with the sitemap inheriting the wrong `loc` (review finding C5). The `path` guard was missing while the `slug` one ten lines above was not.
package/AIKB/links.md CHANGED
@@ -26,7 +26,7 @@ The finding is **advisory**. It never moves `ok`, never moves an exit code, and
26
26
 
27
27
  - **Extraction and resolution are split across time on purpose.** The references are pulled out of the HTML at write time and the file is never read back, because under `kiss-ssg check` — the command the finding exists to serve — the whole staging folder is deleted before anything could read it, and a scan that read pages back would have reported `checked: 0, broken: []` in silence. The cost is a few hundred bytes a page rather than the ~15 MB of retained HTML a 500-page site would need if the documents were kept. `AIKB/kiss.md` has the ordering half of this.
28
28
  - **A same-origin absolute URL is internal, and that is the whole point.** `{{canonical}}` emits `https://site.example/courses/` on every page and `{{absUrl}}` emits one wherever a template asks; a rule of "ignore anything with a scheme" would skip exactly the OG tag and nav link a slug rename silently breaks. The origin test is `new URL(...).origin`, so a trailing slash on `config.siteUrl` is tolerated, and a path prefix (`https://example.com/docs`) is stripped as well: under that prefix the build folder is `/docs`'s root. A same-origin URL **outside** the prefix is called external — it is served by something this build did not write, so this build cannot say whether it is broken.
29
- - **The asset manifest contributes its values, never its keys.** `{{asset 'css/site.css'}}` returns the emitted name, which under `assets.hash` is `css/site.a1b2c3d4.css` — and `recordEmitted` _moves_ the file, so `css/site.css` is not on disk at all. Accepting the manifest key would make a template that hardcoded `/css/site.css` pass while the browser 404s. With no hashing policy key and value are equal, so nothing is lost. An empty manifest (`folders.assets: null`, the deliberate shape of `examples/7-versioned-outputs.js`) is "no assets", not an error.
29
+ - **The asset manifest contributes its values, never its keys.** `{{asset 'css/site.css'}}` returns the emitted name, which under `assets.hash` is `css/site.a1b2c3d4.css` — and `recordEmitted` _moves_ the file, so `css/site.css` is not on disk at all. Accepting the manifest key would make a template that hardcoded `/css/site.css` pass while the browser 404s. With no hashing policy key and value are equal, so nothing is lost. An empty manifest (`folders.assets: null`, the deliberate shape of `examples/7-versioned-outputs/router.js`) is "no assets", not an error.
30
30
  - **`{{link}}` writes references this module resolves by construction, and is not special-cased here.** The helper renders `servedPathFor(pageURL)` — `/`, `/about.html`, `/courses/`, `/blog/post/`, `/data/index.json` — and every one of those shapes is a path `resolveReference` accepts: the file itself, or `<dir>/index.html` for a trailing slash. `absolute=true` renders the site's own origin (plus `siteUrl`'s path prefix, if any), which `classifyReference` calls internal and resolves the same way. So a site whose internal hrefs are all `{{link}}` reports `broken: []`, and this scan stays the net for the hand-written references beside them — it neither knows nor asks which references came from the helper. The qualifier is `known`, below: a `{{link}}` to a page that failed to render is reported broken on every page that linked it, because no such file was published. Identity itself is not this module's business — an id that no page claims fails the _render_ (`AIKB/handlebars-helpers.md`), so it never reaches a written page for this scan to find.
31
31
  - **`known` is checked before the filesystem, and holds only pages that wrote bytes.** It is the cheap half, and on a page-heavy site most references are to other pages. It is built from the pages the scan was given — those whose `hash !== null` — so a link to a page with `generate: false`, or to one that failed to render, is reported as broken, which is the honest answer: no such file was published.
32
32
  - **Everything else in the build folder is answered by the filesystem, which is why `Kiss` runs the scan where it does.** `sitemap.xml`, `llms.txt`, a feed, `_redirects` and anything a pipeline step wrote straight into the build are not pages and not assets; they resolve because they are on disk at the moment the scan runs. That is the reason the scan sits in `complete()`'s settle path rather than in `_finishBuild()`.
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/partials.md CHANGED
@@ -30,5 +30,5 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
30
30
  - Partials are registered on the _instance_ environment (`kiss.handlebars`), never the global `handlebars` module. A consumer helper that does `require('handlebars').partials[name]` (v1 sites did this to render a partial chosen by name — diploma-msc's `renderPartial`) silently gets `undefined` in v2. The migration notes point such sites at `kiss.handlebars.partials` or Handlebars' built-in dynamic partial syntax `{{> (lookup this "name")}}`, which needs no helper at all (verified against diploma-msc on 2026-09-04).
31
31
  - **Every partial is registered as a compiled, recording function** (`tracingPartial`). Handlebars calls a function partial as `partial(context, options)` and handlebars-layouts' `extend`/`embed` as `template(context, { data })`; the wrapper reads `options.data.kissPage` — the page identity `KissPage.generate()` puts in the render's data frame — records `(page, name)` on the injected `deps.graph` (`AIKB/dependency-graph.md`), and returns exactly `compiled(context, options)`. The return contract matters: `undefined` makes Handlebars try to compile the function, and a `SafeString` breaks an indented partial call, which splits the result on newlines. A recorder that throws is swallowed; tracing never affects rendering. Without a `graph` in `deps`, or without a page id in the data, the wrapper is a plain compiled partial.
32
32
  - **Layouts keep their earlier win**: compiled once rather than per `{{#extend}}` — measured when that change landed: `fanout@500` **-27.1%**, `models@500` **-15.7%**, `watch@500` **-17.5%**, `scan@500` **-10.7%**, a watch replay after a model edit **455ms → 172ms**. Registering every partial compiled costs nothing extra: `hbs.compile` is lazy, so an unrendered partial is never parsed and a partial that will not parse still fails at render against the page that used it.
33
- - **`hbs.partials[name]` is a function always** — the compatibility line moved. It was a string only until the first render anyway (Handlebars compiles a string partial on first use and writes the function back into the same object — `mergeIfNeeded` returns `env.partials` uncopied), so a consumer helper reading an entry has always had to accept both; `learna-kiss`'s `getSiteSpecificPartial` calls `handlebars.compile(partial)` unconditionally and would have broken on any partial after its first render. The one-line form is in `llms.txt` and `README.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`. **Such a helper must pass its own `options.data` through**: that data frame is how the invoking page is recorded as using the partial, so a bare `p(ctx)` renders correctly but leaves the page unrecorded — under `watch()` it is not re-rendered when the partial changes (unless no page recorded that partial at all, when every page is). `examples/9-migrated-from-v1.js`'s `renderPartial` is the worked recipe.
33
+ - **`hbs.partials[name]` is a function always** — the compatibility line moved. It was a string only until the first render anyway (Handlebars compiles a string partial on first use and writes the function back into the same object — `mergeIfNeeded` returns `env.partials` uncopied), so a consumer helper reading an entry has always had to accept both; `learna-kiss`'s `getSiteSpecificPartial` calls `handlebars.compile(partial)` unconditionally and would have broken on any partial after its first render. The one-line form is in `llms.txt` and `README.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`. **Such a helper must pass its own `options.data` through**: that data frame is how the invoking page is recorded as using the partial, so a bare `p(ctx)` renders correctly but leaves the page unrecorded — under `watch()` it is not re-rendered when the partial changes (unless no page recorded that partial at all, when every page is). `examples/9-migrated-from-v1/router.js`'s `renderPartial` is the worked recipe.
34
34
  - `partialNameFor(file, folders)` is the only place a file path becomes a partial name outside registration, and it reuses the same slice-and-strip; `Kiss._handleChange` calls it so a chokidar path maps to the name the graph knows. A collision (`foo.md` and `foo.hbs`) maps to one name from either path, which is right: whichever file changed, the registered `foo` is what pages invoked.
package/AIKB/pipeline.md CHANGED
@@ -39,3 +39,7 @@ Runs the external commands of `config.assets.pipeline` — a CSS toolchain, an i
39
39
  `PipelineStep` and `PipelineResult` are declared here and referred to from `lib/config.js` (`KissAssets.pipeline`) and `lib/build-report.js` (`buildReport`'s `pipeline` input), so the shape a site writes, the shape that is run and the shape that is reported cannot drift apart. `types/pipeline.d.ts` is emitted from this JSDoc like every other `lib/` module — `npm run types`, never hand-edited.
40
40
 
41
41
  **Tools that watch stdin.** A watch child is spawned with stdin ignored. Tailwind's `--watch` exits as soon as stdin closes (exit code 0, logged as `pipeline watch "tailwind" exited (code 0)`); `--watch=always` is the flag that keeps it up. Found on a1k9training, the first real site to use the hook.
42
+
43
+ **What the tool scans is not this module's business, and that is exactly why it bites.** The module runs a command line and reports its exit code; it has no idea what the command read. With Tailwind that gap has a sharp edge: v4 walks the whole project for class-name candidates and `@source` _adds_ to that walk rather than replacing it, so a site's own `.md` files are scanned as plain text and an ordinary English word compiles a utility. Paired with `assets.hash: true` (`AIKB/asset-manifest.md`) the stylesheet's hash is its filename, so a documentation edit renames `css/site.<hash>.css` and rewrites the `<link href>` on every page — which surfaces as `kiss-ssg check` reporting `~ N changed, = 0 unchanged` and no page source to blame. It is intermittent (a trailing `.` or `,` stops a word being a candidate token), which is what makes it expensive to diagnose. `@import 'tailwindcss' source(none)` plus explicit `@source` paths — resolved against the stylesheet's folder, not the cwd — is the fix. Verified on Tailwind 4.3.3; documented in `llms.txt` and `README.md` because the recipe there is what gets copied. Originally surfaced by a consuming site running `assets.hash` against a repo full of tracked markdown.
44
+
45
+ **Omitting `-i` is a silent _total_ loss, not a half-build.** `npx @tailwindcss/cli -o out.css` exits 0 and writes a stylesheet, but substitutes a default input of `@import 'tailwindcss'` instead of reading the entry file, so everything authored there goes — every `@layer components` class, every `@theme` token, every `@font-face`, every plugin import and every `@source` line — leaving generic utilities. Verified on 4.3.3; on a real 20-page site the output fell from 66,384 to 24,990 bytes. A step that does this is `{ ok: true }` here and always will be — the tool succeeded. The failure mode worth knowing is a site whose `watch` command carries `-i` and whose `run` does not: dev is correct, production is not, and nothing in the report distinguishes them.
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'`).