kiss-ssg 2.14.0 → 2.16.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.
- package/AIKB/asset-copy.md +7 -0
- package/AIKB/asset-manifest.md +1 -0
- package/AIKB/assets.md +2 -1
- package/AIKB/build-finish.md +4 -0
- package/AIKB/build-report.md +2 -1
- package/AIKB/config.md +14 -6
- package/AIKB/feed.md +2 -2
- package/AIKB/fetch-policy.md +3 -1
- package/AIKB/handlebars-helpers.md +1 -1
- package/AIKB/kiss.md +14 -6
- package/AIKB/llms.md +1 -1
- package/AIKB/model-resolver.md +1 -1
- package/AIKB/package-manifest.md +1 -1
- package/AIKB/partials.md +91 -25
- package/AIKB/pipeline.md +1 -1
- package/AIKB/rebuild.md +5 -1
- package/AIKB/redirects.md +2 -2
- package/AIKB/robots.md +1 -1
- package/AIKB/sitemap.md +4 -4
- package/AIKB/staging.md +12 -3
- package/AIKB/testing.md +4 -1
- package/AIKB/upstream.md +9 -3
- package/AIKB/utils.md +1 -0
- package/CHANGELOG.md +164 -0
- package/GUIDE.md +116 -40
- package/README.md +4 -3
- package/bin/kiss-ssg.js +0 -0
- package/examples/1-scan/src/partials/newsletter.md +1 -1
- package/examples/10-asset-pipeline/src/partials/newsletter.md +1 -1
- package/examples/11-blog/AIKB/last-build.json +2 -1
- package/examples/15-partial-transforms/README.md +50 -0
- package/examples/15-partial-transforms/router.js +74 -0
- package/examples/15-partial-transforms/src/assets/css/site.scss +546 -0
- package/examples/15-partial-transforms/src/layouts/layout.hbs +53 -0
- package/examples/15-partial-transforms/src/pages/brewing.hbs +35 -0
- package/examples/15-partial-transforms/src/pages/index.hbs +38 -0
- package/examples/15-partial-transforms/src/pages/prices.hbs +37 -0
- package/examples/15-partial-transforms/src/partials/callout.hbs +3 -0
- package/examples/15-partial-transforms/src/partials/prices/bar.csv +5 -0
- package/examples/15-partial-transforms/src/partials/prices/beans.csv +4 -0
- package/examples/15-partial-transforms/src/partials/recipes/cafetiere.recipe +10 -0
- package/examples/15-partial-transforms/src/partials/recipes/v60.recipe +10 -0
- package/examples/15-partial-transforms/src/partials/this-week.md +9 -0
- package/examples/15-partial-transforms/transforms.js +139 -0
- package/examples/2-page/src/partials/newsletter.md +1 -1
- package/examples/3-pages/src/partials/newsletter.md +1 -1
- package/examples/4-layouts-and-partials/router.js +4 -2
- package/examples/4-layouts-and-partials/src/layouts/layout.hbs +1 -1
- package/examples/4-layouts-and-partials/src/pages/index.hbs +3 -3
- package/examples/4-layouts-and-partials/src/partials/site/header.hbs +3 -3
- package/examples/4-layouts-and-partials/src/partials/sourcing.md +1 -1
- package/examples/5-helpers/src/partials/tasting.md +1 -1
- package/examples/6-sitemap/src/partials/newsletter.md +1 -1
- package/examples/7-versioned-outputs/src/partials/newsletter.md +1 -1
- package/examples/8-data-fed-site/src/partials/newsletter.md +1 -1
- package/examples/9-migrated-from-v1/AIKB/last-build.json +1 -1
- package/examples/9-migrated-from-v1/src/partials/sourcing-note.md +1 -1
- package/examples/README.md +28 -21
- package/lib/asset-copy.js +16 -0
- package/lib/asset-manifest.js +2 -1
- package/lib/assets.js +79 -3
- package/lib/build-finish.js +26 -4
- package/lib/build-report.js +13 -0
- package/lib/config.js +73 -3
- package/lib/feed.js +1 -13
- package/lib/fetch-policy.js +122 -11
- package/lib/kiss.js +133 -30
- package/lib/package-manifest.js +1 -20
- package/lib/partials.js +211 -63
- package/lib/pipeline.js +2 -1
- package/lib/rebuild.js +4 -2
- package/lib/redirects.js +104 -15
- package/lib/sitemap.js +6 -5
- package/lib/staging.js +37 -6
- package/lib/utils.js +19 -0
- package/llms.txt +12 -13
- package/package.json +13 -9
- package/types/assets.d.ts +10 -0
- package/types/build-finish.d.ts +5 -0
- package/types/build-report.d.ts +9 -0
- package/types/config.d.ts +28 -2
- package/types/kiss.d.ts +49 -21
- package/types/partials.d.ts +141 -7
- package/types/redirects.d.ts +31 -2
- package/types/utils.d.ts +1 -0
- package/examples/10-asset-pipeline/src/assets/css/generated.css +0 -19
package/AIKB/asset-copy.md
CHANGED
|
@@ -53,6 +53,13 @@ into the instance go through it (`kiss._stagedPath()`, `kiss._carry()`, `kiss._r
|
|
|
53
53
|
set it recorded last time before recording this time, which is also what lets a deleted or
|
|
54
54
|
renamed broken stylesheet lose its entry. Failures are `kiss._carry()`-marked: a replay keeps
|
|
55
55
|
them, because an asset re-copy is the only thing that can re-check one.
|
|
56
|
+
- **A source folder that is not there is an `<assets: …>` failure**, owned and cleared the same way
|
|
57
|
+
as a Sass failure, so the copy that finds the folder clears it. `copyAssets` returns
|
|
58
|
+
`missing: true` and this module decides; the only decision is "fail", because the derived
|
|
59
|
+
`config.folders.assets` copy never gets here (the constructor creates that folder,
|
|
60
|
+
`foldersToEnsure`), and an explicit `.copyAssets()` naming a missing folder is an author's
|
|
61
|
+
mistake. Until 2.16 that call did nothing and logged "Copied assets"
|
|
62
|
+
(`test/integration/assets-missing.test.js`).
|
|
56
63
|
- **The key is computed after the copy has run**, not at registration: canonicalising needs the
|
|
57
64
|
path to exist, and two spellings of a destination that does not exist yet could not canonicalise
|
|
58
65
|
and each kept its own key — a failure stranded under the one nothing would reach again.
|
package/AIKB/asset-manifest.md
CHANGED
|
@@ -33,6 +33,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
33
33
|
## Non-obvious behavior
|
|
34
34
|
|
|
35
35
|
- **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
|
|
36
|
+
- **Hashable is necessary, not sufficient.** `lib/assets.js` also leaves a `.css`/`.js` unrenamed when another hashed asset imports it by path, so the manifest maps it to itself like an image (`AIKB/assets.md`). This module only says which extensions may be renamed.
|
|
36
37
|
- **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
|
|
37
38
|
- **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
|
|
38
39
|
- **A map is only written by a copy that finished, so a copy that stopped needs a second record.** `reconcile` runs at the end of a copy; a copy that fails partway has already put files in the build that no map holds, and before `unrecorded` nothing ever removed them (a `git checkout` during `--dev` left a stale asset until restart). They are kept apart from the name map rather than merged into it, because a name has one output: merging a half-copied plain `css/site.css` under a name whose map says `css/site.a1b2c3d4.css` would have reported the last-good hashed file stale and deleted it. A copy with unrecorded outputs counts for `hasOwner`, because its root demonstrably existed, so that root disappearing is a deletion to reconcile, not the typo a missing first root is.
|
package/AIKB/assets.md
CHANGED
|
@@ -22,7 +22,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
22
22
|
- Sass results include `changed`, comparing retained fingerprints of compiled bytes across copies. The fingerprint is computed in memory, without reading the previous CSS file, so hashing may move that file without making an identical compile look changed. A copied CSS sibling wins and is excluded from Sass changes. The watcher swaps one changed stylesheet in place; multiple affected stylesheets use a full refresh; an unchanged Sass save sends no refresh. Failed or removed entries leave the next successful copy to establish a fresh fingerprint.
|
|
23
23
|
- A generated owner refusing a Sass write returns `refused: true`, distinct from `skipped: true` for underscore partials. The copy returns `refused` source paths for diagnostics; restoration uses registry producer identities. Refusals appear in advisory `outputs.collisions`, not the failure list.
|
|
24
24
|
|
|
25
|
-
- Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; so is a root a failed copy had already written from (its unrecorded outputs count for `hasOwner`). A missing first-time source
|
|
25
|
+
- Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; so is a root a failed copy had already written from (its unrecorded outputs count for `hasOwner`). A missing first-time source copies nothing and returns `missing: true`; `lib/asset-copy.js` records it as an `<assets: …>` failure. The check is explicit because fs-extra runs the filter before it checks the source: inside a build the filter refused the root, `fs.copy` resolved having done nothing, and the copy logged "Copied assets" (`test/integration/assets-missing.test.js`, 2026-11-14). Other copies retain their claims. Kiss passes the shared output registry: asset copies yield to generated outputs, warn on collisions, and unlink a stale file only while that copy still owns it. Copy identity uses the requested destination across atomic promotion.
|
|
26
26
|
|
|
27
27
|
- `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
|
|
28
28
|
- **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
|
|
@@ -34,6 +34,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
34
34
|
- **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
|
|
35
35
|
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
36
36
|
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
37
|
+
- **Under `assets.hash`, a file another hashed asset imports by path is not hashed (`importedNames`).** The import sits in the importer's bytes and nothing rewrites it, so a renamed target 404s on a green build, and no link scan or audit reads asset bodies (case built in `planning/reviews/2026-11-10-js-hash-imports.md`; the operator chose this over rewriting imports or a warning, 2026-11-10). Before the hashing loop, every claimed `.js`/`.css` is read and scanned by regular expression (no parser). In JavaScript, the scan matches every quoted string (`'`, `"` or a backtick) that starts `./`, `../` or `/`, whatever surrounds it. That is an allow-list of the safe shape, not a list of import forms: the 2.15.0 list of four forms missed `import /* c */ './x.js'` and `import('./x.js', {…})` (Codex, 2026-11-13), and a false match only leaves one file unhashed. Two properties of the one pattern are load-bearing, each found by a later round. Only the quote that opened the string closes it, so ``'./util`v1.js'`` is read whole (`3f38caf`). And the match is a zero-width lookahead, so `matchAll` tries every quote: a quote that opens no string (in a regex literal, `/"/`) would otherwise begin a false match that swallows the real import after it (`8c0875c`). A scan stops at the next quote of its kind, so the cost stays linear (adversarial 1–4 MB lines took 3–165 ms in the probes). In CSS, it matches `@import` (string and `url()`); a relative specifier resolves against the importer's name, a root-relative one against the build root, and a hit on a claimed name keeps that name, so the manifest maps it to itself and `lib/cache-headers.js` (which writes headers for `name !== output`) gives it none. A URL, a protocol-relative specifier and a JavaScript bare specifier (an import map's) name nothing; a CSS bare one is relative. One `info` line names the files left unhashed. The scan reads the file as emitted before minifying, and minifying keeps specifiers. The consequence a site carries: a changed imported file is fetched fresh only when the host's default cache lets it, while its importer's hashed name stays cached. **Seen only within one copy**: `claimed` is the one copy's emitted names (`recordEmitted` runs per `assets` source), so an import of a file another source writes (a second `assets` source, a vendor folder copied separately) is not seen, and a root-relative specifier is joined to the root as written, with no `siteUrl` path prefix stripped, so `import '/docs/js/util.js'` on a site under `/docs/` names `docs/js/util.js`, not `js/util.js`. In both the target is hashed and the import breaks; the operator chose to document that boundary and change no code (2026-11-11): a module graph imports relatively within one assets folder (stated in `GUIDE.md`'s `assets.hash` passage). Not covered: `sourceMappingURL` (a comment, not a string), a computed dynamic specifier (a template literal with `${}` is matched only as written), and an imported stylesheet that `assets.purge` names, which the settle re-hashes (`lib/css-purge.js`). The scan adds one read of each hashable file per copy.
|
|
37
38
|
- **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims, plus outputs an earlier failed copy wrote and never recorded; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
|
|
38
39
|
- **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
|
|
39
40
|
- `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -29,6 +29,10 @@ Each is the body of the `Kiss` private method named beside it:
|
|
|
29
29
|
- `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
|
|
30
30
|
- `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
|
|
31
31
|
`_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
|
|
32
|
+
`findRedirectChanges` also records every alias `normaliseAlias` refused, from
|
|
33
|
+
its own `collectAliases` call (the warning is `writeRedirects`', said once),
|
|
34
|
+
as `dropped: [{ alias, page, reason }]`, and a site whose only news is a
|
|
35
|
+
dropped alias reports a `redirects` block rather than `null`.
|
|
32
36
|
- `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
|
|
33
37
|
- `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
|
|
34
38
|
- `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
|
package/AIKB/build-report.md
CHANGED
|
@@ -12,7 +12,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
13
13
|
- `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
|
|
14
14
|
- `pages` — `{ view, buildTo, ok, hash, id, canonical, markdown }` per stack entry, in registration order; `markdown` is the Markdown copy the page wrote beside itself (`KissPage.markdown`, mapped to the real build folder like `buildTo`), `null` when it wrote none; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
15
|
-
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
|
|
15
|
+
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report. An output the build asked for that could not be written or lacked an input is one (`<sitemap>`, `<llms>`, `<feed>`, `<robots>`), so `ok` is false and `sitemap`/`llms`/`feed` stay `null` beside it; `Kiss._recordOutputFailure` re-derives the report in place when it lands after a settle (`AIKB/kiss.md`).
|
|
16
16
|
- `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
|
|
17
17
|
- `sitemap` — the `sitemap.xml` this build wrote, or `null`.
|
|
18
18
|
- `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
|
|
@@ -56,6 +56,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
|
|
|
56
56
|
- **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
|
|
57
57
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
|
58
58
|
- **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
|
|
59
|
+
- **`redirects.dropped` is the last key of `redirects`**, `[]` when none: `{ alias, page, reason }` for each alias the engine refused (`reason` is `whitespace`, `pattern` or `query-only`; `page` is the view, else the output path, mapped like a path). Advisory, and `formatReport` (so `kiss-ssg check --summary`) prints one `alias dropped: <alias> on <page> (<reason>)` line each, beside the collisions. Before it the refusal was a `logger.warn` and nothing else.
|
|
59
60
|
- **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
|
|
60
61
|
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. Since 2.8.6 `disallowAll` means `*` or a search engine's crawler is blocked, and `blocked` (appended after it) names every agent kept out of the whole site; an older record has no `blocked`. `lib/robots.js` has the rest.
|
|
61
62
|
|
package/AIKB/config.md
CHANGED
|
@@ -7,12 +7,20 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
|
-
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
|
-
- `resolveFetch(fetch)` → the merged fetch block in the form the engine reads: a `retries` or `
|
|
13
|
-
- `assertFetchIsValid(fetch)` → `void`, throws on a merged fetch block whose key is not its shape: `retries` a whole number of 0 or more, `timeout` a finite number of 0 or more (`0` is no timer), `headers` a plain object (a null prototype counts; not a `Headers` instance or pairs), `cache` a non-empty string or `false` (not `null` or `''`). The message names the key (`config.fetch.retries must be …`) and never the value.
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `ids` (`'view'`; `'path'` makes a page's default id where it is served, `lib/page-registry.js`; any other value throws, from `IDS_VALUES`), `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `transforms` (`{}`; see `resolveTransforms`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `packages` (see `DEFAULT_PACKAGES`), `consent` (`null`; see `AIKB/consent.md`), `embeds` (`null`; the `{{embed}}` wording of a site with no `consent`, resolved by `lib/embed.js` `resolveEmbeds`, which refuses it beside `consent`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
|
+
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`, `maxBytes: 5e7`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
|
+
- `resolveFetch(fetch)` → the merged fetch block in the form the engine reads: a `retries`, `timeout` or `maxBytes` given as a string of digits (`'5000'`, `'1.5'`; what an environment variable is, and what 2.13.2 took through `setTimeout`'s coercion) becomes the number, and the block is then held to its shape by `assertFetchIsValid`. Called by `resolveConfig`, whose result carries the numbers, and again by `lib/fetch-policy.js`'s `fetchModel`, for a caller that never went through `resolveConfig`.
|
|
13
|
+
- `assertFetchIsValid(fetch)` → `void`, throws on a merged fetch block whose key is not its shape: `retries` a whole number of 0 or more, `timeout` a finite number of 0 or more (`0` is no timer), `headers` a plain object (a null prototype counts; not a `Headers` instance or pairs), `cache` a non-empty string or `false` (not `null` or `''`), `maxBytes` a whole number of 0 or more (`0` reads any size). The message names the key (`config.fetch.retries must be …`) and never the value.
|
|
14
14
|
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`), `minify: true` (outside dev, copied `.css`/`.js` are minified before hashing, `lib/asset-minify.js`; anything but a boolean throws), `purge: null` (the stylesheets purged against the written pages at the settle, resolved and checked by `lib/css-purge.js` `resolvePurge`), `images: null` (`{{image}}`'s recipe, filled in by `lib/images.js` `resolveImages` to `{ widths: [480, 768, 960, 1440, 1920], formats: ['avif', 'webp'], quality: { avif: 50, webp: 75, jpeg: 80 }, cache: '.kiss-cache/images' }`, merged one level deeper than the rest of the block, `quality` per format; an unknown key or a bad value throws at `new Kiss()`; so does a `cache` that is the project or a filesystem root, or that is inside or contains any folder in `folders` (`assertImageCacheIsSafe`, after the folders resolve), because `kiss-ssg images` prunes it), plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
15
15
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
|
16
|
+
- `resolveTransforms(transforms)` → the site's partial transforms, keyed by extension without the dot (`AIKB/partials.md`). `null` or `undefined` is `{}`. It throws, naming the key, on:
|
|
17
|
+
- a value that is not a plain object;
|
|
18
|
+
- a key that is not letters and digits;
|
|
19
|
+
- `html` or `hbs` in any case (they "take no transform": they are the compiled templates);
|
|
20
|
+
- a `render` that is not a function, a `compile` that is not a boolean, or a `setup` that is not a function.
|
|
21
|
+
|
|
22
|
+
An entry for `md` or `txt` replaces the built-in. The merge over the built-ins happens in `lib/partials.js`, which is the only place that knows them.
|
|
23
|
+
|
|
16
24
|
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
|
|
17
25
|
- `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), `ignore: []`, the check ids not to run, and `privacyPage: null`, the override of the convention `privacy-link-missing` uses to find the privacy page (id `privacy`, else `privacy.html` or `privacy/index.html`).
|
|
18
26
|
- `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
|
|
@@ -52,11 +60,11 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
52
60
|
- **There is no `folders.root`.** v2 removed it: it was in `DEFAULT_FOLDERS`, in this doc and in llms.txt, but no engine module ever read it and `foldersToEnsure` excluded it, so a consumer setting it got the default layout with no warning (review finding D-06). A `root` key in a user config is now just an unknown key — `resolveFolders` spreads user folders in, so it passes through untouched and nothing reads it.
|
|
53
61
|
- **`folders.aikb` is source, not output, and is the one folder that is neither derived nor created.** It defaults to `./AIKB` rather than `./src/aikb` because the site's knowledge base is written for people and belongs at the root of the repository beside `README.md`; it is absent from `DERIVED_FROM_SRC` so setting `folders.src` does not drag it under the source tree, and absent from `foldersToEnsure` so a site that has never recorded one never finds an empty `AIKB/` it did not ask for. It sits outside `folders.build`, so `cleanBuild` never touches it and `cleanBuild: 'atomic'` never stages it.
|
|
54
62
|
- **Setting the key is not opting in.** `folders.aikb` only says _where_ a knowledge base would live; what opts a site in is `<folder>/site-map.json` being there, which only `npx kiss-ssg aikb <script>` writes. So the default value is harmless in a repository whose `AIKB/` is something else entirely — this one's holds the module notes you are reading — and a build there reports `aikb: null` rather than adopting them. `null` switches the folder off altogether: nothing is built, nothing is reported, and no error is logged, because a site with no knowledge base is an ordinary site (`AIKB/aikb.md`).
|
|
55
|
-
- A folder set to `null` in the resolved config is legal: `foldersToEnsure` skips creating it, `
|
|
63
|
+
- A folder set to `null` in the resolved config is legal: `foldersToEnsure` skips creating it, `registerPartials`/`copyAssets` skip scanning/copying it (each checks for a falsy folder before globbing).
|
|
56
64
|
- `foldersToEnsure` fixes a v1 copy-paste bug where most folders were only created conditionally on `assets` being set; here each folder stands on its own regardless of the others.
|
|
57
65
|
- **The three dev-server keys are defaulted here and nowhere else.** `lib/dev-server.js` takes `port`, `livereloadPort` and `host` as required parameters, so a reader has one place to look for their values. `livereloadPort` exists so two sites can run in dev mode at once (a clash used to kill the process — review finding D-01) and is also the port `lib/kiss-page.js` injects into the dev-mode reload `<script>`. `devHost` defaults to loopback: dev mode used to bind every interface while logging `localhost` (D-13); `'0.0.0.0'` is the documented opt-out for previewing on another device.
|
|
58
66
|
- `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
|
|
59
|
-
- **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `
|
|
67
|
+
- **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries`, `cache` and `maxBytes`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy. Fetching reads the instance's own `config.fetch` (`lib/page-registry.js` hands `kiss.config.fetch` to the model resolver); a page's `config: { fetch: … }` changes only that page's copy, which nothing fetches with. **Every key of the merged block has a shape, read by `resolveFetch` and checked once by `assertFetchIsValid`** (2026-11-07; a string of digits for `retries` or `timeout` is read as the number, because an environment variable is a string and 2.13.2 accepted it): before, `retries: NaN` (a `Number(process.env.X)` of an unset variable) ran the attempt loop no times and every URL model failed with `undefined`, and a `null` header object with a `cache` set threw a `TypeError` from the cache key's `Object.entries(null)` (inferred from the code, not run). The message names the key and never the value, because a value in this block can be read from the environment and `headers` is where a token goes. Header _values_ are not checked here: an unset `process.env.TOKEN` is `undefined`, which `redactFetchHeaders` shows as it is, and a value the fetch would refuse is `lib/fetch-policy.js`'s `checkHeaders`.
|
|
60
68
|
- **A page's copy of the config never holds a header value.** `GUIDE.md` tells a site to put an API token in `fetch.headers`, and each page's `options.config` is its render context, so `{{{stringify this}}}` — the guide's own way to look at a page's data — or a controller returning `{ ...options, site: options.config }` would print it into the built page and, in dev, into the `.json` sibling. `lib/page-registry.js` therefore makes each copy through `redactFetchHeaders`: the header names stay (a template can still ask which headers a site sends), every value reads `'[redacted]'`, and a page's own override that supplies `fetch.headers` is redacted the same way. `kiss.config` itself is never redacted, because it is what the fetch sends. Only `fetch.headers`: it is the one place kiss documents for a secret, and anything else a site puts in its config reaches its templates by design (`{{config.x}}`, below).
|
|
61
69
|
- **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
|
|
62
70
|
- **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
|
package/AIKB/feed.md
CHANGED
|
@@ -28,7 +28,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
|
|
|
28
28
|
- **A date is read from the page option first and the resolved model second, under one field name.** `options[dateField] ?? options.model?.[dateField]` — `dateField` governs both lookups, so a site that dates a post in its `models/posts/*.json` and a site that dates it in the `.page()` call read the same key and neither has to know which the other used. By settle time `options.model` **is** the resolved data (the `.pages()` fan-out has already given each item its own), which is what makes the model lookup possible at all. It is guarded for `null` (a page with no model) and for an array (a non-`dynamic` page handed an array model, which has no date of its own); both are undated, neither is an error.
|
|
29
29
|
- **A page with no date is silent; a date that cannot be read warns once.** Most of a site is undated — an About page is not a post — so warning per undated page would bury the feed's own output in noise on every build. A value that was _given_ and cannot be parsed is a typo the author wants to hear about, so it gets one `logger.warn` naming the page and the value, and the page joins the undated count. Neither ever throws: a feed is not the render error channel, and a build minus one item is still the build the author asked for. `writeFeed` logs the total as one `info` line, which is the only place the count surfaces — `report().feed` is the path, not the tally.
|
|
30
30
|
- **The `url` is the sitemap's `<loc>`, character for character** — a home page (`https://e.com/`), a file page (`/about`) and a directory index (`/courses/`) alike, because the derivation is the same two `utils` functions. An RSS `<guid isPermaLink="true">` that disagreed with the sitemap would be a second identity for one page, and a feed reader would show the post twice after a rename.
|
|
31
|
-
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. `lib/
|
|
32
|
-
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it
|
|
31
|
+
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. The function is `escapeXml` in `lib/utils.js` (a named export, moved there from this file), shared with `lib/sitemap.js`, which escapes every value it writes since work order 06.
|
|
32
|
+
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it, does not invoke the callback and records a `<feed>` build failure (`_recordOutputFailure`). A missing `siteUrl` or `title` (the writer's `'no-site-url'`/`'no-title'`) is the same failure, worded `feed.xml needs config.siteUrl: …, or remove .feed()` (the file name is `feedFileName(options)`). Same contract as `writeSitemap` and `writeLlms`.
|
|
33
33
|
- **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt`, `redirects.json` and the redirect host file are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
|
|
34
34
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing feed left to skip.
|
package/AIKB/fetch-policy.md
CHANGED
|
@@ -6,7 +6,7 @@ Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `async fetchModel(url, { fetchImpl = globalThis.fetch, logger, headers, timeout, retries, cache })` → `Promise<data>` — the parsed JSON body of a 2xx response. Every policy key is optional and falls back to `DEFAULT_FETCH` from `config.js`; a key passed as `undefined` takes its default too, as `resolveConfig` does (a spread let it replace the default, and `retries: undefined` ran the loop no times and rejected with `undefined`). The merged policy goes through `config.js`'s `resolveFetch` before anything else (a `retries` or `timeout` given as a string of digits is read as the number, then every key is held to its shape), for a caller that never went through `resolveConfig`, so a `retries` or `timeout` that is not its shape is kiss's own error naming the key. Rejects with a fresh `Error` (no `cause`, no own properties) on a URL that cannot be fetched, a non-2xx response, a network failure, a 2xx body that is not JSON or a timeout, whose message kiss writes from parts that cannot carry a secret (below), and on a header the fetch would refuse, whose message names the header and never its value (below). A failed cache write does not reject: it warns and the data is returned.
|
|
9
|
+
- `async fetchModel(url, { fetchImpl = globalThis.fetch, logger, headers, timeout, retries, cache, maxBytes })` → `Promise<data>` — the parsed JSON body of a 2xx response. Every policy key is optional and falls back to `DEFAULT_FETCH` from `config.js`; a key passed as `undefined` takes its default too, as `resolveConfig` does (a spread let it replace the default, and `retries: undefined` ran the loop no times and rejected with `undefined`). The merged policy goes through `config.js`'s `resolveFetch` before anything else (a `retries` or `timeout` given as a string of digits is read as the number, then every key is held to its shape), for a caller that never went through `resolveConfig`, so a `retries` or `timeout` that is not its shape is kiss's own error naming the key. Rejects with a fresh `Error` (no `cause`, no own properties) on a URL that cannot be fetched, a non-2xx response, a network failure, a 2xx body that is not JSON or a timeout, whose message kiss writes from parts that cannot carry a secret (below), and on a header the fetch would refuse, whose message names the header and never its value (below). A failed cache write does not reject: it warns and the data is returned.
|
|
10
10
|
|
|
11
11
|
## Depends on
|
|
12
12
|
|
|
@@ -20,6 +20,8 @@ Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to
|
|
|
20
20
|
|
|
21
21
|
- **A header the fetch would refuse is refused first, by name.** Node's `fetch` quotes a header value in the error it raises for one it cannot send (`Headers.append: "<value>" is an invalid header value.`), and `GUIDE.md` tells a site to put its token in `fetch.headers`: a token with a line break or a NUL in it, as a value read from a file can have, was printed twice by the failed-model log line (the review of `7c527a3`, 2026-11-06; measured on Node 24.14.0 through a whole build). The message is kiss's own now in any case (below), but refusing first also keeps the value out of anything Node might print on the way. So `checkHeaders(headers)` runs after the cache read and before the first attempt: it builds the same `Headers` the fetch would, and on a refusal throws kiss's own error, `fetch.headers: "Authorization" cannot be sent — … The value is not shown.`, naming each refused header of a plain record and nothing of what it holds (any other shape is refused without a name). It is outside the attempt loop, so it is never retried and the fetch is never called. `Headers` and the fetch's `Request` refuse the same inputs (measured on Node 24.14.0 for `null`, a record, pairs, a `Headers`, a `Map`, a bad name and a string), so no header set that worked is refused; a value that only ends in a line break is trimmed by both and still sent. A character above 255 is refused by Node with a message that does not quote the value; it gets kiss's message too. `test/unit/fetch-policy.test.js` drives it with the real `fetch`, counted.
|
|
22
22
|
- **Order of operations: policy check → cache read → URL check → header check → each attempt (fetch and parse, under one timer) → non-2xx guard → retry decision → cache write.** The cache is consulted before the network, so a cached model costs no request at all; the body is written only after a 2xx response has parsed, so an error envelope can never become a cache entry (the guard that `model-resolver.js` gained for the same reason — review finding C2 — stays authoritative over the cache). The URL check (`URL.canParse`, then credentials) and the header check are outside the attempt loop: neither can pass on a second attempt, so no fetch is made and the message carries no attempt count (2026-11-07; before, an unparseable URL was fetched `retries + 1` times and reported from whatever the fetch threw). The cache write is outside the attempt's `catch`: a disk that refuses it is a `logger.warn`, `Model cache write failed: <cache file> → <code>; the model is used without caching` (the file is the hashed name, never the URL), and the model is returned. It is not retried, since fetching again cannot make the disk accept it, and it does not fail the page, since the model arrived (2026-11-07; before, it rejected).
|
|
23
|
+
- **A redirect never carries custom headers to another origin.** With `fetch.headers` non-empty, `attempt` passes `redirect: 'manual'` and follows a 3xx itself (the `location` header is readable on a manual response: measured on Node 24.14.0 by the redirect tests, which use two real servers on two ports). A same-origin redirect is followed with the headers, at most 5 times (`MAX_REDIRECTS`; the sixth is `more than 5 redirects, with fetch.headers set`); another origin fails the model: `Model fetch failed: <url> → redirected from <origin> to <origin>, and fetch.headers is set, which is never sent to another origin: fetch the final URL directly, or remove the header`. The origins are parsed (`URL.origin`), the header values are never in the message, and each of these errors is marked in `ours`. Each of these (the cross-origin refusal, the sixth redirect and an unparseable `location`) is also marked in `refusals`, and the retry loop ends at once on one, as on a 4xx: the same request meets the same policy, so with `retries: 3` the first server sees one request and the message has no `(after N attempts)` (2026-11-11; before, it was retried with backoff). With no headers nothing is passed and the fetch follows redirects as before (its own limit, `redirect count exceeded`). The body is size-capped by `fetch.maxBytes` (below).
|
|
24
|
+
- **A body larger than `fetch.maxBytes` is a refusal (`bodyWithin`).** Added 2026-11-13: the architecture review's item 8 asked for it and Phase 0 shipped without it (this doc said the timeout was the limit, but a fast server sends a great deal inside one). A declared `Content-Length` over the limit cancels the body unread; an undeclared one is read through `response.body.getReader()`, counted, and cancelled on the chunk that crosses it, then decoded with `TextDecoder` (which drops a BOM, as `response.json()` does) and `JSON.parse`d. The error, `the body is larger than fetch.maxBytes (<n> bytes): raise it, or 0 for no limit`, is in `ours` and `refusals`, so it is not retried. `0` skips the reader and calls `response.json()`, as does a response with no `body` stream (a test stub). The default, 50,000,000, is fifteen times the largest CMS dump in the fleet (3.3 MB, measured 2026-11-13). A stub passed as `fetchImpl` sees `redirect: 'manual'` in its options only when headers are set.
|
|
23
25
|
- **The cache key is a hash of the URL _and_ the header set**, with the header entries sorted so key order cannot fork the identity. The same URL fetched with two different bearer tokens is two different identities upstream, and one build's body must never be served to the other's request.
|
|
24
26
|
- **The cache has no TTL and no expiry sweep.** A consumer clears the directory (or a single `<hash>.json` inside it) to refetch. That also makes stale-on-error unreachable rather than merely unimplemented: an entry is only ever written after a success, and once written it is hit before any request is made, so there is no state in which a fetch fails _and_ a cached body exists. Stale-on-error only becomes a real option if a TTL is added — which is why `cache` is a plain directory string, not a `{ dir, staleOnError }` object.
|
|
25
27
|
- **An unreadable cache entry is a miss, not a failure**: a truncated or hand-edited file logs one warning and the request goes to the network. A half-written file (a build killed mid-write) must not poison every later build.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
5
|
+
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `image`, `embed`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
package/AIKB/kiss.md
CHANGED
|
@@ -19,9 +19,11 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
19
19
|
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder — each page's `{ view, buildTo, runCount, options }`, the options without `config` (`AIKB/page-registry.md`) — and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
20
20
|
- `.generate(callback)` — sets the `_generateRequested` latch, awaits `_promises`, renders every stack entry with `runCount === 0` once (each render caught, failures collected on `_failures`), awaits the writes, invokes `callback` through `_runCallback` (so a callback that throws, or returns a rejecting promise, is collected on `_failures` too); returns `this`.
|
|
21
21
|
- `.complete(callback)` — awaits `_settle()` (drain, then render whatever the drain left unrendered, repeated until the stack is stable), then **rejects with an `AggregateError`** if `_failures` is non-empty and `_failuresReported` is not yet set; otherwise invokes `callback` with the resolved data (never on a failed build) and returns that data (a `Promise`, unlike the other chainable methods). Under `cleanBuild: 'atomic'` this is also the promotion point: a settle with no failures promotes the staging folder, a settle with failures discards it — and in check mode a settle with no failures discards it too. It is also where the build report is assembled (`_finishBuild()`), so `report()` and `err.report` are set by the time either outcome reaches the caller.
|
|
22
|
-
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`.
|
|
23
|
-
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary
|
|
24
|
-
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title
|
|
22
|
+
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`. No `siteUrl`, or a write error, is a `<sitemap>` build failure (see the output-failures entry below).
|
|
23
|
+
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary`, or a write error, is a `<llms>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
24
|
+
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title`, or a write error, is a `<feed>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
25
|
+
- `.robots(options, callback)` — the fourth file from the same chain, writing `robots.txt` (`AIKB/robots.md`): records `_robotsRequest` (so a watch rebuild re-issues it), awaits `_promises`, writes the file via `writeRobots()`, records `_robotsResult` (`file`, `agents`, `disallowAll`, `blocked`, `sitemaps`) for the report's `robots` field, logs a `notice` when the whole site is disallowed and an `info` line when a named agent is, invokes `callback` (with the rendered text) through `_runCallback` unless an existing file was left in place under `overwrite: false`; returns `this`. Its `Sitemap:` line is emitted only when `.sitemap()` was called — the `.then()` bullet below says why that is read late.
|
|
26
|
+
- `config`, `logger`, `verbose`, `handlebars` and `remarkable` are public instance fields rather than methods: the resolved config, the (staging-wrapped) logger, the `verbose` flag, and the per-instance Handlebars and Remarkable objects a site may register against.
|
|
25
27
|
- There is **no** `.aikb()` method, and deliberately so: the site's knowledge base is written by `npx kiss-ssg aikb <script>`, which sets `KISS_AIKB` for the build it spawns. `_finishBuild()` reads that variable, and no chainable call turns it on (see `_buildAikb()` in `AIKB/build-finish.md` and `AIKB/aikb.md`).
|
|
26
28
|
- `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
|
|
27
29
|
- `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and queued asset reconciliation (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is in `AIKB/rebuild.md` — and every rebuild it asks for goes through the serial queue.
|
|
@@ -31,7 +33,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
31
33
|
|
|
32
34
|
## Depends on
|
|
33
35
|
|
|
34
|
-
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`.
|
|
36
|
+
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./cache-headers.js` (the renderers it re-exports), `./images.js` (`createImageIndex`), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`. It also names `./build-report.js`, `./aikb.js`, `./packages.js` and `./css-purge.js` in JSDoc types only, which load nothing at run time.
|
|
35
37
|
|
|
36
38
|
## Depended on by
|
|
37
39
|
|
|
@@ -65,10 +67,11 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
65
67
|
- **`'atomic'` is a one-shot-build feature, and dev mode says so.** With `dev: true` the promotion point is the wrong shape — the dev server serves `folders.build` from construction and every rebuild would have to promote — so `_setupFolders()` degrades `'atomic'` to `true` (wipe at construction, build in place) and logs one `notice` saying that atomic promotion applies to one-shot builds. A **non-dev** `.watch()` is the one seam left: it stages, promotes at the first successful `complete()`, and behaves as an ordinary watch after that. A build that never calls `complete()` never promotes, and `close()` removes the staging folder — which is also what clears a discarded one that a later write recreated (`_discardStaging` deliberately keeps `_stagingDir` set for exactly that).
|
|
66
68
|
- **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.
|
|
67
69
|
- **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 (dev scoped re-renders have not rewritten every page; non-dev whole-site replays do scan after cleanup), 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. **Under check mode it is one `notice` with the count instead** (`N broken links (listed in the check report)`): `kiss-ssg check` sends the build's log to stderr and the report to stdout, a terminal shows both, and listing the findings in each printed every one twice — 2,756 findings as 5,512 lines on a real site. The report is the list under check, whether `--summary` prints it or the JSON carries it.
|
|
68
|
-
- **The launch-readiness audit runs directly after the link scan, for every one of the link scan's reasons** (`lib/audit.js`, `AIKB/audit.md`). `_runAudit({ quiet })` is called on the next line after `_checkLinks(...)` in `complete()`'s settle path, above the three-way branch, so the pages and the rest of the build folder are still where they were written — `buildDir` is `_writeRoot`, never `config.folders.build`, because under `'atomic'` and under check the pages are in staging. The per-page facts were extracted at write time (`KissPage.audit`, `AIKB/kiss-page.md`) from the pages whose `hash !== null`; each is handed over with `canonicalElsewhere` (the page's `canonical` option is a non-empty string — the field the report already emits, with no URL comparison, so no URL policy is involved) and its own `siteUrl` (the page's merged `options.config.siteUrl`, falling back to the instance's — the `{{canonical}}` rule, `siteUrlFor` in `lib/handlebars-helpers.js`). `ownsFolder` is `_ownsFolder` — the author's `cleanBuild !== false`, captured in the constructor **before** check mode rewrites `cleanBuild` to `'atomic'`, so `check` skips the folder-walk checks exactly where the real build does (reading the config there made `check` tell example 7's shared-folder archive instance to add a 404 page, 2026-09-27) — and `debugWritten` is `_debugWritten`, set by `viewStats()` when it starts the `debug.json` write — state rather than disk, because that write is not awaited. `extensionLess` is `config.extensionLess`, passed so a cross-page `#fragment` is resolved to the page the link scan resolves the same href to (2026-10-20, `fragment-missing`). Skipped, leaving `_audit` `null`, in dev (so on every watch rebuild), under `config.audit.check: false`, and — its one skip of its own — **when `_failures` is non-empty**: a page that failed has no facts, so site-level findings would be false and would bury the real failures. Latched on `_audit` for the nested-`complete()` reason, reset beside `_links` in `_replay()` and at the orphan/restore sweep (reset **then re-run**), and passed through `_reportInputs()` as the last key, so `_refreshReport()` carries it. Logging shares `auditLines()` with `formatReport` (`lib/build-report.js`) over the reported paths, so the build log and `check --summary` cannot word a finding differently: one `info` line (`Audit: N pages, M findings`, or `none
|
|
70
|
+
- **The launch-readiness audit runs directly after the link scan, for every one of the link scan's reasons** (`lib/audit.js`, `AIKB/audit.md`). `_runAudit({ quiet })` is called on the next line after `_checkLinks(...)` in `complete()`'s settle path, above the three-way branch, so the pages and the rest of the build folder are still where they were written — `buildDir` is `_writeRoot`, never `config.folders.build`, because under `'atomic'` and under check the pages are in staging. The per-page facts were extracted at write time (`KissPage.audit`, `AIKB/kiss-page.md`) from the pages whose `hash !== null`; each is handed over with `canonicalElsewhere` (the page's `canonical` option is a non-empty string — the field the report already emits, with no URL comparison, so no URL policy is involved) and its own `siteUrl` (the page's merged `options.config.siteUrl`, falling back to the instance's — the `{{canonical}}` rule, `siteUrlFor` in `lib/handlebars-helpers.js`). `ownsFolder` is `_ownsFolder` — the author's `cleanBuild !== false`, captured in the constructor **before** check mode rewrites `cleanBuild` to `'atomic'`, so `check` skips the folder-walk checks exactly where the real build does (reading the config there made `check` tell example 7's shared-folder archive instance to add a 404 page, 2026-09-27) — and `debugWritten` is `_debugWritten`, set by `viewStats()` when it starts the `debug.json` write — state rather than disk, because that write is not awaited. `extensionLess` is `config.extensionLess`, passed so a cross-page `#fragment` is resolved to the page the link scan resolves the same href to (2026-10-20, `fragment-missing`). Skipped, leaving `_audit` `null`, in dev (so on every watch rebuild), under `config.audit.check: false`, and — its one skip of its own — **when `_failures` is non-empty**: a page that failed has no facts, so site-level findings would be false and would bury the real failures. Latched on `_audit` for the nested-`complete()` reason, reset beside `_links` in `_replay()` and at the orphan/restore sweep (reset **then re-run**), and passed through `_reportInputs()` as the last key, so `_refreshReport()` carries it. Logging shares `auditLines()` with `formatReport` (`lib/build-report.js`) over the reported paths, so the build log and `check --summary` cannot word a finding differently: one `info` line (`Audit: N pages, M findings from K checks`, or `none`; the checks are counted because the lines below are one per check, and "8 findings" over four lines read as a miscount to a clean room, 2026-11-14), then one `notice` per line. **Under check the notices are not logged**: the info line gains `(listed in the check report)`, because the build log goes to stderr beside `--summary`'s stdout and every finding printed twice (diploma-msc's real check, 2026-10-02) — the same rule `_checkLinks` follows for broken links. Advisory — it moves neither `ok` nor the exit code.
|
|
69
71
|
- **The redirect files are 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, rules, json: _redirectsJsonPath, format: _redirectsFormat, files: _redirectsFiles, 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.
|
|
70
72
|
- **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`, `lib/check.js`), 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()` in `AIKB/build-finish.md`). 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.
|
|
71
73
|
- **`config.folders.build` is the author's folder throughout a staged build; the staging path is the engine's `_writeRoot`.** It used to be the other way round — `_setupFolders()` repointed `config.folders.build` at the staging sibling and `_promote()` pointed it back — which routed every write in one move and also handed the staging name to everything that reads config: a template rendering `{{config.folders.build}}`, a controller, and a site's `complete()` callback under check, where nothing ever pointed it back because a check never promotes. Measured on student-handbooks during the 2.5.0 fleet upgrade: `kiss-ssg check` made its callback print `Handbooks generated to: ./handbooks/test.kiss-staging-…`. Now the write sites read `_writeRoot` and the writer modules get `_writeConfig`, config is never touched, and `test/integration/staging.test.js` pins it three ways (the callback under check, the template under `'atomic'`, and a gate that walks every written file, the report, the `KISS_REPORT` line and `last-build.json` for the staging name). The one reader that sees the staging path on purpose is a pipeline step's `KISS_BUILD`: a tool's output has to land in the build being staged, or the swap would leave it behind.
|
|
74
|
+
- **An output the build asked for that fails is a build failure: `<sitemap>`, `<llms>`, `<feed>`, `<robots>`.** Each of `.sitemap()`, `.llms()`, `.robots()` and `.feed()` pushes its run onto `_generating`, which `_drain()` awaits, so the failure is on `_failures` before `complete()`'s settle reads it: the write's `.catch` and the missing-input branch (a writer status other than `written`/`skipped`: `no-site-url`, `no-title`, `no-summary`, turned into an error by `outputInputError`) both call `_recordOutputFailure(view, error)`, which logs, pushes `{ view, buildTo: null, error }` and calls `_refreshReport()`. The refresh is the existing in-place mechanism (a no-op before the first settle, since `_report` is null then); it is what makes an output requested after a build settled (a call in a `generate()` callback that fires late) still move `report().ok`. Not `_carry`ed: a whole-site replay clears it and re-issues the request, which fails again if it still must. Before this change these only logged, and `report().sitemap/feed/llms` was `null` exactly as for an output never asked for. The Markdown copy beside them was already a failure (`_writeMissingCopies`).
|
|
72
75
|
- 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 build failure(s): <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, and so does any `<pseudo-view>` even when it _has_ a `buildTo`. **The count noun used to be "page(s)", and that was a lie for every pseudo-view on `_failures`** — `<site helpers>`, `<pipeline>`, `<redirects>`, `<dev server>`, `<sass: <source folder>/<file>>`.
|
|
73
76
|
- **A Sass failure is identified by the COPY it came from**, not by the file's name within it: `<sass: src/assets/css/theme.scss>`, project-relative when the source folder is inside the project and absolute when it is not. Two asset roots can each hold `css/theme.scss`, and identifying a failure by the source-relative name alone meant a second `.copyAssets()` whose own copy compiled **cleared the first root's failure** — a green verdict with the primary stylesheet missing. Each copy clears the set of failures it recorded last time (by object identity, `AIKB/asset-copy.md`) rather than by the files it just returned, which is also what lets a **deleted or renamed** broken stylesheet lose its entry: it is absent from the new results, so a per-file clear could never reach it, and `_failures` is otherwise cleared only by `_replay()`, which an asset re-copy is not. A helpers module reported as `1 page(s) failed to build: .../helpers/index.js` sends the author to look at their pages; the label is the useful half, and the path is already in the error logged above it. `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).
|
|
74
77
|
- **A replay sweeps `_failures`, it does not empty it** (`carriedAcrossReplay`). A whole-site replay re-runs every page, every controller, every callback and the redirects, so clearing those and letting the rebuild say them again is right. It does not re-import the helpers entry, it does not restart the dev server, and it recompiles the stylesheets only when an `assets.pipeline` step is configured — which the ordinary site has not. Emptying the list dropped all three, and each was still true: a site whose `site.scss` would not compile reported `ok` on the next controller save with nothing fixed, no CSS in the build, and the red compile line already scrolled past. So `<sass: …>`, `<site helpers>` and `<dev server>` are carried across a replay and everything else is dropped. Carrying cannot strand a fixed one, because each is cleared by its own producer when that producer runs again (`copyAssets` by the copy that owns it, `_loadHelpers` by name) and something has to run that producer again. **That last clause is where the first version of this was wrong**: it was true only of `config.folders.assets`, because the replay's pipeline branch and the watcher's `assetsChanged` both re-run THAT copy and no other. A root the site registered itself with `kiss.copyAssets(other, dest)` was never re-run, so its failure could not be cleared by anything — fixed on disk, full replay, still failed, for the rest of the session, on a build whose files are all correct. The mirror of the bug the carrying fixed, and the worse half to leave: an author who fixes a file and watches the build stay red stops believing the verdict. So a replay now also re-runs **the copies it has something to re-check** — the ones still holding an unresolved failure, looked up through `_assetCopies` (the copies with an outstanding stylesheet failure, and only those, keyed the same way, holding the caller's UNSTAGED target so a replay re-applies staging; every source folder copied is `_copySources`, which `kiss-ssg images` reads). A healthy root is not re-copied: a model or controller edit cannot have changed it, and copying every asset root on every keystroke is the cost that guard exists to avoid. Note the standing limit this works around rather than removes — **only `folders.assets` is watched**, so an edit under a second root raises no event and is picked up by the next whole-site replay rather than by itself. `<dev server>` has no second attempt at all, which is exactly why dropping it was wrong. **Carriage is recorded, not matched.** The first version tested `view.startsWith('<sass: ')`, and `view` is only an engine sentinel for engine failures: for an inline template it is the TEMPLATE TEXT, which the author writes, so a page registered as `view: '<sass: x> {{custom "y"}}'` recorded a failure no replay would ever drop — permanent, while the page rendered perfectly. The producers now mark their own on `_carriedFailures`, a `WeakSet` of the failure objects: decided at the one place that knows, needing no pruning, and leaving `_failures` entries exactly the `{ view, buildTo, error }` shape `err.failures` hands out. The copy key canonicalises **the deepest existing ancestor** and re-appends the rest, for the same reason in the other direction — `resolve` preserves the spelling, and on a case-insensitive filesystem `./src/assets` and `./src/Assets` are one directory and were two owners, so the owning copy could not clear its own failure. A plain `realpath` with a `resolve` fallback looks equivalent and is not: it throws ENOENT for a target the copy is about to create, falls back to the spelling, and then returns the canonical spelling once the folder exists — one copy, two keys, and the same unclearable failure by a new route (measured with a target inside a symlinked parent). Walking to the deepest existing ancestor makes the key **stable across the folder's creation** for a path whose ancestor exists — which is most of them, and not the leaf itself: two spellings of a destination that does not exist AT ALL yet (the ordinary case on a fresh build) each keep their own tail, and on a case-insensitive filesystem that is one directory under two keys. So the key is computed **after the copy has run**, when the target is there and both spellings resolve to it. Keying there also puts the registration into queue order, written by the run that owns it rather than synchronously by a caller whose copy has not happened yet — which is what let a copy that SUCCEEDED evict a registration a later queued copy still needed. The paths are **resolved at registration** — in the caller's working directory, at the moment they asked — and it is those the copy runs against, not the caller's raw arguments re-resolved when the queue drains. Both legs had the bug: the replayed copy (measured first) and the INITIAL one, where anything moving the working directory between the call and the drain copied a different folder, wrote it somewhere else, and recorded the result under the original's key. `copyAssets` takes a `display` pair so the id and the log line stay the strings the author wrote, and the `<sass: …>` **label is decided at registration too**. It was derived at execution time from the caller's raw argument against whatever the working directory was by then, so one copy's one failure came out relative or absolute depending on where the process happened to be when the queue drained — and a relative label re-read from a different directory resolves to a DIFFERENT file, which may exist and may compile perfectly, sending an author to a stylesheet with nothing wrong with it. It rides on `err.failures`, `report().failures` and the KISS_REPORT line, so it outlives the process that made it — resolving for correctness must not turn every build's asset line into an absolute path nobody typed. Both maps are pruned together when a copy records nothing — a replay re-runs a copy only to re-check a failure, so a copy with none needs no registration. A copy that records nothing deletes its map entry rather than storing an empty set, so a caller copying into a fresh destination each run does not accumulate one entry per success. `<pipeline…>` is deliberately not carried: it exists only on a site with configured steps, and that is the site whose replay re-runs them.
|
|
@@ -80,7 +83,12 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
80
83
|
- `_promises` must only ever contain _handled_ (caught) promises — a bad model rejecting unhandled crashed the process in v1. The `.page()` chain catches every failure and resolves to `{ id, data: null, error }` instead of rejecting; the failure reaches the caller through `_failures`, not through the chain.
|
|
81
84
|
- `_partialNames` holds what the last `registerPartials()` pass registered, and is handed back to the next one. Handlebars never forgets a registration on its own, so without that set a partial deleted from disk would keep rendering its last-known content for the life of the process (review finding B1). The state lives here, not in `partials.js`, because the Handlebars env is per-instance — see `AIKB/partials.md` for why the diff is by name across all four passes rather than per file. A page that still references a deleted partial now fails on the replay: `complete()` collects it in `_failures` and the rebuild queue logs `Error rebuilding site`, and the page's stale output is left alone because it was never rewritten.
|
|
82
85
|
- Handlebars and Remarkable are created fresh per `Kiss` instance so partials/helpers from one instance never leak into another (matters for tests that build multiple sites in one process).
|
|
83
|
-
- **Remarkable is constructed from `config.markdown
|
|
86
|
+
- **Remarkable is constructed from `config.markdown` before `registerPartials()`.** `registerPartials()` binds the built-in `md` transform to `this.remarkable`, so the instance has to exist first. Since 2.16 a `.md` partial renders per call, after Handlebars, so a consumer mutating `kiss.remarkable` after construction (`kiss.remarkable.use(plugin)`) reaches the partials at the next render, as it reaches the `{{markdown}}` helper. Replacing the property with a new instance does not: the transform holds the instance it was given. See `AIKB/config.md` and `AIKB/partials.md`.
|
|
87
|
+
- **Transform setups follow the `_helpersReady` pattern.**
|
|
88
|
+
- **Start and record.** The constructor starts `_setupTransforms()` after `registerPartials()` and parks the promise on `_transformsReady`. `_setupTransforms()` runs `setupTransforms` (`lib/partials.js`) over `config.transforms` (the built-ins have no `setup`), then writes each failure into `_transformFailures`.
|
|
89
|
+
- **Where it is awaited.** `generate()` awaits it beside `_helpersReady` and discards its result, so `data` keeps its indices (`[, , , ...data]`). The scoped watch re-render (`rebuildEntries`, `AIKB/rebuild.md`) awaits it too. It is never pushed onto `_promises`.
|
|
90
|
+
- **What a failure does.** `_transformFailures` is the very `Map` every registered partial reads at render, which fails each page that calls a partial of that extension. Each failure is also recorded once as a carried `<transform .ext>` failure and logged. It is carried because a replay never re-runs a setup.
|
|
91
|
+
- **A partial collision is a `<partials>` failure.** `registerPartials()` drops the previous `<partials>` entries from `_failures` and records one `{ view: '<partials>', buildTo: null, error }` per collision `lib/partials.js` returns. A watch pass that re-registers therefore clears a collision the author fixed, and reports a new one.
|
|
84
92
|
- `export { Kiss as 'module.exports' }` exists purely so CJS `require('kiss-ssg')` returns the class itself on Node ≥22.12, not `{ default: Kiss }`.
|
|
85
93
|
- `config.logger` is the one dependency-injection seam — pass `silentLogger` (from `lib/logger.js`) in tests to keep output quiet. **`kiss.logger` is not that object**: the constructor wraps it in `lib/staging.js` `stagingLogger`, a Proxy that, while a build is staged, writes the build folder back into the paths its `success` and `info` lines name (a1k9training's log named `./docs.kiss-staging-…/_redirects`, 2.13.3). Every other method and property reaches the injected logger unchanged, so a test spying on `config.logger.warn` still sees each call; a spy on `success` or `info` sees the mapped string.
|
|
86
94
|
- Calling `complete()` from inside a `generate()` callback is safe, not a deadlock: `generate()` invokes the callback (through `_runCallback`, which calls it as `callback.call(this, data)`) and never awaits whatever the callback returns, so the `_generating` entry for that `generate()` run settles as soon as the callback returns (its return value or any promise it returns is not part of what `_drain()` waits on — which is also why `_runCallback` gives a returned promise its own `.catch` rather than pushing it onto `_generating`). `complete()`'s `_drain()` therefore still sees that run resolve and proceeds normally. It is safe on a _failing_ build too, which it was not before A-01: the report-once latch means only one of the two concurrent `complete()` calls rejects, so the one the consumer did not attach a handler to resolves instead of taking the process down. A real downstream site (diploma-msc's `generate.js`) relies on this pattern, calling `complete()` from within a `generate()` callback. Since A-03 the callback's own promise _is_ waited on — by `_drain()`, not by the `generate()` run — so the re-entrancy guard moved there: `complete()` reads `_drainDepth` **synchronously** on entry, and a call that finds it above zero (i.e. one made from inside a callback while another `complete()` is draining) drains `_promises`/`_generating` only, skipping `_callbacks`. Without that skip an `async` callback that awaits `this.complete()` would wait on a drain that is waiting on the callback. A **depth counter, not a flag**: each call increments on entry and decrements in a `finally`, so the answer does not depend on which of the two calls settles first. A flag saved and restored per call got this wrong whenever the nested call settled _last_ — it restored the value it had captured (`true`), leaving the instance permanently "draining", so every later `complete()`, including the one `_replay()` runs on each watch rebuild, silently skipped `_callbacks`. The guard covers the case that exists in practice — the outer `complete()` is what makes the callback run — and not the exotic one where a callback registered while nothing is draining awaits `complete()`: that call would wait on its own callback entry, so the pattern is "await `complete()` inside a callback of a build somebody is already awaiting".
|
package/AIKB/llms.md
CHANGED
|
@@ -33,5 +33,5 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
|
|
|
33
33
|
- **A page's title is its own `title`, else its slug title-cased with hyphens as spaces** (`puppy-classes` → `Puppy Classes`) — the same rule `KissPage.prepare()` uses for the default, so an entry and the page's `<title>` agree. Until the launch-readiness release there was also an `UNTITLED = 'Index'` guard, because `KissPage` filled every untitled page's title with `'Index'` (from the placeholder slug); once that default was fixed the guard only rewrote a title somebody had written, and the operator removed it (2026-09-28). An explicit "Index" is now kept as written.
|
|
34
34
|
- Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
|
|
35
35
|
- `writeLlms` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.llms()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap`.
|
|
36
|
-
- Missing `siteUrl`, `title` or `summary` are reported as a status, never thrown
|
|
36
|
+
- Missing `siteUrl`, `title` or `summary` are reported by the writer as a status, never thrown; `Kiss.llms()` turns any status other than `'written'`/`'skipped'` into a `<llms>` build failure (`llms.txt needs options.title: pass { title } to .llms(), or remove .llms()`, and the like) via `_recordOutputFailure`, and a write error from its `.catch` the same way. It fires the callback for `'written'` and `'skipped'` only. The rest of the site still builds.
|
|
37
37
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing `llms.txt` left to skip.
|
package/AIKB/model-resolver.md
CHANGED
|
@@ -28,7 +28,7 @@ Resolves `options.model` (in any of its four accepted shapes) into `{ id, data }
|
|
|
28
28
|
|
|
29
29
|
- The four model shapes and the `id` each produces: filename (`id` = filename), URL (`id` = URL), plain object (`id` = `hashId(object)`), folder name (`id` = folder name, `data` is an array — this is the shape `.pages()` fan-out expects).
|
|
30
30
|
- **The relative model filename is `path.posix.relative(root, file)`, not `file.slice(root.length + 1)`.** The slice assumed `modelsDir` had no trailing slash; with one, every filename lost its first characters (`blog/a.json` → `log/a.json`), `readModelFile` logged "Can not find model on file system", every model was filtered out and `resolveModel` threw `Invalid model <folder>` (since 2.14.0 `Model folder empty or missing: <folder>`) — an error blaming the folder name for a path-arithmetic bug (review finding C4). `resolveFolders` normalises `folders.models` as well; this is the second line of defence for a caller passing `modelsDir` directly.
|
|
31
|
-
- **The URL branch owns dispatch, not policy.** Headers, timeout, retries and the on-disk cache all live in `lib/fetch-policy.js`; this module passes `fetchConfig` and the injected `fetchImpl` through and keeps its one job (model shape → `{ id, data }`). The order the policy applies is policy-shape check → cache read → URL and header checks → fetch and parse under one timer → non-2xx guard → retry → cache write (a warning on failure); see `AIKB/fetch-policy.md`.
|
|
31
|
+
- **The URL branch owns dispatch, not policy.** Headers, timeout, retries, the size cap and the on-disk cache all live in `lib/fetch-policy.js`; this module passes `fetchConfig` and the injected `fetchImpl` through and keeps its one job (model shape → `{ id, data }`). The order the policy applies is policy-shape check → cache read → URL and header checks → fetch and parse under one timer → non-2xx guard → retry → cache write (a warning on failure); see `AIKB/fetch-policy.md`.
|
|
32
32
|
- **A non-2xx response rejects before the body is parsed** (`Model fetch failed: <url> → <status> <reason>`, the URL redacted and the reason the status's standard phrase from `node:http`'s `STATUS_CODES`, never the response's own `statusText` — `AIKB/fetch-policy.md`). Without that guard an error envelope — the normal shape an API returns with a 500 — parsed as JSON and became the page's model, so the page rendered empty or garbage content and the build reported success (review finding C2). Only an error body that is not JSON failed, and by accident: `response.json()` threw.
|
|
33
33
|
- **A rejection here is a build failure.** `lib/page-registry.js` records it on `_failures` named by the page's view, once, and the page is not prepared (2.14.0; before, the page was logged and skipped and the build reported success). So the two messages say what is wrong rather than what kiss did about it: `Model not found: <file>` for a `.json` model that is not on disk (it was `Skipping: <file>`), and `Model folder empty or missing: <folder>` for any other string that names no folder with a `.json` in it (it was `Invalid model <folder>`). A folder name is the fallback for every string that is neither a URL nor a `.json` file, so a typo in a file name without the extension lands on the folder message.
|
|
34
34
|
- **A model that is not fetched is still printed, so it is printed through `shownModel`.** `Model not found: <model>`, `Model folder empty or missing: <model>` and `readModelFile`'s "Can not find model on file system" line all name the string the author wrote. A file or folder name is shown as written. A string written as `scheme://…` is a URL with a scheme kiss does not fetch (`ftp://user:pass@host/data.json`), and is shown as `redactUrl` shows it: no credentials, no query. Before 2026-11-06 all three printed it whole (the code review of `b8be845`); `test/unit/model-resolver.test.js` holds both shapes, and that an ordinary name with a space or a `?` in it is unchanged.
|
package/AIKB/package-manifest.md
CHANGED
|
@@ -20,7 +20,7 @@ The module is pure: it takes a package's description and returns a string. It kn
|
|
|
20
20
|
|
|
21
21
|
## Depends on
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`node:crypto`; `./utils.js` (`escapeXml`, the one shared escape, which this module carried a private copy of until 2026-11-11; a test holds the manifest's escaping of the five characters).
|
|
24
24
|
|
|
25
25
|
## Depended on by
|
|
26
26
|
|