kiss-ssg 2.15.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/assets.md +2 -2
- package/AIKB/config.md +14 -6
- package/AIKB/fetch-policy.md +3 -2
- package/AIKB/kiss.md +7 -2
- package/AIKB/model-resolver.md +1 -1
- package/AIKB/partials.md +91 -25
- package/AIKB/rebuild.md +5 -1
- package/AIKB/testing.md +3 -2
- package/AIKB/upstream.md +9 -3
- package/CHANGELOG.md +101 -0
- package/GUIDE.md +75 -22
- package/README.md +2 -2
- package/examples/1-scan/src/partials/newsletter.md +1 -1
- package/examples/10-asset-pipeline/src/partials/newsletter.md +1 -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/assets.js +24 -12
- package/lib/build-finish.js +5 -1
- package/lib/config.js +73 -3
- package/lib/fetch-policy.js +56 -7
- package/lib/kiss.js +54 -10
- package/lib/partials.js +211 -63
- package/lib/rebuild.js +4 -2
- package/llms.txt +12 -13
- package/package.json +3 -2
- package/types/assets.d.ts +10 -0
- package/types/config.d.ts +28 -2
- package/types/kiss.d.ts +14 -0
- package/types/partials.d.ts +141 -7
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/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,7 +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)
|
|
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.
|
|
38
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.
|
|
39
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.
|
|
40
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/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/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,7 +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
|
|
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.
|
|
24
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.
|
|
25
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.
|
|
26
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.
|
package/AIKB/kiss.md
CHANGED
|
@@ -67,7 +67,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
67
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).
|
|
68
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.
|
|
69
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.
|
|
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`, 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.
|
|
71
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.
|
|
72
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.
|
|
73
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.
|
|
@@ -83,7 +83,12 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
83
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.
|
|
84
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.
|
|
85
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).
|
|
86
|
-
- **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.
|
|
87
92
|
- `export { Kiss as 'module.exports' }` exists purely so CJS `require('kiss-ssg')` returns the class itself on Node ≥22.12, not `{ default: Kiss }`.
|
|
88
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.
|
|
89
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/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/partials.md
CHANGED
|
@@ -2,40 +2,106 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Registers Handlebars partials and layouts from `config.folders.partials` and `config.folders.layouts` onto a given Handlebars environment
|
|
5
|
+
Registers Handlebars partials and layouts from `config.folders.partials` and `config.folders.layouts` onto a given Handlebars environment. Every partial goes through a **table of transforms keyed by extension**: the built-ins (`md`, `txt`) with a site's `config.transforms` laid over them. `.html` and `.hbs` are the identity transform and are not in the table.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `
|
|
10
|
-
- `
|
|
11
|
-
- `
|
|
9
|
+
- `TEMPLATE_EXTENSIONS` — `['html', 'hbs']`, frozen: the extensions compiled as Handlebars and inserted as they render. A site cannot list them in `config.transforms` (`lib/config.js` refuses them).
|
|
10
|
+
- `builtInTransforms({ markdown, hbs })` → `{ md, txt }`. `md` is `{ render: (output) => markdown.render(output) }`; `txt` is `{ compile: false, render: hbs.Utils.escapeExpression }`.
|
|
11
|
+
- `setupTransforms(transforms)` → `Promise<Map<ext, Error>>`. Awaits every transform's `setup` once, in parallel. **Never rejects**: a failed setup is returned against its extension (a non-`Error` throw is wrapped in one).
|
|
12
|
+
- `registerPartials(hbs, config, deps, previous = [])` → `{ names, collisions }`.
|
|
13
|
+
- **What it registers.** It globs `folders.partials` for `TEMPLATE_EXTENSIONS` plus the table's keys, and `folders.layouts` for `.hbs`. Each file becomes a recording function (`transformingPartial`, below).
|
|
14
|
+
- **`previous`** is the `names` array a prior call returned. Every name in it that this pass did not produce is `hbs.unregisterPartial`ed.
|
|
15
|
+
- **`collisions`** is `{ name, files, error }[]`, for the caller to report.
|
|
16
|
+
- **`deps`** is `{ markdown, logger, graph?, failed? }`. `failed` is the map `setupTransforms` fills; it is read when a partial renders, not at registration.
|
|
17
|
+
- `partialNameFor(file, folders)` → the name `registerPartials` would derive for `file` (native or posix path), or `null` when it is under neither `folders.partials` nor `folders.layouts`.
|
|
18
|
+
- Typedefs: `KissTransform` (`{ compile?, render, setup? }`) and `KissTransformContext` (`{ name, file, page, context, hash }`).
|
|
12
19
|
|
|
13
20
|
## Depends on
|
|
14
21
|
|
|
15
|
-
`fs-extra`; `./utils.js` (`globFiles`, `posixPath`). The markdown renderer, the logger
|
|
22
|
+
`fs-extra`; `node:fs` (`realpathSync.native`) and `node:path`; `./utils.js` (`globFiles`, `posixPath`). The markdown renderer, the logger, the dependency graph (`deps.graph`, `AIKB/dependency-graph.md`) and the setup-failure map are passed in via `deps`, not imported.
|
|
16
23
|
|
|
17
24
|
## Depended on by
|
|
18
25
|
|
|
19
|
-
`lib/kiss.js
|
|
26
|
+
- `lib/kiss.js`: `registerPartials` and `setupTransforms`. The instance keeps the failure map on `_transformFailures` and the setup promise on `_transformsReady`.
|
|
27
|
+
- `lib/config.js`: the `KissTransform` type, for `config.transforms`.
|
|
28
|
+
- `lib/rebuild.js`: `partialNameFor`, which maps an edited partial or layout to its name in `_handleChange()`.
|
|
20
29
|
|
|
21
30
|
## Non-obvious behavior
|
|
22
31
|
|
|
23
|
-
- A partial's
|
|
24
|
-
- `.md`
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
- **Two
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
32
|
+
- **A partial's name** is its path relative to the globbed folder, with the extension stripped and any leading `/` trimmed. For example, `partials/nav/header.hbs` under `folders.partials` registers as `nav/header`.
|
|
33
|
+
- **A `.md` partial renders per call, after Handlebars.**
|
|
34
|
+
- **The order.** The compiled template runs first, then `render` passes its output through `markdown.render()`. So `{{link "x"}}` is already a URL, and `{{> "nav"}}` already markup, by the time Markdown reads the line.
|
|
35
|
+
- **What it fixed.** Until 2.16 Markdown ran first, at registration. Remarkable escaped `"` to `"` and `>` to `>`, and Handlebars then failed to parse every double-quoted argument and every partial call (`test/integration/partial-transforms.test.js`).
|
|
36
|
+
- **Benchmarked; no cache.** Rendering per call was measured before deciding against memoising: `fanout@500` was 7.4% faster in total than the registration-time path (2026-11-14, `planning/sessions/2026-11-14-partial-transforms.md`).
|
|
37
|
+
- **The order has four costs, all documented rather than engineered around** (GUIDE § Partial transforms, CHANGELOG 2.16.0):
|
|
38
|
+
- **Emphasis.** A `*` or `_` that Handlebars inserts becomes emphasis.
|
|
39
|
+
- **Code blocks.** A nested partial called on a line indented four spaces becomes a code block.
|
|
40
|
+
- **A swallowed blank line.** A partial call alone on its line loses that line's newline (`AIKB/upstream.md`, "standalone"). If the called file has no final newline, one blank line after the call becomes none, Markdown's HTML block runs on into the next paragraph, and that paragraph is written without its `<p>`. Measured 2026-11-14, found by the clean room.
|
|
41
|
+
- **`markdown.html: false`.** Under it, HTML that a helper or partial inserted is escaped with the rest of the input.
|
|
42
|
+
- **`config.markdown` and `kiss.remarkable` reach `.md` partials at render.** The built-in `md` transform closes over the renderer passed in `deps.markdown`, which is `kiss.remarkable`. A plugin added to it later is seen by the next render, with no re-register. `Kiss` still builds its Remarkable before calling this module, so the transform binds to the configured instance.
|
|
43
|
+
- **The registered set mirrors disk.** Handlebars keeps a registration until it is explicitly unregistered. Before this was fixed, a partial deleted from disk kept rendering its last-known content for the life of the process (review finding B1).
|
|
44
|
+
- **The recipe.** Every pass finds every file again, then unregisters every name the previous pass produced that this one did not.
|
|
45
|
+
- **Who holds the state.** The caller: `Kiss.registerPartials()` keeps the last `names` on `_partialNames` and passes it back in.
|
|
46
|
+
- **A missing partial fails its page.** A page that still references a deleted partial fails to render (`The partial foo could not be found`) instead of rendering a ghost. Under `watch()` that surfaces as a replay failure, and the page's stale output is left in place (`AIKB/kiss.md`).
|
|
47
|
+
- **Two files, one name: a collision fails the build.** The cases:
|
|
48
|
+
- `foo.md` and `foo.hbs`;
|
|
49
|
+
- `foo.txt` and a site's `foo.adoc`;
|
|
50
|
+
- a partial and a layout that share a name.
|
|
51
|
+
|
|
52
|
+
What happens:
|
|
53
|
+
- **The name is registered as `collidingPartial`**, which throws on every call, naming both files. So every page that calls it fails.
|
|
54
|
+
- **The collision is returned** in `collisions` and logged with `logger.error`. `Kiss.registerPartials()` records it as a `<partials>` failure, which fails the build even when no page calls the name.
|
|
55
|
+
- **Why fail rather than pick one.** Before 2.16 the fixed order of five passes decided which file won, silently. Once a site can extend the table, any order is arbitrary.
|
|
56
|
+
- **The partial-vs-layout case** was added to the spec's rule at the 2026-11-14 pulse, on the operator's decision. Until then the layout won silently.
|
|
57
|
+
|
|
58
|
+
- **One file reached twice is not a collision.** A site may point `folders.partials` and `folders.layouts` at one directory, so the same `.hbs` is found by both globs.
|
|
59
|
+
- **Grouping.** Entries under a name are grouped by `fileIdentity`: `realpathSync.native`, falling back to `path.resolve` for a file gone since the glob.
|
|
60
|
+
- **Why not the path string.** `glob` keeps an in-cwd relative path relative but normalises a `..` path to absolute. A symlink or junction is also one file (Codex, slice 1, rounds 1 and 2).
|
|
61
|
+
- **Extensions outside the table are ignored**, as before. A `notes.adoc` in `partials/` is not a partial until a site lists `adoc` in `config.transforms`.
|
|
62
|
+
- **Partials live on the instance.** They are registered on `kiss.handlebars`, never the global `handlebars` module.
|
|
63
|
+
- **The v1 trap.** A consumer helper that does `require('handlebars').partials[name]` silently gets `undefined` in v2. v1 sites did this to render a partial chosen by name, for example diploma-msc's `renderPartial`.
|
|
64
|
+
- **The fix.** The migration notes point such sites at `kiss.handlebars.partials`, or at Handlebars' dynamic partial syntax `{{> (lookup this "name")}}`, which needs no helper (verified against diploma-msc on 2026-09-04).
|
|
65
|
+
- **Every partial is a recording function** (`transformingPartial`).
|
|
66
|
+
- **How it is called.** Handlebars calls a function partial as `partial(context, options)`, and handlebars-layouts' `extend`/`embed` call it as `template(context, { data })`.
|
|
67
|
+
- **What it does, in order:**
|
|
68
|
+
1. reads `options.data.kissPage`, the page identity `KissPage.generate()` puts in the render's data frame;
|
|
69
|
+
2. records `(page, name)` on `deps.graph` (`AIKB/dependency-graph.md`);
|
|
70
|
+
3. throws if the extension's setup failed;
|
|
71
|
+
4. runs the compiled template, or takes the file's own characters under `compile: false`;
|
|
72
|
+
5. returns the output, or `transform.render(output, ctx)` when the extension has a transform.
|
|
73
|
+
- **The return contract matters.** The result must be a plain string. `undefined` makes Handlebars try to compile the function, and a `SafeString` breaks an indented partial call, which splits the result on newlines. A transform's `render` returns a string for the same reason, and it is inserted raw.
|
|
74
|
+
- **Tracing never affects rendering.** A recorder that throws is swallowed.
|
|
75
|
+
- **Without a graph or a page id,** the wrapper is a plain compiled partial.
|
|
76
|
+
- **The transform's `ctx`.**
|
|
77
|
+
- **The fields:** `{ name, file, page, context, hash }`.
|
|
78
|
+
- **`page`** is the output path of the rendering page (`kissPage`).
|
|
79
|
+
- **`hash`** is the call's hash arguments, or `{}` when it has none. `{{> note who="reader"}}` gives `{ who: 'reader' }`.
|
|
80
|
+
- **A failed `setup` fails the pages that use it, at render.** Registration is synchronous and the setups settle later.
|
|
81
|
+
- **How the failure is read.** `failed` is a shared `Map`, read on each call. Each call to a partial of that extension throws `Partial "x" (file) cannot render: the .adoc transform's setup failed: …`.
|
|
82
|
+
- **Who reports it.** `Kiss._setupTransforms` reports the failure itself once, as a `<transform .adoc>` failure.
|
|
83
|
+
- **The rest of the site still builds**, so the report names both the transform and every page it took down.
|
|
84
|
+
- **Layouts take no transform and stay `.hbs`.** They are compiled once rather than per `{{#extend}}`. When that change landed it measured:
|
|
85
|
+
- `fanout@500`: −27.1%;
|
|
86
|
+
- `models@500`: −15.7%;
|
|
87
|
+
- `watch@500`: −17.5%;
|
|
88
|
+
- `scan@500`: −10.7%;
|
|
89
|
+
- a watch replay after a model edit: 455 ms → 172 ms.
|
|
90
|
+
- **`hbs.compile` is lazy.** An unrendered partial is never parsed, and a partial that will not parse still fails at render, against the page that used it.
|
|
91
|
+
- **`hbs.partials[name]` is always a function.**
|
|
92
|
+
- **Before.** It was a string only until the first render: Handlebars compiles a string partial on first use and writes the function back into the same object. So a consumer helper reading an entry has always had to accept both shapes.
|
|
93
|
+
- **The fix.** The one-line form is in `llms.txt` and `GUIDE.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`.
|
|
94
|
+
- **Pass `options.data` through.** The data frame is how the invoking page is recorded as using the partial. A bare `p(ctx)` renders correctly, but the page is not recorded, so under `watch()` it is not re-rendered when the partial changes.
|
|
95
|
+
- **The worked recipe** is `examples/9-migrated-from-v1/router.js`'s `renderPartial`.
|
|
96
|
+
- **`partialNameFor` is the one place outside registration that turns a path into a name.** It strips any extension, so it needed no change for site transforms. `Kiss._handleChange` calls it, so a chokidar path maps to the name the graph knows.
|
|
97
|
+
- **`.txt` is the built-in `compile: false` transform.**
|
|
98
|
+
- **What happens.** The file's characters are escaped once with `hbs.Utils.escapeExpression`, and the escaped string is returned, so the page shows the file rather than executing it.
|
|
99
|
+
- **A plain string, not a `SafeString`.** The `SafeString` this shipped with first threw `TypeError: result.split is not a function` on an indented call and wrote no page, on the `<pre>`-indented snippet the type exists for.
|
|
100
|
+
- **The rule an author holds:** "a `.txt` partial behaves as if you had written `{{ }}` around its contents". That is why the escaping is Handlebars' own, and why it is safe in an attribute as well as in body text. The cost is that `=` and `"` become `=` and `"` in the source; both render correctly.
|
|
101
|
+
- **Two consequences of `.txt`, both the point rather than limitations:**
|
|
102
|
+
- `{{name}}` inside a `.txt` file is printed rather than interpolated, so a snippet can show template syntax without being one;
|
|
103
|
+
- markup is shown rather than rendered, so a code sample drops into a `<pre>` with no hand-escaping.
|
|
104
|
+
- **`.txt` exists because the extension is a claim about processing** (`CLAUDE.md` § Design philosophy).
|
|
105
|
+
- **The set.** `.hbs` is a template, `.md` is rendered Markdown and `.html` is HTML, and all three are compiled. `.txt` is the one kiss does not compile.
|
|
106
|
+
- **Not for SCMS.** It was deliberately not added to replicate SCMS, whose `.txt` files carried HTML in 2 of 10 cases.
|
|
107
|
+
- **A site transform keeps the promise.** `.adoc` means whatever the site's `config.transforms.adoc` says, and the site's config is where a reader finds it.
|
package/AIKB/rebuild.md
CHANGED
|
@@ -12,7 +12,7 @@ It also holds the body of `watch()`, which wires `lib/watcher.js`'s callbacks to
|
|
|
12
12
|
|
|
13
13
|
Every function takes the `Kiss` instance and reads its state at call time. The state stays on
|
|
14
14
|
`Kiss` — `_rebuildInFlight`, `_pendingReplay`, `_pendingTargets`, `_pendingAssets`, `_closing`,
|
|
15
|
-
`_watcher`, `_helpersReady`, `_unswept`, `_replaying`, and everything a replay resets — and each
|
|
15
|
+
`_watcher`, `_helpersReady`, `_transformsReady`, `_unswept`, `_replaying`, and everything a replay resets — and each
|
|
16
16
|
private method on `Kiss` is a one-line delegator to the function here. **This is the module the
|
|
17
17
|
late-binding rule exists for**: tests spy on or replace `_reload`, `_rebuild`, `_replay`,
|
|
18
18
|
`_requestReplay`, `_requestRebuild` and `registerPartials` on the instance, call
|
|
@@ -74,6 +74,10 @@ Each is the body of the `Kiss` method named beside it:
|
|
|
74
74
|
- A replay re-runs `llms()` on the same rule and right after the sitemap, from `_llmsRequest` — an entry's title, description or URL can all change with a model or controller edit, so a stale `llms.txt` is exactly as wrong as a stale `sitemap.xml` was.
|
|
75
75
|
- A replay re-runs `feed()` too, from `_feedRequest`, immediately after the `llms()` re-issue and on the same rule. A feed item's title, description, date and URL all come from the same options a model or controller edit rewrites, so the three registry-derived files (`sitemap.xml`, `llms.txt`, the feed) are re-issued as one block and can never be a rebuild apart. `_feedRequest` is the one of the three fields `_replay()` does **not** reset — it is the standing request, not the result; `_feedPath` is reset beside `_llmsPath`.
|
|
76
76
|
- A replay re-runs `sitemap()` when one was ever requested: `sitemap()` records `_sitemapRequest = { options, callback }` (idempotent — the replay's own call re-records the same request), and `_replay()` re-issues it right after `generate()` so `complete()` drains it and `sitemap.xml` matches the new stack. v1 (and v2 before this) left `sitemap.xml` stale after a slug change in watch mode. Like the `generate` callback, the `sitemap` callback re-fires on every watch rebuild — once per replay, with that rebuild's `urls`, not just on the original call.
|
|
77
|
+
- **A scoped re-render waits for readiness first.** `rebuildEntries` awaits `_helpersReady` and `_transformsReady` before it renders anything, the two promises `generate()` also awaits.
|
|
78
|
+
- **`_helpersReady`**: a helper reload removes the old registrations before it awaits the registrar, and a page edit landing in that window rendered against the gap.
|
|
79
|
+
- **`_transformsReady`**: a page edited while a `config.transforms` setup was still pending rendered before its transform could (Codex, 2026-11-14).
|
|
80
|
+
- **Neither rejects.** `_loadHelpers` and `_setupTransforms` record a failure rather than throwing, so the await is pure ordering. `test/integration/partial-transforms.test.js` pins the transforms half.
|
|
77
81
|
- Watcher rebuilds are fire-and-forget from the watcher's point of view — nothing awaits what `_handleChange` returns — but they are still _tracked_: `_rebuild(entries)` pushes each `page.generate().catch(() => {})` onto `_generating` (caught there because `KissPage.generate()` now rethrows, and it has already logged; a scoped rebuild must not turn into an unhandled rejection or a `complete()` failure), so a replay's `allSettled` waits for it and `complete()` drains it. `_generating` accumulates one entry per re-rendered page until the next replay resets the array.
|
|
78
82
|
- **All rebuild work goes through one serial queue** (`_runRebuildQueue()`): one in-flight slot and one pending slot, where the pending slot is a set of scoped targets (stack entries to re-render) plus a "replay" bit. `_requestReplay()` sets the bit and clears the set — a replay rebuilds everything, so it supersedes every scoped rebuild — and returns the in-flight promise, so N rapid watcher events (save-all, a formatter, a branch switch) produce at most one in-flight run plus one follow-up and the last replay reads the newest files. `_requestRebuild(entries)` adds to the set, or is a no-op while the bit is set. Two rebuilds must never overlap, for two distinct reasons: `_replay()` resets `_stack` synchronously, so two replays interleaved would let the older one's still-pending chains fill the new stack and the newer pages — the ones carrying the newest edit — would be rejected by `_preparePage`'s `buildTo` dedupe with a `Page already processed` error, leaving stale output; and scoped work in flight when a replay starts re-renders from a stack the replay is about to discard. Two scoped rebuilds racing each other are the same hazard in miniature, which is why the queue covers both kinds rather than only replays. The follow-up run is _not_ chained into the returned promise (the `.finally()` callback returns `undefined`), so awaiting `_requestReplay()` waits only for the run that was in flight. Errors from either kind of run are caught and logged as `Error rebuilding site`.
|
|
79
83
|
- **Live reload is issued from here, once per settled build**, because nothing watches the build folder any more (review finding F-E4 — see `AIKB/dev-server.md` for what was removed and why). `_reload(changed = '/')` is the single call site of the dev server's `refresh`; it is a no-op when there is no dev server, and it swallows anything the call throws, because a live-reload socket that has gone away must never fail the rebuild that triggered it. There are exactly three places it fires:
|
package/AIKB/testing.md
CHANGED
|
@@ -39,11 +39,12 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
39
39
|
- Dev-mode / watcher tests mock the dev server rather than binding a real port for every test: `vi.mock('../../lib/dev-server.js', () => ({ startDevServer: () => ({ ready: Promise.resolve(), close: async () => {} }) }))` before importing `Kiss`. The mock must include `ready` — the `Kiss` constructor does `this._devServer.ready.catch(...)`, and a mock without it throws a `TypeError` that the surrounding `try/catch` swallows into the (silent) logger, so the dev-mode test would pass for the wrong reason.
|
|
40
40
|
- Any test that calls `.watch()` (directly or via `{ dev: true }`) must `await kiss.close()` in `afterEach` — otherwise chokidar's open watchers keep the process/worker alive and can hang the test run.
|
|
41
41
|
- `fs.utimesSync` rounds to whole milliseconds, so a test that pins a file's mtime back and then asserts `statSync().mtimeMs` against the `Date` it passed can be 0.001ms off. Assert the difference is under 1ms, not equality (`test/unit/kiss-page.test.js`, the content-keyed cache test).
|
|
42
|
+
- A test controls every input its assertion reads. A fixture writes its own data rather than reading a live repository file (the `issue --filed` test read this repository's `CHANGELOG.md` and failed once #78 shipped, 2026-11-08), and a test that seals a child process's environment drops every variable the code under test reads, not only `GIT_*` (`KISS_BASE_BRANCH` failed four tests in the first release run, 2026-11-11).
|
|
42
43
|
- `watch({ entry: null })` is the standard way to disable the entry-script watcher in tests (see `lib/watcher.js` — there's no real "entry script" when `Kiss` is constructed from a test helper).
|
|
43
44
|
- `test/integration/esm.test.js` verifies `require('kiss-ssg')` still works from CommonJS by spawning a real `node` process on a generated `.cjs` file (via `execFile`) — this can't be tested in-process because Vitest itself runs under ESM.
|
|
44
45
|
- `test/integration/check.test.js` runs the real `bin/kiss-ssg.js` as a child process against a throwaway site under the OS temp dir — the only way to assert on an exit code and on stdout carrying nothing but the JSON report. The fixture sites it writes import the engine by absolute `file://` URL (a temp dir cannot resolve `kiss-ssg` from `node_modules`), and each one asserts that the build folder is still absent, or still holds its sentinel file, afterwards. Its second `describe` reads the same report through the API instead — `kiss.report()`, `err.report`, and the staged-path mapping after a promoted and after a discarded atomic build — since `lib/kiss.js` is covered here rather than in a unit file. It also pins the engine line on stderr both ways: the honest "no node_modules/kiss-ssg found" for those path-imported fixtures, and the linked wording through a real junction it makes under the temp site — plus a script in a subfolder with its own `node_modules/kiss-ssg`, proving resolution walks up from the script's folder rather than the caller's cwd.
|
|
45
46
|
- `test/integration/init.test.js` runs the real bin's `init` in an empty temp folder whose `node_modules/kiss-ssg` is a **junction** to the repo root (`fs.symlinkSync(root, dir, 'junction')` — no admin rights needed on Windows), so the starter's `import Kiss from 'kiss-ssg'` resolves the working tree the way a consuming site resolves the package; then it runs `check` over what `init` wrote. Every call passes `--no-install`, so the npm path is not exercised here — it was checked by hand from a packed tarball on Windows (see `AIKB/init.md`). The byte-preservation case writes files first and asserts each survives as a prefix.
|
|
46
|
-
- `test/integration/packed.test.js` tests the package as published rather than the working tree: `beforeAll` runs `npm pack` (no scripts, no network), unpacks the tarball into a temp site's `node_modules/kiss-ssg`, and links beside it only the names in `package.json`'s `dependencies`, each to its real folder in this repo's `node_modules` (a **junction**, no elevation on Windows; `realpathSync` first, because a git worktree's `node_modules` is itself a junction). A module that `lib/` or `bin/` imports without declaring it then fails to resolve. It asserts the ESM and CommonJS imports give `Kiss`, that the shipped `starter/` builds with `node router.js`, and that `check` exits 0. `afterAll` unlinks each junction before the recursive delete, which would otherwise follow one and empty the real `node_modules`. Seen red by leaving `handlebars` unlinked. The starter only exercises the top-level imports and its own code paths, and a dependency `lib/` loads lazily (`turndown` by `import()` in `lib/markdown-copy.js`, `sharp`, `purgecss`, `html-minifier-terser`) is reached only when a site uses that feature: leaving `turndown` unlinked passed those three. So a fourth test reads every bare specifier the packed `lib/` and `bin/` import, static or dynamic (including `import(NAME)` of a string constant, and skipping comment lines), and resolves each from the install with `import.meta.resolve`; an optional peer (`peerDependenciesMeta`, today `lighthouse`) is exempt. Seen red by leaving `turndown` unlinked.
|
|
47
|
+
- `test/integration/packed.test.js` tests the package as published rather than the working tree: `beforeAll` runs `npm pack` (no scripts, no network), unpacks the tarball into a temp site's `node_modules/kiss-ssg`, and links beside it only the names in `package.json`'s `dependencies`, each to its real folder in this repo's `node_modules` (a **junction**, no elevation on Windows; `realpathSync` first, because a git worktree's `node_modules` is itself a junction). A module that `lib/` or `bin/` imports without declaring it then fails to resolve. It asserts the ESM and CommonJS imports give `Kiss`, that the shipped `starter/` builds with `node router.js`, and that `check` exits 0. `afterAll` unlinks each junction before the recursive delete, which would otherwise follow one and empty the real `node_modules`. Seen red by leaving `handlebars` unlinked. The starter only exercises the top-level imports and its own code paths, and a dependency `lib/` loads lazily (`turndown` by `import()` in `lib/markdown-copy.js`, `sharp`, `purgecss`, `html-minifier-terser`) is reached only when a site uses that feature: leaving `turndown` unlinked passed those three. So a fourth test reads every bare specifier the packed `lib/` and `bin/` import, static or dynamic (including `import(NAME)` of a string constant, and skipping comment lines), and resolves each from the install with `import.meta.resolve`; an optional peer (`peerDependenciesMeta`, today `lighthouse`) is exempt. Seen red by leaving `turndown` unlinked. **Its boundary:** each dependency is this repo's installed copy, so the test never runs npm's own resolution of the declared ranges (a range that would install a broken version passes here). That is the fleet check's job: `scripts/fleet-check.mjs` installs each packed tarball into a copy of a real site. The operator chose to record this rather than put a networked install in the gates (2026-11-13).
|
|
47
48
|
- **A behaviour that only one platform exhibits gets two tests, not a skip and a shrug.** `npm run gates` runs on `ubuntu-latest` **and** `windows-latest` (`.github/workflows/ci.yml`), so an `it.skipIf(process.platform !== 'win32')` really does run in CI — it is not documentation pretending to be a test. But a test that only ever runs on one leg is still half a guard, so where the same mechanism can be reached portably, reach it: the copy-identity canonicalisation is asserted twice, once through a case-different path (Windows only, where a case-insensitive filesystem makes it meaningful) and once through a **symlink** (everywhere). The symlink half is what catches the refactor that would actually happen — replacing `realpathSync.native` with `path.resolve` to drop a sync filesystem call — on any machine, rather than waiting for the Windows leg. Three behaviours on this branch were Windows-divergent (`new URL(…).pathname`, a path-separator assertion, and this), which is enough of a pattern to make the portable half the default expectation rather than a bonus.
|
|
48
49
|
|
|
49
50
|
- `test/integration/bench-watch.test.js` is the one exception to "the I/O wrapper below the decision core stays untested": `scripts/bench.mjs`'s `--dev` watch reading spawns a real dev-mode site, drives it over a websocket and edits its source files, and none of that can be asserted from a pure function. It generates a three-file site under the OS temp dir, links this repo into its `node_modules` with a **junction** (`fs.symlinkSync(…, 'junction')` — no elevation on Windows, and it is what lets `resolveKissFrom` prove which engine was measured), picks both ports with `net.createServer().listen(0)` because the engine's defaults collide with any dev server the operator left open, and afterwards asserts the three touched files are byte-identical and the livereload port binds again — the proof that no child was left running. 60s timeout, like the other child-process suites; it runs in about three seconds.
|
|
@@ -73,7 +74,7 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
73
74
|
|
|
74
75
|
- Relative-path fixtures must anchor their working directory inside the temporary site: Windows CI can put the checkout and OS temp directory on different drives, where `path.relative` returns an absolute drive path. Prefixing that with `./` does not make it relative. Containment tests use native separator semantics; on POSIX a backslash is a filename character, not a separator.
|
|
75
76
|
|
|
76
|
-
- **`examples.test.js`'s byte-identical AIKB assertions
|
|
77
|
+
- **`examples.test.js`'s byte-identical AIKB assertions fail on every run until the committed record is re-recorded.** Examples 9 and 11 are recorded **in place**, in the committed `examples/*/AIKB/` folders, and `last-build.json` is the whole `BuildReport`, so adding any report key changes both. The test's `recordAikb` keeps the committed bytes, runs the record, compares, and **puts the committed bytes back** whatever the record wrote, so a stale record fails every run, not only the first, and the failure names the fix: `npx kiss-ssg aikb router.js` in `examples/9-migrated-from-v1/` and `examples/11-blog/`, then commit the re-recorded files as part of the change. Until 2026-11-13 the test left the fresh record in place, so it failed once and passed on the re-run (2026-09-16 twice, 2026-11-11; issue #83).
|
|
77
78
|
|
|
78
79
|
- **`types/` is generated — never hand-edit it.** `test/unit/types.test.js` re-emits it and byte-compares, so a JSDoc change in `lib/` fails the suite until `npm run types` has been run and the result committed. The failure names the file and the command.
|
|
79
80
|
- The examples (`npm run eg1` … `eg11`) and `node docs` build and exit by default, so they are safe to run bare — except example 8, which exits 1 by design. It is the `--dev` flag that starts a server and **never exits on its own** — `node docs --dev`, or `npm run eg1 -- --dev` (examples 1–6, 8, 9, 10; example 7 takes a season slug instead). Use `timeout 10 node <script> --dev` in Git Bash (or equivalent) to smoke-run that path and let the timeout kill the process.
|
package/AIKB/upstream.md
CHANGED
|
@@ -31,9 +31,11 @@ one of them is a decision.
|
|
|
31
31
|
|
|
32
32
|
**Observed:** handlebars 4.7. **Effect:** an indented `{{> "x"}}` throws and the
|
|
33
33
|
page writes nothing, because the indentation path calls `result.split('\n')` and
|
|
34
|
-
a `SafeString` has no `split`. **What kiss does:** `
|
|
35
|
-
(`lib/partials.js`) returns
|
|
36
|
-
|
|
34
|
+
a `SafeString` has no `split`. **What kiss does:** `transformingPartial`
|
|
35
|
+
(`lib/partials.js`) returns a plain **string**, and so does every transform's
|
|
36
|
+
`render` (the built-in `.txt` one returns the escaped string rather than a
|
|
37
|
+
`SafeString`). This is a local guard, not a mechanism — one return type, at one
|
|
38
|
+
call site.
|
|
37
39
|
**Re-check:** register a `.txt` partial, render `<pre>\n {{> snippet}}\n</pre>`,
|
|
38
40
|
and see whether returning a `SafeString` still throws.
|
|
39
41
|
|
|
@@ -50,6 +52,10 @@ which would change every existing site's output. `lib/html-split.js`'s
|
|
|
50
52
|
`LineWriter` writes the calls it generates so the rule adds and removes
|
|
51
53
|
nothing: column 0 when a call starts a line, a line break written twice after
|
|
52
54
|
one (Codex review, 2026-10-01 and 10-02). A local guard at one call site.
|
|
55
|
+
Since 2.16 the swallowed newline also reaches Markdown: in a `.md` partial,
|
|
56
|
+
`{{> "x"}}` then one blank line, where `x` has no final newline, leaves no
|
|
57
|
+
blank line, and the paragraph after the call loses its `<p>`. Documented in
|
|
58
|
+
GUIDE § Partial transforms, not guarded.
|
|
53
59
|
**Re-check:** render `{{> "p"}}` indented two spaces, with `p` registered as
|
|
54
60
|
`<pre>a\nb</pre>`, and see whether `b` gains the indent; render
|
|
55
61
|
`{{> "p"}}\nx` and see whether the newline survives.
|