kiss-ssg 2.2.1 → 2.3.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 (49) hide show
  1. package/AIKB/aikb.md +4 -0
  2. package/AIKB/check.md +3 -2
  3. package/AIKB/config.md +2 -1
  4. package/AIKB/handlebars-helpers.md +3 -1
  5. package/AIKB/kiss.md +4 -2
  6. package/AIKB/links.md +1 -1
  7. package/AIKB/partials.md +1 -1
  8. package/AIKB/pipeline.md +4 -0
  9. package/AIKB/testing.md +5 -4
  10. package/README.md +63 -29
  11. package/examples/1-scan/router.js +32 -0
  12. package/examples/10-asset-pipeline/assets/css/generated.css +1 -1
  13. package/examples/10-asset-pipeline/pages/index.hbs +1 -1
  14. package/examples/{10-asset-pipeline.js → 10-asset-pipeline/router.js} +27 -13
  15. package/examples/10-asset-pipeline/tools/tokens.js +2 -2
  16. package/examples/11-blog/AIKB/last-build.json +21 -21
  17. package/examples/11-blog/AIKB/notes/controllers/tag.md +2 -2
  18. package/examples/11-blog/AIKB/site-map.json +82 -82
  19. package/examples/11-blog/AIKB/site-map.md +38 -38
  20. package/examples/11-blog/config/site.js +30 -0
  21. package/examples/{11-blog.js → 11-blog/router.js} +24 -22
  22. package/examples/{2-page.js → 2-page/router.js} +6 -9
  23. package/examples/{3-pages.js → 3-pages/router.js} +6 -9
  24. package/examples/4-layouts-and-partials/pages/index.hbs +0 -4
  25. package/examples/{4-layouts-and-partials.js → 4-layouts-and-partials/router.js} +7 -10
  26. package/examples/5-helpers/pages/index.hbs +1 -1
  27. package/examples/{5-helpers.js → 5-helpers/router.js} +9 -10
  28. package/examples/{6-sitemap.js → 6-sitemap/router.js} +7 -10
  29. package/examples/{7-versioned-outputs.js → 7-versioned-outputs/router.js} +9 -8
  30. package/examples/{8-data-fed-site.js → 8-data-fed-site/router.js} +8 -14
  31. package/examples/9-migrated-from-v1/AIKB/last-build.json +21 -21
  32. package/examples/9-migrated-from-v1/AIKB/site-map.json +46 -46
  33. package/examples/9-migrated-from-v1/AIKB/site-map.md +34 -34
  34. package/examples/9-migrated-from-v1/helpers/index.js +16 -0
  35. package/examples/9-migrated-from-v1/helpers/partials.js +33 -0
  36. package/examples/{9-migrated-from-v1.js → 9-migrated-from-v1/router.js} +19 -30
  37. package/examples/README.md +36 -19
  38. package/examples/_shared/layouts/layout.hbs +0 -4
  39. package/examples/_shared/site.js +6 -12
  40. package/lib/aikb.js +35 -5
  41. package/lib/check.js +67 -0
  42. package/lib/config.js +22 -5
  43. package/lib/handlebars-helpers.js +29 -5
  44. package/lib/kiss.js +12 -1
  45. package/llms.txt +45 -8
  46. package/package.json +13 -12
  47. package/types/check.d.ts +34 -0
  48. package/types/config.d.ts +7 -0
  49. package/examples/1-scan.js +0 -33
package/AIKB/aikb.md CHANGED
@@ -39,6 +39,10 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
39
39
 
40
40
  ## Non-obvious behavior
41
41
 
42
+ - The dangling-reference check accepts **a page id** and rejects **a bare file extension**. A page id (`shelf/index`) has a slash and no extension, so it looked exactly like a path to a missing file — while being the single most natural thing for a note to cite, since it is the name the whole site links that page by and what the site map's `Id` column is for. A bare `` `.json` `` in a sentence like "one `.json` record per page" is prose about a kind of file and can never resolve. Four independent reports across three sites, every one of them a note written correctly and punished for it; a lint that fires on good prose trains people to stop reading it. Covered by `test/unit/aikb.test.js`.
43
+
44
+ - Note lookup is **case-tolerant on read, canonical on write**. `notePathFor` lower-cases the filename it derives, but the README the engine generates states the rule as "the controller's filename without `.js`" — so an author following it writes `jobList.md` for `jobList.js` while the engine looked for `joblist.md`, and `evaluateNotes` reported the same note `missing` _and_ `dead`. Two findings for one casing difference, and unactionable: renaming to clear either one creates the other. `evaluateNotes` now matches a note on disk case-insensitively, while a genuinely missing note is still reported at the lower-cased path, which stays the single spelling the engine writes and documents. Two notes differing only in case cannot coexist on a case-insensitive filesystem; `present` is sorted, so which one answers is stable. Covered by `test/unit/aikb.test.js`.
45
+
42
46
  - **A note may name the engine's own outputs.** `sitemap.xml`, `llms.txt`, `feed.xml` and `_redirects` are written into the build beside the pages and appear in no map list, so `dangling` accepts them by name; any other token is also accepted when it exists under `site.build` on disk. Without this every note that said "the feed" in backticks was a false positive, and a lint that fires on true statements is one people switch off.
43
47
  - **A record is the only writer, and it only ever records a build that worked.** `_buildAikb()` writes when `KISS_AIKB` is on, `config.dev` is false and `failures.length === 0`; anything else evaluates the notes and reports `written: false`. A refused record says so on the log — the operator is standing there waiting for the folder to change, and silence would read as success. The consequence to keep in mind is that the folder describes the _last build that passed_, not the working tree: a failed `kiss-ssg aikb` leaves the previous record in place, which is exactly what makes the next diff meaningful.
44
48
  - **`site-map.json` is the opt-in marker, not the folder.** `isRecorded()` looks for that one file because only a record ever writes it. A folder that merely exists proves nothing: this repository's own `AIKB/` is hand-written module notes, and `docs.js` builds a site inside it with the default `folders.aikb` — if existence were the test, the docs build would adopt those notes as a site map and start reporting every one of them as `dead`.
package/AIKB/check.md CHANGED
@@ -12,8 +12,8 @@ Two commands share all of it. `check <script>` runs the site's build staged and
12
12
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
13
13
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
14
14
  - `readReportsFile(text)` → the same `BuildReport[]`, from any of the three shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, or a single report object. What `--against` reads.
15
- - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged }`, the three lists holding output paths, sorted. Pure.
16
- - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged`. Indented to sit under that site's `formatReport` line.
15
+ - `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure.
16
+ - `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line.
17
17
  - `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
18
18
  - `recordedLine(report)` → the line `aikb --summary` prints under a site: ` recorded <folder>`, ` not recorded — build failed`, ` not recorded — folders.aikb is null`, or ` not recorded`.
19
19
  - `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
@@ -42,6 +42,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
42
42
  - **The bin reads the `--against` file before it spawns the build**, so a mistyped filename costs nothing and prints as the usage error it is (`kiss-ssg: cannot read --against file <path>`, then `HELP`, exit 1) rather than after a minute of building.
43
43
  - **`check` diffs without being asked, and `aikb` does not.** With no `--against`, the bin asks `defaultBaseline(reports)` where the site's own snapshot would be and diffs against it if the file is there — so the ordinary question ("what have I changed since this work opened") is answered by the ordinary command, and the answer is measured from the last _record_, not from the last build anybody happened to run. A check writes nothing, so the file it reads is the one the last record left. `aikb` skips the fallback for the opposite reason: by the time it could read that file it has just overwritten it, and a diff against yourself is `= N unchanged` every time. Naming `--against` explicitly always wins, under either command.
44
44
  - **The first report with a folder wins the baseline.** A script that builds several sites writes one report each, but they share a working tree and therefore one knowledge base; `diffReports` pairs the file's contents with this run's reports by `buildDir`, so a single baseline covers all of them.
45
+ - **The asset rows exist because a page diff can have no cause in any page source.** Under `assets.hash` a stylesheet's filename _is_ the hash of its bytes, so one changed asset rewrites the `<link href>` of every page that references it: the diff reads `~ <every page>, = 0 unchanged`, and a reader who trusts it goes looking through page sources, models and controllers for a change that is not there. Naming the asset that moved turns that into a one-line read — and the rows go **above** the page list, because a cause printed after two hundred rows of consequence is reached only by the reader who already scrolled past the thing it explains. Verified in situ on a 20-page site with a 91-entry manifest before the order was settled. Both sides of the comparison were already on disk — `BuildReport.assets` is the manifest (`AIKB/build-report.md`), and `last-build.json` keeps it — so this needed no record-format change and no new build work. Three deliberate limits: only assets **both** builds emitted are rows (a new one has no previous name to have moved from, and is the asset copy working rather than a fact that explains anything); nothing prints when no page moved, because the rows explain a diff rather than being one; and the list caps at ten as a safety valve rather than a display limit — only `.css`/`.js` are hashable at all, and under `assets.version` nothing is renamed so every entry records `target === source` and no row can ever fire, which means a real site moves one or two and the cap only bounds a site that bundles per page. **The limitation worth knowing rather than fixing:** because it compares targets, an _unhashed_ asset whose bytes changed — an edited image — is not a row, since its `source` and `target` are both unchanged. That is the right trade: an unhashed asset is not referenced by a name that appears in every page's HTML, so it never produces the all-pages-changed symptom these rows exist to explain. Found by a consuming site whose whole-site diffs were being caused by its stylesheet — see the Tailwind notes in `AIKB/pipeline.md`.
45
46
  - **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
46
47
  - **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
47
48
  - **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples && node 9-migrated-from-v1.js`), so moving it would check a different set of paths from the ones it builds with.
package/AIKB/config.md CHANGED
@@ -11,7 +11,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
11
11
  - `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
12
12
  - `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
13
13
  - `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
14
- - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself).
14
+ - `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), and `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`).
15
15
  - `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed, trailing slashes stripped.
16
16
  - `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on a build folder that contains the source folder (all three below).
17
17
  - `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
@@ -55,6 +55,7 @@ The typedefs that describe the config to a consumer live at the top of this modu
55
55
 
56
56
  - **`markdown` is a passthrough, not a schema.** The three keys in `DEFAULT_MARKDOWN` are the ones kiss has an opinion about, not the ones it accepts: `resolveConfig` merges the block one level deep and `lib/kiss.js` spreads the result straight into `new Remarkable(...)`, so `typographer`, `langPrefix` or any other Remarkable option reaches the renderer without kiss knowing the name. That is why the typedef is `KissMarkdown & Record<string, any>` rather than a closed shape — unlike `folders`, where a typo must be an error, an unknown key here is a legitimate renderer option.
57
57
  - **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
58
+ - **`links.canonical` is off by default, and that is the whole design decision.** Turning it on changes the URL every bare `{{link}}` emits, which is a site's entire internal link graph and every `href` in its published output — a major-version change, not a default. It exists because the alternative discipline does not scale: a site that is not `extensionLess`, served by a host that redirects `/about.html` to `/about`, needs `canonical=true` on every single call or it ships a redirect hop per link, and one forgotten call is silent in dev and visible only on the deployed site. A real consuming site was writing `canonical=true` sixty-six times and carrying a `CLAUDE.md` paragraph explaining that it was mandatory. One key here replaces that. The `extensionLess: true` route solves the same problem and is better greenfield, but it changes every output path and therefore the relationship between the built files and the `aliases` a site carries for its old URLs — which is why this is the key and not a nudge towards that flag. Landing it needed no new config surface: the block was already shaped for exactly this by the comment above it.
58
59
  - **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `markdown`, `links`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
59
60
  - **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.
60
61
  - `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
@@ -16,7 +16,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
16
16
  - `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
17
17
  - `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
18
18
  - `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A 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
- - `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit, and it wins if both are set. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
19
+ - `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
20
20
  - `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
21
21
 
22
22
  ## Depends on
@@ -29,6 +29,8 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
29
29
 
30
30
  ## Non-obvious behavior
31
31
 
32
+ - `isActive`'s block context is **the surrounding context, then the hash, then the helper's own keys** (`href`, `folderMatch`, `active`, `pageURL`). It was the hash alone, which meant the natural data-driven nav — `{{#each nav}}{{#isActive ../page href=href}}{{label}}{{/isActive}}{{/each}}` — rendered an empty label for every item on a green build, and nothing could warn: the helper cannot tell a key the caller forgot from one it never wanted. Two independent agents building a fresh site hit it within a day of each other; one shipped a nav of blank links and found it only by reading the rendered HTML. Adding the parent underneath is additive — anything that already passed what it needed is unaffected, because the hash still wins. Covered by `test/unit/handlebars-helpers.test.js`.
33
+
32
34
  - 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.
33
35
  - 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).
34
36
  - `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}}`.
package/AIKB/kiss.md CHANGED
@@ -36,6 +36,8 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
36
36
 
37
37
  ## Non-obvious behavior
38
38
 
39
+ - `_preparePage` takes `extLess` from **the page's** resolved config (`options.config?.extensionLess`), not the instance's, falling back to the instance when a page has none. `page()` merges the global config with the page's overrides before the page is prepared, so this is the resolved value for that page. It matters because a site that is `extensionLess` everywhere still owes its host one literal `404.html` — Netlify and Cloudflare Pages look for that exact filename and do not fall back to `404/index.html`. Before this, an `options.config` override naming `extensionLess` was accepted and silently ignored, which is the worst shape of bug: the config is documented as per-page, the page took it, and the output path came out the other way. Covered by `test/integration/canonical.test.js` § per-page extensionLess.
40
+
39
41
  - 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.
40
42
  - **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`), `llms.txt` (`writeLlms`, same route), 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.
41
43
  - **`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.
@@ -57,7 +59,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
57
59
  - **A production build starts loading the HTML minifier in the constructor.** `preloadMinifier()` (`AIKB/kiss-page.md`) is called, unawaited, as soon as the config is resolved and `config.dev` is known to be false — before `_setupFolders()`, the asset copy and partial registration — so the minifier's ~155ms import overlaps that work and model resolution instead of sitting on the critical path at the first render. Check mode forces `dev: false` first, so a check preloads too. Dev never minifies and never loads it.
58
60
  - **The link scan runs in `complete()`'s settle path, above the branch — not in `_finishBuild()`.** `_checkLinks()` is the only consumer of `lib/links.js` here, and where it is called is the whole design. By the time `_finishBuild()` runs, two of `complete()`'s three branches have already called `_discardStaging()` (check mode, and any failed build) and the third has already called `_promote()` — so a scan that resolved against `config.folders.build` there would have found an empty or renamed folder and reported `checked: 0, broken: []` in silence, under exactly the command the finding exists to serve. Called before the branch instead, the written pages are still where they were written, whichever way the build is about to settle; one call site dominating all three arms is also what keeps a fourth from quietly forgetting it. It needs no ordering against `sitemap.xml`, `llms.txt` or a feed: those are written on the promise queue, which `_settle()` has already drained. The references themselves were extracted at **write** time (`KissPage.links`, `AIKB/kiss-page.md`), so nothing is read back from disk; the filesystem is consulted only for the rest of the build folder — assets, those generated files, anything a pipeline step wrote straight into it. The scan is skipped entirely when `config.links.check` is false or `config.dev` is on (and therefore on every watch rebuild: a scoped re-render has not rewritten every page, so a scan would be describing a half-built site), leaving `_links` `null` — which the report distinguishes from "it ran and found nothing". The result is latched on `_links` for the reason `_report` is latched: the nested-`complete()` pattern settles one build twice, and the second pass would be scanning a folder that is no longer there. `buildDir` is `config.folders.build` **as it is at that moment** — the staging sibling under `'atomic'` and under check — and the pages are recorded as the staging paths they were written to, which is why `buildReport` maps `broken[].page` back through `reportedPath` and the report names the folder the site asked for. Pages are taken from `_stack` entries whose `page.hash !== null`: that is the "these bytes are on disk" promise, and `generate !== false` is not (a page can also have failed to render). A build that failed is scanned too — its surviving pages are on disk and their dead links are real — and the finding moves neither `ok` nor the exit code. One `notice` per broken link, in `formatReport`'s own wording, so the build log and `check --summary` say the same thing; one `info` line when there is nothing to report.
59
61
  - **`_redirects` is written in the same settle path, one line above the scan, and the findings are not.** There is no `.redirects()` method: an alias is a property of a page, not a file the author asks for, so the write has to be automatic — and the only two honest places for it are the promise queue and here. The queue (`Promise.all(this._promises)` pushed onto `_generating`, the `.sitemap()` shape) needs a call site, and the only one that exists on every build is the constructor, where `_promises` holds the asset copy and not one page: the write would run against an empty stack. So `_writeRedirects()` sits at the top of `complete()`'s settle path instead, above the three-way branch, where `_settle()` has already drained the queue (`sitemap.xml`, `llms.txt` and the feed are on disk) and nothing has yet been discarded or promoted. That ordering is load-bearing twice over: written after `_promote()` the file would land in the **promoted** folder, breaking the "the whole folder appears at once" promise with a window where the site is live with none of its redirects; written after `_discardStaging()` it would _resurrect_ the staging folder `fs.outputFile` creates parents for, leaving a stray `<build>.kiss-staging-…` beside published output under `kiss-ssg check`. It also comes free on replay, because `_replay()` ends in `complete()` — which is why `_redirectsRun`, the per-build promise latch that stops the nested-`complete()` pattern writing the file twice, is reset there beside `_redirectsPath`. Unlike the link scan it runs in dev too: nothing serves `_redirects` locally, but it costs one small file and keeps "what the build folder holds" from depending on the mode, where a scoped re-render genuinely has not rewritten every page. A write that rejects is a **build failure**, not an advisory: the `.catch` pushes `{ view: '<redirects>', buildTo: null, error }` onto `_failures`, one line above the settle branch that reads it, so the staging folder is discarded and the previous deployment stays live. That is where `_redirects` parts from `sitemap.xml`, `llms.txt` and the feed, whose write failures are logged and swallowed — a site published without its sitemap is a site search engines index a little later, but a site published without its redirects is a site whose old URLs 404, which is the loss the aliases exist to prevent. The **findings** are pure data and stay in `_finishBuild()`, before `buildReport`: `_redirectFindings()` re-collects the rules from the stack (the same pure function over the same stack, so the file and the count cannot disagree), reads the baseline through `_lastBuildRecord()` and latches `{ file: _redirectsPath, aliases, removed, collisions, moved }` on `_redirectsResult`, or `null` when there is nothing to say — no alias and no finding, which since `moved` landed means `rules.length === 0 && removed.length === 0 && moved.length === 0` (a move is the one finding a site with no alias anywhere can still have). The pages it hands `redirectFindings` are `_stackForRecord()`'s, not the raw stack's, so the `id` the pairing keys on is the same projected id the report and the site map show — a default id withdrawn from two pages is `null` on both sides and cannot pair with an explicit id of that name in the baseline. Each move logs one `notice` carrying its fix (`moved without redirect: <from> -> <to> (<id>) — add "<from>" to that page's aliases`), where the summary line prints the finding without the fix. That last rule is what keeps `last-build.json` byte-identical across two identical records: an empty verdict issued whenever a record merely _exists_ would make a site's first record (written before there is a baseline) differ from its second (written after) with nothing about the site having changed. The baseline is gated on `<folders.aikb>/last-build.json` **existing**, never on `isRecorded()` — that answers "is there a `site-map.json`", which `_buildAikb()` wrote one line earlier, so on a site's first `kiss-ssg aikb` it is true while the record is still absent — and it is read _before_ `writeLastBuild()` replaces it, so a recording run is measured against the previous record rather than against itself. An unreadable record is no baseline rather than a build failure. One `notice` per finding, in `formatReport`'s wording; neither moves `ok` or the exit code. `AIKB/redirects.md` has the derivation half.
60
- - **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` below). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
62
+ - **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs/router.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` below). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
61
63
  - **A staged build's `folders.build` is genuinely the staging path while it runs**, so a template rendering `{{config.folders.build}}` (or a controller reading it) sees the staging name, not the target. Nothing in the engine surfaces it — `{{asset}}` renders build-relative paths and `{{canonical}}`/`sitemap.xml` are built from `siteUrl` — but a site that prints the raw folder into its output should not use `'atomic'` for that page.
62
64
  - Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N page(s) failed to build: <buildTo paths>"` — `buildTo`, not `view`, because `.pages()` fan-out gives every failed entry the same `view` and a message built from `view` would repeat it; `err.failures[]` still carries `view` per entry). Entries with no output path — a controller failure, or a callback failure — fall back to `view` in that message. `generate()`'s own callback still fires either way — callbacks are not the render error channel; `complete()` is. `complete()` does not invoke its callback on failure (same rule as `sitemap()`'s callback).
63
65
  - **`complete()` reports a build's failures once** (`_failuresReported`, reset with `_failures` in `_replay()`): the first call with a non-empty `_failures` rejects, every later call in the same build resolves with the data instead. It used to re-reject, and the documented "`complete()` from inside a `generate` callback" pattern (below) has two calls draining the same build concurrently — the loser's rejection had no handler attached to it and killed the process after the consumer had already caught and reported the winner's (review finding A-01). The outer call wins in practice: its `_drain()` registered its reactions first, so it stays one microtask ahead of a call started from inside the callback.
@@ -89,7 +91,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
89
91
  - `_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.
90
92
  - `_promises` and `_generating` are push-only _within a build_ — never spliced, only reassigned wholesale by `_replay()`. So repeated `complete()` calls inside one build re-await every promise ever queued (all already settled, so cheap), and a long-running `watch()` session no longer grows them without bound: each rebuild starts from empty arrays.
91
93
  - `_drain()` waits on `_promises`, `_generating` **and `_callbacks`** — the promises `_runCallback` collected from `async` callbacks — re-checking the combined length between rounds. Tracking the callback itself is what makes work queued after an `await` inside it part of the build (review finding A-03): before that, the `while` loop only saw array-length growth that had already happened, so `complete()` resolved while an awaiting callback had not yet queued its pages. What still escapes it is work deferred by a callback that returns nothing to track — a `setTimeout`-ed `.page()` from a _synchronous_ callback queues on a later macrotask with no promise the engine can see.
92
- - **`complete()` renders what the drain leaves unrendered.** `_settle()` alternates `_drain()` and `_generatePending()` — which renders every stack entry still at `runCount === 0`, incrementing the count as it picks each one so a concurrent pass cannot render it twice — and loops until a drain leaves nothing pending. A page prepared after the last `generate()` pass iterated the stack (a `scan()` from a `generate` callback, as `examples/3-pages.js` does; or a page queued after `generate()` whose model resolved after that run's `Promise.all` snapshot) used to be silently dropped from a build that then reported success — an outcome decided by model latency (review finding A-02). The implicit pass is gated on `_generateRequested`: `complete()` finishes a render pass the caller asked for, it never starts one that was not, so `.page(…).complete()` with no `generate()` still writes nothing. `_generatePending()`'s run is pushed onto `_generating` so a concurrent `complete()` and a later replay wait for it, and its per-page failures are collected on `_failures` exactly as `generate()`'s are. It fires no callback — `generate()`'s callback belongs to `generate()`.
94
+ - **`complete()` renders what the drain leaves unrendered.** `_settle()` alternates `_drain()` and `_generatePending()` — which renders every stack entry still at `runCount === 0`, incrementing the count as it picks each one so a concurrent pass cannot render it twice — and loops until a drain leaves nothing pending. A page prepared after the last `generate()` pass iterated the stack (a `scan()` from a `generate` callback, as `examples/3-pages/router.js` does; or a page queued after `generate()` whose model resolved after that run's `Promise.all` snapshot) used to be silently dropped from a build that then reported success — an outcome decided by model latency (review finding A-02). The implicit pass is gated on `_generateRequested`: `complete()` finishes a render pass the caller asked for, it never starts one that was not, so `.page(…).complete()` with no `generate()` still writes nothing. `_generatePending()`'s run is pushed onto `_generating` so a concurrent `complete()` and a later replay wait for it, and its per-page failures are collected on `_failures` exactly as `generate()`'s are. It fires no callback — `generate()`'s callback belongs to `generate()`.
93
95
  - The `modelId` used in `.page()`'s `.catch()` failure entry is captured _before_ the `.then()` chain runs, because `options.model` is reassigned to the resolved data inside `.then()` — reading `options.model` inside `.catch()` for a post-resolution failure would report the data, not the original id.
94
96
  - **Two pages claiming one `buildTo` is a build failure** (`_preparePage`): the second is still not stacked, but alongside the `Page already processed` log it now pushes `{ view, buildTo, error }` onto `_failures`, so `complete()` rejects. It used to log and return `null`, which meant a page the caller asked for was simply missing from a build that reported success — the same silent-success class as a dropped render (review finding C6). The dedupe applies to `.pages()` fan-out too, since each fanned-out page goes through `_preparePage` individually (v1 never deduped fan-out output); a fan-out whose controller derives the slug from model data is exactly where a collision now surfaces instead of hiding. The rebuild paths do not hit it: `scan()` dedupes against `_registrations` and `_replay()` replays a snapshot taken before its re-scan, both so the same view is never queued twice.
95
97
  - **The `slug` and `path` fallbacks in `page()` both guard on `.endsWith('.hbs')`.** Only a `.hbs` view is a filename: its folder is the page's path and its basename the slug. An inline template string gets a random `snippet-N` slug (with a logged error) and an _empty_ path — deriving one from the view would cut at the last `/` in the template's own markup, so `'<p>x</p>'` built into `public/-p-x-/…` and any closing tag became an output folder, with the sitemap inheriting the wrong `loc` (review finding C5). The `path` guard was missing while the `slug` one ten lines above was not.
package/AIKB/links.md CHANGED
@@ -26,7 +26,7 @@ The finding is **advisory**. It never moves `ok`, never moves an exit code, and
26
26
 
27
27
  - **Extraction and resolution are split across time on purpose.** The references are pulled out of the HTML at write time and the file is never read back, because under `kiss-ssg check` — the command the finding exists to serve — the whole staging folder is deleted before anything could read it, and a scan that read pages back would have reported `checked: 0, broken: []` in silence. The cost is a few hundred bytes a page rather than the ~15 MB of retained HTML a 500-page site would need if the documents were kept. `AIKB/kiss.md` has the ordering half of this.
28
28
  - **A same-origin absolute URL is internal, and that is the whole point.** `{{canonical}}` emits `https://site.example/courses/` on every page and `{{absUrl}}` emits one wherever a template asks; a rule of "ignore anything with a scheme" would skip exactly the OG tag and nav link a slug rename silently breaks. The origin test is `new URL(...).origin`, so a trailing slash on `config.siteUrl` is tolerated, and a path prefix (`https://example.com/docs`) is stripped as well: under that prefix the build folder is `/docs`'s root. A same-origin URL **outside** the prefix is called external — it is served by something this build did not write, so this build cannot say whether it is broken.
29
- - **The asset manifest contributes its values, never its keys.** `{{asset 'css/site.css'}}` returns the emitted name, which under `assets.hash` is `css/site.a1b2c3d4.css` — and `recordEmitted` _moves_ the file, so `css/site.css` is not on disk at all. Accepting the manifest key would make a template that hardcoded `/css/site.css` pass while the browser 404s. With no hashing policy key and value are equal, so nothing is lost. An empty manifest (`folders.assets: null`, the deliberate shape of `examples/7-versioned-outputs.js`) is "no assets", not an error.
29
+ - **The asset manifest contributes its values, never its keys.** `{{asset 'css/site.css'}}` returns the emitted name, which under `assets.hash` is `css/site.a1b2c3d4.css` — and `recordEmitted` _moves_ the file, so `css/site.css` is not on disk at all. Accepting the manifest key would make a template that hardcoded `/css/site.css` pass while the browser 404s. With no hashing policy key and value are equal, so nothing is lost. An empty manifest (`folders.assets: null`, the deliberate shape of `examples/7-versioned-outputs/router.js`) is "no assets", not an error.
30
30
  - **`{{link}}` writes references this module resolves by construction, and is not special-cased here.** The helper renders `servedPathFor(pageURL)` — `/`, `/about.html`, `/courses/`, `/blog/post/`, `/data/index.json` — and every one of those shapes is a path `resolveReference` accepts: the file itself, or `<dir>/index.html` for a trailing slash. `absolute=true` renders the site's own origin (plus `siteUrl`'s path prefix, if any), which `classifyReference` calls internal and resolves the same way. So a site whose internal hrefs are all `{{link}}` reports `broken: []`, and this scan stays the net for the hand-written references beside them — it neither knows nor asks which references came from the helper. The qualifier is `known`, below: a `{{link}}` to a page that failed to render is reported broken on every page that linked it, because no such file was published. Identity itself is not this module's business — an id that no page claims fails the _render_ (`AIKB/handlebars-helpers.md`), so it never reaches a written page for this scan to find.
31
31
  - **`known` is checked before the filesystem, and holds only pages that wrote bytes.** It is the cheap half, and on a page-heavy site most references are to other pages. It is built from the pages the scan was given — those whose `hash !== null` — so a link to a page with `generate: false`, or to one that failed to render, is reported as broken, which is the honest answer: no such file was published.
32
32
  - **Everything else in the build folder is answered by the filesystem, which is why `Kiss` runs the scan where it does.** `sitemap.xml`, `llms.txt`, a feed, `_redirects` and anything a pipeline step wrote straight into the build are not pages and not assets; they resolve because they are on disk at the moment the scan runs. That is the reason the scan sits in `complete()`'s settle path rather than in `_finishBuild()`.
package/AIKB/partials.md CHANGED
@@ -30,5 +30,5 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
30
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).
31
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
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.
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/router.js`'s `renderPartial` is the worked recipe.
34
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.
package/AIKB/pipeline.md CHANGED
@@ -39,3 +39,7 @@ Runs the external commands of `config.assets.pipeline` — a CSS toolchain, an i
39
39
  `PipelineStep` and `PipelineResult` are declared here and referred to from `lib/config.js` (`KissAssets.pipeline`) and `lib/build-report.js` (`buildReport`'s `pipeline` input), so the shape a site writes, the shape that is run and the shape that is reported cannot drift apart. `types/pipeline.d.ts` is emitted from this JSDoc like every other `lib/` module — `npm run types`, never hand-edited.
40
40
 
41
41
  **Tools that watch stdin.** A watch child is spawned with stdin ignored. Tailwind's `--watch` exits as soon as stdin closes (exit code 0, logged as `pipeline watch "tailwind" exited (code 0)`); `--watch=always` is the flag that keeps it up. Found on a1k9training, the first real site to use the hook.
42
+
43
+ **What the tool scans is not this module's business, and that is exactly why it bites.** The module runs a command line and reports its exit code; it has no idea what the command read. With Tailwind that gap has a sharp edge: v4 walks the whole project for class-name candidates and `@source` _adds_ to that walk rather than replacing it, so a site's own `.md` files are scanned as plain text and an ordinary English word compiles a utility. Paired with `assets.hash: true` (`AIKB/asset-manifest.md`) the stylesheet's hash is its filename, so a documentation edit renames `css/site.<hash>.css` and rewrites the `<link href>` on every page — which surfaces as `kiss-ssg check` reporting `~ N changed, = 0 unchanged` and no page source to blame. It is intermittent (a trailing `.` or `,` stops a word being a candidate token), which is what makes it expensive to diagnose. `@import 'tailwindcss' source(none)` plus explicit `@source` paths — resolved against the stylesheet's folder, not the cwd — is the fix. Verified on Tailwind 4.3.3; documented in `llms.txt` and `README.md` because the recipe there is what gets copied. Originally surfaced by a consuming site running `assets.hash` against a repo full of tracked markdown.
44
+
45
+ **Omitting `-i` is a silent _total_ loss, not a half-build.** `npx @tailwindcss/cli -o out.css` exits 0 and writes a stylesheet, but substitutes a default input of `@import 'tailwindcss'` instead of reading the entry file, so everything authored there goes — every `@layer components` class, every `@theme` token, every `@font-face`, every plugin import and every `@source` line — leaving generic utilities. Verified on 4.3.3; on a real 20-page site the output fell from 66,384 to 24,990 bytes. A step that does this is `{ ok: true }` here and always will be — the tool succeeded. The failure mode worth knowing is a site whose `watch` command carries `-i` and whose `run` does not: dev is correct, production is not, and nothing in the report distinguishes them.
package/AIKB/testing.md CHANGED
@@ -4,13 +4,14 @@ Cross-cutting notes on how tests in `test/` are organized and written — not a
4
4
 
5
5
  ## Layout
6
6
 
7
- - `test/unit/` — one file per `lib/` module _except the orchestrator_ (`lib/kiss.js` has no `test/unit/kiss.test.js`), exercising each in isolation (mostly pure functions, or a module with its dependencies passed in via DI). It also covers `scripts/` — the dev tooling the branch ritual runs (`base-branch`, `gates`, `check-staged-format`, `bench`, `sync-plugin-versions`) and the type emitter (`emit-types`) are outside the engine, so nothing else exercises it. Those tests target the pure decision core each script exports; the I/O wrapper below it stays thin and untested.
7
+ - `test/unit/` — one file per `lib/` module _except the orchestrator_ (`lib/kiss.js` has no `test/unit/kiss.test.js`), exercising each in isolation (mostly pure functions, or a module with its dependencies passed in via DI). It also covers `scripts/` — the dev tooling the branch ritual runs (`base-branch`, `gates`, `check-staged-format`, `bench`, `sync-plugin-versions`, `tag-release`) and the type emitter (`emit-types`) are outside the engine, so nothing else exercises it. Those tests target the pure decision core each script exports; the I/O wrapper below it stays thin and untested.
8
8
  - `test/integration/` — cross-module behavior driven through the public `Kiss` API: lifecycle (`generate`/`complete`), dedupe, config resolution, watch mode, ESM/CJS interop, and a broader "characterization" suite. This is where `lib/kiss.js` itself is covered — end-to-end through its public API rather than in a dedicated unit file.
9
9
  - `test/helpers/site.js` — `makeSite(files)` creates an isolated temp-dir site (under `os.tmpdir()`) and returns `{ root, src, build, folders, read, exists, touch, cleanup }`; `waitFor(predicate, { timeout, interval })` polls until a predicate is true (used for watch-mode assertions) or throws after `timeout`. Paths are returned with forward slashes because the engine compares and slices paths as posix throughout (`utils.posixPath`).
10
10
  - A test that spawns a child process of its own — `test/unit/pipeline.test.js` runs `node -e` commands through `createPipeline` — uses `process.execPath`, quoted, rather than a bare `node`, and must `close()` the pipeline in `afterEach`: a watch process left running keeps the worker (and the temp folder) alive.
11
11
  - `test/helpers/kiss.js` — re-exports `Kiss`/`utils` from `lib/kiss.js` and `ENTRY` (`path.resolve('lib/kiss.js')`), so integration tests import one thing instead of reaching into `lib/` directly.
12
12
  - `test/fixtures/consumer/` — a plain-JS site (`site.js`, `// @ts-check`) plus a deliberately broken one (`bad.js`), each with its own tsconfig mapping `kiss-ssg` to the repo's `types/kiss.d.ts`. `test/unit/types.test.js` runs `tsc --noEmit` over both through `child_process`: `site.js` must be clean, `bad.js` must report exactly three named errors. Not a unit test of any module — it is the published type surface checked from the outside, the way a consumer meets it.
13
13
  - `test/aikb.test.js` — **not** the unit test for `lib/aikb.js` (that is `test/unit/aikb.test.js`, with `test/integration/aikb.test.js` beside it); this one is the repo's docs-sync test, named for the `AIKB/` folder it guards. It is covered by this doc's own CLAUDE.md table row: fails if a `lib/` module has no `AIKB/` doc, a doc isn't listed in `CLAUDE.md`'s lookup table, a doc has no matching module, or a doc drops one of the five required headings. It also holds the two shipped-docs parity checks: every non-underscore `Kiss.prototype` method is named in `llms.txt`, and the config-defaults block in both `llms.txt` and `README.md` evaluates to exactly `resolveConfig({})` — so a new default cannot land in `config.js` alone.
14
+ - `test/unit/changelog.test.js` — the other version-sync net: `CHANGELOG.md`'s first `## <version> — <date>` heading must be the version `package.json` declares. The manifests are carried by `npm version` and need no hand; the changelog is the one version-bearing file a person writes, so this is what catches a bump with no entry or an entry with no bump — and it runs at the gates, which `prepublishOnly` runs too.
14
15
  - `test/unit/plugin-manifests.test.js` — the Claude Code plugin's own sync test: `.claude-plugin/marketplace.json` and `plugins/kiss-ssg/.claude-plugin/plugin.json` must parse, both must carry `package.json`'s version, the marketplace's `source` must resolve to the plugin folder, and every skill's frontmatter `name` must match its folder. It also walks each `SKILL.md` for inline-code paths starting `node_modules/kiss-ssg/` and fails if one does not exist in the repo or is not inside the `files` whitelist — the skills point at the shipped docs instead of restating them, so a path that does not ship sends the agent nowhere.
15
16
 
16
17
  ## Commands
@@ -35,16 +36,16 @@ npm run types # regenerate types/ from the JSDoc in lib/
35
36
  - `test/integration/check.test.js` runs the real `bin/kiss-ssg.js` as a child process against a throwaway site under the OS temp dir — the only way to assert on an exit code and on stdout carrying nothing but the JSON report. The fixture sites it writes import the engine by absolute `file://` URL (a temp dir cannot resolve `kiss-ssg` from `node_modules`), and each one asserts that the build folder is still absent, or still holds its sentinel file, afterwards. Its second `describe` reads the same report through the API instead — `kiss.report()`, `err.report`, and the staged-path mapping after a promoted and after a discarded atomic build — since `lib/kiss.js` is covered here rather than in a unit file.
36
37
  - `test/integration/bench-watch.test.js` is the one exception to "the I/O wrapper below the decision core stays untested": `scripts/bench.mjs`'s `--dev` watch reading spawns a real dev-mode site, drives it over a websocket and edits its source files, and none of that can be asserted from a pure function. It generates a three-file site under the OS temp dir, links this repo into its `node_modules` with a **junction** (`fs.symlinkSync(…, 'junction')` — no elevation on Windows, and it is what lets `resolveKissFrom` prove which engine was measured), picks both ports with `net.createServer().listen(0)` because the engine's defaults collide with any dev server the operator left open, and afterwards asserts the three touched files are byte-identical and the livereload port binds again — the proof that no child was left running. 60s timeout, like the other child-process suites; it runs in about three seconds.
37
38
  - **Red-first without stashing.** When more than one agent works in the tree, `git stash` to prove a new test red would clobber the other's work. The recipe the link-check branch settled on: `git archive HEAD | tar -x -C <scratch>/red-<name>`, symlink `node_modules`, copy the new test files in, run them there against HEAD (they must fail), then delete the copy. Every workstream on that branch recorded its red-first evidence this way.
38
- - `test/unit/skill-coverage.test.js` is the agent-facing counterpart of `test/aikb.test.js`: a table of public feature → the plugin skills that must name it (`{{link`, `aliases`, `.feed(`, the check's finding lines, `kiss-ssg aikb`, the site map's `Id` column). A feature documented in `llms.txt` but never taught to the skill an agent follows is what this catches — add a row when a public feature lands.
39
+ - `test/unit/skill-coverage.test.js` is the agent-facing counterpart of `test/aikb.test.js`: a table of public feature → the plugin skills that must name it (`{{link`, `links: { canonical: true }`, `aliases`, `.feed(`, the check's finding lines, `kiss-ssg aikb`, the site map's `Id` column). A feature documented in `llms.txt` but never taught to the skill an agent follows is what this catches — add a row when a public feature lands.
39
40
  - `test/integration/{links,link-helper,redirects,feed}.test.js` build real sites through the public API and assert on the report keys those modules fill; `test/unit/{links,redirects,feed,utils}.test.js` cover the pure derivations (reference extraction and resolution, alias coverage, served paths, RSS rendering) with injected `exists`/readers so no test depends on a fixture on disk it did not write.
40
41
  - A skill's helper script under `.claude/skills/*/scripts/` gets a `test/unit/` test when it carries logic of its own: `test/unit/corpse-collector.test.js` exercises the scanner's AIKB-staleness check on a temporary git repository (`git init`, commits driven by `execFileSync` with an explicit `cwd`, never a shell string), which is why `scan.mjs` keeps its scan body under `main()` and exports the check.
41
- - `test/integration/examples.test.js` builds every example under `examples/` (1–11) as a child process and asserts its exit code, its page count, and each example's distinctive behaviour (example 8's controlled failure, example 9's deduped `lookup` warning and dedupe recipe, example 10's pipeline-generated stylesheet). Every example writes into the one shared repo-root `public/`, so its tests stay in one plain (non-`concurrent`) `describe` — Vitest's default declaration order is the only sequencing this needs — and each test clears its own example's output folder immediately before building, so a page count can never be a leftover from a previous run. Skips (rather than fails) if `examples/` is absent. Example 9 is the exception to "every example writes into `public/`": `examples/9-migrated-from-v1/AIKB/` is committed source, recorded by `npx kiss-ssg aikb`, so one test runs the published bin from `examples/` and asserts the four generated files come back byte-identical to the committed ones. That is the guard on the property the whole feature rests on: recording an unchanged site twice must leave `git status` clean. Example 8 is the counterpart — it fails on purpose, so it can never be recorded, and its test asserts no `AIKB/` appears beside it.
42
+ - `test/integration/examples.test.js` builds every example under `examples/` (1–11) as a child process, **each from its own folder** (`examples/3-pages/`, running `router.js`) the way `npm run egN` and a real site both run it, and asserts its exit code, its page count, and each example's distinctive behaviour (example 8's controlled failure, example 9's deduped `lookup` warning and dedupe recipe, example 10's pipeline-generated stylesheet). Every example writes into the one shared repo-root `public/`, so its tests stay in one plain (non-`concurrent`) `describe` — Vitest's default declaration order is the only sequencing this needs — and each test clears its own example's output folder immediately before building, so a page count can never be a leftover from a previous run. Skips (rather than fails) if `examples/` is absent. Example 9 is the exception to "every example writes into `public/`": `examples/9-migrated-from-v1/AIKB/` is committed source, recorded by `npx kiss-ssg aikb`, so one test runs the published bin from that example's own folder and asserts the four generated files come back byte-identical to the committed ones. That is the guard on the property the whole feature rests on: recording an unchanged site twice must leave `git status` clean. Example 8 is the counterpart — it fails on purpose, so it can never be recorded, and its test asserts no `AIKB/` appears beside it.
42
43
 
43
44
  ## Gotchas
44
45
 
45
46
  - `npm run lint`'s `@eslint/js` (v10) requires Node ≥22.13, even though the package's own runtime floor (`package.json`'s `engines.node`) is 22.12 — on Node 22.12.x exactly, `npm test` passes but `npm run lint` may refuse to run.
46
47
  - **`types/` is generated — never hand-edit it.** `test/unit/types.test.js` re-emits it and byte-compares, so a JSDoc change in `lib/` fails the suite until `npm run types` has been run and the result committed. The failure names the file and the command.
47
- - The examples (`npm run eg1` … `eg10`) and `node docs` build and exit by default, so they are safe to run bare — except example 8, which exits 1 by design. It is the `--dev` flag that starts a server and **never exits on its own** — `node docs --dev`, or `npm run eg1 -- --dev` (examples 1–6, 8, 9, 10; example 7 takes a season slug instead). Use `timeout 10 node <script> --dev` in Git Bash (or equivalent) to smoke-run that path and let the timeout kill the process.
48
+ - The examples (`npm run eg1` … `eg11`) and `node docs` build and exit by default, so they are safe to run bare — except example 8, which exits 1 by design. It is the `--dev` flag that starts a server and **never exits on its own** — `node docs --dev`, or `npm run eg1 -- --dev` (examples 1–6, 8, 9, 10; example 7 takes a season slug instead). Use `timeout 10 node <script> --dev` in Git Bash (or equivalent) to smoke-run that path and let the timeout kill the process.
48
49
  - `docs/` is emptied by `docs.js` on every run (`cleanBuild: true`, the default) — nothing hand-written can live there. Specs and implementation plans belong in `planning/` (`planning/specs/`, `planning/plans/`), never under `docs/`.
49
50
  - **Watch-mode assertions wait on the rebuild, not on one file.** A replay's orphan sweep removes an output and its dev-mode `.json` sibling one `await` apart, so `waitFor(() => !exists('x.html'))` returns while the sweep is still running. A test that then asserts on the sibling is racing that interleaving — it fails under load (roughly 1 in 100 exposures with the suite run 5-up on 4 cores) and, worse, can pass for the wrong reason when it asserts a file is _kept_. Wait on the file to see the rebuild has started, then drain the queue (`while (kiss._rebuildInFlight) await kiss._rebuildInFlight` — `rebuildSettled()` in `watch.test.js`) before asserting anything else. Likewise, wait on a new page's _content_, not its existence: `fs.outputFile` creates the file before writing it.
50
51
  - The CJS interop test (`test/integration/esm.test.js`) spawns a real `node` binary on a `.cjs` file via `execFile` — a slow or misconfigured `node` on `PATH` shows up as a test timeout there, not an assertion failure.
package/README.md CHANGED
@@ -36,7 +36,7 @@ Everything an agent needs ships in the package, so point it at `node_modules` ra
36
36
 
37
37
  That file is the API contract: the pipeline, every method and option, the helpers, the migration recipes. Beside it sit `node_modules/kiss-ssg/AIKB/` (per-module notes), `node_modules/kiss-ssg/types/` (declarations the agent's editor reads) and `node_modules/kiss-ssg/examples/` (eleven runnable sites with a README each — copy the exemplar whose shape matches).
38
38
 
39
- Give the agent a verdict it can act on: `npx kiss-ssg check site.js` runs your build script as a dry run and prints one JSON report per site built, exit 1 on any failure, without touching the published output (see [Checking a build](#checking-a-build)), and `npx kiss-ssg aikb site.js` records what the site is into `AIKB/`, which the agent reads back next time.
39
+ Give the agent a verdict it can act on: `npx kiss-ssg check router.js` runs your build script as a dry run and prints one JSON report per site built, exit 1 on any failure, without touching the published output (see [Checking a build](#checking-a-build)), and `npx kiss-ssg aikb router.js` records what the site is into `AIKB/`, which the agent reads back next time.
40
40
 
41
41
  If the agent is Claude Code, this repository is also a plugin marketplace. In Claude Code, run:
42
42
 
@@ -52,6 +52,12 @@ The `kiss-ssg` plugin installs four skills, all named `kiss-<something>` so they
52
52
 
53
53
  ## Usage
54
54
 
55
+ ### The build script
56
+
57
+ Call it `router.js`, at the project root, and point `package.json`'s `main` and its `build`/`dev` scripts at it. It is a router: the config, one `registerHelpers(kiss)` call if the site has custom helpers, the `.page()`/`.pages()`/`.scan()` table, the terminal `.generate()`/`.sitemap()`/`.llms()`/`.feed()` chain, and the `complete()`/`catch()` pair. Helper bodies, the facts a site states in both its markup and its JSON-LD, and the completion callbacks once they outgrow a few lines all belong in modules beside it.
58
+
59
+ How much is extracted follows the size of the file, not ambition. One file is correct up to roughly 150 lines; `helpers/` is earned when the custom helpers pass about a third of it, `config/` when a fact appears in both the markup and the structured data. `llms.txt` § The build script has the thresholds, the reasons, and the three mistakes the shape invites — chief among them that renaming an existing build script to `router.js` silently orphans whatever names it, from `package.json` to a CSS toolchain's source globs.
60
+
55
61
  kiss-ssg has 3 methods
56
62
 
57
63
  - .page()
@@ -95,7 +101,8 @@ The default config options are:
95
101
  breaks: false
96
102
  },
97
103
  links: {
98
- check: true
104
+ check: true,
105
+ canonical: false
99
106
  },
100
107
  port: 3001,
101
108
  livereloadPort: 35729,
@@ -116,21 +123,22 @@ The default config options are:
116
123
 
117
124
  Partials: Cam be a .hbs, a .html file or a .md file, Note: .md files are automatically parsed
118
125
 
119
- | Option | Default | Purpose |
120
- | -------------- | :--------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
121
- | dev | false | Dev mode will start a local live-reload server and rebuild on file change. Model and controller changes are picked up too: a rebuild re-runs models and controllers, and edited controller files are reloaded from disk. |
122
- | verbose | false | Enables additional output on the terminal, when set to true |
123
- | cleanBuild | true | `true`, `false` or `'atomic'`. `true` empties the build dir — in the constructor, before anything is rendered. `'atomic'` builds into a staging folder and swaps it in only when `complete()` resolves. See **Cleaning the build folder** below. |
124
- | extensionLess | false | When `true`, a non-index page builds to `<path>/<slug>/index.html` instead of `<path>/<slug>.html` — a URL like `/about/` instead of `/about.html`. |
125
- | sass | `{ includePaths: [] }` | `includePaths` is passed to sass as `loadPaths`, so `@use`/`@import` can resolve from those directories too. |
126
- | fetch | see below | The policy for `http(s)` models: `headers` sent with every request, `timeout` in ms, `retries` after a network error or a 5xx, and `cache` (`false`, or a directory to cache successful bodies in). See **Remote models** below. |
127
- | assets | `{ hash: false, version: null, pipeline: [] }` | Cache busting for the files copied into the build, plus the external commands run before the copy. `hash: true` renames every emitted `.css`/`.js` to carry a content hash; `version: '1.4.5'` renames nothing and makes `{{asset}}` append `?v=1.4.5`; `pipeline` is an ordered list of `{ name?, run, watch?, cwd? }` steps. All off is today's build, unchanged. See **Cache-busting asset URLs** and **An asset pipeline** below. |
128
- | port | 3001 | The port the dev server listens on (`dev: true` only). A port already in use fails the build with one message naming it — nothing can be served, so give a second site its own `port` rather than letting them clash. |
129
- | livereloadPort | 35729 | The port the live-reload server listens on, and the one the injected reload script talks to (`dev: true` only). Give a second site its own value to run both at once — a clash is now logged and live reload simply switched off, rather than killing the process. |
130
- | devHost | '127.0.0.1' | The interface the dev and live-reload servers bind to. Loopback only by default; set `'0.0.0.0'` to reach the preview from another device on your network — live reload follows the host the page was loaded from, so the preview reloads there too. |
131
- | folders | see above | A JSON object of alternative folder locations |
132
- | folders.aikb | './AIKB' | Where `npx kiss-ssg aikb` records the site's knowledge base. Source, not output: it sits outside `folders.build`, is never derived from `folders.src`, is not created until you record one, and is meant to be committed. `null` switches it off |
133
- | siteUrl | undefined | The site's base URL, required by `.sitemap()` (see below) and by the `canonical` / `absUrl` helpers |
126
+ | Option | Default | Purpose |
127
+ | -------------- | :--------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
128
+ | dev | false | Dev mode will start a local live-reload server and rebuild on file change. Model and controller changes are picked up too: a rebuild re-runs models and controllers, and edited controller files are reloaded from disk. |
129
+ | verbose | false | Enables additional output on the terminal, when set to true |
130
+ | cleanBuild | true | `true`, `false` or `'atomic'`. `true` empties the build dir — in the constructor, before anything is rendered. `'atomic'` builds into a staging folder and swaps it in only when `complete()` resolves. See **Cleaning the build folder** below. |
131
+ | extensionLess | false | When `true`, a non-index page builds to `<path>/<slug>/index.html` instead of `<path>/<slug>.html` — a URL like `/about/` instead of `/about.html`. |
132
+ | sass | `{ includePaths: [] }` | `includePaths` is passed to sass as `loadPaths`, so `@use`/`@import` can resolve from those directories too. |
133
+ | fetch | see below | The policy for `http(s)` models: `headers` sent with every request, `timeout` in ms, `retries` after a network error or a 5xx, and `cache` (`false`, or a directory to cache successful bodies in). See **Remote models** below. |
134
+ | assets | `{ hash: false, version: null, pipeline: [] }` | Cache busting for the files copied into the build, plus the external commands run before the copy. `hash: true` renames every emitted `.css`/`.js` to carry a content hash; `version: '1.4.5'` renames nothing and makes `{{asset}}` append `?v=1.4.5`; `pipeline` is an ordered list of `{ name?, run, watch?, cwd? }` steps. All off is today's build, unchanged. See **Cache-busting asset URLs** and **An asset pipeline** below. |
135
+ | links | `{ check: true, canonical: false }` | The broken-internal-link scan, and what `{{link}}` emits. `check: true` scans every written page for internal references that resolve to nothing and reports them as `report().links` — advisory, never changing `ok` or the exit code. `canonical: true` makes a bare `{{link}}` emit the extension-less form (`/about` rather than `/about.html`) for a host that serves it, with the per-call `canonical=` hash still overriding. See **Broken internal links** and the `link` helper below. |
136
+ | port | 3001 | The port the dev server listens on (`dev: true` only). A port already in use fails the build with one message naming it — nothing can be served, so give a second site its own `port` rather than letting them clash. |
137
+ | livereloadPort | 35729 | The port the live-reload server listens on, and the one the injected reload script talks to (`dev: true` only). Give a second site its own value to run both at once — a clash is now logged and live reload simply switched off, rather than killing the process. |
138
+ | devHost | '127.0.0.1' | The interface the dev and live-reload servers bind to. Loopback only by default; set `'0.0.0.0'` to reach the preview from another device on your network — live reload follows the host the page was loaded from, so the preview reloads there too. |
139
+ | folders | see above | A JSON object of alternative folder locations |
140
+ | folders.aikb | './AIKB' | Where `npx kiss-ssg aikb` records the site's knowledge base. Source, not output: it sits outside `folders.build`, is never derived from `folders.src`, is not created until you record one, and is meant to be committed. `null` switches it off |
141
+ | siteUrl | undefined | The site's base URL, required by `.sitemap()` (see below) and by the `canonical` / `absUrl` helpers |
134
142
 
135
143
  A key you pass explicitly as `undefined` takes its default — `new Kiss({ port: process.env.PORT })` with `PORT` unset still gets 3001, and the same holds inside `folders` and `sass`. `null` is a real value: set a folder to `null` to switch it off.
136
144
 
@@ -194,7 +202,7 @@ kiss has no notion of "versions" or "sites" — it is one `Kiss` instance buildi
194
202
 
195
203
  The one thing kiss cannot validate for you: the value that becomes `folders.build` is yours before it ever reaches the constructor. Check it looks like a slug — not empty, no `..`, no path separators — before building, since an empty or malformed value resolves against the parent of every output you have already published, not just the one you meant to build.
196
204
 
197
- See `examples/7-versioned-outputs.js` for a full runnable version: one seasonal menu per season, each with its own copied assets, plus a small second build that lists every season folder found on disk. `examples/` ships in the published package, so `node_modules/kiss-ssg/examples/README.md` is a copy you can run without cloning the repo. Examples 1–6 and 10 are the feature reference, one idea each; 7–9 and 11 are exemplars — whole sites to copy by shape: versioned outputs, a data-fed site with one broken record, the v1 → v2 migration recipes, and a blog with pagination, tag pages, a feed and a redirect. Every example builds and exits by default (`npm run eg1` … `eg11`); pass `--dev` to run examples 1–6, 8, 9, 10 and 11 as a live dev server instead (7 takes a season slug in place of `--dev`, and 8 exits 1 by design).
205
+ See `examples/7-versioned-outputs/router.js` for a full runnable version: one seasonal menu per season, each with its own copied assets, plus a small second build that lists every season folder found on disk. `examples/` ships in the published package, so `node_modules/kiss-ssg/examples/README.md` is a copy you can run without cloning the repo. Examples 1–6 and 10 are the feature reference, one idea each; 7–9 and 11 are exemplars — whole sites to copy by shape: versioned outputs, a data-fed site with one broken record, the v1 → v2 migration recipes, and a blog with pagination, tag pages, a feed and a redirect. Each example is a site in its own folder with its own `router.js`, run from that folder the way a real site is. Every example builds and exits by default (`npm run eg1` … `eg11`); pass `--dev` to run examples 1–6, 8, 9, 10 and 11 as a live dev server instead (7 takes a season slug in place of `--dev`, and 8 exits 1 by design).
198
206
 
199
207
  ### Remote models
200
208
 
@@ -273,7 +281,29 @@ Every `run` is executed through a shell, in order, awaited, **before the asset c
273
281
 
274
282
  `name` defaults to the first word of `run`, `cwd` to `process.cwd()`, and each command inherits `process.env` plus `KISS_BUILD`, `KISS_ASSETS` and `KISS_DEV` (`'1'` or `'0'`). `watch` is the dev-mode half: in `dev: true` only, it is started once — after that step's `run` has succeeded — kept for the session with its output going through kiss's logger, and ended by `close()`. A watch process that dies on its own is logged, not a build failure, and a rebuild never starts a second one. Editing a page, a partial or a layout does **not** re-run the steps; a tool that must see those edits is what `watch` is for.
275
283
 
276
- `kiss-ssg check` runs the pipeline exactly as a build does, so a check is not read-only over your working tree: it regenerates whatever the steps generate. `examples/10-asset-pipeline.js` (`npm run eg10`) is a runnable version that needs nothing installed.
284
+ `kiss-ssg check` runs the pipeline exactly as a build does, so a check is not read-only over your working tree: it regenerates whatever the steps generate. `examples/10-asset-pipeline/` (`npm run eg10`) is a runnable version that needs nothing installed.
285
+
286
+ ##### Two silent traps in the Tailwind recipe
287
+
288
+ Neither of these is kiss's behaviour — both are Tailwind's — but the command above is what people copy, so they belong next to it.
289
+
290
+ **`@source` adds to Tailwind's project walk; it does not replace it.** Tailwind v4 scans your whole project for class-name candidates (everything not excluded by `.gitignore`, `node_modules`, a lockfile, a binary or a CSS file), and an `@source` line registers _additional_ paths. A site that writes an explicit `@source` list, reasonably reading it as _the_ source list, is still scanning its own markdown. Tailwind treats every file as plain text, so an ordinary English word is a candidate class — and with `assets.hash: true` the stylesheet's content hash is its filename. Typing the word `invisible` into a tracked `README.md` compiles `.invisible{visibility:hidden}`, gives `css/site.<hash>.css` a new name, and changes the `<link href>` on **every** page; `kiss-ssg check` then reports the whole site as changed with nothing in the page sources to explain it. On Tailwind 4.3.3 that single word moved the stylesheet from 4,381 to 4,410 bytes.
291
+
292
+ It is intermittent, which is what makes it expensive: a word counts only if it forms a clean candidate token, so `capitalize` in a sentence compiles a rule while `capitalize.` or `capitalize,` does not, and a word matching an already-compiled utility changes nothing. The hash therefore moves on some documentation edits and not others.
293
+
294
+ To scan only what you mean, disable the walk and list your sources — remembering that an `@source` path resolves against the **stylesheet's own folder**, not the working directory:
295
+
296
+ ```css
297
+ @import 'tailwindcss' source(none);
298
+ @source '../views';
299
+ @source '../partials';
300
+ ```
301
+
302
+ A path that matches nothing is silent as well: `source(none)` plus an `@source` that resolves nowhere compiles an empty utility layer rather than failing.
303
+
304
+ **Guard it rather than remember it.** Both failures are silent and both produce a plausible file, so a few lines in the build — a `pipeline` step, or a CI check — that fail when `source(none)` is missing from the entry stylesheet, or when any `@source` path no longer resolves, catch the whole class. It needs nothing built, and it is what caught this on a real site.
305
+
306
+ **Keep the `-i`.** Running `npx @tailwindcss/cli -o out.css` without `-i` exits 0, prints a normal timing line and writes a plausible stylesheet — but it never reads your entry file. The CLI substitutes a default input of `@import 'tailwindcss'`, so what is lost is not a handful of rules but everything you authored: every `@layer components` class, every `@theme` token, every `@font-face`, every plugin import and every `@source` line. Verified on 4.3.3 — an entry file declaring `.btn`, `.card`, `.callout` and a self-hosted `@font-face` compiled all of them with `-i` and none of them without; on a real 20-page site the output fell from 66,384 to 24,990 bytes, leaving generic utilities. Nothing reports an error. A project whose dev command passes `-i` while its build command does not is correct on every developer's machine and wrong only on the deployed site, which is how this survives for months.
277
307
 
278
308
  ### Markdown options
279
309
 
@@ -604,11 +634,11 @@ Your browser is reloaded once per rebuild, when that rebuild has finished writin
604
634
  `npx kiss-ssg check <script>` builds the site your script builds and tells you whether it worked — without publishing anything. The build is staged and then discarded, so the build folder is neither emptied nor written, and what you get back is the verdict instead of the output:
605
635
 
606
636
  ```bash
607
- npx kiss-ssg check build.js # JSON, one report per Kiss instance — and, if the
608
- # site has recorded an AIKB/, what changed since
609
- npx kiss-ssg check build.js --summary # one line per instance instead
610
- npx kiss-ssg check menu.js 2026-spring # arguments after the script go to the script
611
- npx kiss-ssg check --against last.jsonl build.js # diff against some other report instead
637
+ npx kiss-ssg check router.js # JSON, one report per Kiss instance — and, if the
638
+ # site has recorded an AIKB/, what changed since
639
+ npx kiss-ssg check router.js --summary # one line per instance instead
640
+ npx kiss-ssg check menu.js 2026-spring # arguments after the script go to the script
641
+ npx kiss-ssg check --against last.jsonl router.js # diff against some other report instead
612
642
  ```
613
643
 
614
644
  ```json
@@ -661,13 +691,16 @@ When the site has recorded a knowledge base (see [Recording the knowledge base](
661
691
 
662
692
  ```
663
693
  ok ./public (check) — 6 pages, 0 failed, 2 assets, 153ms
694
+ ~ asset css/site.css -> css/site.136acc63.css (was css/site.e7abc083.css)
664
695
  + ./public/news/spring-2026.html
665
696
  - ./public/news/autumn-2025.html
666
697
  ~ ./public/news/index.html
667
698
  = 3 unchanged
668
699
  ```
669
700
 
670
- Without `--summary`, stdout becomes `{ "reports": [...], "diff": [...] }` instead of the bare array whenever there is a diff to print — under `--against`, or under `check` when the site has a recorded baseline — and `diff` runs in the same order as `reports`, one `{ buildDir, added, removed, changed, unchanged }` entry each, every list sorted. A missing or unreadable file is a usage error (exit 1, nothing built). The diff never changes the exit code: it describes the build, it does not judge it.
701
+ Without `--summary`, stdout becomes `{ "reports": [...], "diff": [...] }` instead of the bare array whenever there is a diff to print — under `--against`, or under `check` when the site has a recorded baseline — and `diff` runs in the same order as `reports`, one `{ buildDir, added, removed, changed, unchanged, assets }` entry each, every list sorted. A missing or unreadable file is a usage error (exit 1, nothing built). The diff never changes the exit code: it describes the build, it does not judge it.
702
+
703
+ **Assets that moved are named under the pages.** Whenever a diff has pages in it, `formatDiff` **opens** with one ` ~ asset <source> -> <target> (was <target>)` row per emitted asset whose filename changed — above the page rows, capped at ten with ` … and N more assets changed` — and `diff[].assets` carries the same rows as `{ source, from, to }`. This exists because of `assets.hash`: a hashed stylesheet's filename **is** its content hash, so one changed asset rewrites the `<link href>` of every page that references it and the diff reads `~ <every page>, = 0 unchanged` with nothing in any page source to explain it. The row names the cause, and it goes first because that is where the reader already is: after a two-hundred-page list it would be a footnote reached by scrolling past everything it explains. Only assets both builds emitted are rows — one the baseline never saw has no previous name to have moved from — and nothing is printed when no page moved, since the rows are there to explain a diff rather than to be one.
671
704
 
672
705
  **Broken internal links.** Every settled non-dev build also scans its own output and reports what it found as `report().links` — `{ checked, broken: [{ page, href }] }` — with one ` broken link: <page> -> <href>` line per finding under `--summary`. It checks the `href`, `src`, each `srcset` candidate and `action` of every `a`, `link`, `script`, `img`, `source`, `video`, `audio`, `iframe` and `form` your pages wrote, resolving a root-relative path against the build folder and a relative one against the page's own directory, and accepting a file that is there, a page this build wrote, `<path>/` → `<path>/index.html`, an extension-less `<path>` → `<path>.html` or `<path>/index.html`, and an asset under the name `{{asset}}` actually emitted.
673
706
 
@@ -686,8 +719,8 @@ Two things a check cannot make true. A site that reads its own build folder back
686
719
  `npx kiss-ssg aikb <script>` writes the site's own knowledge base into `config.folders.aikb` (default `./AIKB`) — the map of the site as the build saw it, for the developer who comes back to it in two years and finds that every context window that held this is gone. It runs exactly the build `check` runs — staged and then discarded, publishing nothing — and the folder is the only thing it leaves behind.
687
720
 
688
721
  ```bash
689
- npx kiss-ssg aikb build.js # record it
690
- npx kiss-ssg aikb build.js --summary # ...and say in one line whether it did
722
+ npx kiss-ssg aikb router.js # record it
723
+ npx kiss-ssg aikb router.js --summary # ...and say in one line whether it did
691
724
  ```
692
725
 
693
726
  Nothing else writes that folder. There is no API call for it, and an ordinary build, a `check`, a dev server and a watch rebuild all leave it exactly as they found it. Recording is a **ceremony**: run it when a piece of work is finished, and commit what it wrote. That is what makes `npx kiss-ssg check` mean "what have I changed since this work opened" rather than "since the last time anybody ran a build".
@@ -779,7 +812,7 @@ A relative file path is resolved against `process.cwd()` — not the assets fold
779
812
 
780
813
  `lookup` is Handlebars' own `lookup` helper with one addition: when the key is undefined it logs `lookup: 'moodleAccess' is undefined in handbooks/uob.hbs`, so a dynamic partial `{{> (lookup . 'key')}}` whose key is missing from your data tells you which key and which page — it still fails the build with `The partial undefined could not be found`, as before.
781
814
 
782
- `isActive` renders its block only when the current page matches `href`, handy for highlighting the current nav item:
815
+ `isActive` renders its block only when the current page matches `href`, handy for highlighting the current nav item. The block sees the surrounding context **plus** the hash you pass, the hash winning on a clash — so inside an `{{#each}}` the item's own keys are still in scope, and `active`, `href`, `folderMatch` and `pageURL` are always the helper's own:
783
816
 
784
817
  ```handlebars
785
818
  <nav>
@@ -849,7 +882,7 @@ kiss.handlebars.registerHelper('stringify', function (obj) {
849
882
  <meta property='og:url' content='{{link "about" absolute=true}}' />
850
883
  ```
851
884
 
852
- `{{link "about"}}` renders `/about.html`, or `/about/` on an `extensionLess` site — the path the host actually serves (`/`, `/courses/`, `/blog/the-cascara-experiment/`, `/data/index.json`). Every page has an `id` (see `.page()` above). It is **root-relative**: for a site served from a path prefix, or a link that has to be absolute, `absolute=true` gives `https://example.com/about.html`; for the pretty form the sitemap and `{{canonical}}` emit, `canonical=true` gives `/about` (wrap it in `{{absUrl}}` to make that absolute). **It is the one helper that fails the build**: an id no page claims, an id two pages' defaults both arrived at, or a page with `generate: false` (which claims no id) fails the page that linked it, naming the id and the view that asked — a link is a promise, and unlike a hand-written path it is checkable at render. Under `dev: true` it warns and renders `#` instead, so the live preview shows you both the page and the mistake.
885
+ `{{link "about"}}` renders `/about.html`, or `/about/` on an `extensionLess` site — the path the host actually serves (`/`, `/courses/`, `/blog/the-cascara-experiment/`, `/data/index.json`). Every page has an `id` (see `.page()` above). It is **root-relative**: for a site served from a path prefix, or a link that has to be absolute, `absolute=true` gives `https://example.com/about.html`; for the pretty form the sitemap and `{{canonical}}` emit, `canonical=true` gives `/about` (add `absolute=true` for `https://example.com/about`). A site whose host serves the pretty form on every page sets `links: { canonical: true }` once instead of writing `canonical=true` at every call site; the per-call hash still wins either way, `canonical=false` included, and `canonical` and `absolute` compose — with both, the link is the absolute URL of the pretty path. **It is the one helper that fails the build**: an id no page claims, an id two pages' defaults both arrived at, or a page with `generate: false` (which claims no id) fails the page that linked it, naming the id and the view that asked — a link is a promise, and unlike a hand-written path it is checkable at render. Under `dev: true` it warns and renders `#` instead, so the live preview shows you both the page and the mistake.
853
886
 
854
887
  ## Migrating from v1
855
888
 
@@ -885,4 +918,5 @@ kiss.handlebars.registerHelper('stringify', function (obj) {
885
918
  ```
886
919
 
887
920
  - New: `kiss.close()` stops the dev server and file watcher.
921
+ - **Upgrading from a v2 _prerelease_ rather than v1: `markdown.breaks` flipped.** It was hard-coded `true` in `2.0.0-alpha.5` and has been `false` since `2.0.0-beta.1`, where the option set became the configurable `DEFAULT_MARKDOWN` block; `2.0.0`, `2.1.0` and `2.2.x` all match beta.1. A project pinned to an alpha may carry a workaround for the old default — `kiss.remarkable.set({ breaks: false })` followed by `kiss.registerPartials()`, often with a comment asserting that v2 defaults to `breaks: true`. That comment was correct when written and has been wrong since beta.1: delete both calls, and set `markdown: { breaks: true }` in config if you actually want the old behaviour. The `registerPartials()` half is why the pair survives upgrades unnoticed — it does not look redundant, because `.md` partials are rendered to HTML at registration, so a later mutation of `kiss.remarkable` really would need a re-register to reach them.
888
922
  - **Unchanged in v2**, so there is nothing to migrate even though it looks load-bearing: the per-page `generate: false` option (default `true`, see `.page()`'s options above) still skips building one page; `callback.call(this, ...)` still binds the `Kiss` instance inside `.generate()`/`.complete()`/`.sitemap()` callbacks; the per-page `ext` option; `copyAssets(sourceDir, targetDir)` to a second directory; `.viewStats()`/`.getModelByID()`. Anything under `this._stack` is internal and unversioned — code reading `_stack[].buildTo`/`_stack[].page.options` for a hand-rolled sitemap works today by accident; `.sitemap()` is the supported way to enumerate registered pages.
@@ -0,0 +1,32 @@
1
+ // Tier 0 of the build-script convention (llms.txt § The build script): one
2
+ // file, and at 27 lines that is not a stage to grow out of — it is the right
3
+ // answer. Split when the custom helpers pass about a third of the file or there
4
+ // are more than about three of them; a `helpers/` folder here would be the cost
5
+ // of the convention with none of its benefit.
6
+ import Kiss from '../../lib/kiss.js'
7
+ import { sharedFolders, site, reportBuildFailure } from '../_shared/site.js'
8
+
9
+ const dev = process.argv.includes('--dev')
10
+
11
+ const kiss = new Kiss({
12
+ site,
13
+ nav: [
14
+ { href: 'index.html', label: 'Home' },
15
+ { href: 'about/us.html', label: 'About us' },
16
+ ],
17
+ folders: {
18
+ src: '.',
19
+ build: '../../public/1-scan',
20
+ // Explicit folders beat the ones derived from `src`, so pages, models and
21
+ // controllers stay local while the look comes from _shared.
22
+ ...sharedFolders,
23
+ },
24
+ verbose: true,
25
+ dev,
26
+ })
27
+ .scan()
28
+ .generate()
29
+
30
+ if (!dev) {
31
+ await kiss.complete().catch(reportBuildFailure)
32
+ }
@@ -1,4 +1,4 @@
1
- /* generated from styles/tokens.json at 2026-09-12T21:06:08.893Z — do not edit */
1
+ /* generated from styles/tokens.json at 2026-09-16T13:52:32.682Z — do not edit */
2
2
  :root {
3
3
  --accent: #0f766e;
4
4
  --accent-ink: #ffffff;
@@ -44,7 +44,7 @@
44
44
  <p>
45
45
  Run this with <code>--dev</code> and the step's <code>watch</code> command is started
46
46
  too &mdash; once, after <code>run</code> has succeeded &mdash; and kept for the session.
47
- Edit <code>10-asset-pipeline/styles/tokens.json</code> while it is up: the tool
47
+ Edit <code>styles/tokens.json</code> while it is up: the tool
48
48
  recompiles the stylesheet on its own, kiss's assets watcher copies it into the build,
49
49
  and the browser reloads. <code>close()</code> ends the watch process, so nothing is
50
50
  left running when the server stops.