kiss-ssg 2.7.0 → 2.8.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/asset-copy.md +67 -0
- package/AIKB/asset-manifest.md +6 -3
- package/AIKB/assets.md +6 -4
- package/AIKB/audit.md +20 -6
- package/AIKB/build-finish.md +56 -0
- package/AIKB/build-report.md +4 -3
- package/AIKB/check.md +12 -3
- package/AIKB/config.md +6 -3
- package/AIKB/controller-resolver.md +1 -1
- package/AIKB/handlebars-helpers.md +3 -2
- package/AIKB/init.md +1 -1
- package/AIKB/kiss-page.md +8 -3
- package/AIKB/kiss.md +18 -88
- package/AIKB/links.md +6 -4
- package/AIKB/llms.md +2 -1
- package/AIKB/markdown-copy.md +29 -0
- package/AIKB/model-resolver.md +1 -1
- package/AIKB/output-registry.md +3 -1
- package/AIKB/package-manifest.md +37 -0
- package/AIKB/packages.md +48 -0
- package/AIKB/page-registry.md +65 -0
- package/AIKB/partials.md +1 -1
- package/AIKB/pipeline.md +1 -1
- package/AIKB/rebuild.md +112 -0
- package/AIKB/redirects.md +1 -1
- package/AIKB/scorm-bridge.md +51 -0
- package/AIKB/site-helpers.md +1 -1
- package/AIKB/staging.md +106 -0
- package/AIKB/testing.md +12 -9
- package/AIKB/upstream.md +207 -1
- package/AIKB/utils.md +4 -3
- package/AIKB/watcher.md +2 -1
- package/AIKB/zip.md +33 -0
- package/CHANGELOG.md +360 -0
- package/GUIDE.md +396 -36
- package/README.md +162 -17
- package/examples/1-scan/src/assets/css/site.scss +115 -13
- package/examples/1-scan/src/layouts/layout.hbs +1 -1
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/10-asset-pipeline/src/assets/css/site.scss +115 -13
- package/examples/10-asset-pipeline/src/layouts/layout.hbs +1 -1
- package/examples/11-blog/AIKB/last-build.json +48 -32
- package/examples/11-blog/AIKB/notes/controllers/tag.md +11 -6
- package/examples/11-blog/AIKB/site-map.json +1 -1
- package/examples/11-blog/AIKB/site-map.md +1 -1
- package/examples/11-blog/README.md +1 -1
- package/examples/11-blog/router.js +15 -7
- package/examples/11-blog/src/assets/css/site.scss +115 -13
- package/examples/11-blog/src/controllers/tag.js +16 -1
- package/examples/11-blog/src/layouts/layout.hbs +1 -1
- package/examples/11-blog/src/models/posts/2026-01-20-why-we-roast-on-wednesdays.json +1 -1
- package/examples/11-blog/src/pages/blog/listing.hbs +2 -2
- package/examples/11-blog/src/pages/blog/tag.hbs +1 -1
- package/examples/11-blog/src/pages/blog/tags.hbs +6 -4
- package/examples/11-blog/src/pages/index.hbs +4 -4
- package/examples/12-from-a-single-file/src/assets/css/site.css +2 -0
- package/examples/12-from-a-single-file/src/layouts/layout.hbs +5 -1
- package/examples/12-from-a-single-file/tools/compare.mjs +20 -2
- package/examples/13-course-packages/README.md +54 -0
- package/examples/13-course-packages/router.js +75 -0
- package/examples/13-course-packages/src/assets/css/course.css +79 -0
- package/examples/13-course-packages/src/layouts/lesson.hbs +25 -0
- package/examples/13-course-packages/src/pages/espresso/extraction.hbs +7 -0
- package/examples/13-course-packages/src/pages/espresso/grind.hbs +8 -0
- package/examples/13-course-packages/src/pages/milk/index.hbs +75 -0
- package/examples/2-page/src/assets/css/site.scss +115 -13
- package/examples/2-page/src/layouts/layout.hbs +1 -1
- package/examples/3-pages/src/assets/css/site.scss +115 -13
- package/examples/3-pages/src/layouts/layout.hbs +1 -1
- package/examples/4-layouts-and-partials/src/assets/css/site.scss +115 -13
- package/examples/4-layouts-and-partials/src/layouts/layout.hbs +2 -0
- package/examples/5-helpers/src/assets/css/site.scss +115 -13
- package/examples/5-helpers/src/layouts/layout.hbs +1 -1
- package/examples/6-sitemap/src/assets/css/site.scss +115 -13
- package/examples/6-sitemap/src/layouts/layout.hbs +1 -1
- package/examples/7-versioned-outputs/src/assets/css/site.scss +115 -13
- package/examples/7-versioned-outputs/src/layouts/layout.hbs +1 -1
- package/examples/8-data-fed-site/src/assets/css/site.scss +115 -13
- package/examples/8-data-fed-site/src/layouts/layout.hbs +1 -1
- package/examples/9-migrated-from-v1/AIKB/last-build.json +38 -25
- package/examples/9-migrated-from-v1/README.md +1 -1
- package/examples/9-migrated-from-v1/src/assets/css/site.scss +115 -13
- package/examples/9-migrated-from-v1/src/layouts/layout.hbs +1 -1
- package/examples/README.md +4 -3
- package/lib/asset-copy.js +227 -0
- package/lib/asset-manifest.js +32 -2
- package/lib/assets.js +176 -22
- package/lib/audit.js +192 -1
- package/lib/build-finish.js +632 -0
- package/lib/build-report.js +91 -7
- package/lib/check.js +90 -1
- package/lib/config.js +169 -5
- package/lib/handlebars-helpers.js +31 -1
- package/lib/kiss-page.js +75 -0
- package/lib/kiss.js +278 -2168
- package/lib/links.js +99 -21
- package/lib/llms.js +18 -2
- package/lib/markdown-copy.js +101 -0
- package/lib/output-registry.js +1 -1
- package/lib/package-manifest.js +209 -0
- package/lib/packages.js +796 -0
- package/lib/page-registry.js +644 -0
- package/lib/pipeline.js +1 -0
- package/lib/rebuild.js +632 -0
- package/lib/schemas/scorm12/NOTICE.md +13 -0
- package/lib/schemas/scorm12/adlcp_rootv1p2.xsd +110 -0
- package/lib/schemas/scorm12/ims_xml.xsd +1 -0
- package/lib/schemas/scorm12/imscp_rootv1p1p2.xsd +345 -0
- package/lib/scorm-bridge.js +748 -0
- package/lib/staging.js +322 -0
- package/lib/utils.js +18 -0
- package/lib/watcher.js +21 -3
- package/lib/zip.js +121 -0
- package/llms.txt +81 -434
- package/package.json +10 -3
- package/types/asset-copy.d.ts +12 -0
- package/types/asset-manifest.d.ts +11 -0
- package/types/assets.d.ts +8 -2
- package/types/audit.d.ts +26 -2
- package/types/build-finish.d.ts +69 -0
- package/types/build-report.d.ts +80 -2
- package/types/check.d.ts +50 -0
- package/types/config.d.ts +85 -10
- package/types/handlebars-helpers.d.ts +1 -1
- package/types/kiss-page.d.ts +22 -0
- package/types/kiss.d.ts +202 -102
- package/types/links.d.ts +55 -1
- package/types/llms.d.ts +7 -1
- package/types/markdown-copy.d.ts +22 -0
- package/types/output-registry.d.ts +2 -2
- package/types/package-manifest.d.ts +82 -0
- package/types/packages.d.ts +250 -0
- package/types/page-registry.d.ts +61 -0
- package/types/pipeline.d.ts +4 -0
- package/types/rebuild.d.ts +9 -0
- package/types/scorm-bridge.d.ts +12 -0
- package/types/staging.d.ts +82 -0
- package/types/utils.d.ts +8 -0
- package/types/zip.d.ts +24 -0
package/AIKB/aikb.md
CHANGED
|
@@ -35,7 +35,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
35
35
|
|
|
36
36
|
## Depended on by
|
|
37
37
|
|
|
38
|
-
`lib/
|
|
38
|
+
`lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by the `kiss-memory` plugin's `kiss-site-brief`, `kiss-branch-pulse` and `kiss-branch-close` skills, and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
|
|
39
39
|
|
|
40
40
|
## Non-obvious behavior
|
|
41
41
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# asset-copy.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
The build's asset copies as the engine runs them: one queued `copyAssets` run (`lib/assets.js`)
|
|
6
|
+
per registration, and the bookkeeping around it — the `<sass: …>` failures each copy owns, the
|
|
7
|
+
registrations a replay re-runs, and the copies a released page claim can restore. `lib/assets.js`
|
|
8
|
+
is the copy itself and knows nothing of the instance; this module is what ties a copy to a build.
|
|
9
|
+
|
|
10
|
+
`copyAssetFolder(kiss, …)` takes the `Kiss` instance and reads its state at call time. The state
|
|
11
|
+
stays on `Kiss` — `_assetQueue`, `_sassFailures`, `_assetCopies`, `_restorableAssets`,
|
|
12
|
+
`_failures`, `_promises` — and `Kiss._copyAssets()` is a one-line delegator to it. Calls back
|
|
13
|
+
into the instance go through it (`kiss._stagedPath()`, `kiss._carry()`, `kiss._refreshReport()`).
|
|
14
|
+
|
|
15
|
+
## Public interface
|
|
16
|
+
|
|
17
|
+
- `copyAssetFolder(kiss, sourceDir, targetDir, watch = false)` → the queued run (a promise of
|
|
18
|
+
`lib/assets.js`'s result). The body of `Kiss._copyAssets()`; the public `.copyAssets()` and the
|
|
19
|
+
watch re-copies both arrive here. `watch` keeps the run off `_promises`.
|
|
20
|
+
- `assetCopyKey(sourceDir, targetDir)` → a copy's identity: the canonicalised `(source, target)`
|
|
21
|
+
pair joined by `\u0000`, or `null` when either half is missing (`folders.assets: null`). Also
|
|
22
|
+
imported by `lib/kiss.js`'s `_replay()`, which re-runs a registered copy by this key.
|
|
23
|
+
- `canonical(path)` → `path` canonicalised through `realpathSync.native` as far as it exists, with
|
|
24
|
+
the rest re-appended.
|
|
25
|
+
|
|
26
|
+
## Depends on
|
|
27
|
+
|
|
28
|
+
`fs-extra` (`realpathSync.native`), `node:path`, `./utils.js` (`posixPath`), `./assets.js`
|
|
29
|
+
(`copyAssets`, `assetCopyOwner`).
|
|
30
|
+
|
|
31
|
+
## Depended on by
|
|
32
|
+
|
|
33
|
+
`lib/kiss.js` — `_copyAssets()` delegates to `copyAssetFolder`; `_replay()` uses `assetCopyKey`.
|
|
34
|
+
|
|
35
|
+
## Non-obvious behavior
|
|
36
|
+
|
|
37
|
+
- **Copies run one after another, in registration order**, chained on `_assetQueue`: one copy's
|
|
38
|
+
target can be a subdirectory of another's source (a site copying into its own assets folder), and
|
|
39
|
+
concurrent `fs.copy` walks racing there fail with `ENOENT`. A registration's run is pushed onto
|
|
40
|
+
`_promises`; a watch re-copy (`watch: true`) is tracked by the rebuild queue instead, and
|
|
41
|
+
`generate()` / `_drain()` await the `_assetQueue` slot as well as registration work.
|
|
42
|
+
- **Paths are resolved at registration, in the caller's working directory**, and the queued run
|
|
43
|
+
copies against those. Re-resolving the raw arguments when the queue drained copied a different
|
|
44
|
+
`./vendor` once anything had moved the working directory in between — measured: neither
|
|
45
|
+
directory got the output. The `<sass: …>` label is computed at the same moment, project-relative
|
|
46
|
+
when the source is inside the project and absolute when it is not; it is display only, never
|
|
47
|
+
identity.
|
|
48
|
+
- **A copy owns its Sass failures by object identity**, through `_sassFailures`, a `Map` keyed on
|
|
49
|
+
`assetCopyKey`. Two asset roots can each hold `css/theme.scss`, and a prefix match on the view
|
|
50
|
+
string let a parent root's copy clear a nested root's unresolved failure (measured: a stylesheet
|
|
51
|
+
that could not be written at all, on a build reporting `ok: true`). Each run clears exactly the
|
|
52
|
+
set it recorded last time before recording this time, which is also what lets a deleted or
|
|
53
|
+
renamed broken stylesheet lose its entry. Failures are `kiss._carry()`-marked: a replay keeps
|
|
54
|
+
them, because an asset re-copy is the only thing that can re-check one.
|
|
55
|
+
- **The key is computed after the copy has run**, not at registration: canonicalising needs the
|
|
56
|
+
path to exist, and two spellings of a destination that does not exist yet could not canonicalise
|
|
57
|
+
and each kept its own key — a failure stranded under the one nothing would reach again.
|
|
58
|
+
`canonical` walks up to the deepest existing ancestor for the same reason: a plain `realpath`
|
|
59
|
+
with a `resolve` fallback keys one copy two ways across the folder's creation (reproduced through
|
|
60
|
+
a symlinked parent, and on Windows with a case-different one).
|
|
61
|
+
- **The registration (`_assetCopies`) exists exactly while a failure does**, because a replay
|
|
62
|
+
re-runs a copy only to re-check one; both maps are pruned together on a clean run, so a
|
|
63
|
+
long-lived process copying into a fresh destination each run does not grow them for ever.
|
|
64
|
+
- **`report()` is refreshed after every copy** (`kiss._refreshReport()`): an asset re-copy changes
|
|
65
|
+
`_failures` and settles no build, so nothing else would.
|
|
66
|
+
- `_restorableAssets` records a copy whose owner still has a producer in the output registry, so a
|
|
67
|
+
replay's orphan sweep can restore an asset a released page claim had overwritten.
|
package/AIKB/asset-manifest.md
CHANGED
|
@@ -10,12 +10,14 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
10
10
|
- `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
|
|
11
11
|
- `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
|
|
12
12
|
- `hashedName(urlPath, hash)` → the same path with the hash inserted before the last extension (`css/site.css` → `css/site.a1b2c3d4.css`). A path whose last dot is in a folder name, or which has no dot at all, gets the hash appended instead (`css/site` → `css/site.a1b2c3d4`).
|
|
13
|
-
- `createAssetManifest()` → `{ lookup, toObject, hasOwner, previous, reconcile, reconcileSass, urlRevision }`, a fresh manifest.
|
|
13
|
+
- `createAssetManifest()` → `{ lookup, toObject, hasOwner, owns, previous, reconcile, reconcileSass, unrecorded, urlRevision }`, a fresh manifest.
|
|
14
14
|
- `lookup(urlPath)` — the emitted path, or `null`.
|
|
15
15
|
- `toObject()` — a plain object copy of the map, for debugging.
|
|
16
16
|
|
|
17
|
-
- `hasOwner(owner)` — whether a source/target copy has an inventory.
|
|
18
|
-
- `reconcile(owner, current)` — replaces that copy's map of logical names to emitted filenames, removes vanished mappings, and returns old filenames no copy still owns. Another copy's mappings survive.
|
|
17
|
+
- `hasOwner(owner)` — whether a source/target copy has an inventory, or has written outputs it never recorded (below).
|
|
18
|
+
- `reconcile(owner, current)` — replaces that copy's map of logical names to emitted filenames, removes vanished mappings, and returns old filenames no copy still owns, plus that copy's unrecorded outputs that this run did not produce again. Another copy's mappings survive.
|
|
19
|
+
- `unrecorded(owner, outputs)` — remembers build-relative outputs a copy wrote, or failed to remove, before it stopped partway. They are in no map and `lookup` never sees them; the copy's next `reconcile` reports them stale unless they are live again, then forgets them.
|
|
20
|
+
- `owns(owner, output)` — whether `output` (build-relative, keyed by output not by name) is this copy's, recorded or unrecorded. Never another copy's. `lib/assets.js` asks it only when no output registry was passed; with a registry, the registry is the authority.
|
|
19
21
|
|
|
20
22
|
- `urlRevision` — increments when reconciliation changes a CSS/JS URL mapping, including additions and deletions. Image/font content edits and identical mappings leave it unchanged; the watcher uses it to decide whether hashed references need re-rendering. `reconcile` is the only mapping writer.
|
|
21
23
|
|
|
@@ -32,6 +34,7 @@ Holds what an asset copy emitted — the path a template writes mapped to the fi
|
|
|
32
34
|
- **Only `.css` and `.js` are hashable, and that is a domain rule, not a default.** Renaming an image, a font or `robots.txt` breaks URLs nothing here can rewrite: the ones inside a stylesheet, and the ones a host or a browser asks for by a fixed name. kiss has no bundler (`CLAUDE.md`: "no build step, bundler, or transpilation"), so it renames only the files a template links by name and leaves everything else alone — those still reach the manifest, mapped to themselves, so `{{asset "img/logo.png"}}` resolves rather than warning.
|
|
33
35
|
- **The hash goes before the extension.** A server picks the content type off the extension, and `site.css.a1b2c3d4` is not a stylesheet to it.
|
|
34
36
|
- **Cleanup follows per-copy inventories.** `lib/assets.js` reconciles each source/requested-target pair after copying. Old hashed names and deleted sources disappear; files another copy still owns remain. The output registry separately checks that a generated writer has not taken ownership of a stale file.
|
|
37
|
+
- **A map is only written by a copy that finished, so a copy that stopped needs a second record.** `reconcile` runs at the end of a copy; a copy that fails partway has already put files in the build that no map holds, and before `unrecorded` nothing ever removed them (a `git checkout` during `--dev` left a stale asset until restart). They are kept apart from the name map rather than merged into it, because a name has one output: merging a half-copied plain `css/site.css` under a name whose map says `css/site.a1b2c3d4.css` would have reported the last-good hashed file stale and deleted it. A copy with unrecorded outputs counts for `hasOwner`, because its root demonstrably existed, so that root disappearing is a deletion to reconcile, not the typo a missing first root is.
|
|
35
38
|
- **A manifest is per `Kiss` instance**, like the Handlebars environment, so two sites in one process cannot link each other's files. Nothing here is module-level state.
|
|
36
39
|
- MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
|
|
37
40
|
|
package/AIKB/assets.md
CHANGED
|
@@ -6,7 +6,7 @@ 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. **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.
|
|
9
|
+
- `compileSassFiles(sourceDir, targetDir, { config, logger, outputs?, owner?, touched? })` → 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
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
|
|
@@ -15,14 +15,14 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
15
15
|
|
|
16
16
|
## Depended on by
|
|
17
17
|
|
|
18
|
-
`lib/
|
|
18
|
+
`lib/asset-copy.js` (`copyAssets`, `assetCopyOwner`, in the queued copy behind `Kiss._copyAssets()`).
|
|
19
19
|
|
|
20
20
|
## Non-obvious behavior
|
|
21
21
|
|
|
22
22
|
- Sass results include `changed`, comparing retained fingerprints of compiled bytes across copies. The fingerprint is computed in memory, without reading the previous CSS file, so hashing may move that file without making an identical compile look changed. A copied CSS sibling wins and is excluded from Sass changes. The watcher swaps one changed stylesheet in place; multiple affected stylesheets use a full refresh; an unchanged Sass save sends no refresh. Failed or removed entries leave the next successful copy to establish a fresh fingerprint.
|
|
23
23
|
- A generated owner refusing a Sass write returns `refused: true`, distinct from `skipped: true` for underscore partials. The copy returns `refused` source paths for diagnostics; restoration uses registry producer identities. Refusals appear in advisory `outputs.collisions`, not the failure list.
|
|
24
24
|
|
|
25
|
-
- Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; a missing first-time source remains an error. Other copies retain their claims. Kiss passes the shared output registry: asset copies yield to generated outputs, warn on collisions, and unlink a stale file only while that copy still owns it. Copy identity uses the requested destination across atomic promotion.
|
|
25
|
+
- Each resolved source/target pair owns an inventory in the manifest. After copying, `recordEmitted` reconciles that inventory and unlinks only stale files; it never empties an output folder. Missing previously copied roots are empty inventories, allowing directory deletion in watch mode; so is a root a failed copy had already written from (its unrecorded outputs count for `hasOwner`). A missing first-time source remains an error. Other copies retain their claims. Kiss passes the shared output registry: asset copies yield to generated outputs, warn on collisions, and unlink a stale file only while that copy still owns it. Copy identity uses the requested destination across atomic promotion.
|
|
26
26
|
|
|
27
27
|
- `copyAssets` is written to _always resolve_, never reject: `generate()`/`complete()` await it via `_promises`, and a rejected or forever-pending promise here would hang or crash the whole build. Copy failures are caught and returned as `{ error }` instead of thrown.
|
|
28
28
|
- **The CSS target is path arithmetic, not a string replace.** `path.posix.join(posixPath(targetDir), path.posix.relative(root, sassFile))` — the old `sassFile.replace(root, targetDir)` was an unanchored literal replace, so a `sourceDir` ending in a slash was no longer a substring of the glob result, the replace did nothing, and the compiled CSS was written to a sibling of the build folder (`./publicmain.css` at the project root) while the build logged it as a success and exited 0 (review finding C3). `resolveFolders` now normalises the folder string too; the arithmetic here is what keeps a caller passing `compileSassFiles` a raw folder honest.
|
|
@@ -34,7 +34,7 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
34
34
|
- **With no policy set, the emitted files are byte-for-byte and path-for-path what they always were** — the manifest is built either way, and nothing is renamed until `config.assets.hash` asks for it. That is what keeps an existing site unaffected by the feature's existence.
|
|
35
35
|
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
36
36
|
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
37
|
-
- **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
|
|
37
|
+
- **Stale hashes and deleted assets are reconciled here.** The manifest returns previously owned filenames no copy still claims, plus outputs an earlier failed copy wrote and never recorded; cleanup uses `unlink`, not recursive deletion, and checks current output ownership.
|
|
38
38
|
- **A failed Sass compile keeps this copy's last-good mapping and stylesheet when that file still exists and the copy still owns it.** The failure remains on the report; preserving preview bytes never makes a failed build pass. A removed source has no failed compile to preserve, so its CSS and mapping are still removed. A copied CSS sibling still wins. The previous mapping is per copy, never borrowed from another asset root.
|
|
39
39
|
- `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.
|
|
40
40
|
- **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`.
|
|
@@ -50,3 +50,5 @@ Compiles every Sass file under the assets folder to a sibling `.css` file in the
|
|
|
50
50
|
|
|
51
51
|
- Source-stat failures during inventory discovery are skipped, including dangling links and files disappearing between glob and stat. Healthy files still reconcile. The source-stat ENOENT transition was tested on Windows; creating a real file symlink was denied by the host, so actual dangling-link behavior was not executed here.
|
|
52
52
|
- `assetCopyOwner(source, target)` derives the shared owner identity. Kiss supplies the requested destination before staging; direct module callers use their target. Watch copies use the original registration paths, including the build-root anchor used for manifest names.
|
|
53
|
+
- **Every output a copy is about to write is in `touched` before it is written, so a copy that stops partway still records it.** The manifest learns a copy's outputs only when `recordEmitted` reaches `reconcile` at the end; a copy that failed partway (a source vanishing mid-copy, `ENOENT` on its `copyfile`) used to leave what it had already written in no record, and a later run that found the root gone removed only what the manifest knew. In use, a `git checkout` or a bulk delete during `--dev` left a stale asset until restart (found 2026-10-02; the fs-extra unlink-first gap that exposed it is `AIKB/upstream.md`). Now the three writers add to `touched` before writing: the Sass compile (`compileSassFiles`' `touched` dep), the copy's `filter` (files and links, never a directory), and the hash `fs.move`. A stale output whose `unlink` fails is added too, kept claimed, and the error is raised only after the loop. On any failure the `catch` claims what this copy may still write and hands the lot to `manifest.unrecorded`, and the copy's next `reconcile` removes whatever it does not produce again (`AIKB/asset-manifest.md`). **Filled before each write, not read off the disk in the `catch`**, because fs-extra copies a folder's entries concurrently (`asyncIteratorConcurrentProcess`): `fs.copy` rejects on the first failed entry while its siblings may still be writing, so a `stat` in the `catch` misses them. The same concurrency means a sibling folder can still be walked _after_ the rejection, so the `catch` sets `stopped` first and the filter, checking it after its own `lstat` await and synchronously with the add, starts no write once the failure is recorded: every write either began before the record (and is in it) or never begins. **A directory is never an output, whatever recorded it.** A source file aimed at an existing output directory is recorded by the filter and then fails fs-extra's file-over-directory check without writing anything. Recording that directory made every later reconcile try to `unlink` it, fail and record it again, so the session never recovered once the source was fixed (found by the Codex review, 2026-10-03). The `catch` drops any `touched` path that is a directory before it claims or records, and the unlink loop releases, rather than retries, a stale path that turns out to be one. Pinned in `test/unit/assets.test.js` ("copyAssets that fails partway": one test per writer, the first-copy case, the late sibling write, the directory collision and a recorded directory reaching the unlink loop, each seen red), and end to end by `watch.test.js`'s "copies new assets…" with a 20 ms delay injected between fs-extra's unlink and copy: 5/5 red without the fix, 5/5 green with it (2026-10-03, Windows).
|
|
54
|
+
- **A source that changes type clears its own previous output before fs-extra's type check (`clearOwnConflict`, called from the copy's filter).** A folder that became a file of the same name finds a folder of its own old outputs at the destination; a file that became a folder finds its own old output file. fs-extra refuses both (file-over-directory, directory-over-file), and the old output was removed only by `recordEmitted` after a copy that _succeeded_, so every rebuild failed until restart. An empty folder blocked a later file the same way, and kiss leaves one behind whenever a source folder is deleted, because it never removes output directories. The filter runs before fs-extra's own check (`copy.js`, `runFilter` then `checkPaths`), so it can clear the way: an old output file is unlinked if this copy owns it; a folder is cleared only when **every** file under it is this copy's own output (an empty folder qualifies vacuously), file by file and then folder by folder with a non-recursive `rmdir`, so a file that appears meanwhile stops the clearing rather than being swept up. Anything foreign in the way stays, and the copy fails on it as before. "This copy's own" is `outputs.owns` when a registry is passed (Kiss always passes one) and `manifest.owns` otherwise. The old output's manifest entry is then stale at the end of the copy; when its path has become a folder, the unlink loop's directory rule releases it, and when a _parent_ of it has become a file the unlink answers `ENOTDIR` on POSIX (`ENOENT` on Windows), which `isGone` treats as gone. Treating `ENOTDIR` as a failure re-recorded the path on every run, so a folder replaced by a file never recovered on Linux or macOS while every Windows run passed (found by a second-opinion review, 2026-10-03; the test makes `fs.unlink` answer the POSIX way on any platform). **Sass compiles before the copy and walks nothing**, so `compileSassFiles` (given `ownsOutput`) clears this copy's own old output on the folder path down to the stylesheet, top down, and at the stylesheet's own path, before writing; otherwise a stylesheet in a folder that used to be a file reported a write failure for one rebuild. Because an empty folder qualifies, an empty folder at an output path is removed even if something else made it: kiss cannot tell one it left behind from one it did not, and an empty folder holds nothing. The "names the write" test's obstacle is therefore a folder holding a foreign file. **Clearing is only for output a previous run left: `ownsOutput` is false for anything in `touched`.** Without that, `site.scss` beside a folder `site.css/` compiled the stylesheet, the copy's filter then cleared it as "this copy's own output in the way" and copied the folder in, and the build reported success with no stylesheet, where before clearing existed the collision failed the copy (found by the Codex review, 2026-10-03; pinned by "never clears an output this run already wrote", which asserts that every stylesheet reported compiled is still a file on disk). Pinned in `test/unit/assets.test.js` ("a source that changes type": both directions, with and without a registry, with and without the intermediate copy that leaves the empty folder, the POSIX `ENOTDIR` answer and a stylesheet in a former file's folder; "with something foreign in the way": a page-owned file and a junction veto the clear, mutation-checked against `some`) and end to end by `watch.test.js`'s "follows an asset folder that becomes a file, and back", each seen red. Absorbed at the close of `planning/sessions/2026-10-03-asset-copy-partial-record.md` (Amendments).
|
package/AIKB/audit.md
CHANGED
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident. Two pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
|
|
5
|
+
The launch-readiness audit: decide, from the HTML a build wrote, the files in its build folder and the build's own state, whether the site looks finished — a page with no title or description, no `og:image`, an image with no `alt`, a heading outline that skips, a viewport that locks zoom, a link to `#` or to a fragment no element carries, no favicon, no 404 page, a local `siteUrl`, a `debug.json` or a source map shipped by accident. Two pure-ish functions and the list of check ids; the module holds no state, knows nothing about `Kiss`, and decides nothing about when it runs.
|
|
6
6
|
|
|
7
7
|
The findings are **advisory**. They never move `ok`, `failures` or an exit code, and never fail a page. The design copies the broken-link scan (`AIKB/links.md`) in every respect — extracted at write time, decided once in the settle path, latched and reported as an appended key.
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
|
-
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`.
|
|
13
|
-
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
11
|
+
- `CHECKS` — the frozen, ordered tuple of check ids: `title-missing`, `title-duplicate`, `description-missing`, `description-duplicate`, `og-image-missing`, `og-image-relative`, `canonical-missing`, `img-alt-missing`, `h1-count`, `heading-skip`, `viewport-zoom-lock`, `href-hash`, `fragment-missing`, `favicon-missing`, `not-found-missing`, `site-url-local`, `debug-dump`, `stray-file`, `console-log`. **Public vocabulary**: `config.audit.ignore`, the report's `check` field and the summary lines all use these, and `lib/config.js` validates `ignore` against this list, so renaming one is a breaking change. Declared with a const assertion so `CheckId` emits as a union in `types/audit.d.ts`.
|
|
12
|
+
- `extractPageFacts(html, outputPath)` → `PageFacts` `{ html, title, description, ogImage, canonical, icon, imgMissingAlt, headings, viewports, base, ids, fragmentLinks }`. Called by `KissPage.generate()` on the minified bytes it just wrote, beside `extractReferences`, and parked on `KissPage.audit`. `html` is `false` (and every other field empty) unless the path ends `.html`/`.htm` **and** the bytes carry `<html` or `<body`.
|
|
13
|
+
- `auditBuild({ pages, buildDir, siteUrl, ignore, ownsFolder, debugWritten, extensionLess })` → `{ checked, ignored, skipped, findings: [{ check, page, detail }] }`. `extensionLess` is `config.extensionLess`, passed so a cross-page fragment resolves to the page the link scan would resolve the same href to. `pages` is `[{ buildTo, facts, canonicalElsewhere, siteUrl }]` for the pages that wrote bytes; `buildDir` is the folder they were written into; `ownsFolder` is the author's `cleanBuild !== false`, captured before check mode rewrites `cleanBuild` to `'atomic'` — so `kiss-ssg check` skips what the real build skips. Findings are sorted by check (in `CHECKS` order), then page (`null` first), then detail.
|
|
14
14
|
- The `CheckId`, `PageFacts`, `AuditFinding`, `AuditResult` and `AuditPage` typedefs.
|
|
15
15
|
|
|
16
16
|
## Depends on
|
|
17
17
|
|
|
18
|
-
`node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests)
|
|
18
|
+
`node:fs` (`readdirSync`, `readFileSync`, `existsSync` — the folder walk and the two root-file tests), `node:path` (`path.posix`, to make a page build-relative), `./links.js` (`attribute`, `decodeEntities`, so a tag's attributes are matched and decoded exactly as the link scan does; `classifyReference` and `resolveTarget`, so a cross-page fragment lands on the page the link scan resolves its path to) and `./utils.js` (`posixPath`). No HTML parser, no logger, no config.
|
|
19
19
|
|
|
20
20
|
## Depended on by
|
|
21
21
|
|
|
22
|
-
`lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/
|
|
22
|
+
`lib/kiss-page.js` (`extractPageFacts`, at write time, on `KissPage.audit`), `lib/build-finish.js` (`auditBuild`, from `Kiss._runAudit()` in `complete()`'s settle path directly after `_checkLinks()`, latched on `_audit` and handed to `buildReport` as the report's last key, `audit` — `AIKB/build-report.md`) and `lib/config.js` (`CHECKS`, to refuse an unknown id in `audit.ignore`).
|
|
23
23
|
|
|
24
24
|
## Non-obvious behavior
|
|
25
25
|
|
|
@@ -34,4 +34,18 @@ The findings are **advisory**. They never move `ok`, `failures` or an exit code,
|
|
|
34
34
|
- **Folder ownership.** `not-found-missing`, `stray-file` and `console-log` read the whole build folder. Under `cleanBuild: false` that folder holds whatever earlier builds and sibling instances left (example 7's archive index is the proof), so those three are **not run** and are listed in `skipped` — never silently absent. An ignored check is not also listed as skipped.
|
|
35
35
|
- **Walked paths are joined with `/`, never `path.join`, and `buildDir` is used as given.** `reportedPath` in `lib/build-report.js` maps a finding out of staging by matching the staging prefix followed by `/`; a Windows `\` from `path.join`, or a rewritten root, would leak `kiss-staging` into the report.
|
|
36
36
|
- **`console-log` skips files a site's author did not write**: `*.min.js` — including its `assets.hash` form, `*.min.<hex>.js`, since the rename happens before the audit walks the folder (a Codex review found the exemption lost there, 2026-09-28) — and anything under a `vendor/` or `node_modules/` segment. The count is of the literal `console.log(`, so it is a hint, not a parse.
|
|
37
|
+
- **`viewport-zoom-lock` reads every viewport meta, and fires if any one locks zoom.** Which one a browser applies when a page has two has not been measured here, so the check takes the reading that cannot miss a lock. The content is split on commas, semicolons or whitespace, with spaces around `=` dropped. `user-scalable` of `no` or `0`, or a numeric `maximum-scale` below 2 (WCAG 1.4.4's 200%), fires. The `detail` is the content as written, which is after the minifier, so `width=device-width,maximum-scale=1` has no space.
|
|
38
|
+
- **The fragment checks are two ids on purpose.** `href-hash` is the bare `href="#"`, often a deliberate script hook. `fragment-missing` is a fragment that names no id. Split, a site can `ignore` the first with a reason and keep the second, which is what checks its skip link. `audit.ignore` is per check, not per fragment; that is the operator's decision (2026-10-20), and GUIDE says what ignoring `fragment-missing` costs.
|
|
39
|
+
- **Ids are read with a narrower strip than the counts.** `<svg>` stays, because an id inside inline SVG is in the document and a link may target it. Comments go. So do the **contents** of `<script>`, `<style>` and `<template>`, while their opening tags stay, since those elements are in the document and may carry an id. `<a name>` counts, as the HTML spec still allows. Fragment links are read from `<a>` and `<area>` only, with the counts' wider strip: `<link>` and `<use>` hrefs are not navigation.
|
|
40
|
+
- **The fragment checks read tags quote-aware, unlike the rest of the audit** (`ATTRS`, `TAG_END`). `[^>]*` ends a tag at the first `>`, and `\b` after a tag name stops at a `-`. For the link scan those are false negatives, the safe direction. For the fragment checks they are false positives:
|
|
41
|
+
- `<div x-show="count > 0" id="panel">` hid the id, so every link to it was reported missing;
|
|
42
|
+
- `<a-entity href="#">` (A-Frame) was read as an `<a>`.
|
|
43
|
+
|
|
44
|
+
A same-family review found both, plus a third false positive: `#/route` and `#!` links, which are client-side routes rather than scroll targets and are now skipped (2026-10-21). The count checks keep the old reading, where a stray match cannot invent a finding of this kind.
|
|
45
|
+
|
|
46
|
+
- **A fragment resolves the way a browser would, as far as the build can see.**
|
|
47
|
+
- **Matching:** the raw fragment is compared first, then its percent-decoded form, both case-sensitively. A text directive is cut off at `:~:`, and an empty fragment is skipped. `#top` with no `id="top"` is skipped in any case, because the spec scrolls to the top.
|
|
48
|
+
- **The target page:** an empty path or a query-only path (`?q=1#x`) is the page itself. Anything else goes through `classifyReference` and then `resolveTarget`, with `exists: () => false`, so only pages this build wrote can be named. A fragment on an asset, another origin, an earlier build's file or another instance's page has no ids to check against and is skipped. So is every fragment on a page with a `<base href>`.
|
|
49
|
+
- **`resolveTarget` tries `known` across every candidate before the disk** (`AIKB/links.md`). Asked candidate by candidate, `/about` on a real filesystem named the folder `about`, which holds `about/index.html`, and every extension-less cross-page fragment would have been skipped without a word. A fresh-context critique of the plan caught this before the code existed (2026-10-20).
|
|
50
|
+
- **Facts parked before these fields existed are read as having none** (`?? []`), so an older `KissPage.audit` shape cannot throw.
|
|
37
51
|
- **An unreadable walked file is empty.** A file removed mid-walk or locked on Windows has nothing to report; the audit is advisory and never throws for it.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# build-finish.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
What a settled build says about itself: the build report (`_finishBuild()`, and its re-derivation
|
|
6
|
+
`_refreshReport()`), and the four things the settle path decides beside it — the link scan
|
|
7
|
+
(`_checkLinks()`), the launch-readiness audit (`_runAudit()`), the redirect files and their
|
|
8
|
+
findings (`_writeRedirects()`, `_redirectFindings()`, `_lastBuildRecord()`), and the site's
|
|
9
|
+
knowledge-base record (`_buildAikb()`). `lib/kiss.js` decides _when_ each runs — `complete()`'s
|
|
10
|
+
settle path calls the redirect write, the scan and the audit above its three-way branch, and
|
|
11
|
+
`_finishBuild()` after it (`AIKB/kiss.md`) — and this module does the run and records the result
|
|
12
|
+
on the instance. The pure work is elsewhere: `lib/build-report.js`, `lib/links.js`,
|
|
13
|
+
`lib/audit.js`, `lib/redirects.js`, `lib/aikb.js`.
|
|
14
|
+
|
|
15
|
+
Every function takes the `Kiss` instance and reads its state at call time. The state stays on
|
|
16
|
+
`Kiss` — `_report`, `_finishedAt`, `_aikbVerdict`, `_links`, `_audit`, the `_redirects*` fields —
|
|
17
|
+
and each private method on `Kiss` is a one-line delegator to the function here. Calls between
|
|
18
|
+
these functions, and into the rest of the instance, go through it (`kiss._buildAikb()`,
|
|
19
|
+
`kiss._reportInputs()`, `kiss._stackForRecord()`, `kiss._reportedPath()`), so a test that patches
|
|
20
|
+
one on the instance is seen.
|
|
21
|
+
|
|
22
|
+
## Public interface
|
|
23
|
+
|
|
24
|
+
Each is the body of the `Kiss` private method named beside it:
|
|
25
|
+
|
|
26
|
+
- `finishBuild(kiss)` — `_finishBuild()`; `reportInputs(kiss)` — `_reportInputs()`;
|
|
27
|
+
`refreshReport(kiss)` — `_refreshReport()`.
|
|
28
|
+
- `buildAikb(kiss)` — `_buildAikb()`.
|
|
29
|
+
- `runLinkCheck(kiss, { quiet })` — `_checkLinks()`; `runAudit(kiss, { quiet })` — `_runAudit()`.
|
|
30
|
+
- `writeAliasRedirects(kiss)` — `_writeRedirects()`; `findRedirectChanges(kiss)` —
|
|
31
|
+
`_redirectFindings()`; `lastBuildRecord(kiss)` — `_lastBuildRecord()`.
|
|
32
|
+
|
|
33
|
+
Three are named apart from their method because the method's own name is a function this module
|
|
34
|
+
imports: `checkLinks` (`lib/links.js`), `writeRedirects` and `redirectFindings`
|
|
35
|
+
(`lib/redirects.js`).
|
|
36
|
+
|
|
37
|
+
## Depends on
|
|
38
|
+
|
|
39
|
+
`fs-extra`, `./utils.js`, `./build-report.js` (`buildReport`, `auditLines`), `./links.js`,
|
|
40
|
+
`./audit.js`, `./redirects.js` (`collectAliases`, `redirectFindings`, `writeRedirects`),
|
|
41
|
+
`./check.js` (`readReportsFile`, `aikbRecordRequested`), `./aikb.js` (`buildSiteMap`,
|
|
42
|
+
`isRecorded`, `writeAikb`, `writeLastBuild`).
|
|
43
|
+
|
|
44
|
+
## Depended on by
|
|
45
|
+
|
|
46
|
+
`lib/kiss.js` only, through its delegating methods.
|
|
47
|
+
|
|
48
|
+
## Non-obvious behavior
|
|
49
|
+
|
|
50
|
+
- **`writeLearningPackages()` runs just after `_writeRedirects()`, and for its reasons**: the zips are files this build publishes, so they are written into the staging folder before the swap, from the pages where they were written. It is latched as `_packagesRun` (reset by the replay) and skipped with nothing to report when no page names a `package`, when `config.packages` is `false`, and when the build has already failed, so a failed page is reported once and not again as a package that could not be made. A dev build skips it with one notice per instance: a scoped re-render re-runs no writer. A package that cannot be made fails the build as `<packages>`, and no zip is written at all. `written(file)` is a lookup in the output registry, never the disk, which is what keeps a stale file out of a package (`AIKB/packages.md`). `reportInputs()` carries `_packagesResult` as the report's last key. After writing, it logs one `Package: <build folder>/<dir>/<id>.<format>.zip <title> (<n> pages, <m> files)` info line per zip, beside `Links:` and `Audit:`, named by `config.folders.build` rather than the staging folder an atomic build wrote it in, and a `package outside: <page> -> <href> (<reason>)` notice per reference left out. Before 2026-10-25 only `check --summary` listed packages, and a clean-room run found GUIDE's claim that the build did.
|
|
51
|
+
- **`_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.
|
|
52
|
+
- **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_idIndex`/`_idNoticed`, `_sitemapPath`, `_llmsPath`, `_feedPath`, `_links`, `_audit`, `_debugWritten`, `_redirectsPath`, `_redirectsJsonPath`, `_redirectsFiles`, `_redirectsResult`, and `_startedAt`) reset in `_replay()` beside the failure sweep, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
|
|
53
|
+
- **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()` (above) 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.
|
|
54
|
+
- **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.
|
|
55
|
+
- **`_buildAikb()` decides in two steps: is there a knowledge base here, and may this build write it.** `folders.aikb` unset → `aikb: null`, nothing built. Otherwise `record = aikbRecordRequested()` (`KISS_AIKB`, read by exactly the rule `KISS_CHECK` is read by, beside `checkModeRequested()`) and the site is opted in when `record || isRecorded(folder)` — `isRecorded` (`lib/aikb.js`) being "`<folder>/site-map.json` is there", the one file only a record writes. Not opted in → `aikb: null` and no map is built, which is why an ordinary build of a site that has never recorded costs one `stat`. Opted in → the map is built and the notes evaluated whatever happens next, and `write = record && !config.dev && failures.length === 0`. A record refused for either of those last two reasons logs a notice naming the reason: the operator is waiting for the folder to change, and silence would read as success. There is no per-build state to reset, so a watch replay needs no hook — `_replay()`'s own `complete()` re-enters `_finishBuild()`, and in dev nothing is written anyway.
|
|
56
|
+
- **`_promotedFrom` exists because the dependency graph outlives the staging folder.** `_promote()` rewrites every stack entry's `buildTo` to the real folder but leaves the graph, whose page keys are the paths the pages were actually written to — staging paths, under `cleanBuild: 'atomic'`. It therefore parks the staging prefix on `_promotedFrom` before clearing `_stagingDir`, and `_finishBuild()` passes `_stagingDir ?? _promotedFrom` to both `buildSiteMap` and `buildReport`. Without it a promoted atomic build produced a map with no partials in it at all — and a report whose `sitemap` and `llms` named a folder that no longer existed, since neither `_sitemapPath` nor `_llmsPath` is rewritten by the promotion either. Mapping a path that is already real is a no-op, so nothing else moves.
|
package/AIKB/build-report.md
CHANGED
|
@@ -11,7 +11,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
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
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
|
-
- `pages` — `{ view, buildTo, ok, hash, id, canonical }` per stack entry, in registration order; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
14
|
+
- `pages` — `{ view, buildTo, ok, hash, id, canonical, markdown }` per stack entry, in registration order; `markdown` is the Markdown copy the page wrote beside itself (`KissPage.markdown`, mapped to the real build folder like `buildTo`), `null` when it wrote none; `canonical` is the URL the page named as its own when it named one elsewhere (read off `page.options`, the value `Kiss._preparePage` validated — such a page is absent from the sitemap, `llms.txt` and the feed, and `formatReport` says how many there were in one line rather than one per page, because a site mirroring a sister catalogue has a hundred), `null` otherwise; `ok` is `false` when a failure names that output path, `hash` is the sha1 the page recorded for the bytes it wrote (`null` when it wrote none), and `id` is the page's identity — what `{{link "<id>"}}` resolves — read straight off the stack entry, `null` for an inline template, a `generate: false` page and a default id two pages arrived at (`AIKB/kiss.md`).
|
|
15
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.
|
|
17
17
|
- `sitemap` — the `sitemap.xml` this build wrote, or `null`.
|
|
@@ -23,7 +23,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
24
|
- `audit` — the **last** key, after `outputs`: `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
25
|
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the four link/redirect findings: ` broken link: <page> -> <href>`, ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
26
|
-
- `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the
|
|
26
|
+
- `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the four `VALUE_CHECKS` instead print one line per value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters. For the two duplicate checks, that is because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27). For `viewport-zoom-lock` and `fragment-missing`, the value (the viewport content, the href) is what to grep the templates for; a page list alone sends you reading every link (2026-10-20) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
|
|
27
27
|
- `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
|
|
28
28
|
|
|
29
29
|
## Depends on
|
|
@@ -32,10 +32,11 @@ Nothing.
|
|
|
32
32
|
|
|
33
33
|
## Depended on by
|
|
34
34
|
|
|
35
|
-
`lib/
|
|
35
|
+
`lib/build-finish.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`).
|
|
36
36
|
|
|
37
37
|
## Non-obvious behavior
|
|
38
38
|
|
|
39
|
+
- **`packages` is the last key**, after `audit`: one `BuildPackage` per learning package and format (`id`, `format`, `file`, `title`, `hash`, `pages`, `files`, `outside`), sorted by id then format, or `null` when no page is in a package, packaging is off, the build failed, and in a dev build. `file`, every `pages` entry and every `outside[].page` are mapped out of staging like every other path. `formatReport` ends with one `package:` line per package and up to five `package outside:` lines each, then a count of the rest.
|
|
39
40
|
- **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap`, `llms`, `feed`, `redirects.file` and every `links.broken[].page` all go through the same `reportedPath(target, buildDir, stagingDir)`. `Kiss._reportedPath` does the same thing for the failure _message_; this is the same rule somewhere a unit test can reach it, and it is why `buildReport` takes `stagingDir` as well as `buildDir`. A staged build's paths are otherwise a folder with a random suffix that, in check mode, no longer exists by the time anyone reads the report.
|
|
40
41
|
- **A failure's `Error` is reduced to its `message` on purpose.** `JSON.stringify(new Error('x'))` is `{}`, so a report carrying the object loses exactly the text that says what went wrong. The object itself stays on the `AggregateError` `complete()` rejects with — the report is the serialisable view of the same build, not a replacement for it.
|
|
41
42
|
- **`assets` is the manifest, so it is keyed by the path a template writes**, not by the source file: a `.scss` appears under its compiled `css/site.css` name, and `target` is where cache busting put it (`css/site.a1b2c3d4.css`). See `AIKB/asset-manifest.md`.
|
package/AIKB/check.md
CHANGED
|
@@ -8,12 +8,18 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
+
- `checkModeRequested(env = process.env)` / `aikbRecordRequested(env = process.env)` → whether
|
|
12
|
+
`KISS_CHECK` / `KISS_AIKB` is on: anything but `''`, `'0'` or `'false'` (`CHECK_MODE_OFF`,
|
|
13
|
+
case-insensitive) counts, so `KISS_CHECK=true` cannot fall through to a real build over
|
|
14
|
+
published output. The engine's side of the protocol the bin sets up; they moved here from
|
|
15
|
+
`lib/kiss.js` (2026-10-02) when the method that reads `KISS_AIKB` left it for
|
|
16
|
+
`lib/build-finish.js`, so that no module has to import `kiss.js`.
|
|
11
17
|
- `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line. `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
|
|
12
18
|
- `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
|
|
13
19
|
- `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
|
|
14
20
|
- `readReportsFile(text)` → the same `BuildReport[]`, from any of the four shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, the `{ reports, diff }` object it prints whenever it diffs, or a single report object. The wrapper was once read as a single report with no pages, so a check's own output handed back as `--against` diffed every page as added (found upgrading K9-Solutions, 2026-09-27). What `--against` reads.
|
|
15
|
-
- `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure.
|
|
16
|
-
- `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line.
|
|
21
|
+
- `diffReports(before, after)` → one `CheckDiff` per `after` report, in `after` order: `{ buildDir, added, removed, changed, unchanged, assets }`, the three page lists holding output paths, sorted, and `assets` holding `{ source, from, to }` for every emitted asset whose filename moved. Pure. When the new report wrote learning packages (`packages` is an array), the entry also has `packages: { added, removed, changed, unchanged }`, the zips by `file`, compared by `hash` (a `null` hash is changed); a report with `packages: null` or none gets no key, so a build with packaging off, or one that failed, is never every package removed. The baseline's zips are read the same way, an older report having none.
|
|
22
|
+
- `formatDiff(diff)` → the human rendering of one `CheckDiff`: ` + <path>` added, ` - <path>` removed, ` ~ <path>` changed, then ` = N unchanged` — preceded, only when a page moved, by up to ten ` ~ asset <source> -> <target> (was <target>)` rows and a ` … and N more assets changed` tally, which open the block rather than close it. Indented to sit under that site's `formatReport` line. With `packages`, it then prints ` + package <file>`, ` - package <file>`, ` ~ package <file>` and ` = N packages unchanged`, after the pages: the rows that say which zips to upload again.
|
|
17
23
|
- `defaultBaseline(reports)` → `<folder>/last-build.json` for the first report whose `aikb.folder` is set, else `null`. The file `check` diffs against when `--against` was not given; the bin decides whether it exists, because a site that has never recorded is the ordinary case and not a finding.
|
|
18
24
|
- `recordedLine(report)` → the line `aikb --summary` prints under a site: ` recorded <folder>`, ` not recorded — build failed`, ` not recorded — folders.aikb is null`, or ` not recorded`.
|
|
19
25
|
- `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
|
|
@@ -27,7 +33,10 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
27
33
|
|
|
28
34
|
## Depended on by
|
|
29
35
|
|
|
30
|
-
`bin/kiss-ssg.js`.
|
|
36
|
+
`bin/kiss-ssg.js`. And the engine, for the other half of the protocol the bin starts:
|
|
37
|
+
`lib/kiss.js` reads `checkModeRequested()` in its constructor, and `lib/build-finish.js` reads
|
|
38
|
+
`aikbRecordRequested()` and `readReportsFile()` (the redirects baseline, read by the same code
|
|
39
|
+
`check --against` uses).
|
|
31
40
|
|
|
32
41
|
## Non-obvious behavior
|
|
33
42
|
|
package/AIKB/config.md
CHANGED
|
@@ -7,15 +7,17 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
9
|
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`), plus `aikb` (`./AIKB`), which is neither derived from `src` nor created on start-up.
|
|
10
|
-
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `markdown` (see `DEFAULT_MARKDOWN`), `links` (see `DEFAULT_LINKS`), `redirects` (see `DEFAULT_REDIRECTS`), `audit` (see `DEFAULT_AUDIT`), `markdownCopies` (see `DEFAULT_MARKDOWN_COPIES`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
11
|
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
12
|
- `DEFAULT_ASSETS` — frozen object, the asset block: the cache-busting policy for emitted assets (`hash: false`, `version: null`) plus `pipeline: []`, the ordered external commands run before the asset copy.
|
|
13
13
|
- `DEFAULT_MARKDOWN` — frozen object, the Remarkable options behind `.md` partials and the `{{markdown}}` helper: `html: true`, `xhtmlOut: true`, `breaks: false`.
|
|
14
14
|
- `DEFAULT_LINKS` — frozen object, the link block: `check: true`, which gates the broken-internal-link scan a settled non-dev build runs over its own output (`lib/links.js`, landing with the scan itself), `canonical: false`, which is the default for `{{link}}`'s `canonical` hash (`lib/handlebars-helpers.js`), and `trailingSlash: true`, the host's URL policy for a directory index — see the measured matrix in `AIKB/utils.md`.
|
|
15
15
|
- `DEFAULT_AUDIT` — frozen object, the audit block: `check: true`, which gates the launch-readiness audit a settled non-dev build runs over its own output (`lib/audit.js`), and `ignore: []`, the check ids not to run.
|
|
16
|
+
- `DEFAULT_MARKDOWN_COPIES` — frozen object, the Markdown-copy block: `write: 'llms'`, which writes a `.md` copy beside every HTML page (`lib/markdown-copy.js`) when the build calls `.llms()`, and `selector: 'main'`, the element converted (`<body>` when it matches nothing). `true` writes them regardless and `false` never. Until 2.8.0 the default was `true`: every site, `.llms()` or not, and every learning package's pages, carried copies nothing linked to (the operator, who runs every consuming site, 2026-10-25).
|
|
17
|
+
- `resolveMarkdownCopies(value)` → `{ write, selector }`. The `resolveAudit` shape, except that `true` is a statement rather than the default: `undefined` is the default block, `true` is `{ write: true, … }` (always), `false` is `{ write: false, selector: 'main' }`, a plain object is merged one level deep over the default; anything else throws, and so does a `write` other than `true`, `false` or `'llms'`, or an empty `selector`. Exported because a page's own `config.markdownCopies` arrives as the page wrote it — `false` on one page — and the page registry resolves it with this. An already-resolved block resolves to itself.
|
|
16
18
|
- `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`).
|
|
17
19
|
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed (preserving the leading UNC share prefix), trailing slashes stripped.
|
|
18
|
-
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
20
|
+
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch`, `assets`, `markdown` and `links` shallow-merged with their defaults. Keys whose value is `undefined` are dropped from the user object first (at the top level, inside `folders`, inside `sass`, inside `fetch`, inside `assets`, inside `markdown` and inside `links`), so they take their default. `audit` is resolved by `resolveAudit`: `true` or absent is the default block, `false` is `{ check: false, ignore: [] }`, and an object is merged one level deep over the default. `markdownCopies` is resolved by `resolveMarkdownCopies` the same way. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, on an `audit` that is neither a boolean nor a plain object, an `audit.ignore` that is not an array or names an id not in `CHECKS` (the message names the entry and lists every valid id), on an `assets.pipeline` that is not an array of `{ run: string }` steps, and on unsafe source/output folder layouts (below). Validation is read-only and runs before the constructor performs filesystem mutations.
|
|
19
21
|
- `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
|
|
20
22
|
- `foldersToEnsure(folders)` → array of the folder paths `Kiss` should `fs.ensureDirSync` on startup (`src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers`), filtered to drop falsy entries. **`aikb` is deliberately not among them** — see below.
|
|
21
23
|
|
|
@@ -25,10 +27,11 @@ Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFI
|
|
|
25
27
|
|
|
26
28
|
## Depended on by
|
|
27
29
|
|
|
28
|
-
`lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
|
|
30
|
+
`lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`); `lib/page-registry.js` (`resolveMarkdownCopies`, for a page's own `config.markdownCopies`).
|
|
29
31
|
|
|
30
32
|
## Non-obvious behavior
|
|
31
33
|
|
|
34
|
+
- **`packages` is a block or `false`.** `resolvePackages` merges `config.packages` over `DEFAULT_PACKAGES` (`{ format: 'imscp', dir: 'packages', title: null, only: null, schemas: false }`) and throws for an unknown format, a `dir` that is not a string, a `title` that is not a function, an `only` that is not a list of strings, and a `schemas` that is not `true` or `false`: each would otherwise be found only when the packages are written, after every page. `false` is kept as `false`, not merged, so the writer can tell "off" from "on with defaults"; `true` and nothing are the defaults. Any other value that is not a plain object (a string, an array, a number) throws rather than being spread: `packages: 'scorm12'` spread as the string's character indices, validated, and wrote IMS content packages for an author who asked for SCORM (code review, 2026-10-25). Packaging itself is by convention: the block only applies when a page names a `package` (`AIKB/packages.md`).
|
|
32
35
|
- **`cleanBuild` has three values, and the two that clean do it at different moments.** `true` (the default) calls `fs.emptyDirSync(folders.build)` in `_setupFolders`, i.e. **in the `Kiss` constructor** — before any model resolves and before any page renders — and nothing rolls it back if the build then fails. So a re-run of a build folder that holds published output destroys it first and, on a failure, leaves it empty or half-built (consumer review finding B-3). `false` never cleans. `'atomic'` builds into a staging sibling and swaps it in only when `complete()` resolves; the old output is untouched until that moment and survives a failed build. See `AIKB/kiss.md` for the staging mechanics — this module only carries the value.
|
|
33
36
|
- **The value is validated, not coerced.** Anything but `true`, `false` or `'atomic'` throws out of `resolveConfig` (so out of `new Kiss`), including `null` and `0`. A silently-falsy value is exactly the D-07 failure mode in a new coat: `{ cleanBuild: opts.clean }` with an unrecognised flag would quietly stop cleaning, or quietly stop staging.
|
|
34
37
|
- **Source/output safety is checked before any filesystem mutation**, for every `cleanBuild` value. `folders.src` cannot resolve to the working project root or a filesystem root. Neither can `folders.build`, regardless of whether `src` is disabled or elsewhere. The build must not equal or contain any configured source folder (`src`, its six derived folders, `helpers`, `aikb`), nor be nested inside an individual content folder. Only `src` allows output below it: `src/public` stays valid if separate from every content folder. Null folders are excluded.
|
|
@@ -16,8 +16,9 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
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`. **A page's own `canonical` option wins outright**: when the root context carries a non-empty `canonical` string it is rendered verbatim, before `siteUrl` or `pageURL` are consulted — the page has named another URL as the real one (learna-kiss: 104 course pages whose canonical is the same course on diploma-msc.com). The value is validated at registration (`Kiss._preparePage`, `AIKB/kiss.md`), not here, because the sitemap, `llms.txt` and the feed read the same option and withdraw the page.
|
|
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
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
|
-
- `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.
|
|
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. **On a page of a learning package** (the asking page's root context carries a string `package` and `config.packages` is not `false`) the link is `relativePathFor(asker.pageURL, pageURL)` instead: the file, relative to the page, with no collapse, canonical shape or trailing-slash policy, because an LMS serves a package's files as they are from wherever it unpacks them. `relative=` on the call decides it either way for any page; `absolute=true` always wins (code review, 2026-10-25).
|
|
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
|
+
- `scormBridge(options)` — the SCORM 1.2 bridge as one inline `<script>`, from `scormBridgeScript()` (`AIKB/scorm-bridge.md`). `complete="seen"` (the default) marks the page complete when it loads in an LMS; `complete="manual"` leaves it to `kissScorm.status()`. Any other value is an error: it logs and returns `''`, so a typo never ships a page that tracks differently from what the author asked.
|
|
21
22
|
|
|
22
23
|
## Depends on
|
|
23
24
|
|
|
@@ -57,7 +58,7 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
57
58
|
|
|
58
59
|
- **`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.
|
|
59
60
|
- **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`).
|
|
60
|
-
- **`link` renders no leading host, and a leading slash, where `asset` renders neither.** `{{asset}}` leaves the base to the template because an asset can legitimately be reached three ways; a link to a _page_ is a path from the site root, and `{{root}}`-style climbing would have to be re-derived per page from the id, which is the arithmetic the helper exists to remove. A site served from a path prefix, or a link that must be absolute (an OG tag, a feed), says so: `{{link "about" absolute=true}}`.
|
|
61
|
+
- **`link` renders no leading host, and a leading slash, where `asset` renders neither.** `{{asset}}` leaves the base to the template because an asset can legitimately be reached three ways; a link to a _page_ is a path from the site root, and `{{root}}`-style climbing would have to be re-derived per page from the id, which is the arithmetic the helper exists to remove. A site served from a path prefix, or a link that must be absolute (an OG tag, a feed), says so: `{{link "about" absolute=true}}`. The one exception is a learning-package page, where the helper does the climbing itself (`relativePathFor`), from the asking page's own URL rather than from the id; it is the only page an LMS serves away from the site root.
|
|
61
62
|
- **`absolute=true` is a string join, not `toAbsoluteUrl`.** `toAbsoluteUrl` strips a trailing `index` segment with _any_ extension, so a page built to `data/index.json` would be advertised at `https://site/data/` while its relative form stayed `/data/index.json` — two different URLs for one page, one of them a directory with no index in it, and the link checker would report the absolute one as broken. The join is `siteUrl` with trailing slashes trimmed + the already-collapsed served path, which is exactly what `classifyReference` recognises as internal for both `siteUrl` shapes (bare origin, and origin + path prefix).
|
|
62
63
|
|
|
63
64
|
## Types
|
package/AIKB/init.md
CHANGED
|
@@ -14,7 +14,7 @@ It exists so that a new user reaches a site through a coding agent — Claude Co
|
|
|
14
14
|
- `planInit(state)` → `InitAction[]`, in order: `package.json`, the starter files (or one `skip` for `router.js`), `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, then the install (or a `skip` for `node_modules/kiss-ssg` when `package.json` already asks for a version or under `--no-install`, or nothing at all when the engine is installed). An action is `{ kind: 'write', path, content, verb: 'create'|'merge'|'append', detail? }`, `{ kind: 'skip', path, reason }` or `{ kind: 'install', spec }`. `state` is `InitState`: the folder name, the running version, `install`, `hasEngine`/`hasRouter`/`hasSrc`, the current text (or `null`) of whether `package.json`'s `main` names a real file (`hasMain`), the current text (or `null`) of the six files it may touch (`package.json`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, `.gitignore`), and the starter as `{ relativePath: text }`.
|
|
15
15
|
- `describeAction(action)` → the printed line: ` create router.js`, ` merge package.json (added scripts.dev; kept scripts.build)`, ` skip CLAUDE.md — already points at llms.txt`, ` install kiss-ssg@2.7.0`.
|
|
16
16
|
- `nextSteps()` → the closing text: the Claude Code block (`CLAUDE_STEPS`, then "Run \`claude\` in this folder."), the Codex block (`CODEX_STEPS`, said to install once per user, then "Run \`codex\` in this folder."), then paste `FIRST_PROMPT` to start a site or `IMPORT_PROMPT` to turn one the person already has — most often a link to a Claude or ChatGPT site — into a kiss one. Only the first was offered until 2026-10-02, so the person arriving with a site was told how to describe it. The commands come first because Claude Code does not offer to install plugins a project's `.claude/settings.json` declares (observed 2026-09-26 on a clean profile) — the declaration records which skills the site uses; it installs nothing on its own.
|
|
17
|
-
- `CLAUDE_STEPS` / `CODEX_STEPS` (`string[]`) — each agent's three plugin lines and nothing else (no `mkdir`, no `init`, no bare `claude`/`codex`): `claude plugin marketplace add cprobert/kiss-ssg --scope project` plus a `claude plugin install <plugin> --scope project` per plugin, and `codex plugin marketplace add cprobert/kiss-ssg` plus a `codex plugin add <plugin>` per plugin. Both are derived from `MARKETPLACE_REPO` and `PLUGINS`. They are the one source the README's two Quick start blocks, the docs homepage's
|
|
17
|
+
- `CLAUDE_STEPS` / `CODEX_STEPS` (`string[]`) — each agent's three plugin lines and nothing else (no `mkdir`, no `init`, no bare `claude`/`codex`): `claude plugin marketplace add cprobert/kiss-ssg --scope project` plus a `claude plugin install <plugin> --scope project` per plugin, and `codex plugin marketplace add cprobert/kiss-ssg` plus a `codex plugin add <plugin>` per plugin. Both are derived from `MARKETPLACE_REPO` and `PLUGINS`. They are the one source the README's two Quick start blocks, the docs homepage's Claude Code and Codex boxes and `llms.txt` are tested against.
|
|
18
18
|
- Constants: `MARKETPLACE`, `MARKETPLACE_REPO`, `PLUGINS`, `LLMS_IMPORT`, `STARTER_MARKER`, `STARTER_RENAMES`, `FIRST_PROMPT`, `IMPORT_PROMPT`.
|
|
19
19
|
|
|
20
20
|
## Depends on
|
package/AIKB/kiss-page.md
CHANGED
|
@@ -19,17 +19,21 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
19
19
|
- `hash` — sha1 (hex) of the bytes `generate()` last wrote, or `null` if it has not written any. `BuildPage.hash` is this value (`AIKB/build-report.md`).
|
|
20
20
|
- `links` — every reference the bytes `generate()` last wrote ask a browser to fetch (`href`/`src`/`srcset`/`action`, raw, deduplicated and sorted), or `null` if it has not written any. `extractReferences` (`AIKB/links.md`) produces it; `Kiss._checkLinks()` resolves it.
|
|
21
21
|
- `audit` — the launch-readiness facts of the bytes `generate()` last wrote (`PageFacts`: title, description, `og:image`, canonical, favicon link, images with no alt, the heading outline), or `null` if it has not written any. `extractPageFacts(minifiedHtml, resolvedTarget)` (`AIKB/audit.md`) produces it beside `links`; `Kiss._runAudit()` decides the findings.
|
|
22
|
-
- `
|
|
22
|
+
- `markdownCopy` — `{ selector }` when this page writes a Markdown copy, `null` when it does not (the default for a page built outside `Kiss`). Set by the page registry from the page's own resolved `config.markdownCopies` (`AIKB/page-registry.md`).
|
|
23
|
+
- `markdownTo` (getter) — where the copy will be, in `buildTo`'s spelling (`markdownCopyPath(buildTo)`, `AIKB/markdown-copy.md`), or `null`: copies off, a page that is not HTML, `generate: false`, or (under the default `write: 'llms'`) a build that has not called `.llms()` (`llmsRequested()`). Decided from settings, not from a write, because `.llms()` links to it before any page renders.
|
|
24
|
+
- `markdown` — the copy `generate()` last wrote, in `buildTo`'s spelling, or `null`. `BuildPage.markdown` is this value.
|
|
25
|
+
- `async writeMarkdownCopy()` — writes the copy of a page already written without one, from the HTML on disk, with a render's own conversion and claim: a page that rendered before the build called `.llms()` (from a `generate()` callback), whose `markdownTo` now names a copy `llms.txt` will link. A no-op for a page not written, one with its copy, or one that should have none. `Kiss._writeMissingCopies()` calls it for every page before `llms.txt` is written (Codex review, 2026-10-25: such a page was linked to a copy never written).
|
|
26
|
+
- `async generate()` — compiles the template, renders with `this.options`, injects the livereload script in dev, minifies in production (dev skips minification entirely), asserts the resolved `buildTo` is inside the resolved `buildDir`, converts the bytes to Markdown when `markdownTo` is set, writes the file (records its `hash`), then the copy beside it, and, in dev, the debug `.json`; returns `this.buildTo`. Logs _and rethrows_ render/minify/escape/output-write errors.
|
|
23
27
|
- `loadMinifier()` — the one shared `Promise` for the `html-minifier-terser` import, resolving to its `minify`; every render awaits it.
|
|
24
28
|
- `preloadMinifier()` — starts that load without awaiting it, swallowing a failure that `loadMinifier()` will re-surface at the render that needs it. Called by the `Kiss` constructor for non-dev builds.
|
|
25
29
|
|
|
26
30
|
## Depends on
|
|
27
31
|
|
|
28
|
-
`fs-extra`, `node:path`, `node:crypto` (`createHash`, for the output `hash`), `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./links.js` (`extractReferences`, run over the written HTML — `AIKB/links.md`), `./audit.js` (`extractPageFacts`, over the same bytes — `AIKB/audit.md`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`). The dependency graph arrives as `deps.graph` (`AIKB/dependency-graph.md`) and is referenced only by a type-only `import('./dependency-graph.js')` in the JSDoc — there is no runtime import of that module here.
|
|
32
|
+
`fs-extra`, `node:path`, `node:crypto` (`createHash`, for the output `hash`), `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./links.js` (`extractReferences`, run over the written HTML — `AIKB/links.md`), `./audit.js` (`extractPageFacts`, over the same bytes — `AIKB/audit.md`), `./markdown-copy.js` (`markdownCopyPath`, `toMarkdown` — `AIKB/markdown-copy.md`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`). The dependency graph arrives as `deps.graph` (`AIKB/dependency-graph.md`) and is referenced only by a type-only `import('./dependency-graph.js')` in the JSDoc — there is no runtime import of that module here.
|
|
29
33
|
|
|
30
34
|
## Depended on by
|
|
31
35
|
|
|
32
|
-
`lib/
|
|
36
|
+
`lib/page-registry.js` (`_preparePage` constructs and stacks a `KissPage`), and `lib/kiss.js` (`preloadMinifier`).
|
|
33
37
|
|
|
34
38
|
## Non-obvious behavior
|
|
35
39
|
|
|
@@ -43,6 +47,7 @@ One page's render logic: resolving its title/slug/path/extension, compiling and
|
|
|
43
47
|
- **`generate()` refuses to write outside the build folder**: immediately before `fs.outputFile` it compares `path.resolve(buildTo)` against `path.resolve(buildDir)` and throws `Refusing to write outside the build folder: <buildTo>` otherwise. Belt-and-braces behind the sanitised setters — nothing reachable through them should trip it — and it throws inside `generate()`'s try, so it is logged and collected like any other render failure rather than being a silent write.
|
|
44
48
|
- **`hash` names the output, not the inputs.** It is the sha1 of `minifiedHtml` — the exact string handed to `fs.outputFile` — taken immediately _after_ the write resolves, so it can only exist for bytes that reached the disk. Hashing the render inputs (the template text plus the options) would have been cheaper to reason about and wrong in both directions: a template refactor that produces identical HTML would read as a change, and a minifier upgrade that changes every byte would read as none. It is set to `null` at the top of `generate()`, before anything is rendered, rather than in the catch — every path out of that `try` rethrows, so one clear-before is the only place that can promise "no hash unless _these_ bytes are on disk". A page with `generate: false` never enters the branch, so its hash stays `null` from construction. The consumer is `diffReports` in `lib/check.js`, which is why `null` has to mean "nothing to compare" and not "empty output": it counts a `null` on either side as changed.
|
|
45
49
|
- **`links` is extracted here rather than by reading the page back, and it is `null` exactly when `hash` is.** Both are set immediately after the write resolves and both are cleared at the top of `generate()`, so neither can describe bytes that never reached the disk. The extraction happens at write time because the consumer cannot do it later: under `kiss-ssg check` (and after a failed atomic build) the whole staging folder is deleted before the report is assembled, so a scan that read the HTML back would have found nothing — see `AIKB/links.md` and the `_checkLinks()` bullet in `AIKB/kiss.md`. What is kept is the reference strings, a few hundred bytes a page, never the document: retaining the HTML of a 500-page site to resolve links afterwards would be ~15 MB. The injected dev livereload `<script>` contributes nothing to the list: it carries no `src` attribute, building its URL in code instead, and `extractReferences` drops inline script bodies anyway — and a dev build is never scanned in the first place.
|
|
50
|
+
- **The Markdown copy is converted before anything is written, and a conversion failure fails the page.** `toMarkdown` runs on the bytes about to be written — in dev that includes the live reload `<script>`, which the conversion drops with every script — before the HTML's `fs.outputFile`, so a page whose copy cannot be made writes neither file and is reported like any other failed page. The alternative (log it, keep the HTML) would leave `llms.txt` linking a copy that is not there on a build that reported success, because `.llms()` derives its links from `markdownTo` before the render. The copy is claimed in the output registry under the page's own owner and kind `'page'`, so a whole-site replay sweeps it beside its page (`AIKB/rebuild.md`). `markdown` is cleared with `hash` at the top of `generate()` and set only once the copy's write resolves.
|
|
46
51
|
- **`audit` follows `links` in every respect**: set on the line after it, from the same minified bytes, cleared beside it at the top of `generate()`, so it too is `null` exactly when `hash` is. It is handed the resolved output path as well as the bytes, because the extension decides first whether the file is a page at all — an `ext: 'json'` page is data whatever it contains. It is extracted in dev too, like `links`, although no dev build is audited: the cost is a few regex passes, and a field whose presence depended on the mode would be one more thing to reason about.
|
|
47
52
|
- `fs.outputFile`/`fs.outputJson` writes are `await`ed — `generate()` does not resolve until the file (and, in dev, the debug JSON) is actually on disk.
|
|
48
53
|
- **The injected livereload `<script>` resolves its host at runtime, in the browser.** It is a short inline script that builds `http://<location.hostname || 'localhost'>:<livereloadPort>/livereload.js?snipver=1` and appends a `<script>` element via `document.createElement` + `document.body.appendChild`. Two reasons for the shape: the URL used to hardcode `localhost`, so with `devHost: '0.0.0.0'` a page opened from another device asked _that device's_ localhost and live reload silently did nothing; and the element is appended rather than written with `document.write` (livereload's own README snippet), which browsers warn about for injected scripts. `?snipver=1` is livereload's protocol marker and must stay. The script survives verbatim because dev mode skips `minifyJS`.
|