kiss-ssg 2.0.0-alpha.5 → 2.0.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 (45) hide show
  1. package/AIKB/assets.md +1 -0
  2. package/AIKB/build-report.md +3 -1
  3. package/AIKB/check.md +1 -0
  4. package/AIKB/config.md +56 -52
  5. package/AIKB/controller-resolver.md +1 -0
  6. package/AIKB/dependency-graph.md +31 -0
  7. package/AIKB/handlebars-helpers.md +6 -5
  8. package/AIKB/kiss-page.md +4 -2
  9. package/AIKB/kiss.md +18 -12
  10. package/AIKB/partials.md +8 -5
  11. package/AIKB/pipeline.md +41 -0
  12. package/AIKB/sitemap.md +4 -3
  13. package/AIKB/testing.md +4 -2
  14. package/AIKB/utils.md +8 -6
  15. package/README.md +74 -22
  16. package/examples/10-asset-pipeline/README.md +47 -0
  17. package/examples/10-asset-pipeline/assets/css/generated.css +19 -0
  18. package/examples/10-asset-pipeline/pages/index.hbs +62 -0
  19. package/examples/10-asset-pipeline/styles/tokens.json +6 -0
  20. package/examples/10-asset-pipeline/tools/tokens.js +73 -0
  21. package/examples/10-asset-pipeline.js +81 -0
  22. package/examples/9-migrated-from-v1/pages/handlebars-instance.hbs +7 -3
  23. package/examples/9-migrated-from-v1.js +14 -7
  24. package/examples/README.md +6 -5
  25. package/lib/build-report.js +22 -0
  26. package/lib/config.js +350 -280
  27. package/lib/controller-resolver.js +60 -15
  28. package/lib/dependency-graph.js +76 -0
  29. package/lib/handlebars-helpers.js +5 -2
  30. package/lib/kiss-page.js +15 -4
  31. package/lib/kiss.js +162 -12
  32. package/lib/partials.js +56 -28
  33. package/lib/pipeline.js +317 -0
  34. package/lib/sitemap.js +10 -5
  35. package/lib/utils.js +33 -10
  36. package/llms.txt +38 -11
  37. package/package.json +4 -3
  38. package/types/build-report.d.ts +25 -1
  39. package/types/config.d.ts +70 -13
  40. package/types/dependency-graph.d.ts +28 -0
  41. package/types/kiss-page.d.ts +4 -2
  42. package/types/kiss.d.ts +16 -0
  43. package/types/partials.d.ts +15 -3
  44. package/types/pipeline.d.ts +65 -0
  45. package/types/utils.d.ts +11 -2
package/AIKB/assets.md CHANGED
@@ -32,5 +32,6 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
32
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
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
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`.
35
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.
36
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.
@@ -15,6 +15,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
15
15
  - `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
16
16
  - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
17
  - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
18
+ - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
18
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.
19
20
 
20
21
  ## Depends on
@@ -31,8 +32,9 @@ Nothing.
31
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.
32
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`.
33
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>>`.
34
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.
35
37
 
36
38
  ## Types
37
39
 
38
- `BuildReport`, `BuildPage`, `BuildAsset` and `BuildReportFailure` 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`.
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`.
package/AIKB/check.md CHANGED
@@ -25,6 +25,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
25
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
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
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.
28
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.
29
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.
30
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.
package/AIKB/config.md CHANGED
@@ -1,52 +1,56 @@
1
- # config.js
2
-
3
- ## Responsibility
4
-
5
- Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFIG`/`DEFAULT_FOLDERS`, derives the folder set from `folders.src` when given, and lists which folders `Kiss` must create on startup.
6
-
7
- ## Public interface
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`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
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
- - `DEFAULT_ASSETS` — frozen object, the cache-busting policy for emitted assets: `hash: false`, `version: null`.
13
- - `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.
14
- - `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`, and on a build folder that contains the source folder (both below).
15
- - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
16
- - `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.
17
-
18
- ## Depends on
19
-
20
- `node:path` (resolving the build/source folders for the safety guard below) — nothing else.
21
-
22
- ## Depended on by
23
-
24
- `lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
25
-
26
- ## Non-obvious behavior
27
-
28
- - **`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.
29
- - **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.
30
- - **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'`.
31
-
32
- - **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.
33
- - 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).
34
- - **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.
35
- - **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.
36
- - **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.
37
- - 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).
38
- - `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.
39
- - **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.
40
- - `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
41
- - **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy.
42
- - **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.
43
- - **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.
44
- - **`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.
45
-
46
- ## Types
47
-
48
- 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`, `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.
49
-
50
- - **`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`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
51
- - **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.
52
- - `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.
1
+ # config.js
2
+
3
+ ## Responsibility
4
+
5
+ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFIG`/`DEFAULT_FOLDERS`, derives the folder set from `folders.src` when given, and lists which folders `Kiss` must create on startup.
6
+
7
+ ## Public interface
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'`).
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
+ - `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
+ - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
+ - `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
+ - `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
+
19
+ ## Depends on
20
+
21
+ `node:path` (resolving the build/source folders for the safety guard below) — nothing else.
22
+
23
+ ## Depended on by
24
+
25
+ `lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
26
+
27
+ ## Non-obvious behavior
28
+
29
+ - **`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.
30
+ - **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.
31
+ - **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'`.
32
+
33
+ - **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.
34
+ - 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).
35
+ - **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
+ - **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
+ - **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.
38
+ - 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
+ - `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
+ - **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.
41
+ - `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
42
+ - **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy.
43
+ - **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
+ - **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
+ - **`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.
46
+ - **`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
+
48
+ ## Types
49
+
50
+ 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.
51
+
52
+ - **`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
+ - **`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.
55
+ - **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
+ - `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.
@@ -22,6 +22,7 @@ Resolves `options.controller` (a function, or a filename to load) and runs it ag
22
22
 
23
23
  - Controller files resolve against `process.cwd()` (via `path.resolve`), consistent with every other folder lookup in the codebase (models, partials, layouts, assets). v1 used `require.main.require` (resolved relative to the entry script) but gated the attempt on a cwd-relative `existsSync` check first — so in v1 a differing cwd already caused the existence check to fail before the entry-relative `require` ever ran, meaning v1's entry-relative resolution never actually worked either; this module's cwd-relative resolution is not a behavior change in practice.
24
24
  - `fresh: true` (passed by `lib/kiss.js` only while replaying a watch rebuild) busts _two_ caches, because Node keeps two: the ESM loader caches by resolved URL, so the file URL gets a `?v=<mtimeMs>` query appended; the CommonJS loader caches by _filename_ and never sees that query, so a `module.exports = fn` controller would keep returning the stale export unless `require.cache[require.resolve(path)]` is deleted as well. Both are needed — dropping either one leaves one of the two module systems stale. `require` here comes from `createRequire(import.meta.url)` (this module is ESM, so there is no ambient `require`).
25
+ - A module-level `inFlightFreshLoads` map (`Map<resolvedPath, Promise>`) makes sure only **one** fresh load per controller path is ever in flight; a concurrent `fresh` call for the same path returns the pending promise instead of busting the caches a second time. Two `.page()` registrations naming the same **CommonJS** controller are replayed concurrently on every watch rebuild, and the second call's `delete require.cache[filename]` landed while the first `import()` was still translating the module — Node's translator asserts the entry it just populated is still present, so both pages died on `Error [ERR_INTERNAL_ASSERTION] … at loadCJSModuleWithModuleLoad`. Observed on a real site (two pages sharing `faculty-index.js`), never on a cold build (no `fresh`), and never for an ESM controller (the ESM loader keys on the busted URL, so a second `import()` joins the first job) — but ESM goes through the same map anyway, one code path with no type sniffing. Only _in-flight_ loads are shared: the entry is dropped in a `.finally`, so a `fresh` call made after the previous one settled still re-reads the file, which is the whole point of `fresh`. The non-`fresh` path never touches the map. `test/unit/controller-resolver.test.js` pins both halves, and — because vitest rewrites `import()` inside `lib/` to its own module runner, which never reaches the CJS translator — the reproduction case runs the two concurrent loads in a plain `node` child process, where the assertion was deterministic before the fix. The map is **module-level state shared by every `Kiss` instance in the process**, deliberately: it dedupes identical I/O keyed on an absolute path, so two instances loading the same controller file want the same answer, and Node's own module caches it fronts are process-global anyway. Two residuals are knowingly left open: a **non-`fresh`** `import()` racing a `fresh` one of the same CJS file is still unguarded (only `fresh` calls join the map, so the cache-delete can still land mid-translation of a plain load), and the map key is `path.resolve`, not a realpath — on NTFS a case-variant spelling of the same file (`Faculty-Index.js` vs `faculty-index.js`) takes a second slot and the two loads do not share.
25
26
  - Cache-busting keys on `mtimeMs`, so an _unchanged_ controller still hits the ESM cache on replay (no re-evaluation cost per rebuild); only edited files reload. Two rewrites within the same filesystem-timestamp tick would collapse to one URL — not a concern in watch mode, where chokidar's `awaitWriteFinish` already delays a rebuild ~100ms past the last write.
26
27
  - Without `fresh` (every non-watch build) behaviour is byte-for-byte unchanged: `import()` caches per resolved URL exactly as `require()` cached per path, so a controller changed mid-process is not re-read. `test/unit/controller-resolver.test.js` pins that cached-by-default behaviour alongside the `fresh` cases.
27
28
  - Accepts both `export default fn` and CommonJS-style `module.exports = fn` (`import()` surfaces the latter as `mod.default`), via `mod.default ?? mod`.
@@ -0,0 +1,31 @@
1
+ # dependency-graph.js
2
+
3
+ ## Responsibility
4
+
5
+ A pure, per-instance index of which pages rendered which partials, populated as a side effect of rendering and read by `Kiss._handleChange` to decide which pages an edited partial or layout can affect.
6
+
7
+ ## Public interface
8
+
9
+ - `new DependencyGraph()`
10
+ - `record(page, partial)` — adds the edge both ways; idempotent. `page` is the page's `buildTo`, `partial` its registered name.
11
+ - `clearPage(page)` — removes every edge of that page. A partial it used stays known, possibly with no dependents.
12
+ - `clear()` — forgets everything (a whole-site replay discards the stack, so the graph goes with it).
13
+ - `dependentsOf(partial)` → `string[] | null` — sorted page keys, or **`null` if the partial was never recorded**. `null` and `[]` mean different things to the caller: `null` re-renders every page (the safe fallback), `[]` re-renders none.
14
+ - `usesOf(page)` → `string[]` — sorted partial names that page recorded.
15
+ - `size` — how many partials are known.
16
+ - `toJSON()` → `{ [partial]: pages[] }`, keys and values sorted, so `dependency-graph.json` is stable across builds.
17
+
18
+ ## Depends on
19
+
20
+ Nothing.
21
+
22
+ ## Depended on by
23
+
24
+ `lib/kiss.js` (owns one per instance; dispatch and the verbose-dev dump), `lib/partials.js` (records), `lib/kiss-page.js` (clears before render; lists in the debug sibling).
25
+
26
+ ## Non-obvious behavior
27
+
28
+ - **It is filled by rendering, never by parsing.** A partial is registered as a function that records the invoking page (`lib/partials.js`), so a `{{> (lookup this "name")}}` chosen at render time, a partial reached through another partial, and a layout reached through `{{#extend}}` are all recorded. An inline partial (`{{#*inline}}`) shadows the registered one and records nothing — correct, the page does not depend on the file at that call site.
29
+ - **The page identity travels in Handlebars' data frame**, not in shared state: `KissPage.generate()` renders with `{ data: { kissPage: buildTo } }` and the recording wrapper reads `options.data.kissPage`. Handlebars copies the frame into every nested partial call and handlebars-layouts passes `{ data }` through `extend`/`embed`, so the attribution is correct however many pages render at once or in whatever order. There is no "current page" marker and no ordering invariant.
30
+ - **`null` is the fallback signal.** A partial edited before any page has rendered it (a fresh dev session mid-first-build, a partial only a page that failed would reach) has no record; the dispatcher then re-renders every entry and logs a notice naming the file. A partial whose pages all stopped using it is known with `[]` dependents and re-renders nothing.
31
+ - Keys are `buildTo`, which is stable under `dev: true` (`cleanBuild: 'atomic'` degrades to `true` there, so no staging paths).
@@ -13,8 +13,8 @@ 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 `toURLKey(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
17
- - `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`. 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 `''`.
16
+ - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
17
+ - `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
18
18
  - `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A path already carrying a scheme is returned unchanged. A path the manifest does not know renders as it was given and logs one warning per page per path. A non-string path logs a warning and renders `''`.
19
19
  - `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
20
20
 
@@ -30,7 +30,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
30
30
 
31
31
  - Helpers register on whichever Handlebars environment (`hbs`) is passed in, not a global instance — each `Kiss` instance calls this against its own `this.handlebars`, so helpers never leak between instances.
32
32
  - The `sass` helper's file mode resolves a _relative_ path against `process.cwd()`, not against the view or the assets folder; an **absolute** path is passed to `sass.compile` untouched (`path.isAbsolute(context)` — an unconditional `path.join(process.cwd(), context)` mangled a path a controller had resolved into `<cwd>/abs/path/main.scss` and failed the build, review finding C7). Block mode compiles the block content directly with `sass.compileString` (no `process.cwd()` involved).
33
- - `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js`, shared with `canonical` and `sitemap.js`) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
33
+ - `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js` — page identity, and nothing emitted is built from it) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
34
34
  - The match is by **path segment**: active iff `pageKey === hrefKey` or, under `folderMatch=true`, `pageKey.startsWith(hrefKey + '/')`. It was a bare `pageURL.includes(href)`, which lit `/blog` on `my-blog-post.html` and — because `''.includes()` is always true — lit every item of a nav that omitted `href` (D-02). An empty `href` under `folderMatch` now matches the home page only, which falls out of the same rule.
35
35
  - Helpers **degrade rather than throw**: a page dropped from the build is a worse answer to a template mistake than a missing highlight. `isActive` coalesces (`hash.href ?? ''`) instead of spreading over a default, because `{{#isActive page href=href}}` on a model with no `href` key passes an explicit `undefined` that wins a spread; a missing or non-string `pageURL` logs a warning and renders the block as not-active; and when the page argument is omitted altogether Handlebars passes its own options object as the first parameter, so the block is located by shape (`options ?? pageOptions`) rather than by position (D-05). `markdown` gates its block branch on `typeof obj.fn === 'function'` rather than `typeof obj === 'object'` so a `null`, object or array model field takes the warn-and-render-nothing path, and `env` requires `is` to be a string before calling `.toLowerCase()` (D-04).
36
36
  - `env` reads `config.dev` at _render_ time (helpers close over the `config` object, not a snapshot), so `{{#env is="dev"}}`/`{{#env is="prod"}}` blocks reflect the live config.
@@ -38,8 +38,9 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
38
38
  - `lookup` **shadows the Handlebars built-in on purpose**, and its only difference is the log line. A dynamic partial — `{{> (lookup . 'moodleAccess')}}` — whose key is absent from the data fails as `The partial undefined could not be found`, which names neither the key nor the page: diagnosing 40 such pages in a consumer site needed a hand-written probe helper (review finding G-2). The helper warns and then returns `undefined` anyway, so the render fails exactly as before; the fix is a name in the log, not a change of behaviour.
39
39
  - The prototype guard is **delegated, never reimplemented**: the value comes from `options.lookupProperty(obj, field)`, the container function Handlebars passes into every helper, which is what refuses `__proto__`/`constructor` access. A raw `obj[field]` here would quietly reopen that hole, so the unit test asserts kiss's output against a plain Handlebars instance's for those keys rather than against a literal.
40
40
  - The warning is deduplicated **per page per key** through a `WeakMap` keyed on `options.data.root` — the page's own options object, a fresh one per page. A partial used twice on a page logs once; a `.pages()` fan-out of 100 pages missing the same key logs 100 lines, one each, which is the point (each names its own page). No dedupe is applied when the template was compiled without `data`.
41
- - `canonical` and `absUrl` do **not** own their normalisation: both go through `toAbsoluteUrl` in `utils.js`, which `lib/sitemap.js` also uses to build every `<loc>`. That is the point of the pair — a page's `<link rel="canonical">` and its own sitemap entry are produced by one function, so they cannot drift apart (review gap O5). `canonical` additionally reduces the page URL with `toURLKey`, which is what strips the `.html` and the trailing `index` segment; the sitemap does the same, hence the agreement, and an integration test builds a site both ways round (`extensionLess` on and off) and asserts the two lists are equal.
42
- - **`absUrl` keeps a file extension, `canonical` does not.** `toURLKey` would turn `css/site.css` into `css/site`, so `absUrl` passes its path to `toAbsoluteUrl` directly — that only trims slashes and collapses a trailing `index` segment. A page URL is an identity (every spelling of it is one key); an arbitrary path is a path.
41
+ - `canonical` and `absUrl` do **not** own their normalisation: both go through `toAbsoluteUrl` in `utils.js`, which `lib/sitemap.js` also uses to build every `<loc>`. That is the point of the pair — a page's `<link rel="canonical">` and its own sitemap entry are produced by one function, so they cannot drift apart (review gap O5). `canonical` additionally reduces the page URL with `toCanonicalPath`, which strips the `.html` and leaves the `index` segment for `toAbsoluteUrl` to turn into a trailing slash; the sitemap does exactly the same, hence the agreement, and an integration test builds a site both ways round (`extensionLess` on and off) and asserts the two lists are equal.
42
+ - **A directory index canonicalises with a trailing slash.** `courses/index.html` is served at `/courses/`; Netlify, GitHub Pages and nginx answer the bare `/courses` with a 301, and a canonical must be the URL that returns 200 — so `{{canonical}}` on that page is `https://site/courses/`, and `{{absUrl '/courses/'}}` now keeps the slash it was given rather than trimming it. It used to canonicalise through `toURLKey`, which drops the `index` segment outright and produced the redirecting URL; do not put that back. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html`, so **every** page but the home page is a directory index and its canonical ends in `/` (`courses/bronze` → `https://site/courses/bronze/`) — correct, because that is the URL the host serves, and the one place where the two `extensionLess` settings deliberately emit different URLs.
43
+ - **`absUrl` keeps a file extension, `canonical` does not.** `toCanonicalPath` would turn `css/site.css` into `css/site`, so `absUrl` passes its path to `toAbsoluteUrl` directly — that strips a leading slash, collapses repeated slashes and turns a trailing `index` segment into a trailing one, and otherwise leaves the path alone. A page URL is a page (its extension is a build detail); an arbitrary path is a path.
43
44
  - The `siteUrl` read is the **page's**, `root.config.siteUrl`, falling back to the instance config only when the template was compiled without a page context. `Kiss.page()` gives every page its own shallow copy of the config, so a page built for another domain (`.page({ view, config: { siteUrl } })`) gets a canonical URL on that domain. `sitemap.js` still reads the instance `siteUrl` for every `<loc>`, so such a page is the one case where the two differ — deliberately: it is the page that has been pointed elsewhere.
44
45
  - No `siteUrl` **degrades, like every other helper**: one warning and an empty string, never a throw and never a guessed domain. The warning is deduplicated per page through a `WeakSet` keyed on `options.data.root` (the `lookup` pattern), so a layout with a canonical link and a dozen `absUrl` links logs one line for that page, not thirteen. A missing `pageURL` warns separately and also renders nothing: the same canonical URL on every page is worse for a search engine than no canonical link at all.
45
46
  - **`asset` reads a manifest, it does not look at disk.** The manifest is filled by the one `copyAssets` run the constructor queues, and `generate()`/`complete()` await `_promises` — which that run is on — before any page renders, so the helper always sees a filled manifest. A watch-mode asset edit refills it and, under a hashing policy, `Kiss` re-renders the stack so the new filename reaches the pages (`AIKB/kiss.md`).
package/AIKB/kiss-page.md CHANGED
@@ -7,7 +7,7 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
7
7
  ## Public interface
8
8
 
9
9
  - `class KissPage`
10
- - `new KissPage(view, { hbs, logger } = {})` — `view` is a `.hbs` path (relative to `pagesDir`) or an inline template string.
10
+ - `new KissPage(view, { hbs, logger, graph } = {})` — `view` is a `.hbs` path (relative to `pagesDir`) or an inline template string. `graph` is an optional `DependencyGraph` (`./dependency-graph.js`); without one the render is unchanged.
11
11
  - `set path(path)` — sanitizes via `sanitizePath` (`./utils.js`); falsy values are ignored (default `''` kept).
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).
@@ -22,7 +22,7 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
22
22
 
23
23
  ## Depends on
24
24
 
25
- `fs-extra`, `node:path`, `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
25
+ `fs-extra`, `node:path`, `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`). The dependency graph arrives as `deps.graph` (`AIKB/dependency-graph.md`) and is referenced only by a type-only `import('./dependency-graph.js')` in the JSDoc — there is no runtime import of that module here.
26
26
 
27
27
  ## Depended on by
28
28
 
@@ -30,6 +30,8 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
30
30
 
31
31
  ## Non-obvious behavior
32
32
 
33
+ - **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.
34
+ - 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`.
33
35
  - `_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.
34
36
  - `buildTo` is the value `Kiss._preparePage` uses to detect duplicate pages (same output path) — a duplicate is a build failure there, not a skip.
35
37
  - **Every part of `buildTo` is sanitised, `ext` included.** `ext` used to strip only the _first_ `.` (`replace('.', '')`) and never slugify, so unlike `path` (`sanitizePath`) and `slug` (`toSlug`) it carried `/` and `..` straight into the output path: `ext: './../../../escaped/pwned.html'` wrote three levels above the build folder, and the value is reachable from model data through the documented "controller derives the page's options" pattern (review finding C1). It now goes through `toSlug(extension.replace(/^\./, ''))` — `'.xml'` → `'xml'`, `'../x'` → `'x'`.
package/AIKB/kiss.md CHANGED
@@ -7,7 +7,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
7
7
  ## Public interface
8
8
 
9
9
  - `class Kiss` (default export), also re-exported as `export { Kiss as 'module.exports' }` so `require('kiss-ssg')` on Node ≥22.12 returns the class, not `{ default: Kiss }`.
10
- - `new Kiss(config)` — resolves config, starts loading the HTML minifier when not in dev mode (`preloadMinifier()`, unawaited — see the bullet below), sets up Handlebars/Remarkable and the instance's asset manifest, ensures folders, queues an asset copy, registers helpers and partials, and (in dev mode) starts the dev server and watcher. A dev server that cannot bind is a **build failure** — see the `<dev server>` bullet below. `_setupFolders()` is also where `cleanBuild` is applied: `true` empties `folders.build` here, `'atomic'` redirects it to a staging sibling — see the staged-build bullets below.
10
+ - `new Kiss(config)` — resolves config, starts loading the HTML minifier when not in dev mode (`preloadMinifier()`, unawaited — see the bullet below), sets up Handlebars/Remarkable and the instance's asset manifest, ensures folders, creates the asset pipeline and queues its steps ahead of the asset copy, queues that copy, registers helpers and partials, and (in dev mode) starts the dev server and watcher. A dev server that cannot bind is a **build failure** — see the `<dev server>` bullet below. `_setupFolders()` is also where `cleanBuild` is applied: `true` empties `folders.build` here, `'atomic'` redirects it to a staging sibling — see the staged-build bullets below.
11
11
  - `.copyAssets(sourceDir, targetDir)` — puts `targetDir` through `_stagedPath()` (a staged build's copies have to follow it into staging) and chains `copyAssets()` onto `_assetQueue` (passing `_assetManifest`, so every copy records into the one manifest the `asset` helper reads) (a `Promise` that serializes calls in registration order) and pushes the chained promise onto `_promises`; returns `this`. Sequential rather than concurrent because one copy's target can be a subdirectory of another's source (e.g. a consumer copying into its own assets folder), and concurrent `fs.copy` walks racing there produce `ENOENT` failures. `_assetQueue` is reset to `Promise.resolve()` alongside `_promises`/`_generating`/`_failures` (and the `_failuresReported` latch) in `_replay()`.
12
12
  - `.registerPartials()` — re-runs `registerPartials()` against the current Handlebars env, passing the names the previous pass registered so any that have since left disk are unregistered; stores and returns the new set.
13
13
  - `.page(options)` — attaches a per-page copy of the config, resolves the page's model, runs its controller, prepares a `KissPage`, pushes it onto `_stack`; returns `this`.
@@ -20,12 +20,12 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
20
20
  - `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
21
21
  - `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and `copyAssets()` + a live reload (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is below — and every rebuild it asks for goes through the serial queue.
22
22
  - `.report()` — the last settled build as data (`AIKB/build-report.md`), or `null` before the first `complete()` settles. The same object is on the rejection as `err.report`, and a watch rebuild replaces it with its own.
23
- - `.close()` — sets `_closing`, closes the watcher, drains the rebuild queue, closes the dev server, and removes an un-promoted staging folder; a closed instance stays closed.
23
+ - `.close()` — sets `_closing`, closes the watcher, drains the rebuild queue, closes the dev server, ends every pipeline watch process, and removes an un-promoted staging folder; a closed instance stays closed.
24
24
  - `export { utils }` — re-exports `lib/utils.js`'s default export.
25
25
 
26
26
  ## Depends on
27
27
 
28
- `fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./handlebars-helpers.js`, `./partials.js`, `./assets.js`, `./asset-manifest.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
28
+ `fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./assets.js`, `./asset-manifest.js`, `./pipeline.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
29
29
 
30
30
  ## Depended on by
31
31
 
@@ -35,9 +35,12 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
35
35
 
36
36
  - Pipeline: `.page()`/`.pages()`/`.scan()` queue work (model → controller → `_preparePage`) as one caught promise per page on `_promises`; nothing renders until `.generate()`. `.generate()` awaits `_promises`, renders every stack entry whose `runCount` is 0, awaits the writes, then fires the callback. `_generating` tracks `generate()`/`sitemap()` runs, `_callbacks` the promises callbacks themselves returned. `complete()` drains all three lists repeatedly because callbacks may queue more work, and then renders anything the drain left unrendered.
37
37
  - **The build folder is written by four things, and `config.folders.build` is the single handle on all four.** Pages (`_preparePage` copies it onto each `KissPage.buildDir`, which `buildTo` is composed from — plus the dev-mode `.json` debug sibling beside each page), the asset copy (`copyAssets(folders.assets, folders.build)` at construction, and again on a watched asset change), `sitemap.xml` (`writeSitemap` reads `config.folders.build`), and `viewStats()`'s `debug.json`. Nothing writes into the build by any other route, which is what makes the staged build below a one-line redirection rather than four separate detours.
38
+ - **`config.assets.pipeline` is wired at three points, and all three are one line each** (`AIKB/pipeline.md` has the runner). The constructor creates the pipeline and calls `_queuePipeline()` immediately before the construction-time `copyAssets()`; `_replay()` re-runs it (and re-queues the copy) when — and only when — the site configured a step, so a whole-site watch rebuild re-runs the tools an edited model or controller may have changed the input to; `close()` ends the watch processes. **Ordering needs no mechanism of its own**: the steps are chained onto `_assetQueue`, the promise every copy already chains onto, so "steps first, copy second" is the queue doing what it was built to do. The run is deliberately **not** pushed onto `_promises`: those entries are the `data` array `generate()`/`complete()` hand back, whose first entry is documented as the construction-time asset copy, so a site adding a pipeline would have silently renumbered its own `data`. It goes on `_generating`, which `_drain()` waits for just the same. A step that exits non-zero (or cannot be spawned) is recorded as `{ view: '<pipeline: name>', buildTo: null, error }` — the same shape as a controller or dev-server failure, since there is no output path to name it by — so `complete()` rejects while the copy and every page still run and the report still names everything else that was wrong. Scoped re-renders (a page view, a partial, a layout) never re-run the steps: a tool that must see those edits is what a step's `watch` command is for.
39
+ - **A pipeline step runs before `cleanBuild` could matter, and writes wherever the site tells it to.** `KISS_BUILD`, `KISS_ASSETS` and `KISS_DEV` are handed to each command (read per run, so `KISS_BUILD` follows a staged build into its staging folder and back out again after `_promote()`), but nothing stops a step writing anywhere else. The idiom the feature is built for is a tool that writes into the **source** tree (`src/assets/css/site.css`), which is why `kiss-ssg check` can run the pipeline exactly like a build: the step's output is a source file the check has nothing to discard.
38
40
  - **`cleanBuild: 'atomic'` builds into a staging sibling and swaps it in on success.** `_setupFolders()` keeps the folder the consumer asked for in `_buildTarget` and points `config.folders.build` at `<build>.kiss-staging-<pid>-<random>`, so all four write paths above land there with no further plumbing; the real folder is not touched at all until the swap. `complete()` is the decision point: a settle with no failures calls `_promote()`, and a settle with failures calls `_discardStaging()`, which deletes the staging folder and leaves the previous output exactly as it was. Promotion happens **once**: `_buildSettled` latches, so the nested `complete()` pattern's second call is a no-op and, crucially, a second `complete()` after a failed build (which resolves rather than re-rejecting, see the report-once bullet) can never promote a build that failed. The failure message names the target path, not the staging path (`_reportedPath`), because the staging folder is an implementation detail the operator should not have to decode.
39
41
  - **Promotion is rename-aside, not remove-then-rename**, so the previous output is never absent for the length of a recursive delete. `_promote()` renames the existing target to `<build>.kiss-old-<same suffix>` (both siblings of one build share the `<pid>-<random>` suffix, so a leftover pair is recognisably one crashed run), renames staging into the target, and only then removes the old folder. The previous output is therefore absent only _between two renames_ — metadata operations on one filesystem — and if the second one fails, the cross-device `fs.move` fallback is tried, and if that fails too the fragment it wrote is removed and the old folder is renamed back before the error is rethrown, so a failed promotion ends with the previous output byte-identical. The one case that cannot be repaired automatically is a restore that itself throws: the old folder then holds the only copy, so it is named in an `error` line and `close()` deliberately leaves it alone while the target is missing (it removes `_oldDir` only when the build folder is back in place). `_stagingDir` is likewise cleared only after the swap, so a promotion that threw still leaves the staging folder for `close()`.
40
42
  - **A crashed run's siblings are swept at construction.** `_sweepStaleSiblings()` (atomic, non-dev only) removes any `<build>.kiss-staging-*` / `<build>.kiss-old-*` beside the target and logs one `notice` naming them, so a killed build does not leave a copy of the site accumulating beside published output. Siblings carrying **this** process's pid are skipped: those belong to a live instance, which is the case a second `Kiss` building the same target in one process would hit.
43
+ - **A `generate: false` page claims no output path.** `_preparePage`'s `buildTo` dedupe only counts entries that will be written, on both sides — a skipped page neither fails a real page registered after it nor fails itself when one came first — and the replay sweep's `current` set excludes them, so a page that turns `generate: false` between builds has its stale output removed. It still sits on `_stack` (so `.scan()`'s by-view dedupe and the `complete()` data array are unchanged) and is skipped by the sitemap.
41
44
  - **After a promotion the instance is an ordinary one.** `_promote()` points `config.folders.build` back at the real folder and re-points every stack entry's `buildDir`/`buildTo`, so a second build on the same instance — or a watch rebuild — writes into the promoted folder instead of a staging sibling nothing will ever swap in again. `_stagedPath()` covers the one write that does not read `folders.build`: a `copyAssets()` the consumer aimed explicitly at the build folder (or a subfolder of it, the metacarpus idiom) is rewritten into staging, or the promotion's `fs.remove(target)` would delete the files that copy had just made. A copy aimed anywhere else is left where it was asked for.
42
45
  - **`'atomic'` is a one-shot-build feature, and dev mode says so.** With `dev: true` the promotion point is the wrong shape — the dev server serves `folders.build` from construction and every rebuild would have to promote — so `_setupFolders()` degrades `'atomic'` to `true` (wipe at construction, build in place) and logs one `notice` saying that atomic promotion applies to one-shot builds. A **non-dev** `.watch()` is the one seam left: it stages, promotes at the first successful `complete()`, and behaves as an ordinary watch after that. A build that never calls `complete()` never promotes, and `close()` removes the staging folder — which is also what clears a discarded one that a later write recreated (`_discardStaging` deliberately keeps `_stagingDir` set for exactly that).
43
46
  - **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_sitemapPath`, and `_startedAt`) reset in `_replay()` with `_failures`, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
@@ -54,6 +57,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
54
57
  - `_promises` must only ever contain _handled_ (caught) promises — a bad model rejecting unhandled crashed the process in v1. The `.page()` chain's `.catch()` always resolves to `{ id, data: null, error }` instead of rejecting.
55
58
  - `_partialNames` holds what the last `registerPartials()` pass registered, and is handed back to the next one. Handlebars never forgets a registration on its own, so without that set a partial deleted from disk would keep rendering its last-known content for the life of the process (review finding B1). The state lives here, not in `partials.js`, because the Handlebars env is per-instance — see `AIKB/partials.md` for why the diff is by name across all four passes rather than per file. A page that still references a deleted partial now fails on the replay: `complete()` collects it in `_failures` and the rebuild queue logs `Error rebuilding site`, and the page's stale output is left alone because it was never rewritten.
56
59
  - Handlebars and Remarkable are created fresh per `Kiss` instance so partials/helpers from one instance never leak into another (matters for tests that build multiple sites in one process).
60
+ - **Remarkable is constructed from `config.markdown`, and _where_ in the constructor is load-bearing.** `.md` partials are rendered to HTML by `registerPartials()` at registration time, not at page render, so the renderer has to exist in its final configuration before that call. Building it from the resolved config a few lines earlier is what lets a site set `markdown: { breaks: true }` and have it reach partials — a consumer mutating `kiss.remarkable` after construction changes the `{{markdown}}` helper (which renders per page) but silently misses every partial unless they also call `kiss.registerPartials()`. See `AIKB/config.md` and `AIKB/partials.md`.
57
61
  - `export { Kiss as 'module.exports' }` exists purely so CJS `require('kiss-ssg')` returns the class itself on Node ≥22.12, not `{ default: Kiss }`.
58
62
  - `config.logger` is the one dependency-injection seam — pass `silentLogger` (from `lib/logger.js`) in tests to keep output quiet.
59
63
  - Calling `complete()` from inside a `generate()` callback is safe, not a deadlock: `generate()` invokes the callback (through `_runCallback`, which calls it as `callback.call(this, data)`) and never awaits whatever the callback returns, so the `_generating` entry for that `generate()` run settles as soon as the callback returns (its return value or any promise it returns is not part of what `_drain()` waits on — which is also why `_runCallback` gives a returned promise its own `.catch` rather than pushing it onto `_generating`). `complete()`'s `_drain()` therefore still sees that run resolve and proceeds normally. It is safe on a _failing_ build too, which it was not before A-01: the report-once latch means only one of the two concurrent `complete()` calls rejects, so the one the consumer did not attach a handler to resolves instead of taking the process down. A real downstream site (diploma-msc's `generate.js`) relies on this pattern, calling `complete()` from within a `generate()` callback. Since A-03 the callback's own promise _is_ waited on — by `_drain()`, not by the `generate()` run — so the re-entrancy guard moved there: `complete()` reads `_drainDepth` **synchronously** on entry, and a call that finds it above zero (i.e. one made from inside a callback while another `complete()` is draining) drains `_promises`/`_generating` only, skipping `_callbacks`. Without that skip an `async` callback that awaits `this.complete()` would wait on a drain that is waiting on the callback. A **depth counter, not a flag**: each call increments on entry and decrements in a `finally`, so the answer does not depend on which of the two calls settles first. A flag saved and restored per call got this wrong whenever the nested call settled _last_ — it restored the value it had captured (`true`), leaving the instance permanently "draining", so every later `complete()`, including the one `_replay()` runs on each watch rebuild, silently skipped `_callbacks`. The guard covers the case that exists in practice — the outer `complete()` is what makes the callback run — and not the exotic one where a callback registered while nothing is draining awaits `complete()`: that call would wait on its own callback entry, so the pattern is "await `complete()` inside a callback of a build somebody is already awaiting".
@@ -65,7 +69,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
65
69
  - `_replay()` drops any `scan()`-created registration whose `.hbs` view no longer exists on disk (logged once as `Removed page: <view>`) before re-registering, so a deleted page file is not re-queued, its `buildTo` is absent from the new stack, and the orphan sweep below removes its output and the `.json` sibling. Only scanned registrations are dropped: `scan()` derives the page list _from_ the file tree, so the file going away is the caller's own instruction, whereas a page registered by name is not — a missing view there stays a build failure rather than a silent disappearance, which is what would hide a typo. Scan-created registrations are tracked in a `WeakSet` (`_scanned`) keyed by the registration object rather than by a key on it, because `_registrations` entries are spread straight back into `page()` on replay and any own key would reach the render context and the dev-mode `.json` sibling; `scan()` sets `_scanning` around its `page()` calls the same way `_replay()` sets `_replaying`.
66
70
  - **The asset manifest is instance state that survives a replay.** `_assetManifest` (`AIKB/asset-manifest.md`) is created in the constructor, before the first `copyAssets()`, and handed to both that copy and `registerHandlebarsHelpers` — so the `asset` helper reads what the copy emitted. Ordering is already guaranteed by the queues rather than by anything new: the constructor's copy is the **first** promise on `_promises`, and `generate()`/`_settle()` await `_promises` before rendering a page, so no page can render against an empty manifest. `_replay()` resets `_assetQueue` and `_promises` but never re-copies assets and never clears the manifest, which is exactly right — the files are still in the build under the names the manifest holds.
67
71
  - **A renamed asset is invisible to the orphan sweep, on purpose.** The sweep works from `_stack` `buildTo` paths (page outputs); assets have no stack entry. Stale hashed files are removed by `lib/assets.js` from the manifest's own record of the name it replaced, not here.
68
- - **Under a hashing policy an asset edit re-renders the pages.** `watch()`'s `assetsChanged` normally just re-copies and names the built file for a live-reload stylesheet swap. With `config.assets.hash`, and only for a file the policy actually renames (`isHashable`, i.e. the built `.css`/`.js`), the copy has changed the filename, so every page is linking one that no longer exists: the handler instead asks the rebuild queue for a scoped re-render of `_stack` (the same scoped path an edited partial takes) and lets the queue's `.finally()` do the one reload, after every page has been rewritten. An edited image or `robots.txt` keeps its name, so it keeps the cheaper targeted reload. `_builtAssetPath` also resolves through the manifest, so the path it hands live reload is the file that is actually on disk.
72
+ - **Under a hashing policy an asset edit re-renders the pages.** `watch()`'s `assetsChanged` normally just re-copies and names the built file for a live-reload stylesheet swap. With `config.assets.hash`, and only for a file the policy actually renames (`isHashable`, i.e. the built `.css`/`.js`), the copy has changed the filename, so every page is linking one that no longer exists: the handler instead asks the rebuild queue for a scoped re-render of `_stack` (the same scoped path an edited partial takes, but every entry: a renamed asset can be linked from any page) and lets the queue's `.finally()` do the one reload, after every page has been rewritten. An edited image or `robots.txt` keeps its name, so it keeps the cheaper targeted reload. `_builtAssetPath` also resolves through the manifest, so the path it hands live reload is the file that is actually on disk.
69
73
  - `_replay()` (watch rebuilds only) discards `_stack`, `_promises` and `_generating` (and `_failures`, and resets `_assetQueue`), re-registers partials, then re-runs `page()` for every entry in `_registrations` with `_replaying = true`, and finally `generate()` + `complete()`. Because the whole pipeline re-runs, models are re-read from disk (and URL models are _re-fetched_ — a watch rebuild costs a network round trip per remote model, unless `config.fetch.cache` is set to a directory, in which case the cached body is read instead and no request is made: see `AIKB/fetch-policy.md`) and controllers re-run; `_replaying` also propagates as `fresh` into `applyController`, which busts Node's module caches so an edited controller file reloads (see `AIKB/controller-resolver.md`). The build dir is _not_ cleaned by a replay (that would blow away assets and leave the site briefly empty for the dev server); instead the replay snapshots the previous stack's `buildTo` paths up front and, in a `finally` around `complete()`, removes any that the new stack no longer contains — plus the same-named `.json` dev debug sibling. So a page whose slug changed, or one that vanished from a shrinking `.pages()` fan-out, no longer leaves stale output behind. The removal runs in a `finally` so a failed rebuild still cleans up — but only one that failed **after** it re-queued the site. The `try` now opens right after `previous` is captured and wraps the whole re-registration half (`registerPartials()`, the drop-vanished-registrations filter, the re-scan, the `page()` loop) as well as `complete()`, and a `requeued` flag flips once the `page()` loop has run. Two reasons. (1) A throw in that half used to skip the `finally` altogether, so `previous` was lost and every stale output stayed on disk for the life of the process — no later replay could see it, because each one snapshots `previous` from a `_stack` that no longer mentions those files, and in dev mode the server went on serving a page that had been deleted. (2) Simply widening the `try` is not enough on its own: with `requeued` still false the new stack is empty because the replay _failed_, not because the site shrank, so an unguarded sweep would delete the entire build. So a replay that throws before re-queuing sweeps nothing and parks `previous` on `_unswept`, which the next replay folds back into its own `previous` (deduped) and sweeps then; the last good build stays on disk in the meantime and the failure is reported as `Error rebuilding site` as before. Once the pages are re-queued the old contract holds unchanged: every page that should exist has a `buildTo` in the stack (`_preparePage` runs before the render), so `previous` minus the stack is genuinely stale even when the rebuild then fails while rendering. **The sweep is only atomic at the rebuild's edge, not per page**: it removes the output and then its `.json` sibling one `await` later, and works through `previous` one entry at a time, so between those awaits the build dir is legitimately half-swept. The completion signal is the rebuild queue settling (`_rebuildInFlight`), never an individual file disappearing — anything that reacts to one removal and then reads another (a test, a deploy step) is reading a state the engine never promised. That is what made `test/integration/watch.test.js`'s deleted-scanned-view test load-sensitive: it waited on `gone.html` and then asserted on `gone.json`.
70
74
  - A replay re-runs `sitemap()` when one was ever requested: `sitemap()` records `_sitemapRequest = { options, callback }` (idempotent — the replay's own call re-records the same request), and `_replay()` re-issues it right after `generate()` so `complete()` drains it and `sitemap.xml` matches the new stack. v1 (and v2 before this) left `sitemap.xml` stale after a slug change in watch mode. Like the `generate` callback, the `sitemap` callback re-fires on every watch rebuild — once per replay, with that rebuild's `urls`, not just on the original call.
71
75
  - `_replaying` is read _synchronously_ at the top of `page()` — both to skip re-logging the registration and to capture `fresh` for the async chain that runs later. That is why `_replay()` can reset the flag in a `finally` immediately after the registration loop, even though the chains it started are still pending.
@@ -92,17 +96,19 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
92
96
 
93
97
  - **The watch dispatch lives here, not in the watcher** (`_handleChange(event, path)`), because deciding needs the stack, the partials registration and the rebuild queue — all of which are `Kiss` state. The watcher forwards every event under `src` and this is the whole table:
94
98
 
95
- | Event | Action |
96
- | --------------------------------------------------------- | ------------------------------------------------ |
97
- | `add` / `addDir` (after the watcher's own `ready`) | `_requestReplay()` |
98
- | `unlink` under `folders.pages` | `_requestReplay()` |
99
- | `change` under `folders.partials` / `folders.layouts` | `registerPartials()` + `_requestRebuild(_stack)` |
100
- | `change` of a page view matching stack entries | `_requestRebuild(matches)` |
101
- | anything else (model, controller, unknown file, no match) | `_requestReplay()` |
99
+ | Event | Action |
100
+ | --------------------------------------------------------- | ------------------------------------------------------------------- |
101
+ | `add` / `addDir` (after the watcher's own `ready`) | `_requestReplay()` |
102
+ | `unlink` under `folders.pages` | `_requestReplay()` |
103
+ | `change` under `folders.partials` / `folders.layouts` | `registerPartials()` + `_requestRebuild(_graph.dependentsOf(name))` |
104
+ | `change` of a page view matching stack entries | `_requestRebuild(matches)` |
105
+ | anything else (model, controller, unknown file, no match) | `_requestReplay()` |
102
106
 
103
107
  A replay is the default and the fallback: only the two scoped rows are narrower than one, and each is provably so.
104
108
 
105
- - **The partial/layout fast path is safe without a dependency graph.** An edited partial or layout cannot change `_registrations`, `_stack`, any page's resolved options, its `buildTo`, or the sitemap — the only thing a replay would do differently is _which_ pages it re-renders, and this re-renders all of them. So the stack is unchanged by construction, orphan cleanup, the `buildTo` dedupe and `_failures` are untouched, and what is skipped is exactly the expensive half of a replay: every model re-read from disk, every controller re-imported with a cache bust, every URL model re-fetched over the network (or read from `config.fetch.cache`). That is why the path re-renders the whole stack rather than trying to work out who used the partial — knowing that would need a traced graph, and would only ever save render time on top of this (`planning/specs/2026-09-05-watch-dependency-graph-design.md`, steps 2 and 3). `test/integration/replay-equivalence.test.js` is the standing proof: the same edit sequence applied through the real dispatch and through forced replays must leave byte-identical build directories.
109
+ - **The partial/layout fast path is safe.** An edited partial or layout cannot change `_registrations`, `_stack`, any page's resolved options, its `buildTo`, or the sitemap — the only thing a replay would do differently is _which_ pages it re-renders. So the stack is unchanged by construction, orphan cleanup, the `buildTo` dedupe and `_failures` are untouched, and what is skipped is exactly the expensive half of a replay: every model re-read from disk, every controller re-imported with a cache bust, every URL model re-fetched over the network (or read from `config.fetch.cache`). `test/integration/replay-equivalence.test.js` is the standing proof: the same edit sequence applied through the real dispatch and through forced replays must leave byte-identical build directories.
110
+ - **A partial or layout `change` re-renders the pages that rendered it.** `_handleChange` re-registers the partials, maps the path to a name with `partialNameFor`, and asks `_graph.dependentsOf(name)` (`AIKB/dependency-graph.md`): the matching stack entries go to `_requestRebuild`, through the same serial queue as every other rebuild. `null` — a partial no page has recorded, because nothing has rendered it yet or only a failed page would have — re-renders every entry and logs a notice naming the file, so an untraced edge shows up as a fixable gap rather than silent slowness. `[]` re-renders nothing, which is right: no page used it. A partial edit that arrives while a rebuild is in flight is still upgraded to a replay (below). Measured before the graph existed (diploma-msc, 654 pages): a partial save re-rendered everything in 1247ms; a page render is ≈1.9ms, so a partial N pages use now costs ≈2N ms plus registration.
111
+ - **The graph is per instance and cleared on replay.** `_graph` is a `DependencyGraph` field, handed to `registerPartials` (which records) and to every `KissPage` (which clears its own edges before rendering). `_replay()` clears it with the stack; every page re-records itself as it renders. In `dev: true` + `verbose: true`, `_finishBuild` writes `dependency-graph.json` (`{ partial: [buildTo…] }`, sorted) beside `debug.json`; each page's `.json` sibling lists its own `partials`.
106
112
  - **A partial edit arriving while a rebuild is in flight is upgraded to a replay** (the `_rebuildInFlight` check inside the fast-path branch of `_handleChange`). Whether the run already in flight read the file before or after the edit landed is a race nobody can reason about — the render is synchronous but the registration that fed it may not be — so the pessimistic answer is the only one that can be justified. It costs one full rebuild in the rare overlapping-save case and removes a class of "the dev server is showing the old partial" bug.
107
113
  - **`_rebuild(entries)` skips any target `_stack` no longer holds.** A scoped target queued before a replay names a page from the stack that replay discarded; re-rendering it would write from options nothing holds any more, and its `buildTo` may since have been swept as an orphan. The skip is silent — it is the expected outcome of "a replay supersedes scoped work", not a fault.
108
114
  - `close()` quiesces before tearing down (review finding B6): it sets `_closing` — honoured at the top of the queue, so no new run starts — closes the watchers first so no new requests arrive, then awaits the in-flight slot _in a loop_ (the pending slot can start one more run before the flag is seen), and only then closes the dev server. Without it a rebuild requested just before `close()` kept running and writing, so a caller's clean-or-deploy step raced a build it believed had stopped — in the repro the orphaned replay recreated the build directory after it was removed. `_closing` is never cleared: a closed instance stays closed, so calling `watch()` again on one would watch without ever rebuilding.
package/AIKB/partials.md CHANGED
@@ -6,12 +6,13 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `registerPartialsFrom(hbs, folder, ext, { markdown, logger })` → array of registered partial names. Globs `${folder}/**/*.${ext}`, reads each file, renders it through `markdown.render()` first if `ext === 'md'`, and calls `hbs.registerPartial(name, source)`. Returns `[]` immediately if `folder` is falsy.
9
+ - `registerPartialsFrom(hbs, folder, ext, { markdown, logger, graph })` → array of registered partial names. Globs `${folder}/**/*.${ext}`, reads each file, renders it through `markdown.render()` first if `ext === 'md'`, and calls `hbs.registerPartial(name, …)` with a compiled, recording function (below). Returns `[]` immediately if `folder` is falsy.
10
10
  - `registerPartials(hbs, config, deps, previous = [])` → array of every registered partial name, calling `registerPartialsFrom` four times in sequence: `partials/**/*.html`, `partials/**/*.md`, `partials/**/*.hbs`, then `layouts/**/*.hbs`. `previous` is the array a prior call returned; every name in it that this pass did not produce is `hbs.unregisterPartial`ed before the new names are returned.
11
+ - `partialNameFor(file, folders)` → the name `registerPartialsFrom` would derive for `file` (native or posix path), or `null` when it is under neither `folders.partials` nor `folders.layouts`.
11
12
 
12
13
  ## Depends on
13
14
 
14
- `fs-extra`; `./utils.js` (`globFiles`, `posixPath`). The markdown renderer and logger are passed in via `deps`, not imported.
15
+ `fs-extra`; `./utils.js` (`globFiles`, `posixPath`). The markdown renderer, the logger and the dependency graph (`deps.graph`, `AIKB/dependency-graph.md`) are passed in via `deps`, not imported.
15
16
 
16
17
  ## Depended on by
17
18
 
@@ -21,11 +22,13 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
21
22
 
22
23
  - A partial's registered name is its path relative to the globbed folder with the extension stripped (leading `/` trimmed) — e.g. `partials/nav/header.hbs` under `folders.partials` registers as `nav/header`.
23
24
  - `.md` files are rendered to HTML _at registration time_, not at render time — editing a Markdown partial requires re-registering (i.e. a rebuild), not just re-rendering the page.
25
+ - **Registration-time rendering is also why `config.markdown` exists.** The `markdown` renderer arrives through `deps` already configured, and whatever options it carries at that moment are baked into every `.md` partial's HTML. `Kiss` therefore builds its Remarkable from `config.markdown` _before_ calling this module; changing the renderer afterwards needs a fresh `registerPartials()` pass to take effect on partials, even though the `{{markdown}}` helper would pick it up immediately. See `AIKB/config.md`.
24
26
  - **The registered set mirrors disk, and that is why all four passes re-run.** Handlebars keeps a registration until it is explicitly unregistered, so before the fix a partial deleted from disk kept rendering its last-known content for the life of the process — dev and a fresh production build disagreed (review finding B1). The recipe is: re-run all four passes, then unregister every name the previous pass produced that this one did not. It cannot be done per file. `foo.html`, `foo.md`, `foo.hbs` and a layout `foo` all derive the name `foo` and the last pass wins (below), so unregistering by file would delete a winner that is still on disk, and re-registering by file would install a loser. The caller owns the "previous names" state: `Kiss.registerPartials()` keeps the last returned array on `_partialNames` and passes it back in.
25
27
  - A page that still references a deleted partial now fails to render (`The partial foo could not be found`) instead of rendering a ghost. Under `watch()` that surfaces as a replay failure via the logger, and the page's stale output is left in place — see `AIKB/kiss.md`.
26
28
  - Registration order is html, then md, then hbs (from `partials/`), then hbs (from `layouts/`) — since `hbs.registerPartial` overwrites by name, a layout `.hbs` with the same name as a partial `.hbs` wins, and later-registered extensions in general win over earlier ones for a colliding name.
27
29
  - `registerPartialsFrom` returns `[]` (not an error) when `folder` is falsy, matching `config.js`'s "folders may be `null`" contract — a `null` `folders.layouts`, for example, silently skips layout registration.
28
30
  - Partials are registered on the _instance_ environment (`kiss.handlebars`), never the global `handlebars` module. A consumer helper that does `require('handlebars').partials[name]` (v1 sites did this to render a partial chosen by name — diploma-msc's `renderPartial`) silently gets `undefined` in v2. The migration notes point such sites at `kiss.handlebars.partials` or Handlebars' built-in dynamic partial syntax `{{> (lookup this "name")}}`, which needs no helper at all (verified against diploma-msc on 2026-09-04).
29
- - **Layouts are registered compiled; every other partial stays a source string.** `handlebars-layouts`' `extend` helper reads `handlebars.partials[name]`, and when it finds a string it compiles it — without ever writing the compiled function back. A layout was therefore recompiled from source on _every page render_, which made it the single largest item in a build's CPU profile: 500 compiles of one file in a 500-page build (1.18ms each on the benchmark's 441B layout — 588ms of a 1291ms build; 3.01ms on diploma-msc's 6.4KB layout, ~2s across its 669 pages). Registering `hbs.compile(source)` makes the helper's `typeof template !== 'function'` test false, so it uses the function as-is. Measured: `fanout@500` build **-27.1%**, `models@500` **-15.7%**, `watch@500` **-17.5%**, `scan@500` **-10.7%**, and a watch-mode replay after a model edit **455ms → 172ms**.
30
- - **Only layouts, and that restraint is the compatibility contract.** `hbs.partials` is observable — consuming sites read it in their own helpers, and at least one (`learna-kiss`'s `getSiteSpecificPartial`) calls `handlebars.compile(partial)` unconditionally, which throws when handed a function. A layout is not reachable that way in any site surveyed: none has a partial sharing a layout's name. Regular `{{> partial}}` lookups are already compiled once and cached by Handlebars itself, so converting them would win nothing and risk exactly that breakage. `test/unit/partials.test.js` pins the split — layout a function, `.hbs`/`.html`/`.md` partials strings.
31
- - **`hbs.compile` is lazy**, returning a wrapper that parses on first invocation. So a layout that is never rendered costs nothing at registration, and a layout that will not parse still fails at render against the page that used it — registering eagerly moves no failure earlier, and there is no error handling to add.
31
+ - **Every partial is registered as a compiled, recording function** (`tracingPartial`). Handlebars calls a function partial as `partial(context, options)` and handlebars-layouts' `extend`/`embed` as `template(context, { data })`; the wrapper reads `options.data.kissPage` — the page identity `KissPage.generate()` puts in the render's data frame — records `(page, name)` on the injected `deps.graph` (`AIKB/dependency-graph.md`), and returns exactly `compiled(context, options)`. The return contract matters: `undefined` makes Handlebars try to compile the function, and a `SafeString` breaks an indented partial call, which splits the result on newlines. A recorder that throws is swallowed; tracing never affects rendering. Without a `graph` in `deps`, or without a page id in the data, the wrapper is a plain compiled partial.
32
+ - **Layouts keep their earlier win**: compiled once rather than per `{{#extend}}` — measured when that change landed: `fanout@500` **-27.1%**, `models@500` **-15.7%**, `watch@500` **-17.5%**, `scan@500` **-10.7%**, a watch replay after a model edit **455ms → 172ms**. Registering every partial compiled costs nothing extra: `hbs.compile` is lazy, so an unrendered partial is never parsed and a partial that will not parse still fails at render against the page that used it.
33
+ - **`hbs.partials[name]` is a function always** — the compatibility line moved. It was a string only until the first render anyway (Handlebars compiles a string partial on first use and writes the function back into the same object — `mergeIfNeeded` returns `env.partials` uncopied), so a consumer helper reading an entry has always had to accept both; `learna-kiss`'s `getSiteSpecificPartial` calls `handlebars.compile(partial)` unconditionally and would have broken on any partial after its first render. The one-line form is in `llms.txt` and `README.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`. **Such a helper must pass its own `options.data` through**: that data frame is how the invoking page is recorded as using the partial, so a bare `p(ctx)` renders correctly but leaves the page unrecorded — under `watch()` it is not re-rendered when the partial changes (unless no page recorded that partial at all, when every page is). `examples/9-migrated-from-v1.js`'s `renderPartial` is the worked recipe.
34
+ - `partialNameFor(file, folders)` is the only place a file path becomes a partial name outside registration, and it reuses the same slice-and-strip; `Kiss._handleChange` calls it so a chokidar path maps to the name the graph knows. A collision (`foo.md` and `foo.hbs`) maps to one name from either path, which is right: whichever file changed, the registered `foo` is what pages invoked.