kiss-ssg 1.4.2 → 2.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AIKB/asset-manifest.md +32 -0
- package/AIKB/assets.md +36 -0
- package/AIKB/build-report.md +38 -0
- package/AIKB/check.md +34 -0
- package/AIKB/config.md +52 -0
- package/AIKB/controller-resolver.md +31 -0
- package/AIKB/dev-server.md +36 -0
- package/AIKB/fetch-policy.md +27 -0
- package/AIKB/handlebars-helpers.md +56 -0
- package/AIKB/kiss-page.md +50 -0
- package/AIKB/kiss.md +119 -0
- package/AIKB/logger.md +26 -0
- package/AIKB/model-resolver.md +36 -0
- package/AIKB/partials.md +31 -0
- package/AIKB/sass.md +34 -0
- package/AIKB/sitemap.md +26 -0
- package/AIKB/testing.md +44 -0
- package/AIKB/utils.md +45 -0
- package/AIKB/watcher.md +41 -0
- package/README.md +425 -22
- package/bin/kiss-ssg.js +61 -0
- package/examples/1-scan/README.md +20 -0
- package/examples/1-scan/models/about/us.json +26 -1
- package/examples/1-scan/models/index.json +18 -1
- package/examples/1-scan/pages/about/us.hbs +30 -4
- package/examples/1-scan/pages/index.hbs +41 -4
- package/examples/1-scan.js +24 -2
- package/examples/2-page/README.md +20 -0
- package/examples/2-page/controllers/about.js +6 -3
- package/examples/2-page/controllers/index.js +10 -3
- package/examples/2-page/models/about.json +9 -2
- package/examples/2-page/models/index.json +34 -2
- package/examples/2-page/pages/about.hbs +35 -2
- package/examples/2-page/pages/feed.hbs +9 -0
- package/examples/2-page/pages/index.hbs +58 -6
- package/examples/2-page.js +54 -21
- package/examples/3-pages/README.md +21 -0
- package/examples/3-pages/models/index.json +20 -0
- package/examples/3-pages/models/roasts/arch-blend.json +12 -0
- package/examples/3-pages/models/roasts/finca-la-ilusion.json +12 -0
- package/examples/3-pages/models/roasts/guji-uraga.json +12 -0
- package/examples/3-pages/models/roasts/huila-decaf.json +12 -0
- package/examples/3-pages/models/roasts/nyeri-gatomboya.json +12 -0
- package/examples/3-pages/models/roasts/sumatra-ketiara.json +12 -0
- package/examples/3-pages/pages/index.hbs +42 -1
- package/examples/3-pages/pages/roasts/index.hbs +22 -0
- package/examples/3-pages/pages/roasts/roast.hbs +41 -0
- package/examples/3-pages.js +49 -38
- package/examples/4-layouts-and-partials/README.md +20 -0
- package/examples/4-layouts-and-partials/layouts/layout.hbs +40 -8
- package/examples/4-layouts-and-partials/pages/index.hbs +58 -17
- package/examples/4-layouts-and-partials/pages/stockists.hbs +32 -0
- package/examples/4-layouts-and-partials/partials/boards/espresso.hbs +9 -0
- package/examples/4-layouts-and-partials/partials/boards/filter.hbs +9 -0
- package/examples/4-layouts-and-partials/partials/hours.html +26 -0
- package/examples/4-layouts-and-partials/partials/site/footer.hbs +6 -0
- package/examples/4-layouts-and-partials/partials/site/header.hbs +18 -0
- package/examples/4-layouts-and-partials/partials/sourcing.md +9 -0
- package/examples/4-layouts-and-partials.js +37 -6
- package/examples/5-helpers/README.md +21 -0
- package/examples/5-helpers/pages/brew-guide.hbs +34 -0
- package/examples/5-helpers/pages/index.hbs +129 -47
- package/examples/5-helpers/partials/grinder-note.md +3 -0
- package/examples/5-helpers/partials/tasting.md +3 -0
- package/examples/5-helpers/styles/board.scss +19 -0
- package/examples/5-helpers.js +62 -10
- package/examples/6-sitemap/README.md +20 -0
- package/examples/6-sitemap/models/about.json +6 -0
- package/examples/6-sitemap/models/index.json +6 -0
- package/examples/6-sitemap/models/rota.json +6 -0
- package/examples/6-sitemap/models/stockists.json +6 -0
- package/examples/6-sitemap/pages/about.hbs +22 -0
- package/examples/6-sitemap/pages/index.hbs +54 -0
- package/examples/6-sitemap/pages/rota.hbs +22 -0
- package/examples/6-sitemap/pages/stockists.hbs +22 -0
- package/examples/6-sitemap.js +55 -0
- package/examples/7-versioned-outputs/README.md +25 -0
- package/examples/7-versioned-outputs/models/index.json +23 -0
- package/examples/7-versioned-outputs/pages/index.hbs +47 -0
- package/examples/7-versioned-outputs.js +126 -0
- package/examples/8-data-fed-site/README.md +45 -0
- package/examples/8-data-fed-site/controllers/stockist.js +30 -0
- package/examples/8-data-fed-site/models/stockists/bakers-yard.json +11 -0
- package/examples/8-data-fed-site/models/stockists/cargo-canteen.json +11 -0
- package/examples/8-data-fed-site/models/stockists/harbour-market-stall.json +10 -0
- package/examples/8-data-fed-site/models/stockists/oakfield-deli.json +11 -0
- package/examples/8-data-fed-site/models/stockists/severn-provisions.json +11 -0
- package/examples/8-data-fed-site/models/stockists/the-hop-and-vine.json +11 -0
- package/examples/8-data-fed-site/pages/index.hbs +80 -0
- package/examples/8-data-fed-site/pages/stockists/stockist.hbs +38 -0
- package/examples/8-data-fed-site.js +84 -0
- package/examples/9-migrated-from-v1/README.md +34 -0
- package/examples/9-migrated-from-v1/controllers/shelf-item.js +6 -0
- package/examples/9-migrated-from-v1/pages/await-complete.hbs +71 -0
- package/examples/9-migrated-from-v1/pages/duplicate-paths.hbs +64 -0
- package/examples/9-migrated-from-v1/pages/dynamic-partials.hbs +54 -0
- package/examples/9-migrated-from-v1/pages/folders.hbs +65 -0
- package/examples/9-migrated-from-v1/pages/handlebars-instance.hbs +62 -0
- package/examples/9-migrated-from-v1/pages/index.hbs +57 -0
- package/examples/9-migrated-from-v1/pages/pure-controllers.hbs +65 -0
- package/examples/9-migrated-from-v1/pages/shelf/item.hbs +19 -0
- package/examples/9-migrated-from-v1/pages/smaller-changes.hbs +75 -0
- package/examples/9-migrated-from-v1/partials/sourcing-note.md +5 -0
- package/examples/9-migrated-from-v1/partials/stock-note.hbs +2 -0
- package/examples/9-migrated-from-v1.js +187 -0
- package/examples/README.md +48 -0
- package/examples/_shared/assets/css/site.scss +413 -0
- package/examples/{1-scan → _shared}/assets/robots.txt +1 -1
- package/examples/_shared/layouts/layout.hbs +65 -0
- package/examples/_shared/partials/newsletter.md +5 -0
- package/examples/_shared/site.js +39 -0
- package/lib/asset-manifest.js +52 -0
- package/lib/assets.js +108 -0
- package/lib/build-report.js +155 -0
- package/lib/check.js +124 -0
- package/lib/config.js +280 -0
- package/lib/controller-resolver.js +88 -0
- package/lib/dev-server.js +87 -0
- package/lib/fetch-policy.js +82 -0
- package/lib/handlebars-helpers.js +263 -0
- package/lib/kiss-page.js +285 -0
- package/lib/kiss.js +1521 -0
- package/lib/logger.js +33 -0
- package/lib/model-resolver.js +61 -0
- package/lib/partials.js +75 -0
- package/lib/sass.js +163 -0
- package/lib/sitemap.js +57 -0
- package/lib/utils.js +165 -0
- package/lib/watcher.js +154 -0
- package/llms.txt +161 -0
- package/package.json +61 -16
- package/types/asset-manifest.d.ts +9 -0
- package/types/assets.d.ts +21 -0
- package/types/build-report.d.ts +113 -0
- package/types/check.d.ts +53 -0
- package/types/config.d.ts +290 -0
- package/types/controller-resolver.d.ts +12 -0
- package/types/dev-server.d.ts +11 -0
- package/types/fetch-policy.d.ts +1 -0
- package/types/handlebars-helpers.d.ts +22 -0
- package/types/kiss-page.d.ts +41 -0
- package/types/kiss.d.ts +408 -0
- package/types/logger.d.ts +29 -0
- package/types/model-resolver.d.ts +18 -0
- package/types/partials.d.ts +7 -0
- package/types/sass.d.ts +35 -0
- package/types/sitemap.d.ts +14 -0
- package/types/utils.d.ts +73 -0
- package/types/watcher.d.ts +12 -0
- package/.eslintrc.js +0 -14
- package/.prettierrc +0 -14
- package/.vscode/settings.json +0 -11
- package/TODO.md +0 -1
- package/docs/css/blog.css +0 -106
- package/docs/index.html +0 -285
- package/docs.js +0 -11
- package/examples/1-scan/assets/js/app.js +0 -1
- package/examples/1-scan/assets/sass/another-asset.txt +0 -0
- package/examples/1-scan/assets/sass/styles.scss +0 -25
- package/examples/1-scan/assets/test.txt +0 -2
- package/examples/2-page/assets/robots.txt +0 -1
- package/examples/2-page/pages/xml.hbs +0 -3
- package/examples/3-pages/pages/courses/course.hbs +0 -5
- package/examples/3-pages/pages/courses/index.hbs +0 -6
- package/examples/4-layouts-and-partials/partials/dynamic.hbs +0 -3
- package/examples/4-layouts-and-partials/partials/hbeg.hbs +0 -3
- package/examples/4-layouts-and-partials/partials/htmleg.html +0 -3
- package/examples/4-layouts-and-partials/partials/mdeg.md +0 -8
- package/examples/5-helpers/assets/js/script1.js +0 -3
- package/examples/5-helpers/assets/js/sub/script2.js +0 -3
- package/examples/5-helpers/assets/sass.scss +0 -5
- package/examples/5-helpers/assets/script3.js +0 -3
- package/examples/5-helpers/partials/dynamic.md +0 -4
- package/examples/5-helpers/partials/markdown.md +0 -1
- package/kiss-serve.js +0 -36
- package/kiss-ssg.js +0 -908
- package/libs/on-ice/bundeler.txt +0 -114
- package/libs/on-ice/helpers.js +0 -15
- package/libs/utils.js +0 -83
- package/src/assets/css/blog.css +0 -106
- package/src/layouts/layout.hbs +0 -75
- package/src/pages/index.hbs +0 -23
- package/src/partials/home/jumbotron.hbs +0 -8
- package/src/partials/layout/aside.hbs +0 -14
- package/src/partials/layout/featured.hbs +0 -30
- package/src/partials/layout/footer.hbs +0 -8
- package/src/partials/layout/header.hbs +0 -13
- package/src/partials/layout/nav.hbs +0 -7
- package/src/partials/readme.md +0 -199
- package/src/partials/section.hbs +0 -6
- package/src/partials/usage/controllers.md +0 -25
- package/src/partials/usage/helpers.md +0 -12
- package/src/partials/usage/methods.md +0 -83
- package/src/partials/usage/setup.md +0 -49
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# asset-manifest.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Holds what an asset copy emitted — the path a template writes mapped to the file that is actually in the build — and owns the naming half of the cache-busting policy (content hash, hashed filename, which files may be renamed at all).
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `HASH_LENGTH` — `8`, the number of hex characters a content hash contributes to a filename.
|
|
10
|
+
- `isHashable(urlPath)` → boolean. True for `.css` and `.js` (case-insensitive) only.
|
|
11
|
+
- `contentHash(bytes)` → the first `HASH_LENGTH` hex characters of the MD5 of `bytes` (a `Buffer` or a string).
|
|
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()` → `{ record, lookup, toObject }`, a fresh manifest with its own `Map`.
|
|
14
|
+
- `record(urlPath, emittedPath)` — stores the mapping and returns the **previous** `emittedPath` when it differs from the new one (the caller's cue to delete the file it just replaced), otherwise `null`.
|
|
15
|
+
- `lookup(urlPath)` — the emitted path, or `null`.
|
|
16
|
+
- `toObject()` — a plain object copy of the map, for debugging.
|
|
17
|
+
|
|
18
|
+
## Depends on
|
|
19
|
+
|
|
20
|
+
`node:crypto`.
|
|
21
|
+
|
|
22
|
+
## Depended on by
|
|
23
|
+
|
|
24
|
+
`lib/assets.js` (records and renames), `lib/handlebars-helpers.js` (the `asset` helper reads it), `lib/kiss.js` (creates the one per instance and threads it into both).
|
|
25
|
+
|
|
26
|
+
## Non-obvious behavior
|
|
27
|
+
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **`record` returning the old name is the whole stale-file mechanism.** Nothing else knows which file a rename replaced: `Kiss._replay()`'s orphan sweep only ever considers page outputs (`_stack` `buildTo` paths), so a hashed asset is invisible to it. `lib/assets.js` deletes what `record` hands back.
|
|
31
|
+
- **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.
|
|
32
|
+
- MD5 is a fingerprint, not a security claim — it is comparing a file to its own previous bytes.
|
package/AIKB/assets.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# assets.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Compiles every Sass file under the assets folder to a sibling `.css` file in the build folder, copies everything else (excluding raw Sass sources) straight through, and records what it emitted in the instance's asset manifest — applying the renaming half of the cache-busting policy on the way.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `compileSassFiles(sourceDir, targetDir, { config, logger })` → array of promises (one per `.scss`/`.sass` file found under `sourceDir`), each resolving after that file's compiled CSS is written to `targetDir` (mirroring the relative path, extension swapped to `.css`). Compile errors are caught and logged per-file; the corresponding promise still resolves.
|
|
10
|
+
- `copyAssets(sourceDir, targetDir, { config, logger, manifest })` → `Promise<{ id, data }>` (or `{ id, data: null, error }` on copy failure). `id` is `hashId(\`${sourceDir} - ${targetDir}\`)`. Awaits all Sass compilation, then `fs.copy(sourceDir, targetDir, { filter: ... })`excluding`.scss`/`.sass`files, then records every emitted file in `manifest`. Resolves to`{ id, data: null }`immediately if either directory is falsy.`manifest`is optional — a throwaway one is created when it is absent, so the function is usable standalone — but`Kiss` always passes its own.
|
|
11
|
+
|
|
12
|
+
## Depends on
|
|
13
|
+
|
|
14
|
+
`fs-extra`, `node:path` (the CSS target arithmetic below); `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, `contentHash`, `hashedName`, `isHashable`); `./utils.js` (`globFiles`, `posixPath`, `hashId`).
|
|
15
|
+
|
|
16
|
+
## Depended on by
|
|
17
|
+
|
|
18
|
+
`lib/kiss.js`.
|
|
19
|
+
|
|
20
|
+
## Non-obvious behavior
|
|
21
|
+
|
|
22
|
+
- `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.
|
|
23
|
+
- **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.
|
|
24
|
+
- Sass sources (`.scss`/`.sass`) are explicitly excluded from the plain `fs.copy` pass — they're only ever emitted as compiled `.css`, never copied verbatim.
|
|
25
|
+
- Sass compilation is `await`ed inside `copyAssets` (via `await Promise.all(compileSassFiles(...))`) and is therefore tracked as part of the single promise `Kiss.copyAssets()` pushes onto `_promises` — v1 fired the CSS write and never tracked it, so a build could finish (and `generate()`/`complete()` resolve) before Sass output existed.
|
|
26
|
+
- The returned `{ id, data }` shape is the same shape `Kiss.generate(callback)`'s data array element takes — `id` lets `Kiss.getModelByID` (or manual inspection) find this asset-copy result among the other resolved `_promises`.
|
|
27
|
+
- The sass compiler is imported from `./sass.js`, never from `sass` directly: which export carries the modern API depends on the installed sass version, and that detection lives in one place (see `AIKB/sass.md`).
|
|
28
|
+
- **The manifest is keyed by the path a template writes, not by the source file.** A `.scss` source is recorded under its compiled `.css` name, so `{{asset "css/site.css"}}` resolves and a template never has to know which policy is on (that is the point of gap O6: the template stops encoding the caching scheme). Keys are relative to `config.folders.build`, not to this copy's target, so an extra `copyAssets(src, './public/extra')` records `extra/x.css` — the URL a page asks for. A target outside the build folder falls back to target-relative keys.
|
|
29
|
+
- **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.
|
|
30
|
+
- **A hash is taken over the emitted bytes, never the source.** A stylesheet is hashed after sass compiled it (and after `dev` chose `expanded` over `compressed`), so the name changes exactly when the file a browser downloads changes.
|
|
31
|
+
- **Renaming is a post-pass over the plain copy, not a different copy.** `fs.copy` writes `css/site.css`, the pass then reads it, hashes it and `fs.move`s it to `css/site.<hash>.css`. Keeping the copy itself untouched is what makes the hashing-off path provably the old one.
|
|
32
|
+
- **The stale hashed file is deleted here, because nothing else can.** `Kiss._replay()`'s orphan sweep only knows page outputs; a renamed asset never appears in `_stack`. `manifest.record()` returns the name it replaced and this module removes that file, so a watch-mode stylesheet edit leaves one hashed CSS in the build, not one per save.
|
|
33
|
+
- **One `fs.stat` per globbed source path decides what was emitted**, and it covers two cases at once: a directory the glob returned, and a `.scss` whose compile failed (its `.css` was never written). Neither is a file in the build, so neither is recorded — a broken sass file still logs its own error and leaves the copy successful.
|
|
34
|
+
- `config.assets.hash` and `config.assets.version` are mutually exclusive in effect: with both set the hashed filename wins, `version` is ignored, and one warning says so. The `version` half is the helper's job — nothing is renamed for it — so this module only warns.
|
|
35
|
+
- `copyAssets()` itself has no concurrency guard — two calls given overlapping source/target trees (e.g. one's target a subdirectory of the other's source) can run their `fs.copy` walks simultaneously and produce `ENOENT` failures mid-walk. `Kiss.copyAssets()` is the module's only caller and is responsible for serializing calls (via `_assetQueue`) so this function is never actually invoked concurrently in practice; this module makes no such guarantee on its own.
|
|
36
|
+
- **Sass is compiled through `compileFile()`, which loads the package at the point of compile and memoises the result**, not bound at import — see `AIKB/sass.md`. A site with no `.scss` under its assets folder never loads the `sass` package at all.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# build-report.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report()` hands out, `err.report` carries, `KISS_REPORT` writes and `kiss-ssg check` prints — plus the one-line human rendering of it. Pure: every input is passed in, nothing here reads or writes anything.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap })` → `BuildReport`, whose keys are always in this order:
|
|
10
|
+
- `ok` — `failures.length === 0`.
|
|
11
|
+
- `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
|
|
12
|
+
- `buildDir` — the folder the site asked for, never the staging sibling.
|
|
13
|
+
- `duration` — ms since `startedAt` (`Kiss` passes its construction time).
|
|
14
|
+
- `pages` — `{ view, buildTo, ok }` per stack entry, in registration order; `ok` is `false` when a failure names that output path.
|
|
15
|
+
- `failures` — `{ view, buildTo, message }` per entry of `Kiss._failures`; the `Error` itself is not in the report.
|
|
16
|
+
- `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
|
|
17
|
+
- `sitemap` — the `sitemap.xml` this build wrote, or `null`.
|
|
18
|
+
- `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. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
19
|
+
|
|
20
|
+
## Depends on
|
|
21
|
+
|
|
22
|
+
Nothing.
|
|
23
|
+
|
|
24
|
+
## Depended on by
|
|
25
|
+
|
|
26
|
+
`lib/kiss.js` (assembles one report per settled build in `_finishBuild()`), `bin/kiss-ssg.js` (`formatReport` for `--summary`).
|
|
27
|
+
|
|
28
|
+
## Non-obvious behavior
|
|
29
|
+
|
|
30
|
+
- **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo` and `sitemap` all go through the same internal `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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **`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`.
|
|
33
|
+
- **A failure with no output path (`buildTo: null`) marks no page as failed** — a `.pages()` item, a controller, a callback or the dev server never reached the stack, so no `pages` entry belongs to it. It is still in `failures`, and it still makes `ok` false.
|
|
34
|
+
- **Key order is fixed** because the report is read as text at least as often as it is read as data — a diff of two runs should show what changed in the build, not a reshuffle.
|
|
35
|
+
|
|
36
|
+
## Types
|
|
37
|
+
|
|
38
|
+
`BuildReport`, `BuildPage`, `BuildAsset` and `BuildReportFailure` are declared here and re-exported from `lib/kiss.js` (`@typedef {import('./build-report.js').BuildReport} BuildReport`), the same way `KissConfig` is re-exported from `lib/config.js` — so a consumer reaches every one of them as `import('kiss-ssg').BuildReport`.
|
package/AIKB/check.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# check.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
The decision core of the `kiss-ssg` command line: what an argv asked for, what the reports file says, and what to exit with. `bin/kiss-ssg.js` is the thin wrapper around it — it spawns the site's own build script, makes the temp file and prints; every answer it acts on comes from here.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `HELP` — the usage text both `--help` and a usage error print.
|
|
10
|
+
- `parseArgs(argv)` → `{ command, script, args, summary, error }`. `command` is `'check'` 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; `error` is a usage error (print it with `HELP` and exit 1).
|
|
11
|
+
- `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
|
|
12
|
+
- `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`.
|
|
13
|
+
|
|
14
|
+
## Depends on
|
|
15
|
+
|
|
16
|
+
Nothing (`readReports`' return type refers to `./build-report.js`, but only as a type).
|
|
17
|
+
|
|
18
|
+
## Depended on by
|
|
19
|
+
|
|
20
|
+
`bin/kiss-ssg.js`.
|
|
21
|
+
|
|
22
|
+
## Non-obvious behavior
|
|
23
|
+
|
|
24
|
+
- **Everything after the script is passed through to it, `--summary` excepted.** A site that takes its own arguments (`kiss-ssg check menu.js 2026-spring`) is checked exactly the way it is run; `--summary` is read wherever it is written, because "check this and summarise it" is a thought people finish at the end of the line. That leaves one word reserved, so a bare `--` ends the options and hands the rest through verbatim (`kiss-ssg check menu.js -- --summary`) for the site that genuinely needs it. Before the script an unrecognised flag is a usage error instead: there is no script yet for it to belong to.
|
|
25
|
+
- **No reports at all is a failure, not a pass.** A script that never awaits `complete()` writes no line, and that is the very silence the check exists to break: it exits 0 on a broken site. `exitCodeFor` treats an empty list as `1` and the bin says why on stderr.
|
|
26
|
+
- **The script's own exit code is honoured too.** A site that catches its build failure and exits 1 itself, or one that dies before it ever builds, fails the check whatever the reports say.
|
|
27
|
+
- **A malformed line throws rather than being skipped.** A report nobody can parse is a finding — dropping it silently would turn a corrupt run into "nothing was reported", or worse into a pass.
|
|
28
|
+
- **The bin sends the child's stdout to its own stderr** (`stdio: [0, 2, 2]`). Plain `'inherit'` would interleave the site's build log with the JSON on stdout and nothing could parse it; a person still sees the whole build, on stderr.
|
|
29
|
+
- **The bin runs the script from the caller's cwd**, not from the script's folder: a site resolves `folders` against the cwd it is normally run from (`cd examples && node 8-data-fed-site.js`), so moving it would check a different set of paths from the ones it builds with.
|
|
30
|
+
- **The temp reports file is removed in a `finally`**, and the bin sets `process.exitCode` rather than calling `process.exit()`, so the cleanup runs before the process leaves.
|
|
31
|
+
|
|
32
|
+
## Types
|
|
33
|
+
|
|
34
|
+
`types/check.d.ts` is emitted from the JSDoc here like every other `lib/` module, but nothing imports it: the CLI surface is the command, not the module. `CheckArgs` is documented for the reader of this file.
|
package/AIKB/config.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# config.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Resolves a user-supplied config object against defaults: fills in `DEFAULT_CONFIG`/`DEFAULT_FOLDERS`, derives the folder set from `folders.src` when given, and lists which folders `Kiss` must create on startup.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `DEFAULT_FOLDERS` — frozen object: `src`, `pages`, `build`, `assets`, `layouts`, `partials`, `models`, `controllers` (all `./src/...` or `./public`).
|
|
10
|
+
- `DEFAULT_CONFIG` — frozen object: `dev`, `verbose`, `cleanBuild` (`true`, `false` or `'atomic'`), `extensionLess`, `sass: { includePaths: [] }`, `fetch` (see `DEFAULT_FETCH`), `assets` (see `DEFAULT_ASSETS`), `port` (3001), `livereloadPort` (35729), `devHost` (`'127.0.0.1'`).
|
|
11
|
+
- `DEFAULT_FETCH` — frozen object, the URL-model fetch policy: `headers: {}`, `timeout: 10000`, `retries: 0`, `cache: false`. Exported so `lib/fetch-policy.js` fills its own gaps from the same object rather than repeating the numbers.
|
|
12
|
+
- `DEFAULT_ASSETS` — frozen object, the cache-busting policy for emitted assets: `hash: false`, `version: null`.
|
|
13
|
+
- `resolveFolders(userFolders = {})` → folders object. If `userFolders.src` is set, every key in `DERIVED_FROM_SRC` (`assets`, `layouts`, `pages`, `partials`, `models`, `controllers`) is rewritten to `${src}/${key}` _before_ any explicit per-key override in `userFolders` is applied. Every resulting string value is then normalised: backslashes to `/`, repeated slashes collapsed, trailing slashes stripped.
|
|
14
|
+
- `resolveConfig(userConfig = {})` → full config object with `folders` resolved via `resolveFolders` and `sass`, `fetch` and `assets` 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` and inside `assets`), so they take their default. **Throws** on a `cleanBuild` outside `CLEAN_BUILD_VALUES`, and on a build folder that contains the source folder (both below).
|
|
15
|
+
- `CLEAN_BUILD_VALUES` — frozen `[true, false, 'atomic']`, the only accepted values of `config.cleanBuild`.
|
|
16
|
+
- `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.
|
|
17
|
+
|
|
18
|
+
## Depends on
|
|
19
|
+
|
|
20
|
+
`node:path` (resolving the build/source folders for the safety guard below) — nothing else.
|
|
21
|
+
|
|
22
|
+
## Depended on by
|
|
23
|
+
|
|
24
|
+
`lib/kiss.js`; `lib/fetch-policy.js` (`DEFAULT_FETCH`); `lib/kiss-page.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
|
|
25
|
+
|
|
26
|
+
## Non-obvious behavior
|
|
27
|
+
|
|
28
|
+
- **`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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **A build folder that contains the source folder is refused at construction**, whatever `cleanBuild` says (`false` today is one edit from `true`): `resolveConfig` throws when the resolved `folders.build` is the resolved `folders.src`, is an ancestor of it, or is a filesystem root — which is what catches `build: '.'`, `'./'` and `'/'` on a default layout. It is a **floor, not a fix**: a build folder that holds published output but is not above the source — metacarpus's `./handbooks` archive root, wiped whole by an empty cohort name (finding B-2) — is _not_ caught, because nothing in the config distinguishes it from any other legitimate build folder. That case is answered by validating the name in the consumer and by `cleanBuild: 'atomic'`.
|
|
31
|
+
|
|
32
|
+
- **Folder strings are normalised once, here, because everything downstream is path arithmetic.** A trailing slash on `folders.assets` made `assets.js`'s target derivation miss (compiled CSS landed beside the build folder as `./publicmain.css`, logged as a success), and one on `folders.models` made `model-resolver.js` chop the first characters off every filename so a whole `.pages()` fan-out resolved to `[]`. Both are the same missing normalisation. `./` and `/` keep their slash — trimming them to `.` or `''` would change which directory they name — and a non-string value (`null`) is passed through untouched.
|
|
33
|
+
- Setting `config.folders.src` re-derives the six `DERIVED_FROM_SRC` subfolders from it, _unless_ each is also individually overridden in the same `userFolders` object (the final spread `{ ...folders, ...supplied }` lets explicit keys win over the src-derived ones).
|
|
34
|
+
- **An explicitly `undefined` key takes its default; `null` does not.** A spread merge lets `undefined` win over the default, so `new Kiss({ port: process.env.PORT })` with `PORT` unset used to leave `port` undefined and bind a random ephemeral port, and `{ cleanBuild: opts.clean }` with the flag absent silently skipped emptying the build folder (review finding D-07). `resolveConfig`/`resolveFolders` drop undefined-valued keys before merging. `null` is deliberately kept, because it is the documented way to switch a folder off.
|
|
35
|
+
- **There is no `folders.static`.** v2 removed it for the same reason as `root`: it was in `DEFAULT_FOLDERS`, in `DERIVED_FROM_SRC`, in this doc, in llms.txt and in the README, but no engine module ever read it and `foldersToEnsure` never created it, so a consumer pointing it somewhere got silence (review finding E1). Static files are copied by `lib/assets.js` from `folders.assets`. A `static` key in a user config is now just an unknown key — it passes through `resolveFolders` untouched and nothing reads it.
|
|
36
|
+
- **There is no `folders.root`.** v2 removed it: it was in `DEFAULT_FOLDERS`, in this doc and in llms.txt, but no engine module ever read it and `foldersToEnsure` excluded it, so a consumer setting it got the default layout with no warning (review finding D-06). A `root` key in a user config is now just an unknown key — `resolveFolders` spreads user folders in, so it passes through untouched and nothing reads it.
|
|
37
|
+
- A folder set to `null` in the resolved config is legal: `foldersToEnsure` skips creating it, `registerPartialsFrom`/`copyAssets` skip scanning/copying it (each checks for a falsy folder before globbing).
|
|
38
|
+
- `foldersToEnsure` fixes a v1 copy-paste bug where most folders were only created conditionally on `assets` being set; here each folder stands on its own regardless of the others.
|
|
39
|
+
- **The three dev-server keys are defaulted here and nowhere else.** `lib/dev-server.js` takes `port`, `livereloadPort` and `host` as required parameters, so a reader has one place to look for their values. `livereloadPort` exists so two sites can run in dev mode at once (a clash used to kill the process — review finding D-01) and is also the port `lib/kiss-page.js` injects into the dev-mode reload `<script>`. `devHost` defaults to loopback: dev mode used to bind every interface while logging `localhost` (D-13); `'0.0.0.0'` is the documented opt-out for previewing on another device.
|
|
40
|
+
- `sass.includePaths` is kept as the public config key (for backwards compatibility with v1 configs) even though the modern `sass` package API calls the equivalent option `loadPaths` — consumers (`lib/assets.js`, `lib/handlebars-helpers.js`) read `config.sass.includePaths` and pass it to `sass` as `loadPaths`.
|
|
41
|
+
- **`fetch` is merged exactly one level deep, like `sass`.** `{ fetch: { timeout: 1 } }` keeps the default `headers`, `retries` and `cache`, but `{ fetch: { headers: { A: '1' } } }` replaces the whole header object rather than adding a key to it. The block is per instance: there is no per-page `fetch` override, so one site's remote models share one policy.
|
|
42
|
+
- **The `fetch` defaults are deliberately inert.** No headers, no retries and no cache mean a site that never sets the block behaves as it did before the block existed — with the single exception of `timeout: 10000`, which is new behaviour: an upstream that accepts the connection and then says nothing used to hang a build for ever.
|
|
43
|
+
- **The `assets` block is the cache-busting policy, and it is off by default.** `hash: true` renames each emitted `.css`/`.js` to carry a content hash; `version: '<string>'` renames nothing and makes the `asset` helper append `?v=<string>`. With neither set the build emits exactly the files it always did — the point being that an existing site is unaffected until it opts in, and that the caching scheme lives in config rather than in the template (`{{asset "css/site.css"}}` is the same line in all three modes). Setting both is a misconfiguration: the hashed filename wins and `lib/assets.js` warns. Merged one level deep, like `sass` and `fetch`, and per instance — the files were emitted once, so there is no per-page override.
|
|
44
|
+
- **`config.assets` is not `config.folders.assets`.** One is the policy for what the copy emits, the other is where it copies from. They are deliberately in different namespaces, but the names are close enough to be worth naming here.
|
|
45
|
+
|
|
46
|
+
## Types
|
|
47
|
+
|
|
48
|
+
The typedefs that describe the config to a consumer live at the top of this module, beside the defaults they describe: `KissFolders` / `KissFoldersInput`, `KissFetch`, `KissAssets`, `KissSettings`, and the two the public API is written in terms of — `KissConfig` (resolved: every key present) and `KissConfigInput` (what `new Kiss(config)` takes: every key optional). They are one JSDoc block, not a module of their own, so the shape and the resolver cannot drift apart.
|
|
49
|
+
|
|
50
|
+
- **`KissSettings` is every documented key _except_ `folders`**, and both config types are built from it, so a new default is added once. `KissConfig = KissSettings & { folders: KissFolders } & Record<string, any>`; `KissConfigInput` makes each key optional and each shallow-merged block (`sass`, `fetch`, `assets`, `folders`) partial, because that is exactly what `resolveConfig` merges one level deep.
|
|
51
|
+
- **The index signature is on the config and never on `folders`.** An unknown config key is legitimate — anything a site passes through reaches its views as `{{config.x}}` — so it types as `any` rather than an error. A `folders` key is not: `{ folders: { biuld: './out' } }` is a typo that silently builds to the default folder, so `KissFoldersInput` stays closed and TypeScript reports it. `test/fixtures/consumer/bad.js` pins that difference.
|
|
52
|
+
- `npm run types` regenerates `types/` from this JSDoc; `test/unit/types.test.js` re-emits and byte-compares, so an edit here without a regeneration fails the suite. See `AIKB/kiss.md` § Types.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# controller-resolver.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Resolves `options.controller` (a function, or a filename to load) and runs it against the page's `options`, merging whatever it returns.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `runController(options, controller, { logger })` → `options` merged with `controller(options)`'s return value (`{ ...options, ...controller(options) }`). **Throws** `Invalid controller: not a function` if `controller` isn't a function, and rethrows whatever the controller throws — both after logging.
|
|
10
|
+
- `async loadController(controllersDir, file, { logger, fresh = false })` → the controller function; **throws** `Failed to find controller: <path>` if the file doesn't exist (after logging it). Resolves `${controllersDir}/${file}` to an absolute path with `path.resolve`, `import()`s it via `pathToFileURL(...).href`, and returns `mod.default ?? mod` (so both `export default fn` and legacy `module.exports = fn` work). `fresh: true` bypasses the module caches so an edited file is re-read.
|
|
11
|
+
- `async applyController(options, { controllersDir, logger, fresh = false })` → an options object, with the controller applied (if `options.controller` is a string filename or a function) and `title` defaulted from `options.model.title` if neither is already set. Never mutates the object it was given. **Throws** if the controller cannot be run: a missing file, a module that does not export a function, a controller that throws, or a `controller` of any other type (`Unknown controller type: <typeof>`). `fresh` is passed straight through to `loadController`.
|
|
12
|
+
|
|
13
|
+
## Depends on
|
|
14
|
+
|
|
15
|
+
`fs-extra`, `node:path`, `node:url` (`pathToFileURL`), `node:module` (`createRequire`, used by the `fresh` cache-bust).
|
|
16
|
+
|
|
17
|
+
## Depended on by
|
|
18
|
+
|
|
19
|
+
`lib/kiss.js`.
|
|
20
|
+
|
|
21
|
+
## Non-obvious behavior
|
|
22
|
+
|
|
23
|
+
- Controller files resolve against `process.cwd()` (via `path.resolve`), consistent with every other folder lookup in the codebase (models, partials, layouts, assets). v1 used `require.main.require` (resolved relative to the entry script) but gated the attempt on a cwd-relative `existsSync` check first — so in v1 a differing cwd already caused the existence check to fail before the entry-relative `require` ever ran, meaning v1's entry-relative resolution never actually worked either; this module's cwd-relative resolution is not a behavior change in practice.
|
|
24
|
+
- `fresh: true` (passed by `lib/kiss.js` only while replaying a watch rebuild) busts _two_ caches, because Node keeps two: the ESM loader caches by resolved URL, so the file URL gets a `?v=<mtimeMs>` query appended; the CommonJS loader caches by _filename_ and never sees that query, so a `module.exports = fn` controller would keep returning the stale export unless `require.cache[require.resolve(path)]` is deleted as well. Both are needed — dropping either one leaves one of the two module systems stale. `require` here comes from `createRequire(import.meta.url)` (this module is ESM, so there is no ambient `require`).
|
|
25
|
+
- Cache-busting keys on `mtimeMs`, so an _unchanged_ controller still hits the ESM cache on replay (no re-evaluation cost per rebuild); only edited files reload. Two rewrites within the same filesystem-timestamp tick would collapse to one URL — not a concern in watch mode, where chokidar's `awaitWriteFinish` already delays a rebuild ~100ms past the last write.
|
|
26
|
+
- Without `fresh` (every non-watch build) behaviour is byte-for-byte unchanged: `import()` caches per resolved URL exactly as `require()` cached per path, so a controller changed mid-process is not re-read. `test/unit/controller-resolver.test.js` pins that cached-by-default behaviour alongside the `fresh` cases.
|
|
27
|
+
- Accepts both `export default fn` and CommonJS-style `module.exports = fn` (`import()` surfaces the latter as `mod.default`), via `mod.default ?? mod`.
|
|
28
|
+
- **A controller that cannot run fails the page** (review finding F-01, 2026-09-05). All four paths — a missing file, a module that loads but does not export a function, a controller that throws, an unrecognised `controller` type — used to log and carry on returning the un-controlled `options`, so the page built without its derived slug or reshaped model and the build still exited 0. They now throw; `lib/kiss.js`'s `page()` records the throw on `_failures` and `complete()` reports it. The log lines are unchanged, so the console output a consumer already recognises still appears. `test/unit/controller-resolver.test.js` pinned the old behaviour and was deliberately flipped. `applyController` calls `runController` unguarded now that a non-function export throws there: the `if (fn)` truthiness guard it used to sit behind would have dropped a falsy export (`export default 0`) silently, and `loadController` no longer returns `null` for it to catch.
|
|
29
|
+
- The `?v=<mtime>` ESM cache-bust above leaks module instances by design: Node has no API to evict a module from the ESM registry, so every edit to a controller during a long `watch()` session retains one more instance for the life of the process. Dev-only and small (a few hundred tiny modules over an afternoon of edits), documented rather than fixed (review finding F-02).
|
|
30
|
+
- `options.title` only falls back to `options.model.title` when `options.title` is unset _after_ the controller runs, so a controller that removes/clears `title` will still get the model's title as a fallback.
|
|
31
|
+
- That fallback returns a **new** object rather than assigning onto the caller's `options`. It used to mutate in place, which broke `.pages()`: the fan-out reused one options object per loop, item one set the title, and the "unless already set" guard then skipped every item after it — so every page in the fan-out rendered the first item's title while `{{model.x}}` looked correct. `lib/kiss.js` now also builds a fresh options object per fanned-out page; either fix alone would have closed the observed bug, and both are kept because the aliasing was the real hazard.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# dev-server.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Starts the dev-mode static file server (`connect` + `serve-static`) plus a `livereload` server. It watches nothing: reloads are issued by `Kiss`, once per settled rebuild, through the handle's `refresh`.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `startDevServer(httpRoot, port, { logger, livereloadPort, host })` → `{ server, livereload, refresh, ready, close }`. Every parameter is required: the module is engine-internal and `Kiss` always passes `config.folders.build`, `config.port`, `config.livereloadPort` and `config.devHost`, so the defaults belong in `DEFAULT_CONFIG` (one documented place) rather than here. The dead `'public'`/`3000` defaults this signature used to carry contradicted the documented port of 3001 and were reachable only by a deep import (review finding D-12).
|
|
10
|
+
- `server` — the raw `http.Server` from `connect().listen(port, host)`.
|
|
11
|
+
- `livereload` — the `livereload` server instance. It watches no files.
|
|
12
|
+
- `refresh(path)` — tells every connected browser to reload. `path` names what changed: livereload swaps a `.css` file in place (`applyCSSLive` is on by default), anything else reloads the page, and `'/'` is the generic "reload the page" call. This is the only way live reload is ever triggered — see the no-watcher bullet below.
|
|
13
|
+
- `ready` — a `Promise` that resolves on the HTTP server's `'listening'` event, or rejects on a bind failure with the whole message already assembled (`Dev server could not bind <host>:<port> (<code>): the site is not being served`, original error on `cause`). It is the only report of that failure — the module logs nothing for it.
|
|
14
|
+
- `close()` — `Promise` that closes `livereload` then the HTTP server, resolving once both are down.
|
|
15
|
+
|
|
16
|
+
## Depends on
|
|
17
|
+
|
|
18
|
+
`connect`, `serve-static`, `livereload`.
|
|
19
|
+
|
|
20
|
+
## Depended on by
|
|
21
|
+
|
|
22
|
+
`lib/kiss.js`.
|
|
23
|
+
|
|
24
|
+
## Non-obvious behavior
|
|
25
|
+
|
|
26
|
+
- **A bind failure is fatal, reported once, and never preceded by a `Serving` line** (review findings F-E2/F-E3). It used to be neither: the `Serving (…): http://127.0.0.1:3001` line was printed synchronously after `app.listen()` — before the bind had resolved — and an `EADDRINUSE` was then logged twice, by this module's own `'error'` listener (`Dev server error …`) and again by the `Kiss` constructor's `ready.catch` under the wrong subsystem name (`Error running live reload server`), hundreds of lines apart in a verbose log, while the build ran to completion and the process stayed up serving nothing. Now: the `Serving` line is printed inside the `'listening'` handler, so it only ever claims a server that exists; the bind failure is not logged here at all — `ready` rejects with the full sentence and its owner (`lib/kiss.js`) logs it once and records a `<dev server>` build failure, so `complete()` rejects. The `'error'` listener stays attached for the life of the server (an unheard `'error'` on an `EventEmitter` throws) but only logs errors that arrive **after** `'listening'`; a `listening` flag is what tells the two cases apart.
|
|
27
|
+
- **The livereload port clash is deliberately not fatal** — unlike the HTTP server, live reload is optional and the site keeps being served without it, so that path stays "log once and continue" (see the livereload `'error'` listener bullet below).
|
|
28
|
+
- `close()` is safe after a failed bind: `server.close()` on a server that never listened invokes its callback with `ERR_SERVER_NOT_RUNNING`, which the resolve ignores, so the returned promise still resolves.
|
|
29
|
+
- **The livereload server's `'error'` listener is not optional.** `livereload.createServer()` returns an `EventEmitter` whose `listen()` starts an `http.Server` and wraps it in a `ws.Server`; `ws` re-emits the HTTP server's `'error'`, and livereload's `onError` re-emits that on the livereload object itself. With no listener there, an `EADDRINUSE` on the livereload port was an **uncaught exception that killed the whole dev process** — not, as this doc previously claimed, an error that was merely "not surfaced". Two sites in dev mode hit it every time, which is the documented use of `config.port` (review finding D-01). The listener logs and continues: live reload is optional, the site must keep being served without it.
|
|
30
|
+
- Both ports and the host come from config, so two sites can run side by side: `livereloadPort` also feeds the `<script>` tag `lib/kiss-page.js` injects, otherwise site B's pages would poll site A's livereload server.
|
|
31
|
+
- `host` is passed to both servers so they agree (livereload's own default is `'localhost'`). `app.listen(port)` with no host bound every interface while the log line said `localhost` (review finding D-13); it now binds `config.devHost` — loopback by default — and the log line names the host actually bound. The injected reload `<script>` resolves its host in the browser from `location.hostname` (see `AIKB/kiss-page.md`), so with `devHost: '0.0.0.0'` a page loaded from another device live-reloads from that same device's address rather than asking its own localhost.
|
|
32
|
+
- **Nothing here watches the build folder** (review finding F-E4). It used to: `lr.watch(httpRoot)` gave livereload its own chokidar watcher over the build output, which called `refresh` once **per file written**. A whole-site replay writes for as long as it takes to render every page, so the browser's first reload arrived within a second of the edit while pages were still being written — on a big site it could reload against a page that had not been re-rendered yet and then sit on stale content until the next edit. `Kiss` is the only thing that knows when a rebuild has settled, so it now calls `refresh` itself, exactly once per rebuild (see `AIKB/kiss.md`).
|
|
33
|
+
- **`delay` and `extraExts` went with the watcher.** Both were options of it and mean nothing without it: `delay: 100` was a write-settle for the half-written-file case (livereload passed chokidar's `add` straight to `filterRefresh`, and `fs.outputFile` creates a file before filling it), and `extraExts: ['svg', 'webp', 'avif', 'ico', 'woff', 'woff2']` added asset types to the extension filter that decided which watched files were worth a reload. A reload issued after the rebuild has settled needs neither: every file is fully written by then, and what changed no longer has to be inferred from an extension. The `json`/`xml` exclusion went the same way — the dev-mode `.json` debug siblings and `sitemap.xml` are written on every build, and a watcher was the only thing that could have reloaded on them.
|
|
34
|
+
- `port` of `0` picks a free ephemeral port (Node's standard `net`/`http` behavior) — used by tests that need an isolated server per run without hardcoding a port.
|
|
35
|
+
- Every incoming request is logged via `logger.plain(req.url)` before being served — noisy by design in dev mode.
|
|
36
|
+
- **`connect`, `serve-static` and `livereload` are required on the first server start, not at module import.** Between them they cost ~160ms to load, and a one-shot build — every production build, every CI run, every `kiss-ssg check` — never starts a server at all. As static imports they were paid by `lib/kiss.js` unconditionally, because it imports this module to have `startDevServer` available for the branch it usually does not take. A synchronous `require` (not `await import()`) keeps the signature: `startDevServer` is called from the `Kiss` constructor, which cannot await, and has to return its `{ ready, refresh, close }` handle in the same tick. All three packages are CJS, so a require resolves them.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# fetch-policy.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Applies `config.fetch` — headers, timeout, retries and an on-disk cache — to a single URL model request, and hands back the parsed body. Everything the engine does between "here is a URL" and "here is JSON" lives here; `model-resolver.js` keeps only the dispatch on model shape.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `async fetchModel(url, { fetchImpl = globalThis.fetch, logger, headers, timeout, retries, cache })` → `Promise<data>` — the parsed JSON body of a 2xx response. Every policy key is optional and falls back to `DEFAULT_FETCH` from `config.js`, so a caller that passes only `fetchImpl`/`logger` gets today's behaviour plus the default timeout. Rejects with an `Error` on a non-2xx response, a network failure or a timeout.
|
|
10
|
+
|
|
11
|
+
## Depends on
|
|
12
|
+
|
|
13
|
+
`fs-extra`; `./utils.js` (`hashId`, `posixPath`); `./config.js` (`DEFAULT_FETCH`).
|
|
14
|
+
|
|
15
|
+
## Depended on by
|
|
16
|
+
|
|
17
|
+
`lib/model-resolver.js`.
|
|
18
|
+
|
|
19
|
+
## Non-obvious behavior
|
|
20
|
+
|
|
21
|
+
- **Order of operations: cache read → fetch (headers + timeout) → non-2xx guard → retry decision → parse → cache write.** The cache is consulted before anything else, so a cached model costs no network at all; the body is written only after a 2xx response has parsed, so an error envelope can never become a cache entry (the guard that `model-resolver.js` gained for the same reason — review finding C2 — stays authoritative over the cache).
|
|
22
|
+
- **The cache key is a hash of the URL _and_ the header set**, with the header entries sorted so key order cannot fork the identity. The same URL fetched with two different bearer tokens is two different identities upstream, and one build's body must never be served to the other's request.
|
|
23
|
+
- **The cache has no TTL and no expiry sweep.** A consumer clears the directory (or a single `<hash>.json` inside it) to refetch. That also makes stale-on-error unreachable rather than merely unimplemented: an entry is only ever written after a success, and once written it is hit before any request is made, so there is no state in which a fetch fails _and_ a cached body exists. Stale-on-error only becomes a real option if a TTL is added — which is why `cache` is a plain directory string, not a `{ dir, staleOnError }` object.
|
|
24
|
+
- **An unreadable cache entry is a miss, not a failure**: a truncated or hand-edited file logs one warning and the request goes to the network. A half-written file (a build killed mid-write) must not poison every later build.
|
|
25
|
+
- **The timeout is a `Promise.race` as well as an `AbortController.abort()`.** The abort is what a real `fetch` needs; the race is what makes the timeout hold for a `fetchImpl` that ignores its signal — the injected seam is a test stub or a polyfill as often as it is Node's `fetch`, and one that hangs would otherwise hang the whole build with nothing to report. The losing attempt gets a no-op `.catch()` so the abort's own rejection is never unhandled.
|
|
26
|
+
- **A 4xx is never retried, a 5xx and a network error are.** A 4xx is the server rejecting _this_ request — repeating it cannot change the answer, and hammering an auth failure can get a key locked. Backoff is linear (`200ms × attempts`), deliberately short: this is a build, not a daemon.
|
|
27
|
+
- **The attempt count is named in the rejection only when more than one attempt was made** (`… (after 3 attempts)`). With the default `retries: 0` the message is byte-identical to the one the resolver threw before this module existed, so nothing reading build output has to change for a site that never configures retries.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# handlebars-helpers.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`, `stringify`, `lookup`, `isActive`, `canonical`, `absUrl`, `asset`, `env`) onto a given Handlebars environment. `lookup` deliberately overrides Handlebars' own helper of that name.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `registerHandlebarsHelpers(hbs, config, { markdown, logger, assets })` — registers all ten helpers on `hbs` via `hbs.registerHelper(...)`; returns `hbs`. `assets` is the instance's asset manifest (`AIKB/asset-manifest.md`); it is optional and defaults to an empty one, so a caller that registers helpers without an asset copy still gets a working (degrading) `asset` helper.
|
|
10
|
+
- `markdown` — block or string content, rendered through `markdown.render(trimLines(text))`, wrapped in `hbs.SafeString`.
|
|
11
|
+
- `sass` — file path (string) and/or block; compiles via `sass.compile`/`sass.compileString` (`style` = `'expanded'` in dev, `'compressed'` otherwise; `loadPaths` from `config.sass.includePaths`); returns concatenated CSS as `hbs.SafeString`.
|
|
12
|
+
- `offset(index)` — `index + 1`.
|
|
13
|
+
- `stringify(obj)` — `JSON.stringify(obj, null, 3)`.
|
|
14
|
+
- `lookup(obj, field, options)` — same signature and return as Handlebars' built-in (falsy `obj` returned as-is, otherwise `options.lookupProperty(obj, field)`). An `undefined` result additionally logs one warning: `lookup: '<field>' is undefined` plus ` in <view>` when the root context carries a `view`.
|
|
15
|
+
- `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href.
|
|
16
|
+
- `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 `toURLKey(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`.
|
|
17
|
+
- `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`. A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
|
|
18
|
+
- `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A path already carrying a scheme is returned unchanged. A path the manifest does not know renders as it was given and logs one warning per page per path. A non-string path logs a warning and renders `''`.
|
|
19
|
+
- `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).
|
|
20
|
+
|
|
21
|
+
## Depends on
|
|
22
|
+
|
|
23
|
+
`node:path`; `./sass.js` (the resolved sass binding); `./asset-manifest.js` (`createAssetManifest`, for the empty default); `./utils.js` (`trimLines`, `toURLKey`, `toAbsoluteUrl`).
|
|
24
|
+
|
|
25
|
+
## Depended on by
|
|
26
|
+
|
|
27
|
+
`lib/kiss.js`.
|
|
28
|
+
|
|
29
|
+
## Non-obvious behavior
|
|
30
|
+
|
|
31
|
+
- Helpers register on whichever Handlebars environment (`hbs`) is passed in, not a global instance — each `Kiss` instance calls this against its own `this.handlebars`, so helpers never leak between instances.
|
|
32
|
+
- The `sass` helper's file mode resolves a _relative_ path against `process.cwd()`, not against the view or the assets folder; an **absolute** path is passed to `sass.compile` untouched (`path.isAbsolute(context)` — an unconditional `path.join(process.cwd(), context)` mangled a path a controller had resolved into `<cwd>/abs/path/main.scss` and failed the build, review finding C7). Block mode compiles the block content directly with `sass.compileString` (no `process.cwd()` involved).
|
|
33
|
+
- `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js`, shared with `canonical` and `sitemap.js`) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
|
|
34
|
+
- The match is by **path segment**: active iff `pageKey === hrefKey` or, under `folderMatch=true`, `pageKey.startsWith(hrefKey + '/')`. It was a bare `pageURL.includes(href)`, which lit `/blog` on `my-blog-post.html` and — because `''.includes()` is always true — lit every item of a nav that omitted `href` (D-02). An empty `href` under `folderMatch` now matches the home page only, which falls out of the same rule.
|
|
35
|
+
- Helpers **degrade rather than throw**: a page dropped from the build is a worse answer to a template mistake than a missing highlight. `isActive` coalesces (`hash.href ?? ''`) instead of spreading over a default, because `{{#isActive page href=href}}` on a model with no `href` key passes an explicit `undefined` that wins a spread; a missing or non-string `pageURL` logs a warning and renders the block as not-active; and when the page argument is omitted altogether Handlebars passes its own options object as the first parameter, so the block is located by shape (`options ?? pageOptions`) rather than by position (D-05). `markdown` gates its block branch on `typeof obj.fn === 'function'` rather than `typeof obj === 'object'` so a `null`, object or array model field takes the warn-and-render-nothing path, and `env` requires `is` to be a string before calling `.toLowerCase()` (D-04).
|
|
36
|
+
- `env` reads `config.dev` at _render_ time (helpers close over the `config` object, not a snapshot), so `{{#env is="dev"}}`/`{{#env is="prod"}}` blocks reflect the live config.
|
|
37
|
+
- The sass compiler is imported from `./sass.js`, never from `sass` directly: which export carries the modern API depends on the installed sass version, and that detection lives in one place (see `AIKB/sass.md`).
|
|
38
|
+
- `lookup` **shadows the Handlebars built-in on purpose**, and its only difference is the log line. A dynamic partial — `{{> (lookup . 'moodleAccess')}}` — whose key is absent from the data fails as `The partial undefined could not be found`, which names neither the key nor the page: diagnosing 40 such pages in a consumer site needed a hand-written probe helper (review finding G-2). The helper warns and then returns `undefined` anyway, so the render fails exactly as before; the fix is a name in the log, not a change of behaviour.
|
|
39
|
+
- The prototype guard is **delegated, never reimplemented**: the value comes from `options.lookupProperty(obj, field)`, the container function Handlebars passes into every helper, which is what refuses `__proto__`/`constructor` access. A raw `obj[field]` here would quietly reopen that hole, so the unit test asserts kiss's output against a plain Handlebars instance's for those keys rather than against a literal.
|
|
40
|
+
- The warning is deduplicated **per page per key** through a `WeakMap` keyed on `options.data.root` — the page's own options object, a fresh one per page. A partial used twice on a page logs once; a `.pages()` fan-out of 100 pages missing the same key logs 100 lines, one each, which is the point (each names its own page). No dedupe is applied when the template was compiled without `data`.
|
|
41
|
+
- `canonical` and `absUrl` do **not** own their normalisation: both go through `toAbsoluteUrl` in `utils.js`, which `lib/sitemap.js` also uses to build every `<loc>`. That is the point of the pair — a page's `<link rel="canonical">` and its own sitemap entry are produced by one function, so they cannot drift apart (review gap O5). `canonical` additionally reduces the page URL with `toURLKey`, which is what strips the `.html` and the trailing `index` segment; the sitemap does the same, hence the agreement, and an integration test builds a site both ways round (`extensionLess` on and off) and asserts the two lists are equal.
|
|
42
|
+
- **`absUrl` keeps a file extension, `canonical` does not.** `toURLKey` would turn `css/site.css` into `css/site`, so `absUrl` passes its path to `toAbsoluteUrl` directly — that only trims slashes and collapses a trailing `index` segment. A page URL is an identity (every spelling of it is one key); an arbitrary path is a path.
|
|
43
|
+
- The `siteUrl` read is the **page's**, `root.config.siteUrl`, falling back to the instance config only when the template was compiled without a page context. `Kiss.page()` gives every page its own shallow copy of the config, so a page built for another domain (`.page({ view, config: { siteUrl } })`) gets a canonical URL on that domain. `sitemap.js` still reads the instance `siteUrl` for every `<loc>`, so such a page is the one case where the two differ — deliberately: it is the page that has been pointed elsewhere.
|
|
44
|
+
- No `siteUrl` **degrades, like every other helper**: one warning and an empty string, never a throw and never a guessed domain. The warning is deduplicated per page through a `WeakSet` keyed on `options.data.root` (the `lookup` pattern), so a layout with a canonical link and a dozen `absUrl` links logs one line for that page, not thirteen. A missing `pageURL` warns separately and also renders nothing: the same canonical URL on every page is worse for a search engine than no canonical link at all.
|
|
45
|
+
- **`asset` reads a manifest, it does not look at disk.** The manifest is filled by the one `copyAssets` run the constructor queues, and `generate()`/`complete()` await `_promises` — which that run is on — before any page renders, so the helper always sees a filled manifest. A watch-mode asset edit refills it and, under a hashing policy, `Kiss` re-renders the stack so the new filename reaches the pages (`AIKB/kiss.md`).
|
|
46
|
+
- **The helper returns no leading slash, deliberately.** A root-relative URL is one form of link, not the only one: the examples' shared layout climbs back to the build root with `{{root}}` so a page opens straight off the file system, and a site on a sub-path needs its own prefix. `/{{asset 'css/site.css'}}`, `{{root}}{{asset 'css/site.css'}}` and `{{absUrl (asset 'css/site.css')}}` are the three forms, and the helper stays out of that decision.
|
|
47
|
+
- **The version query is the one helper output that is a `SafeString`.** Handlebars escapes `=` in a `{{...}}` expression, so a plain string comes out of the template as `?v=1.2.3`. The path is escaped with `hbs.Utils.escapeExpression` first and the query appended after it, so the escaping is exactly what Handlebars would have done and only the separator survives.
|
|
48
|
+
- **`absUrl` unwraps and re-wraps a `SafeString` argument** so `{{absUrl (asset …)}}` composes under every policy. Without it the version mode's `SafeString` would fail `absUrl`'s "needs a path string" guard and render nothing, and re-escaping the result would put `=` back. A path that already carries a scheme is returned exactly as it arrived, wrapper included.
|
|
49
|
+
- **`asset` reads the instance config, not the page's** — unlike `canonical`/`absUrl`, which read `root.config.siteUrl`. The policy was applied once, by the copy that emitted the files, before any page rendered; a per-page override could only disagree with what is on disk.
|
|
50
|
+
- `asset` degrades like every other helper: an unknown path is the author's own build, so it renders what they asked for and names the path once per page (the `lookup` `WeakMap`-of-`Set`s pattern), rather than failing the page.
|
|
51
|
+
|
|
52
|
+
## Types
|
|
53
|
+
|
|
54
|
+
`registerHandlebarsHelpers` carries JSDoc for the generated `types/handlebars-helpers.d.ts` — `hbs` is `typeof import('handlebars')`, `config` is `KissConfig` from `lib/config.js`, and `deps.logger` is `ReturnType<typeof createLogger>`. The helpers themselves are Handlebars runtime (variadic argument lists whose last entry is Handlebars' own options object), so they are documented one line each rather than typed: the shape a template calls them with is not expressible as a function signature. `deps.markdown` is `any` because `remarkable` ships no types. Regenerate with `npm run types`.
|
|
55
|
+
|
|
56
|
+
- **The `sass` helper compiles through `compileFile()`/`compileSource()`, which load the compiler on first use and memoise the result** — the helper runs per render, so without the memo a stylesheet named by a partial is recompiled once per page that includes it — see `AIKB/sass.md`. Registering the helper does not load `sass`; calling it does, so a site whose templates never use `{{sass}}` never pays for the package.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# kiss-page.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
One page's render logic: resolving its title/slug/path/extension, compiling and rendering its Handlebars template, minifying the HTML, and writing the output file (plus a debug `.json` sibling in dev mode).
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `class KissPage`
|
|
10
|
+
- `new KissPage(view, { hbs, logger } = {})` — `view` is a `.hbs` path (relative to `pagesDir`) or an inline template string.
|
|
11
|
+
- `set path(path)` — sanitizes via `sanitizePath` (`./utils.js`); falsy values are ignored (default `''` kept).
|
|
12
|
+
- `set slug(slug)` / `get slug()` — setter normalizes via `toSlug` (`./utils.js`); falsy values are ignored (default `'index'` kept).
|
|
13
|
+
- `set ext(extension)` — strips a leading `.`, then slugifies via `toSlug` (`./utils.js`), like `path` and `slug`; falsy values are ignored (default `'html'` kept).
|
|
14
|
+
- `set extLess(val)` / `set isDev(dev)` / `set debug(dev)` — each unconditionally coerces with `!!val` and assigns (`this._extLess = !!val`, etc.) — unlike `path`/`slug`/`ext`, these do **not** ignore falsy values; e.g. `page.debug = false` overwrites the existing value rather than being skipped.
|
|
15
|
+
- `buildDir` / `pagesDir` / `livereloadPort` — plain fields `Kiss._preparePage` assigns from the resolved config (`folders.build`, `folders.pages`, `livereloadPort`); `livereloadPort` falls back to `DEFAULT_CONFIG.livereloadPort`.
|
|
16
|
+
- `get buildTo()` — `${buildDir}/${pageURL()}`, the absolute-ish output path and the dedupe key `Kiss._preparePage` checks.
|
|
17
|
+
- `pageURL()` — the page's URL/relative-path, honoring `extLess` (writes `slug/index.ext` instead of `slug.ext`, except for `slug === 'index'`).
|
|
18
|
+
- `prepare()` — merges default options (`title`, `path`, `slug`, `generate: true`) under any already set on `this.options`; returns `this`.
|
|
19
|
+
- `async generate()` — compiles the template, renders with `this.options`, injects the livereload script in dev, minifies in production (dev skips minification entirely), asserts the resolved `buildTo` is inside the resolved `buildDir`, writes the file (and, in dev, the debug `.json`); returns `this.buildTo`. Logs _and rethrows_ render/minify/escape/output-write errors.
|
|
20
|
+
- `loadMinifier()` — the one shared `Promise` for the `html-minifier-terser` import, resolving to its `minify`; every render awaits it.
|
|
21
|
+
- `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.
|
|
22
|
+
|
|
23
|
+
## Depends on
|
|
24
|
+
|
|
25
|
+
`fs-extra`, `node:path`, `html-minifier-terser` (dynamic `import()`, never at module load); `./utils.js` (`toSlug`, `toTitleCase`, `sanitizePath`), `./logger.js` (fallback logger only), `./config.js` (`DEFAULT_CONFIG.livereloadPort`, the fallback for a `KissPage` built outside `Kiss`).
|
|
26
|
+
|
|
27
|
+
## Depended on by
|
|
28
|
+
|
|
29
|
+
`lib/kiss.js` (`_preparePage` constructs and stacks a `KissPage`).
|
|
30
|
+
|
|
31
|
+
## Non-obvious behavior
|
|
32
|
+
|
|
33
|
+
- `_title` is computed from the _default_ slug (`'index'`) in the constructor, before any page-specific slug is set — so a page without an explicit title or model title falls back to `'Index'`, not a title derived from its own slug. This is a preserved v1 quirk, not a bug to fix.
|
|
34
|
+
- `buildTo` is the value `Kiss._preparePage` uses to detect duplicate pages (same output path) — a duplicate is a build failure there, not a skip.
|
|
35
|
+
- **Every part of `buildTo` is sanitised, `ext` included.** `ext` used to strip only the _first_ `.` (`replace('.', '')`) and never slugify, so unlike `path` (`sanitizePath`) and `slug` (`toSlug`) it carried `/` and `..` straight into the output path: `ext: './../../../escaped/pwned.html'` wrote three levels above the build folder, and the value is reachable from model data through the documented "controller derives the page's options" pattern (review finding C1). It now goes through `toSlug(extension.replace(/^\./, ''))` — `'.xml'` → `'xml'`, `'../x'` → `'x'`.
|
|
36
|
+
- **`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.
|
|
37
|
+
- `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.
|
|
38
|
+
- **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`.
|
|
39
|
+
- The injected livereload `<script>` points at `livereloadPort`, a plain field `Kiss._preparePage` sets from `config.livereloadPort` (the same value `startDevServer` binds). It is a field rather than a render option deliberately: page options reach the template context and the dev-mode `.json` sibling, and the port is engine plumbing, not page data. The port used to be hardcoded, so a second site's pages polled the first site's livereload server (review finding D-01).
|
|
40
|
+
- Dev mode (`isDev`) injects the livereload `<script>` tag immediately before `</body>` and writes a sibling `.json` file (same path with only its _trailing_ extension swapped, via `/\.[^.]+$/` — a plain `replace(ext, 'json')` would also hit an earlier occurrence, e.g. a folder named `html`) containing the fully-resolved `options` — useful for inspecting what a page rendered with.
|
|
41
|
+
- `generate()` logs an error and then **rethrows** it, so `Kiss.generate()` can collect the failure and `Kiss.complete()` can reject with it (v1 logged and resolved, so a site could "build" with a page missing). Four failure paths rethrow: a render throw (e.g. `Missing helper`), a minify throw, the outside-the-build-folder assertion, and a failed write of the _output_ file (its inner `catch` logs `Error creating <buildTo>` and rethrows, so the error is logged twice — once specific, once by the outer `Error processing view <view>` handler). The dev-mode debug `.json` sibling is the exception: it is a debug artifact, so a failure to write it stays log-only and the page still counts as built.
|
|
42
|
+
- A view that ends with `.hbs` is a **filename** and must be read from `pagesDir`; anything else is an inline template string. When the read fails, `_getTemplate` throws `Error reading view: <path>` (with the fs error as `cause`), so the failure reaches `Kiss.generate()`'s catch, lands in `_failures` and makes `complete()` reject. It used to log the error and fall through with `viewText` still set to the view filename, which compiled the _filename_ as the page body — a deleted or misspelled view "built successfully" with `about.hbs` as its content (review finding B3).
|
|
43
|
+
- A template that fails to _compile_ is still log-only: `_getTemplate` returns `null` and `generate()` takes the "Skipping page generation" branch instead of throwing — so an unparseable template is a skipped page, not a build failure.
|
|
44
|
+
- **`html-minifier-terser` is loaded on demand, and a production build starts loading it from the `Kiss` constructor.** It costs ~155ms (it pulls in terser and a CSS parser), and `lib/kiss.js` imports this module eagerly for the `KissPage` class, so as a static import the cost landed on every process that touched Kiss — including ones that render nothing. Loading it at the first render instead put that 155ms on the critical path, serial with the work: measured same-machine, the `build` phase of a 50-page production build went **up ~44%** while end-to-end still fell, because import had dropped further. So `Kiss` calls `preloadMinifier()` from its constructor whenever `config.dev` is false, so the load can overlap whatever asynchronous work sits between construction and the first render. **What that buys depends on how much such work there is.** Most of the 155ms is CPU (evaluating terser), and a dynamic `import()` only progresses while the main thread is awaiting something, so it hides behind model resolution and the asset copy, not behind the synchronous constructor. Measured same-machine, 9 runs: at 50 pages the `build` phase is unchanged (`scan` +2.7%, `fanout` +7.2%, `styled` +0.5% — inside or at the noise floor, because 50 model reads are ~5ms of cover for a 155ms load); at 500 pages it falls (`scan` -5.1%, `fanout` -10.8%), where 500 model or view reads are enough to hide most of it. The 50-page cost of deferring the minifier is therefore _not_ repaid by preloading; it is inherent to loading a 155ms module in a process that has nothing else to do yet. Dev never minifies and never loads it. `loadMinifier()` caches the _promise_, not the resolved function: every page's render starts concurrently, and caching the result had each early caller issue its own `import()` before the first resolved — deduped by Node, so harmless, but one shared promise is the honest shape. A preload failure is swallowed and resurfaces from `loadMinifier()` at the render that needs it, against a page.
|
|
45
|
+
- **Compiled templates are cached per Handlebars environment, in a module-level `WeakMap` keyed on the env.** `_getTemplate` runs on every render and used to `readFileSync` and `hbs.compile` each time. A `scan` build hides that — one page, one view — but a fan-out does not: `.pages({ view: 'item.hbs', model: 'items' })` renders N pages from one template and compiled it N times. Handlebars is 44% of a 500-page build in the CPU profile, and it is the compiler, not the renderer. Measured on a real site's views (diploma-msc's four `catalogs/*.hbs`, 3.56ms each to compile, shared by 111 course pages): 395ms of compilation becomes 14ms. On the fixture's much smaller fan-out view, the build drops ~8%.
|
|
46
|
+
- **The cache hangs off the environment, not the module, because `hbs.compile` closes over the environment it was called on** — its helpers and partials resolve against that instance. Two Kiss instances in one process have two environments, and a template compiled on one must never be served to the other. A `WeakMap` also lets the cache die with the env rather than pinning every template a process ever compiled. `test/unit/kiss-page.test.js` pins this with two environments registering different `shout` helpers against the same view path; swapping the WeakMap for a shared Map fails it.
|
|
47
|
+
- **A cached template is validated by content, not mtime.** The view is read on every render and compiled only when its text differs from the text last compiled: the read costs ~0.03ms, the compile milliseconds, and the compile is the whole saving. The first version keyed on mtime, which depended on the filesystem's timestamp granularity — exFAT and some network and WSL2 mounts round to a second or two, so two saves inside one tick after a render would have served the stale template until the next save, a failure that only ever shows under `watch`. Comparing the text has no such dependence, needs no `stat`, and also sees through a save that changed nothing (a formatter no-op, a `touch`), which the mtime check recompiled. `test/unit/kiss-page.test.js` pins both: a content change with the mtime pinned back to its old value recompiles; a same-content rewrite with a newer mtime does not.
|
|
48
|
+
- **Only a successful compile is cached.** Caching a failed one would hide the error log from every later render of the same broken view; a broken template should complain once per attempt, not once ever.
|
|
49
|
+
- **Dev skips minification entirely rather than running it with every option off.** It used to call the minifier with `collapseWhitespace`, `minifyCSS` and `minifyJS` all `false`, leaving only comment and empty-attribute removal — which still paid for the parse, and (since the minifier is imported at first render) still paid ~155ms to load the package. A dev build never ships, so that pass costs more than it is worth; keeping comments and whitespace is arguably better for reading dev output. Measured over 9 runs: `watch@50` build **-32.7%**, `watch@500` build **-11.3%**. The saving is mostly the fixed import, which is why it is proportionally larger on the smaller site. It does **not** improve the scoped re-render (~37ms, unchanged): by then the package is loaded and per-page minification is a fraction of a millisecond. Production is untouched — the example suite still builds byte-identical to `main`.
|
|
50
|
+
- **Why the Sass compile still emits `compressed` in production, even though the minifier runs `minifyCSS` over the same bytes.** This looked like pure double work — on diploma-msc's real stylesheets the two paths land within 0.16% of each other (`catalog.scss` 21904b vs 21939b; `about-us.scss` +0.02%; `faculty.scss` +0.00%), so dropping the Sass side would have saved ~11ms a page for nothing. It was tried and reverted: Sass's `compressed` style converts percentage-form colours to hex (`rgb(80.4%,41.2%,21.6%)` → `#cd6937`) and clean-css does not, so a stylesheet built on `color.adjust` and friends grows instead. The benchmark's own `styled` fixture is exactly that shape and came out **19% larger** over 200 pages. The three real stylesheets contain zero percentage-form colours, which is why they showed nothing. A library default has to hold for stylesheets nobody has sampled, so the redundancy stays.
|