kiss-ssg 2.6.2 → 2.8.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 (229) hide show
  1. package/AIKB/aikb.md +1 -1
  2. package/AIKB/asset-copy.md +67 -0
  3. package/AIKB/asset-manifest.md +6 -3
  4. package/AIKB/assets.md +6 -4
  5. package/AIKB/audit.md +51 -0
  6. package/AIKB/build-finish.md +56 -0
  7. package/AIKB/build-report.md +8 -5
  8. package/AIKB/check.md +13 -4
  9. package/AIKB/config.md +10 -5
  10. package/AIKB/controller-resolver.md +1 -1
  11. package/AIKB/handlebars-helpers.md +4 -2
  12. package/AIKB/html-split.md +83 -0
  13. package/AIKB/init.md +10 -7
  14. package/AIKB/kiss-page.md +11 -4
  15. package/AIKB/kiss.md +22 -89
  16. package/AIKB/links.md +9 -5
  17. package/AIKB/llms.md +3 -2
  18. package/AIKB/markdown-copy.md +29 -0
  19. package/AIKB/model-resolver.md +1 -1
  20. package/AIKB/output-registry.md +3 -1
  21. package/AIKB/package-manifest.md +37 -0
  22. package/AIKB/packages.md +48 -0
  23. package/AIKB/page-registry.md +65 -0
  24. package/AIKB/partials.md +1 -1
  25. package/AIKB/pipeline.md +1 -1
  26. package/AIKB/rebuild.md +112 -0
  27. package/AIKB/redirects.md +1 -1
  28. package/AIKB/scorm-bridge.md +51 -0
  29. package/AIKB/site-helpers.md +1 -1
  30. package/AIKB/staging.md +106 -0
  31. package/AIKB/testing.md +18 -4
  32. package/AIKB/upstream.md +319 -1
  33. package/AIKB/utils.md +4 -3
  34. package/AIKB/watcher.md +5 -2
  35. package/AIKB/zip.md +33 -0
  36. package/CHANGELOG.md +651 -0
  37. package/GUIDE.md +534 -51
  38. package/README.md +194 -28
  39. package/bin/kiss-ssg.js +1 -0
  40. package/examples/1-scan/router.js +6 -1
  41. package/examples/1-scan/src/assets/css/site.scss +120 -13
  42. package/examples/1-scan/src/layouts/layout.hbs +1 -1
  43. package/examples/1-scan/src/pages/about/us.hbs +1 -1
  44. package/examples/1-scan/src/pages/index.hbs +1 -1
  45. package/examples/1-scan/src/partials/newsletter.md +1 -1
  46. package/examples/10-asset-pipeline/router.js +14 -2
  47. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  48. package/examples/10-asset-pipeline/src/assets/css/site.scss +120 -13
  49. package/examples/10-asset-pipeline/src/assets/img/icon.png +0 -0
  50. package/examples/10-asset-pipeline/src/assets/img/og-card.png +0 -0
  51. package/examples/10-asset-pipeline/src/layouts/layout.hbs +6 -1
  52. package/examples/10-asset-pipeline/src/pages/404.hbs +17 -0
  53. package/examples/10-asset-pipeline/src/partials/newsletter.md +1 -1
  54. package/examples/11-blog/AIKB/last-build.json +70 -31
  55. package/examples/11-blog/AIKB/notes/controllers/tag.md +11 -6
  56. package/examples/11-blog/AIKB/site-map.json +15 -1
  57. package/examples/11-blog/AIKB/site-map.md +11 -10
  58. package/examples/11-blog/README.md +47 -32
  59. package/examples/11-blog/router.js +34 -12
  60. package/examples/11-blog/src/assets/css/site.scss +120 -13
  61. package/examples/11-blog/src/assets/img/icon.png +0 -0
  62. package/examples/11-blog/src/assets/img/og-card.png +0 -0
  63. package/examples/11-blog/src/controllers/tag.js +16 -1
  64. package/examples/11-blog/src/layouts/layout.hbs +19 -12
  65. package/examples/11-blog/src/models/posts/2026-01-20-why-we-roast-on-wednesdays.json +1 -1
  66. package/examples/11-blog/src/pages/404.hbs +23 -0
  67. package/examples/11-blog/src/pages/blog/listing.hbs +6 -7
  68. package/examples/11-blog/src/pages/blog/post.hbs +9 -10
  69. package/examples/11-blog/src/pages/blog/tag.hbs +2 -2
  70. package/examples/11-blog/src/pages/blog/tags.hbs +8 -6
  71. package/examples/11-blog/src/pages/index.hbs +4 -4
  72. package/examples/11-blog/src/partials/post-card.hbs +1 -1
  73. package/examples/12-from-a-single-file/README.md +78 -0
  74. package/examples/12-from-a-single-file/config/site.js +37 -0
  75. package/examples/12-from-a-single-file/router.js +57 -0
  76. package/examples/12-from-a-single-file/source/assets/img/roastery.png +0 -0
  77. package/examples/12-from-a-single-file/source/original.html +177 -0
  78. package/examples/12-from-a-single-file/src/assets/css/site.css +12 -0
  79. package/examples/12-from-a-single-file/src/assets/img/icon.png +0 -0
  80. package/examples/12-from-a-single-file/src/assets/img/og-card.png +0 -0
  81. package/examples/12-from-a-single-file/src/assets/img/roastery.png +0 -0
  82. package/examples/12-from-a-single-file/src/assets/js/site.js +8 -0
  83. package/examples/12-from-a-single-file/src/layouts/layout.hbs +39 -0
  84. package/examples/12-from-a-single-file/src/models/index.json +105 -0
  85. package/examples/12-from-a-single-file/src/pages/404.hbs +19 -0
  86. package/examples/12-from-a-single-file/src/pages/index.hbs +15 -0
  87. package/examples/12-from-a-single-file/src/partials/sections/beans.hbs +20 -0
  88. package/examples/12-from-a-single-file/src/partials/sections/enquiry.hbs +25 -0
  89. package/examples/12-from-a-single-file/src/partials/sections/hero.hbs +24 -0
  90. package/examples/12-from-a-single-file/src/partials/sections/offers.hbs +21 -0
  91. package/examples/12-from-a-single-file/src/partials/sections/visit.hbs +25 -0
  92. package/examples/12-from-a-single-file/src/partials/site/footer.hbs +8 -0
  93. package/examples/12-from-a-single-file/src/partials/site/header.hbs +19 -0
  94. package/examples/12-from-a-single-file/tools/compare.mjs +134 -0
  95. package/examples/12-from-a-single-file/tools/convert.mjs +82 -0
  96. package/examples/13-course-packages/README.md +54 -0
  97. package/examples/13-course-packages/router.js +75 -0
  98. package/examples/13-course-packages/src/assets/css/course.css +79 -0
  99. package/examples/13-course-packages/src/layouts/lesson.hbs +25 -0
  100. package/examples/13-course-packages/src/pages/espresso/extraction.hbs +7 -0
  101. package/examples/13-course-packages/src/pages/espresso/grind.hbs +8 -0
  102. package/examples/13-course-packages/src/pages/milk/index.hbs +75 -0
  103. package/examples/2-page/router.js +10 -2
  104. package/examples/2-page/src/assets/css/site.scss +120 -13
  105. package/examples/2-page/src/layouts/layout.hbs +1 -1
  106. package/examples/2-page/src/pages/about.hbs +1 -1
  107. package/examples/2-page/src/pages/index.hbs +2 -2
  108. package/examples/2-page/src/partials/newsletter.md +1 -1
  109. package/examples/3-pages/router.js +13 -1
  110. package/examples/3-pages/src/assets/css/site.scss +120 -13
  111. package/examples/3-pages/src/layouts/layout.hbs +1 -1
  112. package/examples/3-pages/src/pages/index.hbs +1 -1
  113. package/examples/3-pages/src/pages/roasts/index.hbs +1 -1
  114. package/examples/3-pages/src/pages/roasts/roast.hbs +3 -3
  115. package/examples/3-pages/src/partials/newsletter.md +1 -1
  116. package/examples/4-layouts-and-partials/router.js +8 -0
  117. package/examples/4-layouts-and-partials/src/assets/css/site.scss +120 -13
  118. package/examples/4-layouts-and-partials/src/layouts/layout.hbs +5 -1
  119. package/examples/4-layouts-and-partials/src/pages/stockists.hbs +1 -1
  120. package/examples/4-layouts-and-partials/src/partials/boards/espresso.hbs +1 -1
  121. package/examples/4-layouts-and-partials/src/partials/boards/filter.hbs +1 -1
  122. package/examples/4-layouts-and-partials/src/partials/sourcing.md +1 -1
  123. package/examples/5-helpers/router.js +10 -0
  124. package/examples/5-helpers/src/assets/css/site.scss +120 -13
  125. package/examples/5-helpers/src/layouts/layout.hbs +1 -1
  126. package/examples/5-helpers/src/pages/index.hbs +6 -6
  127. package/examples/5-helpers/src/partials/grinder-note.md +1 -1
  128. package/examples/5-helpers/styles/board.scss +1 -1
  129. package/examples/6-sitemap/router.js +10 -0
  130. package/examples/6-sitemap/src/assets/css/site.scss +120 -13
  131. package/examples/6-sitemap/src/assets/img/icon.png +0 -0
  132. package/examples/6-sitemap/src/assets/img/og-card.png +0 -0
  133. package/examples/6-sitemap/src/layouts/layout.hbs +6 -1
  134. package/examples/6-sitemap/src/models/404.json +6 -0
  135. package/examples/6-sitemap/src/models/index.json +1 -1
  136. package/examples/6-sitemap/src/pages/404.hbs +23 -0
  137. package/examples/6-sitemap/src/pages/index.hbs +4 -4
  138. package/examples/6-sitemap/src/partials/newsletter.md +1 -1
  139. package/examples/7-versioned-outputs/router.js +11 -1
  140. package/examples/7-versioned-outputs/src/assets/css/site.scss +120 -13
  141. package/examples/7-versioned-outputs/src/layouts/layout.hbs +1 -1
  142. package/examples/7-versioned-outputs/src/pages/index.hbs +1 -1
  143. package/examples/7-versioned-outputs/src/partials/newsletter.md +1 -1
  144. package/examples/8-data-fed-site/src/assets/css/site.scss +120 -13
  145. package/examples/8-data-fed-site/src/layouts/layout.hbs +1 -1
  146. package/examples/9-migrated-from-v1/AIKB/last-build.json +61 -25
  147. package/examples/9-migrated-from-v1/AIKB/notes/controllers/shelf-item.md +5 -4
  148. package/examples/9-migrated-from-v1/AIKB/site-map.json +13 -2
  149. package/examples/9-migrated-from-v1/AIKB/site-map.md +8 -7
  150. package/examples/9-migrated-from-v1/README.md +1 -1
  151. package/examples/9-migrated-from-v1/router.js +45 -4
  152. package/examples/9-migrated-from-v1/src/assets/css/site.scss +120 -13
  153. package/examples/9-migrated-from-v1/src/assets/img/icon.png +0 -0
  154. package/examples/9-migrated-from-v1/src/assets/img/og-card.png +0 -0
  155. package/examples/9-migrated-from-v1/src/controllers/shelf-item.js +8 -1
  156. package/examples/9-migrated-from-v1/src/layouts/layout.hbs +9 -2
  157. package/examples/9-migrated-from-v1/src/pages/404.hbs +26 -0
  158. package/examples/9-migrated-from-v1/src/pages/await-complete.hbs +3 -3
  159. package/examples/9-migrated-from-v1/src/pages/duplicate-paths.hbs +3 -3
  160. package/examples/9-migrated-from-v1/src/pages/dynamic-partials.hbs +1 -1
  161. package/examples/9-migrated-from-v1/src/pages/folders.hbs +4 -4
  162. package/examples/9-migrated-from-v1/src/pages/handlebars-instance.hbs +3 -3
  163. package/examples/9-migrated-from-v1/src/pages/index.hbs +7 -7
  164. package/examples/9-migrated-from-v1/src/pages/pure-controllers.hbs +4 -4
  165. package/examples/9-migrated-from-v1/src/pages/smaller-changes.hbs +6 -6
  166. package/examples/README.md +24 -19
  167. package/lib/asset-copy.js +227 -0
  168. package/lib/asset-manifest.js +32 -2
  169. package/lib/assets.js +176 -22
  170. package/lib/audit.js +620 -0
  171. package/lib/build-finish.js +632 -0
  172. package/lib/build-report.js +207 -1
  173. package/lib/check.js +105 -9
  174. package/lib/config.js +262 -4
  175. package/lib/handlebars-helpers.js +31 -1
  176. package/lib/html-split.js +1356 -0
  177. package/lib/init.js +264 -25
  178. package/lib/kiss-page.js +94 -3
  179. package/lib/kiss.js +329 -1988
  180. package/lib/links.js +214 -28
  181. package/lib/llms.js +21 -12
  182. package/lib/markdown-copy.js +101 -0
  183. package/lib/output-registry.js +1 -1
  184. package/lib/package-manifest.js +209 -0
  185. package/lib/packages.js +796 -0
  186. package/lib/page-registry.js +644 -0
  187. package/lib/pipeline.js +1 -0
  188. package/lib/rebuild.js +632 -0
  189. package/lib/schemas/scorm12/NOTICE.md +13 -0
  190. package/lib/schemas/scorm12/adlcp_rootv1p2.xsd +110 -0
  191. package/lib/schemas/scorm12/ims_xml.xsd +1 -0
  192. package/lib/schemas/scorm12/imscp_rootv1p1p2.xsd +345 -0
  193. package/lib/scorm-bridge.js +748 -0
  194. package/lib/staging.js +322 -0
  195. package/lib/utils.js +18 -0
  196. package/lib/watcher.js +56 -5
  197. package/lib/zip.js +121 -0
  198. package/llms.txt +81 -426
  199. package/package.json +14 -4
  200. package/starter/router.js +4 -1
  201. package/starter/src/layouts/layout.hbs +5 -1
  202. package/starter/src/pages/index.hbs +1 -0
  203. package/types/asset-copy.d.ts +12 -0
  204. package/types/asset-manifest.d.ts +11 -0
  205. package/types/assets.d.ts +8 -2
  206. package/types/audit.d.ts +159 -0
  207. package/types/build-finish.d.ts +69 -0
  208. package/types/build-report.d.ts +163 -2
  209. package/types/check.d.ts +63 -8
  210. package/types/config.d.ts +131 -6
  211. package/types/handlebars-helpers.d.ts +1 -1
  212. package/types/html-split.d.ts +341 -0
  213. package/types/init.d.ts +31 -4
  214. package/types/kiss-page.d.ts +24 -1
  215. package/types/kiss.d.ts +216 -78
  216. package/types/links.d.ts +85 -5
  217. package/types/llms.d.ts +9 -2
  218. package/types/markdown-copy.d.ts +22 -0
  219. package/types/output-registry.d.ts +2 -2
  220. package/types/package-manifest.d.ts +82 -0
  221. package/types/packages.d.ts +250 -0
  222. package/types/page-registry.d.ts +61 -0
  223. package/types/pipeline.d.ts +4 -0
  224. package/types/rebuild.d.ts +9 -0
  225. package/types/scorm-bridge.d.ts +12 -0
  226. package/types/staging.d.ts +82 -0
  227. package/types/utils.d.ts +8 -0
  228. package/types/watcher.d.ts +5 -0
  229. package/types/zip.d.ts +24 -0
package/AIKB/aikb.md CHANGED
@@ -35,7 +35,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
35
35
 
36
36
  ## Depended on by
37
37
 
38
- `lib/kiss.js` (`Kiss._buildAikb()`, inside `_finishBuild()`). The generated files are read by the `kiss-memory` plugin's `kiss-site-brief`, `kiss-branch-pulse` and `kiss-branch-close` skills, and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
38
+ `lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by the `kiss-memory` plugin's `kiss-site-brief`, `kiss-branch-pulse` and `kiss-branch-close` skills, and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
39
39
 
40
40
  ## Non-obvious behavior
41
41
 
@@ -0,0 +1,67 @@
1
+ # asset-copy.js
2
+
3
+ ## Responsibility
4
+
5
+ The build's asset copies as the engine runs them: one queued `copyAssets` run (`lib/assets.js`)
6
+ per registration, and the bookkeeping around it — the `<sass: …>` failures each copy owns, the
7
+ registrations a replay re-runs, and the copies a released page claim can restore. `lib/assets.js`
8
+ is the copy itself and knows nothing of the instance; this module is what ties a copy to a build.
9
+
10
+ `copyAssetFolder(kiss, …)` takes the `Kiss` instance and reads its state at call time. The state
11
+ stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `_restorableAssets`,
12
+ `_failures`, `_promises` — and `Kiss._copyAssets()` is a one-line delegator to it. Calls back
13
+ into the instance go through it (`kiss._stagedPath()`, `kiss._carry()`, `kiss._refreshReport()`).
14
+
15
+ ## Public interface
16
+
17
+ - `copyAssetFolder(kiss, sourceDir, targetDir, watch = false)` → the queued run (a promise of
18
+ `lib/assets.js`'s result). The body of `Kiss._copyAssets()`; the public `.copyAssets()` and the
19
+ watch re-copies both arrive here. `watch` keeps the run off `_promises`.
20
+ - `assetCopyKey(sourceDir, targetDir)` → a copy's identity: the canonicalised `(source, target)`
21
+ pair joined by `\u0000`, or `null` when either half is missing (`folders.assets: null`). Also
22
+ imported by `lib/kiss.js`'s `_replay()`, which re-runs a registered copy by this key.
23
+ - `canonical(path)` → `path` canonicalised through `realpathSync.native` as far as it exists, with
24
+ the rest re-appended.
25
+
26
+ ## Depends on
27
+
28
+ `fs-extra` (`realpathSync.native`), `node:path`, `./utils.js` (`posixPath`), `./assets.js`
29
+ (`copyAssets`, `assetCopyOwner`).
30
+
31
+ ## Depended on by
32
+
33
+ `lib/kiss.js` — `_copyAssets()` delegates to `copyAssetFolder`; `_replay()` uses `assetCopyKey`.
34
+
35
+ ## Non-obvious behavior
36
+
37
+ - **Copies run one after another, in registration order**, chained on `_assetQueue`: one copy's
38
+ target can be a subdirectory of another's source (a site copying into its own assets folder), and
39
+ concurrent `fs.copy` walks racing there fail with `ENOENT`. A registration's run is pushed onto
40
+ `_promises`; a watch re-copy (`watch: true`) is tracked by the rebuild queue instead, and
41
+ `generate()` / `_drain()` await the `_assetQueue` slot as well as registration work.
42
+ - **Paths are resolved at registration, in the caller's working directory**, and the queued run
43
+ copies against those. Re-resolving the raw arguments when the queue drained copied a different
44
+ `./vendor` once anything had moved the working directory in between — measured: neither
45
+ directory got the output. The `<sass: …>` label is computed at the same moment, project-relative
46
+ when the source is inside the project and absolute when it is not; it is display only, never
47
+ identity.
48
+ - **A copy owns its Sass failures by object identity**, through `_sassFailures`, a `Map` keyed on
49
+ `assetCopyKey`. Two asset roots can each hold `css/theme.scss`, and a prefix match on the view
50
+ string let a parent root's copy clear a nested root's unresolved failure (measured: a stylesheet
51
+ that could not be written at all, on a build reporting `ok: true`). Each run clears exactly the
52
+ set it recorded last time before recording this time, which is also what lets a deleted or
53
+ renamed broken stylesheet lose its entry. Failures are `kiss._carry()`-marked: a replay keeps
54
+ them, because an asset re-copy is the only thing that can re-check one.
55
+ - **The key is computed after the copy has run**, not at registration: canonicalising needs the
56
+ path to exist, and two spellings of a destination that does not exist yet could not canonicalise
57
+ and each kept its own key — a failure stranded under the one nothing would reach again.
58
+ `canonical` walks up to the deepest existing ancestor for the same reason: a plain `realpath`
59
+ with a `resolve` fallback keys one copy two ways across the folder's creation (reproduced through
60
+ a symlinked parent, and on Windows with a case-different one).
61
+ - **The registration (`_assetCopies`) exists exactly while a failure does**, because a replay
62
+ re-runs a copy only to re-check one; both maps are pruned together on a clean run, so a
63
+ long-lived process copying into a fresh destination each run does not grow them for ever.
64
+ - **`report()` is refreshed after every copy** (`kiss._refreshReport()`): an asset re-copy changes
65
+ `_failures` and settles no build, so nothing else would.
66
+ - `_restorableAssets` records a copy whose owner still has a producer in the output registry, so a
67
+ replay's orphan sweep can restore an asset a released page claim had overwritten.
@@ -10,12 +10,14 @@ 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()` → `{ lookup, toObject, hasOwner, previous, reconcile, reconcileSass, urlRevision }`, a fresh manifest.
13
+ - `createAssetManifest()` → `{ lookup, toObject, hasOwner, owns, previous, reconcile, reconcileSass, unrecorded, urlRevision }`, a fresh manifest.
14
14
  - `lookup(urlPath)` — the emitted path, or `null`.
15
15
  - `toObject()` — a plain object copy of the map, for debugging.
16
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.
17
+ - `hasOwner(owner)` — whether a source/target copy has an inventory, or has written outputs it never recorded (below).
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, plus that copy's unrecorded outputs that this run did not produce again. Another copy's mappings survive.
19
+ - `unrecorded(owner, outputs)` — remembers build-relative outputs a copy wrote, or failed to remove, before it stopped partway. They are in no map and `lookup` never sees them; the copy's next `reconcile` reports them stale unless they are live again, then forgets them.
20
+ - `owns(owner, output)` — whether `output` (build-relative, keyed by output not by name) is this copy's, recorded or unrecorded. Never another copy's. `lib/assets.js` asks it only when no output registry was passed; with a registry, the registry is the authority.
19
21
 
20
22
  - `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
21
23
 
@@ -32,6 +34,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
32
34
  - **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.
33
35
  - **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.
34
36
  - **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.
37
+ - **A map is only written by a copy that finished, so a copy that stopped needs a second record.** `reconcile` runs at the end of a copy; a copy that fails partway has already put files in the build that no map holds, and before `unrecorded` nothing ever removed them (a `git checkout` during `--dev` left a stale asset until restart). They are kept apart from the name map rather than merged into it, because a name has one output: merging a half-copied plain `css/site.css` under a name whose map says `css/site.a1b2c3d4.css` would have reported the last-good hashed file stale and deleted it. A copy with unrecorded outputs counts for `hasOwner`, because its root demonstrably existed, so that root disappearing is a deletion to reconcile, not the typo a missing first root is.
35
38
  - **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.
36
39
  - MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
37
40
 
package/AIKB/assets.md CHANGED
@@ -6,7 +6,7 @@ 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. **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.
9
+ - `compileSassFiles(sourceDir, targetDir, { config, logger, outputs?, owner?, touched? })` → 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
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
@@ -15,14 +15,14 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
15
15
 
16
16
  ## Depended on by
17
17
 
18
- `lib/kiss.js`.
18
+ `lib/asset-copy.js` (`copyAssets`, `assetCopyOwner`, in the queued copy behind `Kiss._copyAssets()`).
19
19
 
20
20
  ## Non-obvious behavior
21
21
 
22
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
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
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.
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; so is a root a failed copy had already written from (its unrecorded outputs count for `hasOwner`). 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
26
 
27
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.
28
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.
@@ -34,7 +34,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
34
34
  - **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
35
35
  - **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
36
36
  - **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
37
- - **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.
37
+ - **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims, plus outputs an earlier failed copy wrote and never recorded; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
38
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.
39
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.
40
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`.
@@ -50,3 +50,5 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
50
50
 
51
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
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.
53
+ - **Every output a copy is about to write is in `touched` before it is written, so a copy that stops partway still records it.** The manifest learns a copy's outputs only when `recordEmitted` reaches `reconcile` at the end; a copy that failed partway (a source vanishing mid-copy, `ENOENT` on its `copyfile`) used to leave what it had already written in no record, and a later run that found the root gone removed only what the manifest knew. In use, a `git checkout` or a bulk delete during `--dev` left a stale asset until restart (found 2026-10-02; the fs-extra unlink-first gap that exposed it is `AIKB/upstream.md`). Now the three writers add to `touched` before writing: the Sass compile (`compileSassFiles`' `touched` dep), the copy's `filter` (files and links, never a directory), and the hash `fs.move`. A stale output whose `unlink` fails is added too, kept claimed, and the error is raised only after the loop. On any failure the `catch` claims what this copy may still write and hands the lot to `manifest.unrecorded`, and the copy's next `reconcile` removes whatever it does not produce again (`AIKB/asset-manifest.md`). **Filled before each write, not read off the disk in the `catch`**, because fs-extra copies a folder's entries concurrently (`asyncIteratorConcurrentProcess`): `fs.copy` rejects on the first failed entry while its siblings may still be writing, so a `stat` in the `catch` misses them. The same concurrency means a sibling folder can still be walked _after_ the rejection, so the `catch` sets `stopped` first and the filter, checking it after its own `lstat` await and synchronously with the add, starts no write once the failure is recorded: every write either began before the record (and is in it) or never begins. **A directory is never an output, whatever recorded it.** A source file aimed at an existing output directory is recorded by the filter and then fails fs-extra's file-over-directory check without writing anything. Recording that directory made every later reconcile try to `unlink` it, fail and record it again, so the session never recovered once the source was fixed (found by the Codex review, 2026-10-03). The `catch` drops any `touched` path that is a directory before it claims or records, and the unlink loop releases, rather than retries, a stale path that turns out to be one. Pinned in `test/unit/assets.test.js` ("copyAssets that fails partway": one test per writer, the first-copy case, the late sibling write, the directory collision and a recorded directory reaching the unlink loop, each seen red), and end to end by `watch.test.js`'s "copies new assets…" with a 20 ms delay injected between fs-extra's unlink and copy: 5/5 red without the fix, 5/5 green with it (2026-10-03, Windows).
54
+ - **A source that changes type clears its own previous output before fs-extra's type check (`clearOwnConflict`, called from the copy's filter).** A folder that became a file of the same name finds a folder of its own old outputs at the destination; a file that became a folder finds its own old output file. fs-extra refuses both (file-over-directory, directory-over-file), and the old output was removed only by `recordEmitted` after a copy that _succeeded_, so every rebuild failed until restart. An empty folder blocked a later file the same way, and kiss leaves one behind whenever a source folder is deleted, because it never removes output directories. The filter runs before fs-extra's own check (`copy.js`, `runFilter` then `checkPaths`), so it can clear the way: an old output file is unlinked if this copy owns it; a folder is cleared only when **every** file under it is this copy's own output (an empty folder qualifies vacuously), file by file and then folder by folder with a non-recursive `rmdir`, so a file that appears meanwhile stops the clearing rather than being swept up. Anything foreign in the way stays, and the copy fails on it as before. "This copy's own" is `outputs.owns` when a registry is passed (Kiss always passes one) and `manifest.owns` otherwise. The old output's manifest entry is then stale at the end of the copy; when its path has become a folder, the unlink loop's directory rule releases it, and when a _parent_ of it has become a file the unlink answers `ENOTDIR` on POSIX (`ENOENT` on Windows), which `isGone` treats as gone. Treating `ENOTDIR` as a failure re-recorded the path on every run, so a folder replaced by a file never recovered on Linux or macOS while every Windows run passed (found by a second-opinion review, 2026-10-03; the test makes `fs.unlink` answer the POSIX way on any platform). **Sass compiles before the copy and walks nothing**, so `compileSassFiles` (given `ownsOutput`) clears this copy's own old output on the folder path down to the stylesheet, top down, and at the stylesheet's own path, before writing; otherwise a stylesheet in a folder that used to be a file reported a write failure for one rebuild. Because an empty folder qualifies, an empty folder at an output path is removed even if something else made it: kiss cannot tell one it left behind from one it did not, and an empty folder holds nothing. The "names the write" test's obstacle is therefore a folder holding a foreign file. **Clearing is only for output a previous run left: `ownsOutput` is false for anything in `touched`.** Without that, `site.scss` beside a folder `site.css/` compiled the stylesheet, the copy's filter then cleared it as "this copy's own output in the way" and copied the folder in, and the build reported success with no stylesheet, where before clearing existed the collision failed the copy (found by the Codex review, 2026-10-03; pinned by "never clears an output this run already wrote", which asserts that every stylesheet reported compiled is still a file on disk). Pinned in `test/unit/assets.test.js` ("a source that changes type": both directions, with and without a registry, with and without the intermediate copy that leaves the empty folder, the POSIX `ENOTDIR` answer and a stylesheet in a former file's folder; "with something foreign in the way": a page-owned file and a junction veto the clear, mutation-checked against `some`) and end to end by `watch.test.js`'s "follows an asset folder that becomes a file, and back", each seen red. Absorbed at the close of `planning/sessions/2026-10-03-asset-copy-partial-record.md` (Amendments).
package/AIKB/audit.md ADDED
@@ -0,0 +1,51 @@
1
+ # audit.js
2
+
3
+ ## Responsibility
4
+
5
+ The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident. Two pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
6
+
7
+ The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
8
+
9
+ ## Public interface
10
+
11
+ - `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
12
+ - `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`.
13
+ - `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess`, passed so a cross-page fragment resolves to the page the link scan would resolve the same href to. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
14
+ - The `CheckId`, `PageFacts`, `AuditFinding`, `AuditResult` and `AuditPage` typedefs.
15
+
16
+ ## Depends on
17
+
18
+ `node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `classifyReference` and `resolveTarget`, so a cross-page fragment lands on the page the link scan resolves its path to) and `./utils.js` (`posixPath`). No HTML parser, no logger, no config.
19
+
20
+ ## Depended on by
21
+
22
+ `lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's last key, `audit` — `AIKB/build-report.md`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
23
+
24
+ ## Non-obvious behavior
25
+
26
+ - **Extraction and decision are split across time, for the link scan's reason.** Under `kiss-ssg check` the staging folder is gone before anything could read a page back, so the facts are taken at write time from the same minified bytes as `KissPage.links`; `auditBuild` never reads HTML. What it does read from disk is the rest of the build folder (the walk), while `Kiss` still has it where it was written — `_writeRoot`, which is the staging folder under `'atomic'` and under check.
27
+ - **The pre-strip is wider than `links.js`'s, because a count check cannot afford a stray match.** Comments, `<script>`, `<style>`, `<template>` and `<svg>` elements are removed whole before matching. An extra reference costs the link scan one lookup; an extra `<h1>` inside a script string, or an icon's `<title>` read as the page title, is a finding invented out of nothing.
28
+ - **The extension decides first whether a file is a page.** An `ext: 'json'` page whose bytes happen to contain `<body` is data. A file extension is a statement about processing (`CLAUDE.md`), and the audit keeps that promise rather than sniffing.
29
+ - **`alt=""` is present, and so is a bare `alt`.** `imgMissingAlt` lists only images with no `alt` attribute at all; an empty alt is the correct markup for a decorative image.
30
+ - **A duplicate is compared among originals only.** A page whose `canonical` option names another URL (`canonicalElsewhere`) is declaring itself a copy, so it is left out of `title-duplicate` and `description-duplicate` — and every other check still runs on it.
31
+ - **`favicon-missing` and `not-found-missing` need at least one audited page.** A feed-only or JSON-only instance has no HTML to be missing them from. `not-found-missing` says so in its `detail` when `404/index.html` exists: `extensionLess` puts a `404` view there, and hosts serve `/404.html`.
32
+ - **`site-url-local` flags loopback, the reserved `.localhost`/`.local`/`.test`/`.invalid` TLDs and the Netlify/Vercel preview hostnames — not `.example`.** That is the documentation domain every shipped example uses, and flagging it would put a finding on every example build. A page whose own `siteUrl` differs from the instance's and is local is reported with that page.
33
+ - **`debug-dump` is decided from state, not disk.** `viewStats()` does not await its `debug.json` write, so a disk check would race it; `Kiss` sets `_debugWritten` when the write starts and passes it in.
34
+ - **Folder ownership.** `not-found-missing`, `stray-file` and `console-log` read the whole build folder. Under `cleanBuild: false` that folder holds whatever earlier builds and sibling instances left (example 7's archive index is the proof), so those three are **not run** and are listed in `skipped` — never silently absent. An ignored check is not also listed as skipped.
35
+ - **Walked paths are joined with `/`, never `path.join`, and `buildDir` is used as given.** `reportedPath` in `lib/build-report.js` maps a finding out of staging by matching the staging prefix followed by `/`; a Windows `\` from `path.join`, or a rewritten root, would leak `kiss-staging` into the report.
36
+ - **`console-log` skips files a site's author did not write**: `*.min.js` — including its `assets.hash` form, `*.min.<hex>.js`, since the rename happens before the audit walks the folder (a Codex review found the exemption lost there, 2026-09-28) — and anything under a `vendor/` or `node_modules/` segment. The count is of the literal `console.log(`, so it is a hint, not a parse.
37
+ - **`viewport-zoom-lock` reads every viewport meta, and fires if any one locks zoom.** Which one a browser applies when a page has two has not been measured here, so the check takes the reading that cannot miss a lock. The content is split on commas, semicolons or whitespace, with spaces around `=` dropped. `user-scalable` of `no` or `0`, or a numeric `maximum-scale` below 2 (WCAG 1.4.4's 200%), fires. The `detail` is the content as written, which is after the minifier, so `width=device-width,maximum-scale=1` has no space.
38
+ - **The fragment checks are two ids on purpose.** `href-hash` is the bare `href="#"`, often a deliberate script hook. `fragment-missing` is a fragment that names no id. Split, a site can `ignore` the first with a reason and keep the second, which is what checks its skip link. `audit.ignore` is per check, not per fragment; that is the operator's decision (2026-10-20), and GUIDE says what ignoring `fragment-missing` costs.
39
+ - **Ids are read with a narrower strip than the counts.** `<svg>` stays, because an id inside inline SVG is in the document and a link may target it. Comments go. So do the **contents** of `<script>`, `<style>` and `<template>`, while their opening tags stay, since those elements are in the document and may carry an id. `<a name>` counts, as the HTML spec still allows. Fragment links are read from `<a>` and `<area>` only, with the counts' wider strip: `<link>` and `<use>` hrefs are not navigation.
40
+ - **The fragment checks read tags quote-aware, unlike the rest of the audit** (`ATTRS`, `TAG_END`). `[^>]*` ends a tag at the first `>`, and `\b` after a tag name stops at a `-`. For the link scan those are false negatives, the safe direction. For the fragment checks they are false positives:
41
+ - `<div x-show="count > 0" id="panel">` hid the id, so every link to it was reported missing;
42
+ - `<a-entity href="#">` (A-Frame) was read as an `<a>`.
43
+
44
+ A same-family review found both, plus a third false positive: `#/route` and `#!` links, which are client-side routes rather than scroll targets and are now skipped (2026-10-21). The count checks keep the old reading, where a stray match cannot invent a finding of this kind.
45
+
46
+ - **A fragment resolves the way a browser would, as far as the build can see.**
47
+ - **Matching:** the raw fragment is compared first, then its percent-decoded form, both case-sensitively. A text directive is cut off at `:~:`, and an empty fragment is skipped. `#top` with no `id="top"` is skipped in any case, because the spec scrolls to the top.
48
+ - **The target page:** an empty path or a query-only path (`?q=1#x`) is the page itself. Anything else goes through `classifyReference` and then `resolveTarget`, with `exists: () => false`, so only pages this build wrote can be named. A fragment on an asset, another origin, an earlier build's file or another instance's page has no ids to check against and is skipped. So is every fragment on a page with a `<base href>`.
49
+ - **`resolveTarget` tries `known` across every candidate before the disk** (`AIKB/links.md`). Asked candidate by candidate, `/about` on a real filesystem named the folder `about`, which holds `about/index.html`, and every extension-less cross-page fragment would have been skipped without a word. A fresh-context critique of the plan caught this before the code existed (2026-10-20).
50
+ - **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw.
51
+ - **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
@@ -0,0 +1,56 @@
1
+ # build-finish.js
2
+
3
+ ## Responsibility
4
+
5
+ What a settled build says about itself: the build report (`_finishBuild()`, and its re-derivation
6
+ `_refreshReport()`), and the four things the settle path decides beside it — the link scan
7
+ (`_checkLinks()`), the launch-readiness audit (`_runAudit()`), the redirect files and their
8
+ findings (`_writeRedirects()`, `_redirectFindings()`, `_lastBuildRecord()`), and the site's
9
+ knowledge-base record (`_buildAikb()`). `lib/kiss.js` decides _when_ each runs — `complete()`'s
10
+ settle path calls the redirect write, the scan and the audit above its three-way branch, and
11
+ `_finishBuild()` after it (`AIKB/kiss.md`) — and this module does the run and records the result
12
+ on the instance. The pure work is elsewhere: `lib/build-report.js`, `lib/links.js`,
13
+ `lib/audit.js`, `lib/redirects.js`, `lib/aikb.js`.
14
+
15
+ Every function takes the `Kiss` instance and reads its state at call time. The state stays on
16
+ `Kiss` — `_report`, `_finishedAt`, `_aikbVerdict`, `_links`, `_audit`, the `_redirects*` fields —
17
+ and each private method on `Kiss` is a one-line delegator to the function here. Calls between
18
+ these functions, and into the rest of the instance, go through it (`kiss._buildAikb()`,
19
+ `kiss._reportInputs()`, `kiss._stackForRecord()`, `kiss._reportedPath()`), so a test that patches
20
+ one on the instance is seen.
21
+
22
+ ## Public interface
23
+
24
+ Each is the body of the `Kiss` private method named beside it:
25
+
26
+ - `finishBuild(kiss)` — `_finishBuild()`; `reportInputs(kiss)` — `_reportInputs()`;
27
+ `refreshReport(kiss)` — `_refreshReport()`.
28
+ - `buildAikb(kiss)` — `_buildAikb()`.
29
+ - `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
30
+ - `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
31
+ `_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
32
+
33
+ Three are named apart from their method because the method's own name is a function this module
34
+ imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
35
+ (`lib/redirects.js`).
36
+
37
+ ## Depends on
38
+
39
+ `fs-extra`, `./utils.js`, `./build-report.js` (`buildReport`, `auditLines`), `./links.js`,
40
+ `./audit.js`, `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
41
+ `./check.js` (`readReportsFile`, `aikbRecordRequested`), `./aikb.js` (`buildSiteMap`,
42
+ `isRecorded`, `writeAikb`, `writeLastBuild`).
43
+
44
+ ## Depended on by
45
+
46
+ `lib/kiss.js` only, through its delegating methods.
47
+
48
+ ## Non-obvious behavior
49
+
50
+ - **`writeLearningPackages()` runs just after `_writeRedirects()`, and for its reasons**: the zips are files this build publishes, so they are written into the staging folder before the swap, from the pages where they were written. It is latched as `_packagesRun` (reset by the replay) and skipped with nothing to report when no page names a `package`, when `config.packages` is `false`, and when the build has already failed, so a failed page is reported once and not again as a package that could not be made. A dev build skips it with one notice per instance: a scoped re-render re-runs no writer. A package that cannot be made fails the build as `<packages>`, and no zip is written at all. `written(file)` is a lookup in the output registry, never the disk, which is what keeps a stale file out of a package (`AIKB/packages.md`). `reportInputs()` carries `_packagesResult` as the report's last key. After writing, it logs one `Package: <build folder>/<dir>/<id>.<format>.zip <title> (<n> pages, <m> files)` info line per zip, beside `Links:` and `Audit:`, named by `config.folders.build` rather than the staging folder an atomic build wrote it in, and a `package outside: <page> -> <href> (<reason>)` notice per reference left out. Before 2026-10-25 only `check --summary` listed packages, and a clean-room run found GUIDE's claim that the build did.
51
+ - **`_writeRedirects()` now writes up to two files, and only one of them is the host's.** `redirects.json` — the host-neutral IR — goes out on every build that has an alias, whatever `config.redirects.format` says; the format picks what goes beside it. `_redirectsPath` stays the **host** file, so the report's `redirects.file` keeps the meaning it had in 2.3, and it is `null` under `format: 'none'`: the honest answer for a build that deliberately wrote no host file, rather than a path that would read as a redirect some host is serving. A custom writer runs inside the same promise the `.catch` below wraps, so a writer that throws fails the build exactly as a failed write does — swallowing it would rebuild, in the site's own code, the silent no-op the format block exists to end.
52
+ - **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_audit`, `_debugWritten`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
53
+ - **Three report keys are wired here before the modules that fill them exist.** `_finishBuild()` passes `links: this._links`, `redirects: this._redirectsResult` and `feed: this._feedPath` into `buildReport`, `links` is filled by `_checkLinks()`, `redirects` by `_writeRedirects()`/`_redirectFindings()` (above) and `feed` by `.feed()` — the three modules that fill the seam have all landed, and each key is still `null` on a build that did not do that piece of work, which is not the same as "it did it and found nothing". The fields are the whole seam: `_links` and `_redirectsResult` are the findings, `_redirectsPath` and `_feedPath` are the files as written — staging paths under `'atomic'` and under check, mapped into the report by `buildReport` exactly as `_sitemapPath` and `_llmsPath` are — and `_feedRequest` is the standing request a replay re-issues. That last one is the one field `_replay()` deliberately does **not** reset: like `_sitemapRequest` and `_llmsRequest` it is the request rather than the result, and clearing it would leave the re-issue block with nothing to re-issue.
54
+ - **The knowledge base is built from `_finishBuild()`, in a fixed order, and it is the only writer that cannot use the promise queue.** `_buildAikb()` runs first — before `buildReport()` — because the report carries the verdict as its appended `aikb` key; `last-build.json` is written afterwards, because it _is_ this build's report. `.sitemap()` and `.llms()` need only the registry and so can wait on `_promises`; the map's partial-per-page index is learned from _rendering_, so anything earlier than a settled build would list no partials at all. A failure to write is logged and reported as `written: false` — never a build failure, the same stance as the `dependency-graph.json` dump.
55
+ - **`_buildAikb()` decides in two steps: is there a knowledge base here, and may this build write it.** `folders.aikb` unset → `aikb: null`, nothing built. Otherwise `record = aikbRecordRequested()` (`KISS_AIKB`, read by exactly the rule `KISS_CHECK` is read by, beside `checkModeRequested()`) and the site is opted in when `record || isRecorded(folder)` — `isRecorded` (`lib/aikb.js`) being "`<folder>/site-map.json` is there", the one file only a record writes. Not opted in → `aikb: null` and no map is built, which is why an ordinary build of a site that has never recorded costs one `stat`. Opted in → the map is built and the notes evaluated whatever happens next, and `write = record && !config.dev && failures.length === 0`. A record refused for either of those last two reasons logs a notice naming the reason: the operator is waiting for the folder to change, and silence would read as success. There is no per-build state to reset, so a watch replay needs no hook — `_replay()`'s own `complete()` re-enters `_finishBuild()`, and in dev nothing is written anyway.
56
+ - **`_promotedFrom` exists because the dependency graph outlives the staging folder.** `_promote()` rewrites every stack entry's `buildTo` to the real folder but leaves the graph, whose page keys are the paths the pages were actually written to — staging paths, under `cleanBuild: 'atomic'`. It therefore parks the staging prefix on `_promotedFrom` before clearing `_stagingDir`, and `_finishBuild()` passes `_stagingDir ?? _promotedFrom` to both `buildSiteMap` and `buildReport`. Without it a promoted atomic build produced a map with no partials in it at all — and a report whose `sitemap` and `llms` named a folder that no longer existed, since neither `_sitemapPath` nor `_llmsPath` is rewritten by the promotion either. Mapping a path that is already real is a no-op, so nothing else moves.
@@ -6,22 +6,24 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed })` → `BuildReport`, whose keys are always in this order:
9
+ - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit })` → `BuildReport`, whose keys are always in this order:
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
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`).
14
+ - `pages` — `{ view, buildTo, ok, hash, id, canonical, markdown }` per stack entry, in registration order; `markdown` is the Markdown copy the page wrote beside itself (`KissPage.markdown`, mapped to the real build folder like `buildTo`), `null` when it wrote none; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
15
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`.
18
18
  - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
19
19
  - `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
20
20
  - `aikb` — `null` when the site has no knowledge base to report on, and otherwise `{ folder, written, notes: { missing, dead, stale, dangling }, subjects }` as `lib/aikb.js` evaluated it (`AIKB/aikb.md`). Appended after `llms`, for the same reason. It is passed through **unmapped**: the knowledge base is source-side and committed, so it is never staged and its paths are already the ones a person would type.
21
- - `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.
21
+ - `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }] }` 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 — 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.
24
+ - `audit` — the **last** key, after `outputs`: `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
25
+ - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the 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. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
26
+ - `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the four `VALUE_CHECKS` instead print one line per value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters. For the two duplicate checks, that is because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27). For `viewport-zoom-lock` and `fragment-missing`, the value (the viewport content, the href) is what to grep the templates for; a page list alone sends you reading every link (2026-10-20) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
25
27
  - `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
28
 
27
29
  ## Depends on
@@ -30,10 +32,11 @@ Nothing.
30
32
 
31
33
  ## Depended on by
32
34
 
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`).
35
+ `lib/build-finish.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
36
 
35
37
  ## Non-obvious behavior
36
38
 
39
+ - **`packages` is the last key**, after `audit`: one `BuildPackage` per learning package and format (`id`, `format`, `file`, `title`, `hash`, `pages`, `files`, `outside`), sorted by id then format, or `null` when no page is in a package, packaging is off, the build failed, and in a dev build. `file`, every `pages` entry and every `outside[].page` are mapped out of staging like every other path. `formatReport` ends with one `package:` line per package and up to five `package outside:` lines each, then a count of the rest.
37
40
  - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` does the same thing for the failure _message_; this is the same rule somewhere a unit test can reach it, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
38
41
  - **A failure's `Error` is reduced to its `message` on purpose.** `JSON.stringify(new Error('x'))` is `{}`, so a report carrying the object loses exactly the text that says what went wrong. The object itself stays on the `AggregateError` `complete()` rejects with — the report is the serialisable view of the same build, not a replacement for it.
39
42
  - **`assets` is the manifest, so it is keyed by the path a template writes**, not by the source file: a `.scss` appears under its compiled `css/site.css` name, and `target` is where cache busting put it (`css/site.a1b2c3d4.css`). See `AIKB/asset-manifest.md`.
package/AIKB/check.md CHANGED
@@ -8,12 +8,18 @@ Two commands share all of it. `check <script>` runs the site's build staged and
8
8
 
9
9
  ## Public interface
10
10
 
11
+ - `checkModeRequested(env = process.env)` / `aikbRecordRequested(env = process.env)` → whether
12
+ `KISS_CHECK` / `KISS_AIKB` is on: anything but `''`, `'0'` or `'false'` (`CHECK_MODE_OFF`,
13
+ case-insensitive) counts, so `KISS_CHECK=true` cannot fall through to a real build over
14
+ published output. The engine's side of the protocol the bin sets up; they moved here from
15
+ `lib/kiss.js` (2026-10-02) when the method that reads `KISS_AIKB` left it for
16
+ `lib/build-finish.js`, so that no module has to import `kiss.js`.
11
17
  - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line. `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
12
18
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
13
19
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
14
- - `readReportsFile(text)` → the same `BuildReport[]`, from any of the three shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, or a single report object. What `--against` reads.
15
- - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure.
16
- - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line.
20
+ - `readReportsFile(text)` → the same `BuildReport[]`, from any of the four shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, the `{ reports, diff }` object it prints whenever it diffs, or a single report object. The wrapper was once read as a single report with no pages, so a check's own output handed back as `--against` diffed every page as added (found upgrading K9-Solutions, 2026-09-27). What `--against` reads.
21
+ - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure. When the new report wrote learning packages (`packages` is an array), the entry also has `packages: { added, removed, changed, unchanged }`, the zips by `file`, compared by `hash` (a `null` hash is changed); a report with `packages: null` or none gets no key, so a build with packaging off, or one that failed, is never every package removed. The baseline's zips are read the same way, an older report having none.
22
+ - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line. With `packages`, it then prints ` + package <file>`, ` - package <file>`, ` ~ package <file>` and ` = N packages unchanged`, after the pages: the rows that say which zips to upload again.
17
23
  - `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
18
24
  - `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
25
  - `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`.
@@ -27,7 +33,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
27
33
 
28
34
  ## Depended on by
29
35
 
30
- `bin/kiss-ssg.js`.
36
+ `bin/kiss-ssg.js`. And the engine, for the other half of the protocol the bin starts:
37
+ `lib/kiss.js` reads `checkModeRequested()` in its constructor, and `lib/build-finish.js` reads
38
+ `aikbRecordRequested()` and `readReportsFile()` (the redirects baseline, read by the same code
39
+ `check --against` uses).
31
40
 
32
41
  ## Non-obvious behavior
33
42
 
package/AIKB/config.md CHANGED
@@ -7,27 +7,31 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
7
7
  ## Public interface
8
8
 
9
9
  - `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
10
- - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
10
+ - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
11
11
  - `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
12
12
  - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
14
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
15
+ - `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), and `ignore: []`, the check ids not to run.
16
+ - `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
17
+ - `resolveMarkdownCopies(value)` → `{ write, selector }`. The `resolveAudit` shape, except that `true` is a statement rather than the default: `undefined` is the default block, `true` is `{ write: true, … }` (always), `false` is `{ write: false, selector: 'main' }`, a plain object is merged one level deep over the default; anything else throws, and so does a `write` other than `true`, `false` or `'llms'`, or an empty `selector`. Exported because a page's own `config.markdownCopies` arrives as the page wrote it — `false` on one page — and the page registry resolves it with this. An already-resolved block resolves to itself.
15
18
  - `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
19
  - `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.
20
+ - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
18
21
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
19
22
  - `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
23
 
21
24
  ## Depends on
22
25
 
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).
26
+ `node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids).
24
27
 
25
28
  ## Depended on by
26
29
 
27
- `lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
30
+ `lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`); `lib/page-registry.js` (`resolveMarkdownCopies`, for a page's own `config.markdownCopies`).
28
31
 
29
32
  ## Non-obvious behavior
30
33
 
34
+ - **`packages` is a block or `false`.** `resolvePackages` merges `config.packages` over `DEFAULT_PACKAGES` (`{ format: 'imscp', dir: 'packages', title: null, only: null, schemas: false }`) and throws for an unknown format, a `dir` that is not a string, a `title` that is not a function, an `only` that is not a list of strings, and a `schemas` that is not `true` or `false`: each would otherwise be found only when the packages are written, after every page. `false` is kept as `false`, not merged, so the writer can tell "off" from "on with defaults"; `true` and nothing are the defaults. Any other value that is not a plain object (a string, an array, a number) throws rather than being spread: `packages: 'scorm12'` spread as the string's character indices, validated, and wrote IMS content packages for an author who asked for SCORM (code review, 2026-10-25). Packaging itself is by convention: the block only applies when a page names a `package` (`AIKB/packages.md`).
31
35
  - **`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
36
  - **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
37
  - **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.
@@ -49,7 +53,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
49
53
  - **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
50
54
  - **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
51
55
  - **`assets.pipeline` is validated, like `cleanBuild`, because every step is a command this process will spawn.** It must be an array; each entry an object with a non-empty string `run`; `name`, `watch` and `cwd`, when present, strings. Anything else throws out of `resolveConfig` (so out of `new Kiss`) naming the index and the value — `{ pipeline: 'npx tailwindcss' }` (a bare string) and `{ pipeline: [{ watch: '…' }] }` (a watch with nothing to run first) are the two shapes that would otherwise be discovered as a `spawn` of `undefined` half way through a build. Being an array, it **replaces** the default rather than merging into it, which is what the one-level-deep merge means for an array value. What the steps then do is `lib/pipeline.js`'s business (`AIKB/pipeline.md`); this module only refuses a shape that cannot be run.
52
- - **`links` is a block, not a boolean, and it is on by default.** `links: { check: true }` gates the broken-internal-link scan that a settled non-dev build runs over the pages it just wrote; `{ links: { check: false } }` switches it off and keeps whatever else the block grows. It is a one-level-merged block for the reason `assets` is: the first thing a real deploy asks for after the scan is an ignore list for paths the host generates and the build never sees (`/cdn-cgi/*`), and adding a key to a block is an addition where turning a boolean into a block is a breaking rename. On by default because the finding is advisory — it never touches `ok`, and `exitCodeFor` (`lib/check.js`) does not read it — so a site that has never heard of it loses nothing and gains the report key.
56
+ - **`links` is a block, not a boolean, and it is on by default.** `links: { check: true }` gates the broken-internal-link scan that a settled non-dev build runs over the pages it just wrote; `{ links: { check: false } }` switches it off and keeps whatever else the block grows. It is a one-level-merged block for the reason `assets` is: the first thing a real deploy asks for after the scan is an ignore list for paths the host generates and the build never sees (`/cdn-cgi/*`), and adding a key to a block is an addition where turning a boolean into a block is a breaking rename. That key arrived as `hostServed` (2026-10-01), first asked for by a Firebase site whose 2,756 findings were 2,650 references to a function rewrite, post-build bundles and a separately generated `llms.txt`. It is named for what it states — the host serves these — rather than what it suppresses, defaults to `[]` (kiss does not guess a host), and is validated in `resolveHostServed`: an array of strings, each starting with `/`, because a pattern is matched against the root-relative path a browser requests and one without the slash could never match anything. On by default because the finding is advisory — it never touches `ok`, and `exitCodeFor` (`lib/check.js`) does not read it — so a site that has never heard of it loses nothing and gains the report key.
53
57
  - **`config.assets` is not `config.folders.assets`.** One is the policy for what the copy emits, the other is where it copies from. They are deliberately in different namespaces, but the names are close enough to be worth naming here.
54
58
 
55
59
  ## Types
@@ -65,6 +69,7 @@ The typedefs that describe the config to a consumer live at the top of this modu
65
69
  - **`markdown` is a passthrough, not a schema.** The three keys in `DEFAULT_MARKDOWN` are the ones kiss has an opinion about, not the ones it accepts: `resolveConfig` merges the block one level deep and `lib/kiss.js` spreads the result straight into `new Remarkable(...)`, so `typographer`, `langPrefix` or any other Remarkable option reaches the renderer without kiss knowing the name. That is why the typedef is `KissMarkdown & Record<string, any>` rather than a closed shape — unlike `folders`, where a typo must be an error, an unknown key here is a legitimate renderer option.
66
70
  - **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
67
71
  - **`links.canonical` is off by default, and that is the whole design decision.** Turning it on changes the URL every bare `{{link}}` emits, which is a site's entire internal link graph and every `href` in its published output — a major-version change, not a default. It exists because the alternative discipline does not scale: a site that is not `extensionLess`, served by a host that redirects `/about.html` to `/about`, needs `canonical=true` on every single call or it ships a redirect hop per link, and one forgotten call is silent in dev and visible only on the deployed site. A real consuming site was writing `canonical=true` sixty-six times and carrying a `CLAUDE.md` paragraph explaining that it was mandatory. One key here replaces that. The `extensionLess: true` route solves the same problem and is better greenfield, but it changes every output path and therefore the relationship between the built files and the `aliases` a site carries for its old URLs — which is why this is the key and not a nudge towards that flag. Landing it needed no new config surface: the block was already shaped for exactly this by the comment above it.
72
+ - **`audit` takes a boolean shorthand, and an unknown check id throws.** `audit: false` is the one-word way to turn a whole advisory pass off, so it is accepted where `links` is not; anything else that is not a plain object is refused rather than read as truthy. The ignore list is validated against `lib/audit.js`'s `CHECKS` for the `redirects.format` reason: a misspelled id that ignored nothing would leave the finding on every build while the author believed it switched off. The ids are imported, never repeated here, so a check cannot be accepted by the config and unknown to the audit.
68
73
  - **`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.
69
74
  - **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.
70
75
  - `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.
@@ -16,7 +16,7 @@ Resolves `options.controller` (a function, or a filename to load) and runs it ag
16
16
 
17
17
  ## Depended on by
18
18
 
19
- `lib/kiss.js`.
19
+ `lib/page-registry.js` (`applyController`, in `_page()` and the `.pages()` fan-out).
20
20
 
21
21
  ## Non-obvious behavior
22
22
 
@@ -16,8 +16,9 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
16
16
  - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`. **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
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
- - `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.
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. **On a page of a learning package** (the asking page's root context carries a string `package` and `config.packages` is not `false`) the link is `relativePathFor(asker.pageURL, pageURL)` instead: the file, relative to the page, with no collapse, canonical shape or trailing-slash policy, because an LMS serves a package's files as they are from wherever it unpacks them. `relative=` on the call decides it either way for any page; `absolute=true` always wins (code review, 2026-10-25).
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
+ - `scormBridge(options)` — the SCORM 1.2 bridge as one inline `<script>`, from `scormBridgeScript()` (`AIKB/scorm-bridge.md`). `complete="seen"` (the default) marks the page complete when it loads in an LMS; `complete="manual"` leaves it to `kissScorm.status()`. Any other value is an error: it logs and returns `''`, so a typo never ships a page that tracks differently from what the author asked.
21
22
 
22
23
  ## Depends on
23
24
 
@@ -33,6 +34,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
33
34
  - **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.
34
35
 
35
36
  - 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.
37
+ - **KNOWN ISSUE, not yet addressed: `{{sass '<path>'}}` is an unconstrained build-time file read, and `lib/html-split.js` made it reachable from a document somebody else wrote.** The helper hands any path it is given to the sass compiler and inlines the result, so `{{sass '/etc/…'}}` in a template reads a file from outside the site and puts it in the built page — anything Dart Sass can parse. Until this branch that was unremarkable: a template is code the site's author wrote, and an author who can write a template can already read their own disk. `splitDocument` changed the premise by turning a **foreign** document into compiled `.hbs`, which is why that module escapes `{{` by default (`AIKB/html-split.md`) — measured closing this path along with a config dump and an attribute exfiltration. The escape is a guard at the one door the branch opened, not a fix: the helper is still unconstrained for any other caller, and a site that opts out with `escapeExpressions: false` on input it did not write re-opens it. The proper fix — confining file mode to the site's own folders, or requiring an explicit allow-list — is a change to the helper's contract and belongs on its own branch. Judged out of scope here by the operator on 2026-10-01, recorded rather than left implicit. Re-check by reading `sassHelper`'s file branch in `lib/handlebars-helpers.js`: while it still calls `path.isAbsolute(context)` and compiles the result with no containment check, this entry is current.
36
38
  - 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).
37
39
  - `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js` — page identity, and nothing emitted is built from it) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
38
40
  - The match is by **path segment**: active iff `pageKey === hrefKey` or, under `folderMatch=true`, `pageKey.startsWith(hrefKey + '/')`. It was a bare `pageURL.includes(href)`, which lit `/blog` on `my-blog-post.html` and — because `''.includes()` is always true — lit every item of a nav that omitted `href` (D-02). An empty `href` under `folderMatch` now matches the home page only, which falls out of the same rule.
@@ -56,7 +58,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
56
58
 
57
59
  - **`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.
58
60
  - **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`).
59
- - **`link` renders no leading host, and a leading slash, where `asset` renders neither.** `{{asset}}` leaves the base to the template because an asset can legitimately be reached three ways; a link to a _page_ is a path from the site root, and `{{root}}`-style climbing would have to be re-derived per page from the id, which is the arithmetic the helper exists to remove. A site served from a path prefix, or a link that must be absolute (an OG tag, a feed), says so: `{{link "about" absolute=true}}`.
61
+ - **`link` renders no leading host, and a leading slash, where `asset` renders neither.** `{{asset}}` leaves the base to the template because an asset can legitimately be reached three ways; a link to a _page_ is a path from the site root, and `{{root}}`-style climbing would have to be re-derived per page from the id, which is the arithmetic the helper exists to remove. A site served from a path prefix, or a link that must be absolute (an OG tag, a feed), says so: `{{link "about" absolute=true}}`. The one exception is a learning-package page, where the helper does the climbing itself (`relativePathFor`), from the asking page's own URL rather than from the id; it is the only page an LMS serves away from the site root.
60
62
  - **`absolute=true` is a string join, not `toAbsoluteUrl`.** `toAbsoluteUrl` strips a trailing `index` segment with _any_ extension, so a page built to `data/index.json` would be advertised at `https://site/data/` while its relative form stayed `/data/index.json` — two different URLs for one page, one of them a directory with no index in it, and the link checker would report the absolute one as broken. The join is `siteUrl` with trailing slashes trimmed + the already-collapsed served path, which is exactly what `classifyReference` recognises as internal for both `siteUrl` shapes (bare origin, and origin + path prefix).
61
63
 
62
64
  ## Types