kiss-ssg 2.6.0 → 2.6.2

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/check.md CHANGED
@@ -8,7 +8,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
8
8
 
9
9
  ## Public interface
10
10
 
11
- - `HELP` — the usage text both `--help` and a usage error print; it documents both commands.
11
+ - `HELP` — the usage text both `--help` and a usage error print; it documents both commands, and names `init` in one line. `init` is not parsed here: the bin dispatches it to `lib/init.js` before `parseArgs` runs, because it takes no script and none of these options — so `parseArgs(['init'])` still answers `unknown command: init`, and `CheckArgs.command` stays `'check'|'aikb'|'help'`.
12
12
  - `parseArgs(argv)` → `{ command, script, args, summary, against, error }`. `command` is `'check'`, `'aikb'` or `'help'`; `script` is the site's build script and `args` everything after it, less our own `--summary`; `summary` is set by `--summary` on either side of the script; `against` is the report file `--against <file>` named, or `null`; `error` is a usage error (print it with `HELP` and exit 1), and it names the command it could not complete.
13
13
  - `readReports(text)` → the JSON Lines file `KISS_REPORT` collects, parsed into `BuildReport[]`. Blank lines are skipped; a malformed line throws.
14
14
  - `readReportsFile(text)` → the same `BuildReport[]`, from any of the three shapes a kept report file has: JSON Lines (a `KISS_REPORT` file), the JSON array `check` prints, or a single report object. What `--against` reads.
package/AIKB/init.md ADDED
@@ -0,0 +1,40 @@
1
+ # init.js
2
+
3
+ ## Responsibility
4
+
5
+ The decision core of `kiss-ssg init`: given what is already in a folder, which files to create, which to merge into, which to leave alone, and whether to install the engine. `bin/kiss-ssg.js` is the thin wrapper around it — it reads the folder and the shipped `starter/`, writes exactly what the plan says, runs npm for an `install` action and prints one line per action; every decision it acts on comes from here.
6
+
7
+ It exists so that a new user reaches a site through a coding agent in three shell lines: `init` declares kiss-ssg's two Claude Code plugins in the project's `.claude/settings.json` (and prints the `--scope project` commands that install them), points `CLAUDE.md` and `AGENTS.md` at `node_modules/kiss-ssg/llms.txt`, and drops a starter the `kiss-site-new` skill grows.
8
+
9
+ ## Public interface
10
+
11
+ - `INIT_HELP` — the usage text `init --help` and a usage error print.
12
+ - `parseInitArgs(argv)` → `{ install, help, error }`. `--no-install` clears `install`; `--help`/`-h` sets `help`; anything else — including a script name, which `check` would take — is a usage `error`.
13
+ - `packageName(folderName)` → a valid npm package name for a new `package.json`: lower case, runs of anything npm refuses collapsed to one hyphen, no leading `.`/`_`/`-`; `kiss-site` when nothing is left (`..`).
14
+ - `planInit(state)` → `InitAction[]`, in order: `package.json`, the starter files (or one `skip` for `router.js`), `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, 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 five files it may touch (`package.json`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.gitignore`), and the starter as `{ relativePath: text }`.
15
+ - `describeAction(action)` → the printed line: ` create router.js`, ` merge package.json (added scripts.dev; kept scripts.build)`, ` skip CLAUDE.md — already points at llms.txt`, ` install kiss-ssg@2.7.0`.
16
+ - `nextSteps()` → the closing text: the three `claude plugin … --scope project` install commands, then run `claude`, then paste `FIRST_PROMPT`. 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
+ - Constants: `MARKETPLACE`, `MARKETPLACE_REPO`, `PLUGINS`, `LLMS_IMPORT`, `STARTER_MARKER`, `STARTER_RENAMES`, `FIRST_PROMPT`.
18
+
19
+ ## Depends on
20
+
21
+ Nothing. It is pure: text in, actions out. The bin supplies the folder's state and the `starter/` folder's contents.
22
+
23
+ ## Depended on by
24
+
25
+ - `bin/kiss-ssg.js` — dispatches `init` before `check`'s `parseArgs`, which never sees it.
26
+ - `test/unit/init.test.js` (the plan, and the shipped `starter/` on disk) and `test/integration/init.test.js` (the bin in an empty folder, then `check` over what it wrote, then a second run that must change nothing).
27
+
28
+ ## Non-obvious behavior
29
+
30
+ - **The Codex review of this branch found the promise broken in five places, all fixed test-first:** an existing `.gitignore` was replaced by the starter's (now `planGitignore` appends only the missing lines); a site's own `kiss-ssg` range was replaced by the running version (now `declaredRange` skips the install and says to run `npm install`); a `scripts` that was not an object was spread into character keys (now skipped); a `null` marketplace entry was overwritten (now `in`); and `"type": "module"` plus `router.js` scripts were added to sites `init` did not write (now only with the starter, and never without a `router.js`).
31
+ - **Nothing is ever overwritten.** `package.json` and `.claude/settings.json` are merged key by key — an existing key is kept and named in the action's `detail` ("kept scripts.build") — and a file that is not a JSON object is skipped with a reason rather than replaced. `CLAUDE.md`/`AGENTS.md` are appended to only when they do not already mention `node_modules/kiss-ssg/llms.txt`, on a line of their own even when the file has no trailing newline.
32
+ - **The module type and `main` are the starter's, not the site's.** `"type": "module"` and `"main": "router.js"` are added only when this run writes the starter; a site that already has a `router.js` gets the scripts and nothing else, because switching its module type would change how Node reads a file `init` did not write.
33
+ - **A kept `"type"` other than `"module"` is reported, not fixed.** The starter's `router.js` uses `import`, so the detail says to set `"type": "module"` or rename it `router.mjs`; changing a site's module type behind its back could break everything else it runs.
34
+ - **A plugin the user switched off stays off.** The merge tests `plugin in enabledPlugins`, not truthiness, so `"kiss-memory@kiss-ssg": false` survives.
35
+ - **The starter is all or nothing, and kiss's shape is not the only sign of a site.** It is written only when `existingSite` finds nothing: no `router.js`, no `src/`, no `package.json` `main` naming a real file, and no `build` script other than ours — the final review found a CommonJS app with `main: index.js` being given `"type": "module"`. `npm init -y` names an `index.js` it never writes, so a folder it has just made still gets the starter. Otherwise one `skip` line names what it found. That is what makes `init` safe to run in an existing site.
36
+ - **`starter/gitignore` has no dot on purpose.** npm never packs a file named `.gitignore`, so the starter ships `gitignore` and `STARTER_RENAMES` writes it as `.gitignore`.
37
+ - **`STARTER_MARKER` is read by a skill.** The starter's `router.js` begins with `// Started by \`npx kiss-ssg init\``; `kiss-site-new`treats a`router.js`carrying it as a starter to grow, and one without it as somebody's site to build around. Changing the string breaks that skill's reading, and`test/unit/skill-coverage.test.js` pins it.
38
+ - **The bin writes defensively.** A `create` uses the `wx` flag, so it can never replace a file (or a dangling link) that appeared since the folder was read; a `merge` or `append` is written beside the file and renamed over it, so it lands whole or not at all. The first failed write stops the run, and a line is printed only after its write succeeded. `hasEngine` means `node_modules/kiss-ssg/package.json` exists, so an empty folder by that name is not taken for an installed engine.
39
+ - **The install is last and pinned to the running version.** Every file is written before npm runs, so a failed network still leaves a wired folder, and `kiss-ssg@<version>` means the engine installed is the one whose `init` just ran. It is skipped when `package.json` already asks for `kiss-ssg` in any dependency field — `npm install kiss-ssg@<version>` would replace the range the site chose. The bin spawns npm through a shell on Windows, where `npm` is `npm.cmd`, as one command string: a shell given an argument array prints Node's DEP0190 warning. (Codex also suggested a folder-local `npm.cmd` would run instead of npm; re-derived on Windows 11 with Node 24 it did not — the real npm ran — so no guard was added.)
40
+ - **Idempotent by construction.** Every action a first run writes produces a `skip` on the second, which `test/unit/init.test.js` asserts on the plan and the integration test asserts on the bytes.
package/AIKB/kiss.md CHANGED
@@ -171,6 +171,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
171
171
  - Watch-only copies use `_copyAssets(..., true)` and the bounded `_assetQueue` slot rather than appending settled results to `_promises`. `generate()` and `_drain()` await that slot as well as registration work; callback data retains its original positions.
172
172
  - Replay retires old page producers before rendering, while preserving their on-disk ownership for the orphan sweep. The sweep checks current kind and never removes another writer's output. Page registrations capture their working directory, retain it through replay, and use absolute `page.outputPath` for ownership, deduplication and cleanup. The report retains `buildTo`'s configured spelling.
173
173
  - `_discardStaging()` captures the staging path before awaiting removal, so concurrent close cannot redirect claim cleanup to a cleared field.
174
+ - **An unpromoted staging folder is removed by `_removeStaging()`, from both ends: a failed build's `_discardStaging()` and `close()` with nothing promoted.** It calls `fs.rm` with `maxRetries` — Node's own retry for the `EBUSY` / `ENOTEMPTY` / `EPERM` a Windows antivirus, indexer or pending delete throws for a moment — and never throws: a removal that outlasts the retries logs one `warn` naming the folder, saying nothing was published from it, to delete it by hand, and that the next `cleanBuild: 'atomic'` build sweeps leftovers at start (`_sweepStaleSiblings()`). Before 2.6.2 both sites removed with `fs.remove` and a debug-only catch, and `_discardStaging()` cleared the output claims only on success — a transient lock left the folder, the claims, and no word about either. The claims are now cleared in a `finally`. (The `ENOTEMPTY` a test hit was a different cause: it discarded while a render was still writing — see `AIKB/testing.md` § Gotchas.)
174
175
 
175
176
  - `_defaultAssetCopy` anchors configured source/destination paths and event-path resolution at construction for watch, pipeline recopy and live-reload filenames. Explicit public `copyAssets` calls still resolve at their own registration time. `_restorableAssets` keeps the source/requested-target pairs of copies with live output producers, separately from failure retries; an empty reconciled copy drops its restoration registration. The sweep obtains asset owner identities from the registry rather than reconstructing output paths from source extensions.
176
177
  - Duplicate-output detection reads the new page's cached `outputPath` once, outside the comparison loop. The per-page cache invalidates on route or build-folder changes, including promotion.
package/AIKB/partials.md CHANGED
@@ -30,7 +30,7 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
30
30
  - Partials are registered on the _instance_ environment (`kiss.handlebars`), never the global `handlebars` module. A consumer helper that does `require('handlebars').partials[name]` (v1 sites did this to render a partial chosen by name — diploma-msc's `renderPartial`) silently gets `undefined` in v2. The migration notes point such sites at `kiss.handlebars.partials` or Handlebars' built-in dynamic partial syntax `{{> (lookup this "name")}}`, which needs no helper at all (verified against diploma-msc on 2026-09-04).
31
31
  - **Every partial is registered as a compiled, recording function** (`tracingPartial`). Handlebars calls a function partial as `partial(context, options)` and handlebars-layouts' `extend`/`embed` as `template(context, { data })`; the wrapper reads `options.data.kissPage` — the page identity `KissPage.generate()` puts in the render's data frame — records `(page, name)` on the injected `deps.graph` (`AIKB/dependency-graph.md`), and returns exactly `compiled(context, options)`. The return contract matters: `undefined` makes Handlebars try to compile the function, and a `SafeString` breaks an indented partial call, which splits the result on newlines. A recorder that throws is swallowed; tracing never affects rendering. Without a `graph` in `deps`, or without a page id in the data, the wrapper is a plain compiled partial.
32
32
  - **Layouts keep their earlier win**: compiled once rather than per `{{#extend}}` — measured when that change landed: `fanout@500` **-27.1%**, `models@500` **-15.7%**, `watch@500` **-17.5%**, `scan@500` **-10.7%**, a watch replay after a model edit **455ms → 172ms**. Registering every partial compiled costs nothing extra: `hbs.compile` is lazy, so an unrendered partial is never parsed and a partial that will not parse still fails at render against the page that used it.
33
- - **`hbs.partials[name]` is a function always** — the compatibility line moved. It was a string only until the first render anyway (Handlebars compiles a string partial on first use and writes the function back into the same object — `mergeIfNeeded` returns `env.partials` uncopied), so a consumer helper reading an entry has always had to accept both; `learna-kiss`'s `getSiteSpecificPartial` calls `handlebars.compile(partial)` unconditionally and would have broken on any partial after its first render. The one-line form is in `llms.txt` and `README.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`. **Such a helper must pass its own `options.data` through**: that data frame is how the invoking page is recorded as using the partial, so a bare `p(ctx)` renders correctly but leaves the page unrecorded — under `watch()` it is not re-rendered when the partial changes (unless no page recorded that partial at all, when every page is). `examples/9-migrated-from-v1/router.js`'s `renderPartial` is the worked recipe.
33
+ - **`hbs.partials[name]` is a function always** — the compatibility line moved. It was a string only until the first render anyway (Handlebars compiles a string partial on first use and writes the function back into the same object — `mergeIfNeeded` returns `env.partials` uncopied), so a consumer helper reading an entry has always had to accept both; `learna-kiss`'s `getSiteSpecificPartial` calls `handlebars.compile(partial)` unconditionally and would have broken on any partial after its first render. The one-line form is in `llms.txt` and `GUIDE.md`: `typeof p === 'function' ? p(ctx, { data: options.data }) : kiss.handlebars.compile(p)(ctx, { data: options.data })`. **Such a helper must pass its own `options.data` through**: that data frame is how the invoking page is recorded as using the partial, so a bare `p(ctx)` renders correctly but leaves the page unrecorded — under `watch()` it is not re-rendered when the partial changes (unless no page recorded that partial at all, when every page is). `examples/9-migrated-from-v1/router.js`'s `renderPartial` is the worked recipe.
34
34
  - `partialNameFor(file, folders)` is the only place a file path becomes a partial name outside registration, and it reuses the same slice-and-strip; `Kiss._handleChange` calls it so a chokidar path maps to the name the graph knows. A collision (`foo.md` and `foo.hbs`) maps to one name from either path, which is right: whichever file changed, the registered `foo` is what pages invoked.
35
35
 
36
36
  - **`.txt` is the one partial type that is not compiled.** `registerPartialsFrom` routes it to `literalPartial` instead of `tracingPartial`: the file's characters are escaped once with `hbs.Utils.escapeExpression` and the escaped **string** is returned, so the page shows the file rather than executing it. A plain string, not a `SafeString`, and the distinction is load-bearing: a partial's return value is inserted raw either way, but Handlebars re-indents an indented call by splitting that value on newlines, so the `SafeString` this shipped with first threw `TypeError: result.split is not a function` and wrote no page at all — on the `<pre>`-indented snippet the type exists for. The return contract is the same one `tracingPartial`'s comment states; both partial kinds return a string. The rule an author holds is "a `.txt` partial behaves as if you had written `{{ }}` around its contents" — which is why the escaping is Handlebars' own rather than a bespoke set, and why it is safe in an attribute as well as in body text. The cost is that `=` and `"` become `&#x3D;`/`&quot;` in the source; both render correctly, and predictability was judged worth the ugliness.
package/AIKB/pipeline.md CHANGED
@@ -40,6 +40,6 @@ Runs the external commands of `config.assets.pipeline` — a CSS toolchain, an i
40
40
 
41
41
  **Tools that watch stdin.** A watch child is spawned with stdin ignored. Tailwind's `--watch` exits as soon as stdin closes (exit code 0, logged as `pipeline watch "tailwind" exited (code 0)`); `--watch=always` is the flag that keeps it up. Found on a1k9training, the first real site to use the hook.
42
42
 
43
- **What the tool scans is not this module's business, and that is exactly why it bites.** The module runs a command line and reports its exit code; it has no idea what the command read. With Tailwind that gap has a sharp edge: v4 walks the whole project for class-name candidates and `@source` _adds_ to that walk rather than replacing it, so a site's own `.md` files are scanned as plain text and an ordinary English word compiles a utility. Paired with `assets.hash: true` (`AIKB/asset-manifest.md`) the stylesheet's hash is its filename, so a documentation edit renames `css/site.<hash>.css` and rewrites the `<link href>` on every page — which surfaces as `kiss-ssg check` reporting `~ N changed, = 0 unchanged` and no page source to blame. It is intermittent (a trailing `.` or `,` stops a word being a candidate token), which is what makes it expensive to diagnose. `@import 'tailwindcss' source(none)` plus explicit `@source` paths — resolved against the stylesheet's folder, not the cwd — is the fix. Verified on Tailwind 4.3.3; documented in `llms.txt` and `README.md` because the recipe there is what gets copied. Originally surfaced by a consuming site running `assets.hash` against a repo full of tracked markdown.
43
+ **What the tool scans is not this module's business, and that is exactly why it bites.** The module runs a command line and reports its exit code; it has no idea what the command read. With Tailwind that gap has a sharp edge: v4 walks the whole project for class-name candidates and `@source` _adds_ to that walk rather than replacing it, so a site's own `.md` files are scanned as plain text and an ordinary English word compiles a utility. Paired with `assets.hash: true` (`AIKB/asset-manifest.md`) the stylesheet's hash is its filename, so a documentation edit renames `css/site.<hash>.css` and rewrites the `<link href>` on every page — which surfaces as `kiss-ssg check` reporting `~ N changed, = 0 unchanged` and no page source to blame. It is intermittent (a trailing `.` or `,` stops a word being a candidate token), which is what makes it expensive to diagnose. `@import 'tailwindcss' source(none)` plus explicit `@source` paths — resolved against the stylesheet's folder, not the cwd — is the fix. Verified on Tailwind 4.3.3; documented in `llms.txt` and `GUIDE.md` because the recipe there is what gets copied. Originally surfaced by a consuming site running `assets.hash` against a repo full of tracked markdown.
44
44
 
45
45
  **Omitting `-i` is a silent _total_ loss, not a half-build.** `npx @tailwindcss/cli -o out.css` exits 0 and writes a stylesheet, but substitutes a default input of `@import 'tailwindcss'` instead of reading the entry file, so everything authored there goes — every `@layer components` class, every `@theme` token, every `@font-face`, every plugin import and every `@source` line — leaving generic utilities. Verified on 4.3.3; on a real 20-page site the output fell from 66,384 to 24,990 bytes. A step that does this is `{ ok: true }` here and always will be — the tool succeeded. The failure mode worth knowing is a site whose `watch` command carries `-i` and whose `run` does not: dev is correct, production is not, and nothing in the report distinguishes them.
package/AIKB/testing.md CHANGED
@@ -10,7 +10,7 @@ Cross-cutting notes on how tests in `test/` are organized and written — not a
10
10
  - A test that spawns a child process of its own — `test/unit/pipeline.test.js` runs `node -e` commands through `createPipeline` — uses `process.execPath`, quoted, rather than a bare `node`, and must `close()` the pipeline in `afterEach`: a watch process left running keeps the worker (and the temp folder) alive.
11
11
  - `test/helpers/kiss.js` — re-exports `Kiss`/`utils` from `lib/kiss.js` and `ENTRY` (`path.resolve('lib/kiss.js')`), so integration tests import one thing instead of reaching into `lib/` directly.
12
12
  - `test/fixtures/consumer/` — a plain-JS site (`site.js`, `// @ts-check`) plus a deliberately broken one (`bad.js`), each with its own tsconfig mapping `kiss-ssg` to the repo's `types/kiss.d.ts`. `test/unit/types.test.js` runs `tsc --noEmit` over both through `child_process`: `site.js` must be clean, `bad.js` must report exactly three named errors. Not a unit test of any module — it is the published type surface checked from the outside, the way a consumer meets it.
13
- - `test/aikb.test.js` — **not** the unit test for `lib/aikb.js` (that is `test/unit/aikb.test.js`, with `test/integration/aikb.test.js` beside it); this one is the repo's docs-sync test, named for the `AIKB/` folder it guards. It is covered by this doc's own CLAUDE.md table row: fails if a `lib/` module has no `AIKB/` doc, a doc isn't listed in `CLAUDE.md`'s lookup table, a doc has no matching module, or a doc drops one of the five required headings. It also holds the two shipped-docs parity checks: every non-underscore `Kiss.prototype` method is named in `llms.txt`, and the config-defaults block in both `llms.txt` and `README.md` evaluates to exactly `resolveConfig({})` — so a new default cannot land in `config.js` alone.
13
+ - `test/aikb.test.js` — **not** the unit test for `lib/aikb.js` (that is `test/unit/aikb.test.js`, with `test/integration/aikb.test.js` beside it); this one is the repo's docs-sync test, named for the `AIKB/` folder it guards. It is covered by this doc's own CLAUDE.md table row: fails if a `lib/` module has no `AIKB/` doc, a doc isn't listed in `CLAUDE.md`'s lookup table, a doc has no matching module, or a doc drops one of the five required headings. It also holds the two shipped-docs parity checks: every non-underscore `Kiss.prototype` method is named in `llms.txt`, and the config-defaults block in both `llms.txt` and `GUIDE.md` evaluates to exactly `resolveConfig({})` — so a new default cannot land in `config.js` alone.
14
14
  - `test/unit/changelog.test.js` — the other version-sync net: `CHANGELOG.md`'s first `## <version> — <date>` heading must be the version `package.json` declares. The manifests are carried by `npm version` and need no hand; the changelog is the one version-bearing file a person writes, so this is what catches a bump with no entry or an entry with no bump — and it runs at the gates, which `prepublishOnly` runs too.
15
15
  - `test/unit/plugin-manifests.test.js` — the Claude Code plugin's own sync test: `.claude-plugin/marketplace.json` and `plugins/kiss-ssg/.claude-plugin/plugin.json` must parse, both must carry `package.json`'s version, the marketplace's `source` must resolve to the plugin folder, and every skill's frontmatter `name` must match its folder. It also walks each `SKILL.md` for inline-code paths starting `node_modules/kiss-ssg/` and fails if one does not exist in the repo or is not inside the `files` whitelist — the skills point at the shipped docs instead of restating them, so a path that does not ship sends the agent nowhere.
16
16
 
@@ -34,6 +34,7 @@ npm run types # regenerate types/ from the JSDoc in lib/
34
34
  - `watch({ entry: null })` is the standard way to disable the entry-script watcher in tests (see `lib/watcher.js` — there's no real "entry script" when `Kiss` is constructed from a test helper).
35
35
  - `test/integration/esm.test.js` verifies `require('kiss-ssg')` still works from CommonJS by spawning a real `node` process on a generated `.cjs` file (via `execFile`) — this can't be tested in-process because Vitest itself runs under ESM.
36
36
  - `test/integration/check.test.js` runs the real `bin/kiss-ssg.js` as a child process against a throwaway site under the OS temp dir — the only way to assert on an exit code and on stdout carrying nothing but the JSON report. The fixture sites it writes import the engine by absolute `file://` URL (a temp dir cannot resolve `kiss-ssg` from `node_modules`), and each one asserts that the build folder is still absent, or still holds its sentinel file, afterwards. Its second `describe` reads the same report through the API instead — `kiss.report()`, `err.report`, and the staged-path mapping after a promoted and after a discarded atomic build — since `lib/kiss.js` is covered here rather than in a unit file. It also pins the engine line on stderr both ways: the honest "no node_modules/kiss-ssg found" for those path-imported fixtures, and the linked wording through a real junction it makes under the temp site — plus a script in a subfolder with its own `node_modules/kiss-ssg`, proving resolution walks up from the script's folder rather than the caller's cwd.
37
+ - `test/integration/init.test.js` runs the real bin's `init` in an empty temp folder whose `node_modules/kiss-ssg` is a **junction** to the repo root (`fs.symlinkSync(root, dir, 'junction')` — no admin rights needed on Windows), so the starter's `import Kiss from 'kiss-ssg'` resolves the working tree the way a consuming site resolves the package; then it runs `check` over what `init` wrote. Every call passes `--no-install`, so the npm path is not exercised here — it was checked by hand from a packed tarball on Windows (see `AIKB/init.md`). The byte-preservation case writes files first and asserts each survives as a prefix.
37
38
  - **A behaviour that only one platform exhibits gets two tests, not a skip and a shrug.** `npm run gates` runs on `ubuntu-latest` **and** `windows-latest` (`.github/workflows/ci.yml`), so an `it.skipIf(process.platform !== 'win32')` really does run in CI — it is not documentation pretending to be a test. But a test that only ever runs on one leg is still half a guard, so where the same mechanism can be reached portably, reach it: the copy-identity canonicalisation is asserted twice, once through a case-different path (Windows only, where a case-insensitive filesystem makes it meaningful) and once through a **symlink** (everywhere). The symlink half is what catches the refactor that would actually happen — replacing `realpathSync.native` with `path.resolve` to drop a sync filesystem call — on any machine, rather than waiting for the Windows leg. Three behaviours on this branch were Windows-divergent (`new URL(…).pathname`, a path-separator assertion, and this), which is enough of a pattern to make the portable half the default expectation rather than a bonus.
38
39
 
39
40
  - `test/integration/bench-watch.test.js` is the one exception to "the I/O wrapper below the decision core stays untested": `scripts/bench.mjs`'s `--dev` watch reading spawns a real dev-mode site, drives it over a websocket and edits its source files, and none of that can be asserted from a pure function. It generates a three-file site under the OS temp dir, links this repo into its `node_modules` with a **junction** (`fs.symlinkSync(…, 'junction')` — no elevation on Windows, and it is what lets `resolveKissFrom` prove which engine was measured), picks both ports with `net.createServer().listen(0)` because the engine's defaults collide with any dev server the operator left open, and afterwards asserts the three touched files are byte-identical and the livereload port binds again — the proof that no child was left running. 60s timeout, like the other child-process suites; it runs in about three seconds.
@@ -47,6 +48,8 @@ npm run types # regenerate types/ from the JSDoc in lib/
47
48
 
48
49
  ## Gotchas
49
50
 
51
+ - **`await Promise.all(kiss._promises)` does not wait for `generate()`'s renders** — those go on `kiss._generating`. A test that inspects or discards the staging folder after it must `await kiss._drain()`, the wait the engine itself uses before it discards or promotes. The "round two" discard test in `watch.test.js` waited on `_promises` and failed about one run in four on Windows: the page write landed in staging mid-removal, `fs.remove` failed with `ENOTEMPTY`, and `_discardStaging` swallows that before clearing the output claims (2026-09-26).
52
+
50
53
  - Relative-path fixtures must anchor their working directory inside the temporary site: Windows CI can put the checkout and OS temp directory on different drives, where `path.relative` returns an absolute drive path. Prefixing that with `./` does not make it relative. Containment tests use native separator semantics; on POSIX a backslash is a filename character, not a separator.
51
54
 
52
55
  - **`examples.test.js`'s byte-identical AIKB assertions self-heal, so a report-shape change fails exactly once and then passes.** Examples 9 and 11 are recorded **in place**, in the committed `examples/*/AIKB/` folders, and the test asserts the files are unchanged before and after. `last-build.json` is the whole `BuildReport`, so adding any report key rewrites both files: the first run fails and leaves them matching, and every run after it is green. Read a green re-run as evidence of nothing — the first run already healed it. The correct response is never to re-run: it is `npx kiss-ssg aikb router.js` in `examples/9-migrated-from-v1/` and `examples/11-blog/`, then commit the re-recorded files as part of the change. (Found the hard way twice on 2026-09-16, once per report key added; the first instance was committed by a `git add -A` before anyone understood what it was.)
package/AIKB/utils.md CHANGED
@@ -47,7 +47,7 @@ Small string/path helpers shared across the engine: slugification, title-casing,
47
47
  | Netlify (`www.a1k9training.co.uk`) | `/courses/` 200 | `/x` 200, `/x/` 301 |
48
48
  | Firebase Hosting, `cleanUrls` + `trailingSlash: false` (`learna.ac.uk`) | `/courses` 200 | `/x` 200, `/x/` 301 |
49
49
 
50
- They agree on file pages and contradict each other on directory indexes, so one has to be the default and the other has to be sayable. The default stays Netlify's (`trailingSlash: true`): all 18 `<loc>` entries in a1k9training's live sitemap return 200 under it, and moving it would turn six of them into redirects. **GitHub Pages' file pages are measured** — `/about` and `/about.html` both 200, `/about/` 404s and nothing redirects (relayed from a clean-room session's live deployment, not measured here; the table in `llms.txt` § trailing slashes and `README.md` carries the same row) — but a **real directory index** on it is still unverified, which is the half that decides the policy. Cloudflare Pages and bare nginx are unverified outright — check before claiming either way rather than reasoning from the three host names this doc used to list. `toAbsoluteUrl` and `servedPathFor` each take the policy as an option and **must be given the same one**: the first is every absolute URL kiss emits, the second is `{{link}}`'s href, and a site that splits them links `/courses/` while canonicalising `/courses` — taking the redirect the setting exists to avoid on every internal click. The site root is the one URL both conventions agree on: its whole path is the `index` segment, so either replacement empties it and the join returns `${siteUrl}/`.
50
+ They agree on file pages and contradict each other on directory indexes, so one has to be the default and the other has to be sayable. The default stays Netlify's (`trailingSlash: true`): all 18 `<loc>` entries in a1k9training's live sitemap return 200 under it, and moving it would turn six of them into redirects. **GitHub Pages' file pages are measured** — `/about` and `/about.html` both 200, `/about/` 404s and nothing redirects (relayed from a clean-room session's live deployment, not measured here; the table in `llms.txt` § trailing slashes and `GUIDE.md` carries the same row) — but a **real directory index** on it is still unverified, which is the half that decides the policy. Cloudflare Pages and bare nginx are unverified outright — check before claiming either way rather than reasoning from the three host names this doc used to list. `toAbsoluteUrl` and `servedPathFor` each take the policy as an option and **must be given the same one**: the first is every absolute URL kiss emits, the second is `{{link}}`'s href, and a site that splits them links `/courses/` while canonicalising `/courses` — taking the redirect the setting exists to avoid on every internal click. The site root is the one URL both conventions agree on: its whole path is the `index` segment, so either replacement empties it and the join returns `${siteUrl}/`.
51
51
 
52
52
  - **A directory index canonicalises with a trailing slash by default, and that is the whole point of `toAbsoluteUrl` keeping one.** `courses/index.html` is served by Netlify at `/courses/`; that host answers the bare `/courses` with a 301, and Google's guidance is that a canonical must be the URL that returns 200. `toAbsoluteUrl` therefore replaces a trailing `index(.ext)?` segment with an empty last segment (`courses/index.html` → `courses/`) instead of dropping it, and no longer trims a trailing slash it was given — `{{absUrl '/courses/'}}` is `https://site/courses/` while the bare `{{absUrl '/courses'}}` stays `https://site/courses`. Repeated slashes are collapsed on the way through. The defect this fixes was found on a real site (`a1k9training`), which was regex-patching its own sitemap after every build.
53
53
  - **`servedPathFor` is the fourth, and it is the only one that describes what the _host_ does.** The other three are about the URL a site _advertises_; this one is the path the file is actually fetched from, which is what an in-page `href` needs: `about.html` is served at `/about.html`, not at the pretty `/about`, unless the site is extension-less (where the two coincide). Two rules make it distinct. It collapses **only `index.html`**, never another `index.<ext>` — `toAbsoluteUrl`'s `index(\.[^./]*)?` strips any extension, which would advertise an `ext: 'json'` index page at `/data/`, a directory with no `index.html` in it, and `lib/links.js` would rightly call that broken. And it is **root-relative, always**: a link to a page is a path from the site root, and the absolute form is a separate, explicit ask (`{{link … absolute=true}}`, which string-joins `siteUrl`'s origin and prefix to this) rather than something the derivation guesses. Its output is a path `resolveReference` accepts by construction — file, `<dir>/`, or `/` — which is what keeps `{{link}}` and the link checker from ever disagreeing (`AIKB/links.md`).
package/CHANGELOG.md CHANGED
@@ -3,6 +3,80 @@
3
3
  Written for people building a site with kiss-ssg, not for people maintaining it.
4
4
  Newest first. `/branch-close` adds an entry alongside each version bump.
5
5
 
6
+ ## 2.6.2 — 2026-09-27
7
+
8
+ ### A staging folder that will not delete is said out loud
9
+
10
+ Under `cleanBuild: 'atomic'` (and `kiss-ssg check`), a failed build's staging
11
+ folder is removed with a few retries, which clears the brief locks a Windows
12
+ antivirus or indexer takes. If it still cannot be removed, kiss now logs one
13
+ warning naming the folder — nothing was published from it, so delete it by
14
+ hand; the next atomic build sweeps leftovers when it starts. Previously the
15
+ failure was silent. `close()` does the same for a staged build that was never
16
+ completed.
17
+
18
+ ### `await complete()` in dev too
19
+
20
+ The `init` starter and every example now end with
21
+ `await kiss.complete().catch(reportBuildFailure)` in both modes, instead of
22
+ only outside `--dev`. Awaited in dev, it resolves after the first build and
23
+ the server keeps serving; behind the old `if (!dev)` guard, a dev server that
24
+ could not bind its port exited 0 instead of 1. If your `router.js` came from
25
+ an example or the starter, you can drop the guard the same way.
26
+
27
+ ### `kiss-site-new` corrected
28
+
29
+ The skill's list of **seven decisions** (it said "four habits") is now
30
+ distinct from the five working habits in `llms.txt`, and its instructions
31
+ match what the examples do: `"type": "module"`, the `check` and `aikb`
32
+ scripts, and the unguarded `complete()`.
33
+
34
+ ## 2.6.1 — 2026-09-26
35
+
36
+ ### `npx kiss-ssg init` — an empty folder to a site an agent can grow
37
+
38
+ ```sh
39
+ mkdir my-site && cd my-site
40
+ npx kiss-ssg@latest init
41
+ ```
42
+
43
+ `init` installs kiss-ssg, drops a one-page starter site (`router.js`,
44
+ `src/layouts/layout.hbs`, `src/pages/index.hbs`, a `.gitignore`), adds the
45
+ `build`, `dev`, `check` and `aikb` scripts to `package.json`, points
46
+ `CLAUDE.md` and `AGENTS.md` at `node_modules/kiss-ssg/llms.txt`, and declares
47
+ kiss-ssg's two Claude Code plugins in `.claude/settings.json`. It ends by
48
+ printing the three commands that install those plugins at project scope —
49
+ declaring a plugin does not install it — and a prompt to paste into the agent.
50
+
51
+ It never overwrites a file. `package.json` and `.claude/settings.json` are
52
+ merged key by key, `CLAUDE.md`, `AGENTS.md` and `.gitignore` are only appended
53
+ to, and the starter is written only when no project is there yet (no
54
+ `router.js`, no `src/`, no `package.json` `main` file or `build` script of its
55
+ own). On an existing site `"type"` and `"main"` are left as they are, and a
56
+ `kiss-ssg` version your `package.json` already asks for is not replaced — run
57
+ `npm install` instead. Running it twice changes nothing. `--no-install` skips
58
+ npm; `kiss-ssg init --help` lists exactly what it writes.
59
+
60
+ ### The README is the agent quick start; the reference is `GUIDE.md`
61
+
62
+ The README now opens with the setup above, prompts to copy for each skill, and
63
+ how to set things up by hand or for agents other than Claude Code. Everything
64
+ it used to document — every method, option and helper, and migrating from v1 —
65
+ moved unchanged to `GUIDE.md`, which ships in the package beside `llms.txt`.
66
+
67
+ ### Plugins install at project scope
68
+
69
+ Every install instruction — the README, `llms.txt`, both plugin READMEs — is now
70
+
71
+ ```sh
72
+ claude plugin marketplace add cprobert/kiss-ssg --scope project
73
+ claude plugin install kiss-ssg@kiss-ssg --scope project
74
+ claude plugin install kiss-memory@kiss-ssg --scope project
75
+ ```
76
+
77
+ so the site's committed `.claude/settings.json` records which skills it is
78
+ built with.
79
+
6
80
  ## 2.6.0 — 2026-09-23
7
81
 
8
82
  ### Source folders are protected before a build touches disk