kiss-ssg 2.0.0 → 2.2.1

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 (191) hide show
  1. package/AIKB/aikb.md +72 -0
  2. package/AIKB/asset-manifest.md +32 -32
  3. package/AIKB/assets.md +37 -37
  4. package/AIKB/build-report.md +55 -40
  5. package/AIKB/check.md +52 -35
  6. package/AIKB/config.md +9 -5
  7. package/AIKB/controller-resolver.md +32 -32
  8. package/AIKB/dependency-graph.md +31 -31
  9. package/AIKB/dev-server.md +36 -36
  10. package/AIKB/feed.md +32 -0
  11. package/AIKB/fetch-policy.md +27 -27
  12. package/AIKB/handlebars-helpers.md +63 -57
  13. package/AIKB/kiss-page.md +56 -52
  14. package/AIKB/kiss.md +143 -125
  15. package/AIKB/links.md +37 -0
  16. package/AIKB/llms.md +33 -0
  17. package/AIKB/logger.md +26 -26
  18. package/AIKB/model-resolver.md +36 -36
  19. package/AIKB/partials.md +34 -34
  20. package/AIKB/pipeline.md +41 -41
  21. package/AIKB/redirects.md +44 -0
  22. package/AIKB/sass.md +34 -34
  23. package/AIKB/sitemap.md +27 -27
  24. package/AIKB/testing.md +50 -46
  25. package/AIKB/utils.md +49 -47
  26. package/AIKB/watcher.md +41 -41
  27. package/LICENSE +201 -201
  28. package/README.md +888 -654
  29. package/bin/kiss-ssg.js +125 -61
  30. package/examples/1-scan/README.md +20 -20
  31. package/examples/1-scan/models/about/us.json +28 -28
  32. package/examples/1-scan/models/index.json +20 -20
  33. package/examples/1-scan/pages/about/us.hbs +30 -30
  34. package/examples/1-scan/pages/index.hbs +41 -41
  35. package/examples/1-scan.js +33 -33
  36. package/examples/10-asset-pipeline/README.md +47 -47
  37. package/examples/10-asset-pipeline/assets/css/generated.css +1 -1
  38. package/examples/10-asset-pipeline/pages/index.hbs +62 -62
  39. package/examples/10-asset-pipeline/styles/tokens.json +6 -6
  40. package/examples/10-asset-pipeline/tools/tokens.js +73 -73
  41. package/examples/10-asset-pipeline.js +81 -81
  42. package/examples/11-blog/AIKB/README.md +72 -0
  43. package/examples/11-blog/AIKB/last-build.json +155 -0
  44. package/examples/11-blog/AIKB/notes/controllers/post.md +81 -0
  45. package/examples/11-blog/AIKB/notes/controllers/tag.md +66 -0
  46. package/examples/11-blog/AIKB/site-map.json +285 -0
  47. package/examples/11-blog/AIKB/site-map.md +69 -0
  48. package/examples/11-blog/README.md +319 -0
  49. package/examples/11-blog/controllers/post.js +118 -0
  50. package/examples/11-blog/controllers/tag.js +57 -0
  51. package/examples/11-blog/models/posts/2025-12-15-a-year-of-bench-lots.json +8 -0
  52. package/examples/11-blog/models/posts/2026-01-06-grind-size-is-the-dial.json +8 -0
  53. package/examples/11-blog/models/posts/2026-01-20-why-we-roast-on-wednesdays.json +8 -0
  54. package/examples/11-blog/models/posts/2026-02-03-guji-uraga-has-landed.json +8 -0
  55. package/examples/11-blog/models/posts/2026-02-18-the-cascara-experiment.json +11 -0
  56. package/examples/11-blog/models/posts/2026-03-02-pour-over-at-home.json +8 -0
  57. package/examples/11-blog/pages/blog/listing.hbs +35 -0
  58. package/examples/11-blog/pages/blog/post.hbs +60 -0
  59. package/examples/11-blog/pages/blog/tag.hbs +23 -0
  60. package/examples/11-blog/pages/blog/tags.hbs +31 -0
  61. package/examples/11-blog/pages/index.hbs +67 -0
  62. package/examples/11-blog/partials/head-feed.hbs +8 -0
  63. package/examples/11-blog/partials/post-card.hbs +15 -0
  64. package/examples/11-blog.js +182 -0
  65. package/examples/2-page/README.md +20 -20
  66. package/examples/2-page/controllers/about.js +9 -9
  67. package/examples/2-page/controllers/index.js +13 -13
  68. package/examples/2-page/models/about.json +11 -11
  69. package/examples/2-page/models/index.json +36 -36
  70. package/examples/2-page/pages/about.hbs +35 -35
  71. package/examples/2-page/pages/feed.hbs +9 -9
  72. package/examples/2-page/pages/index.hbs +59 -59
  73. package/examples/2-page.js +74 -74
  74. package/examples/3-pages/README.md +21 -21
  75. package/examples/3-pages/models/index.json +20 -20
  76. package/examples/3-pages/models/roasts/arch-blend.json +12 -12
  77. package/examples/3-pages/models/roasts/finca-la-ilusion.json +12 -12
  78. package/examples/3-pages/models/roasts/guji-uraga.json +12 -12
  79. package/examples/3-pages/models/roasts/huila-decaf.json +12 -12
  80. package/examples/3-pages/models/roasts/nyeri-gatomboya.json +12 -12
  81. package/examples/3-pages/models/roasts/sumatra-ketiara.json +12 -12
  82. package/examples/3-pages/pages/index.hbs +42 -42
  83. package/examples/3-pages/pages/roasts/index.hbs +22 -22
  84. package/examples/3-pages/pages/roasts/roast.hbs +41 -41
  85. package/examples/3-pages.js +60 -60
  86. package/examples/4-layouts-and-partials/README.md +20 -20
  87. package/examples/4-layouts-and-partials/layouts/layout.hbs +40 -40
  88. package/examples/4-layouts-and-partials/pages/index.hbs +64 -64
  89. package/examples/4-layouts-and-partials/pages/stockists.hbs +32 -32
  90. package/examples/4-layouts-and-partials/partials/boards/espresso.hbs +9 -9
  91. package/examples/4-layouts-and-partials/partials/boards/filter.hbs +9 -9
  92. package/examples/4-layouts-and-partials/partials/hours.html +26 -26
  93. package/examples/4-layouts-and-partials/partials/site/footer.hbs +6 -6
  94. package/examples/4-layouts-and-partials/partials/site/header.hbs +18 -18
  95. package/examples/4-layouts-and-partials/partials/sourcing.md +9 -9
  96. package/examples/4-layouts-and-partials.js +50 -50
  97. package/examples/5-helpers/README.md +21 -21
  98. package/examples/5-helpers/pages/brew-guide.hbs +34 -34
  99. package/examples/5-helpers/pages/index.hbs +142 -142
  100. package/examples/5-helpers/partials/grinder-note.md +3 -3
  101. package/examples/5-helpers/partials/tasting.md +3 -3
  102. package/examples/5-helpers/styles/board.scss +19 -19
  103. package/examples/5-helpers.js +77 -77
  104. package/examples/6-sitemap/README.md +26 -20
  105. package/examples/6-sitemap/models/about.json +6 -6
  106. package/examples/6-sitemap/models/index.json +6 -6
  107. package/examples/6-sitemap/models/rota.json +6 -6
  108. package/examples/6-sitemap/models/stockists.json +6 -6
  109. package/examples/6-sitemap/pages/about.hbs +22 -22
  110. package/examples/6-sitemap/pages/index.hbs +54 -54
  111. package/examples/6-sitemap/pages/rota.hbs +22 -22
  112. package/examples/6-sitemap/pages/stockists.hbs +22 -22
  113. package/examples/6-sitemap.js +67 -55
  114. package/examples/7-versioned-outputs/README.md +25 -25
  115. package/examples/7-versioned-outputs/models/index.json +23 -23
  116. package/examples/7-versioned-outputs/pages/index.hbs +47 -47
  117. package/examples/7-versioned-outputs.js +126 -126
  118. package/examples/8-data-fed-site/README.md +49 -45
  119. package/examples/8-data-fed-site/controllers/stockist.js +30 -30
  120. package/examples/8-data-fed-site/models/stockists/bakers-yard.json +11 -11
  121. package/examples/8-data-fed-site/models/stockists/cargo-canteen.json +11 -11
  122. package/examples/8-data-fed-site/models/stockists/harbour-market-stall.json +10 -10
  123. package/examples/8-data-fed-site/models/stockists/oakfield-deli.json +11 -11
  124. package/examples/8-data-fed-site/models/stockists/severn-provisions.json +11 -11
  125. package/examples/8-data-fed-site/models/stockists/the-hop-and-vine.json +11 -11
  126. package/examples/8-data-fed-site/pages/index.hbs +80 -80
  127. package/examples/8-data-fed-site/pages/stockists/stockist.hbs +38 -38
  128. package/examples/8-data-fed-site.js +84 -84
  129. package/examples/9-migrated-from-v1/AIKB/README.md +72 -0
  130. package/examples/9-migrated-from-v1/AIKB/last-build.json +122 -0
  131. package/examples/9-migrated-from-v1/AIKB/notes/controllers/shelf-item.md +43 -0
  132. package/examples/9-migrated-from-v1/AIKB/site-map.json +193 -0
  133. package/examples/9-migrated-from-v1/AIKB/site-map.md +64 -0
  134. package/examples/9-migrated-from-v1/README.md +63 -34
  135. package/examples/9-migrated-from-v1/controllers/shelf-item.js +6 -6
  136. package/examples/9-migrated-from-v1/pages/await-complete.hbs +71 -71
  137. package/examples/9-migrated-from-v1/pages/duplicate-paths.hbs +64 -64
  138. package/examples/9-migrated-from-v1/pages/dynamic-partials.hbs +54 -54
  139. package/examples/9-migrated-from-v1/pages/folders.hbs +65 -65
  140. package/examples/9-migrated-from-v1/pages/handlebars-instance.hbs +66 -66
  141. package/examples/9-migrated-from-v1/pages/index.hbs +57 -57
  142. package/examples/9-migrated-from-v1/pages/pure-controllers.hbs +65 -65
  143. package/examples/9-migrated-from-v1/pages/shelf/item.hbs +19 -19
  144. package/examples/9-migrated-from-v1/pages/smaller-changes.hbs +75 -75
  145. package/examples/9-migrated-from-v1/partials/sourcing-note.md +5 -5
  146. package/examples/9-migrated-from-v1/partials/stock-note.hbs +2 -2
  147. package/examples/9-migrated-from-v1.js +209 -194
  148. package/examples/README.md +58 -49
  149. package/examples/_shared/assets/css/site.scss +413 -413
  150. package/examples/_shared/assets/robots.txt +2 -2
  151. package/examples/_shared/layouts/layout.hbs +65 -65
  152. package/examples/_shared/partials/newsletter.md +5 -5
  153. package/examples/_shared/site.js +39 -39
  154. package/lib/aikb.js +962 -0
  155. package/lib/asset-manifest.js +52 -52
  156. package/lib/assets.js +108 -108
  157. package/lib/build-report.js +340 -177
  158. package/lib/check.js +330 -124
  159. package/lib/config.js +41 -4
  160. package/lib/controller-resolver.js +133 -133
  161. package/lib/dependency-graph.js +76 -76
  162. package/lib/dev-server.js +87 -87
  163. package/lib/feed.js +275 -0
  164. package/lib/fetch-policy.js +82 -82
  165. package/lib/handlebars-helpers.js +370 -266
  166. package/lib/kiss-page.js +328 -296
  167. package/lib/kiss.js +2404 -1671
  168. package/lib/links.js +357 -0
  169. package/lib/llms.js +276 -0
  170. package/lib/model-resolver.js +61 -61
  171. package/lib/partials.js +103 -103
  172. package/lib/pipeline.js +317 -317
  173. package/lib/redirects.js +350 -0
  174. package/lib/sass.js +163 -163
  175. package/lib/sitemap.js +62 -62
  176. package/lib/utils.js +209 -188
  177. package/lib/watcher.js +154 -154
  178. package/llms.txt +333 -188
  179. package/package.json +103 -102
  180. package/types/aikb.d.ts +362 -0
  181. package/types/build-report.d.ts +244 -2
  182. package/types/check.d.ts +105 -3
  183. package/types/config.d.ts +53 -7
  184. package/types/feed.d.ts +141 -0
  185. package/types/handlebars-helpers.d.ts +18 -4
  186. package/types/kiss-page.d.ts +4 -0
  187. package/types/kiss.d.ts +169 -1
  188. package/types/links.d.ts +97 -0
  189. package/types/llms.d.ts +149 -0
  190. package/types/redirects.d.ts +219 -0
  191. package/types/utils.d.ts +7 -0
package/AIKB/aikb.md ADDED
@@ -0,0 +1,72 @@
1
+ # aikb.js
2
+
3
+ ## Responsibility
4
+
5
+ Writes the site's own knowledge base — the folder `config.folders.aikb` names, default `./AIKB` — from one settled build. The third of the three "describe the site" writers: `sitemap.js` writes it for a crawler, `llms.js` for an answer engine, this one for the **next developer**, who is usually the same developer two years later with none of the context left.
6
+
7
+ Unlike the other two it has no public API call behind it. The only thing that writes this folder is `npx kiss-ssg aikb <script>` — a ceremony performed once at the end of a piece of work — which sets `KISS_AIKB` for the build it spawns. Every other build reads the folder and reports on it, and writes nothing. That is the whole point: `last-build.json` is the baseline `kiss-ssg check` diffs against, so it must move when a piece of work closes and at no other moment, or "what have I changed" silently means "since I last ran a build".
8
+
9
+ It does two separable jobs. It **builds and renders the map** — pages, their models and controllers, which partials each one rendered, the partial→page index, the asset pipeline, and the subjects that want a note with a hash of what each one currently is — which is regenerated on every build and must never be hand-edited. And it **lints the authored half** of the folder (`notes/` and `site.md`), which the engine never writes: which subjects have no note (`missing`), which notes have outlived their subject (`dead`), which were written against a version of their subject that has since changed (`stale`), and which cite a file that resolves to nothing (`dangling`).
10
+
11
+ The four are deliberately mechanical. "Is this note still _true_?" is a question only a person can answer, and a rule that pretended to answer it would be wrong in both directions. "Was this note written against a different controller?" and "does this path exist?" are questions a build can answer for nothing, every time, and they are the two ways a note rots quietly while still reading as correct.
12
+
13
+ Everything but the two writers is pure, and nothing anywhere reads a clock: two identical builds must produce byte-identical files, or the committed folder churns on every build and its diff stops meaning anything.
14
+
15
+ ## Public interface
16
+
17
+ - `isRecorded(folder)` → whether `<folder>/site-map.json` exists. The opt-in test, and synchronous: `lib/kiss.js` asks it once per settled build.
18
+ - `classifyModel(model)` → `file:<name>` / `folder:<name>` / `url:<url>` / `inline` / `none`, from the **registered** model option. The same three string tests `resolveModel` makes, in the same order.
19
+ - `classifyController(controller)` → `file:<name>` / `inline` / `none`, from the registered controller option.
20
+ - `pageOrigin(options)` → `{ model, controller }`. What `lib/kiss.js` calls in `page()`, before the chain replaces `options.model` with the resolved data.
21
+ - `buildSiteMap({ stack, graph, config, pipeline, buildDir, stagingDir, readSubject })` → `SiteMap`: `{ site, pages[], partials{}, models[], controllers[], pipeline[], subjects[] }`, every list sorted. `pipeline` defaults to `config.assets.pipeline`; `readSubject(kind, id)` defaults to a reader that reads `folders.controllers/<id>` off disk.
22
+ - `renderSiteMap(map)` → the markdown of `site-map.md`, one trailing newline. Its `## Pages` table is `Output | Id | View | Model | Controller | Partials & layouts`, one row per page, `none` in any cell the page has nothing for.
23
+ - `renderAikbReadme()` → the markdown of `AIKB/README.md`.
24
+ - `noteSubjects(map)` → `{ kind, id }[]`, sorted by kind then id: every controller **file**, every **URL** model, every pipeline step. `map.subjects` is this list with `note` and `hash` added.
25
+ - `notePathFor(subject, notesDir = 'notes')` → the note's path. `stockist.js` → `notes/controllers/stockist.md`; `https://api.example.com/v2/events?x=1` → `notes/models/api.example.com-v2-events.md`; `tailwind` → `notes/pipeline/tailwind.md`.
26
+ - `readFrontmatter(text)` → the `---` block's `key: value` lines as an object, or `null` when there is none. Not a YAML parser — the same handful of lines `test/unit/plugin-manifests.test.js` reads out of a `SKILL.md` header. `subject-hash` is the only key this module reads, and it never writes one.
27
+ - `evaluateNotes(map, notesDir, { aikbDir })` → `{ missing, dead, stale, dangling }`, all four sorted. The first three are note paths built from `notesDir`, so they are the paths a person would type; `dangling` entries are `<note path>: <token>`. `aikbDir` is what lets `site.md` be scanned and lets a note cite its neighbours.
28
+ - `async writeAikb({ map, folder, logger, write = true })` → `AikbResult` (`{ folder, written, notes, subjects }`). Writes `README.md` (only if absent), `site-map.md` and `site-map.json`. `write: false` evaluates and writes nothing — which is every build but a passing record. A write failure is logged and reported as `written: false`.
29
+ - `lastBuildRecord(report)` → the report with `duration` and every `pipeline[].duration` dropped, key order otherwise unchanged.
30
+ - `async writeLastBuild({ folder, report, logger })` → `boolean`. Writes `last-build.json`.
31
+
32
+ ## Depends on
33
+
34
+ `fs-extra`; `./utils.js` (`globFiles`, `hashId`, `posixPath`); `./build-report.js` (`reportedPath`, `reportedView` — both exported for this module).
35
+
36
+ ## Depended on by
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`).
39
+
40
+ ## Non-obvious behavior
41
+
42
+ - **A note may name the engine's own outputs.** `sitemap.xml`, `llms.txt`, `feed.xml` and `_redirects` are written into the build beside the pages and appear in no map list, so `dangling` accepts them by name; any other token is also accepted when it exists under `site.build` on disk. Without this every note that said "the feed" in backticks was a false positive, and a lint that fires on true statements is one people switch off.
43
+ - **A record is the only writer, and it only ever records a build that worked.** `_buildAikb()` writes when `KISS_AIKB` is on, `config.dev` is false and `failures.length === 0`; anything else evaluates the notes and reports `written: false`. A refused record says so on the log — the operator is standing there waiting for the folder to change, and silence would read as success. The consequence to keep in mind is that the folder describes the _last build that passed_, not the working tree: a failed `kiss-ssg aikb` leaves the previous record in place, which is exactly what makes the next diff meaningful.
44
+ - **`site-map.json` is the opt-in marker, not the folder.** `isRecorded()` looks for that one file because only a record ever writes it. A folder that merely exists proves nothing: this repository's own `AIKB/` is hand-written module notes, and `docs.js` builds a site inside it with the default `folders.aikb` — if existence were the test, the docs build would adopt those notes as a site map and start reporting every one of them as `dead`.
45
+ - **The map is written after the build settles, not on the promise queue.** `.sitemap()` and `.llms()` need only the registry, which is complete once `_promises` has drained. The partial-per-page index is learned from _rendering_ (`lib/dependency-graph.js`), so a map built at that point would list no partials at all. `Kiss._finishBuild()` is the one place where every page has rendered — which is also where the report is assembled, so the report can carry the verdict.
46
+ - **Each page row carries its `id`, second, because the map is read as the `{{link}}` autocomplete.** An agent writing a template reads the identity straight off the row for the page it can see, which is why the column sits immediately after the output path rather than at the end. It is copied from the stack entry (`entry.id ?? null`) exactly as the report copies it, and a page with no id renders the module's own `NONE` (`none`), the same word the Model and Controller columns use — so an idless page reads as "nothing to link", not as a blank cell. `Kiss` withdraws a default id two pages arrived at before it hands over the stack, so an id on the map is always one `{{link}}` will resolve (`AIKB/kiss.md`).
47
+ - **The model classification comes from the registration, not the stack.** By the time a page is on the stack, `options.model` _is_ the resolved data: a map read off the stack alone would say `inline` for every page in the site. `Kiss.page()` calls `pageOrigin(options)` after its two auto-mappings and hands the result to `_preparePage`, which parks it on the stack entry as `entry.origin`. It is deliberately not a key on `options`: every own key of a registration reaches the render context and the dev-mode `.json` sibling.
48
+ - **The dependency graph is keyed on the path a page was written to, which under `cleanBuild: 'atomic'` is the staging path.** A promotion rewrites each stack entry's `buildTo` to the real folder but leaves the graph alone, so `graph.usesOf(entry.buildTo)` returns `[]` after a successful atomic build. `buildSiteMap` therefore inverts `graph.toJSON()` and maps **both** sides through `reportedPath`, and `Kiss` passes `_stagingDir ?? _promotedFrom` so the prefix survives the promotion.
49
+ - **`README.md` is written once and never overwritten.** It is the one file in the folder that asks the reader to write something; a build that rewrote it every time would be punishing them for doing what it asked.
50
+ - **Nothing under `notes/` is ever written, moved or deleted by the engine.** The four rules are findings on the report and nothing more: they never change `ok`, and `exitCodeFor` does not read them. A strict mode is a deliberate non-goal — a rule that fails a build for a missing paragraph gets a paragraph of filler, not a paragraph of judgement.
51
+ - **A subject's hash is a dependency, not a file read.** Everything else in the module is a pure function of what it was handed, and one subject's hash is the bytes of a file nobody passed in. `buildSiteMap` therefore takes `readSubject(kind, id)` with a default that reads the controller off disk: the engine gets the disk, a unit test gets whatever it says, and the module never reaches for a path on its own initiative. The reader is asked about **controllers only** — a pipeline step's hash is sha1 of its `run` string, which is already in the map, and a URL model has no hash at all.
52
+ - **A URL model's hash is `null`, and that is not an omission.** Its id _is_ the subject: an id that changed is a different subject, with a different note path, which the `missing`/`dead` pair already reports. So a URL model can never be `stale`, and nothing is lost by it.
53
+ - **An unreadable controller hashes to `null` rather than to anything.** No controllers folder, a file that was deleted, a directory in its place — all `null`, which is the same answer as "nothing to hash", and a subject with no hash is never stale. Guessing would be worse than saying nothing: a wrong hash would mark every note about it stale for ever.
54
+ - **The engine never writes a stamp.** `subject-hash` is copied into a note's frontmatter by the person (or the `kiss-branch-close` / `kiss-memory-consolidate` skill) who wrote or reviewed it. An unstamped note is the state every note starts in and most notes stay in, and it is never reported stale — only a stamp that _disagrees_ is a finding, which is what makes stamping opt-in per note rather than a burden on everyone.
55
+ - **The subjects are on the report as well as in `site-map.json`, because at the one moment that matters the two disagree.** A piece of work closes with the map on disk still describing the _previous_ record — so the hash in that file is the hash the note is already stamped with, and the hash it wants restamping with exists nowhere but the report of the build that just found the finding. Hence `aikb.subjects` beside `aikb.notes`: a plain `kiss-ssg check` can say both "this note is stale" and "here is what to stamp it with", before any record is made.
56
+ - **The dangling filter errs, at every step, towards saying nothing.** A backticked token is a candidate only if it has a `/` in it or ends in one of eleven known extensions, and only if it holds none of ` <>*{}$` — so `{ name, origin }`, `<title>`, `npx kiss-ssg check` and `p-<8 hex>` are all prose, not paths. Fenced code blocks are dropped whole (a snippet quotes code rather than citing a file), a trailing `/` is a folder, and anything with a URI scheme (`https:`, and also the map's own `file:`/`url:` classifications) is skipped. The reason is not politeness: a lint that fires on every note is a lint people learn to scroll past, and then it is worth less than nothing.
57
+ - **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`.
58
+ - **`site.md` is scanned but never judged.** It is authored, evergreen and about the whole site rather than any one subject, so it has no subject to be missing, dead or stale about — but its file references rot exactly as a note's do, so it is read for `dangling` when it is there. The engine neither writes it nor enforces its shape; the generated `README.md` names it and its five sections, and `kiss-memory-consolidate` maintains it.
59
+ - **A subject hash is over bytes, so a checkout that rewrites line endings restamps every note.** The controller's hash is sha1 of the file as it is on disk; a CRLF checkout of an LF repository hashes differently, and every stamped controller note reads `stale` until it is restamped. The alternative — normalising the bytes before hashing — would make the hash disagree with `git`'s idea of whether the file changed, which is the thing the stamp is trying to track.
60
+ - **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
61
+ - **A note's filename is derived from the subject's id, so two subjects can collide.** `a-x.js` and `a/x.js` both slug to `a-x`. Rare, and the alternative — a hash in the filename — would make the rule impossible to apply by hand, which is the property that matters more.
62
+ - **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes.
63
+ - **Every `.md` under `notes/` that is not an expected note is `dead`,** including one filed under a folder the engine does not recognise. Silently ignoring it would hide it for ever, which is the failure mode the rule exists to catch.
64
+ - **`last-build.json` drops every duration, not just the top-level one.** The plan called for "the report minus `duration`"; a `pipeline[].duration` is timing too, and leaving it in would make the file churn on every build of any site with an asset pipeline — which is exactly the property the folder is committed for. Nothing reads those durations: `diffReports` pairs reports by `buildDir` and matches pages by `buildTo` and `hash`.
65
+ - **A recorded snapshot says `mode: 'check'`.** `kiss-ssg aikb` sets `KISS_CHECK` too — it stages the build and throws it away, because recording must not publish — so the report it snapshots is a check's. Nothing reads that field to diff: `diffReports` pairs by `buildDir` and compares `hash`. It is worth knowing when reading `last-build.json` by eye.
66
+ - **Markdown tables are emitted pre-aligned, the way prettier aligns them** — every cell padded to the widest in its column, a delimiter row of at least three dashes — so a committed `site-map.md` passes `prettier --check` and a pre-commit hook never has to rewrite a build artefact. Width is counted in code points rather than display columns; that is the same number for every character kiss can put in a cell (output paths are slugged to ASCII by `utils.toSlug`, and a partial name is a filename). `site-map.json` and `last-build.json` are `JSON.stringify` with two spaces, which is _not_ what prettier does to a short array — this repository's `.prettierignore` excludes example 9's generated JSON for that reason, and a consuming site should do the same.
67
+ - **A `generate: false` page is in the map,** as it is in the report: it is a registered page with a claimed output path, and hiding it would hide its model and controller too.
68
+ - **The folder is source, not output.** It sits outside `folders.build`, survives `cleanBuild`, is absent from `foldersToEnsure` (so a site that has never recorded never gets an empty `AIKB/`), and is meant to be committed.
69
+
70
+ ## Types
71
+
72
+ `SiteMap` with `SiteMapSite`, `SiteMapPage`, `SiteMapSource`, `SiteMapPipelineStep` and `SiteMapSubject` (`{ kind, id, note, hash }`, the last two added to a `NoteSubject`); `NoteSubject`; `AikbResult`. The report's own view of the last of these is `BuildAikb` in `lib/build-report.js` — the same four fields, declared where the report shape is declared so `BuildReport` stays readable in one file. `SiteMap` is re-exported from `lib/kiss.js` as the shape of `site-map.json`; there are no options anywhere, because there is no call to pass them to.
@@ -1,32 +1,32 @@
1
- # asset-manifest.js
2
-
3
- ## Responsibility
4
-
5
- Holds what an asset copy emitted — the path a template writes mapped to the file that is actually in the build — and owns the naming half of the cache-busting policy (content hash, hashed filename, which files may be renamed at all).
6
-
7
- ## Public interface
8
-
9
- - `HASH_LENGTH` — `8`, the number of hex characters a content hash contributes to a filename.
10
- - `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
11
- - `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
12
- - `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
13
- - `createAssetManifest()` → `{ record, lookup, toObject }`, a fresh manifest with its own `Map`.
14
- - `record(urlPath, emittedPath)` — stores the mapping and returns the **previous** `emittedPath` when it differs from the new one (the caller's cue to delete the file it just replaced), otherwise `null`.
15
- - `lookup(urlPath)` — the emitted path, or `null`.
16
- - `toObject()` — a plain object copy of the map, for debugging.
17
-
18
- ## Depends on
19
-
20
- `node:crypto`.
21
-
22
- ## Depended on by
23
-
24
- `lib/assets.js` (records and renames), `lib/handlebars-helpers.js` (the `asset` helper reads it), `lib/kiss.js` (creates the one per instance and threads it into both).
25
-
26
- ## Non-obvious behavior
27
-
28
- - **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
29
- - **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
30
- - **`record` returning the old name is the whole stale-file mechanism.** Nothing else knows which file a rename replaced: `Kiss._replay()`'s orphan sweep only ever considers page outputs (`_stack` `buildTo` paths), so a hashed asset is invisible to it. `lib/assets.js` deletes what `record` hands back.
31
- - **A manifest is per `Kiss` instance**, like the Handlebars environment, so two sites in one process cannot link each other's files. Nothing here is module-level state.
32
- - MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
1
+ # asset-manifest.js
2
+
3
+ ## Responsibility
4
+
5
+ Holds what an asset copy emitted — the path a template writes mapped to the file that is actually in the build — and owns the naming half of the cache-busting policy (content hash, hashed filename, which files may be renamed at all).
6
+
7
+ ## Public interface
8
+
9
+ - `HASH_LENGTH` — `8`, the number of hex characters a content hash contributes to a filename.
10
+ - `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
11
+ - `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
12
+ - `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
13
+ - `createAssetManifest()` → `{ record, lookup, toObject }`, a fresh manifest with its own `Map`.
14
+ - `record(urlPath, emittedPath)` — stores the mapping and returns the **previous** `emittedPath` when it differs from the new one (the caller's cue to delete the file it just replaced), otherwise `null`.
15
+ - `lookup(urlPath)` — the emitted path, or `null`.
16
+ - `toObject()` — a plain object copy of the map, for debugging.
17
+
18
+ ## Depends on
19
+
20
+ `node:crypto`.
21
+
22
+ ## Depended on by
23
+
24
+ `lib/assets.js` (records and renames), `lib/handlebars-helpers.js` (the `asset` helper reads it), `lib/kiss.js` (creates the one per instance and threads it into both).
25
+
26
+ ## Non-obvious behavior
27
+
28
+ - **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
29
+ - **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
30
+ - **`record` returning the old name is the whole stale-file mechanism.** Nothing else knows which file a rename replaced: `Kiss._replay()`'s orphan sweep only ever considers page outputs (`_stack` `buildTo` paths), so a hashed asset is invisible to it. `lib/assets.js` deletes what `record` hands back.
31
+ - **A manifest is per `Kiss` instance**, like the Handlebars environment, so two sites in one process cannot link each other's files. Nothing here is module-level state.
32
+ - MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
package/AIKB/assets.md CHANGED
@@ -1,37 +1,37 @@
1
- # assets.js
2
-
3
- ## Responsibility
4
-
5
- Compiles every Sass file under the assets folder to a sibling `.css` file in the build folder, copies everything else (excluding raw Sass sources) straight through, and records what it emitted in the instance's asset manifest — applying the renaming half of the cache-busting policy on the way.
6
-
7
- ## Public interface
8
-
9
- - `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves.
10
- - `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss` always passes its own.
11
-
12
- ## Depends on
13
-
14
- `fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
15
-
16
- ## Depended on by
17
-
18
- `lib/kiss.js`.
19
-
20
- ## Non-obvious behavior
21
-
22
- - `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
23
- - **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
24
- - Sass sources (`.scss`/`.sass`) are explicitly excluded from the plain `fs.copy` pass — they're only ever emitted as compiled `.css`, never copied verbatim.
25
- - Sass compilation is `await`ed inside `copyAssets` (via `await Promise.all(compileSassFiles(...))`) and is therefore tracked as part of the single promise `Kiss.copyAssets()` pushes onto `_promises` — v1 fired the CSS write and never tracked it, so a build could finish (and `generate()`/`complete()` resolve) before Sass output existed.
26
- - The returned `{ id, data }` shape is the same shape `Kiss.generate(callback)`'s data array element takes — `id` lets `Kiss.getModelByID` (or manual inspection) find this asset-copy result among the other resolved `_promises`.
27
- - The sass compiler is imported from `./sass.js`, never from `sass` directly: which export carries the modern API depends on the installed sass version, and that detection lives in one place (see `AIKB/sass.md`).
28
- - **The manifest is keyed by the path a template writes, not by the source file.** A `.scss` source is recorded under its compiled `.css` name, so `{{asset "css/site.css"}}` resolves and a template never has to know which policy is on (that is the point of gap O6: the template stops encoding the caching scheme). Keys are relative to `config.folders.build`, not to this copy's target, so an extra `copyAssets(src, './public/extra')` records `extra/x.css` — the URL a page asks for. A target outside the build folder falls back to target-relative keys.
29
- - **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
30
- - **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
31
- - **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
32
- - **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
33
- - **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded — a broken sass file still logs its own error and leaves the copy successful.
34
- - `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
35
- - **The files a copy picks up may have been written moments earlier by `config.assets.pipeline`.** This module knows nothing about that: the steps are chained onto the same `_assetQueue` this module's calls are serialised by, so by the time `copyAssets` walks the source tree the tools have finished writing into it. See `AIKB/pipeline.md` and `AIKB/kiss.md`.
36
- - `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
37
- - **Sass is compiled through `compileFile()`, which loads the package at the point of compile and memoises the result**, not bound at import — see `AIKB/sass.md`. A site with no `.scss` under its assets folder never loads the `sass` package at all.
1
+ # assets.js
2
+
3
+ ## Responsibility
4
+
5
+ Compiles every Sass file under the assets folder to a sibling `.css` file in the build folder, copies everything else (excluding raw Sass sources) straight through, and records what it emitted in the instance's asset manifest — applying the renaming half of the cache-busting policy on the way.
6
+
7
+ ## Public interface
8
+
9
+ - `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves.
10
+ - `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss` always passes its own.
11
+
12
+ ## Depends on
13
+
14
+ `fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
15
+
16
+ ## Depended on by
17
+
18
+ `lib/kiss.js`.
19
+
20
+ ## Non-obvious behavior
21
+
22
+ - `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
23
+ - **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
24
+ - Sass sources (`.scss`/`.sass`) are explicitly excluded from the plain `fs.copy` pass — they're only ever emitted as compiled `.css`, never copied verbatim.
25
+ - Sass compilation is `await`ed inside `copyAssets` (via `await Promise.all(compileSassFiles(...))`) and is therefore tracked as part of the single promise `Kiss.copyAssets()` pushes onto `_promises` — v1 fired the CSS write and never tracked it, so a build could finish (and `generate()`/`complete()` resolve) before Sass output existed.
26
+ - The returned `{ id, data }` shape is the same shape `Kiss.generate(callback)`'s data array element takes — `id` lets `Kiss.getModelByID` (or manual inspection) find this asset-copy result among the other resolved `_promises`.
27
+ - The sass compiler is imported from `./sass.js`, never from `sass` directly: which export carries the modern API depends on the installed sass version, and that detection lives in one place (see `AIKB/sass.md`).
28
+ - **The manifest is keyed by the path a template writes, not by the source file.** A `.scss` source is recorded under its compiled `.css` name, so `{{asset "css/site.css"}}` resolves and a template never has to know which policy is on (that is the point of gap O6: the template stops encoding the caching scheme). Keys are relative to `config.folders.build`, not to this copy's target, so an extra `copyAssets(src, './public/extra')` records `extra/x.css` — the URL a page asks for. A target outside the build folder falls back to target-relative keys.
29
+ - **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
30
+ - **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
31
+ - **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
32
+ - **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
33
+ - **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded — a broken sass file still logs its own error and leaves the copy successful.
34
+ - `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
35
+ - **The files a copy picks up may have been written moments earlier by `config.assets.pipeline`.** This module knows nothing about that: the steps are chained onto the same `_assetQueue` this module's calls are serialised by, so by the time `copyAssets` walks the source tree the tools have finished writing into it. See `AIKB/pipeline.md` and `AIKB/kiss.md`.
36
+ - `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
37
+ - **Sass is compiled through `compileFile()`, which loads the package at the point of compile and memoises the result**, not bound at import — see `AIKB/sass.md`. A site with no `.scss` under its assets folder never loads the `sass` package at all.
@@ -1,40 +1,55 @@
1
- # build-report.js
2
-
3
- ## Responsibility
4
-
5
- Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report()` hands out, `err.report` carries, `KISS_REPORT` writes and `kiss-ssg check` prints — plus the one-line human rendering of it. Pure: every input is passed in, nothing here reads or writes anything.
6
-
7
- ## Public interface
8
-
9
- - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap })` → `BuildReport`, whose keys are always in this order:
10
- - `ok` — `failures.length === 0`.
11
- - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
- - `buildDir` — the folder the site asked for, never the staging sibling.
13
- - `duration` — ms since `startedAt` (`Kiss` passes its construction time).
14
- - `pages` — `{ view, buildTo, ok }` per stack entry, in registration order; `ok` is `false` when a failure names that output path.
15
- - `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
16
- - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
- - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
18
- - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
19
- - `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. What `kiss-ssg check --summary` prints instead of the JSON.
20
-
21
- ## Depends on
22
-
23
- Nothing.
24
-
25
- ## Depended on by
26
-
27
- `lib/kiss.js` (assembles one report per settled build in `_finishBuild()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
28
-
29
- ## Non-obvious behavior
30
-
31
- - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo` and `sitemap` all go through the same internal `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.
32
- - **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.
33
- - **`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`.
34
- - **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
35
- - **`pipeline` is appended, not slotted in beside `assets`.** It reports work that happens _before_ the copy, so its natural home would be above `assets` — but a consumer diffing two reports (or eyeballing one in a terminal) should see one new key rather than a reshuffle of the eight that were already there. A step's `Error` is dropped on the way in for the same reason a failure's is: the message is already in `failures`, under `<pipeline: <name>>`.
36
- - **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
37
-
38
- ## Types
39
-
40
- `BuildReport`, `BuildPage`, `BuildAsset`, `BuildReportFailure` and `BuildPipelineStep` are declared here and re-exported from `lib/kiss.js` (`@typedef {import('./build-report.js').BuildReport} BuildReport`), the same way `KissConfig` is re-exported from `lib/config.js` — so a consumer reaches every one of them as `import('kiss-ssg').BuildReport`.
1
+ # build-report.js
2
+
3
+ ## Responsibility
4
+
5
+ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report()` hands out, `err.report` carries, `KISS_REPORT` writes and `kiss-ssg check` prints — plus the one-line human rendering of it. Pure: every input is passed in, nothing here reads or writes anything.
6
+
7
+ ## Public interface
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:
10
+ - `ok` — `failures.length === 0`.
11
+ - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
+ - `buildDir` — the folder the site asked for, never the staging sibling.
13
+ - `duration` — ms since `startedAt` (`Kiss` passes its construction time).
14
+ - `pages` — `{ view, buildTo, ok, hash, id }` per stack entry, in registration order; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
15
+ - `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
16
+ - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
+ - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
18
+ - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
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
+ - `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.
22
+ - `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`.
23
+ - `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
24
+ - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the four link/redirect findings: ` broken link: <page> -> <href>`, ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. What `kiss-ssg check --summary` prints instead of the JSON.
25
+ - `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
26
+
27
+ ## Depends on
28
+
29
+ Nothing.
30
+
31
+ ## Depended on by
32
+
33
+ `lib/kiss.js` (assembles one report per settled build in `_finishBuild()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
34
+
35
+ ## Non-obvious behavior
36
+
37
+ - **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
+ - **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
+ - **`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`.
40
+ - **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
41
+ - **`pipeline` is appended, not slotted in beside `assets`.** It reports work that happens _before_ the copy, so its natural home would be above `assets` — but a consumer diffing two reports (or eyeballing one in a terminal) should see one new key rather than a reshuffle of the eight that were already there. A step's `Error` is dropped on the way in for the same reason a failure's is: the message is already in `failures`, under `<pipeline: <name>>`.
42
+ - **`aikb` is `null` on most builds, and that is not the same as "no knowledge base".** The key carries a verdict only when the site is opted in — `folders.aikb` set, and either this build is a record (`KISS_AIKB`) or `<folder>/site-map.json` is already there. A site that has never recorded reports `null`; once it has, every build reports `{ folder, written, notes }` with `written: false` unless this one was the record itself. See `AIKB/aikb.md`.
43
+ - **`id` is read off the stack entry, not off the page's options.** Identity is the orchestrator's bookkeeping — `Kiss._preparePage` computes it after `prepare()`, where the normalised slug is finally known, and keeps it on the entry rather than on `options`, because every own key of an options object reaches the render context and the dev-mode `.json` sibling (`AIKB/kiss.md`). This module simply copies `entry.id ?? null`, which is what makes a hand-built stack entry in a test report `null` instead of crashing. Two things report `null` for different reasons and both mean "cannot be linked": a page that never had an id (an inline template, a `generate: false` page) and a default id two pages arrived at, which `Kiss` withdraws from both before it hands this module the stack — deliberately, because `site-map.json`'s rows are read as the `{{link}}` autocomplete and an id the helper would refuse is worse than none. A consumer pairing two records **by id** (`lib/redirects.js`'s `moved`) must therefore skip `null` on both sides, and must not assume an id is unique across builds that predate the key.
44
+ - **`aikb.subjects` is the report's answer to a question `site-map.json` cannot answer yet.** It is `{ kind, id, note, hash }` per subject of _this_ build. The map on disk still describes the previous record, so the hash in that file is the one a note is already stamped with; the hash a note wants restamping with lives only here until the next record is made. A plain `kiss-ssg check` can therefore say both "this note is stale" and "here is what to stamp it with". Appended after `notes` for the reason every other key was appended after the one before it.
45
+ - **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
46
+ - **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
47
+ - **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
48
+ - **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
49
+ - **The last three keys are appended, and `null` is not the same as empty.** `links`, `redirects` and `feed` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
50
+ - **`redirects.file`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map.
51
+ - **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
52
+
53
+ ## Types
54
+
55
+ `BuildReport`, `BuildPage`, `BuildAsset`, `BuildReportFailure`, `BuildPipelineStep`, `BuildAikb`, `BuildLinks`, `BuildBrokenLink` and `BuildRedirects` are declared here and re-exported from `lib/kiss.js` (`@typedef {import('./build-report.js').BuildReport} BuildReport`), the same way `KissConfig` is re-exported from `lib/config.js` — so a consumer reaches every one of them as `import('kiss-ssg').BuildReport`.
package/AIKB/check.md CHANGED
@@ -1,35 +1,52 @@
1
- # check.js
2
-
3
- ## Responsibility
4
-
5
- The decision core of the `kiss-ssg` command line: what an argv asked for, what the reports file says, and what to exit with. `bin/kiss-ssg.js` is the thin wrapper around it — it spawns the site's own build script, makes the temp file and prints; every answer it acts on comes from here.
6
-
7
- ## Public interface
8
-
9
- - `HELP` — the usage text both `--help` and a usage error print.
10
- - `parseArgs(argv)` → `{ command, script, args, summary, error }`. `command` is `'check'` 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; `error` is a usage error (print it with `HELP` and exit 1).
11
- - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
12
- - `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`.
13
-
14
- ## Depends on
15
-
16
- Nothing (`readReports`' return type refers to `./build-report.js`, but only as a type).
17
-
18
- ## Depended on by
19
-
20
- `bin/kiss-ssg.js`.
21
-
22
- ## Non-obvious behavior
23
-
24
- - **Everything after the script is passed through to it, `--summary` excepted.** A site that takes its own arguments (`kiss-ssg check menu.js 2026-spring`) is checked exactly the way it is run; `--summary` is read wherever it is written, because "check this and summarise it" is a thought people finish at the end of the line. That leaves one word reserved, so a bare `--` ends the options and hands the rest through verbatim (`kiss-ssg check menu.js -- --summary`) for the site that genuinely needs it. Before the script an unrecognised flag is a usage error instead: there is no script yet for it to belong to.
25
- - **No reports at all is a failure, not a pass.** A script that never awaits `complete()` writes no line, and that is the very silence the check exists to break: it exits 0 on a broken site. `exitCodeFor` treats an empty list as `1` and the bin says why on stderr.
26
- - **The script's own exit code is honoured too.** A site that catches its build failure and exits 1 itself, or one that dies before it ever builds, fails the check whatever the reports say.
27
- - **A malformed line throws rather than being skipped.** A report nobody can parse is a finding — dropping it silently would turn a corrupt run into "nothing was reported", or worse into a pass.
28
- - **A check runs `config.assets.pipeline` exactly as a build does**, and that is deliberate rather than an oversight in what check mode discards. A step writes into the site's own **source** tree (`src/assets/css/site.css` — see `AIKB/pipeline.md`), so there is nothing extra for the check to throw away: what it stages and discards is still only the build folder. The consequence worth knowing is that a check is not read-only over the working tree — it regenerates whatever its steps generate, and a failing step fails the check like a failing page, reported as `<pipeline: <name>>`. In `dev: false` (which check mode forces) no `watch` process is ever started, so a check always terminates.
29
- - **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
30
- - **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples && node 8-data-fed-site.js`), so moving it would check a different set of paths from the ones it builds with.
31
- - **The temp reports file is removed in a `finally`**, and the bin sets `process.exitCode` rather than calling `process.exit()`, so the cleanup runs before the process leaves.
32
-
33
- ## Types
34
-
35
- `types/check.d.ts` is emitted from the JSDoc here like every other `lib/` module, but nothing imports it: the CLI surface is the command, not the module. `CheckArgs` is documented for the reader of this file.
1
+ # check.js
2
+
3
+ ## Responsibility
4
+
5
+ The decision core of the `kiss-ssg` command line: what an argv asked for, what the reports file says, which baseline to diff against, and what to exit with. `bin/kiss-ssg.js` is the thin wrapper around it — it spawns the site's own build script, makes the temp file and prints; every answer it acts on comes from here.
6
+
7
+ Two commands share all of it. `check <script>` runs the site's build staged and discarded and says what it built; `aikb <script>` runs the identical build and additionally sets `KISS_AIKB`, which is what lets the engine write the site's knowledge base (`AIKB/aikb.md`). Same options, same exit code, one env var and one summary line apart.
8
+
9
+ ## Public interface
10
+
11
+ - `HELP` — the usage text both `--help` and a usage error print; it documents both commands.
12
+ - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
13
+ - `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 }`, the three lists holding output paths, sorted. Pure.
16
+ - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged`. Indented to sit under that site's `formatReport` line.
17
+ - `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
18
+ - `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
+ - `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
20
+
21
+ ## Depends on
22
+
23
+ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a type).
24
+
25
+ ## Depended on by
26
+
27
+ `bin/kiss-ssg.js`.
28
+
29
+ ## Non-obvious behavior
30
+
31
+ - **Everything after the script is passed through to it, `--summary` excepted.** A site that takes its own arguments (`kiss-ssg check menu.js 2026-spring`) is checked exactly the way it is run; `--summary` is read wherever it is written, because "check this and summarise it" is a thought people finish at the end of the line. That leaves one word reserved, so a bare `--` ends the options and hands the rest through verbatim (`kiss-ssg check menu.js -- --summary`) for the site that genuinely needs it. Before the script an unrecognised flag is a usage error instead: there is no script yet for it to belong to.
32
+ - **No reports at all is a failure, not a pass.** A script that never awaits `complete()` writes no line, and that is the very silence the check exists to break: it exits 0 on a broken site. `exitCodeFor` treats an empty list as `1` and the bin says why on stderr.
33
+ - **The script's own exit code is honoured too.** A site that catches its build failure and exits 1 itself, or one that dies before it ever builds, fails the check whatever the reports say.
34
+ - **A malformed line throws rather than being skipped.** A report nobody can parse is a finding — dropping it silently would turn a corrupt run into "nothing was reported", or worse into a pass.
35
+ - **A check runs `config.assets.pipeline` exactly as a build does**, and that is deliberate rather than an oversight in what check mode discards. A step writes into the site's own **source** tree (`src/assets/css/site.css` — see `AIKB/pipeline.md`), so there is nothing extra for the check to throw away: what it stages and discards is still only the build folder. The consequence worth knowing is that a check is not read-only over the working tree — it regenerates whatever its steps generate, and a failing step fails the check like a failing page, reported as `<pipeline: <name>>`. In `dev: false` (which check mode forces) no `watch` process is ever started, so a check always terminates.
36
+ - **Pages are diffed by output path and compared by hash, not by rendering anything twice.** `BuildPage.hash` is the sha1 the page recorded for the bytes it wrote (`AIKB/kiss-page.md`), so the comparison is over what a browser would receive: a template rewritten to produce identical HTML is genuinely unchanged, and a model edit that changes one word is a change to exactly one page. Nothing is read off the disk here, which is what lets a check — which publishes nothing — still say what it would have changed.
37
+ - **A `null` hash on either side counts as changed, never as unchanged.** A page that failed, one registered with `generate: false`, and one from a report written before hashes existed all carry `null`; "unchanged" is the single answer that would be a lie about any of them, and a diff that under-reports is worse than one that over-reports.
38
+ - **Reports are paired by `buildDir`, and the pairing is deliberately one-sided.** One script may build several sites, and page paths in `./public/a` say nothing about `./public/b`. An `after` report with no partner is a site the old file never saw, so all of it is `added`; a `before` report with no partner is _ignored_, because this run did not build that site at all — which is not the same as having removed its pages, and reporting it as a removal would make a per-season build script look like it deleted every other season.
39
+ - **`readReportsFile` sniffs the shape rather than being told it.** Two of the three shapes are called `.json`, so the extension cannot decide, and being wrong would be a usage error over something the bytes already answer. `JSON.parse` is tried first and JSON Lines is the fallback, not the other way round: a one-line file holding a single report is valid JSON too, and only a multi-line file `JSON.parse` rejects is JSON Lines. A file that is neither still throws, from `readReports`, naming the line — the same rule as before.
40
+ - **The two mechanical note lints print here too, and change nothing else.** `--summary` prints ` note stale: <path>` for a note whose `subject-hash` stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` for a file reference in a note (or in `site.md`) that resolves to nothing — beside the older `note missing:` and `note dead:` lines, all four straight from `formatReport`. Like the other two they are findings: `exitCodeFor` does not read them, so a check full of them still exits 0. What makes them worth printing on a plain `check` is that the report also carries `aikb.subjects`, so the hash a stale note wants restamping with is in the same output, before any record has been made.
41
+ - **The diff never touches the exit code.** `exitCodeFor` does not see it. A build that is `ok` but changed 40 pages is a passing check with a loud description; whether that is what the operator meant is a judgement no exit code can make for them.
42
+ - **The bin reads the `--against` file before it spawns the build**, so a mistyped filename costs nothing and prints as the usage error it is (`kiss-ssg: cannot read --against file <path>`, then `HELP`, exit 1) rather than after a minute of building.
43
+ - **`check` diffs without being asked, and `aikb` does not.** With no `--against`, the bin asks `defaultBaseline(reports)` where the site's own snapshot would be and diffs against it if the file is there — so the ordinary question ("what have I changed since this work opened") is answered by the ordinary command, and the answer is measured from the last _record_, not from the last build anybody happened to run. A check writes nothing, so the file it reads is the one the last record left. `aikb` skips the fallback for the opposite reason: by the time it could read that file it has just overwritten it, and a diff against yourself is `= N unchanged` every time. Naming `--against` explicitly always wins, under either command.
44
+ - **The first report with a folder wins the baseline.** A script that builds several sites writes one report each, but they share a working tree and therefore one knowledge base; `diffReports` pairs the file's contents with this run's reports by `buildDir`, so a single baseline covers all of them.
45
+ - **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
46
+ - **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
47
+ - **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples && node 9-migrated-from-v1.js`), so moving it would check a different set of paths from the ones it builds with.
48
+ - **The temp reports file is removed in a `finally`**, and the bin sets `process.exitCode` rather than calling `process.exit()`, so the cleanup runs before the process leaves.
49
+
50
+ ## Types
51
+
52
+ `types/check.d.ts` is emitted from the JSDoc here like every other `lib/` module, but nothing imports it: the CLI surface is the command, not the module. `CheckArgs` (whose `command` is `'check' | 'aikb' | 'help'`) and `CheckDiff` are documented for the reader of this file.
package/AIKB/config.md CHANGED
@@ -6,15 +6,16 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`).
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`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
9
+ - `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
10
+ - `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
11
11
  - `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
12
12
  - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
+ - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself).
14
15
  - `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed, trailing slashes stripped.
15
- - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch` and `assets` 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` and inside `assets`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on a build folder that contains the source folder (all three below).
16
+ - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on a build folder that contains the source folder (all three below).
16
17
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
17
- - `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.
18
+ - `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.
18
19
 
19
20
  ## Depends on
20
21
 
@@ -35,6 +36,8 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
35
36
  - **An explicitly `undefined` key takes its default; `null` does not.** A spread merge lets `undefined` win over the default, so `new Kiss({ port: process.env.PORT })` with `PORT` unset used to leave `port` undefined and bind a random ephemeral port, and `{ cleanBuild: opts.clean }` with the flag absent silently skipped emptying the build folder (review finding D-07). `resolveConfig`/`resolveFolders` drop undefined-valued keys before merging. `null` is deliberately kept, because it is the documented way to switch a folder off.
36
37
  - **There is no `folders.static`.** v2 removed it for the same reason as `root`: it was in `DEFAULT_FOLDERS`, in `DERIVED_FROM_SRC`, in this doc, in llms.txt and in the README, but no engine module ever read it and `foldersToEnsure` never created it, so a consumer pointing it somewhere got silence (review finding E1). Static files are copied by `lib/assets.js` from `folders.assets`. A `static` key in a user config is now just an unknown key — it passes through `resolveFolders` untouched and nothing reads it.
37
38
  - **There is no `folders.root`.** v2 removed it: it was in `DEFAULT_FOLDERS`, in this doc and in llms.txt, but no engine module ever read it and `foldersToEnsure` excluded it, so a consumer setting it got the default layout with no warning (review finding D-06). A `root` key in a user config is now just an unknown key — `resolveFolders` spreads user folders in, so it passes through untouched and nothing reads it.
39
+ - **`folders.aikb` is source, not output, and is the one folder that is neither derived nor created.** It defaults to `./AIKB` rather than `./src/aikb` because the site's knowledge base is written for people and belongs at the root of the repository beside `README.md`; it is absent from `DERIVED_FROM_SRC` so setting `folders.src` does not drag it under the source tree, and absent from `foldersToEnsure` so a site that has never recorded one never finds an empty `AIKB/` it did not ask for. It sits outside `folders.build`, so `cleanBuild` never touches it and `cleanBuild: 'atomic'` never stages it.
40
+ - **Setting the key is not opting in.** `folders.aikb` only says _where_ a knowledge base would live; what opts a site in is `<folder>/site-map.json` being there, which only `npx kiss-ssg aikb <script>` writes. So the default value is harmless in a repository whose `AIKB/` is something else entirely — this one's holds the module notes you are reading — and a build there reports `aikb: null` rather than adopting them. `null` switches the folder off altogether: nothing is built, nothing is reported, and no error is logged, because a site with no knowledge base is an ordinary site (`AIKB/aikb.md`).
38
41
  - A folder set to `null` in the resolved config is legal: `foldersToEnsure` skips creating it, `registerPartialsFrom`/`copyAssets` skip scanning/copying it (each checks for a falsy folder before globbing).
39
42
  - `foldersToEnsure` fixes a v1 copy-paste bug where most folders were only created conditionally on `assets` being set; here each folder stands on its own regardless of the others.
40
43
  - **The three dev-server keys are defaulted here and nowhere else.** `lib/dev-server.js` takes `port`, `livereloadPort` and `host` as required parameters, so a reader has one place to look for their values. `livereloadPort` exists so two sites can run in dev mode at once (a clash used to kill the process — review finding D-01) and is also the port `lib/kiss-page.js` injects into the dev-mode reload `<script>`. `devHost` defaults to loopback: dev mode used to bind every interface while logging `localhost` (D-13); `'0.0.0.0'` is the documented opt-out for previewing on another device.
@@ -43,6 +46,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
43
46
  - **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.
44
47
  - **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.
45
48
  - **`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.
49
+ - **`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.
46
50
  - **`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.
47
51
 
48
52
  ## Types
@@ -51,6 +55,6 @@ The typedefs that describe the config to a consumer live at the top of this modu
51
55
 
52
56
  - **`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.
53
57
  - **`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.
54
- - **`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`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
58
+ - **`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.
55
59
  - **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.
56
60
  - `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.