kiss-ssg 2.4.0 → 2.6.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 (210) hide show
  1. package/AIKB/aikb.md +2 -2
  2. package/AIKB/asset-manifest.md +11 -3
  3. package/AIKB/assets.md +19 -4
  4. package/AIKB/build-report.md +9 -5
  5. package/AIKB/check.md +6 -2
  6. package/AIKB/config.md +12 -5
  7. package/AIKB/design.md +149 -0
  8. package/AIKB/feed.md +3 -1
  9. package/AIKB/handlebars-helpers.md +6 -5
  10. package/AIKB/kiss-page.md +10 -1
  11. package/AIKB/kiss.md +44 -19
  12. package/AIKB/llms.md +3 -1
  13. package/AIKB/output-registry.md +44 -0
  14. package/AIKB/partials.md +9 -3
  15. package/AIKB/redirects.md +3 -1
  16. package/AIKB/robots.md +3 -1
  17. package/AIKB/site-helpers.md +40 -0
  18. package/AIKB/sitemap.md +3 -1
  19. package/AIKB/testing.md +8 -2
  20. package/AIKB/upstream.md +136 -0
  21. package/AIKB/utils.md +5 -1
  22. package/AIKB/watcher.md +17 -12
  23. package/CHANGELOG.md +1219 -0
  24. package/README.md +73 -32
  25. package/bin/kiss-ssg.js +16 -0
  26. package/examples/1-scan/README.md +4 -4
  27. package/examples/1-scan/router.js +23 -9
  28. package/examples/10-asset-pipeline/README.md +37 -4
  29. package/examples/10-asset-pipeline/router.js +43 -11
  30. package/examples/10-asset-pipeline/{assets → src/assets}/css/generated.css +1 -1
  31. package/examples/10-asset-pipeline/src/assets/css/site.scss +413 -0
  32. package/examples/10-asset-pipeline/src/assets/img/icons.svg +18 -0
  33. package/examples/10-asset-pipeline/src/layouts/layout.hbs +61 -0
  34. package/examples/10-asset-pipeline/src/pages/index.hbs +113 -0
  35. package/examples/10-asset-pipeline/src/partials/newsletter.md +5 -0
  36. package/examples/10-asset-pipeline/tools/tokens.js +1 -1
  37. package/examples/11-blog/AIKB/last-build.json +56 -39
  38. package/examples/11-blog/AIKB/notes/controllers/post.md +1 -1
  39. package/examples/11-blog/AIKB/notes/controllers/tag.md +1 -1
  40. package/examples/11-blog/AIKB/site-map.json +84 -83
  41. package/examples/11-blog/AIKB/site-map.md +40 -39
  42. package/examples/11-blog/README.md +21 -11
  43. package/examples/11-blog/router.js +29 -18
  44. package/examples/11-blog/src/assets/css/site.scss +413 -0
  45. package/examples/11-blog/{controllers → src/controllers}/post.js +1 -1
  46. package/examples/11-blog/{controllers → src/controllers}/tag.js +1 -1
  47. package/examples/11-blog/src/layouts/layout.hbs +61 -0
  48. package/examples/2-page/README.md +3 -3
  49. package/examples/2-page/router.js +21 -3
  50. package/examples/2-page/src/assets/css/site.scss +413 -0
  51. package/examples/2-page/src/layouts/layout.hbs +61 -0
  52. package/examples/2-page/src/partials/newsletter.md +5 -0
  53. package/examples/3-pages/README.md +3 -3
  54. package/examples/3-pages/router.js +21 -3
  55. package/examples/3-pages/src/assets/css/site.scss +413 -0
  56. package/examples/3-pages/src/layouts/layout.hbs +61 -0
  57. package/examples/3-pages/src/partials/newsletter.md +5 -0
  58. package/examples/4-layouts-and-partials/README.md +2 -2
  59. package/examples/4-layouts-and-partials/router.js +26 -10
  60. package/examples/4-layouts-and-partials/src/assets/css/site.scss +413 -0
  61. package/examples/4-layouts-and-partials/{layouts → src/layouts}/layout.hbs +1 -1
  62. package/examples/5-helpers/README.md +2 -2
  63. package/examples/5-helpers/router.js +23 -10
  64. package/examples/5-helpers/src/assets/css/site.scss +413 -0
  65. package/examples/5-helpers/src/layouts/layout.hbs +61 -0
  66. package/examples/5-helpers/{pages → src/pages}/index.hbs +1 -1
  67. package/examples/6-sitemap/README.md +3 -3
  68. package/examples/6-sitemap/router.js +23 -7
  69. package/examples/6-sitemap/src/assets/css/site.scss +413 -0
  70. package/examples/6-sitemap/src/layouts/layout.hbs +61 -0
  71. package/examples/6-sitemap/src/partials/newsletter.md +5 -0
  72. package/examples/7-versioned-outputs/README.md +2 -2
  73. package/examples/7-versioned-outputs/router.js +23 -8
  74. package/examples/7-versioned-outputs/src/assets/css/site.scss +413 -0
  75. package/examples/7-versioned-outputs/src/layouts/layout.hbs +61 -0
  76. package/examples/7-versioned-outputs/{pages → src/pages}/index.hbs +1 -1
  77. package/examples/7-versioned-outputs/src/partials/newsletter.md +5 -0
  78. package/examples/8-data-fed-site/README.md +3 -3
  79. package/examples/8-data-fed-site/router.js +24 -8
  80. package/examples/8-data-fed-site/src/assets/css/site.scss +413 -0
  81. package/examples/8-data-fed-site/{controllers → src/controllers}/stockist.js +1 -1
  82. package/examples/8-data-fed-site/src/layouts/layout.hbs +61 -0
  83. package/examples/8-data-fed-site/src/partials/newsletter.md +5 -0
  84. package/examples/9-migrated-from-v1/AIKB/last-build.json +41 -27
  85. package/examples/9-migrated-from-v1/AIKB/notes/controllers/shelf-item.md +1 -1
  86. package/examples/9-migrated-from-v1/AIKB/site-map.json +47 -46
  87. package/examples/9-migrated-from-v1/AIKB/site-map.md +35 -34
  88. package/examples/9-migrated-from-v1/README.md +6 -6
  89. package/examples/9-migrated-from-v1/helpers/index.js +9 -4
  90. package/examples/9-migrated-from-v1/router.js +29 -13
  91. package/examples/9-migrated-from-v1/src/assets/css/site.scss +413 -0
  92. package/examples/9-migrated-from-v1/{controllers → src/controllers}/shelf-item.js +1 -1
  93. package/examples/9-migrated-from-v1/src/layouts/layout.hbs +61 -0
  94. package/examples/9-migrated-from-v1/{pages → src/pages}/await-complete.hbs +1 -1
  95. package/examples/9-migrated-from-v1/{pages → src/pages}/folders.hbs +4 -5
  96. package/examples/README.md +32 -20
  97. package/lib/aikb.js +15 -3
  98. package/lib/asset-manifest.js +59 -6
  99. package/lib/assets.js +263 -37
  100. package/lib/build-report.js +50 -3
  101. package/lib/check.js +80 -1
  102. package/lib/config.js +113 -17
  103. package/lib/feed.js +6 -2
  104. package/lib/handlebars-helpers.js +88 -8
  105. package/lib/kiss-page.js +50 -7
  106. package/lib/kiss.js +1055 -134
  107. package/lib/llms.js +6 -1
  108. package/lib/output-registry.js +201 -0
  109. package/lib/partials.js +47 -4
  110. package/lib/redirects.js +3 -1
  111. package/lib/robots.js +3 -0
  112. package/lib/site-helpers.js +370 -0
  113. package/lib/sitemap.js +5 -1
  114. package/lib/utils.js +18 -0
  115. package/lib/watcher.js +152 -64
  116. package/llms.txt +93 -22
  117. package/package.json +4 -2
  118. package/types/aikb.d.ts +1 -1
  119. package/types/asset-manifest.d.ts +15 -1
  120. package/types/assets.d.ts +44 -4
  121. package/types/build-report.d.ts +26 -2
  122. package/types/check.d.ts +35 -1
  123. package/types/config.d.ts +12 -6
  124. package/types/feed.d.ts +3 -1
  125. package/types/kiss-page.d.ts +15 -3
  126. package/types/kiss.d.ts +194 -5
  127. package/types/llms.d.ts +3 -1
  128. package/types/output-registry.d.ts +74 -0
  129. package/types/redirects.d.ts +3 -1
  130. package/types/robots.d.ts +3 -1
  131. package/types/site-helpers.d.ts +171 -0
  132. package/types/sitemap.d.ts +2 -1
  133. package/types/utils.d.ts +1 -0
  134. package/types/watcher.d.ts +2 -2
  135. package/examples/10-asset-pipeline/pages/index.hbs +0 -62
  136. package/examples/_shared/site.js +0 -33
  137. /package/examples/{_shared → 1-scan/src}/assets/css/site.scss +0 -0
  138. /package/examples/{_shared → 1-scan/src}/layouts/layout.hbs +0 -0
  139. /package/examples/1-scan/{models → src/models}/about/us.json +0 -0
  140. /package/examples/1-scan/{models → src/models}/index.json +0 -0
  141. /package/examples/1-scan/{pages → src/pages}/about/us.hbs +0 -0
  142. /package/examples/1-scan/{pages → src/pages}/index.hbs +0 -0
  143. /package/examples/{_shared → 1-scan/src}/partials/newsletter.md +0 -0
  144. /package/examples/11-blog/{models → src/models}/posts/2025-12-15-a-year-of-bench-lots.json +0 -0
  145. /package/examples/11-blog/{models → src/models}/posts/2026-01-06-grind-size-is-the-dial.json +0 -0
  146. /package/examples/11-blog/{models → src/models}/posts/2026-01-20-why-we-roast-on-wednesdays.json +0 -0
  147. /package/examples/11-blog/{models → src/models}/posts/2026-02-03-guji-uraga-has-landed.json +0 -0
  148. /package/examples/11-blog/{models → src/models}/posts/2026-02-18-the-cascara-experiment.json +0 -0
  149. /package/examples/11-blog/{models → src/models}/posts/2026-03-02-pour-over-at-home.json +0 -0
  150. /package/examples/11-blog/{pages → src/pages}/blog/listing.hbs +0 -0
  151. /package/examples/11-blog/{pages → src/pages}/blog/post.hbs +0 -0
  152. /package/examples/11-blog/{pages → src/pages}/blog/tag.hbs +0 -0
  153. /package/examples/11-blog/{pages → src/pages}/blog/tags.hbs +0 -0
  154. /package/examples/11-blog/{pages → src/pages}/index.hbs +0 -0
  155. /package/examples/11-blog/{partials → src/partials}/head-feed.hbs +0 -0
  156. /package/examples/11-blog/{partials → src/partials}/post-card.hbs +0 -0
  157. /package/examples/2-page/{controllers → src/controllers}/about.js +0 -0
  158. /package/examples/2-page/{controllers → src/controllers}/index.js +0 -0
  159. /package/examples/2-page/{models → src/models}/about.json +0 -0
  160. /package/examples/2-page/{models → src/models}/index.json +0 -0
  161. /package/examples/2-page/{pages → src/pages}/about.hbs +0 -0
  162. /package/examples/2-page/{pages → src/pages}/feed.hbs +0 -0
  163. /package/examples/2-page/{pages → src/pages}/index.hbs +0 -0
  164. /package/examples/3-pages/{models → src/models}/index.json +0 -0
  165. /package/examples/3-pages/{models → src/models}/roasts/arch-blend.json +0 -0
  166. /package/examples/3-pages/{models → src/models}/roasts/finca-la-ilusion.json +0 -0
  167. /package/examples/3-pages/{models → src/models}/roasts/guji-uraga.json +0 -0
  168. /package/examples/3-pages/{models → src/models}/roasts/huila-decaf.json +0 -0
  169. /package/examples/3-pages/{models → src/models}/roasts/nyeri-gatomboya.json +0 -0
  170. /package/examples/3-pages/{models → src/models}/roasts/sumatra-ketiara.json +0 -0
  171. /package/examples/3-pages/{pages → src/pages}/index.hbs +0 -0
  172. /package/examples/3-pages/{pages → src/pages}/roasts/index.hbs +0 -0
  173. /package/examples/3-pages/{pages → src/pages}/roasts/roast.hbs +0 -0
  174. /package/examples/4-layouts-and-partials/{pages → src/pages}/index.hbs +0 -0
  175. /package/examples/4-layouts-and-partials/{pages → src/pages}/stockists.hbs +0 -0
  176. /package/examples/4-layouts-and-partials/{partials → src/partials}/boards/espresso.hbs +0 -0
  177. /package/examples/4-layouts-and-partials/{partials → src/partials}/boards/filter.hbs +0 -0
  178. /package/examples/4-layouts-and-partials/{partials → src/partials}/hours.html +0 -0
  179. /package/examples/4-layouts-and-partials/{partials → src/partials}/site/footer.hbs +0 -0
  180. /package/examples/4-layouts-and-partials/{partials → src/partials}/site/header.hbs +0 -0
  181. /package/examples/4-layouts-and-partials/{partials → src/partials}/sourcing.md +0 -0
  182. /package/examples/5-helpers/{pages → src/pages}/brew-guide.hbs +0 -0
  183. /package/examples/5-helpers/{partials → src/partials}/grinder-note.md +0 -0
  184. /package/examples/5-helpers/{partials → src/partials}/tasting.md +0 -0
  185. /package/examples/6-sitemap/{models → src/models}/about.json +0 -0
  186. /package/examples/6-sitemap/{models → src/models}/index.json +0 -0
  187. /package/examples/6-sitemap/{models → src/models}/rota.json +0 -0
  188. /package/examples/6-sitemap/{models → src/models}/stockists.json +0 -0
  189. /package/examples/6-sitemap/{pages → src/pages}/about.hbs +0 -0
  190. /package/examples/6-sitemap/{pages → src/pages}/index.hbs +0 -0
  191. /package/examples/6-sitemap/{pages → src/pages}/rota.hbs +0 -0
  192. /package/examples/6-sitemap/{pages → src/pages}/stockists.hbs +0 -0
  193. /package/examples/7-versioned-outputs/{models → src/models}/index.json +0 -0
  194. /package/examples/8-data-fed-site/{models → src/models}/stockists/bakers-yard.json +0 -0
  195. /package/examples/8-data-fed-site/{models → src/models}/stockists/cargo-canteen.json +0 -0
  196. /package/examples/8-data-fed-site/{models → src/models}/stockists/harbour-market-stall.json +0 -0
  197. /package/examples/8-data-fed-site/{models → src/models}/stockists/oakfield-deli.json +0 -0
  198. /package/examples/8-data-fed-site/{models → src/models}/stockists/severn-provisions.json +0 -0
  199. /package/examples/8-data-fed-site/{models → src/models}/stockists/the-hop-and-vine.json +0 -0
  200. /package/examples/8-data-fed-site/{pages → src/pages}/index.hbs +0 -0
  201. /package/examples/8-data-fed-site/{pages → src/pages}/stockists/stockist.hbs +0 -0
  202. /package/examples/9-migrated-from-v1/{pages → src/pages}/duplicate-paths.hbs +0 -0
  203. /package/examples/9-migrated-from-v1/{pages → src/pages}/dynamic-partials.hbs +0 -0
  204. /package/examples/9-migrated-from-v1/{pages → src/pages}/handlebars-instance.hbs +0 -0
  205. /package/examples/9-migrated-from-v1/{pages → src/pages}/index.hbs +0 -0
  206. /package/examples/9-migrated-from-v1/{pages → src/pages}/pure-controllers.hbs +0 -0
  207. /package/examples/9-migrated-from-v1/{pages → src/pages}/shelf/item.hbs +0 -0
  208. /package/examples/9-migrated-from-v1/{pages → src/pages}/smaller-changes.hbs +0 -0
  209. /package/examples/9-migrated-from-v1/{partials → src/partials}/sourcing-note.md +0 -0
  210. /package/examples/9-migrated-from-v1/{partials → src/partials}/stock-note.hbs +0 -0
package/AIKB/aikb.md CHANGED
@@ -58,9 +58,9 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
58
58
  - **The engine never writes a stamp.** `subject-hash` is copied into a note's frontmatter by the person (or the `kiss-branch-close` / `kiss-memory-consolidate` skill) who wrote or reviewed it. An unstamped note is the state every note starts in and most notes stay in, and it is never reported stale — only a stamp that _disagrees_ is a finding, which is what makes stamping opt-in per note rather than a burden on everyone.
59
59
  - **The subjects are on the report as well as in `site-map.json`, because at the one moment that matters the two disagree.** A piece of work closes with the map on disk still describing the _previous_ record — so the hash in that file is the hash the note is already stamped with, and the hash it wants restamping with exists nowhere but the report of the build that just found the finding. Hence `aikb.subjects` beside `aikb.notes`: a plain `kiss-ssg check` can say both "this note is stale" and "here is what to stamp it with", before any record is made.
60
60
  - **The dangling filter errs, at every step, towards saying nothing.** A backticked token is a candidate only if it has a `/` in it or ends in one of eleven known extensions, and only if it holds none of ` <>*{}$` — so `{ name, origin }`, `<title>`, `npx kiss-ssg check` and `p-<8 hex>` are all prose, not paths. Fenced code blocks are dropped whole (a snippet quotes code rather than citing a file), a trailing `/` is a folder, and anything with a URI scheme (`https:`, and also the map's own `file:`/`url:` classifications) is skipped. The reason is not politeness: a lint that fires on every note is a lint people learn to scroll past, and then it is worth less than nothing.
61
- - **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`.
61
+ - **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`. **A leading slash is stripped before both lookups**, because a note written the way the deployed site reads cites `/docs` for the folder the config calls `./docs` — and `posixPath` strips a leading `./` but not a leading `/`, so one character was the whole difference between resolving and being reported dangling. Measured on one record before the fix: `/docs`, `/src`, `/src/assets` and `/src/pages` were all reported dangling while `./docs`, `docs`, `docs/`, `src`, `src/assets` and `src/pages` all resolved — four findings, no real problems, every one of them on a folder the site is configured with. The suffix fallback could not have caught it: it looks for a candidate ending `//docs`. Found by a downstream site conversion, not by the suite.
62
62
  - **`site.md` is scanned but never judged.** It is authored, evergreen and about the whole site rather than any one subject, so it has no subject to be missing, dead or stale about — but its file references rot exactly as a note's do, so it is read for `dangling` when it is there. The engine neither writes it nor enforces its shape; the generated `README.md` names it and its five sections, and `kiss-memory-consolidate` maintains it.
63
- - **A subject hash is over bytes, so a checkout that rewrites line endings restamps every note.** The controller's hash is sha1 of the file as it is on disk; a CRLF checkout of an LF repository hashes differently, and every stamped controller note reads `stale` until it is restamped. The alternative — normalising the bytes before hashing — would make the hash disagree with `git`'s idea of whether the file changed, which is the thing the stamp is trying to track.
63
+ - **A subject hash is over LF-normalised bytes, so a checkout that rewrites line endings does NOT restamp every note.** `subjectHash` (`lib/aikb.js:320`) replaces CRLF with LF before hashing: a CRLF checkout of an LF repository is the same controller, and git agrees, so a Windows clone reads the same stamps as the Linux clone that wrote them. The first version of this hashed the raw bytes and said normalising would make the hash disagree with git — measured the other way round: git normalises too, and the raw hash was what disagreed, restamping every controller note on every Windows clone. The generated `AIKB/README.md` and `llms.txt` say "LF-normalised" for the same reason.
64
64
  - **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
65
65
  - **A note's filename is derived from the subject's id, so two subjects can collide.** `a-x.js` and `a/x.js` both slug to `a-x`. Rare, and the alternative — a hash in the filename — would make the rule impossible to apply by hand, which is the property that matters more.
66
66
  - **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes.
@@ -10,11 +10,15 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
10
10
  - `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
11
11
  - `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
12
12
  - `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
13
- - `createAssetManifest()` → `{ record, lookup, toObject }`, a fresh manifest with its own `Map`.
14
- - `record(urlPath, emittedPath)` — stores the mapping and returns the **previous** `emittedPath` when it differs from the new one (the caller's cue to delete the file it just replaced), otherwise `null`.
13
+ - `createAssetManifest()` → `{ lookup, toObject, hasOwner, previous, reconcile, reconcileSass, urlRevision }`, a fresh manifest.
15
14
  - `lookup(urlPath)` — the emitted path, or `null`.
16
15
  - `toObject()` — a plain object copy of the map, for debugging.
17
16
 
17
+ - `hasOwner(owner)` — whether a source/target copy has an inventory.
18
+ - `reconcile(owner, current)` — replaces that copy's map of logical names to emitted filenames, removes vanished mappings, and returns old filenames no copy still owns. Another copy's mappings survive.
19
+
20
+ - `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
21
+
18
22
  ## Depends on
19
23
 
20
24
  `node:crypto`.
@@ -27,6 +31,10 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
27
31
 
28
32
  - **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
29
33
  - **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
30
- - **`record` returning the old name is the whole stale-file mechanism.** Nothing else knows which file a rename replaced: `Kiss._replay()`'s orphan sweep only ever considers page outputs (`_stack` `buildTo` paths), so a hashed asset is invisible to it. `lib/assets.js` deletes what `record` hands back.
34
+ - **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
31
35
  - **A manifest is per `Kiss` instance**, like the Handlebars environment, so two sites in one process cannot link each other's files. Nothing here is module-level state.
32
36
  - MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
37
+
38
+ - `reconcileSass(owner, fingerprints)` retains the latest successful, unshadowed Sass fingerprints per copy and returns the changed source names. It does not change URL mappings or their revision; replacing the map also retires removed entries.
39
+
40
+ - `previous(owner, name)` returns that copy's last-good output or null. Failed Sass compiles may carry it into reconciliation; removed sources do not. This does not read another copy's global mapping.
package/AIKB/assets.md CHANGED
@@ -6,8 +6,8 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves.
10
- - `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss` always passes its own.
9
+ - `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves. **The compile and the write are caught separately**, because they fail for different reasons and have different fixes: a syntax error is `Error parsing sass file: <source>`, and a stylesheet that parsed and could not be written (a directory in the way, a read-only target, a full disk) is `Error writing compiled sass to: <output>`. Both used to be the parse message, with the real cause on the `warn` line underneath — which is the line a reader skips once the red one has named a cause.
10
+ - `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss`always passes its own.`display` is optional too, and is the pair of strings the id and the log line are built from: the caller resolves the paths it actually copies — a relative path must not be re-resolved against whatever the working directory is when a queue drains — and resolving for correctness must not turn every build's asset line into an absolute path nobody typed.
11
11
 
12
12
  ## Depends on
13
13
 
@@ -19,6 +19,11 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
19
19
 
20
20
  ## Non-obvious behavior
21
21
 
22
+ - Sass results include `changed`, comparing retained fingerprints of compiled bytes across copies. The fingerprint is computed in memory, without reading the previous CSS file, so hashing may move that file without making an identical compile look changed. A copied CSS sibling wins and is excluded from Sass changes. The watcher swaps one changed stylesheet in place; multiple affected stylesheets use a full refresh; an unchanged Sass save sends no refresh. Failed or removed entries leave the next successful copy to establish a fresh fingerprint.
23
+ - A generated owner refusing a Sass write returns `refused: true`, distinct from `skipped: true` for underscore partials. The copy returns `refused` source paths for diagnostics; restoration uses registry producer identities. Refusals appear in advisory `outputs.collisions`, not the failure list.
24
+
25
+ - Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; a missing first-time source remains an error. Other copies retain their claims. Kiss passes the shared output registry: asset copies yield to generated outputs, warn on collisions, and unlink a stale file only while that copy still owns it. Copy identity uses the requested destination across atomic promotion.
26
+
22
27
  - `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
23
28
  - **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
24
29
  - Sass sources (`.scss`/`.sass`) are explicitly excluded from the plain `fs.copy` pass — they're only ever emitted as compiled `.css`, never copied verbatim.
@@ -29,9 +34,19 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
29
34
  - **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
30
35
  - **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
31
36
  - **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
32
- - **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
33
- - **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded — a broken sass file still logs its own error and leaves the copy successful.
37
+ - **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
38
+ - **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
34
39
  - `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
35
40
  - **The files a copy picks up may have been written moments earlier by `config.assets.pipeline`.** This module knows nothing about that: the steps are chained onto the same `_assetQueue` this module's calls are serialised by, so by the time `copyAssets` walks the source tree the tools have finished writing into it. See `AIKB/pipeline.md` and `AIKB/kiss.md`.
36
41
  - `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
37
42
  - **Sass is compiled through `compileFile()`, which loads the package at the point of compile and memoises the result**, not bound at import — see `AIKB/sass.md`. A site with no `.scss` under its assets folder never loads the `sass` package at all.
43
+
44
+ - **`_`-prefixed files are not compiled**, because a leading underscore is the Sass convention for "this is a partial, do not compile me standalone" and dart-sass itself never compiles one. kiss globbed them anyway, which was a harmless red log line right up until a Sass error became a build failure — and then the most ordinary stylesheet layout there is (`main.scss` defining a variable and `@import`ing a `_buttons.scss` that reads it) could not build, while `main.css` compiled correctly beside it. Measured across a real estate of six sites: two carry underscore partials, and they passed only because those partials happen to be self-contained.
45
+ - **The skip is reported, because skipping silently was the third quiet regression on this branch.** `_vendor.scss` — self-contained, imported by nothing — used to emit `_vendor.css` and then emitted nothing at all, with zero mentions anywhere in the log: a site serving that file began serving a 404 after a clean build and nothing said why. One `info` line names the whole set (one line, not one per file, so a dozen partials do not become a dozen lines every build), and `copyAssets` reports them to its caller as `{ file, skipped: true }`. **`isSassPartial` is shared by the compile pass and the claim pass** so the two cannot disagree about what was emitted — they did, and the claim pass then warned that a legitimately served `_theme.css` had overwritten a compile that never ran, advising the author to delete a file another entry point correctly consumes.
46
+ - **A Sass compile error is a build failure, not a red line in the log.** `compileSassFiles` resolves to one `{ file, error? }` per stylesheet it tried — every file, not only the failures, so a caller can clear the entry for one that has since started compiling — and `copyAssets` passes them back as `sass`. `Kiss.copyAssets` turns each error into a `<sass: <file>>` entry on `_failures`, the same shape as `<pipeline: name>`. It used to be logged and dropped: `complete()` resolved, `report().ok` stayed true, `kiss-ssg check` said `ok`, and the site shipped with **no stylesheet** — while `kiss-build-check` tells an agent that `ok: true` and exit 0 are the only passing result, which makes that verdict load-bearing. Found by a clean-room conversion that published exactly that site. One broken stylesheet still does not stop the others or the copy, the same rule a failing page follows; and because an asset re-copy is not a `_replay()`, each copy clears the entries for the files it is about to re-try, or a fixed stylesheet would stay failed for the rest of a watch session.
47
+ - **A copied `.css` beats a Sass compile, deliberately, and `recordEmitted` now says so.** `copyAssets` compiles Sass and then `fs.copy`s over the top, so when `site.scss` and `site.css` both emit `css/site.css` the copied file is served and the Sass is discarded. **That precedence is the design, not an accident** — it dates from this module's first commit (`ca49564`, 2026-09-02) and is what makes `config.assets.pipeline` work: a step's `run` writes into the assets folder _before_ the copy, so a Tailwind or PostCSS stylesheet lands as `src/assets/css/site.css` and must beat kiss's built-in Sass. Inverting it would have kiss silently clobbering the toolchain the site explicitly configured. What was missing was not the rule but any sign of it: the pages are correct and only the asset is wrong, so page hashes structurally cannot catch it and `kiss-ssg check` reports a clean build. It reached us from a real conversion where a project migrating off a pipeline that committed its compiled CSS had `layout.scss`/`layout.css` pairs, deleted the `.css` siblings for tidiness, and compared the compiled bytes only afterwards. The detection is a `Map` of emitted name → sources built in a pass of its own, before anything is renamed: no manifest shape change, because the manifest already records Sass output keyed by its compiled name (a collision is two writes to one key). **The separate pass is what makes it work under `assets.hash`.** It first lived inside the emit loop, which killed it in exactly the configuration a production site builds with: hashing `fs.move`s the first source's file to its hashed name, so the second source's `fs.stat` of the plain path found nothing and `continue`d before any comparison. Hash off warned, hash on was silent — and a silently dead stylesheet is a production problem, not a development one. Claims are gathered first, the hash/move and the manifest write happen second, once per emitted name; `test/unit/assets.test.js` now pins both settings. **The direction of the message is load-bearing and is not glob order**: `globFiles` sorts, so `site.css` is seen first, but the write order is compile-then-copy, so the warning keys off `isSass(source)` — the first version keyed off iteration order, named the pair backwards, and would have sent the author to edit the file that was already winning. `test/unit/assets.test.js` pins the exact wording, the "wins by design" phrasing and the served bytes together. **The line leads with the consequence** ("edits to `css/site.scss` are not reaching the site") and puts the compile/copy mechanism second, per the documentation rule this branch adopted. **It is deliberately not suppressed when `config.assets.pipeline` is set**, which was proposed on the reasoning that a pipeline site would see it on every build. Measured, it does not: the warning needs two sources at one emitted name, and a Tailwind-shaped pipeline writes `site.css` with no `site.scss` to collide with, so nothing fires at all. What a pipeline site _can_ have is a leftover `site.scss` beside the generated file — dead, silent, and precisely what this exists to catch. Suppressing on `pipeline` would blind the one case it is for.
48
+
49
+ - The registry reconciles all attempted file outputs, including refused writes, separately from the manifest of emitted files. Deleting a refused source therefore clears its collision even when it never reached the manifest. Directories are not output producers.
50
+
51
+ - Source-stat failures during inventory discovery are skipped, including dangling links and files disappearing between glob and stat. Healthy files still reconcile. The source-stat ENOENT transition was tested on Windows; creating a real file symlink was denied by the host, so actual dangling-link behavior was not executed here.
52
+ - `assetCopyOwner(source, target)` derives the shared owner identity. Kiss supplies the requested destination before staging; direct module callers use their target. Watch copies use the original registration paths, including the build-root anchor used for manifest names.
@@ -10,8 +10,8 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
10
10
  - `ok` — `failures.length === 0`.
11
11
  - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
12
  - `buildDir` — the folder the site asked for, never the staging sibling.
13
- - `duration` — ms since `startedAt` (`Kiss` passes its construction time).
14
- - `pages` — `{ view, buildTo, ok, hash, id }` per stack entry, in registration order; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
13
+ - `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
14
+ - `pages` — `{ view, buildTo, ok, hash, id, canonical }` per stack entry, in registration order; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
15
15
  - `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
16
16
  - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
17
  - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
@@ -21,7 +21,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
21
21
  - `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, 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
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
- - `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.
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 — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the 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.
26
26
 
27
27
  ## Depends on
@@ -30,7 +30,7 @@ Nothing.
30
30
 
31
31
  ## Depended on by
32
32
 
33
- `lib/kiss.js` (assembles one report per settled build in `_finishBuild()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
33
+ `lib/kiss.js` (assembles one report per settled build in `_finishBuild()`, and re-derives it in place through `_refreshReport()` whenever something changes the failure list without settling a build — a watch asset re-copy, a helpers reload — so `ok` is never behind the log. The refresh re-runs `buildReport` and nothing else: `_finishBuild()`'s once-per-build side effects (the `KISS_REPORT` line, the `last-build.json` record, `dependency-graph.json`) stay per build, and both call sites read one `_reportInputs()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
34
34
 
35
35
  ## Non-obvious behavior
36
36
 
@@ -48,10 +48,14 @@ Nothing.
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
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
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.
51
+ - **The four derived-output 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
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.
53
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.
54
54
 
55
55
  ## Types
56
56
 
57
57
  `BuildReport`, `BuildPage`, `BuildAsset`, `BuildReportFailure`, `BuildPipelineStep`, `BuildAikb`, `BuildLinks`, `BuildBrokenLink` and `BuildRedirects` are declared here and re-exported from `lib/kiss.js` (`@typedef {import('./build-report.js').BuildReport} BuildReport`), the same way `KissConfig` is re-exported from `lib/config.js` — so a consumer reaches every one of them as `import('kiss-ssg').BuildReport`.
58
+
59
+ - `outputs` is appended after `robots`, always `{ collisions: [] }` when none were observed. Each collision has `file`, `producers` (`{ owner, kind }`), `winner` (claim or null) and `refused` (owner strings). Both resolved staging paths and resolved build paths are mapped to the configured build-folder spelling. Collisions track active producers; resolved conflicts disappear on reconciliation and standing conflicts survive replay; they are advisory and never change `ok` or the check exit code. `formatReport` includes an `output collision:` line with producers, winner and refused owners.
60
+
61
+ - The shared `real()` projection handles configured and resolved staging prefixes and the resolved build prefix for every reported file, including output collisions. Registry identities remain absolute.
package/AIKB/check.md CHANGED
@@ -18,9 +18,12 @@ Two commands share all of it. `check <script>` runs the site's build staged and
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`.
20
20
 
21
+ - `describeEngine({ cwd, from = cwd })` → `{ version, dir, linked, target } | null` — the nearest `node_modules/kiss-ssg` walking up from `from`, which the bin sets to the **script's** folder because that is where Node resolves its imports (a caller standing elsewhere would otherwise be told about its own copy — Codex, on review), with `dir` relative to `cwd`: its `package.json` version, its path relative to `cwd`, whether `lstat` says it is a symbolic link (a Windows junction counts), and the link's real target when it is. `null` when there is none above `cwd`.
22
+ - `engineLine(engine)` → the line the bin prints on stderr before the build, in both output modes: `kiss-ssg 2.5.0 from node_modules/kiss-ssg`, or `… — a link to C:/Code/kiss-ssg, not the registry package` for a target outside any `node_modules` (a working tree), or the neutral `… — a link into <target>` for one inside (pnpm lays every package out as a link into `node_modules/.pnpm/…`, and that is the registry package — Codex, on the close's review), or the honest `kiss-ssg: no node_modules/kiss-ssg found from here — the script resolves the package some other way`.
23
+
21
24
  ## Depends on
22
25
 
23
- Nothing (`readReports`' return type refers to `./build-report.js`, but only as a type).
26
+ `node:fs` and `node:path`, for `describeEngine` alone — it `lstat`s `node_modules/kiss-ssg` and reads its `package.json`, walking up from the script's folder. Everything else here is pure. (`readReports`' return type refers to `./build-report.js`, but only as a type.)
24
27
 
25
28
  ## Depended on by
26
29
 
@@ -35,6 +38,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
35
38
  - **A check runs `config.assets.pipeline` exactly as a build does**, and that is deliberate rather than an oversight in what check mode discards. A step writes into the site's own **source** tree (`src/assets/css/site.css` — see `AIKB/pipeline.md`), so there is nothing extra for the check to throw away: what it stages and discards is still only the build folder. The consequence worth knowing is that a check is not read-only over the working tree — it regenerates whatever its steps generate, and a failing step fails the check like a failing page, reported as `<pipeline: <name>>`. In `dev: false` (which check mode forces) no `watch` process is ever started, so a check always terminates.
36
39
  - **Pages are diffed by output path and compared by hash, not by rendering anything twice.** `BuildPage.hash` is the sha1 the page recorded for the bytes it wrote (`AIKB/kiss-page.md`), so the comparison is over what a browser would receive: a template rewritten to produce identical HTML is genuinely unchanged, and a model edit that changes one word is a change to exactly one page. Nothing is read off the disk here, which is what lets a check — which publishes nothing — still say what it would have changed.
37
40
  - **A `null` hash on either side counts as changed, never as unchanged.** A page that failed, one registered with `generate: false`, and one from a report written before hashes existed all carry `null`; "unchanged" is the single answer that would be a lie about any of them, and a diff that under-reports is worse than one that over-reports.
41
+ - **The engine line goes to stderr, before the build, in both output modes.** It is a fact about the environment rather than part of the report, so stdout stays the report — the JSON consumer's contract — while a person still sees it beside the build log. Why it exists: the 2.5.0 fleet upgrade hit the same trap on six of six sites — after `file:../../kiss-ssg` was edited to `^2.5.0`, a plain `npm install` kept the junction because the lockfile's `"link": true` entry already satisfied the range, so every site reported a registry version while building against the working tree, and only a hand check of `node_modules/kiss-ssg` said otherwise. `check` is the one command every upgrade runs, so it says which engine it found and whether that folder is a link. `test/integration/check.test.js` pins both wordings end to end, the linked one through a real junction.
38
42
  - **Reports are paired by `buildDir`, and the pairing is deliberately one-sided.** One script may build several sites, and page paths in `./public/a` say nothing about `./public/b`. An `after` report with no partner is a site the old file never saw, so all of it is `added`; a `before` report with no partner is _ignored_, because this run did not build that site at all — which is not the same as having removed its pages, and reporting it as a removal would make a per-season build script look like it deleted every other season.
39
43
  - **`readReportsFile` sniffs the shape rather than being told it.** Two of the three shapes are called `.json`, so the extension cannot decide, and being wrong would be a usage error over something the bytes already answer. `JSON.parse` is tried first and JSON Lines is the fallback, not the other way round: a one-line file holding a single report is valid JSON too, and only a multi-line file `JSON.parse` rejects is JSON Lines. A file that is neither still throws, from `readReports`, naming the line — the same rule as before.
40
44
  - **The two mechanical note lints print here too, and change nothing else.** `--summary` prints ` note stale: <path>` for a note whose `subject-hash` stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` for a file reference in a note (or in `site.md`) that resolves to nothing — beside the older `note missing:` and `note dead:` lines, all four straight from `formatReport`. Like the other two they are findings: `exitCodeFor` does not read them, so a check full of them still exits 0. What makes them worth printing on a plain `check` is that the report also carries `aikb.subjects`, so the hash a stale note wants restamping with is in the same output, before any record has been made.
@@ -45,7 +49,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
45
49
  - **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`.
46
50
  - **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.
47
51
  - **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.
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.
52
+ - **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/9-migrated-from-v1 && node router.js`), so moving it would check a different set of paths from the ones it builds with.
49
53
  - **The temp reports file is removed in a `finally`**, and the bin sets `process.exitCode` rather than calling `process.exit()`, so the cleanup runs before the process leaves.
50
54
 
51
55
  ## Types
package/AIKB/config.md CHANGED
@@ -13,14 +13,14 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
14
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
15
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`).
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.
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).
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 (preserving the leading UNC share prefix), trailing slashes stripped.
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 unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
18
18
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
19
19
  - `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
20
20
 
21
21
  ## Depends on
22
22
 
23
- `node:path` (resolving the build/source folders for the safety guard below) — nothing else.
23
+ `node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names).
24
24
 
25
25
  ## Depended on by
26
26
 
@@ -30,7 +30,9 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
30
30
 
31
31
  - **`cleanBuild` has three values, and the two that clean do it at different moments.** `true` (the default) calls `fs.emptyDirSync(folders.build)` in `_setupFolders`, i.e. **in the `Kiss` constructor** — before any model resolves and before any page renders — and nothing rolls it back if the build then fails. So a re-run of a build folder that holds published output destroys it first and, on a failure, leaves it empty or half-built (consumer review finding B-3). `false` never cleans. `'atomic'` builds into a staging sibling and swaps it in only when `complete()` resolves; the old output is untouched until that moment and survives a failed build. See `AIKB/kiss.md` for the staging mechanics — this module only carries the value.
32
32
  - **The value is validated, not coerced.** Anything but `true`, `false` or `'atomic'` throws out of `resolveConfig` (so out of `new Kiss`), including `null` and `0`. A silently-falsy value is exactly the D-07 failure mode in a new coat: `{ cleanBuild: opts.clean }` with an unrecognised flag would quietly stop cleaning, or quietly stop staging.
33
- - **A build folder that contains the source folder is refused at construction**, whatever `cleanBuild` says (`false` today is one edit from `true`): `resolveConfig` throws when the resolved `folders.build` is the resolved `folders.src`, is an ancestor of it, or is a filesystem root — which is what catches `build: '.'`, `'./'` and `'/'` on a default layout. It is a **floor, not a fix**: a build folder that holds published output but is not above the source — metacarpus's `./handbooks` archive root, wiped whole by an empty cohort name (finding B-2) — is _not_ caught, because nothing in the config distinguishes it from any other legitimate build folder. That case is answered by validating the name in the consumer and by `cleanBuild: 'atomic'`.
33
+ - **Source/output safety is checked before any filesystem mutation**, for every `cleanBuild` value. `folders.src` cannot resolve to the working project root or a filesystem root. Neither can `folders.build`, regardless of whether `src` is disabled or elsewhere. The build must not equal or contain any configured source folder (`src`, its six derived folders, `helpers`, `aikb`), nor be nested inside an individual content folder. Only `src` allows output below it: `src/public` stays valid if separate from every content folder. Null folders are excluded.
34
+ - **Compare both lexical and real locations.** `realFolder` resolves the nearest existing ancestor and appends missing descendants, so `alias/future` is checked before `future` exists. `folderLocations` keeps the lexical spelling too: deleting a source link itself is destructive even if its target is elsewhere. `path.relative` supplies segment boundaries and Windows case semantics. Inspection errors and dangling links fail closed with the setting named in the error; failures inspecting the working directory name that directory rather than blaming `folders.src`. None of these helpers writes to disk.
35
+ - **Configured folder protection is not arbitrary-file protection.** An archive root containing earlier output but no configured source is still a legal destination. Validate variable output names in the consumer and use atomic builds when old output must survive a failed build. Custom pipeline commands, extra copy destinations, and filesystem changes after validation are not governed by this constructor check. Unit tests cover root rules and valid boundaries; integration tests cover every protected folder in all cleanup modes, aliases and missing descendants, asserting source sentinels and directory structure survive rejection.
34
36
 
35
37
  - **Folder strings are normalised once, here, because everything downstream is path arithmetic.** A trailing slash on `folders.assets` made `assets.js`'s target derivation miss (compiled CSS landed beside the build folder as `./publicmain.css`, logged as a success), and one on `folders.models` made `model-resolver.js` chop the first characters off every filename so a whole `.pages()` fan-out resolved to `[]`. Both are the same missing normalisation. `./` and `/` keep their slash — trimming them to `.` or `''` would change which directory they name — and a non-string value (`null`) is passed through untouched.
36
38
  - Setting `config.folders.src` re-derives the six `DERIVED_FROM_SRC` subfolders from it, _unless_ each is also individually overridden in the same `userFolders` object (the final spread `{ ...folders, ...supplied }` lets explicit keys win over the src-derived ones).
@@ -54,7 +56,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
54
56
 
55
57
  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.
56
58
 
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.
59
+ - **`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 warning 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
60
 
59
61
  - **`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
62
 
@@ -66,3 +68,8 @@ The typedefs that describe the config to a consumer live at the top of this modu
66
68
  - **`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.
67
69
  - **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.
68
70
  - `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.
71
+
72
+ - **`folders.helpers` is neither derived from `src` nor ensured.** It defaults to `./helpers`, beside the build script, for the same reason `aikb` does: it is code the site author writes, not content derived from `src`. It is absent from `foldersToEnsure` because a site with no custom helpers should not acquire an empty folder on start-up — and its absence is the ordinary case, which `lib/site-helpers.js` handles silently rather than warning about. Because it is a _default_, `Kiss` also records whether the site supplied the key at all (before `resolveConfig` erases the difference): a folder kiss guessed that turns out to hold no registrar is a warning and the build continues, where one the author named is a build failure. See `AIKB/site-helpers.md`.
73
+
74
+ - Folder containment uses the shared `isInside` predicate from `utils.js`, the same whole-segment and platform-case comparison as watcher exclusions.
75
+ - Alias checks retain intermediate spellings obtained by resolving each parent prefix, as well as the lexical path and final target. An aliased project/temp parent must not hide a source junction inside output just because that junction ultimately targets a separate content folder. The nested-junction integration regression was observed failing before this correction; ordinary paths whose real and lexical locations agree avoid the extra prefix walk.
package/AIKB/design.md ADDED
@@ -0,0 +1,149 @@
1
+ # design
2
+
3
+ Cross-cutting, like `testing.md`: not a module, but the things that are true of kiss as a whole and that
4
+ every module doc assumes. Read it when a decision feels like it is being argued from first principles for
5
+ the second time.
6
+
7
+ ## Responsibility
8
+
9
+ Holds what kiss **is** and where it came from, so that design arguments resolve against a written position
10
+ rather than being re-derived. `CLAUDE.md` § Design philosophy states the position; this doc holds the
11
+ lineage and the worked consequences that are too long for it.
12
+
13
+ ## Lineage
14
+
15
+ kiss descends from **SCMS ("simple CMS")**, a Ruby static CMS its author wrote while learning Ruby. kiss is
16
+ its version 2 in intent — "reasonably compatible and easy to upgrade" — which is why a real SCMS site was
17
+ the edge test that produced most of the notes in this folder.
18
+
19
+ SCMS's shape was a folder per page: `_pages/<slug>/` holding a `_config.yml` and one content file per layout
20
+ hole, with the **file extension choosing the renderer**. The unit of work and the unit of storage were the
21
+ same thing — to change the about page you opened `_pages/about/` and everything about that page was in front
22
+ of you.
23
+
24
+ kiss trades that for a single answer to "what pages does this site have": `router.js`. That is the right
25
+ trade for kiss's goal, because an agent's hardest problem is exactly that question. But it **is** a trade,
26
+ and naming what it cost stops it being re-litigated:
27
+
28
+ - **A page has no single home.** Changing one can touch `src/pages/`, `src/models/`, `src/controllers/`,
29
+ `src/partials/` and `router.js` — five directories, five tables.
30
+ - **A page cannot be half-deleted in SCMS; in kiss it can.** Delete a view and you leave a `.page()` call
31
+ that fails the build, a model nobody reads, and partials nobody renders. Failing loudly is better than the
32
+ alternative, but nothing enumerates the leftovers.
33
+ - **Both shapes are legible, to different people.** A folder named `about` containing `intro`, `leadin` and
34
+ `main` explains itself to anyone. `router.js` explains itself to a programmer.
35
+ - **kiss's cost lands on maintenance, SCMS's landed on comprehension.** A conversion exercises neither, which
36
+ is why a migration is a poor test of the thing it appears to be testing.
37
+
38
+ **The inherited idea kiss keeps** is that the extension states the processing — see `AIKB/partials.md` for how
39
+ `.hbs`/`.md`/`.html`/`.txt` carry that through.
40
+
41
+ ## The organising principle
42
+
43
+ **Fail at build time and name the file.** It is the single idea most of `lib/` is an instance of:
44
+ `{{link}}` fails on an id no page claims and names the id and the view; two pages claiming one output path
45
+ fail and name the path; `assertBuildFolderIsSafe` throws and names both folders; a `.hbs` that cannot be read
46
+ fails that page rather than writing the filename into the output.
47
+
48
+ Stated as a rule for new work: **find every place kiss knows something is wrong and says nothing, and make it
49
+ say so.** A silent, deterministic, wrong result on a green build is the worst thing this codebase can
50
+ produce, and every defect a real conversion found in 2.4.0 was an instance of it — a sass compile silently
51
+ overwritten, a dev build written into the published folder, a watch rebuild that reported success and
52
+ served stale output, an AIKB lint noisy about something correct (the same failure inverted).
53
+
54
+ ## Prior art: the same three problems in other generators
55
+
56
+ Recorded 2026-09-23, after six review rounds on `codex/protect-source-folders` had all landed on the same
57
+ three problems: two producers writing one output path, stale output after a rebuild, and a live-reload
58
+ message with no recipient. Every static site generator meets them. Checked against Jekyll, Eleventy, Hugo
59
+ and Astro (whose dev server is Vite), from their current docs and issue trackers; the sources are listed at
60
+ the end of this section so the check can be repeated rather than re-derived.
61
+
62
+ **Two producers, one path.** Everyone warns or fails; nobody restores the loser afterwards.
63
+
64
+ | Generator | Behaviour |
65
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | Jekyll | warns `Conflict: The following destination is shared by multiple files`, extended to static files in 2020; the build continues |
67
+ | Eleventy | throws `DuplicatePermalinkOutputError`; the build fails (an open issue asks for a config key to soften it) |
68
+ | Astro | `prerenderConflictBehavior`: `warn` (default), `error` or `ignore`; the highest-priority route wins |
69
+ | Hugo | silent overwrite unless `--printPathWarnings` is passed |
70
+ | kiss | warns, names both producers, and puts the collision on the report as `outputs.collisions`; advisory, no config key (`AIKB/output-registry.md`) |
71
+
72
+ kiss's is Jekyll's shape plus the report. Astro's configurable warn-or-error is the nicest version and is a
73
+ one-key addition if a site ever wants a collision to fail the build.
74
+
75
+ **Stale output after a rebuild.** The others avoid the problem rather than solve it.
76
+
77
+ - Jekyll wipes the destination on every build ("files or folders that are not created by your site will be
78
+ removed"), with `keep_files` for what other tools put there. Its incremental mode is still labelled
79
+ experimental and "may break site generation".
80
+ - Eleventy never cleans: a deleted source stays in `_site` and the docs say delete the folder. A community
81
+ plugin keeps an inventory of "files previously created but no longer", which is what kiss's asset
82
+ reconcile does.
83
+ - Hugo's dev server can render to memory (`--renderToMemory`), so there is no stale file on disk to clean;
84
+ production has `--cleanDestinationDir`.
85
+
86
+ kiss does three different things, and the split is the point: a one-shot `cleanBuild: true` empties the
87
+ folder in the constructor (Jekyll's wipe), `'atomic'` stages and swaps (Hugo's "nothing stale can exist"
88
+ without the memory), and `false` never cleans (Eleventy's default). A **watch replay** is the exception: it
89
+ does not wipe, it sweeps by ownership — the output registry for pages and generated files, the manifest
90
+ inventory for assets. That is the hard road, and it is where the six rounds went: every defect was a
91
+ transition the ledger got wrong (a Sass error deleting the last-good stylesheet, a rename recorded as a
92
+ collision, an asset shadowed by a deleted page never restored). It was chosen so a replay re-copies nothing
93
+ it does not have to. **The fallback, if the lifecycle keeps costing more than it saves:** a replay already
94
+ re-renders every page, so a replay that stages and swaps, exactly as one-shot `'atomic'` does, would delete
95
+ the sweep, the page side of the registry and the restore logic, at the price of re-copying assets per
96
+ replay (Hugo's `--forceSyncStatic` is that trade, named). Not adopted; recorded so the next person weighing
97
+ it starts from here.
98
+
99
+ **A reload with no recipient.** livereload-js reconnects with a one-second backoff and replays nothing, so a
100
+ refresh broadcast in the ~200 ms between a reload's DOM-ready and the new socket is lost (measured; see
101
+ `AIKB/upstream.md`). Vite's client reloads the page whenever its socket reconnects, which covers the lost
102
+ message at the price of the opposite complaint, needless reloads. Eleventy's dev server has its own
103
+ WebSocket server and updates the DOM by diffing, so the page rarely reloads and the window rarely opens. kiss
104
+ keeps the documented window: the client is livereload-js's, not ours, and a human saving files cannot hit
105
+ it.
106
+
107
+ **The watcher.** Chokidar is the Node norm. Eleventy uses it outright; Vite still uses chokidar 3.6 for files
108
+ outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar 5.0.0, the latest, and
109
+ none of the three problems above is the watcher's.
110
+
111
+ Sources, as checked on 2026-09-23: Jekyll PR jekyll/jekyll#8459 and `docs/configuration/options` and
112
+ `docs/configuration/incremental-regeneration`; Eleventy issue 11ty/eleventy#3001, discussion #2294,
113
+ `docs/dev-server`, and `kentaroi/eleventy-plugin-clean`; Hugo `commands/hugo_server` and `commands/hugo`;
114
+ Astro `reference/configuration-reference` (`prerenderConflictBehavior`); Vite `config/server-options`
115
+ (`server.watch`) and issue vitejs/vite#5675; the livereload-js README.
116
+
117
+ ## The documentation rule
118
+
119
+ **Any behaviour whose consequence is a wrong artefact rather than an error states that consequence in the
120
+ same sentence as the rule.** This is not a style preference; it is the diagnosis of a measured failure. Of
121
+ ten problems a competent agent hit building a real site on 2.4.0 unaided, **eight were cases where the
122
+ documentation was correct and it got it wrong anyway** — because a reader retains the consequence and
123
+ forgets the rule.
124
+
125
+ STATED "in dev: true it degrades to true and logs one notice"
126
+ RETAINED nothing — and a preview build went into the published folder
127
+ NEEDED "...so pointing dev at your published folder leaves a preview build in it"
128
+
129
+ There are not many such behaviours. `cleanBuild: 'atomic'` in dev, non-dev minification, helper
130
+ registration, and the asset precedence in `AIKB/assets.md` are the set as it stands.
131
+
132
+ ## Non-obvious behavior
133
+
134
+ - **What ships is an example.** `examples/` is in the `files` whitelist so an agent in a consuming project can
135
+ read it, which means every example is copied as a claim about good practice. Each one is therefore a
136
+ standalone project that builds unmodified when copied out — the import is `'kiss-ssg'` (resolved in-repo by
137
+ package self-reference), the output is its own `public/`, and nine of eleven configure no `folders` at all,
138
+ because the defaults are the shape. That last fact is the philosophy's best demonstration and the reason
139
+ the restructure was worth its diff.
140
+ - **Skills do not ship, and cannot be assumed.** `plugins/` is not in the `files` whitelist. A real conversion
141
+ was completed without them because nothing in the installed package revealed they existed. So anything an
142
+ unaided agent needs in order to build correctly belongs in `llms.txt`, which ships; a skill may hold a
143
+ procedure the operator opts into, never a fact the build depends on.
144
+ - **The steer is on the shape of the code, not the shape of the content.** `{{link}}` punishes a hand-written
145
+ URL; `folders.models` existing makes "this repeated markup is data" the path of least resistance. Nothing
146
+ resists collapsing a site's editable content into templates — measured: a conversion turned 39 separately
147
+ editable content files into 11 templates with 2 non-template editing surfaces, irreversibly, and neither
148
+ the engine nor the output diff could see it. For a tool descended from a CMS that is the open gap, recorded
149
+ here rather than solved.
package/AIKB/feed.md CHANGED
@@ -8,7 +8,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
8
8
 
9
9
  - `feedFileName(options = {})` → `options.filename` (whitespace-collapsed), else `'feed.xml'`. The one place the default lives, so `Kiss.feed()` and `writeFeed` cannot disagree about where the file went.
10
10
  - `toDate(value)` → a `Date`, or `null`. Accepts a `Date` (copied, never aliased), a finite number read as epoch milliseconds, or a non-blank string `new Date()` parses. Anything else — a boolean, an object, `'last Thursday-ish'`, `Infinity`, an `Invalid Date` — is `null`.
11
- - `buildFeedItems(stack, { siteUrl, buildDir, section, dateField = 'date', limit = 20, logger })` → `{ items, undated }`. One `{ title, url, description, date }` per page that survives every filter and carries a readable date; `undated` counts the pages that survived the filters but had no usable one. Skips a page whose `page.options` has `ignoreFeed`, `ignoreSitemap` or `generate === false`, and — when `section` is given — one whose first `path` segment is not that section (both sides through `topSegment`, so `'/Blog/'` matches `blog/`). `url` is `toAbsoluteUrl(siteUrl, toCanonicalPath(buildTo.slice(buildDir.length)))` — the identical join `buildSitemapEntries` and `buildLlmsEntries` make. `title` and `description` come from `llms.js`'s own `entryTitle`/`oneLine`. Sorted newest first with the `url` as the tie-break, then truncated to `limit` (a non-finite or negative `limit` means no cap; `0` means no items).
11
+ - `buildFeedItems(stack, { siteUrl, buildDir, section, dateField = 'date', limit = 20, logger })` → `{ items, undated }`. One `{ title, url, description, date }` per page that survives every filter and carries a readable date; `undated` counts the pages that survived the filters but had no usable one. Skips a page whose `page.options` has `ignoreFeed`, `ignoreSitemap`, `canonical` (its real home is elsewhere — the sitemap and `llms.txt` exclude it for the same reason) or `generate === false`, and — when `section` is given — one whose first `path` segment is not that section (both sides through `topSegment`, so `'/Blog/'` matches `blog/`). `url` is `toAbsoluteUrl(siteUrl, toCanonicalPath(buildTo.slice(buildDir.length)))` — the identical join `buildSitemapEntries` and `buildLlmsEntries` make. `title` and `description` come from `llms.js`'s own `entryTitle`/`oneLine`. Sorted newest first with the `url` as the tie-break, then truncated to `limit` (a non-finite or negative `limit` means no cap; `0` means no items).
12
12
  - `renderRss(channel, items)` → the document's text, one trailing newline. `channel` is `{ title, link, description, feedUrl }`. Emits `<rss version="2.0" xmlns:atom="…">`, a `<channel>` with `title`/`link`/`description`/`atom:link rel="self"`, `<lastBuildDate>` when there is at least one item, then one `<item>` per entry with `title`, `link`, `guid isPermaLink="true"`, `pubDate` (`toUTCString()`) and `description` (omitted when empty). Every text node and attribute value is XML-escaped.
13
13
  - `async writeFeed(stack, { config, logger, options, overwrite = true })` → `Promise<{ status, text }>`. `status` is `'no-site-url'` or `'no-title'` (nothing written), `'skipped'` (`overwrite: false` and a file exists), or `'written'`.
14
14
 
@@ -22,6 +22,8 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
22
22
 
23
23
  ## Non-obvious behavior
24
24
 
25
+ - The optional `outputs` dependency is Kiss's shared output registry. Each successful file write claims its actual path at the write site; skipped or failed writes claim nothing. Collisions warn, and later asset cleanup cannot delete this writer's output. Custom filenames participate without a whitelist.
26
+
25
27
  - **`<lastBuildDate>` is the newest item's date, never the wall clock.** The success criterion for this file is that two identical builds produce identical bytes — a build script that commits its output, and a `kiss-ssg check` diffed against a recorded build, both depend on it; so would `test/integration/replay-equivalence.test.js`'s byte-comparison of two build folders, the day a site under it calls `.feed()`. A `new Date()` in the channel would make every build differ from the last for no reason anyone could act on. The newest item's date is the honest answer anyway: nothing in the feed changed since then. With no items there is no date to claim, so the element is left out rather than invented. `lib/sitemap.js` makes the same move one step less far, injecting `now` as a parameter; here there is no parameter to inject because there is nothing a caller could usefully pass.
26
28
  - **A date is read from the page option first and the resolved model second, under one field name.** `options[dateField] ?? options.model?.[dateField]` — `dateField` governs both lookups, so a site that dates a post in its `models/posts/*.json` and a site that dates it in the `.page()` call read the same key and neither has to know which the other used. By settle time `options.model` **is** the resolved data (the `.pages()` fan-out has already given each item its own), which is what makes the model lookup possible at all. It is guarded for `null` (a page with no model) and for an array (a non-`dynamic` page handed an array model, which has no date of its own); both are undated, neither is an error.
27
29
  - **A page with no date is silent; a date that cannot be read warns once.** Most of a site is undated — an About page is not a post — so warning per undated page would bury the feed's own output in noise on every build. A value that was _given_ and cannot be parsed is a typo the author wants to hear about, so it gets one `logger.warn` naming the page and the value, and the page joins the undated count. Neither ever throws: a feed is not the render error channel, and a build minus one item is still the build the author asked for. `writeFeed` logs the total as one `info` line, which is the only place the count surfaces — `report().feed` is the path, not the tally.
@@ -13,9 +13,9 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
13
13
  - `stringify(obj)` — `JSON.stringify(obj, null, 3)`.
14
14
  - `lookup(obj, field, options)` — same signature and return as Handlebars' built-in (falsy `obj` returned as-is, otherwise `options.lookupProperty(obj, field)`). An `undefined` result additionally logs one warning: `lookup: '<field>' is undefined` plus ` in <view>` when the root context carries a `view`.
15
15
  - `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href.
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`.
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`. **A page's own `canonical` option wins outright**: when the root context carries a non-empty `canonical` string it is rendered verbatim, before `siteUrl` or `pageURL` are consulted — the page has named another URL as the real one (learna-kiss: 104 course pages whose canonical is the same course on diploma-msc.com). The value is validated at registration (`Kiss._preparePage`, `AIKB/kiss.md`), not here, because the sitemap, `llms.txt` and the feed read the same option and withdraw the page.
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
- - `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 `''`.
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 reference that is not a path into this build is returned unchanged: any scheme (`https:`, and also `data:`/`mailto:`, which carry no `//`) and the protocol-relative `//cdn/x.css`. The original test required `://`, so the last two fell through to a manifest lookup they could never satisfy — harmless while the helper warned, a failed build once it threw. A `?query`/`#fragment` is split off before the lookup and re-appended after, because the manifest is keyed by the path a copy emitted: `img/logo.svg#symbol` is an SVG sprite reference to a file that IS in the build, and looking the whole string up failed the build while telling the author the file was missing. `lib/links.js` splits references the same way, for the same reason. The failure message names the reference **as the template wrote it**, and the key underneath it when the two differ (`asset: '/css/x.css#a' … no .copyAssets() emitted 'css/x.css'`, `emitted it` when they are the same string); it named the key alone until an independent review pointed out that the key — slash stripped, suffix gone — is a string the author never typed and cannot search for, while the dev warning had always named the reference. A path the manifest does not know **fails the build**, the same shape as `link` on an id no page claims — they are the two halves of "never hand-write an internal URL" and were checked to different depths until R25. Warning and rendering meant the build passed, `report().ok` stayed true and the site shipped a 404; it also stripped the leading slash first, so `{{asset "/missing.css"}}` became `missing.css`, which on a nested page is a different URL. In **dev** it warns once per page per path (deduped on the page's root data frame, so a shared layout does not warn per page render) and renders the path **as written** rather than the stripped form, so the browser's 404 names what the template asked for. A non-string path logs a warning and renders `''`.
19
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
 
@@ -29,7 +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`.
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 .. 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
+ - **The positional argument is the page context itself, and the docs said otherwise for a long time.** A page's render context is exactly `title`, `path`, `slug`, `generate`, `view`, `config`, `model`, `pageURL` — there is no `page` key — so the documented `{{#isActive page …}}` handed the helper `undefined` and logged `isActive received no page context` once per call on every page. Write `this`, or a bare `..` inside an `{{#each}}` (which is what `examples/11-blog` does). **And the block always renders**: the match is `{{active}}` inside it, never an `{{else}}`, so the documented literal `class="active"` inside the block printed on every page of the site. Both were found by a clean-room conversion and both were invisible to `test/unit/handlebars-helpers.test.js`, which fabricates a `{ page: { pageURL } }` context — the shape the docs described rather than the shape a render has. `test/integration/is-active.test.js` renders through a real build for exactly that reason: a unit test that builds its own context cannot catch a doc that describes a different helper.
33
34
 
34
35
  - 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.
35
36
  - 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).
@@ -48,10 +49,10 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
48
49
  - 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.
49
50
  - **`asset` reads a manifest, it does not look at disk.** The manifest is filled by the one `copyAssets` run the constructor queues, and `generate()`/`complete()` await `_promises` — which that run is on — before any page renders, so the helper always sees a filled manifest. A watch-mode asset edit refills it and, under a hashing policy, `Kiss` re-renders the stack so the new filename reaches the pages (`AIKB/kiss.md`).
50
51
  - **The helper returns no leading slash, deliberately.** A root-relative URL is one form of link, not the only one: the examples' shared layout climbs back to the build root with `{{root}}` so a page opens straight off the file system, and a site on a sub-path needs its own prefix. `/{{asset 'css/site.css'}}`, `{{root}}{{asset 'css/site.css'}}` and `{{absUrl (asset 'css/site.css')}}` are the three forms, and the helper stays out of that decision.
51
- - **The version query is the one helper output that is a `SafeString`.** Handlebars escapes `=` in a `{{...}}` expression, so a plain string comes out of the template as `?v&#x3D;1.2.3`. The path is escaped with `hbs.Utils.escapeExpression` first and the query appended after it, so the escaping is exactly what Handlebars would have done and only the separator survives.
52
+ - **`asset` returns a `SafeString` exactly when the URL it rendered carries a `=`.** Handlebars escapes `=` in a `{{...}}` expression, so a plain string comes out of the template as `?v&#x3D;1.2.3`. Two branches produce one: `assets.version`'s own `?v=…`, and an author's own `?query` suffix riding on the emitted name (`{{asset "css/site.css?v=2"}}`). Both escape with `hbs.Utils.escapeExpression` and then put `=` — and only `=` — back (`unescapeEquals`), so the escaping is exactly what Handlebars would have done and the one character that makes a URL unrecognisable to the author who wrote it survives; `=` cannot terminate an attribute value, quoted or not. A name with no suffix under no policy is returned as a plain string and escaped normally. **The pass-through branch is deliberately not in this set**: a `https://`, `//host/…` or `data:` reference can have arrived from a model, so it keeps full escaping and a browser decodes the `&#x3D;` itself.
52
53
  - **`absUrl` unwraps and re-wraps a `SafeString` argument** so `{{absUrl (asset …)}}` composes under every policy. Without it the version mode's `SafeString` would fail `absUrl`'s "needs a path string" guard and render nothing, and re-escaping the result would put `&#x3D;` back. A path that already carries a scheme is returned exactly as it arrived, wrapper included.
53
54
  - **`asset` reads the instance config, not the page's** — unlike `canonical`/`absUrl`, which read `root.config.siteUrl`. The policy was applied once, by the copy that emitted the files, before any page rendered; a per-page override could only disagree with what is on disk.
54
- - `asset` degrades like every other helper: an unknown path is the author's own build, so it renders what they asked for and names the path once per page (the `lookup` `WeakMap`-of-`Set`s pattern), rather than failing the page.
55
+ - **`asset` no longer degrades, and that was a deliberate reversal.** It used to reason that an unknown path is the author's own build, so it rendered what they asked for and named the path once per page. The consequence was a build that passed with a 404 in it — `report().ok` true, `kiss-ssg check` ok, the stylesheet absent — which is the verdict-versus-reality gap the Sass work closed twice elsewhere on this branch, and kiss's own documented bar says ok and exit 0 are the only passing result. It now fails the build outside dev, like `link`. **Dev keeps the degrade**, and keeps the once-per-page dedupe (the `WeakMap`-of-`Set`s pattern), because the file you are about to add legitimately is not there yet — but it renders the path as written rather than stripped, so the browser's 404 names what the template asked for.
55
56
 
56
57
  - **`link` is the only helper that throws, and only in a real build.** Every other helper degrades because a template mistake is a worse reason to lose a page than a missing highlight. An id is different in kind: a path is a claim about a host the engine cannot see (which is why a broken one is an advisory finding — `AIKB/links.md`), while an id is a claim about _this build's registry_, checkable exactly at render. A link that cannot be resolved is therefore a build failure — recorded like any other page failure, with no bytes written, `hash: null`, and `complete()` rejecting — except under `config.dev`, where it takes the `{{asset}}` stance: a warning and a `#`, so the live reload shows the page and the mistake together.
57
58
  - **The registry arrives as a function, and that is load-bearing.** `registerHandlebarsHelpers` is called once, in the `Kiss` constructor, and is never re-run — not even by `_replay()`, which replaces `_stack` with a brand-new array. A helper closing over the array, or over an index built at registration, would serve the first build's registry to every watch rebuild for the life of the session. `Kiss` therefore passes `lookupPage: (id) => this._lookupPage(id)`, and the index behind it is memoised against the live stack and invalidated whenever a page is pushed (`AIKB/kiss.md`).