kiss-ssg 2.3.0 → 2.5.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/aikb.md +1 -1
- package/AIKB/assets.md +8 -3
- package/AIKB/build-report.md +7 -5
- package/AIKB/check.md +1 -1
- package/AIKB/config.md +11 -2
- package/AIKB/design.md +86 -0
- package/AIKB/feed.md +1 -1
- package/AIKB/handlebars-helpers.md +6 -5
- package/AIKB/kiss.md +26 -13
- package/AIKB/llms.md +1 -1
- package/AIKB/partials.md +9 -3
- package/AIKB/redirects.md +26 -9
- package/AIKB/robots.md +45 -0
- package/AIKB/site-helpers.md +40 -0
- package/AIKB/sitemap.md +1 -1
- package/AIKB/testing.md +7 -1
- package/AIKB/upstream.md +128 -0
- package/AIKB/utils.md +11 -2
- package/AIKB/watcher.md +5 -3
- package/CHANGELOG.md +1112 -0
- package/README.md +194 -31
- package/examples/1-scan/README.md +4 -4
- package/examples/1-scan/router.js +23 -9
- package/examples/10-asset-pipeline/README.md +37 -4
- package/examples/10-asset-pipeline/router.js +43 -11
- package/examples/10-asset-pipeline/{assets → src/assets}/css/generated.css +1 -1
- package/examples/10-asset-pipeline/src/assets/css/site.scss +413 -0
- package/examples/10-asset-pipeline/src/assets/img/icons.svg +18 -0
- package/examples/10-asset-pipeline/src/layouts/layout.hbs +61 -0
- package/examples/10-asset-pipeline/src/pages/index.hbs +113 -0
- package/examples/10-asset-pipeline/src/partials/newsletter.md +5 -0
- package/examples/10-asset-pipeline/tools/tokens.js +1 -1
- package/examples/11-blog/AIKB/last-build.json +44 -26
- package/examples/11-blog/AIKB/notes/controllers/post.md +1 -1
- package/examples/11-blog/AIKB/notes/controllers/tag.md +1 -1
- package/examples/11-blog/AIKB/site-map.json +84 -83
- package/examples/11-blog/AIKB/site-map.md +40 -39
- package/examples/11-blog/README.md +21 -11
- package/examples/11-blog/router.js +41 -16
- package/examples/11-blog/src/assets/css/site.scss +413 -0
- package/examples/11-blog/{controllers → src/controllers}/post.js +1 -1
- package/examples/11-blog/{controllers → src/controllers}/tag.js +1 -1
- package/examples/11-blog/src/layouts/layout.hbs +61 -0
- package/examples/2-page/README.md +3 -3
- package/examples/2-page/router.js +21 -3
- package/examples/2-page/src/assets/css/site.scss +413 -0
- package/examples/2-page/src/layouts/layout.hbs +61 -0
- package/examples/2-page/src/partials/newsletter.md +5 -0
- package/examples/3-pages/README.md +3 -3
- package/examples/3-pages/router.js +21 -3
- package/examples/3-pages/src/assets/css/site.scss +413 -0
- package/examples/3-pages/src/layouts/layout.hbs +61 -0
- package/examples/3-pages/src/partials/newsletter.md +5 -0
- package/examples/4-layouts-and-partials/README.md +2 -2
- package/examples/4-layouts-and-partials/router.js +26 -10
- package/examples/4-layouts-and-partials/src/assets/css/site.scss +413 -0
- package/examples/4-layouts-and-partials/{layouts → src/layouts}/layout.hbs +1 -1
- package/examples/5-helpers/README.md +2 -2
- package/examples/5-helpers/router.js +23 -10
- package/examples/5-helpers/src/assets/css/site.scss +413 -0
- package/examples/5-helpers/src/layouts/layout.hbs +61 -0
- package/examples/5-helpers/{pages → src/pages}/index.hbs +1 -1
- package/examples/6-sitemap/README.md +3 -3
- package/examples/6-sitemap/router.js +32 -7
- package/examples/6-sitemap/src/assets/css/site.scss +413 -0
- package/examples/6-sitemap/src/layouts/layout.hbs +61 -0
- package/examples/6-sitemap/src/partials/newsletter.md +5 -0
- package/examples/7-versioned-outputs/README.md +2 -2
- package/examples/7-versioned-outputs/router.js +23 -8
- package/examples/7-versioned-outputs/src/assets/css/site.scss +413 -0
- package/examples/7-versioned-outputs/src/layouts/layout.hbs +61 -0
- package/examples/7-versioned-outputs/{pages → src/pages}/index.hbs +1 -1
- package/examples/7-versioned-outputs/src/partials/newsletter.md +5 -0
- package/examples/8-data-fed-site/README.md +3 -3
- package/examples/8-data-fed-site/router.js +24 -8
- package/examples/8-data-fed-site/src/assets/css/site.scss +413 -0
- package/examples/8-data-fed-site/{controllers → src/controllers}/stockist.js +1 -1
- package/examples/8-data-fed-site/src/layouts/layout.hbs +61 -0
- package/examples/8-data-fed-site/src/partials/newsletter.md +5 -0
- package/examples/9-migrated-from-v1/AIKB/last-build.json +17 -20
- package/examples/9-migrated-from-v1/AIKB/notes/controllers/shelf-item.md +1 -1
- package/examples/9-migrated-from-v1/AIKB/site-map.json +47 -46
- package/examples/9-migrated-from-v1/AIKB/site-map.md +35 -34
- package/examples/9-migrated-from-v1/README.md +6 -6
- package/examples/9-migrated-from-v1/helpers/index.js +9 -4
- package/examples/9-migrated-from-v1/router.js +29 -13
- package/examples/9-migrated-from-v1/src/assets/css/site.scss +413 -0
- package/examples/9-migrated-from-v1/{controllers → src/controllers}/shelf-item.js +1 -1
- package/examples/9-migrated-from-v1/src/layouts/layout.hbs +61 -0
- package/examples/9-migrated-from-v1/{pages → src/pages}/await-complete.hbs +1 -1
- package/examples/9-migrated-from-v1/{pages → src/pages}/folders.hbs +4 -5
- package/examples/README.md +32 -20
- package/lib/aikb.js +13 -1
- package/lib/assets.js +174 -30
- package/lib/build-report.js +44 -2
- package/lib/config.js +135 -4
- package/lib/feed.js +15 -4
- package/lib/handlebars-helpers.js +100 -14
- package/lib/kiss.js +916 -61
- package/lib/llms.js +7 -2
- package/lib/partials.js +47 -4
- package/lib/redirects.js +320 -27
- package/lib/robots.js +237 -0
- package/lib/site-helpers.js +370 -0
- package/lib/sitemap.js +11 -6
- package/lib/utils.js +41 -13
- package/lib/watcher.js +59 -4
- package/llms.txt +110 -21
- package/package.json +4 -2
- package/types/assets.d.ts +28 -3
- package/types/build-report.d.ts +77 -3
- package/types/config.d.ts +97 -8
- package/types/feed.d.ts +3 -1
- package/types/kiss.d.ts +218 -5
- package/types/llms.d.ts +3 -1
- package/types/redirects.d.ts +175 -10
- package/types/robots.d.ts +207 -0
- package/types/site-helpers.d.ts +171 -0
- package/types/sitemap.d.ts +2 -1
- package/types/utils.d.ts +19 -6
- package/types/watcher.d.ts +2 -1
- package/examples/10-asset-pipeline/pages/index.hbs +0 -62
- package/examples/_shared/assets/robots.txt +0 -2
- package/examples/_shared/site.js +0 -33
- /package/examples/{_shared → 1-scan/src}/assets/css/site.scss +0 -0
- /package/examples/{_shared → 1-scan/src}/layouts/layout.hbs +0 -0
- /package/examples/1-scan/{models → src/models}/about/us.json +0 -0
- /package/examples/1-scan/{models → src/models}/index.json +0 -0
- /package/examples/1-scan/{pages → src/pages}/about/us.hbs +0 -0
- /package/examples/1-scan/{pages → src/pages}/index.hbs +0 -0
- /package/examples/{_shared → 1-scan/src}/partials/newsletter.md +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2025-12-15-a-year-of-bench-lots.json +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2026-01-06-grind-size-is-the-dial.json +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2026-01-20-why-we-roast-on-wednesdays.json +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2026-02-03-guji-uraga-has-landed.json +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2026-02-18-the-cascara-experiment.json +0 -0
- /package/examples/11-blog/{models → src/models}/posts/2026-03-02-pour-over-at-home.json +0 -0
- /package/examples/11-blog/{pages → src/pages}/blog/listing.hbs +0 -0
- /package/examples/11-blog/{pages → src/pages}/blog/post.hbs +0 -0
- /package/examples/11-blog/{pages → src/pages}/blog/tag.hbs +0 -0
- /package/examples/11-blog/{pages → src/pages}/blog/tags.hbs +0 -0
- /package/examples/11-blog/{pages → src/pages}/index.hbs +0 -0
- /package/examples/11-blog/{partials → src/partials}/head-feed.hbs +0 -0
- /package/examples/11-blog/{partials → src/partials}/post-card.hbs +0 -0
- /package/examples/2-page/{controllers → src/controllers}/about.js +0 -0
- /package/examples/2-page/{controllers → src/controllers}/index.js +0 -0
- /package/examples/2-page/{models → src/models}/about.json +0 -0
- /package/examples/2-page/{models → src/models}/index.json +0 -0
- /package/examples/2-page/{pages → src/pages}/about.hbs +0 -0
- /package/examples/2-page/{pages → src/pages}/feed.hbs +0 -0
- /package/examples/2-page/{pages → src/pages}/index.hbs +0 -0
- /package/examples/3-pages/{models → src/models}/index.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/arch-blend.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/finca-la-ilusion.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/guji-uraga.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/huila-decaf.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/nyeri-gatomboya.json +0 -0
- /package/examples/3-pages/{models → src/models}/roasts/sumatra-ketiara.json +0 -0
- /package/examples/3-pages/{pages → src/pages}/index.hbs +0 -0
- /package/examples/3-pages/{pages → src/pages}/roasts/index.hbs +0 -0
- /package/examples/3-pages/{pages → src/pages}/roasts/roast.hbs +0 -0
- /package/examples/4-layouts-and-partials/{pages → src/pages}/index.hbs +0 -0
- /package/examples/4-layouts-and-partials/{pages → src/pages}/stockists.hbs +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/boards/espresso.hbs +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/boards/filter.hbs +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/hours.html +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/site/footer.hbs +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/site/header.hbs +0 -0
- /package/examples/4-layouts-and-partials/{partials → src/partials}/sourcing.md +0 -0
- /package/examples/5-helpers/{pages → src/pages}/brew-guide.hbs +0 -0
- /package/examples/5-helpers/{partials → src/partials}/grinder-note.md +0 -0
- /package/examples/5-helpers/{partials → src/partials}/tasting.md +0 -0
- /package/examples/6-sitemap/{models → src/models}/about.json +0 -0
- /package/examples/6-sitemap/{models → src/models}/index.json +0 -0
- /package/examples/6-sitemap/{models → src/models}/rota.json +0 -0
- /package/examples/6-sitemap/{models → src/models}/stockists.json +0 -0
- /package/examples/6-sitemap/{pages → src/pages}/about.hbs +0 -0
- /package/examples/6-sitemap/{pages → src/pages}/index.hbs +0 -0
- /package/examples/6-sitemap/{pages → src/pages}/rota.hbs +0 -0
- /package/examples/6-sitemap/{pages → src/pages}/stockists.hbs +0 -0
- /package/examples/7-versioned-outputs/{models → src/models}/index.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/bakers-yard.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/cargo-canteen.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/harbour-market-stall.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/oakfield-deli.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/severn-provisions.json +0 -0
- /package/examples/8-data-fed-site/{models → src/models}/stockists/the-hop-and-vine.json +0 -0
- /package/examples/8-data-fed-site/{pages → src/pages}/index.hbs +0 -0
- /package/examples/8-data-fed-site/{pages → src/pages}/stockists/stockist.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/duplicate-paths.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/dynamic-partials.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/handlebars-instance.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/index.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/pure-controllers.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/shelf/item.hbs +0 -0
- /package/examples/9-migrated-from-v1/{pages → src/pages}/smaller-changes.hbs +0 -0
- /package/examples/9-migrated-from-v1/{partials → src/partials}/sourcing-note.md +0 -0
- /package/examples/9-migrated-from-v1/{partials → src/partials}/stock-note.hbs +0 -0
package/AIKB/aikb.md
CHANGED
|
@@ -58,7 +58,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
58
58
|
- **The engine never writes a stamp.** `subject-hash` is copied into a note's frontmatter by the person (or the `kiss-branch-close` / `kiss-memory-consolidate` skill) who wrote or reviewed it. An unstamped note is the state every note starts in and most notes stay in, and it is never reported stale — only a stamp that _disagrees_ is a finding, which is what makes stamping opt-in per note rather than a burden on everyone.
|
|
59
59
|
- **The subjects are on the report as well as in `site-map.json`, because at the one moment that matters the two disagree.** A piece of work closes with the map on disk still describing the _previous_ record — so the hash in that file is the hash the note is already stamped with, and the hash it wants restamping with exists nowhere but the report of the build that just found the finding. Hence `aikb.subjects` beside `aikb.notes`: a plain `kiss-ssg check` can say both "this note is stale" and "here is what to stamp it with", before any record is made.
|
|
60
60
|
- **The dangling filter errs, at every step, towards saying nothing.** A backticked token is a candidate only if it has a `/` in it or ends in one of eleven known extensions, and only if it holds none of ` <>*{}$` — so `{ name, origin }`, `<title>`, `npx kiss-ssg check` and `p-<8 hex>` are all prose, not paths. Fenced code blocks are dropped whole (a snippet quotes code rather than citing a file), a trailing `/` is a folder, and anything with a URI scheme (`https:`, and also the map's own `file:`/`url:` classifications) is skipped. The reason is not politeness: a lint that fires on every note is a lint people learn to scroll past, and then it is worth less than nothing.
|
|
61
|
-
- **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`.
|
|
61
|
+
- **A note is read as written from where the site is, not from where the build script is run.** A token resolves if it is a page view, an output path, a partial name, a model or controller name, a folder in the map, a path under the AIKB folder, or a file on disk from the cwd — and _also_ if any of those paths ends with `/<token>`. That last one is what lets a note say `shelf/arch-blend.html` about a page the build wrote to `../public/site/shelf/arch-blend.html`, which is the only readable way to cite it. The suffix is anchored on a separator, so `a.html` never resolves against `banana.html`. **A leading slash is stripped before both lookups**, because a note written the way the deployed site reads cites `/docs` for the folder the config calls `./docs` — and `posixPath` strips a leading `./` but not a leading `/`, so one character was the whole difference between resolving and being reported dangling. Measured on one record before the fix: `/docs`, `/src`, `/src/assets` and `/src/pages` were all reported dangling while `./docs`, `docs`, `docs/`, `src`, `src/assets` and `src/pages` all resolved — four findings, no real problems, every one of them on a folder the site is configured with. The suffix fallback could not have caught it: it looks for a candidate ending `//docs`. Found by a downstream site conversion, not by the suite.
|
|
62
62
|
- **`site.md` is scanned but never judged.** It is authored, evergreen and about the whole site rather than any one subject, so it has no subject to be missing, dead or stale about — but its file references rot exactly as a note's do, so it is read for `dangling` when it is there. The engine neither writes it nor enforces its shape; the generated `README.md` names it and its five sections, and `kiss-memory-consolidate` maintains it.
|
|
63
63
|
- **A subject hash is over bytes, so a checkout that rewrites line endings restamps every note.** The controller's hash is sha1 of the file as it is on disk; a CRLF checkout of an LF repository hashes differently, and every stamped controller note reads `stale` until it is restamped. The alternative — normalising the bytes before hashing — would make the hash disagree with `git`'s idea of whether the file changed, which is the thing the stamp is trying to track.
|
|
64
64
|
- **Inline controllers and inline (object) models are not subjects, and cannot be.** There is no file to hang a note on and no stable id to name one by. Example 9's `pure-controllers` page has an inline controller and is deliberately not a subject; its shelf fan-out's `shelf-item.js` is. This is a documented limit, not an oversight.
|
package/AIKB/assets.md
CHANGED
|
@@ -6,8 +6,8 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves.
|
|
10
|
-
- `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss`
|
|
9
|
+
- `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves. **The compile and the write are caught separately**, because they fail for different reasons and have different fixes: a syntax error is `Error parsing sass file: <source>`, and a stylesheet that parsed and could not be written (a directory in the way, a read-only target, a full disk) is `Error writing compiled sass to: <output>`. Both used to be the parse message, with the real cause on the `warn` line underneath — which is the line a reader skips once the red one has named a cause.
|
|
10
|
+
- `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss`always passes its own.`display` is optional too, and is the pair of strings the id and the log line are built from: the caller resolves the paths it actually copies — a relative path must not be re-resolved against whatever the working directory is when a queue drains — and resolving for correctness must not turn every build's asset line into an absolute path nobody typed.
|
|
11
11
|
|
|
12
12
|
## Depends on
|
|
13
13
|
|
|
@@ -30,8 +30,13 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
30
30
|
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
31
31
|
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
32
32
|
- **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
|
|
33
|
-
- **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded
|
|
33
|
+
- **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded. That is about the MANIFEST only: a broken stylesheet is not an emitted file, so nothing claims it. It is **not** a statement about the build's verdict — a Sass failure fails the build, as the bullet below says. This sentence used to end by calling the copy successful, which was the pre-R8 behaviour left standing beside its replacement; a reader scanning for "does a broken stylesheet fail my build" could land on it and get the retired answer. That wording is now banned by `test/unit/skill-coverage.test.js`'s `CONTRADICTIONS` table, which is also why this note paraphrases it rather than quoting it.
|
|
34
34
|
- `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
|
|
35
35
|
- **The files a copy picks up may have been written moments earlier by `config.assets.pipeline`.** This module knows nothing about that: the steps are chained onto the same `_assetQueue` this module's calls are serialised by, so by the time `copyAssets` walks the source tree the tools have finished writing into it. See `AIKB/pipeline.md` and `AIKB/kiss.md`.
|
|
36
36
|
- `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
|
|
37
37
|
- **Sass is compiled through `compileFile()`, which loads the package at the point of compile and memoises the result**, not bound at import — see `AIKB/sass.md`. A site with no `.scss` under its assets folder never loads the `sass` package at all.
|
|
38
|
+
|
|
39
|
+
- **`_`-prefixed files are not compiled**, because a leading underscore is the Sass convention for "this is a partial, do not compile me standalone" and dart-sass itself never compiles one. kiss globbed them anyway, which was a harmless red log line right up until a Sass error became a build failure — and then the most ordinary stylesheet layout there is (`main.scss` defining a variable and `@import`ing a `_buttons.scss` that reads it) could not build, while `main.css` compiled correctly beside it. Measured across a real estate of six sites: two carry underscore partials, and they passed only because those partials happen to be self-contained.
|
|
40
|
+
- **The skip is reported, because skipping silently was the third quiet regression on this branch.** `_vendor.scss` — self-contained, imported by nothing — used to emit `_vendor.css` and then emitted nothing at all, with zero mentions anywhere in the log: a site serving that file began serving a 404 after a clean build and nothing said why. One `info` line names the whole set (one line, not one per file, so a dozen partials do not become a dozen lines every build), and `copyAssets` reports them to its caller as `{ file, skipped: true }`. **`isSassPartial` is shared by the compile pass and the claim pass** so the two cannot disagree about what was emitted — they did, and the claim pass then warned that a legitimately served `_theme.css` had overwritten a compile that never ran, advising the author to delete a file another entry point correctly consumes.
|
|
41
|
+
- **A Sass compile error is a build failure, not a red line in the log.** `compileSassFiles` resolves to one `{ file, error? }` per stylesheet it tried — every file, not only the failures, so a caller can clear the entry for one that has since started compiling — and `copyAssets` passes them back as `sass`. `Kiss.copyAssets` turns each error into a `<sass: <file>>` entry on `_failures`, the same shape as `<pipeline: name>`. It used to be logged and dropped: `complete()` resolved, `report().ok` stayed true, `kiss-ssg check` said `ok`, and the site shipped with **no stylesheet** — while `kiss-build-check` tells an agent that `ok: true` and exit 0 are the only passing result, which makes that verdict load-bearing. Found by a clean-room conversion that published exactly that site. One broken stylesheet still does not stop the others or the copy, the same rule a failing page follows; and because an asset re-copy is not a `_replay()`, each copy clears the entries for the files it is about to re-try, or a fixed stylesheet would stay failed for the rest of a watch session.
|
|
42
|
+
- **A copied `.css` beats a Sass compile, deliberately, and `recordEmitted` now says so.** `copyAssets` compiles Sass and then `fs.copy`s over the top, so when `site.scss` and `site.css` both emit `css/site.css` the copied file is served and the Sass is discarded. **That precedence is the design, not an accident** — it dates from this module's first commit (`ca49564`, 2026-09-02) and is what makes `config.assets.pipeline` work: a step's `run` writes into the assets folder _before_ the copy, so a Tailwind or PostCSS stylesheet lands as `src/assets/css/site.css` and must beat kiss's built-in Sass. Inverting it would have kiss silently clobbering the toolchain the site explicitly configured. What was missing was not the rule but any sign of it: the pages are correct and only the asset is wrong, so page hashes structurally cannot catch it and `kiss-ssg check` reports a clean build. It reached us from a real conversion where a project migrating off a pipeline that committed its compiled CSS had `layout.scss`/`layout.css` pairs, deleted the `.css` siblings for tidiness, and compared the compiled bytes only afterwards. The detection is a `Map` of emitted name → sources built in a pass of its own, before anything is renamed: no manifest shape change, because the manifest already records Sass output keyed by its compiled name (a collision is two writes to one key). **The separate pass is what makes it work under `assets.hash`.** It first lived inside the emit loop, which killed it in exactly the configuration a production site builds with: hashing `fs.move`s the first source's file to its hashed name, so the second source's `fs.stat` of the plain path found nothing and `continue`d before any comparison. Hash off warned, hash on was silent — and a silently dead stylesheet is a production problem, not a development one. Claims are gathered first, the hash/move and the manifest write happen second, once per emitted name; `test/unit/assets.test.js` now pins both settings. **The direction of the message is load-bearing and is not glob order**: `globFiles` sorts, so `site.css` is seen first, but the write order is compile-then-copy, so the warning keys off `isSass(source)` — the first version keyed off iteration order, named the pair backwards, and would have sent the author to edit the file that was already winning. `test/unit/assets.test.js` pins the exact wording, the "wins by design" phrasing and the served bytes together. **The line leads with the consequence** ("edits to `css/site.scss` are not reaching the site") and puts the compile/copy mechanism second, per the documentation rule this branch adopted. **It is deliberately not suppressed when `config.assets.pipeline` is set**, which was proposed on the reasoning that a pipeline site would see it on every build. Measured, it does not: the warning needs two sources at one emitted name, and a Tailwind-shaped pipeline writes `site.css` with no `site.scss` to collide with, so nothing fires at all. What a pipeline site _can_ have is a leftover `site.scss` beside the generated file — dead, silent, and precisely what this exists to catch. Suppressing on `pipeline` would blind the one case it is for.
|
package/AIKB/build-report.md
CHANGED
|
@@ -10,7 +10,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
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.
|
|
13
|
-
- `duration` — ms
|
|
13
|
+
- `duration` — ms from `startedAt` (`Kiss` passes its construction time) to `finishedAt`, which `Kiss` captures once the settle has done everything it does, the knowledge-base record and the redirect findings included. Both ends are passed in rather than read from the clock here, and that is what makes a re-derived report honest: `Kiss._refreshReport()` re-runs this whenever a watch asset copy or a helpers reload changes the verdict, and a duration measured at assembly time would grow with the idle time of the session. Measured before the fix: a 9ms build reported as 1216ms after a 1.2-second pause.
|
|
14
14
|
- `pages` — `{ view, buildTo, ok, hash, id }` per stack entry, in registration order; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
15
15
|
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
|
|
16
16
|
- `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
|
|
@@ -19,7 +19,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
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
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.
|
|
22
|
-
- `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved }` 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`.
|
|
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
|
- `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 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. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
25
25
|
- `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.
|
|
@@ -30,7 +30,7 @@ Nothing.
|
|
|
30
30
|
|
|
31
31
|
## Depended on by
|
|
32
32
|
|
|
33
|
-
`lib/kiss.js` (assembles one report per settled build in `_finishBuild()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
|
|
33
|
+
`lib/kiss.js` (assembles one report per settled build in `_finishBuild()`, and re-derives it in place through `_refreshReport()` whenever something changes the failure list without settling a build — a watch asset re-copy, a helpers reload — so `ok` is never behind the log. The refresh re-runs `buildReport` and nothing else: `_finishBuild()`'s once-per-build side effects (the `KISS_REPORT` line, the `last-build.json` record, `dependency-graph.json`) stay per build, and both call sites read one `_reportInputs()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
|
|
34
34
|
|
|
35
35
|
## Non-obvious behavior
|
|
36
36
|
|
|
@@ -46,8 +46,10 @@ Nothing.
|
|
|
46
46
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
|
47
47
|
- **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
|
|
48
48
|
- **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
|
|
49
|
-
-
|
|
50
|
-
|
|
49
|
+
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. `lib/robots.js` has the rest.
|
|
50
|
+
|
|
51
|
+
- **The last four keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
|
|
52
|
+
- **`redirects.file`, `redirects.json`, every `redirects.files[]`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved`/`rules` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map. `rules` is URL paths on both sides for the same reason.
|
|
51
53
|
- **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
|
|
52
54
|
|
|
53
55
|
## Types
|
package/AIKB/check.md
CHANGED
|
@@ -45,7 +45,7 @@ Nothing (`readReports`' return type refers to `./build-report.js`, but only as a
|
|
|
45
45
|
- **The asset rows exist because a page diff can have no cause in any page source.** Under `assets.hash` a stylesheet's filename _is_ the hash of its bytes, so one changed asset rewrites the `<link href>` of every page that references it: the diff reads `~ <every page>, = 0 unchanged`, and a reader who trusts it goes looking through page sources, models and controllers for a change that is not there. Naming the asset that moved turns that into a one-line read — and the rows go **above** the page list, because a cause printed after two hundred rows of consequence is reached only by the reader who already scrolled past the thing it explains. Verified in situ on a 20-page site with a 91-entry manifest before the order was settled. Both sides of the comparison were already on disk — `BuildReport.assets` is the manifest (`AIKB/build-report.md`), and `last-build.json` keeps it — so this needed no record-format change and no new build work. Three deliberate limits: only assets **both** builds emitted are rows (a new one has no previous name to have moved from, and is the asset copy working rather than a fact that explains anything); nothing prints when no page moved, because the rows explain a diff rather than being one; and the list caps at ten as a safety valve rather than a display limit — only `.css`/`.js` are hashable at all, and under `assets.version` nothing is renamed so every entry records `target === source` and no row can ever fire, which means a real site moves one or two and the cap only bounds a site that bundles per page. **The limitation worth knowing rather than fixing:** because it compares targets, an _unhashed_ asset whose bytes changed — an edited image — is not a row, since its `source` and `target` are both unchanged. That is the right trade: an unhashed asset is not referenced by a name that appears in every page's HTML, so it never produces the all-pages-changed symptom these rows exist to explain. Found by a consuming site whose whole-site diffs were being caused by its stylesheet — see the Tailwind notes in `AIKB/pipeline.md`.
|
|
46
46
|
- **A diff shape can now appear without the flag.** Under `--against` — or under `check` with a recorded baseline — stdout is `{ "reports": [...], "diff": [...] }` rather than the bare array. A consumer that parses stdout should read the wrapper when it is there; the bare array is what a site with no knowledge base and no flag still prints. `--summary` prints each site's diff under its own report line instead, which is why `diffReports` answers in `after` order: the bin pairs them by index.
|
|
47
47
|
- **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
|
|
48
|
-
- **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples
|
|
48
|
+
- **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples/9-migrated-from-v1 && node router.js`), so moving it would check a different set of paths from the ones it builds with.
|
|
49
49
|
- **The temp reports file is removed in a `finally`**, and the bin sets `process.exitCode` rather than calling `process.exit()`, so the cleanup runs before the process leaves.
|
|
50
50
|
|
|
51
51
|
## Types
|
package/AIKB/config.md
CHANGED
|
@@ -7,11 +7,12 @@ 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`), `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`), `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
|
-
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself),
|
|
14
|
+
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `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_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`).
|
|
15
16
|
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed, trailing slashes stripped.
|
|
16
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 a build folder that contains the source folder (all three below).
|
|
17
18
|
- `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
|
|
@@ -53,9 +54,17 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
53
54
|
|
|
54
55
|
The typedefs that describe the config to a consumer live at the top of this module, beside the defaults they describe: `KissFolders` / `KissFoldersInput`, `KissFetch`, `KissAssets` (whose `pipeline` refers to `lib/pipeline.js`'s `PipelineStep`, so the shape a site writes and the shape that is run cannot drift apart), `KissSettings`, and the two the public API is written in terms of — `KissConfig` (resolved: every key present) and `KissConfigInput` (what `new Kiss(config)` takes: every key optional). They are one JSDoc block, not a module of their own, so the shape and the resolver cannot drift apart.
|
|
55
56
|
|
|
57
|
+
- **`format: null` is the default and is not the same as `'none'`, though they emit the same files.** `null` means "never chose", and a build with aliases under it gets one notice naming the fix — because every version before this one always wrote `_redirects`, and losing a host's redirects in silence on upgrade is the failure the block exists to abolish. `'none'` and `[]` mean "chose the IR alone" and say nothing. The distinction lives in the config rather than in a hidden flag precisely so a site can express it.
|
|
58
|
+
|
|
59
|
+
- **`redirects.format` is validated, `markdown` is not, and the difference is the failure mode.** An unknown Remarkable option is a legitimate renderer option kiss has not heard of; an unknown redirect format is a typo that would write no host file, report success, and leave every old URL 404ing. `assertRedirectFormatIsValid` therefore refuses it with the offending value in the message, the way `cleanBuild` is refused — checking **every entry of a list**, so one typo in an array of four is named rather than silently dropped — while still carrying unknown _sibling_ keys through the one-level merge, so a later `statusCode` is an addition rather than a breaking change.
|
|
60
|
+
|
|
61
|
+
- **`links.trailingSlash` defaults to `true` because a default cannot be neutral here.** Two mainstream hosts serve directory indexes at contradictory URLs (measured; the matrix is in `AIKB/utils.md`), so the key is not a preference but a statement of fact about a deployment. `true` is 2.3's behaviour, and a1k9training's live sitemap returns 200 on all 18 entries under it — moving the default would have turned six of them into redirects on a site that was already correct.
|
|
62
|
+
|
|
56
63
|
- **`markdown` is a passthrough, not a schema.** The three keys in `DEFAULT_MARKDOWN` are the ones kiss has an opinion about, not the ones it accepts: `resolveConfig` merges the block one level deep and `lib/kiss.js` spreads the result straight into `new Remarkable(...)`, so `typographer`, `langPrefix` or any other Remarkable option reaches the renderer without kiss knowing the name. That is why the typedef is `KissMarkdown & Record<string, any>` rather than a closed shape — unlike `folders`, where a typo must be an error, an unknown key here is a legitimate renderer option.
|
|
57
64
|
- **`breaks: false` is a correction, not a preference.** Published v1 rendered with `breaks: false`; the v2 refactor that isolated Remarkable per instance flipped it to `true` incidentally, which puts a `<br />` at every wrap point of a hard-wrapped `.md` partial — prose broken mid-sentence at whatever column the author's editor used. Restored here, with `xhtmlOut: true` deliberately left at v2's value. `test/integration/markdown.test.js` pins both halves.
|
|
58
65
|
- **`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.
|
|
59
66
|
- **`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.
|
|
60
67
|
- **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.
|
|
61
68
|
- `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.
|
|
69
|
+
|
|
70
|
+
- **`folders.helpers` is neither derived from `src` nor ensured.** It defaults to `./helpers`, beside the build script, for the same reason `aikb` does: it is code the site author writes, not content derived from `src`. It is absent from `foldersToEnsure` because a site with no custom helpers should not acquire an empty folder on start-up — and its absence is the ordinary case, which `lib/site-helpers.js` handles silently rather than warning about. Because it is a _default_, `Kiss` also records whether the site supplied the key at all (before `resolveConfig` erases the difference): a folder kiss guessed that turns out to hold no registrar is a warning and the build continues, where one the author named is a build failure. See `AIKB/site-helpers.md`.
|
package/AIKB/design.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# design
|
|
2
|
+
|
|
3
|
+
Cross-cutting, like `testing.md`: not a module, but the things that are true of kiss as a whole and that
|
|
4
|
+
every module doc assumes. Read it when a decision feels like it is being argued from first principles for
|
|
5
|
+
the second time.
|
|
6
|
+
|
|
7
|
+
## Responsibility
|
|
8
|
+
|
|
9
|
+
Holds what kiss **is** and where it came from, so that design arguments resolve against a written position
|
|
10
|
+
rather than being re-derived. `CLAUDE.md` § Design philosophy states the position; this doc holds the
|
|
11
|
+
lineage and the worked consequences that are too long for it.
|
|
12
|
+
|
|
13
|
+
## Lineage
|
|
14
|
+
|
|
15
|
+
kiss descends from **SCMS ("simple CMS")**, a Ruby static CMS its author wrote while learning Ruby. kiss is
|
|
16
|
+
its version 2 in intent — "reasonably compatible and easy to upgrade" — which is why a real SCMS site was
|
|
17
|
+
the edge test that produced most of the notes in this folder.
|
|
18
|
+
|
|
19
|
+
SCMS's shape was a folder per page: `_pages/<slug>/` holding a `_config.yml` and one content file per layout
|
|
20
|
+
hole, with the **file extension choosing the renderer**. The unit of work and the unit of storage were the
|
|
21
|
+
same thing — to change the about page you opened `_pages/about/` and everything about that page was in front
|
|
22
|
+
of you.
|
|
23
|
+
|
|
24
|
+
kiss trades that for a single answer to "what pages does this site have": `router.js`. That is the right
|
|
25
|
+
trade for kiss's goal, because an agent's hardest problem is exactly that question. But it **is** a trade,
|
|
26
|
+
and naming what it cost stops it being re-litigated:
|
|
27
|
+
|
|
28
|
+
- **A page has no single home.** Changing one can touch `src/pages/`, `src/models/`, `src/controllers/`,
|
|
29
|
+
`src/partials/` and `router.js` — five directories, five tables.
|
|
30
|
+
- **A page cannot be half-deleted in SCMS; in kiss it can.** Delete a view and you leave a `.page()` call
|
|
31
|
+
that fails the build, a model nobody reads, and partials nobody renders. Failing loudly is better than the
|
|
32
|
+
alternative, but nothing enumerates the leftovers.
|
|
33
|
+
- **Both shapes are legible, to different people.** A folder named `about` containing `intro`, `leadin` and
|
|
34
|
+
`main` explains itself to anyone. `router.js` explains itself to a programmer.
|
|
35
|
+
- **kiss's cost lands on maintenance, SCMS's landed on comprehension.** A conversion exercises neither, which
|
|
36
|
+
is why a migration is a poor test of the thing it appears to be testing.
|
|
37
|
+
|
|
38
|
+
**The inherited idea kiss keeps** is that the extension states the processing — see `AIKB/partials.md` for how
|
|
39
|
+
`.hbs`/`.md`/`.html`/`.txt` carry that through.
|
|
40
|
+
|
|
41
|
+
## The organising principle
|
|
42
|
+
|
|
43
|
+
**Fail at build time and name the file.** It is the single idea most of `lib/` is an instance of:
|
|
44
|
+
`{{link}}` fails on an id no page claims and names the id and the view; two pages claiming one output path
|
|
45
|
+
fail and name the path; `assertBuildFolderIsSafe` throws and names both folders; a `.hbs` that cannot be read
|
|
46
|
+
fails that page rather than writing the filename into the output.
|
|
47
|
+
|
|
48
|
+
Stated as a rule for new work: **find every place kiss knows something is wrong and says nothing, and make it
|
|
49
|
+
say so.** A silent, deterministic, wrong result on a green build is the worst thing this codebase can
|
|
50
|
+
produce, and every defect a real conversion found in 2.4.0 was an instance of it — a sass compile silently
|
|
51
|
+
overwritten, a dev build written into the published folder, a watch rebuild that reported success and
|
|
52
|
+
served stale output, an AIKB lint noisy about something correct (the same failure inverted).
|
|
53
|
+
|
|
54
|
+
## The documentation rule
|
|
55
|
+
|
|
56
|
+
**Any behaviour whose consequence is a wrong artefact rather than an error states that consequence in the
|
|
57
|
+
same sentence as the rule.** This is not a style preference; it is the diagnosis of a measured failure. Of
|
|
58
|
+
ten problems a competent agent hit building a real site on 2.4.0 unaided, **eight were cases where the
|
|
59
|
+
documentation was correct and it got it wrong anyway** — because a reader retains the consequence and
|
|
60
|
+
forgets the rule.
|
|
61
|
+
|
|
62
|
+
STATED "in dev: true it degrades to true and logs one notice"
|
|
63
|
+
RETAINED nothing — and a preview build went into the published folder
|
|
64
|
+
NEEDED "...so pointing dev at your published folder leaves a preview build in it"
|
|
65
|
+
|
|
66
|
+
There are not many such behaviours. `cleanBuild: 'atomic'` in dev, non-dev minification, helper
|
|
67
|
+
registration, and the asset precedence in `AIKB/assets.md` are the set as it stands.
|
|
68
|
+
|
|
69
|
+
## Non-obvious behavior
|
|
70
|
+
|
|
71
|
+
- **What ships is an example.** `examples/` is in the `files` whitelist so an agent in a consuming project can
|
|
72
|
+
read it, which means every example is copied as a claim about good practice. Each one is therefore a
|
|
73
|
+
standalone project that builds unmodified when copied out — the import is `'kiss-ssg'` (resolved in-repo by
|
|
74
|
+
package self-reference), the output is its own `public/`, and nine of eleven configure no `folders` at all,
|
|
75
|
+
because the defaults are the shape. That last fact is the philosophy's best demonstration and the reason
|
|
76
|
+
the restructure was worth its diff.
|
|
77
|
+
- **Skills do not ship, and cannot be assumed.** `plugins/` is not in the `files` whitelist. A real conversion
|
|
78
|
+
was completed without them because nothing in the installed package revealed they existed. So anything an
|
|
79
|
+
unaided agent needs in order to build correctly belongs in `llms.txt`, which ships; a skill may hold a
|
|
80
|
+
procedure the operator opts into, never a fact the build depends on.
|
|
81
|
+
- **The steer is on the shape of the code, not the shape of the content.** `{{link}}` punishes a hand-written
|
|
82
|
+
URL; `folders.models` existing makes "this repeated markup is data" the path of least resistance. Nothing
|
|
83
|
+
resists collapsing a site's editable content into templates — measured: a conversion turned 39 separately
|
|
84
|
+
editable content files into 11 templates with 2 non-template editing surfaces, irreversibly, and neither
|
|
85
|
+
the engine nor the output diff could see it. For a tool descended from a CMS that is the open gap, recorded
|
|
86
|
+
here rather than solved.
|
package/AIKB/feed.md
CHANGED
|
@@ -28,5 +28,5 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
|
|
|
28
28
|
- **The `url` is the sitemap's `<loc>`, character for character** — a home page (`https://e.com/`), a file page (`/about`) and a directory index (`/courses/`) alike, because the derivation is the same two `utils` functions. An RSS `<guid isPermaLink="true">` that disagreed with the sitemap would be a second identity for one page, and a feed reader would show the post twice after a rename.
|
|
29
29
|
- **Escaping is one function for text and attributes.** `&` is replaced first (or it would re-escape the entities that follow it) and both quote forms go, so the same function is correct inside `<title>` and inside `atom:link`'s `href`. `lib/sitemap.js` escapes nothing at all, which is a standing bug there rather than a precedent here.
|
|
30
30
|
- `writeFeed` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.feed()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap` and `writeLlms`.
|
|
31
|
-
- **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt` and
|
|
31
|
+
- **`writeFeed` refuses a `filename` that resolves outside the build folder** — `Refusing to write outside the build folder: <path>`, thrown before the `overwrite` check so it reaches `Kiss.feed()`'s `.catch()` like any other failed write. It is the same `path.resolve` containment check `KissPage.generate()` makes (`AIKB/kiss-page.md`), and the feed is the only one of the auxiliary files whose name the author chooses (`sitemap.xml`, `llms.txt`, `redirects.json` and the redirect host file are fixed), so it is the only one that needs it: under `'atomic'` and under check `folders.build` is a staging sibling that was promised to be the only thing written, and `filename: '../x'` would have written beside it. `oneLine` collapses whitespace in the name; it does not touch `..`.
|
|
32
32
|
- `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing feed left to skip.
|
|
@@ -15,7 +15,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
15
15
|
- `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href.
|
|
16
16
|
- `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
|
|
17
17
|
- `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
|
|
18
|
-
- `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A
|
|
18
|
+
- `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A reference that is not a path into this build is returned unchanged: any scheme (`https:`, and also `data:`/`mailto:`, which carry no `//`) and the protocol-relative `//cdn/x.css`. The original test required `://`, so the last two fell through to a manifest lookup they could never satisfy — harmless while the helper warned, a failed build once it threw. A `?query`/`#fragment` is split off before the lookup and re-appended after, because the manifest is keyed by the path a copy emitted: `img/logo.svg#symbol` is an SVG sprite reference to a file that IS in the build, and looking the whole string up failed the build while telling the author the file was missing. `lib/links.js` splits references the same way, for the same reason. The failure message names the reference **as the template wrote it**, and the key underneath it when the two differ (`asset: '/css/x.css#a' … no .copyAssets() emitted 'css/x.css'`, `emitted it` when they are the same string); it named the key alone until an independent review pointed out that the key — slash stripped, suffix gone — is a string the author never typed and cannot search for, while the dev warning had always named the reference. A path the manifest does not know **fails the build**, the same shape as `link` on an id no page claims — they are the two halves of "never hand-write an internal URL" and were checked to different depths until R25. Warning and rendering meant the build passed, `report().ok` stayed true and the site shipped a 404; it also stripped the leading slash first, so `{{asset "/missing.css"}}` became `missing.css`, which on a nested page is a different URL. In **dev** it warns once per page per path (deduped on the page's root data frame, so a shared layout does not warn per page render) and renders the path **as written** rather than the stripped form, so the browser's 404 names what the template asked for. A non-string path logs a warning and renders `''`.
|
|
19
19
|
- `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/blog/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form.
|
|
20
20
|
- `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
|
|
21
21
|
|
|
@@ -29,7 +29,8 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
29
29
|
|
|
30
30
|
## Non-obvious behavior
|
|
31
31
|
|
|
32
|
-
- `isActive`'s block context is **the surrounding context, then the hash, then the helper's own keys** (`href`, `folderMatch`, `active`, `pageURL`). It was the hash alone, which meant the natural data-driven nav — `{{#each nav}}{{#isActive
|
|
32
|
+
- `isActive`'s block context is **the surrounding context, then the hash, then the helper's own keys** (`href`, `folderMatch`, `active`, `pageURL`). It was the hash alone, which meant the natural data-driven nav — `{{#each nav}}{{#isActive .. href=href}}{{label}}{{/isActive}}{{/each}}` — rendered an empty label for every item on a green build, and nothing could warn: the helper cannot tell a key the caller forgot from one it never wanted. Two independent agents building a fresh site hit it within a day of each other; one shipped a nav of blank links and found it only by reading the rendered HTML. Adding the parent underneath is additive — anything that already passed what it needed is unaffected, because the hash still wins. Covered by `test/unit/handlebars-helpers.test.js`.
|
|
33
|
+
- **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.
|
|
33
34
|
|
|
34
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.
|
|
35
36
|
- 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).
|
|
@@ -42,16 +43,16 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
42
43
|
- The prototype guard is **delegated, never reimplemented**: the value comes from `options.lookupProperty(obj, field)`, the container function Handlebars passes into every helper, which is what refuses `__proto__`/`constructor` access. A raw `obj[field]` here would quietly reopen that hole, so the unit test asserts kiss's output against a plain Handlebars instance's for those keys rather than against a literal.
|
|
43
44
|
- The warning is deduplicated **per page per key** through a `WeakMap` keyed on `options.data.root` — the page's own options object, a fresh one per page. A partial used twice on a page logs once; a `.pages()` fan-out of 100 pages missing the same key logs 100 lines, one each, which is the point (each names its own page). No dedupe is applied when the template was compiled without `data`.
|
|
44
45
|
- `canonical` and `absUrl` do **not** own their normalisation: both go through `toAbsoluteUrl` in `utils.js`, which `lib/sitemap.js` also uses to build every `<loc>`. That is the point of the pair — a page's `<link rel="canonical">` and its own sitemap entry are produced by one function, so they cannot drift apart (review gap O5). `canonical` additionally reduces the page URL with `toCanonicalPath`, which strips the `.html` and leaves the `index` segment for `toAbsoluteUrl` to turn into a trailing slash; the sitemap does exactly the same, hence the agreement, and an integration test builds a site both ways round (`extensionLess` on and off) and asserts the two lists are equal.
|
|
45
|
-
- **A directory index canonicalises with a trailing slash.** `courses/index.html` is served at `/courses
|
|
46
|
+
- **A directory index canonicalises with a trailing slash by default, and `config.links.trailingSlash` is what says otherwise.** `courses/index.html` is served at `/courses/` on Netlify, which answers the bare `/courses` with a 301, and a canonical must be the URL that returns 200; Firebase with `trailingSlash: false` is the exact reverse (the measured matrix is in `AIKB/utils.md`). `canonical`, `absUrl` and `link` all read the policy through one `trailingSlashPolicy()` closure, at call time rather than at registration, because a watch replay rebuilds the config object — so `{{canonical}}` on that page is `https://site/courses/`, and `{{absUrl '/courses/'}}` now keeps the slash it was given rather than trimming it. It used to canonicalise through `toURLKey`, which drops the `index` segment outright and produced the redirecting URL; do not put that back. Under `extensionLess: true` every page but the home page builds to `<path>/<slug>/index.html`, so **every** page but the home page is a directory index and its canonical ends in `/` (`courses/bronze` → `https://site/courses/bronze/`) — correct, because that is the URL the host serves, and the one place where the two `extensionLess` settings deliberately emit different URLs.
|
|
46
47
|
- **`absUrl` keeps a file extension, `canonical` does not.** `toCanonicalPath` would turn `css/site.css` into `css/site`, so `absUrl` passes its path to `toAbsoluteUrl` directly — that strips a leading slash, collapses repeated slashes and turns a trailing `index` segment into a trailing one, and otherwise leaves the path alone. A page URL is a page (its extension is a build detail); an arbitrary path is a path.
|
|
47
48
|
- The `siteUrl` read is the **page's**, `root.config.siteUrl`, falling back to the instance config only when the template was compiled without a page context. `Kiss.page()` gives every page its own shallow copy of the config, so a page built for another domain (`.page({ view, config: { siteUrl } })`) gets a canonical URL on that domain. `sitemap.js` still reads the instance `siteUrl` for every `<loc>`, so such a page is the one case where the two differ — deliberately: it is the page that has been pointed elsewhere.
|
|
48
49
|
- No `siteUrl` **degrades, like every other helper**: one warning and an empty string, never a throw and never a guessed domain. The warning is deduplicated per page through a `WeakSet` keyed on `options.data.root` (the `lookup` pattern), so a layout with a canonical link and a dozen `absUrl` links logs one line for that page, not thirteen. A missing `pageURL` warns separately and also renders nothing: the same canonical URL on every page is worse for a search engine than no canonical link at all.
|
|
49
50
|
- **`asset` reads a manifest, it does not look at disk.** The manifest is filled by the one `copyAssets` run the constructor queues, and `generate()`/`complete()` await `_promises` — which that run is on — before any page renders, so the helper always sees a filled manifest. A watch-mode asset edit refills it and, under a hashing policy, `Kiss` re-renders the stack so the new filename reaches the pages (`AIKB/kiss.md`).
|
|
50
51
|
- **The helper returns no leading slash, deliberately.** A root-relative URL is one form of link, not the only one: the examples' shared layout climbs back to the build root with `{{root}}` so a page opens straight off the file system, and a site on a sub-path needs its own prefix. `/{{asset 'css/site.css'}}`, `{{root}}{{asset 'css/site.css'}}` and `{{absUrl (asset 'css/site.css')}}` are the three forms, and the helper stays out of that decision.
|
|
51
|
-
-
|
|
52
|
+
- **`asset` returns a `SafeString` exactly when the URL it rendered carries a `=`.** Handlebars escapes `=` in a `{{...}}` expression, so a plain string comes out of the template as `?v=1.2.3`. Two branches produce one: `assets.version`'s own `?v=…`, and an author's own `?query` suffix riding on the emitted name (`{{asset "css/site.css?v=2"}}`). Both escape with `hbs.Utils.escapeExpression` and then put `=` — and only `=` — back (`unescapeEquals`), so the escaping is exactly what Handlebars would have done and the one character that makes a URL unrecognisable to the author who wrote it survives; `=` cannot terminate an attribute value, quoted or not. A name with no suffix under no policy is returned as a plain string and escaped normally. **The pass-through branch is deliberately not in this set**: a `https://`, `//host/…` or `data:` reference can have arrived from a model, so it keeps full escaping and a browser decodes the `=` itself.
|
|
52
53
|
- **`absUrl` unwraps and re-wraps a `SafeString` argument** so `{{absUrl (asset …)}}` composes under every policy. Without it the version mode's `SafeString` would fail `absUrl`'s "needs a path string" guard and render nothing, and re-escaping the result would put `=` back. A path that already carries a scheme is returned exactly as it arrived, wrapper included.
|
|
53
54
|
- **`asset` reads the instance config, not the page's** — unlike `canonical`/`absUrl`, which read `root.config.siteUrl`. The policy was applied once, by the copy that emitted the files, before any page rendered; a per-page override could only disagree with what is on disk.
|
|
54
|
-
-
|
|
55
|
+
- **`asset` no longer degrades, and that was a deliberate reversal.** It used to reason that an unknown path is the author's own build, so it rendered what they asked for and named the path once per page. The consequence was a build that passed with a 404 in it — `report().ok` true, `kiss-ssg check` ok, the stylesheet absent — which is the verdict-versus-reality gap the Sass work closed twice elsewhere on this branch, and kiss's own documented bar says ok and exit 0 are the only passing result. It now fails the build outside dev, like `link`. **Dev keeps the degrade**, and keeps the once-per-page dedupe (the `WeakMap`-of-`Set`s pattern), because the file you are about to add legitimately is not there yet — but it renders the path as written rather than stripped, so the browser's 404 names what the template asked for.
|
|
55
56
|
|
|
56
57
|
- **`link` is the only helper that throws, and only in a real build.** Every other helper degrades because a template mistake is a worse reason to lose a page than a missing highlight. An id is different in kind: a path is a claim about a host the engine cannot see (which is why a broken one is an advisory finding — `AIKB/links.md`), while an id is a claim about _this build's registry_, checkable exactly at render. A link that cannot be resolved is therefore a build failure — recorded like any other page failure, with no bytes written, `hash: null`, and `complete()` rejecting — except under `config.dev`, where it takes the `{{asset}}` stance: a warning and a `#`, so the live reload shows the page and the mistake together.
|
|
57
58
|
- **The registry arrives as a function, and that is load-bearing.** `registerHandlebarsHelpers` is called once, in the `Kiss` constructor, and is never re-run — not even by `_replay()`, which replaces `_stack` with a brand-new array. A helper closing over the array, or over an index built at registration, would serve the first build's registry to every watch rebuild for the life of the session. `Kiss` therefore passes `lookupPage: (id) => this._lookupPage(id)`, and the index behind it is memoised against the live stack and invalidated whenever a page is pushed (`AIKB/kiss.md`).
|
package/AIKB/kiss.md
CHANGED
|
@@ -8,7 +8,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
8
8
|
|
|
9
9
|
- `class Kiss` (default export), also re-exported as `export { Kiss as 'module.exports' }` so `require('kiss-ssg')` on Node ≥22.12 returns the class, not `{ default: Kiss }`.
|
|
10
10
|
- `new Kiss(config)` — resolves config, starts loading the HTML minifier when not in dev mode (`preloadMinifier()`, unawaited — see the bullet below), sets up Handlebars/Remarkable and the instance's asset manifest, ensures folders, creates the asset pipeline and queues its steps ahead of the asset copy, queues that copy, registers helpers and partials, and (in dev mode) starts the dev server and watcher. A dev server that cannot bind is a **build failure** — see the `<dev server>` bullet below. `_setupFolders()` is also where `cleanBuild` is applied: `true` empties `folders.build` here, `'atomic'` redirects it to a staging sibling — see the staged-build bullets below.
|
|
11
|
-
- `.copyAssets(sourceDir, targetDir)` — puts `targetDir` through `_stagedPath()` (a staged build's copies have to follow it into staging) and chains `copyAssets()` onto `_assetQueue` (passing `_assetManifest`, so every copy records into the one manifest the `asset` helper reads) (a `Promise` that serializes calls in registration order) and pushes the chained promise onto `_promises`; returns `this`. Sequential rather than concurrent because one copy's target can be a subdirectory of another's source (e.g. a consumer copying into its own assets folder), and concurrent `fs.copy` walks racing there produce `ENOENT` failures. `_assetQueue` is reset to `Promise.resolve()` alongside `_promises`/`_generating
|
|
11
|
+
- `.copyAssets(sourceDir, targetDir)` — puts `targetDir` through `_stagedPath()` (a staged build's copies have to follow it into staging) and chains `copyAssets()` onto `_assetQueue` (passing `_assetManifest`, so every copy records into the one manifest the `asset` helper reads) (a `Promise` that serializes calls in registration order) and pushes the chained promise onto `_promises`; returns `this`. Sequential rather than concurrent because one copy's target can be a subdirectory of another's source (e.g. a consumer copying into its own assets folder), and concurrent `fs.copy` walks racing there produce `ENOENT` failures. `_assetQueue` is reset to `Promise.resolve()` alongside `_promises`/`_generating` (and the `_failuresReported` latch) in `_replay()`, which sweeps `_failures` rather than emptying it (`carriedAcrossReplay`, below). Each copy also refreshes its own Sass failures on `_failures`, and **owns them by object identity** through `_sassFailures`, a `Map` keyed on the copy's resolved `(source, target)` pair. That key was first a prefix of the view string and the string was the wrong place for it: `src/assets` is a prefix of `src/assets/nested`, so a parent root's copy cleared a nested root's unresolved failure — and the parent then compiled that same file successfully to a different target, so nothing raised it again. Measured: a nested stylesheet that could not be written at all, on a build reporting `ok: true`. Resolving both paths also takes the working directory out of it, so two roots that are `src/assets` under different cwds stay distinct. The label in the view is display only. A copy clears exactly the set it recorded last time before recording this time, which is what lets a DELETED stylesheet lose its entry — it is absent from the new results, so a per-file clear could never reach it.
|
|
12
12
|
- `.registerPartials()` — re-runs `registerPartials()` against the current Handlebars env, passing the names the previous pass registered so any that have since left disk are unregistered; stores and returns the new set.
|
|
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.
|
|
@@ -22,7 +22,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
|
|
|
22
22
|
- There is **no** `.aikb()` method, and deliberately so: the site's knowledge base is written by `npx kiss-ssg aikb <script>`, which sets `KISS_AIKB` for the build it spawns. `_finishBuild()` reads that variable, and no chainable call turns it on (see `_buildAikb()` below and `AIKB/aikb.md`).
|
|
23
23
|
- `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
|
|
24
24
|
- `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and `copyAssets()` + a live reload (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is below — and every rebuild it asks for goes through the serial queue.
|
|
25
|
-
- `.report()` — the last settled build as data (`AIKB/build-report.md`), or `null` before the first `complete()` settles. The same object is on the rejection as `err.report
|
|
25
|
+
- `.report()` — the last **settled** build as data (`AIKB/build-report.md`), or `null` before the first `complete()` settles. The same object is on the rejection as `err.report`. A whole-site replay replaces it with its own, and anything that changes the failure list **without** settling a build refreshes it in place through `_refreshReport()`. That refresh exists because the verdict used to lag the log: a watch asset save that broke a stylesheet, or a helpers reload that failed, put the failure on `_failures` and printed it in red while `report().ok` went on saying `true` until the next replay — measured, and the one thing a consumer reads to decide whether the build is good. A scoped page re-render records them too, as of the operator's call that a page failing under `.watch()` should be reported like any other failure: `_rebuild` used to catch each page's rejection and drop it, which made it the ONE path that put nothing on the list at all — loud in the console, absent from `_failures` and from `report()`. It drops the entry's previous failure before re-rendering, so a fixed page clears; ownership is a `WeakMap` keyed on the stack ENTRY rather than on `view`, because an inline template's `view` is the template text and two pages can share one. The accepted cost is that a transient mid-edit render error moves `ok` to `false` until the next save. **The refresh re-derives the report and nothing else**: `_finishBuild()` carries once-per-build side effects — the `KISS_REPORT` line (one per build, not per save), the `last-build.json` record, `dependency-graph.json` — and re-running it per keystroke would trade a stale verdict for a corrupted record. Both call sites read one `_reportInputs()`, because two literals assembling the same report is the drift shape this branch has been bitten by repeatedly. What a refresh does not move is the metadata of the build it describes (`duration`, `startedAt`): it is the last settled build with a current verdict, not a new build. The aikb verdict and the redirect findings are reused from that settle — neither an asset copy nor a helpers reload can change a map of pages and partials or a set of page aliases.
|
|
26
26
|
- `.close()` — sets `_closing`, closes the watcher, drains the rebuild queue, closes the dev server, ends every pipeline watch process, and removes an un-promoted staging folder; a closed instance stays closed.
|
|
27
27
|
- `export { utils }` — re-exports `lib/utils.js`'s default export.
|
|
28
28
|
|
|
@@ -51,17 +51,25 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
51
51
|
- **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.
|
|
52
52
|
- **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.
|
|
53
53
|
- **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`.
|
|
54
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
- **`_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.
|
|
57
|
+
|
|
58
|
+
- **`.robots()` reads `_sitemapRequest` inside its queued `.then()`, not at call time.** That is what makes `.robots().sitemap()` and `.sitemap().robots()` produce the same file: both requests are recorded synchronously while the chain is being written, and the check runs after `Promise.all(this._promises)` has drained. Reading it at call time would make the `Sitemap:` line depend on the order a site happened to chain two methods in, which is not a thing an author should have to know. `_robotsRequest` is re-issued by `_replay()` beside the other three standing requests; `_robotsResult` resets with the rest, because it is a result rather than a request.
|
|
59
|
+
|
|
54
60
|
- **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.
|
|
55
61
|
- **After a promotion the instance is an ordinary one.** `_promote()` points `config.folders.build` back at the real folder 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 `folders.build`: 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.
|
|
56
62
|
- **`'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).
|
|
57
|
-
- **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`, `_redirectsResult`, and `_startedAt`) reset in `_replay()`
|
|
63
|
+
- **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`.
|
|
58
64
|
- **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.
|
|
59
65
|
- **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.
|
|
60
66
|
- **The link scan runs in `complete()`'s settle path, above the branch — not in `_finishBuild()`.** `_checkLinks()` is the only consumer of `lib/links.js` here, and where it is called is the whole design. By the time `_finishBuild()` runs, two of `complete()`'s three branches have already called `_discardStaging()` (check mode, and any failed build) and the third has already called `_promote()` — so a scan that resolved against `config.folders.build` there would have found an empty or renamed folder and reported `checked: 0, broken: []` in silence, under exactly the command the finding exists to serve. Called before the branch instead, the written pages are still where they were written, whichever way the build is about to settle; one call site dominating all three arms is also what keeps a fourth from quietly forgetting it. It needs no ordering against `sitemap.xml`, `llms.txt` or a feed: those are written on the promise queue, which `_settle()` has already drained. The references themselves were extracted at **write** time (`KissPage.links`, `AIKB/kiss-page.md`), so nothing is read back from disk; the filesystem is consulted only for the rest of the build folder — assets, those generated files, anything a pipeline step wrote straight into it. The scan is skipped entirely when `config.links.check` is false or `config.dev` is on (and therefore on every watch rebuild: a scoped re-render has not rewritten every page, so a scan would be describing a half-built site), leaving `_links` `null` — which the report distinguishes from "it ran and found nothing". The result is latched on `_links` for the reason `_report` is latched: the nested-`complete()` pattern settles one build twice, and the second pass would be scanning a folder that is no longer there. `buildDir` is `config.folders.build` **as it is at that moment** — the staging sibling under `'atomic'` and under check — and the pages are recorded as the staging paths they were written to, which is why `buildReport` maps `broken[].page` back through `reportedPath` and the report names the folder the site asked for. Pages are taken from `_stack` entries whose `page.hash !== null`: that is the "these bytes are on disk" promise, and `generate !== false` is not (a page can also have failed to render). A build that failed is scanned too — its surviving pages are on disk and their dead links are real — and the finding moves neither `ok` nor the exit code. One `notice` per broken link, in `formatReport`'s own wording, so the build log and `check --summary` say the same thing; one `info` line when there is nothing to report.
|
|
61
|
-
-
|
|
67
|
+
- **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.
|
|
62
68
|
- **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.
|
|
63
69
|
- **A staged build's `folders.build` is genuinely the staging path while it runs**, so a template rendering `{{config.folders.build}}` (or a controller reading it) sees the staging name, not the target. Nothing in the engine surfaces it — `{{asset}}` renders build-relative paths and `{{canonical}}`/`sitemap.xml` are built from `siteUrl` — but a site that prints the raw folder into its output should not use `'atomic'` for that page.
|
|
64
|
-
- Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N
|
|
70
|
+
- Build failures are collected, not thrown at the page: `KissPage.generate()` rethrows a render/minify/write error, `generate()` catches it per page into `_failures` (`{ view, buildTo, error }`) so the remaining pages still build, and `complete()` rejects with an `AggregateError` (`err.failures` = that array, message `"N build failure(s): <buildTo paths>"` — `buildTo`, not `view`, because `.pages()` fan-out gives every failed entry the same `view` and a message built from `view` would repeat it; `err.failures[]` still carries `view` per entry). Entries with no output path — a controller failure, or a callback failure — fall back to `view` in that message, and so does any `<pseudo-view>` even when it _has_ a `buildTo`. **The count noun used to be "page(s)", and that was a lie for every pseudo-view on `_failures`** — `<site helpers>`, `<pipeline>`, `<redirects>`, `<dev server>`, `<sass: <source folder>/<file>>`.
|
|
71
|
+
- **A Sass failure is identified by the COPY it came from**, not by the file's name within it: `<sass: src/assets/css/theme.scss>`, project-relative when the source folder is inside the project and absolute when it is not. Two asset roots can each hold `css/theme.scss`, and identifying a failure by the source-relative name alone meant a second `.copyAssets()` whose own copy compiled **cleared the first root's failure** — a green verdict with the primary stylesheet missing. Each copy clears by that prefix rather than by the files it just returned, which is also what lets a **deleted or renamed** broken stylesheet lose its entry: it is absent from the new results, so a per-file clear could never reach it, and `_failures` is otherwise cleared only by `_replay()`, which an asset re-copy is not. A helpers module reported as `1 page(s) failed to build: .../helpers/index.js` sends the author to look at their pages; the label is the useful half, and the path is already in the error logged above it. `generate()`'s own callback still fires either way — callbacks are not the render error channel; `complete()` is. `complete()` does not invoke its callback on failure (same rule as `sitemap()`'s callback).
|
|
72
|
+
- **A replay sweeps `_failures`, it does not empty it** (`carriedAcrossReplay`). A whole-site replay re-runs every page, every controller, every callback and the redirects, so clearing those and letting the rebuild say them again is right. It does not re-import the helpers entry, it does not restart the dev server, and it recompiles the stylesheets only when an `assets.pipeline` step is configured — which the ordinary site has not. Emptying the list dropped all three, and each was still true: a site whose `site.scss` would not compile reported `ok` on the next controller save with nothing fixed, no CSS in the build, and the red compile line already scrolled past. So `<sass: …>`, `<site helpers>` and `<dev server>` are carried across a replay and everything else is dropped. Carrying cannot strand a fixed one, because each is cleared by its own producer when that producer runs again (`copyAssets` by the copy that owns it, `_loadHelpers` by name) and something has to run that producer again. **That last clause is where the first version of this was wrong**: it was true only of `config.folders.assets`, because the replay's pipeline branch and the watcher's `assetsChanged` both re-run THAT copy and no other. A root the site registered itself with `kiss.copyAssets(other, dest)` was never re-run, so its failure could not be cleared by anything — fixed on disk, full replay, still failed, for the rest of the session, on a build whose files are all correct. The mirror of the bug the carrying fixed, and the worse half to leave: an author who fixes a file and watches the build stay red stops believing the verdict. So a replay now also re-runs **the copies it has something to re-check** — the ones still holding an unresolved failure, looked up through `_assetCopies` (every copy this instance was asked for, keyed the same way, holding the caller's UNSTAGED target so a replay re-applies staging). A healthy root is not re-copied: a model or controller edit cannot have changed it, and copying every asset root on every keystroke is the cost that guard exists to avoid. Note the standing limit this works around rather than removes — **only `folders.assets` is watched**, so an edit under a second root raises no event and is picked up by the next whole-site replay rather than by itself. `<dev server>` has no second attempt at all, which is exactly why dropping it was wrong. **Carriage is recorded, not matched.** The first version tested `view.startsWith('<sass: ')`, and `view` is only an engine sentinel for engine failures: for an inline template it is the TEMPLATE TEXT, which the author writes, so a page registered as `view: '<sass: x> {{custom "y"}}'` recorded a failure no replay would ever drop — permanent, while the page rendered perfectly. The producers now mark their own on `_carriedFailures`, a `WeakSet` of the failure objects: decided at the one place that knows, needing no pruning, and leaving `_failures` entries exactly the `{ view, buildTo, error }` shape `err.failures` hands out. The copy key canonicalises **the deepest existing ancestor** and re-appends the rest, for the same reason in the other direction — `resolve` preserves the spelling, and on a case-insensitive filesystem `./src/assets` and `./src/Assets` are one directory and were two owners, so the owning copy could not clear its own failure. A plain `realpath` with a `resolve` fallback looks equivalent and is not: it throws ENOENT for a target the copy is about to create, falls back to the spelling, and then returns the canonical spelling once the folder exists — one copy, two keys, and the same unclearable failure by a new route (measured with a target inside a symlinked parent). Walking to the deepest existing ancestor makes the key **stable across the folder's creation** for a path whose ancestor exists — which is most of them, and not the leaf itself: two spellings of a destination that does not exist AT ALL yet (the ordinary case on a fresh build) each keep their own tail, and on a case-insensitive filesystem that is one directory under two keys. So the key is computed **after the copy has run**, when the target is there and both spellings resolve to it. Keying there also puts the registration into queue order, written by the run that owns it rather than synchronously by a caller whose copy has not happened yet — which is what let a copy that SUCCEEDED evict a registration a later queued copy still needed. The paths are **resolved at registration** — in the caller's working directory, at the moment they asked — and it is those the copy runs against, not the caller's raw arguments re-resolved when the queue drains. Both legs had the bug: the replayed copy (measured first) and the INITIAL one, where anything moving the working directory between the call and the drain copied a different folder, wrote it somewhere else, and recorded the result under the original's key. `copyAssets` takes a `display` pair so the id and the log line stay the strings the author wrote, and the `<sass: …>` **label is decided at registration too**. It was derived at execution time from the caller's raw argument against whatever the working directory was by then, so one copy's one failure came out relative or absolute depending on where the process happened to be when the queue drained — and a relative label re-read from a different directory resolves to a DIFFERENT file, which may exist and may compile perfectly, sending an author to a stylesheet with nothing wrong with it. It rides on `err.failures`, `report().failures` and the KISS_REPORT line, so it outlives the process that made it — resolving for correctness must not turn every build's asset line into an absolute path nobody typed. Both maps are pruned together when a copy records nothing — a replay re-runs a copy only to re-check a failure, so a copy with none needs no registration. A copy that records nothing deletes its map entry rather than storing an empty set, so a caller copying into a fresh destination each run does not accumulate one entry per success. `<pipeline…>` is deliberately not carried: it exists only on a site with configured steps, and that is the site whose replay re-runs them.
|
|
65
73
|
- **`complete()` reports a build's failures once** (`_failuresReported`, reset with `_failures` in `_replay()`): the first call with a non-empty `_failures` rejects, every later call in the same build resolves with the data instead. It used to re-reject, and the documented "`complete()` from inside a `generate` callback" pattern (below) has two calls draining the same build concurrently — the loser's rejection had no handler attached to it and killed the process after the consumer had already caught and reported the winner's (review finding A-01). The outer call wins in practice: its `_drain()` registered its reactions first, so it stays one microtask ahead of a call started from inside the callback.
|
|
66
74
|
- **A `generate()`/`sitemap()` callback that throws is a build failure**, recorded by `_runCallback` as `{ view: '<generate callback>' | '<sitemap callback>', buildTo: null, error }` — not swallowed by the enclosing `.catch()`, which would log it as `Error generating site` or, worse, `Error creating sitemap.xml` for a sitemap that had in fact been written correctly (review finding A-04). The two catches are now distinct: the enclosing one covers the engine's own work, `_runCallback` covers the consumer's. The callback's return value is still not awaited by the `generate()` run itself (see the `complete()`-inside-a-callback bullet below), so a promise it returns gets its own `.catch` — but that caught promise is now recorded on `_callbacks`, which `_drain()` waits for (review finding A-03). So an async callback that rejects is reported by the `complete()` that was already draining, not deferred to a later one; the only rejection still reported late is one from a callback registered after every `complete()` has finished.
|
|
67
75
|
- **A dev server that cannot bind is a build failure**, recorded as `{ view: '<dev server>', buildTo: null, error }` — the same shape as a callback failure, since there is no output path to name it by (review findings F-E2/F-E3). It used to be non-fatal and mislabelled: the constructor's `ready.catch` logged `Error running live reload server` for the _static_ server, a second copy of the same fault had already been logged inside `lib/dev-server.js`, and the build then ran to completion with the process left up serving nothing behind an already-printed `Serving …` line. Now `dev-server.js` logs nothing for a bind failure and `ready` carries the whole message; the constructor logs it once, records the failure so `complete()` rejects, and then `await this.close()`s — unlike live reload, which is optional, there is nothing useful to do without the HTTP server, and the watcher plus the (successfully bound) livereload server would otherwise hold the process open long after the consumer's rejection handler had run. The `ready.catch` promise is pushed onto `_generating` so the drain waits for the bind result: a small site would otherwise race the `'error'` event and `complete()` could resolve before the failure was recorded. A synchronous throw out of `startDevServer` takes the same path, and `watch()` is never reached in that case.
|
|
@@ -110,18 +118,23 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
|
|
|
110
118
|
|
|
111
119
|
The rebuild reload sits in the queue's `.finally()`, which is **after** a replay's orphan sweep, so the browser can never fetch a page that is still being written or one the sweep is about to remove. The initial one exists because a browser left open across a restart would otherwise sit on the previous build's output until the next edit. `_builtAssetPath(changed)` maps an edited asset to where it lands in the build folder (with `.scss`/`.sass` → `.css`, since that is what sass wrote): naming the built file is what lets livereload swap a stylesheet in place instead of reloading the page, which is why the assets watcher forwards the changed path at all. It returns `'/'` when there is no assets or build folder — nothing was copied, so there is nothing to name.
|
|
112
120
|
|
|
113
|
-
- **The watch dispatch lives here, not in the watcher** (`_handleChange(event, path)`), because deciding needs the stack, the partials registration and the rebuild queue — all of which are `Kiss` state. The watcher forwards every event under `src` and this is the whole table:
|
|
121
|
+
- **The watch dispatch lives here, not in the watcher** (`_handleChange(event, path)`), because deciding needs the stack, the partials registration and the rebuild queue — all of which are `Kiss` state. The watcher forwards every event under `src` and this is the whole table: **Every folder test in it resolves both sides**, including the page test, which was a raw `changed.startsWith(pagesDir + '/')` — a comparison of two path SPELLINGS. `folders` derives the six folders from `src`, so they normally share a form and it worked; but either can be given explicitly, and a site naming `src` relatively and `pages` absolutely made that test false for every page edit. The consequence was the safe direction and therefore invisible — the view matched no stack entry and the edit fell through to the replay fallback — so a scoped re-render of one page silently became a whole-site rebuild, which is the difference the benchmark work exists to protect. The view name is now `path.relative(pages, changed)` rather than a slice by string length, for the same reason: two spellings can be the same place.
|
|
114
122
|
|
|
115
|
-
| Event | Action
|
|
116
|
-
| --------------------------------------------------------- |
|
|
117
|
-
| `add` / `addDir` (after the watcher's own `ready`) | `_requestReplay()`
|
|
118
|
-
| `unlink` under `folders.pages` | `_requestReplay()`
|
|
119
|
-
| `change` under `folders.partials` / `folders.layouts` | `registerPartials()` + `_requestRebuild(_graph.dependentsOf(name))`
|
|
120
|
-
| `change` of a page view matching stack entries | `_requestRebuild(matches)`
|
|
121
|
-
| anything else (model, controller, unknown file, no match) | `_requestReplay()`
|
|
123
|
+
| Event | Action |
|
|
124
|
+
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
125
|
+
| `add` / `addDir` (after the watcher's own `ready`) | `_requestReplay()` |
|
|
126
|
+
| `unlink` under `folders.pages` | `_requestReplay()` |
|
|
127
|
+
| `change` under `folders.partials` / `folders.layouts` | `registerPartials()` + `_requestRebuild(_graph.dependentsOf(name))` |
|
|
128
|
+
| `change` of a page view matching stack entries | `_requestRebuild(matches)` |
|
|
129
|
+
| anything else (model, controller, unknown file, no match) | `_requestReplay()`, plus a restart notice for a `.js`/`.mjs`/`.cjs` file under none of `folders.models`, `folders.controllers`, `folders.helpers` |
|
|
122
130
|
|
|
123
131
|
A replay is the default and the fallback: only the two scoped rows are narrower than one, and each is provably so.
|
|
124
132
|
|
|
133
|
+
- **The site's own helpers are loaded by `Kiss`, not by the build script.** The constructor starts `_loadHelpers()` and parks the promise on `_helpersReady`; `generate()` awaits it alongside `_promises` and **discards its result** (`Promise.all([this._helpersReady, ...this._promises]).then(([, ...data]) => …)`) because `data`'s entry 0 is the documented construction-time asset copy and renumbering it would break `getModelByID` — the same reasoning that keeps `config.assets.pipeline` off `_promises`. Awaiting rather than blocking the constructor is safe because Handlebars resolves a helper from the registry at **render** time; `llms.txt` claimed the opposite for years and that false claim is what made this look impossible. A failure is recorded as `<site helpers>` on `_failures`. In dev the helpers folder has its own watcher (`AIKB/watcher.md`), and `helpersChanged` reloads with `fresh: true` then re-renders the whole stack — helpers only affect rendering, so it is a replay minus the expensive half. That applies to the **entry** only, and which files count as one is `isActiveHelpersEntry`'s question rather than `helpersEntry()`'s — resolved on both sides, because the default `folders.helpers` is relative and the watcher's events are too. A JavaScript sibling the entry imports cannot be evicted from the ESM cache, so `helpersChanged` prints the restart notice and returns without rebuilding rather than re-rendering against the cached copy; a non-module file in the same folder is re-rendered without a reload, since a helper reads it at render time. Reloads are chained onto `_helpersReady` rather than started beside it, so two saves in quick succession cannot run two registrars over one registry — and `_rebuild()` awaits `_helpersReady` before rendering, because a reload removes the old registrations before it awaits the registrar and a page edit landing in that window used to render against the gap. `_siteHelpers` carries `{ name, prior }` pairs into the next reload as `previous`, which is how a helper the new source dropped leaves the registry — restored to what it displaced rather than deleted, so an overridden built-in survives the site dropping its override; deleting the entry outright undoes all of them, since the site no longer has the helpers it is rendering against. **What a removed helper then does was measured, and "the pages fail loudly" is only half true**: `{{shout "hi"}}` throws `Missing helper: "shout"` and `KissPage.generate()` logs it before rethrowing, but `{{copyright}}` with no arguments is a missing _property_ to Handlebars rather than a missing helper — it renders empty and says nothing. Either way `_rebuild` catches the rejection per page, so the previously written file stays on disk. The console is loud; the served bytes are stale. Each load also drops the previous `<site helpers>` entry from `_failures` before recording its own — a helpers reload is a scoped rebuild rather than a replay, so a broken save used to stay in `report()` for the rest of the session after the file was fixed. That self-clear is also why a replay can safely **carry** `<site helpers>` rather than dropping it: the replay never re-imports the entry, so only the load itself can say whether the failure is still true. Full detail: `AIKB/site-helpers.md`.
|
|
134
|
+
|
|
135
|
+
- **A replay cannot pick up every file it rebuilds for, and says so.** Models are re-read from disk and controllers re-imported on a cache-busted URL, so an edit to either reaches the build — that is why the last row's notice excludes them. Everything else the build script imported is already in the ESM cache, and the replay runs from `_registrations` rather than by re-running the script, so a helper module's edit cannot reach the build at all. Announcing only `Rebuilding site:` therefore presented a change that was never applied as one that was: the site rebuilt, the browser reloaded, and the old helper output was served. The notice is the whole fix — re-registering helpers is the build script's job, not `Kiss`'s, and `Kiss` has no handle on it. **It is scoped to JavaScript modules on purpose**: `import` is what holds a cached copy, so only a `.js`/`.mjs`/`.cjs` file can be stuck. A data file under `src` (`.json`, `.md`, `.csv`) is read at render time — the pattern the docs recommend for helper data — and a replay re-renders every page against whatever is on disk, so the edit does land. Measured: a helper reading `src/data/business.json` with `readJsonSync` at render time picked the edit up while the first version of this notice was still claiming it had not, which is the same lie in reverse.
|
|
136
|
+
|
|
137
|
+
- **A path-matching regression in this dispatch is invisible in the output, and that is worth knowing before trusting a green build as evidence.** An event whose path matches no folder falls through to `replay()`, which re-reads every model, re-imports every controller and re-renders every page — so it produces exactly the bytes a correct scoped dispatch would, by the expensive route. Measured: mutating the `src` watcher to emit absolute paths (the mismatch that made the helpers entry unreachable) left an output-only watch test green. `test/integration/watch.test.js`'s defaults test therefore asserts the **dispatch** — `Rebuilding:` is scoped, `Rebuilding site:` is a replay — and it was confirmed red under that mutation. An assertion about output cannot tell the two apart, which also means a site can lose the whole scoped fast path to a config typo and only ever feel it as slowness.
|
|
125
138
|
- **The partial/layout fast path is safe.** An edited partial or layout cannot change `_registrations`, `_stack`, any page's resolved options, its `buildTo`, or the sitemap — the only thing a replay would do differently is _which_ pages it re-renders. So the stack is unchanged by construction, orphan cleanup, the `buildTo` dedupe and `_failures` are untouched, and what is skipped is exactly the expensive half of a replay: every model re-read from disk, every controller re-imported with a cache bust, every URL model re-fetched over the network (or read from `config.fetch.cache`). `test/integration/replay-equivalence.test.js` is the standing proof: the same edit sequence applied through the real dispatch and through forced replays must leave byte-identical build directories.
|
|
126
139
|
- **A partial or layout `change` re-renders the pages that rendered it.** `_handleChange` re-registers the partials, maps the path to a name with `partialNameFor`, and asks `_graph.dependentsOf(name)` (`AIKB/dependency-graph.md`): the matching stack entries go to `_requestRebuild`, through the same serial queue as every other rebuild. `null` — a partial no page has recorded, because nothing has rendered it yet or only a failed page would have — re-renders every entry and logs a notice naming the file, so an untraced edge shows up as a fixable gap rather than silent slowness. `[]` re-renders nothing, which is right: no page used it. A partial edit that arrives while a rebuild is in flight is still upgraded to a replay (below). Measured before the graph existed (diploma-msc, 654 pages): a partial save re-rendered everything in 1247ms; a page render is ≈1.9ms, so a partial N pages use now costs ≈2N ms plus registration.
|
|
127
140
|
- **The knowledge base is built from `_finishBuild()`, in a fixed order, and it is the only writer that cannot use the promise queue.** `_buildAikb()` runs first — before `buildReport()` — because the report carries the verdict as its appended `aikb` key; `last-build.json` is written afterwards, because it _is_ this build's report. `.sitemap()` and `.llms()` need only the registry and so can wait on `_promises`; the map's partial-per-page index is learned from _rendering_, so anything earlier than a settled build would list no partials at all. A failure to write is logged and reported as `written: false` — never a build failure, the same stance as the `dependency-graph.json` dump.
|
package/AIKB/llms.md
CHANGED
|
@@ -24,7 +24,7 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
|
|
|
24
24
|
|
|
25
25
|
## Non-obvious behavior
|
|
26
26
|
|
|
27
|
-
- **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
|
|
27
|
+
- **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three, or loses it in all three under `config.links.trailingSlash: false`. `writeLlms` reads that policy off the config and passes it to `buildLlmsEntries`; an entry here emitting the slashed form while the canonical emitted the bare one would be the same page advertised at two URLs, one of them a redirect. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
|
|
28
28
|
- **`ignoreSitemap` excludes a page from `llms.txt` too.** The AI index is a curated subset of the site the sitemap describes, never a superset: a page the author has already said should not be crawled has no business being recommended to an answer engine. `ignoreLlms` is the one-way opt-out — out of `llms.txt`, still in the sitemap.
|
|
29
29
|
- **An untitled page arrives here titled `Index`.** `KissPage`'s constructor computes its default title from the _placeholder_ slug (`index`) before the real slug is set, so `prepare()` fills every untitled page's `title` with `'Index'`. That value is treated as unset and the page's own slug is title-cased instead (`puppy-classes` → `Puppy Classes`). It costs nothing for a page that really is the index — its slug title-cases to the same word — and it costs a page genuinely titled "Index" its title, which is the cheaper of the two mistakes.
|
|
30
30
|
- Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
|