kiss-ssg 2.6.0 → 2.7.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/audit.md +37 -0
- package/AIKB/build-report.md +5 -3
- package/AIKB/check.md +2 -2
- package/AIKB/config.md +6 -4
- package/AIKB/handlebars-helpers.md +1 -0
- package/AIKB/html-split.md +83 -0
- package/AIKB/init.md +43 -0
- package/AIKB/kiss-page.md +4 -2
- package/AIKB/kiss.md +10 -6
- package/AIKB/links.md +4 -2
- package/AIKB/llms.md +1 -1
- package/AIKB/partials.md +1 -1
- package/AIKB/pipeline.md +1 -1
- package/AIKB/testing.md +16 -2
- package/AIKB/upstream.md +112 -0
- package/AIKB/utils.md +1 -1
- package/AIKB/watcher.md +3 -1
- package/CHANGELOG.md +365 -0
- package/GUIDE.md +1197 -0
- package/README.md +56 -1066
- package/bin/kiss-ssg.js +128 -6
- package/examples/1-scan/router.js +7 -4
- package/examples/1-scan/src/assets/css/site.scss +5 -0
- package/examples/1-scan/src/pages/about/us.hbs +1 -1
- package/examples/1-scan/src/pages/index.hbs +1 -1
- package/examples/1-scan/src/partials/newsletter.md +1 -1
- package/examples/10-asset-pipeline/router.js +23 -13
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/10-asset-pipeline/src/assets/css/site.scss +5 -0
- package/examples/10-asset-pipeline/src/assets/img/icon.png +0 -0
- package/examples/10-asset-pipeline/src/assets/img/og-card.png +0 -0
- package/examples/10-asset-pipeline/src/layouts/layout.hbs +5 -0
- package/examples/10-asset-pipeline/src/pages/404.hbs +17 -0
- package/examples/10-asset-pipeline/src/partials/newsletter.md +1 -1
- package/examples/11-blog/AIKB/last-build.json +38 -15
- package/examples/11-blog/AIKB/site-map.json +14 -0
- package/examples/11-blog/AIKB/site-map.md +10 -9
- package/examples/11-blog/README.md +47 -32
- package/examples/11-blog/router.js +24 -12
- package/examples/11-blog/src/assets/css/site.scss +5 -0
- package/examples/11-blog/src/assets/img/icon.png +0 -0
- package/examples/11-blog/src/assets/img/og-card.png +0 -0
- package/examples/11-blog/src/layouts/layout.hbs +18 -11
- package/examples/11-blog/src/pages/404.hbs +23 -0
- package/examples/11-blog/src/pages/blog/listing.hbs +4 -5
- package/examples/11-blog/src/pages/blog/post.hbs +9 -10
- package/examples/11-blog/src/pages/blog/tag.hbs +1 -1
- package/examples/11-blog/src/pages/blog/tags.hbs +2 -2
- package/examples/11-blog/src/partials/post-card.hbs +1 -1
- package/examples/12-from-a-single-file/README.md +78 -0
- package/examples/12-from-a-single-file/config/site.js +37 -0
- package/examples/12-from-a-single-file/router.js +57 -0
- package/examples/12-from-a-single-file/source/assets/img/roastery.png +0 -0
- package/examples/12-from-a-single-file/source/original.html +177 -0
- package/examples/12-from-a-single-file/src/assets/css/site.css +10 -0
- package/examples/12-from-a-single-file/src/assets/img/icon.png +0 -0
- package/examples/12-from-a-single-file/src/assets/img/og-card.png +0 -0
- package/examples/12-from-a-single-file/src/assets/img/roastery.png +0 -0
- package/examples/12-from-a-single-file/src/assets/js/site.js +8 -0
- package/examples/12-from-a-single-file/src/layouts/layout.hbs +35 -0
- package/examples/12-from-a-single-file/src/models/index.json +105 -0
- package/examples/12-from-a-single-file/src/pages/404.hbs +19 -0
- package/examples/12-from-a-single-file/src/pages/index.hbs +15 -0
- package/examples/12-from-a-single-file/src/partials/sections/beans.hbs +20 -0
- package/examples/12-from-a-single-file/src/partials/sections/enquiry.hbs +25 -0
- package/examples/12-from-a-single-file/src/partials/sections/hero.hbs +24 -0
- package/examples/12-from-a-single-file/src/partials/sections/offers.hbs +21 -0
- package/examples/12-from-a-single-file/src/partials/sections/visit.hbs +25 -0
- package/examples/12-from-a-single-file/src/partials/site/footer.hbs +8 -0
- package/examples/12-from-a-single-file/src/partials/site/header.hbs +19 -0
- package/examples/12-from-a-single-file/tools/compare.mjs +116 -0
- package/examples/12-from-a-single-file/tools/convert.mjs +82 -0
- package/examples/2-page/router.js +11 -5
- package/examples/2-page/src/assets/css/site.scss +5 -0
- package/examples/2-page/src/pages/about.hbs +1 -1
- package/examples/2-page/src/pages/index.hbs +2 -2
- package/examples/2-page/src/partials/newsletter.md +1 -1
- package/examples/3-pages/router.js +14 -4
- package/examples/3-pages/src/assets/css/site.scss +5 -0
- package/examples/3-pages/src/pages/index.hbs +1 -1
- package/examples/3-pages/src/pages/roasts/index.hbs +1 -1
- package/examples/3-pages/src/pages/roasts/roast.hbs +3 -3
- package/examples/3-pages/src/partials/newsletter.md +1 -1
- package/examples/4-layouts-and-partials/router.js +19 -13
- package/examples/4-layouts-and-partials/src/assets/css/site.scss +5 -0
- package/examples/4-layouts-and-partials/src/layouts/layout.hbs +3 -1
- package/examples/4-layouts-and-partials/src/pages/stockists.hbs +1 -1
- package/examples/4-layouts-and-partials/src/partials/boards/espresso.hbs +1 -1
- package/examples/4-layouts-and-partials/src/partials/boards/filter.hbs +1 -1
- package/examples/4-layouts-and-partials/src/partials/sourcing.md +1 -1
- package/examples/5-helpers/router.js +10 -0
- package/examples/5-helpers/src/assets/css/site.scss +5 -0
- package/examples/5-helpers/src/pages/index.hbs +6 -6
- package/examples/5-helpers/src/partials/grinder-note.md +1 -1
- package/examples/5-helpers/styles/board.scss +1 -1
- package/examples/6-sitemap/router.js +11 -3
- package/examples/6-sitemap/src/assets/css/site.scss +5 -0
- package/examples/6-sitemap/src/assets/img/icon.png +0 -0
- package/examples/6-sitemap/src/assets/img/og-card.png +0 -0
- package/examples/6-sitemap/src/layouts/layout.hbs +5 -0
- package/examples/6-sitemap/src/models/404.json +6 -0
- package/examples/6-sitemap/src/models/index.json +1 -1
- package/examples/6-sitemap/src/pages/404.hbs +23 -0
- package/examples/6-sitemap/src/pages/index.hbs +4 -4
- package/examples/6-sitemap/src/partials/newsletter.md +1 -1
- package/examples/7-versioned-outputs/router.js +11 -1
- package/examples/7-versioned-outputs/src/assets/css/site.scss +5 -0
- package/examples/7-versioned-outputs/src/pages/index.hbs +1 -1
- package/examples/7-versioned-outputs/src/partials/newsletter.md +1 -1
- package/examples/8-data-fed-site/router.js +3 -5
- package/examples/8-data-fed-site/src/assets/css/site.scss +5 -0
- package/examples/9-migrated-from-v1/AIKB/last-build.json +36 -13
- package/examples/9-migrated-from-v1/AIKB/notes/controllers/shelf-item.md +5 -4
- package/examples/9-migrated-from-v1/AIKB/site-map.json +13 -2
- package/examples/9-migrated-from-v1/AIKB/site-map.md +8 -7
- package/examples/9-migrated-from-v1/router.js +51 -12
- package/examples/9-migrated-from-v1/src/assets/css/site.scss +5 -0
- package/examples/9-migrated-from-v1/src/assets/img/icon.png +0 -0
- package/examples/9-migrated-from-v1/src/assets/img/og-card.png +0 -0
- package/examples/9-migrated-from-v1/src/controllers/shelf-item.js +8 -1
- package/examples/9-migrated-from-v1/src/layouts/layout.hbs +8 -1
- package/examples/9-migrated-from-v1/src/pages/404.hbs +26 -0
- package/examples/9-migrated-from-v1/src/pages/await-complete.hbs +3 -3
- package/examples/9-migrated-from-v1/src/pages/duplicate-paths.hbs +3 -3
- package/examples/9-migrated-from-v1/src/pages/dynamic-partials.hbs +1 -1
- package/examples/9-migrated-from-v1/src/pages/folders.hbs +4 -4
- package/examples/9-migrated-from-v1/src/pages/handlebars-instance.hbs +3 -3
- package/examples/9-migrated-from-v1/src/pages/index.hbs +7 -7
- package/examples/9-migrated-from-v1/src/pages/pure-controllers.hbs +4 -4
- package/examples/9-migrated-from-v1/src/pages/smaller-changes.hbs +6 -6
- package/examples/README.md +21 -17
- package/lib/audit.js +429 -0
- package/lib/build-report.js +122 -0
- package/lib/check.js +19 -10
- package/lib/config.js +97 -3
- package/lib/html-split.js +1356 -0
- package/lib/init.js +743 -0
- package/lib/kiss-page.js +19 -3
- package/lib/kiss.js +301 -42
- package/lib/links.js +115 -7
- package/lib/llms.js +3 -10
- package/lib/watcher.js +35 -2
- package/llms.txt +29 -16
- package/package.json +8 -3
- package/starter/gitignore +2 -0
- package/starter/router.js +33 -0
- package/starter/src/layouts/layout.hbs +17 -0
- package/starter/src/pages/index.hbs +7 -0
- package/types/audit.d.ts +135 -0
- package/types/build-report.d.ts +84 -1
- package/types/check.d.ts +13 -8
- package/types/config.d.ts +55 -5
- package/types/html-split.d.ts +341 -0
- package/types/init.d.ts +150 -0
- package/types/kiss-page.d.ts +2 -1
- package/types/kiss.d.ts +44 -1
- package/types/links.d.ts +30 -4
- package/types/llms.d.ts +2 -1
- package/types/watcher.d.ts +5 -0
package/AIKB/audit.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# audit.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident. Two pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
|
|
6
|
+
|
|
7
|
+
The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
|
|
8
|
+
|
|
9
|
+
## Public interface
|
|
10
|
+
|
|
11
|
+
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
|
+
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`.
|
|
13
|
+
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
14
|
+
- The `CheckId`, `PageFacts`, `AuditFinding`, `AuditResult` and `AuditPage` typedefs.
|
|
15
|
+
|
|
16
|
+
## Depends on
|
|
17
|
+
|
|
18
|
+
`node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests) and `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does). No HTML parser, no logger, no config.
|
|
19
|
+
|
|
20
|
+
## Depended on by
|
|
21
|
+
|
|
22
|
+
`lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/kiss.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's last key, `audit` — `AIKB/build-report.md`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
|
|
23
|
+
|
|
24
|
+
## Non-obvious behavior
|
|
25
|
+
|
|
26
|
+
- **Extraction and decision are split across time, for the link scan's reason.** Under `kiss-ssg check` the staging folder is gone before anything could read a page back, so the facts are taken at write time from the same minified bytes as `KissPage.links`; `auditBuild` never reads HTML. What it does read from disk is the rest of the build folder (the walk), while `Kiss` still has it where it was written — `_writeRoot`, which is the staging folder under `'atomic'` and under check.
|
|
27
|
+
- **The pre-strip is wider than `links.js`'s, because a count check cannot afford a stray match.** Comments, `<script>`, `<style>`, `<template>` and `<svg>` elements are removed whole before matching. An extra reference costs the link scan one lookup; an extra `<h1>` inside a script string, or an icon's `<title>` read as the page title, is a finding invented out of nothing.
|
|
28
|
+
- **The extension decides first whether a file is a page.** An `ext: 'json'` page whose bytes happen to contain `<body` is data. A file extension is a statement about processing (`CLAUDE.md`), and the audit keeps that promise rather than sniffing.
|
|
29
|
+
- **`alt=""` is present, and so is a bare `alt`.** `imgMissingAlt` lists only images with no `alt` attribute at all; an empty alt is the correct markup for a decorative image.
|
|
30
|
+
- **A duplicate is compared among originals only.** A page whose `canonical` option names another URL (`canonicalElsewhere`) is declaring itself a copy, so it is left out of `title-duplicate` and `description-duplicate` — and every other check still runs on it.
|
|
31
|
+
- **`favicon-missing` and `not-found-missing` need at least one audited page.** A feed-only or JSON-only instance has no HTML to be missing them from. `not-found-missing` says so in its `detail` when `404/index.html` exists: `extensionLess` puts a `404` view there, and hosts serve `/404.html`.
|
|
32
|
+
- **`site-url-local` flags loopback, the reserved `.localhost`/`.local`/`.test`/`.invalid` TLDs and the Netlify/Vercel preview hostnames — not `.example`.** That is the documentation domain every shipped example uses, and flagging it would put a finding on every example build. A page whose own `siteUrl` differs from the instance's and is local is reported with that page.
|
|
33
|
+
- **`debug-dump` is decided from state, not disk.** `viewStats()` does not await its `debug.json` write, so a disk check would race it; `Kiss` sets `_debugWritten` when the write starts and passes it in.
|
|
34
|
+
- **Folder ownership.** `not-found-missing`, `stray-file` and `console-log` read the whole build folder. Under `cleanBuild: false` that folder holds whatever earlier builds and sibling instances left (example 7's archive index is the proof), so those three are **not run** and are listed in `skipped` — never silently absent. An ignored check is not also listed as skipped.
|
|
35
|
+
- **Walked paths are joined with `/`, never `path.join`, and `buildDir` is used as given.** `reportedPath` in `lib/build-report.js` maps a finding out of staging by matching the staging prefix followed by `/`; a Windows `\` from `path.join`, or a rewritten root, would leak `kiss-staging` into the report.
|
|
36
|
+
- **`console-log` skips files a site's author did not write**: `*.min.js` — including its `assets.hash` form, `*.min.<hex>.js`, since the rename happens before the audit walks the folder (a Codex review found the exemption lost there, 2026-09-28) — and anything under a `vendor/` or `node_modules/` segment. The count is of the literal `console.log(`, so it is a hint, not a parse.
|
|
37
|
+
- **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
|
package/AIKB/build-report.md
CHANGED
|
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed })` → `BuildReport`, whose keys are always in this order:
|
|
9
|
+
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, aikb, links, redirects, feed, robots, outputs, audit })` → `BuildReport`, whose keys are always in this order:
|
|
10
10
|
- `ok` — `failures.length === 0`.
|
|
11
11
|
- `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
|
|
12
12
|
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
@@ -18,10 +18,12 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
18
18
|
- `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
|
|
19
19
|
- `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
|
|
20
20
|
- `aikb` — `null` when the site has no knowledge base to report on, and otherwise `{ folder, written, notes: { missing, dead, stale, dangling }, subjects }` as `lib/aikb.js` evaluated it (`AIKB/aikb.md`). Appended after `llms`, for the same reason. It is passed through **unmapped**: the knowledge base is source-side and committed, so it is never staged and its paths are already the ones a person would type.
|
|
21
|
-
- `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, broken: [{ page, href }] }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder.
|
|
21
|
+
- `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }] }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder.
|
|
22
22
|
- `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved, rules, json, format, files }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`. The last four keys are the format block: `rules` is the resolved `[{ from, to }]` — the host-neutral fact, handed over so a site whose host kiss does not encode can write its own redirects from `kiss.report()` rather than by parsing a file kiss wrote — `json` is the `redirects.json` IR (mapped), `formats` is the list of names that ran, in order (`'custom'` per writer function, `[]` for IR-only), and `files` is every redirects file written (mapped). `file` is the **first** host file in `formats` order, and `null` when no host format ran at all — the honest answer rather than a claim about a redirect no host serves. With more than one format it names one of several, so `files` is the authoritative list; that is the one place `file` is a lead rather than the whole answer, and it is why `files` exists.
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
|
-
- `
|
|
24
|
+
- `audit` — the **last** key, after `outputs`: `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
|
+
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the four link/redirect findings: ` broken link: <page> -> <href>`, ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
26
|
+
- `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the two duplicate checks instead print one line per shared value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters, because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
|
|
25
27
|
- `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
|
|
26
28
|
|
|
27
29
|
## Depends on
|
package/AIKB/check.md
CHANGED
|
@@ -8,10 +8,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `HELP` — the usage text both `--help` and a usage error print; it documents both commands.
|
|
11
|
+
- `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line. `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
|
|
12
12
|
- `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
|
|
13
13
|
- `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
|
|
14
|
-
- `readReportsFile(text)` → the same `BuildReport[]`, from any of the
|
|
14
|
+
- `readReportsFile(text)` → the same `BuildReport[]`, from any of the four shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, the `{ reports, diff }` object it prints whenever it diffs, or a single report object. The wrapper was once read as a single report with no pages, so a check's own output handed back as `--against` diffed every page as added (found upgrading K9-Solutions, 2026-09-27). What `--against` reads.
|
|
15
15
|
- `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure.
|
|
16
16
|
- `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line.
|
|
17
17
|
- `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
|
package/AIKB/config.md
CHANGED
|
@@ -7,20 +7,21 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
11
|
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
12
|
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
13
13
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
|
14
14
|
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
|
|
15
|
+
- `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), and `ignore: []`, the check ids not to run.
|
|
15
16
|
- `DEFAULT_REDIRECTS` — frozen object, the redirect block: `format: null`, meaning no host encoding — the IR alone. It also takes a **list**, so `['netlify', 'firebase']` emits both host files. `REDIRECT_FORMATS` is the frozen list of accepted names (`'netlify'`, `'firebase'`, `'vercel'`, `'none'`); a function is also accepted, as a custom writer (`AIKB/redirects.md`).
|
|
16
17
|
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
|
|
17
|
-
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
18
|
+
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
18
19
|
- `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
|
|
19
20
|
- `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
|
|
20
21
|
|
|
21
22
|
## Depends on
|
|
22
23
|
|
|
23
|
-
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names).
|
|
24
|
+
`node:path` and `node:fs` (lexical and real folder locations for the read-only safety guard); `./redirects.js` (`HOST_FORMATS`, the accepted host format names); `./audit.js` (`CHECKS`, the accepted `audit.ignore` ids).
|
|
24
25
|
|
|
25
26
|
## Depended on by
|
|
26
27
|
|
|
@@ -49,7 +50,7 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
49
50
|
- **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
|
|
50
51
|
- **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
|
|
51
52
|
- **`assets.pipeline` is validated, like `cleanBuild`, because every step is a command this process will spawn.** It must be an array; each entry an object with a non-empty string `run`; `name`, `watch` and `cwd`, when present, strings. Anything else throws out of `resolveConfig` (so out of `new Kiss`) naming the index and the value — `{ pipeline: 'npx tailwindcss' }` (a bare string) and `{ pipeline: [{ watch: '…' }] }` (a watch with nothing to run first) are the two shapes that would otherwise be discovered as a `spawn` of `undefined` half way through a build. Being an array, it **replaces** the default rather than merging into it, which is what the one-level-deep merge means for an array value. What the steps then do is `lib/pipeline.js`'s business (`AIKB/pipeline.md`); this module only refuses a shape that cannot be run.
|
|
52
|
-
- **`links` is a block, not a boolean, and it is on by default.** `links: { check: true }` gates the broken-internal-link scan that a settled non-dev build runs over the pages it just wrote; `{ links: { check: false } }` switches it off and keeps whatever else the block grows. It is a one-level-merged block for the reason `assets` is: the first thing a real deploy asks for after the scan is an ignore list for paths the host generates and the build never sees (`/cdn-cgi/*`), and adding a key to a block is an addition where turning a boolean into a block is a breaking rename. On by default because the finding is advisory — it never touches `ok`, and `exitCodeFor` (`lib/check.js`) does not read it — so a site that has never heard of it loses nothing and gains the report key.
|
|
53
|
+
- **`links` is a block, not a boolean, and it is on by default.** `links: { check: true }` gates the broken-internal-link scan that a settled non-dev build runs over the pages it just wrote; `{ links: { check: false } }` switches it off and keeps whatever else the block grows. It is a one-level-merged block for the reason `assets` is: the first thing a real deploy asks for after the scan is an ignore list for paths the host generates and the build never sees (`/cdn-cgi/*`), and adding a key to a block is an addition where turning a boolean into a block is a breaking rename. That key arrived as `hostServed` (2026-10-01), first asked for by a Firebase site whose 2,756 findings were 2,650 references to a function rewrite, post-build bundles and a separately generated `llms.txt`. It is named for what it states — the host serves these — rather than what it suppresses, defaults to `[]` (kiss does not guess a host), and is validated in `resolveHostServed`: an array of strings, each starting with `/`, because a pattern is matched against the root-relative path a browser requests and one without the slash could never match anything. On by default because the finding is advisory — it never touches `ok`, and `exitCodeFor` (`lib/check.js`) does not read it — so a site that has never heard of it loses nothing and gains the report key.
|
|
53
54
|
- **`config.assets` is not `config.folders.assets`.** One is the policy for what the copy emits, the other is where it copies from. They are deliberately in different namespaces, but the names are close enough to be worth naming here.
|
|
54
55
|
|
|
55
56
|
## Types
|
|
@@ -65,6 +66,7 @@ The typedefs that describe the config to a consumer live at the top of this modu
|
|
|
65
66
|
- **`markdown` is a passthrough, not a schema.** The three keys in `DEFAULT_MARKDOWN` are the ones kiss has an opinion about, not the ones it accepts: `resolveConfig` merges the block one level deep and `lib/kiss.js` spreads the result straight into `new Remarkable(...)`, so `typographer`, `langPrefix` or any other Remarkable option reaches the renderer without kiss knowing the name. That is why the typedef is `KissMarkdown & Record<string, any>` rather than a closed shape — unlike `folders`, where a typo must be an error, an unknown key here is a legitimate renderer option.
|
|
66
67
|
- **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
|
|
67
68
|
- **`links.canonical` is off by default, and that is the whole design decision.** Turning it on changes the URL every bare `{{link}}` emits, which is a site's entire internal link graph and every `href` in its published output — a major-version change, not a default. It exists because the alternative discipline does not scale: a site that is not `extensionLess`, served by a host that redirects `/about.html` to `/about`, needs `canonical=true` on every single call or it ships a redirect hop per link, and one forgotten call is silent in dev and visible only on the deployed site. A real consuming site was writing `canonical=true` sixty-six times and carrying a `CLAUDE.md` paragraph explaining that it was mandatory. One key here replaces that. The `extensionLess: true` route solves the same problem and is better greenfield, but it changes every output path and therefore the relationship between the built files and the `aliases` a site carries for its old URLs — which is why this is the key and not a nudge towards that flag. Landing it needed no new config surface: the block was already shaped for exactly this by the comment above it.
|
|
69
|
+
- **`audit` takes a boolean shorthand, and an unknown check id throws.** `audit: false` is the one-word way to turn a whole advisory pass off, so it is accepted where `links` is not; anything else that is not a plain object is refused rather than read as truthy. The ignore list is validated against `lib/audit.js`'s `CHECKS` for the `redirects.format` reason: a misspelled id that ignored nothing would leave the finding on every build while the author believed it switched off. The ids are imported, never repeated here, so a check cannot be accepted by the config and unknown to the audit.
|
|
68
70
|
- **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `markdown`, `links`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
|
|
69
71
|
- **The index signature is on the config and never on `folders`.** An unknown config key is legitimate — anything a site passes through reaches its views as `{{config.x}}` — so it types as `any` rather than an error. A `folders` key is not: `{ folders: { biuld: './out' } }` is a typo that silently builds to the default folder, so `KissFoldersInput` stays closed and TypeScript reports it. `test/fixtures/consumer/bad.js` pins that difference.
|
|
70
72
|
- `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
|
|
@@ -33,6 +33,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
33
33
|
- **The positional argument is the page context itself, and the docs said otherwise for a long time.** A page's render context is exactly `title`, `path`, `slug`, `generate`, `view`, `config`, `model`, `pageURL` — there is no `page` key — so the documented `{{#isActive page …}}` handed the helper `undefined` and logged `isActive received no page context` once per call on every page. Write `this`, or a bare `..` inside an `{{#each}}` (which is what `examples/11-blog` does). **And the block always renders**: the match is `{{active}}` inside it, never an `{{else}}`, so the documented literal `class="active"` inside the block printed on every page of the site. Both were found by a clean-room conversion and both were invisible to `test/unit/handlebars-helpers.test.js`, which fabricates a `{ page: { pageURL } }` context — the shape the docs described rather than the shape a render has. `test/integration/is-active.test.js` renders through a real build for exactly that reason: a unit test that builds its own context cannot catch a doc that describes a different helper.
|
|
34
34
|
|
|
35
35
|
- Helpers register on whichever Handlebars environment (`hbs`) is passed in, not a global instance — each `Kiss` instance calls this against its own `this.handlebars`, so helpers never leak between instances.
|
|
36
|
+
- **KNOWN ISSUE, not yet addressed: `{{sass '<path>'}}` is an unconstrained build-time file read, and `lib/html-split.js` made it reachable from a document somebody else wrote.** The helper hands any path it is given to the sass compiler and inlines the result, so `{{sass '/etc/…'}}` in a template reads a file from outside the site and puts it in the built page — anything Dart Sass can parse. Until this branch that was unremarkable: a template is code the site's author wrote, and an author who can write a template can already read their own disk. `splitDocument` changed the premise by turning a **foreign** document into compiled `.hbs`, which is why that module escapes `{{` by default (`AIKB/html-split.md`) — measured closing this path along with a config dump and an attribute exfiltration. The escape is a guard at the one door the branch opened, not a fix: the helper is still unconstrained for any other caller, and a site that opts out with `escapeExpressions: false` on input it did not write re-opens it. The proper fix — confining file mode to the site's own folders, or requiring an explicit allow-list — is a change to the helper's contract and belongs on its own branch. Judged out of scope here by the operator on 2026-10-01, recorded rather than left implicit. Re-check by reading `sassHelper`'s file branch in `lib/handlebars-helpers.js`: while it still calls `path.isAbsolute(context)` and compiles the result with no containment check, this entry is current.
|
|
36
37
|
- The `sass` helper's file mode resolves a _relative_ path against `process.cwd()`, not against the view or the assets folder; an **absolute** path is passed to `sass.compile` untouched (`path.isAbsolute(context)` — an unconditional `path.join(process.cwd(), context)` mangled a path a controller had resolved into `<cwd>/abs/path/main.scss` and failed the build, review finding C7). Block mode compiles the block content directly with `sass.compileString` (no `process.cwd()` involved).
|
|
37
38
|
- `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js` — page identity, and nothing emitted is built from it) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
|
|
38
39
|
- The match is by **path segment**: active iff `pageKey === hrefKey` or, under `folderMatch=true`, `pageKey.startsWith(hrefKey + '/')`. It was a bare `pageURL.includes(href)`, which lit `/blog` on `my-blog-post.html` and — because `''.includes()` is always true — lit every item of a nav that omitted `href` (D-02). An empty `href` under `folderMatch` now matches the home page only, which falls out of the same rule.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# html-split.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Cuts a single-file HTML document into a kiss layout, a page view and one partial per region. Pure — no fs, no config, no logger, no knowledge of `Kiss`. The caller decides where the files go.
|
|
6
|
+
|
|
7
|
+
The mechanical half of converting a single-file site. The stylesheet is not this module's to transform: it stays whole, inline in the layout where the page had it, as every inline `<style>` and `<script>` does. (A stylesheet splitter, `lib/css-split.js`, shipped on this branch and was removed before 2.7.0 was published, 2026-10-02 — SCSS reads plain CSS differently in more places than a translation could keep up with, and the operator judged the split not worth it.) The division of labour is the point: parsing and cutting are things a person should never do by hand, and deciding that a region is called `hero` is not. Names are an input.
|
|
8
|
+
|
|
9
|
+
**What it deliberately does not produce is the model.** The precedent's `src/models/index.json` has `hero: { kicker, heading, intro, primary, secondary, image }` — semantic field names chosen by whoever did the conversion. A module that guessed at those would emit names no human would have picked, and nothing could check them. The structural cut is mechanical; lifting words into JSON is not.
|
|
10
|
+
|
|
11
|
+
## Public interface
|
|
12
|
+
|
|
13
|
+
- `parseHtml(src)` → `HtmlNode[]`, every source byte in exactly one node. Also re-exported from `lib/kiss.js`.
|
|
14
|
+
- `serializeNodes(nodes, depth)` → HTML, with a line break only where the source had one — it never adds whitespace.
|
|
15
|
+
- `attr(node, name)` → one attribute's value, as the tokenizer read it.
|
|
16
|
+
- `findTag(nodes, tag)` → the first matching descendant, breadth-first, or `null`. Also re-exported.
|
|
17
|
+
- `suggestName(node)` → a kebab-case name from `id`, else the first class, else the tag.
|
|
18
|
+
- `regionKind(node)` → `'chrome'` or `'section'`.
|
|
19
|
+
- `findRegions(nodes)` → `{ regions, wrappedInMain, container, main, body }`. Each region carries `kind`, `tag`, `name` and `node`. `container` and `main` are returned rather than left to be re-derived: `splitDocument` has to walk the same stream and unwrap the same element, and computing them twice is exactly how a `<script>` inside `<main>` got deleted.
|
|
20
|
+
- `bodyNodesOf(nodes)` → the nodes standing in for the body's children, falling through `<html>` when there is no `<body>` (it is optional and the tokenizer does not imply one).
|
|
21
|
+
- `nameRegions(regions, names)` → the regions with caller names applied and made unique.
|
|
22
|
+
- `findExpressions(nodes)` → every `{{…}}` the document carries, as short excerpts.
|
|
23
|
+
- `splitDocument(html, { names, layoutName, pageName, escapeExpressions })` → `{ layout, page, partials, assets, regions, expressions, warnings, stats }`. Also re-exported from `lib/kiss.js`. `warnings` is empty for every ordinary document; non-empty means the conversion did something it could not do losslessly and is saying so. `assets.styles` is `{ attrs, content }` per inline `<style>` and `assets.scripts` is `{ attrs, type, content }` per src-less inline script, both collected from the whole document. It throws only if its own output would carry a live expression, which is a bug in the module rather than a property of the input (see the backstop below).
|
|
24
|
+
- The `HtmlNode`, `HtmlRegion` and `HtmlSplitResult` typedefs. An `HtmlNode` element carries `open` and `close` — its tags as the source spelled them — and `attribs`.
|
|
25
|
+
|
|
26
|
+
## Depends on
|
|
27
|
+
|
|
28
|
+
`htmlparser2` (runtime dependency, added 2026-10-02) for tokenizing — see `AIKB/upstream.md`. Nothing else: no `node:` builtins, no other module of kiss's.
|
|
29
|
+
|
|
30
|
+
## Depended on by
|
|
31
|
+
|
|
32
|
+
`lib/kiss.js`, for the re-export only. Nothing in the build pipeline calls it; it is a tool a conversion reaches for, not a step a build runs.
|
|
33
|
+
|
|
34
|
+
## Non-obvious behavior
|
|
35
|
+
|
|
36
|
+
- **THE INVARIANT: assembling the layout, the partials and the page view through Handlebars reproduces the document that was split.** `test/unit/html-split.test.js` proves it on named shapes by rendering through `handlebars-layouts`, the way `lib/kiss.js` does, and **`test/unit/html-split.corpus.test.js` proves it on every pair of thirty-odd awkward atoms** — touching, a space apart and a line apart, at body level, in `<main>` and in a section — checking that whitespace-sensitive elements come back byte for byte and that nowhere else gains or loses whitespace.
|
|
37
|
+
|
|
38
|
+
It has **three stated exceptions**, and stating them is the point. A qualified invariant is worth more than an absolute one that is wrong, and the module reports two of the three at the point they occur:
|
|
39
|
+
1. `escapeExpressions: false` on a document containing `{{…}}`. Those braces are compiled rather than reproduced. Not reported — it is what the caller asked for.
|
|
40
|
+
2. `{{` with a backslash already in front of it. Handlebars has exactly one escape, `\{{`, and a preceding backslash eats it; measured across runs of 0–5, two or more emit N−1 and evaluate anyway. So the braces go out as `{{` — the page renders the same, the bytes do not, and inside `<script>` or `<style>` neither does the text. In `warnings`.
|
|
41
|
+
3. A node written **between two sections** — a `<nav>`, a `<script>`, a comment, stray text — is emitted after all of them. See below. In `warnings`.
|
|
42
|
+
|
|
43
|
+
- **The corpus is how this module is reviewed now, and why it exists.** Five review rounds (two security, three Codex) each found the next place the split changed a page, one adjacency at a time. The corpus stopped that: on its first run it found a bug in the very rewrite it was written to check (a stray close tag landing outside its parent). A finding becomes an atom in `ATOMS`, not a one-off test — a one-off test proves the case that prompted it and nothing beside it, which is the "fix verified only on its own input" failure the second security round named.
|
|
44
|
+
|
|
45
|
+
- **The tokenizer is htmlparser2, and the tree is made of source slices.** Until 2026-10-02 this was a hand-rolled scanner, and roughly half of what the reviews found was that scanner reading HTML differently from a browser: a `<` before a non-letter parsed as a tag, `/>` honoured on an HTML element (`<script src="a.js" />` then lost its real `</script>` and a browser read the rest of the page as script), an apostrophe in an unquoted value opening a quote, a raw-text element ended at `"</scriptfoo"` in a JS string, attributes read by a regex that matched `class='site-header'` written inside another attribute's value. htmlparser2 reads all of those as the spec does, and — unlike parse5 — never adds `<html>`, `<body>` or `<tbody>` the page did not write. Two choices make the tree lossless whatever it decides:
|
|
46
|
+
1. **Every tag is kept as the source text that spelled it** (`open`, `close`) and printed from there. Quoting, attribute order, case (`<linearGradient>` inside `<svg>`), a trailing `/` on `<meta … />`, an odd close tag — all the source's own bytes, never re-serialised. A void element, an element self-closed inside `<svg>`, or one closed by a later tag or the end of the document has an empty `close`, so no close tag is invented.
|
|
47
|
+
2. **Source the tokenizer reports nothing for is kept as text.** It drops malformed input silently — `</ x>`, a stray `</span>`, a trailing unfinished `<b` — so every gap between two events is filled from the source. Each byte belongs to exactly one node, by construction. The fill happens **before** a closing element is popped; after, it put a stray `</span>` outside the element it sat in.
|
|
48
|
+
|
|
49
|
+
The tokenizer runs with tag names lower-cased: with case kept, it stopped recognising `<IMG>` as void and nested a `<P>` inside it. The spelling survives in `open`/`close`.
|
|
50
|
+
|
|
51
|
+
- **The escape, and the backstop behind it.** `{{` is escaped as `\{{` by default, everywhere — text, tags (names included), comments, raw text. What this module emits is `.hbs`, which kiss compiles, so braces in a foreign document are code: unescaped, an Alpine or Vue page has every interpolation evaluated to empty, and the same mechanism was measured carrying a config dump, a secret in a tracking pixel's query string and a file read from outside the site into a published page. `escapeExpressions: false` opts out; `result.expressions` reports what was found either way. Every review round found another surface the escape had missed — the container tags, the doctype, a backslash, a tag name — so **`assertInert` checks the output itself**: with the module's own `{{> …}}` and `{{#block …}}` removed, any `{{` without exactly one backslash before it throws. The next missed surface is an error, not an injection; confirmed by running a copy of the module with the tag-name escape removed. Adjacent text is also merged as it is read, so the escape sees the braces the output will have.
|
|
52
|
+
|
|
53
|
+
- **THE PRINTER NEVER ADDS WHITESPACE — the operator's call (2026-10-01).** Whether whitespace between two elements renders depends on their CSS `display`, which the markup cannot say: an inline-block `<li>`, two `<input>`s, two `<svg>`s. Each earlier rule rested on a list of inline elements, and each review found a member missing. Now a line break is written only where the source had a gap containing one — reshaped to this module's indent, which renders the same — a gap of plain spaces is kept exactly, and tags the source wrote touching stay touching. **The cost is legibility on minified input**, which is not re-indented at all: the structure is still cut, but a partial from a minified page is dense, and formatting it is the caller's deliberate, separate step.
|
|
54
|
+
|
|
55
|
+
- **A brace or backslash touching a region could become template syntax**, and the writer guards it (Codex round 6). Source text ending in `{` before a call or the content block made `{{{`, a `}` after one made `}}}`, and a backslash run escaped the call. A backslash run gets one more backslash (Handlebars emits N−1 of N ≥ 2 and still evaluates, so the page gets the source's run back exactly); a brace becomes its character reference, `{` or `}`, and the escape's own `\{{` before a call becomes `{{`. Only body-level text can touch a region, and there a reference renders the same — the bytes differ by that character, the one place they do outside the stated exceptions. The corpus carries `{`, `}`, `{{`, `}}`, `\` and `\\` as atoms; `{{` beside a region is what showed the escape case.
|
|
56
|
+
|
|
57
|
+
- **The rule holds across region boundaries too — `LineWriter`.** A region becomes a partial call, and the layout's chrome calls, the content block and the page view's section calls all go through a `LineWriter` that carries the source's gaps between them: a gap between two sections goes into the page view between their calls; before the first and after the last it stays in the layout. Handlebars makes this harder than it looks, because a partial call **alone on a line** is "standalone" (`AIKB/upstream.md`): it indents every line of the partial by the call's indentation, and it swallows its own line's newline. So a call that starts a line is written at column 0, a line break after a call is written twice, and a source line break that arrives inside a text node rather than as a gap of its own gets the same treatment. Partial files carry no trailing newline, so a call adds exactly the region's bytes. Codex measured the boundary case before this existed: two `display:inline-block` sections came back with a newline between them.
|
|
58
|
+
|
|
59
|
+
- **Regions are an allowlist of default-block elements** (`BLOCK_REGION`: `section`, `div`, `header`, `ul`, `p`, …). Inline, replaced and custom elements and assets — `script`, `style`, `template`, `noscript` — are never regions and stay in the layout where they were written; a top-level `<style>` once became `sections/style.hbs`.
|
|
60
|
+
|
|
61
|
+
- **Inside an element whose whitespace renders, even a gap is content.** `<pre>`, `<listing>`, `<xmp>`, `<plaintext>`, SVG's `<text>`, `<tspan>` and `<textPath>`, and any element whose inline `style` sets `white-space` (or `white-space-collapse`) to a preserving value are emitted verbatim. **The stated limit:** a class whose stylesheet sets `white-space: pre` cannot be seen from the markup, so such an element is treated like any other. A `<main>` that preserves whitespace is not unwrapped (below); a `<body>` that does is reported in `warnings`, because the layout's own line breaks would render there.
|
|
62
|
+
|
|
63
|
+
- **One walk handles `<html>`, `<head>` and `<body>` at whatever level the document wrote them**, because all three are optional. Reaching for them by name and emitting a skeleton deleted everything they sat beside — an SRI `<script>` after `</body>`, a build marker between `</head>` and `<body>` — first inside `<html>`, then again in a document with no `<html>`.
|
|
64
|
+
|
|
65
|
+
**A document gets back exactly the structure it wrote** (2026-10-02): no doctype, `<html>`, `<head>` or `<body>` is added, and no close tag it omitted is written. Codex found the two cases with consequences — a missing doctype was supplied, switching the browser out of quirks mode, and whitespace before `<head>` in a document with no `<body>` was counted as body content, so the whole body came out before the head and moved the head's scripts after it. (Only what follows `<head>` stands in for a missing body now.) Adding an empty `<head>` or a `<body>` tag rendered the same, and went anyway, by the same rule. Only a bare fragment — none of the three — gets the skeleton. The corpus's **document shapes** run every atom through each shape HTML allows.
|
|
66
|
+
|
|
67
|
+
- **The layout is rebuilt by walking the body in document order**, not by emitting chrome-before, the content block, then chrome-after. That positional shape re-emitted a bootstrap `<script>` last, deleted a `<script integrity=…>` inside `<main>` outright, and ignored comments between sections. Body `<script>` tags therefore sit in the layout where they were written, ahead of the per-page `{{#block "scripts"}}`.
|
|
68
|
+
|
|
69
|
+
- **A node between two sections is MOVED, not dropped — a documented limitation.** Sections render at a single `{{#block "main"}}`, so `<section>A</section><nav>N</nav><section>B</section>` assembles as A, B, N. Keeping the order would mean the page view emitting the interleaved run, making a `<nav>` between sections page-scoped rather than shared furniture; the operator judged that a bigger change to every converted site's shape than the defect earns (2026-10-01). `warnings` names each node moved, scripts included — they are where a move changes behaviour.
|
|
70
|
+
|
|
71
|
+
- **`<main>` is unwrapped only when there is exactly one, every section is inside it, and its whitespace does not render.** A page wrapped in `<main>` would otherwise have one region; the layout keeps `<main>`'s own tags around the content block. With a section beside it, unwrapping moved sections across the boundary (Codex, 2026-10-01) — a different landmark and a different `main > section` — so then `<main>` stays whole as one region. `<main>` is never added to a page that did not have one.
|
|
72
|
+
|
|
73
|
+
- **Inline `<style>` and `<script>` are reported, not removed.** `assets.styles` and `assets.scripts` say what is worth lifting; the document keeps them. Removing them broke the invariant silently — the live round trip against `k9solutions.uk` lost both `<script src>` tags. Lifting means choosing a filename and rewriting the tag, which only the caller can do. Each comes with its `attrs` (and a script with its lower-cased `type`), because the attributes decide what the text is.
|
|
74
|
+
|
|
75
|
+
**And lifting is not part of the conversion — the operator's call (2026-10-02).** Two Codex rounds found ways a lift changed the page: a `<style media="print">` merged into one stylesheet applied on screen, merged styles landed before an external `<link>` that used to follow them, and `defer` made an inline script run after a later `<script src>` that depended on it. The family is larger than what was found — a relative `url()` resolves against `css/` once moved, a CSP nonce stops matching — so instead of a rule per hazard, the skill and example 12's conversion leave every inline `<style>` and `<script>` where the split left it, and lifting is a separate improvement with those hazards listed.
|
|
76
|
+
|
|
77
|
+
- **Two regions can propose the same suffixed name, and the uniquifier loops.** `hero`, `hero`, `hero-2` once gave two regions `hero-2`: one filename, two partials, one region's markup gone.
|
|
78
|
+
|
|
79
|
+
- **Chrome is detected by tag, by role _and_ by class token.** `header`, `nav`, `footer`, `aside`; `role="banner"`, `"navigation"`, `"contentinfo"`; or an id/class **token** matching `CHROME_HINTS` (`site-header`, `main-nav`, `topbar`, …). Whole tokens, never `\b`, which matches at a hyphen and made `hero-banner`, `card-header` and `nav-tabs` chrome. Bare `banner` is not a hint: it is as often a promotion as a masthead.
|
|
80
|
+
|
|
81
|
+
## Measured against the precedent
|
|
82
|
+
|
|
83
|
+
Run against the live `k9solutions.uk` home page — 21,213 bytes, minified, longest line 17,405 characters — the cut finds **11 regions**: `topbar`, `site-header` and `footer` as chrome, and `hero`, `trust-strip`, `help`, `services`, `location`, `approach`, `reviews`, `enquire` as sections. That is, allowing for the live page's class names differing from the partial filenames, the same structure the hand conversion chose. This measurement predates the htmlparser2 tokenizer and the never-add-whitespace printer; re-run it before quoting it again.
|
package/AIKB/init.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# init.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
The decision core of `kiss-ssg init`: given what is already in a folder, which files to create, which to merge into, which to leave alone, and whether to install the engine. `bin/kiss-ssg.js` is the thin wrapper around it — it reads the folder and the shipped `starter/`, writes exactly what the plan says, runs npm for an `install` action and prints one line per action; every decision it acts on comes from here.
|
|
6
|
+
|
|
7
|
+
It exists so that a new user reaches a site through a coding agent — Claude Code or Codex — in a few shell lines: `init` declares kiss-ssg's two plugins in the project's `.claude/settings.json` and `.codex/config.toml` (and prints the `--scope project` commands that install them for Claude Code, then the `codex plugin` commands that install the same plugins for Codex — once per user, because Codex enables plugins per project from a trusted project's `.codex/config.toml` but installs them per user, and `codex plugin add` also switches them on for every folder), points `CLAUDE.md` and `AGENTS.md` at `node_modules/kiss-ssg/llms.txt`, writes the skills and the Codex lines into `AGENTS.md`, and drops a starter the `kiss-site-new` skill grows. One marketplace serves both agents: Codex reads the existing `.claude-plugin/marketplace.json` unchanged (see `AIKB/upstream.md`, "Codex enables plugins per project, installs them per user").
|
|
8
|
+
|
|
9
|
+
## Public interface
|
|
10
|
+
|
|
11
|
+
- `INIT_HELP` — the usage text `init --help` and a usage error print.
|
|
12
|
+
- `parseInitArgs(argv)` → `{ install, help, error }`. `--no-install` clears `install`; `--help`/`-h` sets `help`; anything else — including a script name, which `check` would take — is a usage `error`.
|
|
13
|
+
- `packageName(folderName)` → a valid npm package name for a new `package.json`: lower case, runs of anything npm refuses collapsed to one hyphen, no leading `.`/`_`/`-`; `kiss-site` when nothing is left (`..`).
|
|
14
|
+
- `planInit(state)` → `InitAction[]`, in order: `package.json`, the starter files (or one `skip` for `router.js`), `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, then the install (or a `skip` for `node_modules/kiss-ssg` when `package.json` already asks for a version or under `--no-install`, or nothing at all when the engine is installed). An action is `{ kind: 'write', path, content, verb: 'create'|'merge'|'append', detail? }`, `{ kind: 'skip', path, reason }` or `{ kind: 'install', spec }`. `state` is `InitState`: the folder name, the running version, `install`, `hasEngine`/`hasRouter`/`hasSrc`, the current text (or `null`) of whether `package.json`'s `main` names a real file (`hasMain`), the current text (or `null`) of the six files it may touch (`package.json`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, `.gitignore`), and the starter as `{ relativePath: text }`.
|
|
15
|
+
- `describeAction(action)` → the printed line: ` create router.js`, ` merge package.json (added scripts.dev; kept scripts.build)`, ` skip CLAUDE.md — already points at llms.txt`, ` install kiss-ssg@2.7.0`.
|
|
16
|
+
- `nextSteps()` → the closing text: the Claude Code block (`CLAUDE_STEPS`, then "Run \`claude\` in this folder."), the Codex block (`CODEX_STEPS`, said to install once per user, then "Run \`codex\` in this folder."), then paste `FIRST_PROMPT` to start a site or `IMPORT_PROMPT` to turn one the person already has — most often a link to a Claude or ChatGPT site — into a kiss one. Only the first was offered until 2026-10-02, so the person arriving with a site was told how to describe it. The commands come first because Claude Code does not offer to install plugins a project's `.claude/settings.json` declares (observed 2026-09-26 on a clean profile) — the declaration records which skills the site uses; it installs nothing on its own.
|
|
17
|
+
- `CLAUDE_STEPS` / `CODEX_STEPS` (`string[]`) — each agent's three plugin lines and nothing else (no `mkdir`, no `init`, no bare `claude`/`codex`): `claude plugin marketplace add cprobert/kiss-ssg --scope project` plus a `claude plugin install <plugin> --scope project` per plugin, and `codex plugin marketplace add cprobert/kiss-ssg` plus a `codex plugin add <plugin>` per plugin. Both are derived from `MARKETPLACE_REPO` and `PLUGINS`. They are the one source the README's two Quick start blocks, the docs homepage's two boxes and `llms.txt` are tested against.
|
|
18
|
+
- Constants: `MARKETPLACE`, `MARKETPLACE_REPO`, `PLUGINS`, `LLMS_IMPORT`, `STARTER_MARKER`, `STARTER_RENAMES`, `FIRST_PROMPT`, `IMPORT_PROMPT`.
|
|
19
|
+
|
|
20
|
+
## Depends on
|
|
21
|
+
|
|
22
|
+
Nothing. It is pure: text in, actions out. The bin supplies the folder's state and the `starter/` folder's contents.
|
|
23
|
+
|
|
24
|
+
## Depended on by
|
|
25
|
+
|
|
26
|
+
- `bin/kiss-ssg.js` — dispatches `init` before `check`'s `parseArgs`, which never sees it.
|
|
27
|
+
- `test/unit/init.test.js` (the plan, and the shipped `starter/` on disk) and `test/integration/init.test.js` (the bin in an empty folder, then `check` over what it wrote, then a second run that must change nothing).
|
|
28
|
+
|
|
29
|
+
## Non-obvious behavior
|
|
30
|
+
|
|
31
|
+
- **The Codex review of this branch found the promise broken in five places, all fixed test-first:** an existing `.gitignore` was replaced by the starter's (now `planGitignore` appends only the missing lines); a site's own `kiss-ssg` range was replaced by the running version (now `declaredRange` skips the install and says to run `npm install`); a `scripts` that was not an object was spread into character keys (now skipped); a `null` marketplace entry was overwritten (now `in`); and `"type": "module"` plus `router.js` scripts were added to sites `init` did not write (now only with the starter, and never without a `router.js`).
|
|
32
|
+
- **Nothing is ever overwritten.** `package.json` and `.claude/settings.json` are merged key by key — an existing key is kept and named in the action's `detail` ("kept scripts.build") — and a file that is not a JSON object is skipped with a reason rather than replaced. `CLAUDE.md`/`AGENTS.md` are appended to only when they lack something, on a line of their own even when the file has no trailing newline, in the file's own line ending.
|
|
33
|
+
- **`AGENTS.md` has two parts, checked one at a time** (`planPointer`'s `PointerPart[]`). The pointer is marked by `node_modules/kiss-ssg/llms.txt`; the skills-and-Codex section by its first line, `codex plugin marketplace add cprobert/kiss-ssg` (`CODEX_STEPS[0]`). A fresh file gets both. An existing one gets each part it lacks appended, in order, and is skipped when it has both. This matters because every `AGENTS.md` an `init` wrote before 2.8.0 holds the pointer and nothing else, and the old rule — skip anything that mentions `llms.txt` — would never have added the Codex section to it; the operator's decision (2026-10-02) was to append if missing. Existing text is never rewritten, so a site that edited the section keeps its edit as long as the marker line survives. `CLAUDE.md` uses the same function with its one part.
|
|
34
|
+
- **The module type and `main` are the starter's, not the site's.** `"type": "module"` and `"main": "router.js"` are added only when this run writes the starter; a site that already has a `router.js` gets the scripts and nothing else, because switching its module type would change how Node reads a file `init` did not write. When it does write the starter, `npm init -y`'s boilerplate is replaced, with a note saying so: `"type": "commonjs"` becomes `"module"` (the starter's `router.js` uses `import`), and a `main` naming a file that does not exist (`"index.js"`) becomes `"router.js"`. Both were found by clean rooms that ran `npm init -y` first (2026-10-02). Starting implies no `main` names a real file, since `existingSite` would otherwise have found a project, so neither replacement can override a choice the site made.
|
|
35
|
+
- **A kept `"type"` other than `"module"` is reported, not fixed — except `"commonjs"` when the starter is being written.** The starter's `router.js` uses `import`, so the detail says to set `"type": "module"` or rename it `router.mjs`; changing a site's module type behind its back could break everything else it runs. The exception (2026-10-02): npm 11's `npm init -y` writes `"type": "commonjs"` by default, and the starter is only written into a folder with no code, so that value is npm's boilerplate rather than the site's choice. Keeping it made the starter crash on its first `import` for anyone who ran `npm init -y` first — the clean-room import run hit exactly that. It is changed to `"module"`, and the detail says so.
|
|
36
|
+
- **`.codex/config.toml` is appended to by table, with no TOML parser** (`planCodexConfig`). It is the counterpart of `.claude/settings.json`, in the form Codex writes itself: `[marketplaces.kiss-ssg]` (`source_type = "git"`, `source = "https://github.com/cprobert/kiss-ssg.git"`) and one `[plugins."<plugin>"]` table with `enabled = true` per plugin. An absent file is written whole; an existing one gains each table it does not define, in its own line ending, and is skipped when it has all three. A table already there is the site's whatever it says, so `enabled = false` stays. TOML forbids defining a table twice, so a duplicate makes the whole file unloadable: headers are compared by meaning, not spelling (`tomlHeaderKey`: whitespace, quote style and a trailing comment do not matter, so `[ plugins . 'kiss-ssg@kiss-ssg' ] # off` is recognised), and the rest is an **allow-list**: a line that mentions `plugins` or `marketplaces` is safe only as a plain header under it (`[plugins]`, `[plugins."…"]`, `[marketplaces.…]`), and a file with any other such line (an inline `plugins = {}`, an array of tables `[[plugins]]`, a dotted or quoted key, a `[marketplaces]` table, or an inline entry naming a kiss plugin) is **left exactly as it is**. It was a deny-list until two review rounds in a row each found a shape it missed (an inline parent table, then `[[plugins]]`): every such shape stops `plugins` being a plain table, and a plain table is the only thing an appended header can extend. The cost is conservative: a string value that merely contains the word (a path with `\plugins\` in it) also leaves the file alone, which costs a note, never a broken file, skipped with "add the rest by hand", because appending beside a shape the check cannot read could define it twice. The first version compared exact trimmed lines; Codex's review (2026-10-02) found that a valid header with a trailing comment slipped past it, and `init` appended a duplicate. The file only _enables_: Codex reads it once the folder is trusted, and the plugins must still be installed per user with `CODEX_STEPS` (measured 2026-10-02, `AIKB/upstream.md`).
|
|
37
|
+
- **A plugin the user switched off stays off.** The merge tests `plugin in enabledPlugins`, not truthiness, so `"kiss-memory@kiss-ssg": false` survives.
|
|
38
|
+
- **The starter is all or nothing, and kiss's shape is not the only sign of a site.** It is written only when `existingSite` finds nothing: no `router.js`, no `src/`, no `package.json` `main` naming a real file, and no `build` script other than ours — the final review found a CommonJS app with `main: index.js` being given `"type": "module"`. `npm init -y` names an `index.js` it never writes, so a folder it has just made still gets the starter. Otherwise one `skip` line names what it found. That is what makes `init` safe to run in an existing site.
|
|
39
|
+
- **`starter/gitignore` has no dot on purpose.** npm never packs a file named `.gitignore`, so the starter ships `gitignore` and `STARTER_RENAMES` writes it as `.gitignore`.
|
|
40
|
+
- **`STARTER_MARKER` is read by a skill.** The starter's `router.js` begins with `// Started by \`npx kiss-ssg init\``; `kiss-site-new`treats a`router.js`carrying it as a starter to grow, and one without it as somebody's site to build around. Changing the string breaks that skill's reading, and`test/unit/skill-coverage.test.js` pins it.
|
|
41
|
+
- **The bin writes defensively.** A `create` uses the `wx` flag, so it can never replace a file (or a dangling link) that appeared since the folder was read; a `merge` or `append` is written beside the file and renamed over it, so it lands whole or not at all. The first failed write stops the run, and a line is printed only after its write succeeded. `hasEngine` means `node_modules/kiss-ssg/package.json` exists, so an empty folder by that name is not taken for an installed engine.
|
|
42
|
+
- **The install is last and pinned to the running version.** Every file is written before npm runs, so a failed network still leaves a wired folder, and `kiss-ssg@<version>` means the engine installed is the one whose `init` just ran. It is skipped when `package.json` already asks for `kiss-ssg` in any dependency field — `npm install kiss-ssg@<version>` would replace the range the site chose. The bin spawns npm through a shell on Windows, where `npm` is `npm.cmd`, as one command string: a shell given an argument array prints Node's DEP0190 warning. (Codex also suggested a folder-local `npm.cmd` would run instead of npm; re-derived on Windows 11 with Node 24 it did not — the real npm ran — so no guard was added.)
|
|
43
|
+
- **Idempotent by construction.** Every action a first run writes produces a `skip` on the second, which `test/unit/init.test.js` asserts on the plan and the integration test asserts on the bytes.
|
package/AIKB/kiss-page.md
CHANGED
|
@@ -18,13 +18,14 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
18
18
|
- `prepare()` — merges default options (`title`, `path`, `slug`, `generate: true`) under any already set on `this.options`; returns `this`.
|
|
19
19
|
- `hash` — sha1 (hex) of the bytes `generate()` last wrote, or `null` if it has not written any. `BuildPage.hash` is this value (`AIKB/build-report.md`).
|
|
20
20
|
- `links` — every reference the bytes `generate()` last wrote ask a browser to fetch (`href`/`src`/`srcset`/`action`, raw, deduplicated and sorted), or `null` if it has not written any. `extractReferences` (`AIKB/links.md`) produces it; `Kiss._checkLinks()` resolves it.
|
|
21
|
+
- `audit` — the launch-readiness facts of the bytes `generate()` last wrote (`PageFacts`: title, description, `og:image`, canonical, favicon link, images with no alt, the heading outline), or `null` if it has not written any. `extractPageFacts(minifiedHtml, resolvedTarget)` (`AIKB/audit.md`) produces it beside `links`; `Kiss._runAudit()` decides the findings.
|
|
21
22
|
- `async generate()` — compiles the template, renders with `this.options`, injects the livereload script in dev, minifies in production (dev skips minification entirely), asserts the resolved `buildTo` is inside the resolved `buildDir`, writes the file (records its `hash`) and, in dev, the debug `.json`; returns `this.buildTo`. Logs _and rethrows_ render/minify/escape/output-write errors.
|
|
22
23
|
- `loadMinifier()` — the one shared `Promise` for the `html-minifier-terser` import, resolving to its `minify`; every render awaits it.
|
|
23
24
|
- `preloadMinifier()` — starts that load without awaiting it, swallowing a failure that `loadMinifier()` will re-surface at the render that needs it. Called by the `Kiss` constructor for non-dev builds.
|
|
24
25
|
|
|
25
26
|
## Depends on
|
|
26
27
|
|
|
27
|
-
`fs-extra`, `node:path`, `node:crypto` (`createHash`, for the output `hash`), `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./links.js` (`extractReferences`, run over the written HTML — `AIKB/links.md`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`). The dependency graph arrives as `deps.graph` (`AIKB/dependency-graph.md`) and is referenced only by a type-only `import('./dependency-graph.js')` in the JSDoc — there is no runtime import of that module here.
|
|
28
|
+
`fs-extra`, `node:path`, `node:crypto` (`createHash`, for the output `hash`), `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./links.js` (`extractReferences`, run over the written HTML — `AIKB/links.md`), `./audit.js` (`extractPageFacts`, over the same bytes — `AIKB/audit.md`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`). The dependency graph arrives as `deps.graph` (`AIKB/dependency-graph.md`) and is referenced only by a type-only `import('./dependency-graph.js')` in the JSDoc — there is no runtime import of that module here.
|
|
28
29
|
|
|
29
30
|
## Depended on by
|
|
30
31
|
|
|
@@ -36,12 +37,13 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
36
37
|
|
|
37
38
|
- **The render carries the page's identity in Handlebars' data frame**: `template(this.options, { data: { kissPage: this.buildTo } })`. Handlebars copies the frame into every nested partial invocation and handlebars-layouts passes `{ data }` through `extend`/`embed`, so a recording partial (`AIKB/partials.md`) reads `options.data.kissPage` and attributes itself to the right page whatever the render order — there is no "current page" variable and no assumption that pages render one at a time. Before rendering, the page calls `graph.clearPage(buildTo)` so its edge set is exactly what this render reached. `graph` is optional (`deps.graph`); without it the render is byte-identical to before — the extra `data` only adds a key to a frame Handlebars creates anyway.
|
|
38
39
|
- In dev mode the `.json` sibling gains `partials`: the names this page recorded, from `graph.usesOf(buildTo)` — the per-page view of `dependency-graph.json`.
|
|
39
|
-
-
|
|
40
|
+
- **The default title is the page's own slug**, title-cased with hyphens as spaces (`about-us` → `About Us`, the rule `lib/llms.js` uses for its own fallback), computed in `prepare()` — after `_preparePage` has set the slug, and after any controller has returned one. Until the launch-readiness release it was computed in the constructor from the _default_ slug `'index'`, so every page without an explicit title was `'Index'`; this doc called that "a preserved v1 quirk, not a bug to fix". The audit's `title-duplicate` then reported it on every site that relied on the default (example 3's six roasts, and the starter the moment a second page is added), and the operator chose to fix it (2026-09-27). `kiss-site-migrate` carries the upgrade note.
|
|
40
41
|
- `buildTo` is the value `Kiss._preparePage` uses to detect duplicate pages (same output path) — a duplicate is a build failure there, not a skip.
|
|
41
42
|
- **Every part of `buildTo` is sanitised, `ext` included.** `ext` used to strip only the _first_ `.` (`replace('.', '')`) and never slugify, so unlike `path` (`sanitizePath`) and `slug` (`toSlug`) it carried `/` and `..` straight into the output path: `ext: './../../../escaped/pwned.html'` wrote three levels above the build folder, and the value is reachable from model data through the documented "controller derives the page's options" pattern (review finding C1). It now goes through `toSlug(extension.replace(/^\./, ''))` — `'.xml'` → `'xml'`, `'../x'` → `'x'`.
|
|
42
43
|
- **`generate()` refuses to write outside the build folder**: immediately before `fs.outputFile` it compares `path.resolve(buildTo)` against `path.resolve(buildDir)` and throws `Refusing to write outside the build folder: <buildTo>` otherwise. Belt-and-braces behind the sanitised setters — nothing reachable through them should trip it — and it throws inside `generate()`'s try, so it is logged and collected like any other render failure rather than being a silent write.
|
|
43
44
|
- **`hash` names the output, not the inputs.** It is the sha1 of `minifiedHtml` — the exact string handed to `fs.outputFile` — taken immediately _after_ the write resolves, so it can only exist for bytes that reached the disk. Hashing the render inputs (the template text plus the options) would have been cheaper to reason about and wrong in both directions: a template refactor that produces identical HTML would read as a change, and a minifier upgrade that changes every byte would read as none. It is set to `null` at the top of `generate()`, before anything is rendered, rather than in the catch — every path out of that `try` rethrows, so one clear-before is the only place that can promise "no hash unless _these_ bytes are on disk". A page with `generate: false` never enters the branch, so its hash stays `null` from construction. The consumer is `diffReports` in `lib/check.js`, which is why `null` has to mean "nothing to compare" and not "empty output": it counts a `null` on either side as changed.
|
|
44
45
|
- **`links` is extracted here rather than by reading the page back, and it is `null` exactly when `hash` is.** Both are set immediately after the write resolves and both are cleared at the top of `generate()`, so neither can describe bytes that never reached the disk. The extraction happens at write time because the consumer cannot do it later: under `kiss-ssg check` (and after a failed atomic build) the whole staging folder is deleted before the report is assembled, so a scan that read the HTML back would have found nothing — see `AIKB/links.md` and the `_checkLinks()` bullet in `AIKB/kiss.md`. What is kept is the reference strings, a few hundred bytes a page, never the document: retaining the HTML of a 500-page site to resolve links afterwards would be ~15 MB. The injected dev livereload `<script>` contributes nothing to the list: it carries no `src` attribute, building its URL in code instead, and `extractReferences` drops inline script bodies anyway — and a dev build is never scanned in the first place.
|
|
46
|
+
- **`audit` follows `links` in every respect**: set on the line after it, from the same minified bytes, cleared beside it at the top of `generate()`, so it too is `null` exactly when `hash` is. It is handed the resolved output path as well as the bytes, because the extension decides first whether the file is a page at all — an `ext: 'json'` page is data whatever it contains. It is extracted in dev too, like `links`, although no dev build is audited: the cost is a few regex passes, and a field whose presence depended on the mode would be one more thing to reason about.
|
|
45
47
|
- `fs.outputFile`/`fs.outputJson` writes are `await`ed — `generate()` does not resolve until the file (and, in dev, the debug JSON) is actually on disk.
|
|
46
48
|
- **The injected livereload `<script>` resolves its host at runtime, in the browser.** It is a short inline script that builds `http://<location.hostname || 'localhost'>:<livereloadPort>/livereload.js?snipver=1` and appends a `<script>` element via `document.createElement` + `document.body.appendChild`. Two reasons for the shape: the URL used to hardcode `localhost`, so with `devHost: '0.0.0.0'` a page opened from another device asked _that device's_ localhost and live reload silently did nothing; and the element is appended rather than written with `document.write` (livereload's own README snippet), which browsers warn about for injected scripts. `?snipver=1` is livereload's protocol marker and must stay. The script survives verbatim because dev mode skips `minifyJS`.
|
|
47
49
|
- The injected livereload `<script>` points at `livereloadPort`, a plain field `Kiss._preparePage` sets from `config.livereloadPort` (the same value `startDevServer` binds). It is a field rather than a render option deliberately: page options reach the template context and the dev-mode `.json` sibling, and the port is engine plumbing, not page data. The port used to be hardcoded, so a second site's pages polled the first site's livereload server (review finding D-01).
|
package/AIKB/kiss.md
CHANGED
|
@@ -13,7 +13,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
13
13
|
- `.page(options)` — attaches a per-page copy of the config, resolves the page's model, runs its controller, prepares a `KissPage`, gives it an identity (`options.id`, or the default derived in `_preparePage` — see the identity bullets below), pushes it onto `_stack`; returns `this`.
|
|
14
14
|
- `.pages(options)` — same as `.page()` with `options.dynamic = true` (one page per model in an array); returns `this`. A bad item fails only its own page and is reported on its own — see the `_prepareMultiplePages` bullet below.
|
|
15
15
|
- `.scan()` — globs every `.hbs` under `config.folders.pages` (via `utils.globFiles`, so the list is sorted and posix-normalised) and calls `.page()` for any view not already in `_registrations`; sets `_scanRequested` so watch rebuilds scan again; returns `this`.
|
|
16
|
-
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder); returns `this`.
|
|
16
|
+
- `.viewStats()` — logs `{ promise, stack }` counts (and, if `verbose`, writes `debug.json` to the build folder and sets `_debugWritten`, which the audit reports as `debug-dump`); returns `this`.
|
|
17
17
|
- `.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`.
|
|
18
18
|
- `.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.
|
|
19
19
|
- `.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`.
|
|
@@ -28,7 +28,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
28
28
|
|
|
29
29
|
## Depends on
|
|
30
30
|
|
|
31
|
-
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./aikb.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./assets.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./links.js`, `./redirects.js`, `./check.js` (`readReportsFile`, for the redirects baseline), `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
|
|
31
|
+
`fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./aikb.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./assets.js`, `./asset-manifest.js`, `./output-registry.js`, `./pipeline.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./llms.js`, `./feed.js`, `./links.js`, `./audit.js`, `./redirects.js`, `./check.js` (`readReportsFile`, for the redirects baseline), `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
|
|
32
32
|
|
|
33
33
|
## Depended on by
|
|
34
34
|
|
|
@@ -38,7 +38,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
38
38
|
|
|
39
39
|
- Non-dev `watch()` with `cleanBuild: 'atomic'` is unsupported: a failed build latches disposal, and later replays can write staging without promoting it. Use a separate one-shot atomic build for publication. This existing limitation is documented, not repaired by the source-protection branch.
|
|
40
40
|
|
|
41
|
-
- Replay invalidates the settled link-check cache after orphan removal and asset restoration: removed pages must no longer resolve, and restored copies can bring in newly added files. The rebuild queue's preliminary scan is quiet; the final scan logs its verdict and refreshes the report. Dev mode and `links.check: false` still skip link checking.
|
|
41
|
+
- Replay invalidates the settled link-check cache after orphan removal and asset restoration: removed pages must no longer resolve, and restored copies can bring in newly added files. The rebuild queue's preliminary scan is quiet; the final scan logs its verdict and refreshes the report. Dev mode and `links.check: false` still skip link checking. The launch-readiness audit follows the scan here exactly: `_audit` is reset and `_runAudit()` re-run beside it, because the orphan sweep and the restoration change what the folder walk finds.
|
|
42
42
|
|
|
43
43
|
- Asset watcher events join `_pendingAssets` in the rebuild queue. Each batch waits for initial rendering, copies/reconciles assets, then performs its replay or scoped render. With hashing enabled, all pages re-render only when the manifest's CSS/JS URL revision changes; image/font edits and identical emitted URLs keep the fast path. One changed Sass output gets a stylesheet refresh; multiple outputs get a full refresh. Reload follows completion, and `close()` drains the active batch. The shared output registry protects generated files at write and cleanup time, including custom auxiliary filenames, releases removed page ownership, and follows atomic promotion.
|
|
44
44
|
|
|
@@ -49,7 +49,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
49
49
|
- **`config.assets.pipeline` is wired at three points, and all three are one line each** (`AIKB/pipeline.md` has the runner). The constructor creates the pipeline and calls `_queuePipeline()` immediately before the construction-time `copyAssets()`; `_replay()` re-runs it (and re-queues the copy) when — and only when — the site configured a step, so a whole-site watch rebuild re-runs the tools an edited model or controller may have changed the input to; `close()` ends the watch processes. **Ordering needs no mechanism of its own**: the steps are chained onto `_assetQueue`, the promise every copy already chains onto, so "steps first, copy second" is the queue doing what it was built to do. The run is deliberately **not** pushed onto `_promises`: those entries are the `data` array `generate()`/`complete()` hand back, whose first entry is documented as the construction-time asset copy, so a site adding a pipeline would have silently renumbered its own `data`. It goes on `_generating`, which `_drain()` waits for just the same. A step that exits non-zero (or cannot be spawned) is recorded as `{ view: '<pipeline: name>', buildTo: null, error }` — the same shape as a controller or dev-server failure, since there is no output path to name it by — so `complete()` rejects while the copy and every page still run and the report still names everything else that was wrong. Scoped re-renders (a page view, a partial, a layout) never re-run the steps: a tool that must see those edits is what a step's `watch` command is for.
|
|
50
50
|
- **A pipeline step runs before `cleanBuild` could matter, and writes wherever the site tells it to.** `KISS_BUILD`, `KISS_ASSETS` and `KISS_DEV` are handed to each command (read per run, so `KISS_BUILD` follows a staged build into its staging folder and back out again after `_promote()`), but nothing stops a step writing anywhere else. The idiom the feature is built for is a tool that writes into the **source** tree (`src/assets/css/site.css`), which is why `kiss-ssg check` can run the pipeline exactly like a build: the step's output is a source file the check has nothing to discard.
|
|
51
51
|
- **`cleanBuild: 'atomic'` builds into a staging sibling and swaps it in on success.** `_setupFolders()` keeps the folder the consumer asked for in `_buildTarget` and sets `_stagingDir` to `<build>.kiss-staging-<pid>-<random>`, which `_writeRoot` and `_writeConfig` follow, so every write path above lands there with no further plumbing; the real folder is not touched at all until the swap, and `config.folders.build` is never repointed (see the write-root bullet below for why that used to be the mechanism and no longer is). `complete()` is the decision point: a settle with no failures calls `_promote()`, and a settle with failures calls `_discardStaging()`, which deletes the staging folder and leaves the previous output exactly as it was. Promotion happens **once**: `_buildSettled` latches, so the nested `complete()` pattern's second call is a no-op and, crucially, a second `complete()` after a failed build (which resolves rather than re-rejecting, see the report-once bullet) can never promote a build that failed. The failure message names the target path, not the staging path (`_reportedPath`), because the staging folder is an implementation detail the operator should not have to decode.
|
|
52
|
-
- **Promotion is rename-aside, not remove-then-rename**, so the previous output is never absent for the length of a recursive delete. `_promote()` renames the existing target to `<build>.kiss-old-<same suffix>` (both siblings of one build share the `<pid>-<random>` suffix, so a leftover pair is recognisably one crashed run), renames staging into the target, and only then removes the old folder. The previous output is therefore absent only _between two renames_ — metadata operations on one filesystem — and
|
|
52
|
+
- **Promotion is rename-aside, not remove-then-rename**, so the previous output is never absent for the length of a recursive delete. `_promote()` renames the existing target to `<build>.kiss-old-<same suffix>` (both siblings of one build share the `<pid>-<random>` suffix, so a leftover pair is recognisably one crashed run), renames staging into the target, and only then removes the old folder. The previous output is therefore absent only _between two renames_ — metadata operations on one filesystem. **Both renames (and the restore) go through `_renameForPromote()`, which calls `node:fs/promises` directly, not fs-extra**: fs-extra's `rename` is graceful-fs's, which on Windows retries `EPERM`/`EACCES`/`EBUSY` for up to 60 s, so a preview server or editor holding a file open in the build folder made the build sit silent for a minute and then fail with a bare `EPERM` (measured 60136 ms, 2026-09-28; `AIKB/upstream.md`). It retries those codes itself for about 1.5 s (`PROMOTE_RETRY_MS`) — long enough for an antivirus or indexer blip — and then throws an error naming the folder and the likely holder ("open in another program — a preview server such as VS Code's Live Server, an editor, or antivirus"). If the second rename fails, the cross-device `fs.move` fallback is tried **only for `EXDEV`** — a lock is not a filesystem boundary, and a copy under one fails the same way after far more work, so any other error restores the previous output and rethrows; and if the fallback fails too the fragment it wrote is removed and the old folder is renamed back before the error is rethrown, so a failed promotion ends with the previous output byte-identical. The one case that cannot be repaired automatically is a restore that itself throws: the old folder then holds the only copy, so it is named in an `error` line and `close()` deliberately leaves it alone while the target is missing (it removes `_oldDir` only when the build folder is back in place). The swap itself lives in `_swapIn()`, and `_promote()` wraps it: **any** throw from it — the rename-aside of the published folder (what a real lock fails first), the swap, or the fallback — removes the staging folder at once through `_abandonStaging()`, the same removal a failed build's `_discardStaging()` uses, before rethrowing. It used to be left for `close()`, which a one-shot build script never calls, so every locked build left a full copy of the site beside the published one until the next build's sweep. `_stagingDir` is still cleared only after a successful swap, so `close()` removes anything written into it afterwards.
|
|
53
53
|
- **A crashed run's siblings are swept at construction.** `_sweepStaleSiblings()` (atomic, non-dev only) removes any `<build>.kiss-staging-*` / `<build>.kiss-old-*` beside the target and logs one `notice` naming them, so a killed build does not leave a copy of the site accumulating beside published output. Siblings carrying **this** process's pid are skipped: those belong to a live instance, which is the case a second `Kiss` building the same target in one process would hit.
|
|
54
54
|
- **A `generate: false` page claims no output path.** `_preparePage`'s `buildTo` dedupe only counts entries that will be written, on both sides — a skipped page neither fails a real page registered after it nor fails itself when one came first — and the replay sweep's `current` set excludes them, so a page that turns `generate: false` between builds has its stale output removed. It still sits on `_stack` (so `.scan()`'s by-view dedupe and the `complete()` data array are unchanged) and is skipped by the sitemap.
|
|
55
55
|
- **A page's identity is computed in `_preparePage`, after `prepare()`, and nowhere else.** `id` is what `{{link "<id>"}}` resolves, and the default has two halves that are known at two different times: the view route is known synchronously at `page()` entry, but the page's **normalised** slug only exists once `KissPage`'s setter has run it through `toSlug` — `options.slug` is still raw (`'Hello World'`, or the `${base}-${i}` a fan-out seeds). `_preparePage` after `prepare()` and before the push is the one point where both are in hand, which is also why the `{{link "blog/post" slug=…}}` sugar slugs its argument: both sides of that comparison go through `toSlug` exactly once. The rules: a `.page()`/`.scan()` page defaults to the view's route without its extension (`blog/listing.hbs` → `blog/listing`, leading `./` or `/` stripped); a `.pages()` item defaults to `<prefix>/<normalised slug>`, where the prefix is the registration's own `id` if it has one and the view route otherwise — which is how two fan-outs of one view into different folders get distinct ids; an inline template (a view that is not a `.hbs` filename) gets none, because it has no route; and a `generate: false` page claims none either, on the same reasoning as the output-path exemption above — it cannot be linked, so it must not take an id off a page that can.
|
|
@@ -57,6 +57,8 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
57
57
|
- **Two explicit ids collide like two output paths; two default ids withdraw instead.** An explicit id already claimed by another entry is logged as `Page id already claimed: <id>`, recorded on `_failures` and the page is not pushed — the exact treatment `Page already processed` gives a duplicate `buildTo`, because two pages claiming one identity means a `{{link}}` to it could only ever have meant one of them. A **default** id two pages arrived at must not fail: one view rendered by two `.page()` calls is how pagination is written today, and every site that builds must keep building. So neither page keeps it, one `notice` names both views and asks for an explicit id, and a `{{link}}` to it fails the page that asked. Explicit beats default: the explicit page keeps the id, the default withdraws with a notice naming both — deterministic, and fixable by naming one page.
|
|
58
58
|
- **The id index is lazy, memoised against the live stack, and never a snapshot.** `_idIndexFor()` builds `{ byId, withdrawn }` from `_stack` on demand; `_preparePage` sets `_idIndex = null` after every push, and `_replay()` resets it (and `_idNoticed`) beside `_report`. Two things make that necessary rather than tidy. Pages are pushed one at a time and can be registered after a render pass has begun, so an id stamped onto an entry at push time would let the first page keep an id the second is about to collide with — and any page rendered in between would have linked it successfully. And `_replay()` replaces `_stack` with a **new array**, so anything holding the old one (or an index built at helper-registration time, which happens once per instance in the constructor and is never re-run) would serve the first build's registry for the life of the watch session. The helper therefore gets `lookupPage: (id) => this._lookupPage(id)` — a bound function, resolved per call. `_idNoticed` keeps a collision notice to one line per id per build, because the index is rebuilt on every registration.
|
|
59
59
|
- **What can be linked is "any page registered before the first `.generate()` call".** `generate()` captures `Promise.all(this._promises)` synchronously and iterates `_stack` inside the `.then`, so a page registered afterwards is seen only if its model chain happened to settle in the microtasks before that `.then` ran: a page registered synchronously after the call resolves, and one registered a macrotask later does not (it is still _built_, by `_generatePending()` in `_settle`). That is order-dependent and **unsupported** rather than a rule, and it fails loudly — the linking page throws rather than shipping a guessed href. Both halves are pinned by tests in `test/integration/link-helper.test.js`.
|
|
60
|
+
- **The module also re-exports the markup splitter, for the same reason.** `splitDocument`, `parseHtml` and `findTag` from `lib/html-split.js`. (A stylesheet splitter, `lib/css-split.js`, was exported here too until it was removed before 2.7.0 shipped: the stylesheet now stays whole, inline in the layout.) They turn a single-file page into a kiss site, and the agent that needs them runs in the _consuming_ project, where the one-entry `exports` map makes `kiss-ssg/lib/html-split.js` an `ERR_PACKAGE_PATH_NOT_EXPORTED`. A tool nothing can import is a tool nobody uses. `findTag` rides along with `parseHtml` because a caller that parses a document almost always wants one element out of it — the `<body>`, to compare a conversion's output against its input — and it was added when an example reached for it and found the list one function short.
|
|
61
|
+
|
|
60
62
|
- **The module re-exports the redirect renderers, and that is a deliberate widening of the public surface.** `renderRedirects`, `renderRedirectsJson`, `renderFirebaseRedirects`, `renderVercelRedirects` and `renderHtaccessRedirects` pass straight through from `lib/redirects.js`. The `exports` map has one entry by design, so a named export here is the _only_ way anything reaches a consumer — and a `config.redirects.format` writer that cannot reuse an encoding kiss already has is an extension point with nothing to extend. Each name is now a semver promise; `test/unit/types.test.js` asserts all five survive into `types/kiss.d.ts`, and `test/fixtures/consumer/site.js` uses one under `tsc --noEmit` so a typed consumer proves it rather than the source claiming it.
|
|
61
63
|
|
|
62
64
|
- **`_writeRedirects()` now writes up to two files, and only one of them is the host's.** `redirects.json` — the host-neutral IR — goes out on every build that has an alias, whatever `config.redirects.format` says; the format picks what goes beside it. `_redirectsPath` stays the **host** file, so the report's `redirects.file` keeps the meaning it had in 2.3, and it is `null` under `format: 'none'`: the honest answer for a build that deliberately wrote no host file, rather than a path that would read as a redirect some host is serving. A custom writer runs inside the same promise the `.catch` below wraps, so a writer that throws fails the build exactly as a failed write does — swallowing it would rebuild, in the site's own code, the silent no-op the format block exists to end.
|
|
@@ -66,10 +68,11 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
66
68
|
- **A withdrawn default id is `null` on the record, not the string.** `_stackForRecord()` projects the stack before `buildReport` and `buildSiteMap` see it, nulling the id of any non-explicit entry whose id was withdrawn. `site-map.json`'s rows are read as the `{{link}}` autocomplete, so an id on the map must be one the helper will resolve; the alternative — reporting an id two pages share — would also hand `lib/redirects.js`'s id pairing an ambiguous key. The projection is a shallow copy per affected entry, so the live stack keeps its ids and a later registration can still change the answer.
|
|
67
69
|
- **After a promotion the instance is an ordinary one.** `_promote()` clears `_stagingDir`, so `_writeRoot` is the real folder again, and re-points every stack entry's `buildDir`/`buildTo`, so a second build on the same instance — or a watch rebuild — writes into the promoted folder instead of a staging sibling nothing will ever swap in again. `_stagedPath()` covers the one write that does not read the write root: a `copyAssets()` the consumer aimed explicitly at the build folder (or a subfolder of it, the metacarpus idiom) is rewritten into staging, or the promotion's `fs.remove(target)` would delete the files that copy had just made. A copy aimed anywhere else is left where it was asked for.
|
|
68
70
|
- **`'atomic'` is a one-shot-build feature, and dev mode says so.** With `dev: true` the promotion point is the wrong shape — the dev server serves `folders.build` from construction and every rebuild would have to promote — so `_setupFolders()` degrades `'atomic'` to `true` (wipe at construction, build in place) and logs one `notice` saying that atomic promotion applies to one-shot builds. A **non-dev** `.watch()` is the one seam left: it stages, promotes at the first successful `complete()`, and behaves as an ordinary watch after that. A build that never calls `complete()` never promotes, and `close()` removes the staging folder — which is also what clears a discarded one that a later write recreated (`_discardStaging` deliberately keeps `_stagingDir` set for exactly that).
|
|
69
|
-
- **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
|
|
71
|
+
- **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_audit`, `_debugWritten`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
|
|
70
72
|
- **Three report keys are wired here before the modules that fill them exist.** `_finishBuild()` passes `links: this._links`, `redirects: this._redirectsResult` and `feed: this._feedPath` into `buildReport`, `links` is filled by `_checkLinks()`, `redirects` by `_writeRedirects()`/`_redirectFindings()` (both below) and `feed` by `.feed()` — the three modules that fill the seam have all landed, and each key is still `null` on a build that did not do that piece of work, which is not the same as "it did it and found nothing". The fields are the whole seam: `_links` and `_redirectsResult` are the findings, `_redirectsPath` and `_feedPath` are the files as written — staging paths under `'atomic'` and under check, mapped into the report by `buildReport` exactly as `_sitemapPath` and `_llmsPath` are — and `_feedRequest` is the standing request a replay re-issues. That last one is the one field `_replay()` deliberately does **not** reset: like `_sitemapRequest` and `_llmsRequest` it is the request rather than the result, and clearing it would leave the re-issue block with nothing to re-issue.
|
|
71
73
|
- **A production build starts loading the HTML minifier in the constructor.** `preloadMinifier()` (`AIKB/kiss-page.md`) is called, unawaited, as soon as the config is resolved and `config.dev` is known to be false — before `_setupFolders()`, the asset copy and partial registration — so the minifier's ~155ms import overlaps that work and model resolution instead of sitting on the critical path at the first render. Check mode forces `dev: false` first, so a check preloads too. Dev never minifies and never loads it.
|
|
72
|
-
- **The link scan runs in `complete()`'s settle path, above the branch — not in `_finishBuild()`.** `_checkLinks()` is the only consumer of `lib/links.js` here, and where it is called is the whole design. By the time `_finishBuild()` runs, two of `complete()`'s three branches have already called `_discardStaging()` (check mode, and any failed build) and the third has already called `_promote()` — so a scan that resolved against `config.folders.build` there would have found an empty or renamed folder and reported `checked: 0, broken: []` in silence, under exactly the command the finding exists to serve. Called before the branch instead, the written pages are still where they were written, whichever way the build is about to settle; one call site dominating all three arms is also what keeps a fourth from quietly forgetting it. It needs no ordering against `sitemap.xml`, `llms.txt` or a feed: those are written on the promise queue, which `_settle()` has already drained. The references themselves were extracted at **write** time (`KissPage.links`, `AIKB/kiss-page.md`), so nothing is read back from disk; the filesystem is consulted only for the rest of the build folder — assets, those generated files, anything a pipeline step wrote straight into it. The scan is skipped entirely when `config.links.check` is false or `config.dev` is on (dev scoped re-renders have not rewritten every page; non-dev whole-site replays do scan after cleanup), leaving `_links` `null` — which the report distinguishes from "it ran and found nothing". The result is latched on `_links` for the reason `_report` is latched: the nested-`complete()` pattern settles one build twice, and the second pass would be scanning a folder that is no longer there. `buildDir` is `config.folders.build` **as it is at that moment** — the staging sibling under `'atomic'` and under check — and the pages are recorded as the staging paths they were written to, which is why `buildReport` maps `broken[].page` back through `reportedPath` and the report names the folder the site asked for. Pages are taken from `_stack` entries whose `page.hash !== null`: that is the "these bytes are on disk" promise, and `generate !== false` is not (a page can also have failed to render). A build that failed is scanned too — its surviving pages are on disk and their dead links are real — and the finding moves neither `ok` nor the exit code. One `notice` per broken link, in `formatReport`'s own wording, so the build log and `check --summary` say the same thing; one `info` line when there is nothing to report.
|
|
74
|
+
- **The link scan runs in `complete()`'s settle path, above the branch — not in `_finishBuild()`.** `_checkLinks()` is the only consumer of `lib/links.js` here, and where it is called is the whole design. By the time `_finishBuild()` runs, two of `complete()`'s three branches have already called `_discardStaging()` (check mode, and any failed build) and the third has already called `_promote()` — so a scan that resolved against `config.folders.build` there would have found an empty or renamed folder and reported `checked: 0, broken: []` in silence, under exactly the command the finding exists to serve. Called before the branch instead, the written pages are still where they were written, whichever way the build is about to settle; one call site dominating all three arms is also what keeps a fourth from quietly forgetting it. It needs no ordering against `sitemap.xml`, `llms.txt` or a feed: those are written on the promise queue, which `_settle()` has already drained. The references themselves were extracted at **write** time (`KissPage.links`, `AIKB/kiss-page.md`), so nothing is read back from disk; the filesystem is consulted only for the rest of the build folder — assets, those generated files, anything a pipeline step wrote straight into it. The scan is skipped entirely when `config.links.check` is false or `config.dev` is on (dev scoped re-renders have not rewritten every page; non-dev whole-site replays do scan after cleanup), leaving `_links` `null` — which the report distinguishes from "it ran and found nothing". The result is latched on `_links` for the reason `_report` is latched: the nested-`complete()` pattern settles one build twice, and the second pass would be scanning a folder that is no longer there. `buildDir` is `config.folders.build` **as it is at that moment** — the staging sibling under `'atomic'` and under check — and the pages are recorded as the staging paths they were written to, which is why `buildReport` maps `broken[].page` back through `reportedPath` and the report names the folder the site asked for. Pages are taken from `_stack` entries whose `page.hash !== null`: that is the "these bytes are on disk" promise, and `generate !== false` is not (a page can also have failed to render). A build that failed is scanned too — its surviving pages are on disk and their dead links are real — and the finding moves neither `ok` nor the exit code. One `notice` per broken link, in `formatReport`'s own wording, so the build log and `check --summary` say the same thing; one `info` line when there is nothing to report. **Under check mode it is one `notice` with the count instead** (`N broken links (listed in the check report)`): `kiss-ssg check` sends the build's log to stderr and the report to stdout, a terminal shows both, and listing the findings in each printed every one twice — 2,756 findings as 5,512 lines on a real site. The report is the list under check, whether `--summary` prints it or the JSON carries it.
|
|
75
|
+
- **The launch-readiness audit runs directly after the link scan, for every one of the link scan's reasons** (`lib/audit.js`, `AIKB/audit.md`). `_runAudit({ quiet })` is called on the next line after `_checkLinks(...)` in `complete()`'s settle path, above the three-way branch, so the pages and the rest of the build folder are still where they were written — `buildDir` is `_writeRoot`, never `config.folders.build`, because under `'atomic'` and under check the pages are in staging. The per-page facts were extracted at write time (`KissPage.audit`, `AIKB/kiss-page.md`) from the pages whose `hash !== null`; each is handed over with `canonicalElsewhere` (the page's `canonical` option is a non-empty string — the field the report already emits, with no URL comparison, so no URL policy is involved) and its own `siteUrl` (the page's merged `options.config.siteUrl`, falling back to the instance's — the `{{canonical}}` rule, `siteUrlFor` in `lib/handlebars-helpers.js`). `ownsFolder` is `_ownsFolder` — the author's `cleanBuild !== false`, captured in the constructor **before** check mode rewrites `cleanBuild` to `'atomic'`, so `check` skips the folder-walk checks exactly where the real build does (reading the config there made `check` tell example 7's shared-folder archive instance to add a 404 page, 2026-09-27) — and `debugWritten` is `_debugWritten`, set by `viewStats()` when it starts the `debug.json` write — state rather than disk, because that write is not awaited. Skipped, leaving `_audit` `null`, in dev (so on every watch rebuild), under `config.audit.check: false`, and — its one skip of its own — **when `_failures` is non-empty**: a page that failed has no facts, so site-level findings would be false and would bury the real failures. Latched on `_audit` for the nested-`complete()` reason, reset beside `_links` in `_replay()` and at the orphan/restore sweep (reset **then re-run**), and passed through `_reportInputs()` as the last key, so `_refreshReport()` carries it. Logging shares `auditLines()` with `formatReport` (`lib/build-report.js`) over the reported paths, so the build log and `check --summary` cannot word a finding differently: one `info` line (`Audit: N pages, M findings`, or `none`), then one `notice` per line. **Under check the notices are not logged**: the info line gains `(listed in the check report)`, because the build log goes to stderr beside `--summary`'s stdout and every finding printed twice (diploma-msc's real check, 2026-10-02) — the same rule `_checkLinks` follows for broken links. Advisory — it moves neither `ok` nor the exit code.
|
|
73
76
|
- **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.
|
|
74
77
|
- **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs/router.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. `KISS_AIKB=1` is the third variable on that channel, read by the same rule in `aikbRecordRequested()` and set only by `kiss-ssg aikb`: it is what lets a build write the site's knowledge base (see `_buildAikb()` below). None of the three changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
|
|
75
78
|
- **`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.
|
|
@@ -171,6 +174,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
171
174
|
- Watch-only copies use `_copyAssets(..., true)` and the bounded `_assetQueue` slot rather than appending settled results to `_promises`. `generate()` and `_drain()` await that slot as well as registration work; callback data retains its original positions.
|
|
172
175
|
- Replay retires old page producers before rendering, while preserving their on-disk ownership for the orphan sweep. The sweep checks current kind and never removes another writer's output. Page registrations capture their working directory, retain it through replay, and use absolute `page.outputPath` for ownership, deduplication and cleanup. The report retains `buildTo`'s configured spelling.
|
|
173
176
|
- `_discardStaging()` captures the staging path before awaiting removal, so concurrent close cannot redirect claim cleanup to a cleared field.
|
|
177
|
+
- **An unpromoted staging folder is removed by `_removeStaging()`, from both ends: a failed build's `_discardStaging()` and `close()` with nothing promoted.** It calls `fs.rm` with `maxRetries` — Node's own retry for the `EBUSY` / `ENOTEMPTY` / `EPERM` a Windows antivirus, indexer or pending delete throws for a moment — and never throws: a removal that outlasts the retries logs one `warn` naming the folder, saying nothing was published from it, to delete it by hand, and that the next `cleanBuild: 'atomic'` build sweeps leftovers at start (`_sweepStaleSiblings()`). Before 2.6.2 both sites removed with `fs.remove` and a debug-only catch, and `_discardStaging()` cleared the output claims only on success — a transient lock left the folder, the claims, and no word about either. The claims are now cleared in a `finally`. (The `ENOTEMPTY` a test hit was a different cause: it discarded while a render was still writing — see `AIKB/testing.md` § Gotchas.)
|
|
174
178
|
|
|
175
179
|
- `_defaultAssetCopy` anchors configured source/destination paths and event-path resolution at construction for watch, pipeline recopy and live-reload filenames. Explicit public `copyAssets` calls still resolve at their own registration time. `_restorableAssets` keeps the source/requested-target pairs of copies with live output producers, separately from failure retries; an empty reconciled copy drops its restoration registration. The sweep obtains asset owner identities from the registry rather than reconstructing output paths from source extensions.
|
|
176
180
|
- Duplicate-output detection reads the new page's cached `outputPath` once, outside the comparison loop. The per-page cache invalidates on route or build-folder changes, including promotion.
|