kiss-ssg 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AIKB/aikb.md +1 -1
  2. package/AIKB/asset-manifest.md +11 -3
  3. package/AIKB/assets.md +12 -2
  4. package/AIKB/build-report.md +7 -3
  5. package/AIKB/check.md +5 -1
  6. package/AIKB/config.md +10 -5
  7. package/AIKB/design.md +63 -0
  8. package/AIKB/feed.md +3 -1
  9. package/AIKB/handlebars-helpers.md +1 -1
  10. package/AIKB/kiss-page.md +10 -1
  11. package/AIKB/kiss.md +26 -8
  12. package/AIKB/llms.md +3 -1
  13. package/AIKB/output-registry.md +44 -0
  14. package/AIKB/redirects.md +3 -1
  15. package/AIKB/robots.md +2 -0
  16. package/AIKB/sitemap.md +3 -1
  17. package/AIKB/testing.md +4 -2
  18. package/AIKB/upstream.md +8 -0
  19. package/AIKB/utils.md +4 -0
  20. package/AIKB/watcher.md +13 -10
  21. package/CHANGELOG.md +107 -0
  22. package/README.md +23 -6
  23. package/bin/kiss-ssg.js +16 -0
  24. package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
  25. package/examples/11-blog/AIKB/last-build.json +31 -14
  26. package/examples/11-blog/router.js +1 -1
  27. package/examples/9-migrated-from-v1/AIKB/last-build.json +26 -12
  28. package/lib/aikb.js +2 -2
  29. package/lib/asset-manifest.js +59 -6
  30. package/lib/assets.js +97 -15
  31. package/lib/build-report.js +42 -2
  32. package/lib/check.js +80 -1
  33. package/lib/config.js +93 -15
  34. package/lib/feed.js +6 -2
  35. package/lib/handlebars-helpers.js +11 -0
  36. package/lib/kiss-page.js +50 -7
  37. package/lib/kiss.js +326 -82
  38. package/lib/llms.js +6 -1
  39. package/lib/output-registry.js +201 -0
  40. package/lib/redirects.js +3 -1
  41. package/lib/robots.js +3 -0
  42. package/lib/sitemap.js +5 -1
  43. package/lib/utils.js +18 -0
  44. package/lib/watcher.js +104 -71
  45. package/llms.txt +18 -8
  46. package/package.json +1 -1
  47. package/types/aikb.d.ts +1 -1
  48. package/types/asset-manifest.d.ts +15 -1
  49. package/types/assets.d.ts +20 -5
  50. package/types/build-report.d.ts +19 -2
  51. package/types/check.d.ts +35 -1
  52. package/types/config.d.ts +2 -2
  53. package/types/feed.d.ts +3 -1
  54. package/types/kiss-page.d.ts +15 -3
  55. package/types/kiss.d.ts +29 -1
  56. package/types/llms.d.ts +3 -1
  57. package/types/output-registry.d.ts +74 -0
  58. package/types/redirects.d.ts +3 -1
  59. package/types/robots.d.ts +3 -1
  60. package/types/sitemap.d.ts +2 -1
  61. package/types/utils.d.ts +1 -0
  62. package/types/watcher.d.ts +0 -1
package/AIKB/aikb.md CHANGED
@@ -60,7 +60,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
60
60
  - **The dangling filter errs, at every step, towards saying nothing.** A backticked token is a candidate only if it has a `/` in it or ends in one of eleven known extensions, and only if it holds none of ` <>*{}$` — so `{ name, origin }`, `<title>`, `npx kiss-ssg check` and `p-<8 hex>` are all prose, not paths. Fenced code blocks are dropped whole (a snippet quotes code rather than citing a file), a trailing `/` is a folder, and anything with a URI scheme (`https:`, and also the map's own `file:`/`url:` classifications) is skipped. The reason is not politeness: a lint that fires on every note is a lint people learn to scroll past, and then it is worth less than nothing.
61
61
  - **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`. **A leading slash is stripped before both lookups**, because a note written the way the deployed site reads cites `/docs` for the folder the config calls `./docs` — and `posixPath` strips a leading `./` but not a leading `/`, so one character was the whole difference between resolving and being reported dangling. Measured on one record before the fix: `/docs`, `/src`, `/src/assets` and `/src/pages` were all reported dangling while `./docs`, `docs`, `docs/`, `src`, `src/assets` and `src/pages` all resolved — four findings, no real problems, every one of them on a folder the site is configured with. The suffix fallback could not have caught it: it looks for a candidate ending `//docs`. Found by a downstream site conversion, not by the suite.
62
62
  - **`site.md` is scanned but never judged.** It is authored, evergreen and about the whole site rather than any one subject, so it has no subject to be missing, dead or stale about — but its file references rot exactly as a note's do, so it is read for `dangling` when it is there. The engine neither writes it nor enforces its shape; the generated `README.md` names it and its five sections, and `kiss-memory-consolidate` maintains it.
63
- - **A subject hash is over bytes, so a checkout that rewrites line endings restamps every note.** The controller's hash is sha1 of the file as it is on disk; a CRLF checkout of an LF repository hashes differently, and every stamped controller note reads `stale` until it is restamped. The alternative — normalising the bytes before hashing — would make the hash disagree with `git`'s idea of whether the file changed, which is the thing the stamp is trying to track.
63
+ - **A subject hash is over LF-normalised bytes, so a checkout that rewrites line endings does NOT restamp every note.** `subjectHash` (`lib/aikb.js:320`) replaces CRLF with LF before hashing: a CRLF checkout of an LF repository is the same controller, and git agrees, so a Windows clone reads the same stamps as the Linux clone that wrote them. The first version of this hashed the raw bytes and said normalising would make the hash disagree with git — measured the other way round: git normalises too, and the raw hash was what disagreed, restamping every controller note on every Windows clone. The generated `AIKB/README.md` and `llms.txt` say "LF-normalised" for the same reason.
64
64
  - **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
65
65
  - **A note's filename is derived from the subject's id, so two subjects can collide.** `a-x.js` and `a/x.js` both slug to `a-x`. Rare, and the alternative — a hash in the filename — would make the rule impossible to apply by hand, which is the property that matters more.
66
66
  - **A URL model's note is named for the endpoint, not the request.** Host plus path, query and fragment dropped: the query is where the page number and the API key live, and a note per page of results is a note nobody writes.
@@ -10,11 +10,15 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
10
10
  - `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
11
11
  - `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
12
12
  - `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
13
- - `createAssetManifest()` → `{ record, lookup, toObject }`, a fresh manifest with its own `Map`.
14
- - `record(urlPath, emittedPath)` — stores the mapping and returns the **previous** `emittedPath` when it differs from the new one (the caller's cue to delete the file it just replaced), otherwise `null`.
13
+ - `createAssetManifest()` → `{ lookup, toObject, hasOwner, previous, reconcile, reconcileSass, urlRevision }`, a fresh manifest.
15
14
  - `lookup(urlPath)` — the emitted path, or `null`.
16
15
  - `toObject()` — a plain object copy of the map, for debugging.
17
16
 
17
+ - `hasOwner(owner)` — whether a source/target copy has an inventory.
18
+ - `reconcile(owner, current)` — replaces that copy's map of logical names to emitted filenames, removes vanished mappings, and returns old filenames no copy still owns. Another copy's mappings survive.
19
+
20
+ - `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
21
+
18
22
  ## Depends on
19
23
 
20
24
  `node:crypto`.
@@ -27,6 +31,10 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
27
31
 
28
32
  - **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
29
33
  - **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
30
- - **`record` returning the old name is the whole stale-file mechanism.** Nothing else knows which file a rename replaced: `Kiss._replay()`'s orphan sweep only ever considers page outputs (`_stack` `buildTo` paths), so a hashed asset is invisible to it. `lib/assets.js` deletes what `record` hands back.
34
+ - **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
31
35
  - **A manifest is per `Kiss` instance**, like the Handlebars environment, so two sites in one process cannot link each other's files. Nothing here is module-level state.
32
36
  - MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
37
+
38
+ - `reconcileSass(owner, fingerprints)` retains the latest successful, unshadowed Sass fingerprints per copy and returns the changed source names. It does not change URL mappings or their revision; replacing the map also retires removed entries.
39
+
40
+ - `previous(owner, name)` returns that copy's last-good output or null. Failed Sass compiles may carry it into reconciliation; removed sources do not. This does not read another copy's global mapping.
package/AIKB/assets.md CHANGED
@@ -19,6 +19,11 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
19
19
 
20
20
  ## Non-obvious behavior
21
21
 
22
+ - Sass results include `changed`, comparing retained fingerprints of compiled bytes across copies. The fingerprint is computed in memory, without reading the previous CSS file, so hashing may move that file without making an identical compile look changed. A copied CSS sibling wins and is excluded from Sass changes. The watcher swaps one changed stylesheet in place; multiple affected stylesheets use a full refresh; an unchanged Sass save sends no refresh. Failed or removed entries leave the next successful copy to establish a fresh fingerprint.
23
+ - A generated owner refusing a Sass write returns `refused: true`, distinct from `skipped: true` for underscore partials. The copy returns `refused` source paths for diagnostics; restoration uses registry producer identities. Refusals appear in advisory `outputs.collisions`, not the failure list.
24
+
25
+ - Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; a missing first-time source remains an error. Other copies retain their claims. Kiss passes the shared output registry: asset copies yield to generated outputs, warn on collisions, and unlink a stale file only while that copy still owns it. Copy identity uses the requested destination across atomic promotion.
26
+
22
27
  - `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
23
28
  - **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
24
29
  - Sass sources (`.scss`/`.sass`) are explicitly excluded from the plain `fs.copy` pass — they're only ever emitted as compiled `.css`, never copied verbatim.
@@ -29,8 +34,8 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
29
34
  - **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
30
35
  - **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
31
36
  - **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
32
- - **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
33
- - **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded. That is about the MANIFEST only: a broken stylesheet is not an emitted file, so nothing claims it. It is **not** a statement about the build's verdict — a Sass failure fails the build, as the bullet below says. This sentence used to end by calling the copy successful, which was the pre-R8 behaviour left standing beside its replacement; a reader scanning for "does a broken stylesheet fail my build" could land on it and get the retired answer. That wording is now banned by `test/unit/skill-coverage.test.js`'s `CONTRADICTIONS` table, which is also why this note paraphrases it rather than quoting it.
37
+ - **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
38
+ - **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
34
39
  - `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
35
40
  - **The files a copy picks up may have been written moments earlier by `config.assets.pipeline`.** This module knows nothing about that: the steps are chained onto the same `_assetQueue` this module's calls are serialised by, so by the time `copyAssets` walks the source tree the tools have finished writing into it. See `AIKB/pipeline.md` and `AIKB/kiss.md`.
36
41
  - `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
@@ -40,3 +45,8 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
40
45
  - **The skip is reported, because skipping silently was the third quiet regression on this branch.** `_vendor.scss` — self-contained, imported by nothing — used to emit `_vendor.css` and then emitted nothing at all, with zero mentions anywhere in the log: a site serving that file began serving a 404 after a clean build and nothing said why. One `info` line names the whole set (one line, not one per file, so a dozen partials do not become a dozen lines every build), and `copyAssets` reports them to its caller as `{ file, skipped: true }`. **`isSassPartial` is shared by the compile pass and the claim pass** so the two cannot disagree about what was emitted — they did, and the claim pass then warned that a legitimately served `_theme.css` had overwritten a compile that never ran, advising the author to delete a file another entry point correctly consumes.
41
46
  - **A Sass compile error is a build failure, not a red line in the log.** `compileSassFiles` resolves to one `{ file, error? }` per stylesheet it tried — every file, not only the failures, so a caller can clear the entry for one that has since started compiling — and `copyAssets` passes them back as `sass`. `Kiss.copyAssets` turns each error into a `<sass: <file>>` entry on `_failures`, the same shape as `<pipeline: name>`. It used to be logged and dropped: `complete()` resolved, `report().ok` stayed true, `kiss-ssg check` said `ok`, and the site shipped with **no stylesheet** — while `kiss-build-check` tells an agent that `ok: true` and exit 0 are the only passing result, which makes that verdict load-bearing. Found by a clean-room conversion that published exactly that site. One broken stylesheet still does not stop the others or the copy, the same rule a failing page follows; and because an asset re-copy is not a `_replay()`, each copy clears the entries for the files it is about to re-try, or a fixed stylesheet would stay failed for the rest of a watch session.
42
47
  - **A copied `.css` beats a Sass compile, deliberately, and `recordEmitted` now says so.** `copyAssets` compiles Sass and then `fs.copy`s over the top, so when `site.scss` and `site.css` both emit `css/site.css` the copied file is served and the Sass is discarded. **That precedence is the design, not an accident** — it dates from this module's first commit (`ca49564`, 2026-09-02) and is what makes `config.assets.pipeline` work: a step's `run` writes into the assets folder _before_ the copy, so a Tailwind or PostCSS stylesheet lands as `src/assets/css/site.css` and must beat kiss's built-in Sass. Inverting it would have kiss silently clobbering the toolchain the site explicitly configured. What was missing was not the rule but any sign of it: the pages are correct and only the asset is wrong, so page hashes structurally cannot catch it and `kiss-ssg check` reports a clean build. It reached us from a real conversion where a project migrating off a pipeline that committed its compiled CSS had `layout.scss`/`layout.css` pairs, deleted the `.css` siblings for tidiness, and compared the compiled bytes only afterwards. The detection is a `Map` of emitted name → sources built in a pass of its own, before anything is renamed: no manifest shape change, because the manifest already records Sass output keyed by its compiled name (a collision is two writes to one key). **The separate pass is what makes it work under `assets.hash`.** It first lived inside the emit loop, which killed it in exactly the configuration a production site builds with: hashing `fs.move`s the first source's file to its hashed name, so the second source's `fs.stat` of the plain path found nothing and `continue`d before any comparison. Hash off warned, hash on was silent — and a silently dead stylesheet is a production problem, not a development one. Claims are gathered first, the hash/move and the manifest write happen second, once per emitted name; `test/unit/assets.test.js` now pins both settings. **The direction of the message is load-bearing and is not glob order**: `globFiles` sorts, so `site.css` is seen first, but the write order is compile-then-copy, so the warning keys off `isSass(source)` — the first version keyed off iteration order, named the pair backwards, and would have sent the author to edit the file that was already winning. `test/unit/assets.test.js` pins the exact wording, the "wins by design" phrasing and the served bytes together. **The line leads with the consequence** ("edits to `css/site.scss` are not reaching the site") and puts the compile/copy mechanism second, per the documentation rule this branch adopted. **It is deliberately not suppressed when `config.assets.pipeline` is set**, which was proposed on the reasoning that a pipeline site would see it on every build. Measured, it does not: the warning needs two sources at one emitted name, and a Tailwind-shaped pipeline writes `site.css` with no `site.scss` to collide with, so nothing fires at all. What a pipeline site _can_ have is a leftover `site.scss` beside the generated file — dead, silent, and precisely what this exists to catch. Suppressing on `pipeline` would blind the one case it is for.
48
+
49
+ - The registry reconciles all attempted file outputs, including refused writes, separately from the manifest of emitted files. Deleting a refused source therefore clears its collision even when it never reached the manifest. Directories are not output producers.
50
+
51
+ - Source-stat failures during inventory discovery are skipped, including dangling links and files disappearing between glob and stat. Healthy files still reconcile. The source-stat ENOENT transition was tested on Windows; creating a real file symlink was denied by the host, so actual dangling-link behavior was not executed here.
52
+ - `assetCopyOwner(source, target)` derives the shared owner identity. Kiss supplies the requested destination before staging; direct module callers use their target. Watch copies use the original registration paths, including the build-root anchor used for manifest names.
@@ -11,7 +11,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
11
11
  - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
12
  - `buildDir` — the folder the site asked for, never the staging sibling.
13
13
  - `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
14
- - `pages` — `{ view, buildTo, ok, hash, id }` 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`).
14
+ - `pages` — `{ view, buildTo, ok, hash, id, canonical }` per stack entry, in registration order; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
15
15
  - `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
16
16
  - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
17
  - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
@@ -21,7 +21,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
21
21
  - `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, broken: [{ page, href }] }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder.
22
22
  - `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved, rules, json, format, files }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`. The last four keys are the format block: `rules` is the resolved `[{ from, to }]` — the host-neutral fact, handed over so a site whose host kiss does not encode can write its own redirects from `kiss.report()` rather than by parsing a file kiss wrote — `json` is the `redirects.json` IR (mapped), `formats` is the list of names that ran, in order (`'custom'` per writer function, `[]` for IR-only), and `files` is every redirects file written (mapped). `file` is the **first** host file in `formats` order, and `null` when no host format ran at all — the honest answer rather than a claim about a redirect no host serves. With more than one format it names one of several, so `files` is the authoritative list; that is the one place `file` is a lead rather than the whole answer, and it is why `files` exists.
23
23
  - `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
24
- - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the four link/redirect findings: ` broken link: <page> -> <href>`, ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. What `kiss-ssg check --summary` prints instead of the JSON.
24
+ - `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the four link/redirect findings: ` broken link: <page> -> <href>`, ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. What `kiss-ssg check --summary` prints instead of the JSON.
25
25
  - `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
26
26
 
27
27
  ## Depends on
@@ -48,10 +48,14 @@ Nothing.
48
48
  - **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
49
49
  - **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. `lib/robots.js` has the rest.
50
50
 
51
- - **The last four keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
51
+ - **The four derived-output keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
52
52
  - **`redirects.file`, `redirects.json`, every `redirects.files[]`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved`/`rules` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map. `rules` is URL paths on both sides for the same reason.
53
53
  - **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
54
54
 
55
55
  ## Types
56
56
 
57
57
  `BuildReport`, `BuildPage`, `BuildAsset`, `BuildReportFailure`, `BuildPipelineStep`, `BuildAikb`, `BuildLinks`, `BuildBrokenLink` and `BuildRedirects` are declared here and re-exported from `lib/kiss.js` (`@typedef {import('./build-report.js').BuildReport} BuildReport`), the same way `KissConfig` is re-exported from `lib/config.js` — so a consumer reaches every one of them as `import('kiss-ssg').BuildReport`.
58
+
59
+ - `outputs` is appended after `robots`, always `{ collisions: [] }` when none were observed. Each collision has `file`, `producers` (`{ owner, kind }`), `winner` (claim or null) and `refused` (owner strings). Both resolved staging paths and resolved build paths are mapped to the configured build-folder spelling. Collisions track active producers; resolved conflicts disappear on reconciliation and standing conflicts survive replay; they are advisory and never change `ok` or the check exit code. `formatReport` includes an `output collision:` line with producers, winner and refused owners.
60
+
61
+ - The shared `real()` projection handles configured and resolved staging prefixes and the resolved build prefix for every reported file, including output collisions. Registry identities remain absolute.
package/AIKB/check.md CHANGED
@@ -18,9 +18,12 @@ Two commands share all of it. `check <script>` runs the site's build staged and
18
18
  - `recordedLine(report)` → the line `aikb --summary` prints under a site: ` recorded <folder>`, ` not recorded — build failed`, ` not recorded — folders.aikb is null`, or ` not recorded`.
19
19
  - `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
20
20
 
21
+ - `describeEngine({ cwd, from = cwd })` → `{ version, dir, linked, target } | null` — the nearest `node_modules/kiss-ssg` walking up from `from`, which the bin sets to the **script's** folder because that is where Node resolves its imports (a caller standing elsewhere would otherwise be told about its own copy — Codex, on review), with `dir` relative to `cwd`: its `package.json` version, its path relative to `cwd`, whether `lstat` says it is a symbolic link (a Windows junction counts), and the link's real target when it is. `null` when there is none above `cwd`.
22
+ - `engineLine(engine)` → the line the bin prints on stderr before the build, in both output modes: `kiss-ssg 2.5.0 from node_modules/kiss-ssg`, or `… — a link to C:/Code/kiss-ssg, not the registry package` for a target outside any `node_modules` (a working tree), or the neutral `… — a link into <target>` for one inside (pnpm lays every package out as a link into `node_modules/.pnpm/…`, and that is the registry package — Codex, on the close's review), or the honest `kiss-ssg: no node_modules/kiss-ssg found from here — the script resolves the package some other way`.
23
+
21
24
  ## Depends on
22
25
 
23
- Nothing (`readReports`' return type refers to `./build-report.js`, but only as a type).
26
+ `node:fs` and `node:path`, for `describeEngine` alone — it `lstat`s `node_modules/kiss-ssg` and reads its `package.json`, walking up from the script's folder. Everything else here is pure. (`readReports`' return type refers to `./build-report.js`, but only as a type.)
24
27
 
25
28
  ## Depended on by
26
29
 
@@ -35,6 +38,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
35
38
  - **A check runs `config.assets.pipeline` exactly as a build does**, and that is deliberate rather than an oversight in what check mode discards. A step writes into the site's own **source** tree (`src/assets/css/site.css` — see `AIKB/pipeline.md`), so there is nothing extra for the check to throw away: what it stages and discards is still only the build folder. The consequence worth knowing is that a check is not read-only over the working tree — it regenerates whatever its steps generate, and a failing step fails the check like a failing page, reported as `<pipeline: <name>>`. In `dev: false` (which check mode forces) no `watch` process is ever started, so a check always terminates.
36
39
  - **Pages are diffed by output path and compared by hash, not by rendering anything twice.** `BuildPage.hash` is the sha1 the page recorded for the bytes it wrote (`AIKB/kiss-page.md`), so the comparison is over what a browser would receive: a template rewritten to produce identical HTML is genuinely unchanged, and a model edit that changes one word is a change to exactly one page. Nothing is read off the disk here, which is what lets a check — which publishes nothing — still say what it would have changed.
37
40
  - **A `null` hash on either side counts as changed, never as unchanged.** A page that failed, one registered with `generate: false`, and one from a report written before hashes existed all carry `null`; "unchanged" is the single answer that would be a lie about any of them, and a diff that under-reports is worse than one that over-reports.
41
+ - **The engine line goes to stderr, before the build, in both output modes.** It is a fact about the environment rather than part of the report, so stdout stays the report — the JSON consumer's contract — while a person still sees it beside the build log. Why it exists: the 2.5.0 fleet upgrade hit the same trap on six of six sites — after `file:../../kiss-ssg` was edited to `^2.5.0`, a plain `npm install` kept the junction because the lockfile's `"link": true` entry already satisfied the range, so every site reported a registry version while building against the working tree, and only a hand check of `node_modules/kiss-ssg` said otherwise. `check` is the one command every upgrade runs, so it says which engine it found and whether that folder is a link. `test/integration/check.test.js` pins both wordings end to end, the linked one through a real junction.
38
42
  - **Reports are paired by `buildDir`, and the pairing is deliberately one-sided.** One script may build several sites, and page paths in `./public/a` say nothing about `./public/b`. An `after` report with no partner is a site the old file never saw, so all of it is `added`; a `before` report with no partner is _ignored_, because this run did not build that site at all — which is not the same as having removed its pages, and reporting it as a removal would make a per-season build script look like it deleted every other season.
39
43
  - **`readReportsFile` sniffs the shape rather than being told it.** Two of the three shapes are called `.json`, so the extension cannot decide, and being wrong would be a usage error over something the bytes already answer. `JSON.parse` is tried first and JSON Lines is the fallback, not the other way round: a one-line file holding a single report is valid JSON too, and only a multi-line file `JSON.parse` rejects is JSON Lines. A file that is neither still throws, from `readReports`, naming the line — the same rule as before.
40
44
  - **The two mechanical note lints print here too, and change nothing else.** `--summary` prints ` note stale: <path>` for a note whose `subject-hash` stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` for a file reference in a note (or in `site.md`) that resolves to nothing — beside the older `note missing:` and `note dead:` lines, all four straight from `formatReport`. Like the other two they are findings: `exitCodeFor` does not read them, so a check full of them still exits 0. What makes them worth printing on a plain `check` is that the report also carries `aikb.subjects`, so the hash a stale note wants restamping with is in the same output, before any record has been made.
package/AIKB/config.md CHANGED
@@ -13,14 +13,14 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
14
  - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
15
15
  - `DEFAULT_REDIRECTS` — frozen object, the redirect block: `format: null`, meaning no host encoding — the IR alone. It also takes a **list**, so `['netlify', 'firebase']` emits both host files. `REDIRECT_FORMATS` is the frozen list of accepted names (`'netlify'`, `'firebase'`, `'vercel'`, `'none'`); a function is also accepted, as a custom writer (`AIKB/redirects.md`).
16
- - `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed, trailing slashes stripped.
17
- - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on a build folder that contains the source folder (all three below).
16
+ - `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
17
+ - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
18
18
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
19
19
  - `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
20
20
 
21
21
  ## Depends on
22
22
 
23
- `node:path` (resolving the build/source folders for the safety guard below) — nothing else.
23
+ `node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names).
24
24
 
25
25
  ## Depended on by
26
26
 
@@ -30,7 +30,9 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
30
30
 
31
31
  - **`cleanBuild` has three values, and the two that clean do it at different moments.** `true` (the default) calls `fs.emptyDirSync(folders.build)` in `_setupFolders`, i.e. **in the `Kiss` constructor** — before any model resolves and before any page renders — and nothing rolls it back if the build then fails. So a re-run of a build folder that holds published output destroys it first and, on a failure, leaves it empty or half-built (consumer review finding B-3). `false` never cleans. `'atomic'` builds into a staging sibling and swaps it in only when `complete()` resolves; the old output is untouched until that moment and survives a failed build. See `AIKB/kiss.md` for the staging mechanics — this module only carries the value.
32
32
  - **The value is validated, not coerced.** Anything but `true`, `false` or `'atomic'` throws out of `resolveConfig` (so out of `new Kiss`), including `null` and `0`. A silently-falsy value is exactly the D-07 failure mode in a new coat: `{ cleanBuild: opts.clean }` with an unrecognised flag would quietly stop cleaning, or quietly stop staging.
33
- - **A build folder that contains the source folder is refused at construction**, whatever `cleanBuild` says (`false` today is one edit from `true`): `resolveConfig` throws when the resolved `folders.build` is the resolved `folders.src`, is an ancestor of it, or is a filesystem root — which is what catches `build: '.'`, `'./'` and `'/'` on a default layout. It is a **floor, not a fix**: a build folder that holds published output but is not above the source — metacarpus's `./handbooks` archive root, wiped whole by an empty cohort name (finding B-2) — is _not_ caught, because nothing in the config distinguishes it from any other legitimate build folder. That case is answered by validating the name in the consumer and by `cleanBuild: 'atomic'`.
33
+ - **Source/output safety is checked before any filesystem mutation**, for every `cleanBuild` value. `folders.src` cannot resolve to the working project root or a filesystem root. Neither can `folders.build`, regardless of whether `src` is disabled or elsewhere. The build must not equal or contain any configured source folder (`src`, its six derived folders, `helpers`, `aikb`), nor be nested inside an individual content folder. Only `src` allows output below it: `src/public` stays valid if separate from every content folder. Null folders are excluded.
34
+ - **Compare both lexical and real locations.** `realFolder` resolves the nearest existing ancestor and appends missing descendants, so `alias/future` is checked before `future` exists. `folderLocations` keeps the lexical spelling too: deleting a source link itself is destructive even if its target is elsewhere. `path.relative` supplies segment boundaries and Windows case semantics. Inspection errors and dangling links fail closed with the setting named in the error; failures inspecting the working directory name that directory rather than blaming `folders.src`. None of these helpers writes to disk.
35
+ - **Configured folder protection is not arbitrary-file protection.** An archive root containing earlier output but no configured source is still a legal destination. Validate variable output names in the consumer and use atomic builds when old output must survive a failed build. Custom pipeline commands, extra copy destinations, and filesystem changes after validation are not governed by this constructor check. Unit tests cover root rules and valid boundaries; integration tests cover every protected folder in all cleanup modes, aliases and missing descendants, asserting source sentinels and directory structure survive rejection.
34
36
 
35
37
  - **Folder strings are normalised once, here, because everything downstream is path arithmetic.** A trailing slash on `folders.assets` made `assets.js`'s target derivation miss (compiled CSS landed beside the build folder as `./publicmain.css`, logged as a success), and one on `folders.models` made `model-resolver.js` chop the first characters off every filename so a whole `.pages()` fan-out resolved to `[]`. Both are the same missing normalisation. `./` and `/` keep their slash — trimming them to `.` or `''` would change which directory they name — and a non-string value (`null`) is passed through untouched.
36
38
  - Setting `config.folders.src` re-derives the six `DERIVED_FROM_SRC` subfolders from it, _unless_ each is also individually overridden in the same `userFolders` object (the final spread `{ ...folders, ...supplied }` lets explicit keys win over the src-derived ones).
@@ -54,7 +56,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
54
56
 
55
57
  The typedefs that describe the config to a consumer live at the top of this module, beside the defaults they describe: `KissFolders` / `KissFoldersInput`, `KissFetch`, `KissAssets` (whose `pipeline` refers to `lib/pipeline.js`'s `PipelineStep`, so the shape a site writes and the shape that is run cannot drift apart), `KissSettings`, and the two the public API is written in terms of — `KissConfig` (resolved: every key present) and `KissConfigInput` (what `new Kiss(config)` takes: every key optional). They are one JSDoc block, not a module of their own, so the shape and the resolver cannot drift apart.
56
58
 
57
- - **`format: null` is the default and is not the same as `'none'`, though they emit the same files.** `null` means "never chose", and a build with aliases under it gets one notice naming the fix — because every version before this one always wrote `_redirects`, and losing a host's redirects in silence on upgrade is the failure the block exists to abolish. `'none'` and `[]` mean "chose the IR alone" and say nothing. The distinction lives in the config rather than in a hidden flag precisely so a site can express it.
59
+ - **`format: null` is the default and is not the same as `'none'`, though they emit the same files.** `null` means "never chose", and a build with aliases under it gets one warning naming the fix — because every version before this one always wrote `_redirects`, and losing a host's redirects in silence on upgrade is the failure the block exists to abolish. `'none'` and `[]` mean "chose the IR alone" and say nothing. The distinction lives in the config rather than in a hidden flag precisely so a site can express it.
58
60
 
59
61
  - **`redirects.format` is validated, `markdown` is not, and the difference is the failure mode.** An unknown Remarkable option is a legitimate renderer option kiss has not heard of; an unknown redirect format is a typo that would write no host file, report success, and leave every old URL 404ing. `assertRedirectFormatIsValid` therefore refuses it with the offending value in the message, the way `cleanBuild` is refused — checking **every entry of a list**, so one typo in an array of four is named rather than silently dropped — while still carrying unknown _sibling_ keys through the one-level merge, so a later `statusCode` is an addition rather than a breaking change.
60
62
 
@@ -68,3 +70,6 @@ The typedefs that describe the config to a consumer live at the top of this modu
68
70
  - `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
69
71
 
70
72
  - **`folders.helpers` is neither derived from `src` nor ensured.** It defaults to `./helpers`, beside the build script, for the same reason `aikb` does: it is code the site author writes, not content derived from `src`. It is absent from `foldersToEnsure` because a site with no custom helpers should not acquire an empty folder on start-up — and its absence is the ordinary case, which `lib/site-helpers.js` handles silently rather than warning about. Because it is a _default_, `Kiss` also records whether the site supplied the key at all (before `resolveConfig` erases the difference): a folder kiss guessed that turns out to hold no registrar is a warning and the build continues, where one the author named is a build failure. See `AIKB/site-helpers.md`.
73
+
74
+ - Folder containment uses the shared `isInside` predicate from `utils.js`, the same whole-segment and platform-case comparison as watcher exclusions.
75
+ - Alias checks retain intermediate spellings obtained by resolving each parent prefix, as well as the lexical path and final target. An aliased project/temp parent must not hide a source junction inside output just because that junction ultimately targets a separate content folder. The nested-junction integration regression was observed failing before this correction; ordinary paths whose real and lexical locations agree avoid the extra prefix walk.
package/AIKB/design.md CHANGED
@@ -51,6 +51,69 @@ produce, and every defect a real conversion found in 2.4.0 was an instance of it
51
51
  overwritten, a dev build written into the published folder, a watch rebuild that reported success and
52
52
  served stale output, an AIKB lint noisy about something correct (the same failure inverted).
53
53
 
54
+ ## Prior art: the same three problems in other generators
55
+
56
+ Recorded 2026-09-23, after six review rounds on `codex/protect-source-folders` had all landed on the same
57
+ three problems: two producers writing one output path, stale output after a rebuild, and a live-reload
58
+ message with no recipient. Every static site generator meets them. Checked against Jekyll, Eleventy, Hugo
59
+ and Astro (whose dev server is Vite), from their current docs and issue trackers; the sources are listed at
60
+ the end of this section so the check can be repeated rather than re-derived.
61
+
62
+ **Two producers, one path.** Everyone warns or fails; nobody restores the loser afterwards.
63
+
64
+ | Generator | Behaviour |
65
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | Jekyll | warns `Conflict: The following destination is shared by multiple files`, extended to static files in 2020; the build continues |
67
+ | Eleventy | throws `DuplicatePermalinkOutputError`; the build fails (an open issue asks for a config key to soften it) |
68
+ | Astro | `prerenderConflictBehavior`: `warn` (default), `error` or `ignore`; the highest-priority route wins |
69
+ | Hugo | silent overwrite unless `--printPathWarnings` is passed |
70
+ | kiss | warns, names both producers, and puts the collision on the report as `outputs.collisions`; advisory, no config key (`AIKB/output-registry.md`) |
71
+
72
+ kiss's is Jekyll's shape plus the report. Astro's configurable warn-or-error is the nicest version and is a
73
+ one-key addition if a site ever wants a collision to fail the build.
74
+
75
+ **Stale output after a rebuild.** The others avoid the problem rather than solve it.
76
+
77
+ - Jekyll wipes the destination on every build ("files or folders that are not created by your site will be
78
+ removed"), with `keep_files` for what other tools put there. Its incremental mode is still labelled
79
+ experimental and "may break site generation".
80
+ - Eleventy never cleans: a deleted source stays in `_site` and the docs say delete the folder. A community
81
+ plugin keeps an inventory of "files previously created but no longer", which is what kiss's asset
82
+ reconcile does.
83
+ - Hugo's dev server can render to memory (`--renderToMemory`), so there is no stale file on disk to clean;
84
+ production has `--cleanDestinationDir`.
85
+
86
+ kiss does three different things, and the split is the point: a one-shot `cleanBuild: true` empties the
87
+ folder in the constructor (Jekyll's wipe), `'atomic'` stages and swaps (Hugo's "nothing stale can exist"
88
+ without the memory), and `false` never cleans (Eleventy's default). A **watch replay** is the exception: it
89
+ does not wipe, it sweeps by ownership — the output registry for pages and generated files, the manifest
90
+ inventory for assets. That is the hard road, and it is where the six rounds went: every defect was a
91
+ transition the ledger got wrong (a Sass error deleting the last-good stylesheet, a rename recorded as a
92
+ collision, an asset shadowed by a deleted page never restored). It was chosen so a replay re-copies nothing
93
+ it does not have to. **The fallback, if the lifecycle keeps costing more than it saves:** a replay already
94
+ re-renders every page, so a replay that stages and swaps, exactly as one-shot `'atomic'` does, would delete
95
+ the sweep, the page side of the registry and the restore logic, at the price of re-copying assets per
96
+ replay (Hugo's `--forceSyncStatic` is that trade, named). Not adopted; recorded so the next person weighing
97
+ it starts from here.
98
+
99
+ **A reload with no recipient.** livereload-js reconnects with a one-second backoff and replays nothing, so a
100
+ refresh broadcast in the ~200 ms between a reload's DOM-ready and the new socket is lost (measured; see
101
+ `AIKB/upstream.md`). Vite's client reloads the page whenever its socket reconnects, which covers the lost
102
+ message at the price of the opposite complaint, needless reloads. Eleventy's dev server has its own
103
+ WebSocket server and updates the DOM by diffing, so the page rarely reloads and the window rarely opens. kiss
104
+ keeps the documented window: the client is livereload-js's, not ours, and a human saving files cannot hit
105
+ it.
106
+
107
+ **The watcher.** Chokidar is the Node norm. Eleventy uses it outright; Vite still uses chokidar 3.6 for files
108
+ outside its module graph, with a WSL2 caveat that needs polling. kiss is on chokidar 5.0.0, the latest, and
109
+ none of the three problems above is the watcher's.
110
+
111
+ Sources, as checked on 2026-09-23: Jekyll PR jekyll/jekyll#8459 and `docs/configuration/options` and
112
+ `docs/configuration/incremental-regeneration`; Eleventy issue 11ty/eleventy#3001, discussion #2294,
113
+ `docs/dev-server`, and `kentaroi/eleventy-plugin-clean`; Hugo `commands/hugo_server` and `commands/hugo`;
114
+ Astro `reference/configuration-reference` (`prerenderConflictBehavior`); Vite `config/server-options`
115
+ (`server.watch`) and issue vitejs/vite#5675; the livereload-js README.
116
+
54
117
  ## The documentation rule
55
118
 
56
119
  **Any behaviour whose consequence is a wrong artefact rather than an error states that consequence in the
package/AIKB/feed.md CHANGED
@@ -8,7 +8,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
8
8
 
9
9
  - `feedFileName(options = {})` → `options.filename` (whitespace-collapsed), else `'feed.xml'`. The one place the default lives, so `Kiss.feed()` and `writeFeed` cannot disagree about where the file went.
10
10
  - `toDate(value)` → a `Date`, or `null`. Accepts a `Date` (copied, never aliased), a finite number read as epoch milliseconds, or a non-blank string `new Date()` parses. Anything else — a boolean, an object, `'last Thursday-ish'`, `Infinity`, an `Invalid Date` — is `null`.
11
- - `buildFeedItems(stack, { siteUrl, buildDir, section, dateField = 'date', limit = 20, logger })` → `{ items, undated }`. One `{ title, url, description, date }` per page that survives every filter and carries a readable date; `undated` counts the pages that survived the filters but had no usable one. Skips a page whose `page.options` has `ignoreFeed`, `ignoreSitemap` or `generate === false`, and — when `section` is given — one whose first `path` segment is not that section (both sides through `topSegment`, so `'/Blog/'` matches `blog/`). `url` is `toAbsoluteUrl(siteUrl, toCanonicalPath(buildTo.slice(buildDir.length)))` — the identical join `buildSitemapEntries` and `buildLlmsEntries` make. `title` and `description` come from `llms.js`'s own `entryTitle`/`oneLine`. Sorted newest first with the `url` as the tie-break, then truncated to `limit` (a non-finite or negative `limit` means no cap; `0` means no items).
11
+ - `buildFeedItems(stack, { siteUrl, buildDir, section, dateField = 'date', limit = 20, logger })` → `{ items, undated }`. One `{ title, url, description, date }` per page that survives every filter and carries a readable date; `undated` counts the pages that survived the filters but had no usable one. Skips a page whose `page.options` has `ignoreFeed`, `ignoreSitemap`, `canonical` (its real home is elsewhere — the sitemap and `llms.txt` exclude it for the same reason) or `generate === false`, and — when `section` is given — one whose first `path` segment is not that section (both sides through `topSegment`, so `'/Blog/'` matches `blog/`). `url` is `toAbsoluteUrl(siteUrl, toCanonicalPath(buildTo.slice(buildDir.length)))` — the identical join `buildSitemapEntries` and `buildLlmsEntries` make. `title` and `description` come from `llms.js`'s own `entryTitle`/`oneLine`. Sorted newest first with the `url` as the tie-break, then truncated to `limit` (a non-finite or negative `limit` means no cap; `0` means no items).
12
12
  - `renderRss(channel, items)` → the document's text, one trailing newline. `channel` is `{ title, link, description, feedUrl }`. Emits `<rss version="2.0" xmlns:atom="…">`, a `<channel>` with `title`/`link`/`description`/`atom:link rel="self"`, `<lastBuildDate>` when there is at least one item, then one `<item>` per entry with `title`, `link`, `guid isPermaLink="true"`, `pubDate` (`toUTCString()`) and `description` (omitted when empty). Every text node and attribute value is XML-escaped.
13
13
  - `async writeFeed(stack, { config, logger, options, overwrite = true })` → `Promise<{ status, text }>`. `status` is `'no-site-url'` or `'no-title'` (nothing written), `'skipped'` (`overwrite: false` and a file exists), or `'written'`.
14
14
 
@@ -22,6 +22,8 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
22
22
 
23
23
  ## Non-obvious behavior
24
24
 
25
+ - The optional `outputs` dependency is Kiss's shared output registry. Each successful file write claims its actual path at the write site; skipped or failed writes claim nothing. Collisions warn, and later asset cleanup cannot delete this writer's output. Custom filenames participate without a whitelist.
26
+
25
27
  - **`<lastBuildDate>` is the newest item's date, never the wall clock.** The success criterion for this file is that two identical builds produce identical bytes — a build script that commits its output, and a `kiss-ssg check` diffed against a recorded build, both depend on it; so would `test/integration/replay-equivalence.test.js`'s byte-comparison of two build folders, the day a site under it calls `.feed()`. A `new Date()` in the channel would make every build differ from the last for no reason anyone could act on. The newest item's date is the honest answer anyway: nothing in the feed changed since then. With no items there is no date to claim, so the element is left out rather than invented. `lib/sitemap.js` makes the same move one step less far, injecting `now` as a parameter; here there is no parameter to inject because there is nothing a caller could usefully pass.
26
28
  - **A date is read from the page option first and the resolved model second, under one field name.** `options[dateField] ?? options.model?.[dateField]` — `dateField` governs both lookups, so a site that dates a post in its `models/posts/*.json` and a site that dates it in the `.page()` call read the same key and neither has to know which the other used. By settle time `options.model` **is** the resolved data (the `.pages()` fan-out has already given each item its own), which is what makes the model lookup possible at all. It is guarded for `null` (a page with no model) and for an array (a non-`dynamic` page handed an array model, which has no date of its own); both are undated, neither is an error.
27
29
  - **A page with no date is silent; a date that cannot be read warns once.** Most of a site is undated — an About page is not a post — so warning per undated page would bury the feed's own output in noise on every build. A value that was _given_ and cannot be parsed is a typo the author wants to hear about, so it gets one `logger.warn` naming the page and the value, and the page joins the undated count. Neither ever throws: a feed is not the render error channel, and a build minus one item is still the build the author asked for. `writeFeed` logs the total as one `info` line, which is the only place the count surfaces — `report().feed` is the path, not the tally.
@@ -13,7 +13,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
13
13
  - `stringify(obj)` — `JSON.stringify(obj, null, 3)`.
14
14
  - `lookup(obj, field, options)` — same signature and return as Handlebars' built-in (falsy `obj` returned as-is, otherwise `options.lookupProperty(obj, field)`). An `undefined` result additionally logs one warning: `lookup: '<field>' is undefined` plus ` in <view>` when the root context carries a `view`.
15
15
  - `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href.
16
- - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
16
+ - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`. **A page's own `canonical` option wins outright**: when the root context carries a non-empty `canonical` string it is rendered verbatim, before `siteUrl` or `pageURL` are consulted — the page has named another URL as the real one (learna-kiss: 104 course pages whose canonical is the same course on diploma-msc.com). The value is validated at registration (`Kiss._preparePage`, `AIKB/kiss.md`), not here, because the sitemap, `llms.txt` and the feed read the same option and withdraw the page.
17
17
  - `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
18
18
  - `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A reference that is not a path into this build is returned unchanged: any scheme (`https:`, and also `data:`/`mailto:`, which carry no `//`) and the protocol-relative `//cdn/x.css`. The original test required `://`, so the last two fell through to a manifest lookup they could never satisfy — harmless while the helper warned, a failed build once it threw. A `?query`/`#fragment` is split off before the lookup and re-appended after, because the manifest is keyed by the path a copy emitted: `img/logo.svg#symbol` is an SVG sprite reference to a file that IS in the build, and looking the whole string up failed the build while telling the author the file was missing. `lib/links.js` splits references the same way, for the same reason. The failure message names the reference **as the template wrote it**, and the key underneath it when the two differ (`asset: '/css/x.css#a' … no .copyAssets() emitted 'css/x.css'`, `emitted it` when they are the same string); it named the key alone until an independent review pointed out that the key — slash stripped, suffix gone — is a string the author never typed and cannot search for, while the dev warning had always named the reference. A path the manifest does not know **fails the build**, the same shape as `link` on an id no page claims — they are the two halves of "never hand-write an internal URL" and were checked to different depths until R25. Warning and rendering meant the build passed, `report().ok` stayed true and the site shipped a 404; it also stripped the leading slash first, so `{{asset "/missing.css"}}` became `missing.css`, which on a nested page is a different URL. In **dev** it warns once per page per path (deduped on the page's root data frame, so a shared layout does not warn per page render) and renders the path **as written** rather than the stripped form, so the browser's 404 names what the template asked for. A non-string path logs a warning and renders `''`.
19
19
  - `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
package/AIKB/kiss-page.md CHANGED
@@ -12,7 +12,7 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
12
12
  - `set slug(slug)` / `get slug()` — setter normalizes via `toSlug` (`./utils.js`); falsy values are ignored (default `'index'` kept).
13
13
  - `set ext(extension)` — strips a leading `.`, then slugifies via `toSlug` (`./utils.js`), like `path` and `slug`; falsy values are ignored (default `'html'` kept).
14
14
  - `set extLess(val)` / `set isDev(dev)` / `set debug(dev)` — each unconditionally coerces with `!!val` and assigns (`this._extLess = !!val`, etc.) — unlike `path`/`slug`/`ext`, these do **not** ignore falsy values; e.g. `page.debug = false` overwrites the existing value rather than being skipped.
15
- - `buildDir` / `pagesDir` / `livereloadPort` — plain fields `Kiss._preparePage` assigns from the resolved config (`folders.build`, `folders.pages`, `livereloadPort`); `livereloadPort` falls back to `DEFAULT_CONFIG.livereloadPort`.
15
+ - `buildDir` / `pagesDir` / `livereloadPort` — properties `Kiss._preparePage` assigns from the resolved config (`folders.build`, `folders.pages`, `livereloadPort`); `livereloadPort` falls back to `DEFAULT_CONFIG.livereloadPort`.
16
16
  - `get buildTo()` — `${buildDir}/${pageURL()}`, the absolute-ish output path and the dedupe key `Kiss._preparePage` checks.
17
17
  - `pageURL()` — the page's URL/relative-path, honoring `extLess` (writes `slug/index.ext` instead of `slug.ext`, except for `slug === 'index'`). Read by three things besides `buildTo`: `{{canonical}}` and the sitemap (through `toCanonicalPath`), and `{{link}}` (through `servedPathFor`) — it is independent of `buildDir`, so a URL derived from it during a staged build survives `Kiss._promote()`'s rewrite of every `buildTo` unchanged.
18
18
  - `prepare()` — merges default options (`title`, `path`, `slug`, `generate: true`) under any already set on `this.options`; returns `this`.
@@ -32,6 +32,8 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
32
32
 
33
33
  ## Non-obvious behavior
34
34
 
35
+ - The optional `outputs` dependency registers the HTML and development JSON sibling separately after each successful write, with kind `page`. Asset reconciliation then respects ownership without inspecting the page stack.
36
+
35
37
  - **The render carries the page's identity in Handlebars' data frame**: `template(this.options, { data: { kissPage: this.buildTo } })`. Handlebars copies the frame into every nested partial invocation and handlebars-layouts passes `{ data }` through `extend`/`embed`, so a recording partial (`AIKB/partials.md`) reads `options.data.kissPage` and attributes itself to the right page whatever the render order — there is no "current page" variable and no assumption that pages render one at a time. Before rendering, the page calls `graph.clearPage(buildTo)` so its edge set is exactly what this render reached. `graph` is optional (`deps.graph`); without it the render is byte-identical to before — the extra `data` only adds a key to a frame Handlebars creates anyway.
36
38
  - In dev mode the `.json` sibling gains `partials`: the names this page recorded, from `graph.usesOf(buildTo)` — the per-page view of `dependency-graph.json`.
37
39
  - `_title` is computed from the _default_ slug (`'index'`) in the constructor, before any page-specific slug is set — so a page without an explicit title or model title falls back to `'Index'`, not a title derived from its own slug. This is a preserved v1 quirk, not a bug to fix.
@@ -54,3 +56,10 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
54
56
  - **Only a successful compile is cached.** Caching a failed one would hide the error log from every later render of the same broken view; a broken template should complain once per attempt, not once ever.
55
57
  - **Dev skips minification entirely rather than running it with every option off.** It used to call the minifier with `collapseWhitespace`, `minifyCSS` and `minifyJS` all `false`, leaving only comment and empty-attribute removal — which still paid for the parse, and (since the minifier is imported at first render) still paid ~155ms to load the package. A dev build never ships, so that pass costs more than it is worth; keeping comments and whitespace is arguably better for reading dev output. Measured over 9 runs: `watch@50` build **-32.7%**, `watch@500` build **-11.3%**. The saving is mostly the fixed import, which is why it is proportionally larger on the smaller site. It does **not** improve the scoped re-render (~37ms, unchanged): by then the package is loaded and per-page minification is a fraction of a millisecond. Production is untouched — the example suite still builds byte-identical to `main`.
56
58
  - **Why the Sass compile still emits `compressed` in production, even though the minifier runs `minifyCSS` over the same bytes.** This looked like pure double work — on diploma-msc's real stylesheets the two paths land within 0.16% of each other (`catalog.scss` 21904b vs 21939b; `about-us.scss` +0.02%; `faculty.scss` +0.00%), so dropping the Sass side would have saved ~11ms a page for nothing. It was tried and reverted: Sass's `compressed` style converts percentage-form colours to hex (`rgb(80.4%,41.2%,21.6%)` → `#cd6937`) and clean-css does not, so a stylesheet built on `color.adjust` and friends grows instead. The benchmark's own `styled` fixture is exactly that shape and came out **19% larger** over 200 pages. The three real stylesheets contain zero percentage-form colours, which is why they showed nothing. A library default has to hold for stylesheets nobody has sampled, so the redundancy stays.
59
+
60
+ - `outputPath` is the absolute filesystem destination resolved against the working directory captured for this page registration (passed as `deps.directory`). Writes and ownership claims use it, including the debug sibling. `buildTo` keeps the configured spelling for reports; atomic promotion updates `buildDir`, so the filesystem destination follows it.
61
+
62
+ - `outputPath` is memoized; setters for buildDir, path, slug, ext and extLess invalidate it. Promotion therefore changes the filesystem path without re-resolving it per duplicate comparison.
63
+ - `outputOwner` names file templates by filename and inline templates by a short SHA-1 fingerprint, so distinct inline sources stay distinct without putting their bodies in collision reports or committed knowledge bases.
64
+
65
+ - The write boundary independently resolves and checks the current path, so the safety guard does not trust cache invalidation when internal route fields are altered directly. This is one check per write, not per duplicate comparison.