kiss-ssg 2.14.0 → 2.15.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-manifest.md +1 -0
- package/AIKB/assets.md +1 -0
- package/AIKB/build-finish.md +4 -0
- package/AIKB/build-report.md +2 -1
- package/AIKB/feed.md +2 -2
- package/AIKB/fetch-policy.md +1 -0
- package/AIKB/handlebars-helpers.md +1 -1
- package/AIKB/kiss.md +7 -4
- package/AIKB/llms.md +1 -1
- package/AIKB/package-manifest.md +1 -1
- package/AIKB/pipeline.md +1 -1
- package/AIKB/redirects.md +2 -2
- package/AIKB/robots.md +1 -1
- package/AIKB/sitemap.md +4 -4
- package/AIKB/staging.md +12 -3
- package/AIKB/testing.md +2 -0
- package/AIKB/utils.md +1 -0
- package/CHANGELOG.md +63 -0
- package/GUIDE.md +43 -20
- package/README.md +2 -1
- package/bin/kiss-ssg.js +0 -0
- package/examples/11-blog/AIKB/last-build.json +2 -1
- package/lib/asset-manifest.js +2 -1
- package/lib/assets.js +65 -1
- package/lib/build-finish.js +21 -3
- package/lib/build-report.js +13 -0
- package/lib/feed.js +1 -13
- package/lib/fetch-policy.js +66 -4
- package/lib/kiss.js +79 -20
- package/lib/package-manifest.js +1 -20
- package/lib/pipeline.js +2 -1
- package/lib/redirects.js +104 -15
- package/lib/sitemap.js +6 -5
- package/lib/staging.js +37 -6
- package/lib/utils.js +19 -0
- package/package.json +11 -8
- package/types/build-finish.d.ts +5 -0
- package/types/build-report.d.ts +9 -0
- package/types/kiss.d.ts +35 -21
- package/types/redirects.d.ts +31 -2
- package/types/utils.d.ts +1 -0
- package/examples/10-asset-pipeline/src/assets/css/generated.css +0 -19
package/AIKB/asset-manifest.md
CHANGED
|
@@ -33,6 +33,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
33
33
|
## Non-obvious behavior
|
|
34
34
|
|
|
35
35
|
- **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
|
|
36
|
+
- **Hashable is necessary, not sufficient.** `lib/assets.js` also leaves a `.css`/`.js` unrenamed when another hashed asset imports it by path, so the manifest maps it to itself like an image (`AIKB/assets.md`). This module only says which extensions may be renamed.
|
|
36
37
|
- **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
|
|
37
38
|
- **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
|
|
38
39
|
- **A map is only written by a copy that finished, so a copy that stopped needs a second record.** `reconcile` runs at the end of a copy; a copy that fails partway has already put files in the build that no map holds, and before `unrecorded` nothing ever removed them (a `git checkout` during `--dev` left a stale asset until restart). They are kept apart from the name map rather than merged into it, because a name has one output: merging a half-copied plain `css/site.css` under a name whose map says `css/site.a1b2c3d4.css` would have reported the last-good hashed file stale and deleted it. A copy with unrecorded outputs counts for `hasOwner`, because its root demonstrably existed, so that root disappearing is a deletion to reconcile, not the typo a missing first root is.
|
package/AIKB/assets.md
CHANGED
|
@@ -34,6 +34,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
34
34
|
- **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
|
|
35
35
|
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
36
36
|
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
37
|
+
- **Under `assets.hash`, a file another hashed asset imports by path is not hashed (`importedNames`).** The import sits in the importer's bytes and nothing rewrites it, so a renamed target 404s on a green build, and no link scan or audit reads asset bodies (case built in `planning/reviews/2026-11-10-js-hash-imports.md`; the operator chose this over rewriting imports or a warning, 2026-11-10). Before the hashing loop, every claimed `.js`/`.css` is read and scanned by regular expression (no parser) for `import … from`, `export … from`, `import '…'`, `import('…')` and CSS `@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: `new URL('./x.js', import.meta.url)`, `sourceMappingURL`, a computed dynamic specifier, and an imported stylesheet that `assets.purge` names, which the settle re-hashes (`lib/css-purge.js`). The scan adds one read of each hashable file per copy.
|
|
37
38
|
- **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims, plus outputs an earlier failed copy wrote and never recorded; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
|
|
38
39
|
- **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
|
|
39
40
|
- `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
|
package/AIKB/build-finish.md
CHANGED
|
@@ -29,6 +29,10 @@ Each is the body of the `Kiss` private method named beside it:
|
|
|
29
29
|
- `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
|
|
30
30
|
- `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
|
|
31
31
|
`_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
|
|
32
|
+
`findRedirectChanges` also records every alias `normaliseAlias` refused, from
|
|
33
|
+
its own `collectAliases` call (the warning is `writeRedirects`', said once),
|
|
34
|
+
as `dropped: [{ alias, page, reason }]`, and a site whose only news is a
|
|
35
|
+
dropped alias reports a `redirects` block rather than `null`.
|
|
32
36
|
- `writeBannerRegistry(kiss)` — `_writeBannerRegistry()`: `<build>/consent-banners.json`.
|
|
33
37
|
- `purgeAssets(kiss)` — `_purgeAssets()`: the CSS purge of `assets.purge` (`lib/css-purge.js`, `AIKB/css-purge.md`), **first** at the settle, before the redirects. It is latched as `_purgeRun`, reset by the replay, and skipped in dev and on a failed build. It purges against every written HTML page and, when a stylesheet was re-hashed, rewrites each page to the new name (`KissPage.replaceInOutput`), so the link scan, the audit, the cache headers and the report all describe the purged file. The result is `_purgeResult`, logged as `Purged <name>: <before> KB → <after> KB`. A failure fails the build (`<purge>`): the site named the file, so a purge that silently did nothing would ship the whole stylesheet. It reads every `.html` in the build folder, a copied or retained page included, and relinks only the rendered ones.
|
|
34
38
|
- `checkPageIntegrity(kiss)` — `_checkIntegrity()`: after the cache headers and before the packages, each rendered page's local `<script>`/`<link>` with an `integrity` attribute is checked against the file the build wrote (`lib/integrity.js`, `AIKB/integrity.md`). A mismatch is a `<integrity>` failure naming the page and the fix. Skipped in dev and on a failed build.
|
package/AIKB/build-report.md
CHANGED
|
@@ -12,7 +12,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
13
13
|
- `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
|
|
14
14
|
- `pages` — `{ view, buildTo, ok, hash, id, canonical, markdown }` per stack entry, in registration order; `markdown` is the Markdown copy the page wrote beside itself (`KissPage.markdown`, mapped to the real build folder like `buildTo`), `null` when it wrote none; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
15
|
-
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
|
|
15
|
+
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report. An output the build asked for that could not be written or lacked an input is one (`<sitemap>`, `<llms>`, `<feed>`, `<robots>`), so `ok` is false and `sitemap`/`llms`/`feed` stay `null` beside it; `Kiss._recordOutputFailure` re-derives the report in place when it lands after a settle (`AIKB/kiss.md`).
|
|
16
16
|
- `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
|
|
17
17
|
- `sitemap` — the `sitemap.xml` this build wrote, or `null`.
|
|
18
18
|
- `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
|
|
@@ -56,6 +56,7 @@ Nothing at run time (`node:path` only). Its typedefs import `lib/audit.js`'s `Ch
|
|
|
56
56
|
- **Note findings never touch the verdict.** `aikb.notes` is advisory: `ok` counts failures only, `exitCodeFor` (`lib/check.js`) does not read it, and a build with ten missing notes still exits 0. `formatReport` says them out loud because they are the half of a site's knowledge base a build cannot write for anyone — but a rule that failed a build for a missing paragraph would get a paragraph of filler, not a paragraph of judgement.
|
|
57
57
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
|
58
58
|
- **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
|
|
59
|
+
- **`redirects.dropped` is the last key of `redirects`**, `[]` when none: `{ alias, page, reason }` for each alias the engine refused (`reason` is `whitespace`, `pattern` or `query-only`; `page` is the view, else the output path, mapped like a path). Advisory, and `formatReport` (so `kiss-ssg check --summary`) prints one `alias dropped: <alias> on <page> (<reason>)` line each, beside the collisions. Before it the refusal was a `logger.warn` and nothing else.
|
|
59
60
|
- **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
|
|
60
61
|
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. Since 2.8.6 `disallowAll` means `*` or a search engine's crawler is blocked, and `blocked` (appended after it) names every agent kept out of the whole site; an older record has no `blocked`. `lib/robots.js` has the rest.
|
|
61
62
|
|
package/AIKB/feed.md
CHANGED
|
@@ -28,7 +28,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
|
|
|
28
28
|
- **A date is read from the page option first and the resolved model second, under one field name.** `options[dateField] ?? options.model?.[dateField]` — `dateField` governs both lookups, so a site that dates a post in its `models/posts/*.json` and a site that dates it in the `.page()` call read the same key and neither has to know which the other used. By settle time `options.model` **is** the resolved data (the `.pages()` fan-out has already given each item its own), which is what makes the model lookup possible at all. It is guarded for `null` (a page with no model) and for an array (a non-`dynamic` page handed an array model, which has no date of its own); both are undated, neither is an error.
|
|
29
29
|
- **A page with no date is silent; a date that cannot be read warns once.** Most of a site is undated — an About page is not a post — so warning per undated page would bury the feed's own output in noise on every build. A value that was _given_ and cannot be parsed is a typo the author wants to hear about, so it gets one `logger.warn` naming the page and the value, and the page joins the undated count. Neither ever throws: a feed is not the render error channel, and a build minus one item is still the build the author asked for. `writeFeed` logs the total as one `info` line, which is the only place the count surfaces — `report().feed` is the path, not the tally.
|
|
30
30
|
- **The `url` is the sitemap's `<loc>`, character for character** — a home page (`https://e.com/`), a file page (`/about`) and a directory index (`/courses/`) alike, because the derivation is the same two `utils` functions. An RSS `<guid isPermaLink="true">` that disagreed with the sitemap would be a second identity for one page, and a feed reader would show the post twice after a rename.
|
|
31
|
-
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. `lib/
|
|
32
|
-
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it
|
|
31
|
+
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. The function is `escapeXml` in `lib/utils.js` (a named export, moved there from this file), shared with `lib/sitemap.js`, which escapes every value it writes since work order 06.
|
|
32
|
+
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it, does not invoke the callback and records a `<feed>` build failure (`_recordOutputFailure`). A missing `siteUrl` or `title` (the writer's `'no-site-url'`/`'no-title'`) is the same failure, worded `feed.xml needs config.siteUrl: …, or remove .feed()` (the file name is `feedFileName(options)`). Same contract as `writeSitemap` and `writeLlms`.
|
|
33
33
|
- **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt`, `redirects.json` and the redirect host file are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
|
|
34
34
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing feed left to skip.
|
package/AIKB/fetch-policy.md
CHANGED
|
@@ -20,6 +20,7 @@ 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 response is not size-capped: the per-attempt timeout bounds a response, and that is the limit. A stub passed as `fetchImpl` sees `redirect: 'manual'` in its options only when headers are set.
|
|
23
24
|
- **The cache key is a hash of the URL _and_ the header set**, with the header entries sorted so key order cannot fork the identity. The same URL fetched with two different bearer tokens is two different identities upstream, and one build's body must never be served to the other's request.
|
|
24
25
|
- **The cache has no TTL and no expiry sweep.** A consumer clears the directory (or a single `<hash>.json` inside it) to refetch. That also makes stale-on-error unreachable rather than merely unimplemented: an entry is only ever written after a success, and once written it is hit before any request is made, so there is no state in which a fetch fails _and_ a cached body exists. Stale-on-error only becomes a real option if a TTL is added — which is why `cache` is a plain directory string, not a `{ dir, staleOnError }` object.
|
|
25
26
|
- **An unreadable cache entry is a miss, not a failure**: a truncated or hand-edited file logs one warning and the request goes to the network. A half-written file (a build killed mid-write) must not poison every later build.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
5
|
+
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `link`, `image`, `embed`, `env`, `scormBridge`, and the six consent helpers) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name. The roster is checked, not trusted: `test/aikb.test.js` reads every `registerHelper('<name>'` out of the module and fails unless that name is documented here and in `llms.txt`.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
package/AIKB/kiss.md
CHANGED
|
@@ -19,9 +19,11 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
19
19
|
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder — each page's `{ view, buildTo, runCount, options }`, the options without `config` (`AIKB/page-registry.md`) — and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
20
20
|
- `.generate(callback)` — sets the `_generateRequested` latch, awaits `_promises`, renders every stack entry with `runCount === 0` once (each render caught, failures collected on `_failures`), awaits the writes, invokes `callback` through `_runCallback` (so a callback that throws, or returns a rejecting promise, is collected on `_failures` too); returns `this`.
|
|
21
21
|
- `.complete(callback)` — awaits `_settle()` (drain, then render whatever the drain left unrendered, repeated until the stack is stable), then **rejects with an `AggregateError`** if `_failures` is non-empty and `_failuresReported` is not yet set; otherwise invokes `callback` with the resolved data (never on a failed build) and returns that data (a `Promise`, unlike the other chainable methods). Under `cleanBuild: 'atomic'` this is also the promotion point: a settle with no failures promotes the staging folder, a settle with failures discards it — and in check mode a settle with no failures discards it too. It is also where the build report is assembled (`_finishBuild()`), so `report()` and `err.report` are set by the time either outcome reaches the caller.
|
|
22
|
-
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`.
|
|
23
|
-
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary
|
|
24
|
-
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title
|
|
22
|
+
- `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`. No `siteUrl`, or a write error, is a `<sitemap>` build failure (see the output-failures entry below).
|
|
23
|
+
- `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises` and then every run already on `_generating` when it was called (settled, so a failed run stays that run's failure), writes the Markdown copy of each page that rendered before the call and should have one (`_writeMissingCopies()`, `KissPage.writeMarkdownCopy()`: the `.llms()` called from a `generate()` callback, whose pages had already rendered without copies), writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary`, or a write error, is a `<llms>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
24
|
+
- `.feed(options, callback)` — the third file from the same registry, on the identical shape: records `_feedRequest`, awaits `_promises`, writes the RSS document via `writeFeed()` (`AIKB/feed.md`), records `_feedPath` (`folders.build` joined to `feedFileName(options)`, so a staged build records the staging path and the report maps it back) for the report's `feed` field, invokes `callback` (with the rendered XML) through `_runCallback`; returns `this`. A missing `siteUrl` or `title`, or a write error, is a `<feed>` build failure (see the output-failures entry below), and the callback does not fire.
|
|
25
|
+
- `.robots(options, callback)` — the fourth file from the same chain, writing `robots.txt` (`AIKB/robots.md`): records `_robotsRequest` (so a watch rebuild re-issues it), awaits `_promises`, writes the file via `writeRobots()`, records `_robotsResult` (`file`, `agents`, `disallowAll`, `blocked`, `sitemaps`) for the report's `robots` field, logs a `notice` when the whole site is disallowed and an `info` line when a named agent is, invokes `callback` (with the rendered text) through `_runCallback` unless an existing file was left in place under `overwrite: false`; returns `this`. Its `Sitemap:` line is emitted only when `.sitemap()` was called — the `.then()` bullet below says why that is read late.
|
|
26
|
+
- `config`, `logger`, `verbose`, `handlebars` and `remarkable` are public instance fields rather than methods: the resolved config, the (staging-wrapped) logger, the `verbose` flag, and the per-instance Handlebars and Remarkable objects a site may register against.
|
|
25
27
|
- There is **no** `.aikb()` method, and deliberately so: the site's knowledge base is written by `npx kiss-ssg aikb <script>`, which sets `KISS_AIKB` for the build it spawns. `_finishBuild()` reads that variable, and no chainable call turns it on (see `_buildAikb()` in `AIKB/build-finish.md` and `AIKB/aikb.md`).
|
|
26
28
|
- `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
|
|
27
29
|
- `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and queued asset reconciliation (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is in `AIKB/rebuild.md` — and every rebuild it asks for goes through the serial queue.
|
|
@@ -31,7 +33,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
31
33
|
|
|
32
34
|
## Depends on
|
|
33
35
|
|
|
34
|
-
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`.
|
|
36
|
+
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./site-helpers.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./robots.js`, `./redirects.js` (the renderers it re-exports), `./cache-headers.js` (the renderers it re-exports), `./images.js` (`createImageIndex`), `./html-split.js` (re-exported), `./check.js` (`checkModeRequested`), `./kiss-page.js` (`preloadMinifier`), `./dev-server.js`; and the modules this file's private methods delegate to, each a set of functions over the instance: `./staging.js`, `./asset-copy.js` (which also holds `assetCopyKey`, used by `_replay()`), `./page-registry.js`, `./build-finish.js`, `./rebuild.js`. It also names `./build-report.js`, `./aikb.js`, `./packages.js` and `./css-purge.js` in JSDoc types only, which load nothing at run time.
|
|
35
37
|
|
|
36
38
|
## Depended on by
|
|
37
39
|
|
|
@@ -69,6 +71,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
69
71
|
- **The redirect files are written in the same settle path, one line above the scan, and the findings are not.** There is no `.redirects()` method: an alias is a property of a page, not a file the author asks for, so the write has to be automatic — and the only two honest places for it are the promise queue and here. The queue (`Promise.all(this._promises)` pushed onto `_generating`, the `.sitemap()` shape) needs a call site, and the only one that exists on every build is the constructor, where `_promises` holds the asset copy and not one page: the write would run against an empty stack. So `_writeRedirects()` sits at the top of `complete()`'s settle path instead, above the three-way branch, where `_settle()` has already drained the queue (`sitemap.xml`, `llms.txt` and the feed are on disk) and nothing has yet been discarded or promoted. That ordering is load-bearing twice over: written after `_promote()` the file would land in the **promoted** folder, breaking the "the whole folder appears at once" promise with a window where the site is live with none of its redirects; written after `_discardStaging()` it would _resurrect_ the staging folder `fs.outputFile` creates parents for, leaving a stray `<build>.kiss-staging-…` beside published output under `kiss-ssg check`. It also comes free on replay, because `_replay()` ends in `complete()` — which is why `_redirectsRun`, the per-build promise latch that stops the nested-`complete()` pattern writing the file twice, is reset there beside `_redirectsPath`. Unlike the link scan it runs in dev too: nothing serves `_redirects` locally, but it costs one small file and keeps "what the build folder holds" from depending on the mode, where a scoped re-render genuinely has not rewritten every page. A write that rejects is a **build failure**, not an advisory: the `.catch` pushes `{ view: '<redirects>', buildTo: null, error }` onto `_failures`, one line above the settle branch that reads it, so the staging folder is discarded and the previous deployment stays live. That is where `_redirects` parts from `sitemap.xml`, `llms.txt` and the feed, whose write failures are logged and swallowed — a site published without its sitemap is a site search engines index a little later, but a site published without its redirects is a site whose old URLs 404, which is the loss the aliases exist to prevent. The **findings** are pure data and stay in `_finishBuild()`, before `buildReport`: `_redirectFindings()` re-collects the rules from the stack (the same pure function over the same stack, so the file and the count cannot disagree), reads the baseline through `_lastBuildRecord()` and latches `{ file: _redirectsPath, aliases, rules, json: _redirectsJsonPath, format: _redirectsFormat, files: _redirectsFiles, removed, collisions, moved }` on `_redirectsResult`, or `null` when there is nothing to say — no alias and no finding, which since `moved` landed means `rules.length === 0 && removed.length === 0 && moved.length === 0` (a move is the one finding a site with no alias anywhere can still have). The pages it hands `redirectFindings` are `_stackForRecord()`'s, not the raw stack's, so the `id` the pairing keys on is the same projected id the report and the site map show — a default id withdrawn from two pages is `null` on both sides and cannot pair with an explicit id of that name in the baseline. Each move logs one `notice` carrying its fix (`moved without redirect: <from> -> <to> (<id>) — add "<from>" to that page's aliases`), where the summary line prints the finding without the fix. That last rule is what keeps `last-build.json` byte-identical across two identical records: an empty verdict issued whenever a record merely _exists_ would make a site's first record (written before there is a baseline) differ from its second (written after) with nothing about the site having changed. The baseline is gated on `<folders.aikb>/last-build.json` **existing**, never on `isRecorded()` — that answers "is there a `site-map.json`", which `_buildAikb()` wrote one line earlier, so on a site's first `kiss-ssg aikb` it is true while the record is still absent — and it is read _before_ `writeLastBuild()` replaces it, so a recording run is measured against the previous record rather than against itself. An unreadable record is no baseline rather than a build failure. One `notice` per finding, in `formatReport`'s wording; neither moves `ok` or the exit code. `AIKB/redirects.md` has the derivation half.
|
|
70
72
|
- **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`, `lib/check.js`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs/router.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` in `AIKB/build-finish.md`). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
|
|
71
73
|
- **`config.folders.build` is the author's folder throughout a staged build; the staging path is the engine's `_writeRoot`.** It used to be the other way round — `_setupFolders()` repointed `config.folders.build` at the staging sibling and `_promote()` pointed it back — which routed every write in one move and also handed the staging name to everything that reads config: a template rendering `{{config.folders.build}}`, a controller, and a site's `complete()` callback under check, where nothing ever pointed it back because a check never promotes. Measured on student-handbooks during the 2.5.0 fleet upgrade: `kiss-ssg check` made its callback print `Handbooks generated to: ./handbooks/test.kiss-staging-…`. Now the write sites read `_writeRoot` and the writer modules get `_writeConfig`, config is never touched, and `test/integration/staging.test.js` pins it three ways (the callback under check, the template under `'atomic'`, and a gate that walks every written file, the report, the `KISS_REPORT` line and `last-build.json` for the staging name). The one reader that sees the staging path on purpose is a pipeline step's `KISS_BUILD`: a tool's output has to land in the build being staged, or the swap would leave it behind.
|
|
74
|
+
- **An output the build asked for that fails is a build failure: `<sitemap>`, `<llms>`, `<feed>`, `<robots>`.** Each of `.sitemap()`, `.llms()`, `.robots()` and `.feed()` pushes its run onto `_generating`, which `_drain()` awaits, so the failure is on `_failures` before `complete()`'s settle reads it: the write's `.catch` and the missing-input branch (a writer status other than `written`/`skipped`: `no-site-url`, `no-title`, `no-summary`, turned into an error by `outputInputError`) both call `_recordOutputFailure(view, error)`, which logs, pushes `{ view, buildTo: null, error }` and calls `_refreshReport()`. The refresh is the existing in-place mechanism (a no-op before the first settle, since `_report` is null then); it is what makes an output requested after a build settled (a call in a `generate()` callback that fires late) still move `report().ok`. Not `_carry`ed: a whole-site replay clears it and re-issues the request, which fails again if it still must. Before this change these only logged, and `report().sitemap/feed/llms` was `null` exactly as for an output never asked for. The Markdown copy beside them was already a failure (`_writeMissingCopies`).
|
|
72
75
|
- Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N build failure(s): <buildTo paths>"` — `buildTo`, not `view`, because `.pages()` fan-out gives every failed entry the same `view` and a message built from `view` would repeat it; `err.failures[]` still carries `view` per entry). Entries with no output path — a controller failure, or a callback failure — fall back to `view` in that message, and so does any `<pseudo-view>` even when it _has_ a `buildTo`. **The count noun used to be "page(s)", and that was a lie for every pseudo-view on `_failures`** — `<site helpers>`, `<pipeline>`, `<redirects>`, `<dev server>`, `<sass: <source folder>/<file>>`.
|
|
73
76
|
- **A Sass failure is identified by the COPY it came from**, not by the file's name within it: `<sass: src/assets/css/theme.scss>`, project-relative when the source folder is inside the project and absolute when it is not. Two asset roots can each hold `css/theme.scss`, and identifying a failure by the source-relative name alone meant a second `.copyAssets()` whose own copy compiled **cleared the first root's failure** — a green verdict with the primary stylesheet missing. Each copy clears the set of failures it recorded last time (by object identity, `AIKB/asset-copy.md`) rather than by the files it just returned, which is also what lets a **deleted or renamed** broken stylesheet lose its entry: it is absent from the new results, so a per-file clear could never reach it, and `_failures` is otherwise cleared only by `_replay()`, which an asset re-copy is not. A helpers module reported as `1 page(s) failed to build: .../helpers/index.js` sends the author to look at their pages; the label is the useful half, and the path is already in the error logged above it. `generate()`'s own callback still fires either way — callbacks are not the render error channel; `complete()` is. `complete()` does not invoke its callback on failure (same rule as `sitemap()`'s callback).
|
|
74
77
|
- **A replay sweeps `_failures`, it does not empty it** (`carriedAcrossReplay`). A whole-site replay re-runs every page, every controller, every callback and the redirects, so clearing those and letting the rebuild say them again is right. It does not re-import the helpers entry, it does not restart the dev server, and it recompiles the stylesheets only when an `assets.pipeline` step is configured — which the ordinary site has not. Emptying the list dropped all three, and each was still true: a site whose `site.scss` would not compile reported `ok` on the next controller save with nothing fixed, no CSS in the build, and the red compile line already scrolled past. So `<sass: …>`, `<site helpers>` and `<dev server>` are carried across a replay and everything else is dropped. Carrying cannot strand a fixed one, because each is cleared by its own producer when that producer runs again (`copyAssets` by the copy that owns it, `_loadHelpers` by name) and something has to run that producer again. **That last clause is where the first version of this was wrong**: it was true only of `config.folders.assets`, because the replay's pipeline branch and the watcher's `assetsChanged` both re-run THAT copy and no other. A root the site registered itself with `kiss.copyAssets(other, dest)` was never re-run, so its failure could not be cleared by anything — fixed on disk, full replay, still failed, for the rest of the session, on a build whose files are all correct. The mirror of the bug the carrying fixed, and the worse half to leave: an author who fixes a file and watches the build stay red stops believing the verdict. So a replay now also re-runs **the copies it has something to re-check** — the ones still holding an unresolved failure, looked up through `_assetCopies` (the copies with an outstanding stylesheet failure, and only those, keyed the same way, holding the caller's UNSTAGED target so a replay re-applies staging; every source folder copied is `_copySources`, which `kiss-ssg images` reads). A healthy root is not re-copied: a model or controller edit cannot have changed it, and copying every asset root on every keystroke is the cost that guard exists to avoid. Note the standing limit this works around rather than removes — **only `folders.assets` is watched**, so an edit under a second root raises no event and is picked up by the next whole-site replay rather than by itself. `<dev server>` has no second attempt at all, which is exactly why dropping it was wrong. **Carriage is recorded, not matched.** The first version tested `view.startsWith('<sass: ')`, and `view` is only an engine sentinel for engine failures: for an inline template it is the TEMPLATE TEXT, which the author writes, so a page registered as `view: '<sass: x> {{custom "y"}}'` recorded a failure no replay would ever drop — permanent, while the page rendered perfectly. The producers now mark their own on `_carriedFailures`, a `WeakSet` of the failure objects: decided at the one place that knows, needing no pruning, and leaving `_failures` entries exactly the `{ view, buildTo, error }` shape `err.failures` hands out. The copy key canonicalises **the deepest existing ancestor** and re-appends the rest, for the same reason in the other direction — `resolve` preserves the spelling, and on a case-insensitive filesystem `./src/assets` and `./src/Assets` are one directory and were two owners, so the owning copy could not clear its own failure. A plain `realpath` with a `resolve` fallback looks equivalent and is not: it throws ENOENT for a target the copy is about to create, falls back to the spelling, and then returns the canonical spelling once the folder exists — one copy, two keys, and the same unclearable failure by a new route (measured with a target inside a symlinked parent). Walking to the deepest existing ancestor makes the key **stable across the folder's creation** for a path whose ancestor exists — which is most of them, and not the leaf itself: two spellings of a destination that does not exist AT ALL yet (the ordinary case on a fresh build) each keep their own tail, and on a case-insensitive filesystem that is one directory under two keys. So the key is computed **after the copy has run**, when the target is there and both spellings resolve to it. Keying there also puts the registration into queue order, written by the run that owns it rather than synchronously by a caller whose copy has not happened yet — which is what let a copy that SUCCEEDED evict a registration a later queued copy still needed. The paths are **resolved at registration** — in the caller's working directory, at the moment they asked — and it is those the copy runs against, not the caller's raw arguments re-resolved when the queue drains. Both legs had the bug: the replayed copy (measured first) and the INITIAL one, where anything moving the working directory between the call and the drain copied a different folder, wrote it somewhere else, and recorded the result under the original's key. `copyAssets` takes a `display` pair so the id and the log line stay the strings the author wrote, and the `<sass: …>` **label is decided at registration too**. It was derived at execution time from the caller's raw argument against whatever the working directory was by then, so one copy's one failure came out relative or absolute depending on where the process happened to be when the queue drained — and a relative label re-read from a different directory resolves to a DIFFERENT file, which may exist and may compile perfectly, sending an author to a stylesheet with nothing wrong with it. It rides on `err.failures`, `report().failures` and the KISS_REPORT line, so it outlives the process that made it — resolving for correctness must not turn every build's asset line into an absolute path nobody typed. Both maps are pruned together when a copy records nothing — a replay re-runs a copy only to re-check a failure, so a copy with none needs no registration. A copy that records nothing deletes its map entry rather than storing an empty set, so a caller copying into a fresh destination each run does not accumulate one entry per success. `<pipeline…>` is deliberately not carried: it exists only on a site with configured steps, and that is the site whose replay re-runs them.
|
package/AIKB/llms.md
CHANGED
|
@@ -33,5 +33,5 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
|
|
|
33
33
|
- **A page's title is its own `title`, else its slug title-cased with hyphens as spaces** (`puppy-classes` → `Puppy Classes`) — the same rule `KissPage.prepare()` uses for the default, so an entry and the page's `<title>` agree. Until the launch-readiness release there was also an `UNTITLED = 'Index'` guard, because `KissPage` filled every untitled page's title with `'Index'` (from the placeholder slug); once that default was fixed the guard only rewrote a title somebody had written, and the operator removed it (2026-09-28). An explicit "Index" is now kept as written.
|
|
34
34
|
- Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
|
|
35
35
|
- `writeLlms` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.llms()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap`.
|
|
36
|
-
- Missing `siteUrl`, `title` or `summary` are reported as a status, never thrown
|
|
36
|
+
- Missing `siteUrl`, `title` or `summary` are reported by the writer as a status, never thrown; `Kiss.llms()` turns any status other than `'written'`/`'skipped'` into a `<llms>` build failure (`llms.txt needs options.title: pass { title } to .llms(), or remove .llms()`, and the like) via `_recordOutputFailure`, and a write error from its `.catch` the same way. It fires the callback for `'written'` and `'skipped'` only. The rest of the site still builds.
|
|
37
37
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing `llms.txt` left to skip.
|
package/AIKB/package-manifest.md
CHANGED
|
@@ -20,7 +20,7 @@ The module is pure: it takes a package's description and returns a string. It kn
|
|
|
20
20
|
|
|
21
21
|
## Depends on
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`node:crypto`; `./utils.js` (`escapeXml`, the one shared escape, which this module carried a private copy of until 2026-11-11; a test holds the manifest's escaping of the five characters).
|
|
24
24
|
|
|
25
25
|
## Depended on by
|
|
26
26
|
|
package/AIKB/pipeline.md
CHANGED
|
@@ -6,7 +6,7 @@ Runs the external commands of `config.assets.pipeline` — a CSS toolchain, an i
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `programOf(run)` → the program a command runs: its first token, whole when it is quoted (`"C:\Program Files\nodejs\node.exe" -e …` is one program). The one part of a command kiss prints or records: the log line (`pipeline: <name> — <program>`), the failure message (`pipeline step "<name>" failed (<how>): <program>`) and the site map's `program` (`lib/aikb.js`) all use it, never `run` itself, because a command's arguments are where a token typed on the command line sits, and the log and the report are not the place for it.
|
|
9
|
+
- `programOf(run)` → the program a command runs: its first token, whole when it is quoted (`"C:\Program Files\nodejs\node.exe" -e …` is one program). The one part of a command kiss prints or records: the log line (`pipeline: <name> — <program>`), the line a watch process starts under (`pipeline: watching with <program> [<name>]`, and never the `watch` command itself), the failure message (`pipeline step "<name>" failed (<how>): <program>`) and the site map's `program` (`lib/aikb.js`) all use it, never `run` itself, because a command's arguments are where a token typed on the command line sits, and the log and the report are not the place for it.
|
|
10
10
|
- `stepName(step)` → the name a step is logged and reported under: its own `name`, or `programOf(step.run)` (`npx`, `node`, `sass`), or `'pipeline'` if there is neither.
|
|
11
11
|
- `createPipeline({ steps, logger, dev })` → `{ run, close, watching }`. One per `Kiss` instance, created in the constructor and closed in `close()`.
|
|
12
12
|
- `run(env)` → `Promise<PipelineResult[]>`, one `{ name, ok, duration, error? }` per step, in order. Each step's `run` is spawned with `shell: true` in `step.cwd` (default `process.cwd()`) with `{ ...process.env, ...env }`, and awaited before the next starts. **Never rejects**; a failed step is a result carrying its `error`. In `dev` mode a step that has a `watch` command and whose `run` succeeded also gets that watch process started, once.
|
package/AIKB/redirects.md
CHANGED
|
@@ -15,12 +15,12 @@ So the rules come first and the encoding second. `redirects.json` — `{ version
|
|
|
15
15
|
## Public interface
|
|
16
16
|
|
|
17
17
|
- `canonicalPathFor(buildTo, buildDir, { trailingSlash })` → the page's origin-less canonical path: `/`, `/courses/`, `/about`. It is `toAbsoluteUrl('', toCanonicalPath(rel))` over the same naive `buildTo.slice(buildDir.length)` the sitemap makes.
|
|
18
|
-
- `normaliseAlias(alias)` → the alias as it will be written: leading `/` added, `?query` and `#fragment` removed, repeated slashes collapsed, trailing slash **preserved as written**. `null` for an alias with no path in it (`''`, `'?utm=1'`) and for one with whitespace _inside_ it (`'/old post'`, `'/a\n/b'`): the file is space-separated columns and newline-separated rules, so such an alias would write more than the one rule it stands for — and on a fan-out the alias came from the record, which may have been fetched rather than typed.
|
|
18
|
+
- `normaliseAlias(alias)` → the alias as it will be written: leading `/` added, `?query` and `#fragment` removed, repeated slashes collapsed, trailing slash **preserved as written**. `null` for an alias with no path in it (`''`, `'?utm=1'`) and for one with whitespace _inside_ it (`'/old post'`, `'/a\n/b'`): the file is space-separated columns and newline-separated rules, so such an alias would write more than the one rule it stands for — and on a fan-out the alias came from the record, which may have been fetched rather than typed. Also `null` for anything outside an **allow-list** (`PLAIN_ALIAS`, applied once the query and fragment are stripped): unreserved characters (`A-Za-z0-9-._~`), a `%XX` escape, `/`, the sub-delimiters no host reads as a pattern (`$ & ' , ; =`), `:` inside a segment (`/a:b`, never at a segment's start) and any non-ASCII character (a raw `/café`). The alias is written unchanged into four formats that read far more than `*` and `:x` as patterns (Netlify `*`/`:name`; Firebase globs `{a,b}`, `[…]`, `@(…)`, `?`; Vercel path-to-regexp `(…)`, `{…}`, `+`), so a deny-list of the shapes seen so far would lose to the next one; a fetched record could otherwise write a site-wide rule, and it is refused wherever it came from, because the function cannot tell the source. Internally `checkAlias` returns `{ path }` or `{ reason }`, `reason` one of `whitespace`, `pattern`, `query-only` (an alias that is only a query or fragment); `normaliseAlias` is its path-or-`null` view and is silent. `collectAliases` takes an optional `onDrop(alias, entry, reason)` and calls it for each non-empty alias refused. Two callers hand one over: `writeRedirects` (the warning, once per build) via `describeDroppedAlias(alias, page, reason)` — `Alias "<alias>" on <view or output path> dropped: ` plus `an alias is one old path, and this one has whitespace in it`, `… has characters a host reads as a pattern`, or `an alias is a path, and this one is only a query or a fragment` — and `findRedirectChanges` (the record in `report().redirects.dropped`, `AIKB/build-finish.md`). `droppedAliasPage` names the page the same way for both. An empty alias is dropped without a word, and is not in the list.
|
|
19
19
|
- `collectAliases(stack, { buildDir, trailingSlash })` → `RedirectRule[]` (`{ from, to }`), sorted by `from` then `to`. Reads `entry.page.options.aliases` — an array, or a bare string — from every stack entry that is not `generate: false`; `to` is that entry's `canonicalPathFor`. One page listing an alias twice yields one rule; two pages claiming one `from` yield two.
|
|
20
20
|
- `renderRedirects(rules)` → the Netlify/Cloudflare Pages text: one `<from> <to> 301` per line, sorted again, newline-terminated, `''` for no rules.
|
|
21
21
|
- `renderRedirectsJson(rules)` → the host-neutral IR: `{ version: 1, rules: [{ from, to, status: 301 }] }`, pretty-printed and sorted, so two identical builds write identical bytes.
|
|
22
22
|
- `renderHtaccessRedirects(rules)` → the Apache fragment: one `Redirect 301 <from> <to>` per line, sorted. mod_alias `Redirect`, **not** `RewriteRule` — a rewrite's left-hand side is a regex, so an alias containing a `.` or a `+` would match paths it does not name.
|
|
23
|
-
- `renderFirebaseRedirects(rules)` / `renderVercelRedirects(rules)` → a fragment to merge: `{ redirects: [{ source, destination, type: 301 }] }` and `{ redirects: [{ source, destination, permanent: true }] }`. Vercel's `permanent: true` is a 308 — the permanent redirect that platform offers, and the closest honest equivalent of the `301` every other format writes.
|
|
23
|
+
- `renderFirebaseRedirects(rules)` / `renderVercelRedirects(rules)` → a fragment to merge: `{ redirects: [{ source, destination, type: 301 }] }` and `{ redirects: [{ source, destination, permanent: true }] }`. Vercel's `permanent: true` is a 308 — the permanent redirect that platform offers, and the closest honest equivalent of the `301` every other format writes. The Vercel `source` escapes each `:` as `\:`: its path-to-regexp reads `:name` as a parameter **anywhere** in a path, not only at a segment's start as Netlify does, so the colon the allow-list keeps inside a segment (`/wiki/Category:News`) would otherwise also match `/wiki/Category:Sports`. The other three formats read it literally and write it unchanged (found by the close's Codex review, 2026-11-11).
|
|
24
24
|
- `normaliseWriterOutput(returned, buildDir)` → a custom writer's return value as `[{ file, contents }]`, with `file` resolved under the build folder. A writer returning nothing writes nothing. An absolute path, or one climbing out with `..`, **throws**: a redirect writer is site code running in a build, and "write any file on this machine" is not the capability it asked for.
|
|
25
25
|
- `coveredByAlias(rel, fromSet, { trailingSlash })` → whether an alias already answers for a build-relative old **file** path. The match is made **in canonical form** (`/old-post.html` → `/old-post`, `/old/index.html` → `/old/`), which is the form a rule's source and target are both written in; an alias spelled as the file (`/old-post.html`) covers nothing. One predicate, used by `removed` and by `moved`, so the two can never disagree about one page.
|
|
26
26
|
- `redirectFindings({ rules, currentPages, previousPages, buildDir, trailingSlash, ids })` → `{ removed, collisions, moved }`, all three sorted. `moved` pairs by id only when the record's `ids` (absent: `'view'`) equals this build's `ids` (`config.ids`). Across a switch, the same id string can name a different page (`index` was `landing.hbs` at `home.html` and is now the home page), so pairing would report a move of two live pages. `removed` runs on paths either way. `currentPages` is `[{ buildTo, id }]` for the pages this build writes; `previousPages` is the last record (`{ buildDir, pages: [{ buildTo, hash, id }] }`) or `null`. `removed` and `moved`'s `from`/`to` are **served** paths (`servedPathFor`), not file paths.
|
package/AIKB/robots.md
CHANGED
|
@@ -46,4 +46,4 @@ Unlike `_redirects` there **is** a method to call. A crawl policy is a statement
|
|
|
46
46
|
|
|
47
47
|
- **A block with no rules still writes a bare `Disallow:`.** That is the format's way of saying "nothing is disallowed"; a `User-agent:` line with no directive under it is undefined in the original spec and is read inconsistently. `Disallow:` and `Disallow: /` differ by one character and mean opposite things, which is the whole reason the empty case is written explicitly rather than left out.
|
|
48
48
|
|
|
49
|
-
- **A write failure
|
|
49
|
+
- **A write failure fails the build** (changed from "logged, not fatal"). `Kiss.robots()`'s `.catch` logs, then `_recordOutputFailure('<robots>', err)` pushes `{ view: '<robots>', buildTo: null, error }` onto `_failures`, so `complete()` rejects, `report().ok` is false and `check` exits 1. A call to `.robots()` is the author asking for the file; a green build with no `robots.txt` (or a stale one) was the defect. There is no missing-input case: `robots()` needs no `siteUrl` (a `sitemap` URL is only advertised when it can be built). `AIKB/kiss.md` has the mechanism.
|
package/AIKB/sitemap.md
CHANGED
|
@@ -7,12 +7,12 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `buildSitemapEntries(stack, { siteUrl, buildDir, trailingSlash = true, logger })` → array of `{ loc, lastmod?, changefreq?, priority? }`. Skips entries where `entry.page.options.ignoreSitemap` is truthy, where `entry.page.options.canonical` is set (the page has named another URL as the real one, and a sitemap that listed it would advertise a duplicate), and where `entry.page.options.generate === false`. `loc` = `siteUrl` joined to the page's URL path (derived from `buildTo`) by `toAbsoluteUrl` from `utils.js`. `lastmod` is the page's `sitemapLastmod` as given, else its `updated`, else its `date` (each read from the options, then the resolved model, by `feed.js`'s `rawDate`), and is absent when the page has none. `priority` and `changefreq` are present only when the page sets `sitemapPriority` / `sitemapChangefreq`.
|
|
10
|
-
- `renderSitemapXml(urls)` → the `sitemap.xml` string (standard `<urlset>`/`<url>` schema; `<lastmod>`, `<changefreq>` and `<priority>` each emitted only when the entry has it).
|
|
10
|
+
- `renderSitemapXml(urls)` → the `sitemap.xml` string (standard `<urlset>`/`<url>` schema; `<lastmod>`, `<changefreq>` and `<priority>` each emitted only when the entry has it). Every value goes through `escapeXml` (`utils.js`), so a `siteUrl` with `&` or a `sitemapLastmod` with `<` still writes well-formed XML; the others (`sitemapChangefreq`, `sitemapPriority`) are the author's words and are escaped, not validated.
|
|
11
11
|
- `async writeSitemap(stack, { config, logger, overwrite = true })` → `Promise<{ status, urls }>`. `status` is `'no-site-url'` (no `config.siteUrl`, nothing written), `'skipped'` (`overwrite: false` and a `sitemap.xml` already exists), or `'written'` (file written, `urls` populated).
|
|
12
12
|
|
|
13
13
|
## Depends on
|
|
14
14
|
|
|
15
|
-
`fs-extra`; `./utils.js` (`toCanonicalPath`, `toAbsoluteUrl`); `./feed.js` (`rawDate`, `toDate`), so a page's date is read by one rule in both files.
|
|
15
|
+
`fs-extra`; `./utils.js` (`toCanonicalPath`, `toAbsoluteUrl`, `escapeXml`); `./feed.js` (`rawDate`, `toDate`), so a page's date is read by one rule in both files.
|
|
16
16
|
|
|
17
17
|
## Depended on by
|
|
18
18
|
|
|
@@ -26,5 +26,5 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
|
|
|
26
26
|
- URL derivation from `buildTo`: strip the leading `buildDir` (`entry.buildTo.slice(buildDir.length)`), then `toCanonicalPath` (the file extension dropped, the `index` segment kept) and `toAbsoluteUrl` (joined to `siteUrl`, a trailing `index` segment becoming a trailing `/`, an empty path giving the site root with one trailing slash). **Those two functions are shared with the `canonical` helper on purpose** — a page's `<loc>` and its own `<link rel="canonical">` are built by the same code, so they cannot drift apart (`AIKB/handlebars-helpers.md`). Changing the derivation here changes both; an integration test asserts they stay equal (it builds a site with a section index under `extensionLess` on and off). The one deliberate exception is a page that overrides `config.siteUrl` for itself: its canonical URL follows the override, its `<loc>` keeps the site's own domain.
|
|
27
27
|
- **A directory index gets a trailing slash in its `<loc>` by default** — `out/courses/index.html` is `https://site/courses/`, not `https://site/courses`. Netlify serves the directory at the slashed URL and answers the bare one with a 301, so the old shape listed a redirect for every section index; the sitemap lists the URL that returns 200. **Which one that is depends on the host**, so `buildSitemapEntries` takes `trailingSlash` from `config.links` and a Firebase-style site lists the bare form instead — see the measured matrix in `AIKB/utils.md`. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html` and so ends in `/` too — again the URL the host actually serves. This is why the derivation goes through `toCanonicalPath` rather than `toURLKey`: the identity key drops the `index` segment outright, which is exactly what produced the redirecting URL.
|
|
28
28
|
- `overwrite: false` only has any observable effect when `config.cleanBuild: false` too — the default `cleanBuild: true` already empties the whole build directory (any prior `sitemap.xml` included) in the `Kiss` constructor, before `.sitemap()` ever runs, so there is nothing left to "skip" in the default configuration.
|
|
29
|
-
- `writeSitemap` deliberately does **not** catch its own write errors (`fs.outputFile` can reject) — they propagate up to `Kiss.sitemap()`'s `.catch()`, which logs the error
|
|
30
|
-
- `status` values and what `Kiss.sitemap()` does with each: `'no-site-url'`
|
|
29
|
+
- `writeSitemap` deliberately does **not** catch its own write errors (`fs.outputFile` can reject) — they propagate up to `Kiss.sitemap()`'s `.catch()`, which logs the error, does **not** invoke the callback, and records `<sitemap>` on `_failures` (`_recordOutputFailure`), so the build fails. (It used to be silent beyond the logged error, with `report().ok` true.)
|
|
30
|
+
- `status` values and what `Kiss.sitemap()` does with each: `'no-site-url'` records a `<sitemap>` build failure (`sitemap.xml needs config.siteUrl: set siteUrl in the Kiss config, or remove .sitemap()`) without invoking the callback; `'skipped'` and `'written'` both fall through to invoke the callback with `urls` (`null` for `'skipped'`, the array for `'written'`). The writer still only logs and returns the status; the failure is Kiss's decision.
|
package/AIKB/staging.md
CHANGED
|
@@ -135,9 +135,18 @@ direct call would step around the patch.
|
|
|
135
135
|
written into it afterwards.
|
|
136
136
|
- **A crashed run's siblings are swept at construction.** `sweepStaleSiblings` (atomic, non-dev
|
|
137
137
|
only) removes any `<build>.kiss-staging-*` / `<build>.kiss-old-*` beside the target and logs one
|
|
138
|
-
`notice` naming them.
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
`notice` naming them. A sibling is kept when the pid in its name is **running**: this process's
|
|
139
|
+
own (a second `Kiss` building the same target in one process) or another's (a `kiss-ssg check`
|
|
140
|
+
run beside a build still in progress, which used to delete the live build's staging folder,
|
|
141
|
+
reproduced 2026-11-09). Liveness is `process.kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive
|
|
142
|
+
under another user. One `info` line per folder left for that reason names it and the pid parsed after the
|
|
143
|
+
prefix (never re-read from the whole name, which a build folder like `site-2026-out` would
|
|
144
|
+
poison), says only that a process is running with that pid so it _may_ be a build in progress, and
|
|
145
|
+
says to delete the folder when no build of this site is running. A name whose pid cannot be read
|
|
146
|
+
is treated as stale. **Known limit (work order 03's, kept by decision):** liveness is all there is
|
|
147
|
+
to go on, so a pid the system has recycled for an unrelated process keeps a dead build's
|
|
148
|
+
leftovers until that process ends, or until someone deletes the folder as the line says; there is
|
|
149
|
+
no lock file, timestamp or age guard. The names are built (`stagingSiblings`) and matched (`sweepStaleSiblings`) in this one file
|
|
141
150
|
on purpose: they are two halves of one invariant.
|
|
142
151
|
- **After a promotion the instance is an ordinary one.** `promote` clears `_stagingDir`, so
|
|
143
152
|
`_writeRoot` is the real folder again, and re-points every stack entry's `buildDir`/`buildTo`, so
|
package/AIKB/testing.md
CHANGED
|
@@ -43,6 +43,7 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
43
43
|
- `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
44
|
- `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
45
|
- `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.
|
|
46
47
|
- **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.
|
|
47
48
|
|
|
48
49
|
- `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.
|
|
@@ -84,5 +85,6 @@ npm run types # regenerate types/ from the JSDoc in lib/
|
|
|
84
85
|
- **A test that makes or reads images with `sharp` turns its cache off (`sharp.cache(false)`).** libvips' operation cache keeps the source files open, and on Windows an open file cannot be removed, so the temp folder's `afterEach` and the build's staging discard fail with `EPERM`. `image-encode.test.js`, `images-sweep.test.js` and both image integration tests do it at the top, as `lib/image-encode.js` does for the engine; `image-header.test.js` and `images.test.js` mostly parse buffers sharp returns, and do not.
|
|
85
86
|
- **A Windows-only behaviour gets a `skipIf(process.platform !== 'win32')` test, which CI's Windows leg runs.** Two of the image work's fixes are like that: a `\`-joined staging path (`build-report.test.js` › `reportedPath`) and a cache folder spelt in two letter cases (`images-sweep.test.js`). On Ubuntu they are skipped, which is correct: there a `\` is a filename character and case is significant.
|
|
86
87
|
- **The one timing assertion in the suite is sized far from both outcomes.** `image-header.test.js` › "does bounded work when extent records take no bytes at all" asserts under 250 ms, where the bounded code takes about 10 ms and the unbounded one took 1.5 s (2026-11-05). A loaded runner can be ten times slower without failing it; a regression that brings back the loop cannot pass it.
|
|
88
|
+
- **`test/integration/scale.test.js` guards that build time grows in proportion to the page count.** It builds one generated site (each page links to two others with `{{link}}`, the link check and audit on) at 500 and at 2,000 pages in temp folders, takes the faster of two runs at each size after a discarded warmup build, and asserts the 2,000-page time is under 6 times the 500-page time: linear is 4, quadratic 16. It asserts a ratio because a ratio holds across machines and a time in seconds does not (measured here: 3.8 to 4.05 over five runs, about 8 s for the file). Seen red with a quadratic loop in the controller (ratio 10.17). When it fails, the fix is in the engine: never raise `MAX_RATIO`.
|
|
87
89
|
- **`kiss-ssg speed` is tested against a fake Lighthouse**, a `lighthouse` and a `chrome-launcher` package written into the temp site's `node_modules` and resolved from the site's folder, the way the real ones are (`test/integration/speed.test.js`). A real Lighthouse run needs Chrome and takes seconds per page, so none happens in the suite.
|
|
88
90
|
- The CJS interop test (`test/integration/esm.test.js`) spawns a real `node` binary on a `.cjs` file via `execFile` — a slow or misconfigured `node` on `PATH` shows up as a test timeout there, not an assertion failure.
|
package/AIKB/utils.md
CHANGED
|
@@ -22,6 +22,7 @@ Small string/path helpers shared across the engine: slugification, title-casing,
|
|
|
22
22
|
- `redactUrl(url)` → a URL as kiss **shows** it, never as it fetches it: scheme, host, port and path kept (as the WHATWG parser writes them: host lowercased, a default port dropped), no `user:pass@`, no fragment, each query parameter's name kept and its value replaced by `[redacted]` (`?access_token=SECRET&type=course` → `?access_token=[redacted]&type=[redacted]`: kiss cannot know which value is the secret, and the names are what tell one model from another), a parameter with no `=` replaced whole (`?[redacted]`), an empty query dropped. A secret in the **path** cannot be told from a path segment and is shown as written; `GUIDE.md` § Remote models sends a credential to `fetch.headers`. A string the parser rejects, **or one it accepts with a scheme that is not `http:` or `https:`** (the parser gives such a scheme no authority, so `httpx:user:pass@host/x` parses with the credentials as its path, and was shown whole until 2026-11-06), cannot be split into credentials, path and query (a password may hold a raw `/`, `?`, `#` or `@`), so it goes by the position of its **last** `@` against its first `?` or `#`: no `@`, it is cut at the first `?` or `#` and given `?[redacted]` when it had a query; the last `@` before that cut, everything between the scheme and that `@` is dropped and then the same cut applies (`https://user:abc/def==@api.example.com/data?x=1` → `https://api.example.com/data?[redacted]`); the last `@` after it, only the scheme is kept (`https://user:pa?ss@api.example.com/data` → `https://[redacted]`). "The scheme" is `scheme:` with whatever run of `/` or `\` follows it, as written, because the parser opens an authority after `http:`, `http:/` and `http:\\` alike; a string with no scheme keeps nothing before its `@`. **Named export only, not on the default object** — it is engine-internal, and the default object is the public `utils` a site imports.
|
|
23
23
|
- `REDACTED` → the string `'[redacted]'`, the one word every redacted value becomes: `redactUrl`'s query values and `lib/config.js`'s `redactFetchHeaders`. Named export only, not on the default object.
|
|
24
24
|
- `isUrlModel(model)` → `true` for a string whose scheme is `http:` or `https:`, in any case and after any leading space (`/^\s*https?:/i`, both of which `fetch` takes), `false` for anything else, a non-string included. The one test of whether a page's `model` is a URL kiss fetches, made by `lib/model-resolver.js` (`resolveModel`, which fetches it), `lib/aikb.js` (`classifyModel`, which records it) and `lib/page-registry.js` (`registerPage`'s `No view specified` log, which redacts it). Until `b8be845` it was the prefix test those three copies made (`startsWith('http')`), which had two faults the code review of that commit named: `HTTPS://api.example.com/x?token=…` and a URL with a leading space were taken for a folder, never fetched, and printed whole by `Invalid model …`, past every redaction; and `httpdata.json`, a file, was sent to the network. `httpx://…` is not a URL model either: it is a string `resolveModel` rejects, shown through `redactUrl`. Named export only, not on the default object.
|
|
25
|
+
- `escapeXml(value)` → `String(value)` with `&` (first, so the entities after it are not re-escaped), `<`, `>`, `"` and `'` replaced by their entities: right for XML text and for an attribute value alike. Moved here from `lib/feed.js`; used by `lib/feed.js`, `lib/sitemap.js` and `lib/package-manifest.js` (whose private copy was removed 2026-11-11). Named export only, not on the default object.
|
|
25
26
|
- `escapeRegExp(text)` → `text` with every character a `RegExp` reads specially escaped, for building a pattern from a path or a name. Named export only, not on the default object. Used by `lib/css-purge.js`, `lib/staging.js` and `lib/issue.js`; `lib/audit.js` and `lib/links.js` still carry older copies of their own (recorded 2026-11-08, not merged).
|
|
26
27
|
- `export default { trimLines, toSlug, toTitleCase, trimPath, sanitizePath, posixPath, globFiles, hashId, toURLKey, toAbsoluteUrl, toCanonicalPath, servedPathFor, relativePathFor }` (also each function is a named export).
|
|
27
28
|
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,69 @@
|
|
|
3
3
|
Written for people building a site with kiss-ssg, not for people maintaining it.
|
|
4
4
|
Newest first. `/branch-close` adds an entry alongside each version bump.
|
|
5
5
|
|
|
6
|
+
## 2.15.0 — 2026-11-11
|
|
7
|
+
|
|
8
|
+
Clearing the ground for 3.0. A build that asked for a sitemap, feed, `robots.txt` or
|
|
9
|
+
`llms.txt` and did not get one now fails instead of passing. Hashed JavaScript and CSS
|
|
10
|
+
that import each other keep working. An alias a host would read as a pattern is refused
|
|
11
|
+
and reported. Read the upgrading notes: a build that passes today can fail on the first.
|
|
12
|
+
|
|
13
|
+
- **An output the build asked for that cannot be written fails the build.** A build that
|
|
14
|
+
called `.sitemap()`, `.feed()`, `.robots()` or `.llms()` reported `ok: true` when the
|
|
15
|
+
file could not be written, or when it was skipped for a missing `siteUrl`, feed `title`
|
|
16
|
+
or `llms` `summary`: one red log line, and `report()` showed the output as `null`, as if
|
|
17
|
+
it had never been asked for. Each now goes into the build's failures (`<sitemap>`,
|
|
18
|
+
`<feed>`, `<robots>`, `<llms>`), so `complete()` rejects, `report().ok` is false and
|
|
19
|
+
`kiss-ssg check` exits 1, with a message naming what is missing and how to fix it.
|
|
20
|
+
- **A hashed file that another asset imports keeps its name.** Under `assets.hash`, a
|
|
21
|
+
`.js` file's `import './util.js'` and a stylesheet's `@import "other.css"` named files
|
|
22
|
+
the build had renamed, so the module or the imported rules 404'd, and no check noticed.
|
|
23
|
+
A `.js` or `.css` file another hashed asset imports by path (`import … from`,
|
|
24
|
+
`export … from`, `import()`, `@import`) is now copied under its own name, so the import
|
|
25
|
+
resolves; one info line names them. GUIDE.md § Assets says which imports are seen: within
|
|
26
|
+
one assets folder, relative or from the site root.
|
|
27
|
+
- **An alias is one old path, never a pattern.** An alias is kept only if every character
|
|
28
|
+
is a plain path character. `*`, `:name` at the start of a segment, `{a,b}`, `[…]`, `(…)`,
|
|
29
|
+
`+`, `!` and `@` are read as patterns by Netlify, Firebase, Vercel or `.htaccess`, and an
|
|
30
|
+
alias carrying one (from a fetched record, say) could write a site-wide rule. A dropped
|
|
31
|
+
alias is now listed in `report().redirects.dropped` with its page and a reason
|
|
32
|
+
(`whitespace`, `pattern`, `query-only`), shown by `check --summary` and recorded in
|
|
33
|
+
`last-build.json`; before, it was dropped with no word. A colon inside a segment
|
|
34
|
+
(`/wiki/Category:News`) is still a plain path, and `redirects.vercel.json` now escapes it
|
|
35
|
+
(`\:`), because Vercel reads `:News` as a parameter anywhere in a path.
|
|
36
|
+
- **A URL model fetched with `fetch.headers` never sends them to another origin.** A
|
|
37
|
+
redirect to the same origin is followed (at most five); a redirect to another origin
|
|
38
|
+
fails that model, naming both origins, and is not retried. With no custom headers,
|
|
39
|
+
redirects are followed as before.
|
|
40
|
+
- **`kiss-ssg check` beside a running build no longer deletes that build's staging
|
|
41
|
+
folder.** The sweep of leftover `<build>.kiss-staging-…` folders spares one whose
|
|
42
|
+
process is still running, and says so.
|
|
43
|
+
- **`sitemap.xml` escapes its values**, so a `siteUrl` with `&` writes valid XML.
|
|
44
|
+
- **An asset pipeline's `watch` line names the program, never its arguments**, where a
|
|
45
|
+
token might sit.
|
|
46
|
+
- **Releases are staged by CI with npm provenance.** From this version, a release is built
|
|
47
|
+
from its tagged commit by a workflow, staged on npm, and approved by the maintainer with
|
|
48
|
+
2FA; npm attaches a provenance attestation.
|
|
49
|
+
|
|
50
|
+
Measured: three real sites (20, 7 and 11 pages) built from the packed 2.14.0 and from
|
|
51
|
+
this release give the same files, byte for byte; none relied on a silently skipped output.
|
|
52
|
+
|
|
53
|
+
### Upgrading
|
|
54
|
+
|
|
55
|
+
- **If your build calls `.sitemap()`, `.feed()` or `.llms()`, check it still passes.**
|
|
56
|
+
One without `siteUrl` in the Kiss config, a `.feed()` without `title`, or a `.llms()`
|
|
57
|
+
without `title` or `summary` now fails the build where it passed with an error line.
|
|
58
|
+
Set the missing value, or remove the call if you did not want the file.
|
|
59
|
+
- **Run `npx kiss-ssg check <script> --summary` and look for `alias dropped:`.** An alias
|
|
60
|
+
with a pattern character, whitespace or only a query in it is now listed there. Each one
|
|
61
|
+
was already dropped before, without a word: replace it with the plain old path, one per
|
|
62
|
+
alias, or remove it.
|
|
63
|
+
- **A file your hashed JavaScript or CSS imports is now served under its plain name**, with
|
|
64
|
+
ordinary cache headers rather than a year's. Imports that were broken now work; nothing
|
|
65
|
+
to change. Keep a module graph inside one assets folder, imported relatively.
|
|
66
|
+
- **A URL model with `fetch.headers` whose endpoint redirects to another origin now
|
|
67
|
+
fails.** Fetch the final URL directly, or remove the header.
|
|
68
|
+
|
|
6
69
|
## 2.14.0 — 2026-11-08
|
|
7
70
|
|
|
8
71
|
The purge keeps Tailwind's classes, and three changes a client site asked for. A site
|