kiss-ssg 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
6
6
 
7
7
  ## Public interface
8
8
 
9
- - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap })` → `BuildReport`, whose keys are always in this order:
9
+ - `buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms })` → `BuildReport`, whose keys are always in this order:
10
10
  - `ok` — `failures.length === 0`.
11
11
  - `mode` — `'build'` for a build that publishes, `'check'` for one that is staged and discarded.
12
12
  - `buildDir` — the folder the site asked for, never the staging sibling.
@@ -16,6 +16,7 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
16
16
  - `assets` — `{ source, target }` per asset-manifest entry: the build-relative path a template asks for, and the file that is actually there.
17
17
  - `sitemap` — the `sitemap.xml` this build wrote, or `null`.
18
18
  - `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
19
+ - `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
19
20
  - `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.
20
21
 
21
22
  ## Depends on
@@ -28,7 +29,7 @@ Nothing.
28
29
 
29
30
  ## Non-obvious behavior
30
31
 
31
- - **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.
32
+ - **Every path in the report is mapped out of the staging folder** — page `buildTo`, failure `buildTo`, `sitemap` and `llms` 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.
32
33
  - **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.
33
34
  - **`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`.
34
35
  - **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.
package/AIKB/kiss.md CHANGED
@@ -17,6 +17,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
17
17
  - `.generate(callback)` — sets the `_generateRequested` latch, awaits `_promises`, renders every stack entry with `runCount === 0` once (each render caught, failures collected on `_failures`), awaits the writes, invokes `callback` through `_runCallback` (so a callback that throws, or returns a rejecting promise, is collected on `_failures` too); returns `this`.
18
18
  - `.complete(callback)` — awaits `_settle()` (drain, then render whatever the drain left unrendered, repeated until the stack is stable), then **rejects with an `AggregateError`** if `_failures` is non-empty and `_failuresReported` is not yet set; otherwise invokes `callback` with the resolved data (never on a failed build) and returns that data (a `Promise`, unlike the other chainable methods). Under `cleanBuild: 'atomic'` this is also the promotion point: a settle with no failures promotes the staging folder, a settle with failures discards it — and in check mode a settle with no failures discards it too. It is also where the build report is assembled (`_finishBuild()`), so `report()` and `err.report` are set by the time either outcome reaches the caller.
19
19
  - `.sitemap(options, callback)` — records the request on `_sitemapRequest` (so watch rebuilds re-run it), awaits `_promises`, writes `sitemap.xml` via `writeSitemap()`, invokes `callback` through `_runCallback`; returns `this`.
20
+ - `.llms(options, callback)` — the same shape one file over: records `_llmsRequest`, awaits `_promises`, writes `llms.txt` via `writeLlms()` (`AIKB/llms.md`), records `_llmsPath` for the report's `llms` field, invokes `callback` (with the rendered text) through `_runCallback`; returns `this`. A missing `siteUrl`, `title` or `summary` is logged and skipped, and the callback does not fire.
20
21
  - `.getModelByID(id, data)` — looks up `{ id }` in a resolved-models array; returns `.data` or an error object.
21
22
  - `.watch({ entry = process.argv[1] } = {})` — starts (once) a `createWatcher()` wired to `_handleChange` (every `src` event), `_requestReplay()` (the entry script) and `copyAssets()` + a live reload (the assets tree); returns `this`. What each event means is decided in `_handleChange` — the dispatch table is below — and every rebuild it asks for goes through the serial queue.
22
23
  - `.report()` — the last settled build as data (`AIKB/build-report.md`), or `null` before the first `complete()` settles. The same object is on the rejection as `err.report`, and a watch rebuild replaces it with its own.
@@ -25,7 +26,7 @@ The orchestrator and public API. `Kiss` owns config, a per-instance Handlebars e
25
26
 
26
27
  ## Depends on
27
28
 
28
- `fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./assets.js`, `./asset-manifest.js`, `./pipeline.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
29
+ `fs-extra`, `node:path`, `handlebars`, `handlebars-layouts`, `remarkable`; and `./utils.js`, `./logger.js`, `./config.js`, `./build-report.js`, `./handlebars-helpers.js`, `./partials.js`, `./dependency-graph.js`, `./assets.js`, `./asset-manifest.js`, `./pipeline.js`, `./model-resolver.js`, `./controller-resolver.js`, `./sitemap.js`, `./llms.js`, `./kiss-page.js`, `./dev-server.js`, `./watcher.js`.
29
30
 
30
31
  ## Depended on by
31
32
 
@@ -34,7 +35,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
34
35
  ## Non-obvious behavior
35
36
 
36
37
  - Pipeline: `.page()`/`.pages()`/`.scan()` queue work (model → controller → `_preparePage`) as one caught promise per page on `_promises`; nothing renders until `.generate()`. `.generate()` awaits `_promises`, renders every stack entry whose `runCount` is 0, awaits the writes, then fires the callback. `_generating` tracks `generate()`/`sitemap()` runs, `_callbacks` the promises callbacks themselves returned. `complete()` drains all three lists repeatedly because callbacks may queue more work, and then renders anything the drain left unrendered.
37
- - **The build folder is written by four things, and `config.folders.build` is the single handle on all four.** Pages (`_preparePage` copies it onto each `KissPage.buildDir`, which `buildTo` is composed from — plus the dev-mode `.json` debug sibling beside each page), the asset copy (`copyAssets(folders.assets, folders.build)` at construction, and again on a watched asset change), `sitemap.xml` (`writeSitemap` reads `config.folders.build`), and `viewStats()`'s `debug.json`. Nothing writes into the build by any other route, which is what makes the staged build below a one-line redirection rather than four separate detours.
38
+ - **The build folder is written by four things, and `config.folders.build` is the single handle on all four.** Pages (`_preparePage` copies it onto each `KissPage.buildDir`, which `buildTo` is composed from — plus the dev-mode `.json` debug sibling beside each page), the asset copy (`copyAssets(folders.assets, folders.build)` at construction, and again on a watched asset change), `sitemap.xml` (`writeSitemap` reads `config.folders.build`), `llms.txt` (`writeLlms`, same route), and `viewStats()`'s `debug.json`. Nothing writes into the build by any other route, which is what makes the staged build below a one-line redirection rather than four separate detours.
38
39
  - **`config.assets.pipeline` is wired at three points, and all three are one line each** (`AIKB/pipeline.md` has the runner). The constructor creates the pipeline and calls `_queuePipeline()` immediately before the construction-time `copyAssets()`; `_replay()` re-runs it (and re-queues the copy) when — and only when — the site configured a step, so a whole-site watch rebuild re-runs the tools an edited model or controller may have changed the input to; `close()` ends the watch processes. **Ordering needs no mechanism of its own**: the steps are chained onto `_assetQueue`, the promise every copy already chains onto, so "steps first, copy second" is the queue doing what it was built to do. The run is deliberately **not** pushed onto `_promises`: those entries are the `data` array `generate()`/`complete()` hand back, whose first entry is documented as the construction-time asset copy, so a site adding a pipeline would have silently renumbered its own `data`. It goes on `_generating`, which `_drain()` waits for just the same. A step that exits non-zero (or cannot be spawned) is recorded as `{ view: '<pipeline: name>', buildTo: null, error }` — the same shape as a controller or dev-server failure, since there is no output path to name it by — so `complete()` rejects while the copy and every page still run and the report still names everything else that was wrong. Scoped re-renders (a page view, a partial, a layout) never re-run the steps: a tool that must see those edits is what a step's `watch` command is for.
39
40
  - **A pipeline step runs before `cleanBuild` could matter, and writes wherever the site tells it to.** `KISS_BUILD`, `KISS_ASSETS` and `KISS_DEV` are handed to each command (read per run, so `KISS_BUILD` follows a staged build into its staging folder and back out again after `_promote()`), but nothing stops a step writing anywhere else. The idiom the feature is built for is a tool that writes into the **source** tree (`src/assets/css/site.css`), which is why `kiss-ssg check` can run the pipeline exactly like a build: the step's output is a source file the check has nothing to discard.
40
41
  - **`cleanBuild: 'atomic'` builds into a staging sibling and swaps it in on success.** `_setupFolders()` keeps the folder the consumer asked for in `_buildTarget` and points `config.folders.build` at `<build>.kiss-staging-<pid>-<random>`, so all four write paths above land there with no further plumbing; the real folder is not touched at all until the swap. `complete()` is the decision point: a settle with no failures calls `_promote()`, and a settle with failures calls `_discardStaging()`, which deletes the staging folder and leaves the previous output exactly as it was. Promotion happens **once**: `_buildSettled` latches, so the nested `complete()` pattern's second call is a no-op and, crucially, a second `complete()` after a failed build (which resolves rather than re-rejecting, see the report-once bullet) can never promote a build that failed. The failure message names the target path, not the staging path (`_reportedPath`), because the staging folder is an implementation detail the operator should not have to decode.
@@ -43,7 +44,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
43
44
  - **A `generate: false` page claims no output path.** `_preparePage`'s `buildTo` dedupe only counts entries that will be written, on both sides — a skipped page neither fails a real page registered after it nor fails itself when one came first — and the replay sweep's `current` set excludes them, so a page that turns `generate: false` between builds has its stale output removed. It still sits on `_stack` (so `.scan()`'s by-view dedupe and the `complete()` data array are unchanged) and is skipped by the sitemap.
44
45
  - **After a promotion the instance is an ordinary one.** `_promote()` points `config.folders.build` back at the real folder and re-points every stack entry's `buildDir`/`buildTo`, so a second build on the same instance — or a watch rebuild — writes into the promoted folder instead of a staging sibling nothing will ever swap in again. `_stagedPath()` covers the one write that does not read `folders.build`: a `copyAssets()` the consumer aimed explicitly at the build folder (or a subfolder of it, the metacarpus idiom) is rewritten into staging, or the promotion's `fs.remove(target)` would delete the files that copy had just made. A copy aimed anywhere else is left where it was asked for.
45
46
  - **`'atomic'` is a one-shot-build feature, and dev mode says so.** With `dev: true` the promotion point is the wrong shape — the dev server serves `folders.build` from construction and every rebuild would have to promote — so `_setupFolders()` degrades `'atomic'` to `true` (wipe at construction, build in place) and logs one `notice` saying that atomic promotion applies to one-shot builds. A **non-dev** `.watch()` is the one seam left: it stages, promotes at the first successful `complete()`, and behaves as an ordinary watch after that. A build that never calls `complete()` never promotes, and `close()` removes the staging folder — which is also what clears a discarded one that a later write recreated (`_discardStaging` deliberately keeps `_stagingDir` set for exactly that).
46
- - **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_sitemapPath`, and `_startedAt`) reset in `_replay()` with `_failures`, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
47
+ - **Every settled build assembles exactly one report** (`_finishBuild()`, `AIKB/build-report.md`), after the staging folder has been promoted or discarded so every path in it names the folder the site asked for. It is latched on `_report` rather than on `_failuresReported`, because the nested-`complete()` pattern settles one build twice and `KISS_REPORT` takes one line per build, not per call; `_report` (and `_sitemapPath`, `_llmsPath`, and `_startedAt`) reset in `_replay()` with `_failures`, so a watch rebuild reports itself rather than the first build, and `duration` measures that rebuild rather than the hours the dev server has been up. The report is deliberately additive: `complete()` still resolves with the `generate` data array and still rejects with the `AggregateError` — consumers documented in `llms.txt` rely on both — and the report rides along on `err.report`.
47
48
  - **A production build starts loading the HTML minifier in the constructor.** `preloadMinifier()` (`AIKB/kiss-page.md`) is called, unawaited, as soon as the config is resolved and `config.dev` is known to be false — before `_setupFolders()`, the asset copy and partial registration — so the minifier's ~155ms import overlaps that work and model resolution instead of sitting on the critical path at the first render. Check mode forces `dev: false` first, so a check preloads too. Dev never minifies and never loads it.
48
49
  - **Check mode is the one place the engine reads process state.** `KISS_CHECK` is read once in the constructor (`checkModeRequested()`), never in `resolveConfig`, which stays pure. It forces `cleanBuild: 'atomic'` and `dev: false` over the resolved config before `_setupFolders()` runs — so every write goes to the staging sibling and the real build folder is neither emptied nor written — and at the settle it **discards** the staging folder on success as well as on failure. `dev` cannot survive it: `'atomic'` degrades to a wipe in place under a dev server, and a check has to exit. Any value counts as on except `''`, `'0'` and `'false'`: `KISS_CHECK=true` falling silently through to a real build over published output is the one outcome the feature exists to prevent. `KISS_REPORT=<file>` is the second half of the channel — each settled build appends its report as one JSON Lines row, because a script may build several sites (`examples/7-versioned-outputs.js` builds two) and `bin/kiss-ssg.js` wants them all. A report that cannot be written is logged, never fatal. Neither variable changes the script's own exit code; the bin decides that (`AIKB/check.md`). What check mode cannot make honest is a script that reads its own build folder back after `complete()`: example 7's archive index lists the season folders on disk, and under a check no season was published, so it reports none.
49
50
  - **A staged build's `folders.build` is genuinely the staging path while it runs**, so a template rendering `{{config.folders.build}}` (or a controller reading it) sees the staging name, not the target. Nothing in the engine surfaces it — `{{asset}}` renders build-relative paths and `{{canonical}}`/`sitemap.xml` are built from `siteUrl` — but a site that prints the raw folder into its output should not use `'atomic'` for that page.
@@ -71,6 +72,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
71
72
  - **A renamed asset is invisible to the orphan sweep, on purpose.** The sweep works from `_stack` `buildTo` paths (page outputs); assets have no stack entry. Stale hashed files are removed by `lib/assets.js` from the manifest's own record of the name it replaced, not here.
72
73
  - **Under a hashing policy an asset edit re-renders the pages.** `watch()`'s `assetsChanged` normally just re-copies and names the built file for a live-reload stylesheet swap. With `config.assets.hash`, and only for a file the policy actually renames (`isHashable`, i.e. the built `.css`/`.js`), the copy has changed the filename, so every page is linking one that no longer exists: the handler instead asks the rebuild queue for a scoped re-render of `_stack` (the same scoped path an edited partial takes, but every entry: a renamed asset can be linked from any page) and lets the queue's `.finally()` do the one reload, after every page has been rewritten. An edited image or `robots.txt` keeps its name, so it keeps the cheaper targeted reload. `_builtAssetPath` also resolves through the manifest, so the path it hands live reload is the file that is actually on disk.
73
74
  - `_replay()` (watch rebuilds only) discards `_stack`, `_promises` and `_generating` (and `_failures`, and resets `_assetQueue`), re-registers partials, then re-runs `page()` for every entry in `_registrations` with `_replaying = true`, and finally `generate()` + `complete()`. Because the whole pipeline re-runs, models are re-read from disk (and URL models are _re-fetched_ — a watch rebuild costs a network round trip per remote model, unless `config.fetch.cache` is set to a directory, in which case the cached body is read instead and no request is made: see `AIKB/fetch-policy.md`) and controllers re-run; `_replaying` also propagates as `fresh` into `applyController`, which busts Node's module caches so an edited controller file reloads (see `AIKB/controller-resolver.md`). The build dir is _not_ cleaned by a replay (that would blow away assets and leave the site briefly empty for the dev server); instead the replay snapshots the previous stack's `buildTo` paths up front and, in a `finally` around `complete()`, removes any that the new stack no longer contains — plus the same-named `.json` dev debug sibling. So a page whose slug changed, or one that vanished from a shrinking `.pages()` fan-out, no longer leaves stale output behind. The removal runs in a `finally` so a failed rebuild still cleans up — but only one that failed **after** it re-queued the site. The `try` now opens right after `previous` is captured and wraps the whole re-registration half (`registerPartials()`, the drop-vanished-registrations filter, the re-scan, the `page()` loop) as well as `complete()`, and a `requeued` flag flips once the `page()` loop has run. Two reasons. (1) A throw in that half used to skip the `finally` altogether, so `previous` was lost and every stale output stayed on disk for the life of the process — no later replay could see it, because each one snapshots `previous` from a `_stack` that no longer mentions those files, and in dev mode the server went on serving a page that had been deleted. (2) Simply widening the `try` is not enough on its own: with `requeued` still false the new stack is empty because the replay _failed_, not because the site shrank, so an unguarded sweep would delete the entire build. So a replay that throws before re-queuing sweeps nothing and parks `previous` on `_unswept`, which the next replay folds back into its own `previous` (deduped) and sweeps then; the last good build stays on disk in the meantime and the failure is reported as `Error rebuilding site` as before. Once the pages are re-queued the old contract holds unchanged: every page that should exist has a `buildTo` in the stack (`_preparePage` runs before the render), so `previous` minus the stack is genuinely stale even when the rebuild then fails while rendering. **The sweep is only atomic at the rebuild's edge, not per page**: it removes the output and then its `.json` sibling one `await` later, and works through `previous` one entry at a time, so between those awaits the build dir is legitimately half-swept. The completion signal is the rebuild queue settling (`_rebuildInFlight`), never an individual file disappearing — anything that reacts to one removal and then reads another (a test, a deploy step) is reading a state the engine never promised. That is what made `test/integration/watch.test.js`'s deleted-scanned-view test load-sensitive: it waited on `gone.html` and then asserted on `gone.json`.
75
+ - A replay re-runs `llms()` on the same rule and right after the sitemap, from `_llmsRequest` — an entry's title, description or URL can all change with a model or controller edit, so a stale `llms.txt` is exactly as wrong as a stale `sitemap.xml` was.
74
76
  - A replay re-runs `sitemap()` when one was ever requested: `sitemap()` records `_sitemapRequest = { options, callback }` (idempotent — the replay's own call re-records the same request), and `_replay()` re-issues it right after `generate()` so `complete()` drains it and `sitemap.xml` matches the new stack. v1 (and v2 before this) left `sitemap.xml` stale after a slug change in watch mode. Like the `generate` callback, the `sitemap` callback re-fires on every watch rebuild — once per replay, with that rebuild's `urls`, not just on the original call.
75
77
  - `_replaying` is read _synchronously_ at the top of `page()` — both to skip re-logging the registration and to capture `fresh` for the async chain that runs later. That is why `_replay()` can reset the flag in a `finally` immediately after the registration loop, even though the chains it started are still pending.
76
78
  - `_promises` and `_generating` are push-only _within a build_ — never spliced, only reassigned wholesale by `_replay()`. So repeated `complete()` calls inside one build re-await every promise ever queued (all already settled, so cheap), and a long-running `watch()` session no longer grows them without bound: each rebuild starts from empty arrays.
@@ -81,7 +83,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
81
83
  - **The `slug` and `path` fallbacks in `page()` both guard on `.endsWith('.hbs')`.** Only a `.hbs` view is a filename: its folder is the page's path and its basename the slug. An inline template string gets a random `snippet-N` slug (with a logged error) and an _empty_ path — deriving one from the view would cut at the last `/` in the template's own markup, so `'<p>x</p>'` built into `public/-p-x-/…` and any closing tag became an output folder, with the sitemap inheriting the wrong `loc` (review finding C5). The `path` guard was missing while the `slug` one ten lines above was not.
82
84
  - `_prepareMultiplePages` builds a **fresh options object per fanned-out page** (`{ ...options, slug, model }`), and must keep doing so. It previously mutated and reused one object across the loop, which `_preparePage` then stored on each `KissPage` by reference (`kissPage.options = options`) — so every page in the fan-out aliased the same options. The visible symptom was `applyController`'s "title from `model.title`, unless already set" mapping: item one filled the title and the guard skipped the rest, so all pages rendered the first item's title. `{{model.x}}` stayed correct throughout, which is why the characterization tests missed it — they only ever asserted on `model`, never on an option derived from it.
83
85
  - **A bad item in a `.pages()` fan-out fails that item only** (`_prepareMultiplePages`): the per-item `applyController` + `_preparePage` step runs inside its own `try`, so a controller that throws for one item is recorded as `{ view: '<view> [item N: <slug>]', buildTo: null, error }` and the loop moves on to the next item. It used to let the throw unwind the whole loop into `page()`'s `try`, which recorded one `{ view, buildTo: null }` entry and abandoned every later item — unregistered, unrendered and unreported, so one failure entry stood for the entire tail of the fan-out (review finding F-E1; diploma-msc fans 64 catalogue pages from one call). N bad items now report N failures and the good items still build. The label is the only handle the operator has on _which_ record was malformed: there is no `buildTo` yet, and every item in a fan-out shares one `view`, so the position is always included and the item's own `slug` too when the model carries one. Because nothing escapes the loop, `page()`'s catch never records a second entry for the same item — that double-record is what a `throw` after the per-item catch would reintroduce.
84
- - `viewStats()`'s `debug.json` serialises a _projection_ of `_stack` (`{ view, buildTo, runCount, options }`), never the entries themselves: a stack entry's `page` holds the Handlebars environment (internally cyclic) and the logger, so `JSON.stringify` on the raw stack throws "Converting circular structure to JSON" and the file silently never gets written. The write is not awaited — `test/integration/view-stats.test.js` polls for the file with `waitFor`.
86
+ - `viewStats()`'s `debug.json` serialises a _projection_ of `_stack` (`{ view, buildTo, runCount, options }`), never the entries themselves: a stack entry's `page` holds the Handlebars environment (internally cyclic) and the logger, so `JSON.stringify` on the raw stack throws "Converting circular structure to JSON" and the file silently never gets written. The write is not awaited — `test/integration/view-stats.test.js` polls for the file with `waitFor`, on it _parsing_ rather than on it existing: `fs.outputJson` creates the file truncated and fills it a tick later, so a `pathExists` poll wins the race often enough to fail the suite under load and reads back an empty string.
85
87
  - Watcher rebuilds are fire-and-forget from the watcher's point of view — nothing awaits what `_handleChange` returns — but they are still _tracked_: `_rebuild(entries)` pushes each `page.generate().catch(() => {})` onto `_generating` (caught there because `KissPage.generate()` now rethrows, and it has already logged; a scoped rebuild must not turn into an unhandled rejection or a `complete()` failure), so a replay's `allSettled` waits for it and `complete()` drains it. `_generating` accumulates one entry per re-rendered page until the next replay resets the array.
86
88
  - **All rebuild work goes through one serial queue** (`_runRebuildQueue()`): one in-flight slot and one pending slot, where the pending slot is a set of scoped targets (stack entries to re-render) plus a "replay" bit. `_requestReplay()` sets the bit and clears the set — a replay rebuilds everything, so it supersedes every scoped rebuild — and returns the in-flight promise, so N rapid watcher events (save-all, a formatter, a branch switch) produce at most one in-flight run plus one follow-up and the last replay reads the newest files. `_requestRebuild(entries)` adds to the set, or is a no-op while the bit is set. Two rebuilds must never overlap, for two distinct reasons: `_replay()` resets `_stack` synchronously, so two replays interleaved would let the older one's still-pending chains fill the new stack and the newer pages — the ones carrying the newest edit — would be rejected by `_preparePage`'s `buildTo` dedupe with a `Page already processed` error, leaving stale output; and scoped work in flight when a replay starts re-renders from a stack the replay is about to discard. Two scoped rebuilds racing each other are the same hazard in miniature, which is why the queue covers both kinds rather than only replays. The follow-up run is _not_ chained into the returned promise (the `.finally()` callback returns `undefined`), so awaiting `_requestReplay()` waits only for the run that was in flight. Errors from either kind of run are caught and logged as `Error rebuilding site`.
87
89
  - **Live reload is issued from here, once per settled build**, because nothing watches the build folder any more (review finding F-E4 — see `AIKB/dev-server.md` for what was removed and why). `_reload(changed = '/')` is the single call site of the dev server's `refresh`; it is a no-op when there is no dev server, and it swallows anything the call throws, because a live-reload socket that has gone away must never fail the rebuild that triggered it. There are exactly three places it fires:
@@ -117,7 +119,7 @@ Nothing in `lib/` — it is the entry point (`package.json`'s `main`). `bin/kiss
117
119
 
118
120
  `kiss-ssg` ships TypeScript declarations without being TypeScript. The public API's JSDoc lives beside the runtime it describes — the page/build typedefs at the top of this module, the config ones in `lib/config.js` — and `npm run types` generates `types/*.d.ts` from it. `package.json` points `types` and its `exports` map at `types/kiss.d.ts`, and `files` ships the folder, so a consumer gets full completion from plain JavaScript with `// @ts-check` and no TypeScript of their own.
119
121
 
120
- - **The typedefs declared here**: `PageOptionsKnown` (the documented page options) and `PageOptions` (those plus arbitrary extra keys, which render as top-level context); `PagesOptions`; `PageOptionsPatch` and the `KissController` callback that returns one; `BuildDatum`/`BuildData` (what `generate()`/`complete()` resolve with), `BuildFailure`, and `BuildError` — the `AggregateError & { failures }` `complete()` rejects with; `SitemapUrl`, `SitemapOptions`, `WatchOptions`. `KissConfig`, `KissConfigInput` and `KissFolders` are re-exported from `lib/config.js`, and `BuildReport`, `BuildPage`, `BuildAsset` and `BuildReportFailure` from `lib/build-report.js`, both with `@typedef {import('./x.js').X} X` — so a consumer reaches every one of them as `import('kiss-ssg').X` from a single entry.
122
+ - **The typedefs declared here**: `PageOptionsKnown` (the documented page options) and `PageOptions` (those plus arbitrary extra keys, which render as top-level context); `PagesOptions`; `PageOptionsPatch` and the `KissController` callback that returns one; `BuildDatum`/`BuildData` (what `generate()`/`complete()` resolve with), `BuildFailure`, and `BuildError` — the `AggregateError & { failures }` `complete()` rejects with; `SitemapUrl`, `SitemapOptions`, `LlmsOptions`, `WatchOptions`. `KissConfig`, `KissConfigInput` and `KissFolders` are re-exported from `lib/config.js`, and `BuildReport`, `BuildPage`, `BuildAsset` and `BuildReportFailure` from `lib/build-report.js`, both with `@typedef {import('./x.js').X} X` — so a consumer reaches every one of them as `import('kiss-ssg').X` from a single entry.
121
123
  - **Every `_`-prefixed field and method is marked `@private`**, so the emitted class declaration carries them as `private _stack;` rather than as documented API. The public surface of `types/kiss.d.ts` is exactly the API llms.txt describes.
122
124
  - **`npm run types` is `node scripts/emit-types.mjs`, not a bare `tsc`, because of one TypeScript bug.** The declaration emitter drops the quotes from an arbitrary module namespace name when it reads it out of a `.js` file (from a `.ts` source they survive), so `export { Kiss as 'module.exports' }` — the line that makes `require('kiss-ssg')` return the class on Node ≥22.12 — comes out as `export { Kiss as module.exports }`, which is a **syntax error**: the whole declaration fails to parse, and every consumer silently falls back to `any`. The script runs `tsc -p tsconfig.types.json` and then puts the quotes back (`quoteStringExportNames`). Re-check this whenever TypeScript is upgraded; the fixup is a regex over the emitted text and `test/unit/types.test.js` asserts the line survives.
123
125
  - **`types/` is checked in and must not be hand-edited.** `test/unit/types.test.js` re-emits into a temp folder and byte-compares every file, so a JSDoc edit without a regeneration fails the suite naming the file and the command. `types/` is in `.prettierignore` — tsc owns its formatting, and the comparison is against exactly what tsc wrote.
package/AIKB/llms.md ADDED
@@ -0,0 +1,32 @@
1
+ # llms.js
2
+
3
+ ## Responsibility
4
+
5
+ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the page stack and writes it to the build folder. The AI-facing sibling of `sitemap.js`: same registry, same titles, same URL derivation, a different file for a different reader.
6
+
7
+ ## Public interface
8
+
9
+ - `resolveText(value)` → `Promise<string>`. A `summary`/`notes` option is either the text itself or a path (relative to `process.cwd()`) to a file holding it: if the string names an existing **file** its contents are returned, otherwise the string is. A `.md` file is used as-is — `llms.txt` is markdown. Only a single-line string of at most 512 characters is offered to `fs` at all; anything longer or multi-line is prose by construction. A non-string is `''`.
10
+ - `sectionNameFor(segment, sections = {})` → the display name for one top-level path segment: `sections[segment]` if mapped, else the segment title-cased with `-`/`_` read as spaces. The empty segment is the root group: `sections.root`, else `'Pages'`.
11
+ - `buildLlmsEntries(stack, { siteUrl, buildDir, sections })` → `{ title, url, description, section }[]` in registration order. Skips an entry whose `page.options` has `ignoreLlms`, `ignoreSitemap`, or `generate === false`. `url` is `toAbsoluteUrl(siteUrl, toCanonicalPath(buildTo.slice(buildDir.length)))` — the identical join `buildSitemapEntries` makes. `section` is `options.llmsSection` when set, else `sectionNameFor` over the first segment of the page's (sanitised) `path`.
12
+ - `groupLlmsEntries(entries, rootSection)` → `{ name, entries }[]`, sections in first-seen order with the root section hoisted to the front when it is present.
13
+ - `renderLlmsTxt({ title, summary, groups, notes })` → the file's text: `# title`, a `> ` blockquote summary, one `## Section` per group with a `- [title](url): description` line per page (`: description` omitted when there is none), an optional trailing `## Notes`. One trailing newline, a blank line under every heading.
14
+ - `async writeLlms(stack, { config, logger, options, overwrite = true })` → `Promise<{ status, text }>`. `status` is `'no-site-url'`, `'no-title'`, `'no-summary'` (nothing written), `'skipped'` (`overwrite: false` and a file exists), or `'written'`.
15
+
16
+ ## Depends on
17
+
18
+ `fs-extra`; `./utils.js` (`sanitizePath`, `toAbsoluteUrl`, `toCanonicalPath`, `toTitleCase`).
19
+
20
+ ## Depended on by
21
+
22
+ `lib/kiss.js` (`Kiss.llms()`).
23
+
24
+ ## Non-obvious behavior
25
+
26
+ - **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
27
+ - **`ignoreSitemap` excludes a page from `llms.txt` too.** The AI index is a curated subset of the site the sitemap describes, never a superset: a page the author has already said should not be crawled has no business being recommended to an answer engine. `ignoreLlms` is the one-way opt-out — out of `llms.txt`, still in the sitemap.
28
+ - **An untitled page arrives here titled `Index`.** `KissPage`'s constructor computes its default title from the _placeholder_ slug (`index`) before the real slug is set, so `prepare()` fills every untitled page's `title` with `'Index'`. That value is treated as unset and the page's own slug is title-cased instead (`puppy-classes` → `Puppy Classes`). It costs nothing for a page that really is the index — its slug title-cases to the same word — and it costs a page genuinely titled "Index" its title, which is the cheaper of the two mistakes.
29
+ - Titles and descriptions are collapsed to one line (`\s+` → one space) before rendering: a description is what follows `: ` on the entry's own line, and a newline in it would silently end the list item. `[`/`]`/`\` in a title are backslash-escaped and `(`/`)` in a URL are percent-encoded, so a bracketed title or a parenthesised path stays a valid markdown link.
30
+ - `writeLlms` deliberately does **not** catch its own write error (`fs.outputFile` can reject) — it propagates to `Kiss.llms()`'s `.catch()`, which logs it and does not invoke the callback. Same contract as `writeSitemap`.
31
+ - Missing `siteUrl`, `title` or `summary` are reported as a status, never thrown: the rest of the build is still what the author asked for, minus this file. `Kiss.llms()` fires the callback for `'written'` and `'skipped'` only.
32
+ - `overwrite: false` only has an observable effect with `cleanBuild: false` too — the default `cleanBuild: true` empties the build folder in the `Kiss` constructor, so there is never an existing `llms.txt` left to skip.
package/AIKB/sitemap.md CHANGED
@@ -16,7 +16,7 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
16
16
 
17
17
  ## Depended on by
18
18
 
19
- `lib/kiss.js` (`Kiss.sitemap()`).
19
+ `lib/kiss.js` (`Kiss.sitemap()`). `lib/llms.js` is its sibling rather than a consumer: it copies the same `buildTo` → `toCanonicalPath` → `toAbsoluteUrl` derivation, and honours `ignoreSitemap` as an exclusion of its own — a page kept out of the sitemap is kept out of `llms.txt` too.
20
20
 
21
21
  ## Non-obvious behavior
22
22
 
package/README.md CHANGED
@@ -38,14 +38,14 @@ That file is the API contract: the pipeline, every method and option, the helper
38
38
 
39
39
  Give the agent a verdict it can act on: `npx kiss-ssg check site.js` runs your build script as a dry run and prints one JSON report per site built, exit 1 on any failure, without touching the published output (see [Checking a build](#checking-a-build)).
40
40
 
41
- If the agent is Claude Code, this repository is also a plugin marketplace:
41
+ If the agent is Claude Code, this repository is also a plugin marketplace. In Claude Code, run:
42
42
 
43
43
  ```
44
44
  /plugin marketplace add cprobert/kiss-ssg
45
45
  /plugin install kiss-ssg@kiss-ssg
46
46
  ```
47
47
 
48
- That installs three skills — `/kiss-ssg:new-site` (build a site from a description), `/kiss-ssg:migrate-v1` (move a v1 project to v2) and `/kiss-ssg:check` (verify a build and read its report). They carry no copy of the API: each points at the docs installed in `node_modules/kiss-ssg/`, so the guidance cannot drift from the engine you have. The plugin source is [`plugins/kiss-ssg/`](plugins/kiss-ssg/).
48
+ The first line registers this repository as a marketplace; the second installs the plugin from it. That installs four skills, all named `kiss-<something>` so they're easy to spot alongside skills from other plugins — `/kiss-ssg:kiss-new-site` (build a site from a description, or a whole new section on one), `/kiss-ssg:kiss-add-page` (add or update a single page on a site that's already set up), `/kiss-ssg:kiss-migrate-v1` (move a v1 project to v2) and `/kiss-ssg:kiss-check` (verify a build and read its report). They carry no copy of the API: each points at the docs installed in `node_modules/kiss-ssg/`, so the guidance cannot drift from the engine you have. You don't have to invoke them by name — each skill's description is written for automatic discovery, so a request like "add a page to this site" or "why is my kiss-ssg build failing" reaches for the matching skill on its own. The plugin source is [`plugins/kiss-ssg/`](plugins/kiss-ssg/).
49
49
 
50
50
  ## Usage
51
51
 
@@ -426,6 +426,51 @@ kiss.sitemap({ overwrite: false })
426
426
 
427
427
  **Note**: `overwrite: false` only has an effect if you also set `cleanBuild: false` on the Kiss config. With the default `cleanBuild: true`, the whole build folder — including any previous `sitemap.xml` — is emptied before generation starts, so there's never an existing file left for `.sitemap()` to find.
428
428
 
429
+ ### .llms()
430
+
431
+ Writes an [`llms.txt`](https://llmstxt.org) into the root of the build folder: the curated, AI-facing index of your site, the file an answer engine reads before it crawls. It is `.sitemap()`'s sibling — same registry, same titles, same URLs, a different reader — so the two can never drift apart. It needs `siteUrl`, a `title` and a `summary`; without any of them it logs an error and skips the file rather than throwing.
432
+
433
+ ```js
434
+ kiss
435
+ .page({
436
+ view: 'index.hbs',
437
+ title: 'A1K9 Training',
438
+ description: 'Dog training and behaviour work in South Wales',
439
+ })
440
+ .pages({ view: 'courses/course.hbs', model: 'courses', path: 'courses' })
441
+ .page({ view: 'rota.hbs', ignoreLlms: true }) // in the sitemap, out of the index
442
+ .generate()
443
+ .sitemap()
444
+ .llms({
445
+ title: 'A1K9 Training',
446
+ summary: 'Dog behaviour and obedience training in South Wales.',
447
+ sections: { root: 'Pages', courses: 'Courses' },
448
+ notes: 'src/content/llms-notes.md',
449
+ })
450
+ ```
451
+
452
+ writes:
453
+
454
+ ```markdown
455
+ # A1K9 Training
456
+
457
+ > Dog behaviour and obedience training in South Wales.
458
+
459
+ ## Pages
460
+
461
+ - [A1K9 Training](https://a1k9training.co.uk/): Dog training and behaviour work in South Wales
462
+
463
+ ## Courses
464
+
465
+ - [Bronze obedience](https://a1k9training.co.uk/courses/bronze-obedience): Six weeks, group class
466
+ ```
467
+
468
+ **The options**: `title` is the `# ` heading and `summary` the `> ` blockquote — and `summary` (like the optional `notes`, which becomes a trailing `## Notes` section) is either the text itself or a path, relative to your working directory, to a `.md`/`.txt` file holding it, so a long summary can live beside the rest of your content. `sections` maps a top-level path segment to a heading (`{ courses: 'Courses' }`); the key `root` names the section holding pages with no path (default `Pages`), and any segment you do not map is title-cased (`behavioural-consultations` → `Behavioural Consultations`). `overwrite` (default `true`) behaves exactly as the sitemap's.
469
+
470
+ **Which pages are listed**: every registered page, grouped by the first segment of its `path` with the root group first, in registration order. A page opts out with `ignoreLlms: true`, and a page already out of the sitemap (`ignoreSitemap: true`) or not being built at all (`generate: false`) is out of `llms.txt` too — the index is a subset of the site the sitemap describes, never a superset. `llmsSection: 'Name'` puts one page under a heading of your choosing regardless of its path. Each entry is `- [title](url): description`, with the description omitted when the page has none and the title falling back to the page's slug, title-cased; each URL is the same string that page's own `{{canonical}}` renders.
471
+
472
+ It is chainable, can be called before or after `.generate()`, and is re-run by a whole-site watch rebuild like `.sitemap()`. Its callback receives the rendered text (`kiss.llms(options, (text) => …)`), and the file it wrote is reported as the build report's `llms`.
473
+
429
474
  ### Waiting for the build
430
475
 
431
476
  `.generate()` is chainable and returns immediately; its callback fires once every page has been attempted — including any that failed to render or write. Failures don't surface through this callback; they surface via `.complete()` (below). The callback's `data` argument (and `.complete()`'s resolved value) is `[{ id, data }]`, **one entry per queued promise in registration order** — the assets copy that runs automatically at construction is queued before any page you register, so `data[0]` is that copy's result, not your first page. Use `.getModelByID(id, data)` (see "Other methods" below) to pull out a specific page's model rather than indexing by position. To wait for the whole build (including a `.sitemap()` call and anything queued from a callback):
@@ -483,7 +528,9 @@ npx kiss-ssg check menu.js 2026-spring # arguments after the script go to the sc
483
528
  }
484
529
  ],
485
530
  "assets": [{ "source": "css/site.css", "target": "css/site.605b52d7.css" }],
486
- "sitemap": "./public/sitemap.xml"
531
+ "sitemap": "./public/sitemap.xml",
532
+ "pipeline": [{ "name": "tailwind", "ok": true, "duration": 420 }],
533
+ "llms": "./public/llms.txt"
487
534
  }
488
535
  ]
489
536
  ```
@@ -499,7 +546,7 @@ Two things a check cannot make true. A site that reads its own build folder back
499
546
  - `.registerPartials()` — re-registers every partial and layout from disk, unregistering any whose file has gone, and returns the registered names. Kiss runs it for you at start-up and on every watch rebuild; call it yourself if you add or remove partial files at runtime without `.watch()`.
500
547
  - `.viewStats()` — logs how many pages are queued and prepared, and with `verbose: true` writes a `debug.json` into the build folder listing every page as `{ view, buildTo, runCount, options }`. Chainable; handy from a `.generate()` callback to see what the build actually produced.
501
548
  - `.getModelByID(id, data)` — pulls one entry out of the `[{ id, data }]` array `.generate()`/`.complete()` hand back, returning its `data` (or `{ error }` if no entry has that id). The id is the model's filename or URL.
502
- - `.report()` — the last settled build as data, or `null` before the first `.complete()` has settled: `{ ok, mode, buildDir, duration, pages, failures, assets, sitemap }`, every value JSON-safe. `pages` is `{ view, buildTo, ok }` per queued page and `failures` is `{ view, buildTo, message }` — the same list as `err.failures`, with each `Error` reduced to its message. The same object is on the rejection as `err.report`, so a failed build can be read as data rather than parsed out of a log. See "Checking a build" above.
549
+ - `.report()` — the last settled build as data, or `null` before the first `.complete()` has settled: `{ ok, mode, buildDir, duration, pages, failures, assets, sitemap, pipeline, llms }`, every value JSON-safe. `pages` is `{ view, buildTo, ok }` per queued page and `failures` is `{ view, buildTo, message }` — the same list as `err.failures`, with each `Error` reduced to its message. The same object is on the rejection as `err.report`, so a failed build can be read as data rather than parsed out of a log. See "Checking a build" above.
503
550
 
504
551
  ```js
505
552
  kiss.scan().generate(function (data) {
@@ -1,4 +1,4 @@
1
- /* generated from styles/tokens.json at 2026-09-09T10:08:48.985Z — do not edit */
1
+ /* generated from styles/tokens.json at 2026-09-10T12:32:52.196Z — do not edit */
2
2
  :root {
3
3
  --accent: #0f766e;
4
4
  --accent-ink: #ffffff;
@@ -1,10 +1,14 @@
1
- # 6 · sitemap
1
+ # 6 · sitemap and llms.txt
2
2
 
3
3
  `.sitemap()` writes `sitemap.xml` from the pages already registered, using `config.siteUrl` —
4
4
  without which it logs an error and skips the file rather than guessing. Per-page
5
5
  `ignoreSitemap`, `sitemapPriority` and `sitemapChangefreq` tune the entries, and this is the one
6
6
  example that turns on `assets: { hash: true }` and `extensionLess: true`.
7
7
 
8
+ `.llms()` writes `llms.txt` beside it from the same registry — an llmstxt.org index with a
9
+ title, a `>` summary, one `##` section per top-level path and a `- [title](url): description`
10
+ line per page. `rota` is out of both files from the one `ignoreSitemap` on its page.
11
+
8
12
  ## Run it
9
13
 
10
14
  ```bash
@@ -18,3 +22,5 @@ node 6-sitemap.js --dev # live preview on http://127.0.0.1:3001
18
22
  The config block in `6-sitemap.js`: `siteUrl`, `extensionLess` and `assets: { hash: true }` are
19
23
  three decisions every published site has to make, and the layout's `{{asset 'css/site.css'}}`
20
24
  is unchanged by any of them — the template asks for a file, the config decides how it is cached.
25
+ And the `.sitemap().llms()` pair at the end of the chain: two files, one registry, so a crawler
26
+ and an answer engine are told the same thing.
@@ -50,6 +50,18 @@ const kiss = new Kiss({
50
50
  console.log('sitemap.xml lists:', urls)
51
51
  })
52
52
 
53
+ // The sitemap's sibling: llms.txt, the llmstxt.org index an answer engine
54
+ // reads first. Same registry, same URLs — `rota` is out of both, because a
55
+ // page kept out of the sitemap is kept out of this too. The summary is
56
+ // inline here; a path to a `.md` file would be read instead.
57
+ .llms({
58
+ title: 'Aster & Oak',
59
+ summary:
60
+ 'A small-batch coffee roastery in Bristol. Beans, brewing notes and the shops that stock us.',
61
+ sections: { root: 'Pages' },
62
+ notes: 'Prices and opening hours change seasonally.',
63
+ })
64
+
53
65
  if (!dev) {
54
66
  await kiss.complete().catch(reportBuildFailure)
55
67
  }
@@ -9,7 +9,7 @@ They come in two tiers.
9
9
 
10
10
  **Examples 1–6 and 10 are the feature reference: one idea each.** Scanning a folder of views,
11
11
  registering a page by hand, fanning out over an array, layouts and partials, the built-in
12
- helpers, the sitemap, an external tool in the asset pipeline. Each script is short enough to read in one sitting and each page explains,
12
+ helpers, the sitemap and llms.txt, an external tool in the asset pipeline. Each script is short enough to read in one sitting and each page explains,
13
13
  in a "How this example works" panel, the feature it is demonstrating — with the script that built
14
14
  it printed underneath. Read one when you want to know how a single thing works.
15
15
 
@@ -27,7 +27,7 @@ the feature reference.
27
27
  | [3 · pages](3-pages/) | Reference | `.pages()` fans out one page per JSON file; the index reuses the same resolved array | `npm run eg3` · `--dev` |
28
28
  | [4 · layouts and partials](4-layouts-and-partials/) | Reference | One layout, blocks and content, nested `.hbs`/`.html`/`.md` partials, a dynamic partial | `npm run eg4` · `--dev` |
29
29
  | [5 · helpers](5-helpers/) | Reference | The six built-in helpers, all on one page, plus custom dev ports | `npm run eg5` · `--dev` |
30
- | [6 · sitemap](6-sitemap/) | Reference | `.sitemap()` with `siteUrl`, per-page tuning, `extensionLess`, hashed assets | `npm run eg6` · `--dev` |
30
+ | [6 · sitemap and llms.txt](6-sitemap/) | Reference | `.sitemap()` and `.llms()` with `siteUrl`, per-page tuning, `extensionLess`, hashed assets | `npm run eg6` · `--dev` |
31
31
  | [7 · versioned outputs](7-versioned-outputs/) | Exemplar | One build per season into its own folder, atomically, with an archive index beside them | `npm run eg7` · `node 7-versioned-outputs.js <season>` |
32
32
  | [8 · data-fed site](8-data-fed-site/) | Exemplar | A site built from a folder of records, validated in the controller — one record is broken | `npm run eg8` · exits 1 · `--dev` |
33
33
  | [9 · migrated from v1](9-migrated-from-v1/) | Exemplar | Every v1 → v2 migration recipe as running code, in a site that builds clean | `npm run eg9` · `--dev` |
@@ -55,6 +55,7 @@
55
55
  * @property {BuildAsset[]} assets
56
56
  * @property {string|null} sitemap the `sitemap.xml` written, or `null` if none was
57
57
  * @property {BuildPipelineStep[]} pipeline every `config.assets.pipeline` step, in order; empty when there are none
58
+ * @property {string|null} llms the `llms.txt` written, or `null` if none was
58
59
  */
59
60
 
60
61
  // A staged build writes into `<build>.kiss-staging-<pid>-<random>`, which is an
@@ -100,6 +101,7 @@ function reportedView(view) {
100
101
  * @param {number} [input.startedAt] `Date.now()` at construction
101
102
  * @param {string|null} [input.sitemap] the sitemap written by this build
102
103
  * @param {import('./pipeline.js').PipelineResult[]} [input.pipeline] what the asset pipeline's steps did
104
+ * @param {string|null} [input.llms] the llms.txt written by this build
103
105
  * @returns {BuildReport}
104
106
  */
105
107
  export function buildReport({
@@ -112,6 +114,7 @@ export function buildReport({
112
114
  startedAt = Date.now(),
113
115
  sitemap = null,
114
116
  pipeline = [],
117
+ llms = null,
115
118
  }) {
116
119
  const real = (target) => reportedPath(target, buildDir, stagingDir)
117
120
  const failedPaths = new Set(
@@ -153,6 +156,10 @@ export function buildReport({
153
156
  ok,
154
157
  duration,
155
158
  })),
159
+ // Appended for the same reason `pipeline` was: a consumer diffing two
160
+ // reports should see one new key, not a reshuffle of the nine already
161
+ // there.
162
+ llms: real(llms),
156
163
  }
157
164
  }
158
165
 
package/lib/kiss.js CHANGED
@@ -16,6 +16,7 @@ import { buildReport } from './build-report.js'
16
16
  import { resolveModel } from './model-resolver.js'
17
17
  import { applyController } from './controller-resolver.js'
18
18
  import { writeSitemap } from './sitemap.js'
19
+ import { writeLlms } from './llms.js'
19
20
  import { KissPage, preloadMinifier } from './kiss-page.js'
20
21
  import { startDevServer } from './dev-server.js'
21
22
  import { createWatcher, isInside } from './watcher.js'
@@ -50,6 +51,8 @@ import { createWatcher, isInside } from './watcher.js'
50
51
  * @property {string} [sitemapPriority] default `'1.00'`
51
52
  * @property {string} [sitemapChangefreq] omitted from the XML unless set
52
53
  * @property {string} [sitemapLastmod] default: one timestamp shared by every page
54
+ * @property {boolean} [ignoreLlms] keep this page out of `llms.txt`
55
+ * @property {string} [llmsSection] the `llms.txt` section this page is listed under, overriding its path
53
56
  */
54
57
 
55
58
  /**
@@ -134,6 +137,19 @@ import { createWatcher, isInside } from './watcher.js'
134
137
  * @property {boolean} [overwrite] default `true`; `false` leaves an existing `sitemap.xml` alone
135
138
  */
136
139
 
140
+ /**
141
+ * The options `.llms()` takes. `title` and `summary` are required — without
142
+ * either, kiss logs an error and writes nothing, exactly as it does for a
143
+ * sitemap with no `siteUrl`.
144
+ *
145
+ * @typedef {Object} LlmsOptions
146
+ * @property {string} title the site's name — the file's `# ` heading
147
+ * @property {string} summary what the site is, as a `> ` blockquote: the text itself, or a path (relative to `process.cwd()`) to a `.md`/`.txt` file holding it
148
+ * @property {string} [notes] a trailing `## Notes` section; same text-or-file rule as `summary`
149
+ * @property {Record<string, string>} [sections] top-level path segment → section heading (`{ courses: 'Courses' }`); the key `root` names the section holding pages with no path (default `Pages`). An unmapped segment is title-cased.
150
+ * @property {boolean} [overwrite] default `true`; `false` leaves an existing `llms.txt` alone
151
+ */
152
+
137
153
  /**
138
154
  * @typedef {Object} WatchOptions
139
155
  * @property {string} [entry] the script whose own change triggers a whole-site rebuild; defaults to `process.argv[1]`
@@ -221,6 +237,8 @@ class Kiss {
221
237
  /** @private */
222
238
  _sitemapRequest = null
223
239
  /** @private */
240
+ _llmsRequest = null
241
+ /** @private */
224
242
  _watcher = null
225
243
  /** @private */
226
244
  _devServer = null
@@ -262,6 +280,9 @@ class Kiss {
262
280
  // Where this build wrote sitemap.xml, if it wrote one.
263
281
  /** @private */
264
282
  _sitemapPath = null
283
+ // Where this build wrote llms.txt, if it wrote one.
284
+ /** @private */
285
+ _llmsPath = null
265
286
  // Whether KISS_CHECK asked for a dry run: build into staging, report, and
266
287
  // discard — the real build folder is neither emptied nor written.
267
288
  /** @private */
@@ -665,6 +686,7 @@ class Kiss {
665
686
  startedAt: this._startedAt,
666
687
  sitemap: this._sitemapPath,
667
688
  pipeline: this._pipelineResults,
689
+ llms: this._llmsPath,
668
690
  })
669
691
  // The graph as data, for a human to look at: which pages each partial
670
692
  // reaches. Dev only — a one-shot build has no watcher to use it — and
@@ -1267,6 +1289,49 @@ class Kiss {
1267
1289
  return this
1268
1290
  }
1269
1291
 
1292
+ /**
1293
+ * Writes `llms.txt` into the build folder — the llmstxt.org index an answer
1294
+ * engine reads first — from the same registry, titles and URLs the sitemap is
1295
+ * built from, so the two can never disagree. Requires `config.siteUrl`, and
1296
+ * `options.title`/`options.summary`; without any of them this logs an error
1297
+ * and skips. Like `.sitemap()`, it can be called before or after
1298
+ * `.generate()`, and it is re-run by a whole-site watch rebuild.
1299
+ *
1300
+ * @param {LlmsOptions} options
1301
+ * @param {(text: string) => void|Promise<void>} [callback] receives the
1302
+ * rendered file; not fired when llms.txt was skipped for want of an option
1303
+ * @returns {this}
1304
+ */
1305
+ llms(options, callback) {
1306
+ // Remembered so a watch rebuild can re-run it against the new stack, the
1307
+ // same way `_sitemapRequest` is — the replay's own call re-records it.
1308
+ this._llmsRequest = { options, callback }
1309
+ const overwrite = !options || options.overwrite !== false
1310
+ const run = Promise.all(this._promises)
1311
+ .then(async () => {
1312
+ const { status, text } = await writeLlms(this._stack, {
1313
+ config: this.config,
1314
+ logger: this.logger,
1315
+ options: options || {},
1316
+ overwrite,
1317
+ })
1318
+ // A missing siteUrl, title or summary wrote nothing and there is
1319
+ // nothing to hand a callback; a skip left an existing file in place,
1320
+ // which is still the build's llms.txt.
1321
+ if (status !== 'written' && status !== 'skipped') return
1322
+ // A staged build records the staging path and the report maps it back
1323
+ // to the real folder, like every other path in it.
1324
+ this._llmsPath = `${this.config.folders.build}/llms.txt`
1325
+ if (callback) this._runCallback('llms', callback, text)
1326
+ })
1327
+ .catch((err) => {
1328
+ this.logger.error('Error creating llms.txt')
1329
+ this.logger.warn(err)
1330
+ })
1331
+ this._generating.push(run)
1332
+ return this
1333
+ }
1334
+
1270
1335
  /**
1271
1336
  * Pulls one resolved model out of the array `.generate()`/`.complete()` hand
1272
1337
  * back — the way to read a model without relying on its position.
@@ -1325,6 +1390,7 @@ class Kiss {
1325
1390
  // report the hours since the process started as its duration.
1326
1391
  this._report = null
1327
1392
  this._sitemapPath = null
1393
+ this._llmsPath = null
1328
1394
  this._startedAt = Date.now()
1329
1395
  this._callbacks = []
1330
1396
  this._assetQueue = Promise.resolve()
@@ -1389,6 +1455,9 @@ class Kiss {
1389
1455
  this._sitemapRequest.callback,
1390
1456
  )
1391
1457
  }
1458
+ if (this._llmsRequest) {
1459
+ this.llms(this._llmsRequest.options, this._llmsRequest.callback)
1460
+ }
1392
1461
  await this.complete()
1393
1462
  } finally {
1394
1463
  // A replay that threw before re-queuing has an empty stack because it
package/lib/llms.js ADDED
@@ -0,0 +1,249 @@
1
+ import fs from 'fs-extra'
2
+ import {
3
+ sanitizePath,
4
+ toAbsoluteUrl,
5
+ toCanonicalPath,
6
+ toTitleCase,
7
+ } from './utils.js'
8
+
9
+ /**
10
+ * One page as `llms.txt` lists it.
11
+ *
12
+ * @typedef {Object} LlmsEntry
13
+ * @property {string} title the page's `title`, or its slug title-cased
14
+ * @property {string} url the same absolute URL the sitemap and `{{canonical}}` emit
15
+ * @property {string} description the page's `description`, or `''` when it has none
16
+ * @property {string} section the display name of the group it belongs to
17
+ */
18
+
19
+ /**
20
+ * One `##` section of the file: a display name and the pages under it.
21
+ *
22
+ * @typedef {Object} LlmsGroup
23
+ * @property {string} name
24
+ * @property {LlmsEntry[]} entries
25
+ */
26
+
27
+ // The title `KissPage.prepare()` fills in for a page that was given none. It is
28
+ // derived from the *placeholder* slug (`index`) in the constructor, before the
29
+ // real slug is set, so every untitled page arrives here as "Index" — which is a
30
+ // useless entry in a curated index. Treated as unset, and the page's own slug
31
+ // title-cased instead. Harmless for a page that really is the index: its slug
32
+ // title-cases to the same word.
33
+ const UNTITLED = 'Index'
34
+
35
+ // A summary or notes option is either the text itself or a path to a file
36
+ // holding it. `fs` decides which — but only for a string that could plausibly be
37
+ // a path: a multi-line or very long one is prose, and asking the filesystem
38
+ // about it is a syscall that can only ever answer "no".
39
+ const MAX_PATH_LENGTH = 512
40
+
41
+ /**
42
+ * Resolves a `summary`/`notes` option: the contents of the file it names, or the
43
+ * string itself. A `.md` file is used as it is — `llms.txt` is markdown.
44
+ *
45
+ * @param {unknown} value
46
+ * @returns {Promise<string>} the resolved text, trailing whitespace trimmed
47
+ */
48
+ export async function resolveText(value) {
49
+ if (typeof value !== 'string') return ''
50
+ const looksLikePath =
51
+ value.length > 0 && value.length <= MAX_PATH_LENGTH && !value.includes('\n')
52
+ if (looksLikePath) {
53
+ // Relative to process.cwd(), like every other path a build script hands the
54
+ // engine from the outside.
55
+ const stat = await fs.stat(value).catch(() => null)
56
+ if (stat?.isFile()) return (await fs.readFile(value, 'utf8')).trimEnd()
57
+ }
58
+ return value.trimEnd()
59
+ }
60
+
61
+ /**
62
+ * The display name for one top-level path segment: an explicit mapping, else the
63
+ * segment title-cased. The root group (a page with no `path`) is keyed `root`.
64
+ *
65
+ * @param {string} segment the sanitised first segment of a page's `path`, `''` for a root page
66
+ * @param {Record<string, string>} [sections]
67
+ * @returns {string}
68
+ */
69
+ export function sectionNameFor(segment, sections = {}) {
70
+ if (sections[segment]) return sections[segment]
71
+ if (!segment) return sections.root || 'Pages'
72
+ return toTitleCase(segment.replace(/[-_]+/g, ' '))
73
+ }
74
+
75
+ // A title is link text and a description follows a colon on the same line, so
76
+ // neither may carry a newline into the file.
77
+ const oneLine = (value) =>
78
+ typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
79
+
80
+ // `[` and `]` would end the link text early; the URL's own parentheses would end
81
+ // the target early. Percent-encoding the URL's is safe — it is the same URL — so
82
+ // the entry stays a valid markdown link either way.
83
+ const escapeText = (text) => text.replace(/([[\]\\])/g, '\\$1')
84
+ const escapeUrl = (url) => url.replace(/\(/g, '%28').replace(/\)/g, '%29')
85
+
86
+ function entryTitle(options) {
87
+ const slugTitle = toTitleCase(String(options.slug ?? '').replace(/-/g, ' '))
88
+ const title = oneLine(options.title)
89
+ return title && title !== UNTITLED ? title : slugTitle
90
+ }
91
+
92
+ /**
93
+ * One entry per page that belongs in `llms.txt`, in registration order. A page
94
+ * is left out by `ignoreLlms`, by `ignoreSitemap` (the AI index is a curated
95
+ * subset of the same site the sitemap describes, never a superset), or by
96
+ * `generate: false` (there is nothing at that URL).
97
+ *
98
+ * @param {{ buildTo: string, page: { options: Record<string, any> } }[]} stack
99
+ * @param {Object} context
100
+ * @param {string} context.siteUrl
101
+ * @param {string} context.buildDir
102
+ * @param {Record<string, string>} [context.sections] top-level path segment → display name
103
+ * @returns {LlmsEntry[]}
104
+ */
105
+ export function buildLlmsEntries(stack, { siteUrl, buildDir, sections = {} }) {
106
+ return stack
107
+ .filter((entry) => {
108
+ const options = entry.page.options
109
+ return (
110
+ !options.ignoreLlms &&
111
+ !options.ignoreSitemap &&
112
+ options.generate !== false
113
+ )
114
+ })
115
+ .map((entry) => {
116
+ const options = entry.page.options
117
+ // Exactly the join `buildSitemapEntries` makes, and therefore exactly the
118
+ // one the `canonical` helper makes: an entry here can never name a URL
119
+ // the page itself does not claim.
120
+ const urlPath = toCanonicalPath(entry.buildTo.slice(buildDir.length))
121
+ const segment = String(sanitizePath(String(options.path ?? '')) || '')
122
+ .split('/')[0]
123
+ .trim()
124
+ return {
125
+ title: entryTitle(options),
126
+ url: toAbsoluteUrl(siteUrl, urlPath),
127
+ description: oneLine(options.description),
128
+ section:
129
+ oneLine(options.llmsSection) || sectionNameFor(segment, sections),
130
+ }
131
+ })
132
+ }
133
+
134
+ /**
135
+ * Groups entries by section, first-seen order, with the root section first
136
+ * wherever it appears — a reader meets the site's own top-level pages before its
137
+ * subsections.
138
+ *
139
+ * @param {LlmsEntry[]} entries
140
+ * @param {string} [rootSection] the display name the root group resolved to
141
+ * @returns {LlmsGroup[]}
142
+ */
143
+ export function groupLlmsEntries(entries, rootSection) {
144
+ /** @type {Map<string, LlmsEntry[]>} */
145
+ const groups = new Map()
146
+ for (const entry of entries) {
147
+ if (!groups.has(entry.section)) groups.set(entry.section, [])
148
+ groups.get(entry.section)?.push(entry)
149
+ }
150
+ const names = [...groups.keys()]
151
+ if (rootSection && groups.has(rootSection))
152
+ names.sort((a, b) => (a === rootSection ? -1 : b === rootSection ? 1 : 0))
153
+ return names.map((name) => ({
154
+ name,
155
+ entries: /** @type {LlmsEntry[]} */ (groups.get(name)),
156
+ }))
157
+ }
158
+
159
+ // A blockquote line with nothing to quote is `>` and not `> `: a trailing space
160
+ // is invisible in the file and noise in a diff.
161
+ const quote = (text) =>
162
+ text
163
+ .split('\n')
164
+ .map((line) => (line.trim() ? `> ${line}` : '>'))
165
+ .join('\n')
166
+
167
+ /**
168
+ * Renders the file: an H1 title, a blockquote summary, one `##` section per
169
+ * group and an optional trailing `## Notes` — the llmstxt.org shape.
170
+ *
171
+ * @param {Object} input
172
+ * @param {string} input.title
173
+ * @param {string} input.summary
174
+ * @param {LlmsGroup[]} [input.groups]
175
+ * @param {string} [input.notes]
176
+ * @returns {string} the file's text, one trailing newline
177
+ */
178
+ export function renderLlmsTxt({ title, summary, groups = [], notes = '' }) {
179
+ const blocks = [`# ${oneLine(title)}`, quote(summary)]
180
+ for (const group of groups) {
181
+ const lines = group.entries.map((entry) => {
182
+ const link = `- [${escapeText(entry.title)}](${escapeUrl(entry.url)})`
183
+ return entry.description ? `${link}: ${entry.description}` : link
184
+ })
185
+ // A blank line under every `##`, the same shape `## Notes` has: the file
186
+ // is read as markdown as often as it is parsed as a list.
187
+ blocks.push([`## ${group.name}`, lines.join('\n')].join('\n\n'))
188
+ }
189
+ if (notes) blocks.push(['## Notes', notes].join('\n\n'))
190
+ return `${blocks.join('\n\n')}\n`
191
+ }
192
+
193
+ /**
194
+ * @typedef {Object} LlmsWriteResult
195
+ * @property {'no-site-url'|'no-title'|'no-summary'|'skipped'|'written'} status
196
+ * @property {string|null} text the rendered file, or `null` when nothing was written
197
+ */
198
+
199
+ /**
200
+ * Writes `<build>/llms.txt`. Every reason not to write one is reported rather
201
+ * than thrown — a missing `siteUrl`, `title` or `summary` is a build the author
202
+ * still wants, minus this file.
203
+ *
204
+ * @param {{ buildTo: string, page: { options: Record<string, any> } }[]} stack
205
+ * @param {Object} deps
206
+ * @param {any} deps.config the resolved config — `siteUrl` and `folders.build`
207
+ * @param {any} deps.logger
208
+ * @param {Record<string, any>} [deps.options] the `.llms()` options
209
+ * @param {boolean} [deps.overwrite] default `true`
210
+ * @returns {Promise<LlmsWriteResult>}
211
+ */
212
+ export async function writeLlms(
213
+ stack,
214
+ { config, logger, options = {}, overwrite = true },
215
+ ) {
216
+ if (!config.siteUrl) {
217
+ logger.error('Cannot generate llms.txt: config.siteUrl is not set')
218
+ return { status: 'no-site-url', text: null }
219
+ }
220
+ if (!options.title || typeof options.title !== 'string') {
221
+ logger.error('Cannot generate llms.txt: options.title is not set')
222
+ return { status: 'no-title', text: null }
223
+ }
224
+ if (!options.summary || typeof options.summary !== 'string') {
225
+ logger.error('Cannot generate llms.txt: options.summary is not set')
226
+ return { status: 'no-summary', text: null }
227
+ }
228
+ const buildDir = config.folders.build
229
+ const llmsPath = `${buildDir}/llms.txt`
230
+ if (!overwrite && fs.existsSync(llmsPath)) {
231
+ logger.info('Skipping llms.txt: already exists')
232
+ return { status: 'skipped', text: null }
233
+ }
234
+ const sections = options.sections || {}
235
+ const entries = buildLlmsEntries(stack, {
236
+ siteUrl: config.siteUrl,
237
+ buildDir,
238
+ sections,
239
+ })
240
+ const text = renderLlmsTxt({
241
+ title: options.title,
242
+ summary: await resolveText(options.summary),
243
+ notes: await resolveText(options.notes),
244
+ groups: groupLlmsEntries(entries, sectionNameFor('', sections)),
245
+ })
246
+ await fs.outputFile(llmsPath, text)
247
+ logger.success(llmsPath)
248
+ return { status: 'written', text }
249
+ }
package/llms.txt CHANGED
@@ -11,8 +11,9 @@ Install: `npm install kiss-ssg --save-dev`. `import Kiss from 'kiss-ssg'` (CJS `
11
11
  - `.scan()` — auto-discovers every `.hbs` under `config.folders.pages` you have not already registered (by `view`, so a `.page()` earlier in the same chain wins and the view is not built twice), and calls `.page()` on each with default options. The simplest way to build a whole site: `kiss.scan().generate()`. Under `.watch()` the scan is repeated on every whole-site rebuild, so page files you add or delete while the dev server runs are picked up.
12
12
  - `.generate(callback)` — waits for every queued page's model to resolve, then renders and writes each page exactly once (in a normal build HTML is minified via `html-minifier-terser`, comments included; under `dev: true` minification is skipped altogether, so dev output keeps its comments and whitespace and a dev build never loads the minifier). `callback(data)` fires after every page has been attempted — including any that failed to render or write; page failures don't surface here, they surface via `complete()`. `data` is `[{ id, data }]`, one entry per queued promise **in registration order** — the automatic construction-time asset copy is queued first, so `data[0]` is that copy's result (`{ id, data: 'Copied assets: …' }`), not your first page. Use `getModelByID(id, data)` (below) rather than indexing by position. An error thrown by the callback itself (or a rejection from a promise it returns) is a build failure of its own, recorded as `<generate callback>` and reported by `complete()`. Chainable — returns the `Kiss` instance. To await the whole build use `await kiss.complete()`.
13
13
  - `.complete(callback)` — resolves after every queued page and any `sitemap()` call has finished writing; resolves with the same `data` array `generate` receives. "Every queued page" includes pages queued from a `generate` callback, whether the callback queues them synchronously or after an `await` (the promise an `async` callback returns is part of what `complete()` waits for), and it includes any page queued after the last `generate()` call: `complete()` renders whatever no `generate()` pass reached, so a page whose model resolved late — or one a callback's `scan()` discovered — is never silently dropped from a build that reports success. It renders nothing if you never called `generate()` at all. Work a _synchronous_ callback defers to a later tick (a `setTimeout`-ed `.page()`) is still outside the build — queue pages synchronously, or from an `async` callback. Rejects with an `AggregateError` — `err.failures` = `[{ view, buildTo, error }]`, message `"N page(s) failed to build: <buildTo paths>"` (build paths, not views, since `.pages()` fan-out gives failed entries the same view; entries with no output path, such as a failing controller or callback, are named by `view` instead) — if any page failed to render or write, any controller failed, or a `generate`/`sitemap` callback threw; other pages still build, and the `generate` callback still fires. In `dev: true` a dev server that cannot bind `config.port` is a build failure too, reported as `<dev server>`. It rejects **once per build**: a second `complete()` in the same build resolves instead of rejecting again, so the pattern of calling `complete()` from inside a `generate` callback cannot leave a second, unhandled rejection behind. A failed build never runs `complete()`'s own callback. A model failure is not a build failure: it is logged and skipped, and shows up in the resolved data as `{ id, data: null, error }`. A `.hbs` view whose file cannot be read (missing or misspelled) **is** a build failure: that page fails and `complete()` rejects, rather than the filename being written into the output.
14
- - `.report()` — the last settled build as data, or `null` before the first `.complete()` has settled (a watch rebuild replaces it with its own). `{ ok, mode, buildDir, duration, pages, failures, assets, sitemap }`: `ok` is "nothing failed"; `mode` is `'build'`, or `'check'` when the build was staged and thrown away (see `## Checking a build`); `buildDir` is the folder you asked for, never the staging sibling a `'atomic'` build writes to; `duration` is ms from `new Kiss()`; `pages` is `{ view, buildTo, ok }` per queued page, in registration order (an inline template's `view` is elided to its first line); `failures` is `{ view, buildTo, message }` — the same list `err.failures` carries, with the `Error` reduced to its message so the whole thing survives `JSON.stringify`; `assets` is `{ source, target }` per emitted file (the path a template asks for, and the file that is actually there under `config.assets`); `sitemap` is the `sitemap.xml` this build wrote, or `null`; `pipeline` is `{ name, ok, duration }` per `config.assets.pipeline` step, in order, and `[]` when there are none. The same object is on the rejection as `err.report`, so a failed build is readable as data without catching and re-deriving anything. It is additive: `complete()` still resolves with the `data` array and still rejects with the `AggregateError`.
14
+ - `.report()` — the last settled build as data, or `null` before the first `.complete()` has settled (a watch rebuild replaces it with its own). `{ ok, mode, buildDir, duration, pages, failures, assets, sitemap, pipeline, llms }`: `ok` is "nothing failed"; `mode` is `'build'`, or `'check'` when the build was staged and thrown away (see `## Checking a build`); `buildDir` is the folder you asked for, never the staging sibling a `'atomic'` build writes to; `duration` is ms from `new Kiss()`; `pages` is `{ view, buildTo, ok }` per queued page, in registration order (an inline template's `view` is elided to its first line); `failures` is `{ view, buildTo, message }` — the same list `err.failures` carries, with the `Error` reduced to its message so the whole thing survives `JSON.stringify`; `assets` is `{ source, target }` per emitted file (the path a template asks for, and the file that is actually there under `config.assets`); `sitemap` is the `sitemap.xml` this build wrote, or `null`; `pipeline` is `{ name, ok, duration }` per `config.assets.pipeline` step, in order, and `[]` when there are none; `llms` is the `llms.txt` this build wrote, or `null`. The same object is on the rejection as `err.report`, so a failed build is readable as data without catching and re-deriving anything. It is additive: `complete()` still resolves with the `data` array and still rejects with the `AggregateError`.
15
15
  - `.sitemap(options, callback)` — writes `sitemap.xml` to `config.folders.build`, derived from every registered page. Requires `config.siteUrl` (logs an error and skips, does not throw, if unset). Can be called before or after `.generate()` — both just wait on the same internal promise queue. Per-page opt-out: `ignoreSitemap: true` in that page's `.page()`/`.pages()` options; a `generate: false` page is left out too. Per-page overrides: `sitemapPriority` (default `'1.00'`), `sitemapChangefreq` (omitted from the XML unless set), `sitemapLastmod` (default: one timestamp shared by all pages, captured when `.sitemap()` runs). `options.overwrite` (default `true`) — set `false` to skip (re)writing if `sitemap.xml` already exists; only has an effect if `cleanBuild: false` too, since the default `cleanBuild: true` already empties the whole build dir — including any prior `sitemap.xml` — before `.sitemap()` ever runs.
16
+ - `.llms(options, callback)` — writes `llms.txt` to `config.folders.build`: the [llmstxt.org](https://llmstxt.org) index an answer engine reads first, derived from the same registry the sitemap is. Same lifecycle as `.sitemap()` — chainable, callable before or after `.generate()`, waiting on the same internal queue, and re-run by every whole-site `watch` rebuild. `options.title` (required) is the file's `# ` heading; `options.summary` (required) is the `> ` blockquote, and is either the text itself or a path (relative to `process.cwd()`) to a `.md`/`.txt` file holding it; `options.notes` (optional, same text-or-file rule) becomes a trailing `## Notes` section; `options.sections` maps a top-level path segment to a heading (`{ courses: 'Courses' }`, the key `root` naming the section that holds pages with no path, default `Pages`), and an unmapped segment is title-cased; `options.overwrite` (default `true`) behaves exactly as the sitemap's. A missing `config.siteUrl`, `title` or `summary` logs an error and skips the file — it never throws, and the callback does not fire. Pages are grouped by the first segment of their `path` (root pages first) and listed in registration order as `- [title](url): description`, with the description omitted when the page has none and the title falling back to the page's slug, title-cased. Each URL is the **same string** the page's own `{{canonical}}` and its `<loc>` in `sitemap.xml` are, built by the same code. Per-page: `ignoreLlms: true` keeps a page out; `ignoreSitemap: true` and `generate: false` keep it out automatically; `llmsSection: 'Name'` overrides the group it lands in. `callback(text)` receives the rendered file.
16
17
  - `.watch(options)` — starts a `chokidar` watcher on `config.folders.src` (only meaningful with `dev: true`, which also starts a livereload dev server on `config.port`, default `3001`, bound to `config.devHost`, with live reload on `config.livereloadPort`); rebuilds just the affected page(s), or the whole site if a changed file can't be matched to a queued page. Editing a partial or a layout re-registers the partials and re-renders **only the pages that rendered it** — learned at render time, so a partial chosen with `lookup`, reached through another partial, or used as a layout all count — without re-resolving any model (URL models included) or re-running any controller; a partial no page has rendered yet re-renders every page and logs a notice naming it. With `verbose: true` a dev build also writes `dependency-graph.json` (`{ partial: [built pages…] }`) beside `debug.json`, and each page's `.json` sibling lists the `partials` it used. Every other non-page change is a whole-site rebuild. A whole-site rebuild replays every `.page()`/`.pages()`/`.scan()` registration from scratch, so edited models and controllers take effect (controller files are reloaded from disk, ESM and CommonJS alike) — which means every URL model is fetched again on every rebuild unless you set `config.fetch.cache`; a single-page rebuild is a template re-render. A whole-site rebuild also deletes output files the previous build wrote that the new one no longer produces (a removed fan-out model, a changed slug, or a page `.scan()` had discovered whose `.hbs` file you deleted — a page you registered by name instead keeps failing the build until you remove the call) and re-runs `.sitemap()` if you ever called it. Creating a file under `src/` rebuilds the whole site too, so a partial or layout you add mid-session is registered and usable straight away (a file created empty is picked up once it has content — an event on an empty file is ignored, which is also what keeps a slow truncate-then-write save from rebuilding against a half-written file), and a new page `.hbs` is built as well if you used `.scan()` (a rebuild scans the pages folder again; on an explicitly registered site you still add the `.page()` call yourself). Deleting a partial or layout unregisters it, so any page still referencing it fails the rebuild with `The partial <name> could not be found` instead of quietly rendering the deleted content until you restart. `options.entry` (default `process.argv[1]`): the script whose change triggers a full rebuild. The browser is told to reload **once per rebuild, after every page has been written** (and once when the first build finishes), never once per file — so a reload can never land on a page the rebuild has not re-rendered yet. An edited stylesheet reloads that stylesheet alone, leaving the page's state alone.
17
18
  - `.close()` — stops the file watcher and dev server (dev mode / after `.watch()`); returns a Promise that resolves once any in-flight rebuild has finished, so nothing is written after it.
18
19
  - `.copyAssets(sourceDir, targetDir)` — compiles every `*.scss`/`*.sass` under `sourceDir` to a sibling `.css` and copies everything else straight through to `targetDir`. Runs automatically once at construction time for `config.folders.assets` → `config.folders.build`; callable again for extra asset directories. Calls run one after another in registration order, not concurrently. Every emitted file is recorded in the instance's asset manifest — that is what `{{asset}}` reads, and what `config.assets` renames (see `## Config`).
@@ -114,6 +115,42 @@ Registered on `kiss.handlebars` for every instance; register your own on the sam
114
115
  - `asset` — the path of a file `.copyAssets()` put in the build, under whatever cache-busting policy `config.assets` sets: `{{asset "css/site.css"}}` renders `css/site.css` with no policy, `css/site.a1b2c3d4.css` under `assets: { hash: true }`, and `css/site.css?v=1.4.5` under `assets: { version: '1.4.5' }`. The template says which file it wants; the config says how it is cached, so changing the policy never touches a template. Ask for the path the file has when nothing is renaming it — a `.scss` source by its compiled `.css` name (`css/site.css`, not `css/site.scss`) — with or without a leading slash. It renders **no leading slash**, so the template picks the base: `href="/{{asset "css/site.css"}}"` for a root-relative link, `href="{{root}}{{asset "css/site.css"}}"` for one that also works opened off the file system, and `{{absUrl (asset "css/site.css")}}` for an absolute URL (the query string and the hashed extension both survive). A URL that already has a scheme is passed through untouched. A path that is not in the build renders as you wrote it and logs one warning per page naming it, rather than failing the page; a non-string path logs a warning and renders nothing.
115
116
  - `env` — block helper gated on `config.dev`: `{{#env is="dev"}}dev-only{{else}}prod-only{{/env}}`. Hash key: `is` (required string, case-insensitive substring match — `"dev"`/`"prod"`). A missing or non-string `is` logs an error and renders neither branch.
116
117
 
118
+ ## Writing llms.txt
119
+
120
+ `llms.txt` is the curated, AI-facing index of a site — what an answer engine reads before it crawls. `.llms()` builds one from the pages already registered, so it cannot list a URL the site does not serve or a title the page does not carry. End to end:
121
+
122
+ ```js
123
+ kiss
124
+ .page({ view: 'index.hbs', title: 'A1K9 Training', description: 'Dog training and behaviour work in South Wales' })
125
+ .pages({ view: 'courses/course.hbs', model: 'courses', path: 'courses' })
126
+ .page({ view: 'rota.hbs', ignoreLlms: true }) // in the sitemap, out of the index
127
+ .generate()
128
+ .sitemap()
129
+ .llms({
130
+ title: 'A1K9 Training',
131
+ // Inline text, or a path to a file: `summary: 'src/content/summary.md'`
132
+ summary: 'Dog behaviour and obedience training in South Wales, run by Gaynor Probert.',
133
+ sections: { root: 'Pages', courses: 'Courses' },
134
+ notes: 'src/content/llms-notes.md',
135
+ })
136
+ ```
137
+
138
+ writes:
139
+
140
+ ```markdown
141
+ # A1K9 Training
142
+
143
+ > Dog behaviour and obedience training in South Wales, run by Gaynor Probert.
144
+
145
+ ## Pages
146
+
147
+ - [A1K9 Training](https://a1k9training.co.uk/): Dog training and behaviour work in South Wales
148
+
149
+ ## Courses
150
+
151
+ - [Bronze obedience](https://a1k9training.co.uk/courses/bronze-obedience): Six weeks, group class
152
+ ```
153
+
117
154
  ## Checking a build
118
155
 
119
156
  `npx kiss-ssg check <script> [args…]` runs a site's own build script as a dry run: the build is staged and then discarded, so the build folder is neither emptied nor written, and what you get back is the verdict instead of the output. Arguments after the script are passed through to it, so a site that takes a season or a cohort is checked the way it is run. It prints a JSON array with one report per `Kiss` instance the script created — a script that builds several outputs gets several reports:
@@ -135,7 +172,8 @@ Registered on `kiss.handlebars` for every instance; register your own on the sam
135
172
  ],
136
173
  "assets": [{ "source": "css/site.css", "target": "css/site.605b52d7.css" }],
137
174
  "sitemap": "./public/sitemap.xml",
138
- "pipeline": [{ "name": "tailwind", "ok": true, "duration": 420 }]
175
+ "pipeline": [{ "name": "tailwind", "ok": true, "duration": 420 }],
176
+ "llms": "./public/llms.txt"
139
177
  }
140
178
  ]
141
179
  ```
@@ -151,10 +189,10 @@ Two things a check cannot make true. A site that **reads its own build folder ba
151
189
  ## Docs
152
190
 
153
191
  - [README.md](README.md): full usage docs, worked examples for `.page()`/`.pages()`/`.sitemap()`, the helpers list.
154
- - [examples/](examples/): ships in the package, so an agent working in a consuming project can read `node_modules/kiss-ssg/examples/README.md` and run a copy from there. Two tiers. Examples 1–6 are the feature reference, one idea each — scan, page/model/controller, `.pages()` fan-out, layouts and partials, helpers, sitemap. Examples 7–9 are exemplars, whole sites to copy by shape rather than a single feature: 7 is versioned outputs, one build per season; 8 is a site built from a folder of records with one broken one, fed and validated in the controller; 9 is every v1 → v2 migration recipe as running code, in a site that builds clean. Every example builds and exits by default (`node examples/<N>-name.js`, or `npm run eg<N>`); pass `--dev` to run examples 1–6, 8 and 9 as a live dev server instead (7 takes a season slug in place of `--dev`). Example 8 is the only one that exits non-zero, and it does so on purpose.
155
- - [types/](types/): TypeScript declarations, generated from the JSDoc in `lib/` and shipped in the package (`package.json`'s `types`, and an `exports` map whose `.` entry resolves `types` → `./types/kiss.d.ts` and both `import` and `require` → `./lib/kiss.js`). A plain-JavaScript site gets full completion and checking by putting `// @ts-check` at the top of its build script — no TypeScript, no `@types/` package. Every option type is reachable from the one entry: `/** @type {import('kiss-ssg').KissConfigInput} */` on the config object, `import('kiss-ssg').PageOptions` / `PagesOptions` on page options, `import('kiss-ssg').KissController` on a controller, and `import('kiss-ssg').BuildError` to cast the error `complete()` rejects with before reading `err.failures`. Also exported: `KissConfig` (the resolved config a view sees), `KissFolders`, `PageOptionsPatch` (what a controller returns), `BuildData`, `BuildFailure`, `BuildReport` (what `report()` returns) with `BuildPage`, `BuildAsset` and `BuildReportFailure`, `SitemapOptions`, `SitemapUrl`, `WatchOptions`. Arbitrary keys are allowed on the config and on page options — they reach the template — so only the documented keys are checked; `folders` is closed, so a misspelled folder name is an error.
156
- - [plugins/kiss-ssg/](plugins/kiss-ssg/): a Claude Code plugin, installed with `/plugin marketplace add cprobert/kiss-ssg` then `/plugin install kiss-ssg@kiss-ssg`. Three skills — `new-site`, `migrate-v1`, `check` — which sequence this file and the examples rather than restating them. The plugin does not ship in the npm tarball, only in the repository; a project's CLAUDE.md can import this file directly with `@node_modules/kiss-ssg/llms.txt`.
157
- - [lib/](lib/): the engine — `kiss.js` (orchestrator, public API), `kiss-page.js` (one page's render), and one module per concern (`config`, `logger`, `handlebars-helpers`, `partials`, `dependency-graph`, `assets`, `asset-manifest`, `sass`, `model-resolver`, `fetch-policy`, `controller-resolver`, `sitemap`, `dev-server`, `watcher`, `utils`, `build-report`, `check`, `pipeline`). Per-module notes: [AIKB/](AIKB/).
192
+ - [examples/](examples/): ships in the package, so an agent working in a consuming project can read `node_modules/kiss-ssg/examples/README.md` and run a copy from there. Two tiers. Examples 1–6 are the feature reference, one idea each — scan, page/model/controller, `.pages()` fan-out, layouts and partials, helpers, sitemap and llms.txt. Examples 7–9 are exemplars, whole sites to copy by shape rather than a single feature: 7 is versioned outputs, one build per season; 8 is a site built from a folder of records with one broken one, fed and validated in the controller; 9 is every v1 → v2 migration recipe as running code, in a site that builds clean. Every example builds and exits by default (`node examples/<N>-name.js`, or `npm run eg<N>`); pass `--dev` to run examples 1–6, 8 and 9 as a live dev server instead (7 takes a season slug in place of `--dev`). Example 8 is the only one that exits non-zero, and it does so on purpose.
193
+ - [types/](types/): TypeScript declarations, generated from the JSDoc in `lib/` and shipped in the package (`package.json`'s `types`, and an `exports` map whose `.` entry resolves `types` → `./types/kiss.d.ts` and both `import` and `require` → `./lib/kiss.js`). A plain-JavaScript site gets full completion and checking by putting `// @ts-check` at the top of its build script — no TypeScript, no `@types/` package. Every option type is reachable from the one entry: `/** @type {import('kiss-ssg').KissConfigInput} */` on the config object, `import('kiss-ssg').PageOptions` / `PagesOptions` on page options, `import('kiss-ssg').KissController` on a controller, and `import('kiss-ssg').BuildError` to cast the error `complete()` rejects with before reading `err.failures`. Also exported: `KissConfig` (the resolved config a view sees), `KissFolders`, `PageOptionsPatch` (what a controller returns), `BuildData`, `BuildFailure`, `BuildReport` (what `report()` returns) with `BuildPage`, `BuildAsset` and `BuildReportFailure`, `SitemapOptions`, `SitemapUrl`, `LlmsOptions`, `WatchOptions`. Arbitrary keys are allowed on the config and on page options — they reach the template — so only the documented keys are checked; `folders` is closed, so a misspelled folder name is an error.
194
+ - [plugins/kiss-ssg/](plugins/kiss-ssg/): a Claude Code plugin, installed with `/plugin marketplace add cprobert/kiss-ssg` then `/plugin install kiss-ssg@kiss-ssg`. Four skills, each named `kiss-<something>` for easy discovery — `kiss-new-site`, `kiss-add-page`, `kiss-migrate-v1`, `kiss-check` — which sequence this file and the examples rather than restating them, and whose descriptions are written so Claude reaches for them on its own without being invoked by name. The plugin does not ship in the npm tarball, only in the repository; a project's CLAUDE.md can import this file directly with `@node_modules/kiss-ssg/llms.txt`.
195
+ - [lib/](lib/): the engine — `kiss.js` (orchestrator, public API), `kiss-page.js` (one page's render), and one module per concern (`config`, `logger`, `handlebars-helpers`, `partials`, `dependency-graph`, `assets`, `asset-manifest`, `sass`, `model-resolver`, `fetch-policy`, `controller-resolver`, `sitemap`, `dev-server`, `watcher`, `utils`, `build-report`, `check`, `pipeline`, `llms`). Per-module notes: [AIKB/](AIKB/).
158
196
 
159
197
  ## Migrating from v1
160
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiss-ssg",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Simple Static Website Generator",
5
5
  "main": "lib/kiss.js",
6
6
  "types": "./types/kiss.d.ts",
@@ -12,9 +12,10 @@
12
12
  * @param {number} [input.startedAt] `Date.now()` at construction
13
13
  * @param {string|null} [input.sitemap] the sitemap written by this build
14
14
  * @param {import('./pipeline.js').PipelineResult[]} [input.pipeline] what the asset pipeline's steps did
15
+ * @param {string|null} [input.llms] the llms.txt written by this build
15
16
  * @returns {BuildReport}
16
17
  */
17
- export function buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, }: {
18
+ export function buildReport({ stack, failures, manifest, buildDir, stagingDir, mode, startedAt, sitemap, pipeline, llms, }: {
18
19
  stack?: {
19
20
  view: string;
20
21
  buildTo: string | null;
@@ -29,6 +30,7 @@ export function buildReport({ stack, failures, manifest, buildDir, stagingDir, m
29
30
  startedAt?: number;
30
31
  sitemap?: string | null;
31
32
  pipeline?: import("./pipeline.js").PipelineResult[];
33
+ llms?: string | null;
32
34
  }): BuildReport;
33
35
  /**
34
36
  * The one-line human rendering of a report, plus one line per failure — what
@@ -134,4 +136,8 @@ export type BuildReport = {
134
136
  * every `config.assets.pipeline` step, in order; empty when there are none
135
137
  */
136
138
  pipeline: BuildPipelineStep[];
139
+ /**
140
+ * the `llms.txt` written, or `null` if none was
141
+ */
142
+ llms: string | null;
137
143
  };
package/types/kiss.d.ts CHANGED
@@ -67,6 +67,14 @@ export type PageOptionsKnown = {
67
67
  * default: one timestamp shared by every page
68
68
  */
69
69
  sitemapLastmod?: string;
70
+ /**
71
+ * keep this page out of `llms.txt`
72
+ */
73
+ ignoreLlms?: boolean;
74
+ /**
75
+ * the `llms.txt` section this page is listed under, overriding its path
76
+ */
77
+ llmsSection?: string;
70
78
  };
71
79
  /**
72
80
  * The options `.page()` takes: {@link PageOptionsKnown} plus any extra keys of
@@ -147,6 +155,33 @@ export type SitemapOptions = {
147
155
  */
148
156
  overwrite?: boolean;
149
157
  };
158
+ /**
159
+ * The options `.llms()` takes. `title` and `summary` are required — without
160
+ * either, kiss logs an error and writes nothing, exactly as it does for a
161
+ * sitemap with no `siteUrl`.
162
+ */
163
+ export type LlmsOptions = {
164
+ /**
165
+ * the site's name — the file's `# ` heading
166
+ */
167
+ title: string;
168
+ /**
169
+ * what the site is, as a `> ` blockquote: the text itself, or a path (relative to `process.cwd()`) to a `.md`/`.txt` file holding it
170
+ */
171
+ summary: string;
172
+ /**
173
+ * a trailing `## Notes` section; same text-or-file rule as `summary`
174
+ */
175
+ notes?: string;
176
+ /**
177
+ * top-level path segment → section heading (`{ courses: 'Courses' }`); the key `root` names the section holding pages with no path (default `Pages`). An unmapped segment is title-cased.
178
+ */
179
+ sections?: Record<string, string>;
180
+ /**
181
+ * default `true`; `false` leaves an existing `llms.txt` alone
182
+ */
183
+ overwrite?: boolean;
184
+ };
150
185
  export type WatchOptions = {
151
186
  /**
152
187
  * the script whose own change triggers a whole-site rebuild; defaults to `process.argv[1]`
@@ -216,6 +251,8 @@ declare class Kiss {
216
251
  /** @private */
217
252
  private _sitemapRequest;
218
253
  /** @private */
254
+ private _llmsRequest;
255
+ /** @private */
219
256
  private _watcher;
220
257
  /** @private */
221
258
  private _devServer;
@@ -240,6 +277,8 @@ declare class Kiss {
240
277
  /** @private */
241
278
  private _sitemapPath;
242
279
  /** @private */
280
+ private _llmsPath;
281
+ /** @private */
243
282
  private _checkMode;
244
283
  /** @type {KissConfig} */
245
284
  config: KissConfig;
@@ -377,6 +416,20 @@ declare class Kiss {
377
416
  * @returns {this}
378
417
  */
379
418
  sitemap(options?: SitemapOptions | null, callback?: (urls: SitemapUrl[]) => void | Promise<void>): this;
419
+ /**
420
+ * Writes `llms.txt` into the build folder — the llmstxt.org index an answer
421
+ * engine reads first — from the same registry, titles and URLs the sitemap is
422
+ * built from, so the two can never disagree. Requires `config.siteUrl`, and
423
+ * `options.title`/`options.summary`; without any of them this logs an error
424
+ * and skips. Like `.sitemap()`, it can be called before or after
425
+ * `.generate()`, and it is re-run by a whole-site watch rebuild.
426
+ *
427
+ * @param {LlmsOptions} options
428
+ * @param {(text: string) => void|Promise<void>} [callback] receives the
429
+ * rendered file; not fired when llms.txt was skipped for want of an option
430
+ * @returns {this}
431
+ */
432
+ llms(options: LlmsOptions, callback?: (text: string) => void | Promise<void>): this;
380
433
  /**
381
434
  * Pulls one resolved model out of the array `.generate()`/`.complete()` hand
382
435
  * back — the way to read a model without relying on its position.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Resolves a `summary`/`notes` option: the contents of the file it names, or the
3
+ * string itself. A `.md` file is used as it is — `llms.txt` is markdown.
4
+ *
5
+ * @param {unknown} value
6
+ * @returns {Promise<string>} the resolved text, trailing whitespace trimmed
7
+ */
8
+ export function resolveText(value: unknown): Promise<string>;
9
+ /**
10
+ * The display name for one top-level path segment: an explicit mapping, else the
11
+ * segment title-cased. The root group (a page with no `path`) is keyed `root`.
12
+ *
13
+ * @param {string} segment the sanitised first segment of a page's `path`, `''` for a root page
14
+ * @param {Record<string, string>} [sections]
15
+ * @returns {string}
16
+ */
17
+ export function sectionNameFor(segment: string, sections?: Record<string, string>): string;
18
+ /**
19
+ * One entry per page that belongs in `llms.txt`, in registration order. A page
20
+ * is left out by `ignoreLlms`, by `ignoreSitemap` (the AI index is a curated
21
+ * subset of the same site the sitemap describes, never a superset), or by
22
+ * `generate: false` (there is nothing at that URL).
23
+ *
24
+ * @param {{ buildTo: string, page: { options: Record<string, any> } }[]} stack
25
+ * @param {Object} context
26
+ * @param {string} context.siteUrl
27
+ * @param {string} context.buildDir
28
+ * @param {Record<string, string>} [context.sections] top-level path segment → display name
29
+ * @returns {LlmsEntry[]}
30
+ */
31
+ export function buildLlmsEntries(stack: {
32
+ buildTo: string;
33
+ page: {
34
+ options: Record<string, any>;
35
+ };
36
+ }[], { siteUrl, buildDir, sections }: {
37
+ siteUrl: string;
38
+ buildDir: string;
39
+ sections?: Record<string, string>;
40
+ }): LlmsEntry[];
41
+ /**
42
+ * Groups entries by section, first-seen order, with the root section first
43
+ * wherever it appears — a reader meets the site's own top-level pages before its
44
+ * subsections.
45
+ *
46
+ * @param {LlmsEntry[]} entries
47
+ * @param {string} [rootSection] the display name the root group resolved to
48
+ * @returns {LlmsGroup[]}
49
+ */
50
+ export function groupLlmsEntries(entries: LlmsEntry[], rootSection?: string): LlmsGroup[];
51
+ /**
52
+ * Renders the file: an H1 title, a blockquote summary, one `##` section per
53
+ * group and an optional trailing `## Notes` — the llmstxt.org shape.
54
+ *
55
+ * @param {Object} input
56
+ * @param {string} input.title
57
+ * @param {string} input.summary
58
+ * @param {LlmsGroup[]} [input.groups]
59
+ * @param {string} [input.notes]
60
+ * @returns {string} the file's text, one trailing newline
61
+ */
62
+ export function renderLlmsTxt({ title, summary, groups, notes }: {
63
+ title: string;
64
+ summary: string;
65
+ groups?: LlmsGroup[];
66
+ notes?: string;
67
+ }): string;
68
+ /**
69
+ * @typedef {Object} LlmsWriteResult
70
+ * @property {'no-site-url'|'no-title'|'no-summary'|'skipped'|'written'} status
71
+ * @property {string|null} text the rendered file, or `null` when nothing was written
72
+ */
73
+ /**
74
+ * Writes `<build>/llms.txt`. Every reason not to write one is reported rather
75
+ * than thrown — a missing `siteUrl`, `title` or `summary` is a build the author
76
+ * still wants, minus this file.
77
+ *
78
+ * @param {{ buildTo: string, page: { options: Record<string, any> } }[]} stack
79
+ * @param {Object} deps
80
+ * @param {any} deps.config the resolved config — `siteUrl` and `folders.build`
81
+ * @param {any} deps.logger
82
+ * @param {Record<string, any>} [deps.options] the `.llms()` options
83
+ * @param {boolean} [deps.overwrite] default `true`
84
+ * @returns {Promise<LlmsWriteResult>}
85
+ */
86
+ export function writeLlms(stack: {
87
+ buildTo: string;
88
+ page: {
89
+ options: Record<string, any>;
90
+ };
91
+ }[], { config, logger, options, overwrite }: {
92
+ config: any;
93
+ logger: any;
94
+ options?: Record<string, any>;
95
+ overwrite?: boolean;
96
+ }): Promise<LlmsWriteResult>;
97
+ export type LlmsWriteResult = {
98
+ status: "no-site-url" | "no-title" | "no-summary" | "skipped" | "written";
99
+ /**
100
+ * the rendered file, or `null` when nothing was written
101
+ */
102
+ text: string | null;
103
+ };
104
+ /**
105
+ * One page as `llms.txt` lists it.
106
+ */
107
+ export type LlmsEntry = {
108
+ /**
109
+ * the page's `title`, or its slug title-cased
110
+ */
111
+ title: string;
112
+ /**
113
+ * the same absolute URL the sitemap and `{{canonical}}` emit
114
+ */
115
+ url: string;
116
+ /**
117
+ * the page's `description`, or `''` when it has none
118
+ */
119
+ description: string;
120
+ /**
121
+ * the display name of the group it belongs to
122
+ */
123
+ section: string;
124
+ };
125
+ /**
126
+ * One `##` section of the file: a display name and the pages under it.
127
+ */
128
+ export type LlmsGroup = {
129
+ name: string;
130
+ entries: LlmsEntry[];
131
+ };