kiss-ssg 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AIKB/aikb.md +2 -2
- package/AIKB/base.md +34 -0
- package/AIKB/build-report.md +3 -3
- package/AIKB/check.md +2 -1
- package/AIKB/feed.md +2 -2
- package/AIKB/git.md +32 -0
- package/AIKB/handlebars-helpers.md +5 -4
- package/AIKB/init.md +10 -3
- package/AIKB/links.md +1 -1
- package/AIKB/llms.md +3 -2
- package/AIKB/partials.md +1 -0
- package/AIKB/robots.md +4 -2
- package/AIKB/scorm-bridge.md +4 -3
- package/AIKB/sessions.md +44 -0
- package/AIKB/sitemap.md +4 -3
- package/AIKB/testing.md +6 -4
- package/AIKB/upgrade.md +53 -0
- package/AIKB/upstream.md +62 -0
- package/CHANGELOG.md +432 -34
- package/GUIDE.md +93 -23
- package/README.md +22 -15
- package/bin/kiss-ssg.js +272 -14
- package/examples/1-scan/src/layouts/layout.hbs +5 -3
- package/examples/10-asset-pipeline/src/assets/css/generated.css +1 -1
- package/examples/10-asset-pipeline/src/layouts/layout.hbs +5 -3
- package/examples/11-blog/AIKB/last-build.json +126 -72
- package/examples/11-blog/AIKB/notes/controllers/post.md +5 -5
- package/examples/11-blog/AIKB/notes/controllers/tag.md +5 -5
- package/examples/11-blog/AIKB/site-map.json +111 -111
- package/examples/11-blog/AIKB/site-map.md +32 -32
- package/examples/11-blog/README.md +88 -57
- package/examples/11-blog/router.js +63 -26
- package/examples/11-blog/src/controllers/post.js +4 -4
- package/examples/11-blog/src/controllers/tag.js +1 -1
- package/examples/11-blog/src/layouts/layout.hbs +7 -4
- package/examples/11-blog/src/models/posts/2025-12-15-a-year-of-bench-lots.json +1 -0
- package/examples/11-blog/src/models/posts/2026-01-06-grind-size-is-the-dial.json +2 -1
- package/examples/11-blog/src/models/posts/2026-01-20-why-we-roast-on-wednesdays.json +1 -0
- package/examples/11-blog/src/models/posts/2026-02-03-guji-uraga-has-landed.json +2 -1
- package/examples/11-blog/src/models/posts/2026-02-18-the-cascara-experiment.json +3 -3
- package/examples/11-blog/src/models/posts/2026-03-02-pour-over-at-home.json +2 -1
- package/examples/11-blog/src/pages/404.hbs +2 -2
- package/examples/11-blog/src/pages/index.hbs +14 -13
- package/examples/11-blog/src/pages/{blog → journal}/listing.hbs +4 -4
- package/examples/11-blog/src/pages/{blog → journal}/post.hbs +13 -12
- package/examples/11-blog/src/pages/{blog → journal}/tag.hbs +1 -1
- package/examples/11-blog/src/pages/{blog → journal}/tags.hbs +3 -3
- package/examples/11-blog/src/partials/post-card.hbs +2 -2
- package/examples/13-course-packages/README.md +24 -6
- package/examples/13-course-packages/router.js +6 -4
- package/examples/13-course-packages/src/assets/css/course.css +178 -6
- package/examples/13-course-packages/src/layouts/lesson.hbs +5 -3
- package/examples/13-course-packages/src/models/milk.json +44 -0
- package/examples/13-course-packages/src/pages/espresso/extraction.hbs +1 -1
- package/examples/13-course-packages/src/pages/espresso/grind.hbs +1 -1
- package/examples/13-course-packages/src/pages/milk/index.hbs +66 -35
- package/examples/13-course-packages/src/partials/question.hbs +35 -0
- package/examples/13-course-packages/src/partials/quiz-script.hbs +155 -0
- package/examples/2-page/src/layouts/layout.hbs +5 -3
- package/examples/3-pages/src/layouts/layout.hbs +5 -3
- package/examples/4-layouts-and-partials/src/partials/site/header.hbs +1 -1
- package/examples/5-helpers/src/layouts/layout.hbs +5 -3
- package/examples/6-sitemap/README.md +7 -5
- package/examples/6-sitemap/router.js +11 -10
- package/examples/6-sitemap/src/layouts/layout.hbs +5 -3
- package/examples/6-sitemap/src/models/stockists.json +2 -1
- package/examples/6-sitemap/src/pages/index.hbs +9 -8
- package/examples/7-versioned-outputs/src/layouts/layout.hbs +5 -3
- package/examples/8-data-fed-site/src/layouts/layout.hbs +5 -3
- package/examples/9-migrated-from-v1/AIKB/last-build.json +14 -13
- package/examples/9-migrated-from-v1/src/layouts/layout.hbs +5 -3
- package/lib/aikb.js +1 -1
- package/lib/base.js +216 -0
- package/lib/build-finish.js +5 -1
- package/lib/build-report.js +12 -1
- package/lib/check.js +49 -8
- package/lib/feed.js +7 -3
- package/lib/git.js +84 -0
- package/lib/handlebars-helpers.js +27 -10
- package/lib/init.js +112 -11
- package/lib/kiss.js +14 -8
- package/lib/links.js +35 -5
- package/lib/robots.js +48 -4
- package/lib/scorm-bridge.js +12 -10
- package/lib/sessions.js +311 -0
- package/lib/sitemap.js +52 -9
- package/lib/upgrade.js +746 -0
- package/llms.txt +13 -10
- package/package.json +1 -1
- package/types/base.d.ts +50 -0
- package/types/build-report.d.ts +12 -2
- package/types/check.d.ts +28 -1
- package/types/feed.d.ts +10 -0
- package/types/git.d.ts +53 -0
- package/types/init.d.ts +17 -0
- package/types/kiss.d.ts +25 -11
- package/types/links.d.ts +11 -2
- package/types/robots.d.ts +18 -4
- package/types/scorm-bridge.d.ts +5 -4
- package/types/sessions.d.ts +130 -0
- package/types/sitemap.d.ts +2 -2
- package/types/upgrade.d.ts +366 -0
package/AIKB/aikb.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Writes the site's own knowledge base — the folder `config.folders.aikb` names, default `./AIKB` — from one settled build. The third of the three "describe the site" writers: `sitemap.js` writes it for a crawler, `llms.js` for an
|
|
5
|
+
Writes the site's own knowledge base — the folder `config.folders.aikb` names, default `./AIKB` — from one settled build. The third of the three "describe the site" writers: `sitemap.js` writes it for a crawler, `llms.js` for an agent pointed at the site, this one for the **next developer**, who is usually the same developer two years later with none of the context left.
|
|
6
6
|
|
|
7
7
|
Unlike the other two it has no public API call behind it. The only thing that writes this folder is `npx kiss-ssg aikb <script>` — a ceremony performed once at the end of a piece of work — which sets `KISS_AIKB` for the build it spawns. Every other build reads the folder and reports on it, and writes nothing. That is the whole point: `last-build.json` is the baseline `kiss-ssg check` diffs against, so it must move when a piece of work closes and at no other moment, or "what have I changed" silently means "since I last ran a build".
|
|
8
8
|
|
|
@@ -35,7 +35,7 @@ Everything but the two writers is pure, and nothing anywhere reads a clock: two
|
|
|
35
35
|
|
|
36
36
|
## Depended on by
|
|
37
37
|
|
|
38
|
-
`lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by the `kiss-memory` plugin
|
|
38
|
+
`lib/build-finish.js` (`Kiss._buildAikb()`, inside `_finishBuild()`), and `lib/page-registry.js` (`pageOrigin`, in `_page()`). The generated files are read by every skill in the `kiss-memory` plugin: `kiss-site-brief`, `kiss-branch-open`, `kiss-branch-pulse`, `kiss-branch-close`, `kiss-branch-merged` (whose step 4 checks the merged base against `last-build.json`) and `kiss-memory-consolidate` (a grep for `last-build.json` and `kiss-ssg check` across `plugins/kiss-memory/skills/` finds all six), and `last-build.json` is the file `kiss-ssg check` diffs against by default (`defaultBaseline` in `lib/check.js` derives its path from the report's own `aikb.folder`).
|
|
39
39
|
|
|
40
40
|
## Non-obvious behavior
|
|
41
41
|
|
package/AIKB/base.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# base.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
`kiss-ssg base`: the branch a site's piece of work merges into, and the ref to diff against or start new work from. The kiss-memory skills call it rather than ask git themselves.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `parseLsRemote(text)` — the branch `git ls-remote --symref origin HEAD` names, or `null`.
|
|
10
|
+
- `chooseDiffRef(found, behind)` — of the local base and origin's, the one with the fewest commits to HEAD; a tie keeps the local branch.
|
|
11
|
+
- `chooseStart(facts, name)` — `{ ref, reason }`: where new work starts.
|
|
12
|
+
- `runBase(argv, { cwd })` — the whole command: `{ out, err, code }`.
|
|
13
|
+
- `BASE_HELP` — the command's help text.
|
|
14
|
+
|
|
15
|
+
## Depends on
|
|
16
|
+
|
|
17
|
+
`lib/git.js`, `lib/sessions.js`.
|
|
18
|
+
|
|
19
|
+
## Depended on by
|
|
20
|
+
|
|
21
|
+
`bin/kiss-ssg.js` (`kiss-ssg base`), and through it the `kiss-memory` skills.
|
|
22
|
+
|
|
23
|
+
## Non-obvious behavior
|
|
24
|
+
|
|
25
|
+
- **The base is the session log's `base:` line first**, then the remote's default branch. The default comes from the clone's record (`refs/remotes/origin/HEAD`), else from asking origin (`ls-remote --symref`), because a remote added by hand has no record. With neither, the command exits 1 and says to ask: it never assumes `main`. A merge skill that knows better (a pull request's `baseRefName`, which a retargeted request changes) uses that instead; the skill says so.
|
|
26
|
+
- **Where the name came from is printed on stderr**, and stdout carries the bare answer, so a skill can read either.
|
|
27
|
+
- **`--ref` picks the ref this work started from.** `git fetch` never moves a local branch, so a stale local base would put everyone else's newer commits in the diff, while a local base with unpushed commits is the one the work came from. It measures from the work's own commits: the named branch's tip when a branch is named (the local branch, or origin's copy), and `HEAD` only for the current one. Counting to `HEAD` for a named branch measured whatever this checkout stood on, and named a stale local base from a checkout on it (code review at the close, 2026-10-31).
|
|
28
|
+
- **`--start` starts from origin's base** unless only the local one exists or it has commits origin lacks; when the two have diverged it exits 1, because which to start from is the person's call.
|
|
29
|
+
- **`--base <name>` skips the lookup.** `kiss-branch-open` creates a worktree before it writes the session log, so at that moment no log can name the base the person chose; without the option, `--start` would answer for the remote's default instead. It does not combine with `--from-branch`, which only says where to look the base up.
|
|
30
|
+
- **Every ref is printed in full**, so a tag of the same name (`v2`) is never read in its place. With the short name, `git pull origin v2` fetched the tag and said "Already up to date" while the base stayed behind (measured, 2026-10-31).
|
|
31
|
+
|
|
32
|
+
## Types
|
|
33
|
+
|
|
34
|
+
Returns `import('./sessions.js').CommandResult`.
|
package/AIKB/build-report.md
CHANGED
|
@@ -18,11 +18,11 @@ Turns one settled build into data: the JSON-safe `BuildReport` that `Kiss.report
|
|
|
18
18
|
- `pipeline` — `{ name, ok, duration }` per `config.assets.pipeline` step this build ran, in order; `[]` when the site configured none.
|
|
19
19
|
- `llms` — the `llms.txt` this build wrote, or `null`. Appended after `pipeline` for the same reason `pipeline` was appended after `sitemap`: a consumer diffing two reports should see one new key, not a reshuffle.
|
|
20
20
|
- `aikb` — `null` when the site has no knowledge base to report on, and otherwise `{ folder, written, notes: { missing, dead, stale, dangling }, subjects }` as `lib/aikb.js` evaluated it (`AIKB/aikb.md`). Appended after `llms`, for the same reason. It is passed through **unmapped**: the knowledge base is source-side and committed, so it is never staged and its paths are already the ones a person would type.
|
|
21
|
-
- `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }] }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder.
|
|
21
|
+
- `links` — `null` when this build ran no scan (a dev build, a watch rebuild, or `config.links.check: false`), and otherwise `{ checked, hostServed, broken: [{ page, href }], orphans }` as `lib/links.js` resolves it (the module lands with the scan; this key is the seam it fills); `broken` is sorted by page then href, and every `page` is mapped out of the staging folder. `orphans` is the sorted HTML pages no other page links to, never the home page, `404.html` or a page in a learning package, each mapped out of the staging folder the same way (`AIKB/links.md`).
|
|
22
22
|
- `redirects` — `null` when there is nothing to say, and otherwise `{ file, aliases, removed, collisions, moved, rules, json, format, files }` as `lib/redirects.js` derives it (the module lands with the writer; this key is the seam it fills): the `_redirects` written (mapped, `null` when no page has an alias), how many alias paths went into it, and the three advisory findings — pages the last record had that this build does not and no alias covers, aliases a live page already answers, and `moved`, `{ id, from, to }` per page the record and this build share an `id` with whose path changed and whose old path no alias covers. All three lists sorted; `moved` by `from`. The last four keys are the format block: `rules` is the resolved `[{ from, to }]` — the host-neutral fact, handed over so a site whose host kiss does not encode can write its own redirects from `kiss.report()` rather than by parsing a file kiss wrote — `json` is the `redirects.json` IR (mapped), `formats` is the list of names that ran, in order (`'custom'` per writer function, `[]` for IR-only), and `files` is every redirects file written (mapped). `file` is the **first** host file in `formats` order, and `null` when no host format ran at all — the honest answer rather than a claim about a redirect no host serves. With more than one format it names one of several, so `files` is the authoritative list; that is the one place `file` is a lead rather than the whole answer, and it is why `files` exists.
|
|
23
23
|
- `feed` — the feed file this build wrote, or `null`. Mapped like `sitemap` and `llms`.
|
|
24
24
|
- `audit` — the **last** key, after `outputs`: `null` when no audit ran (a dev build, `config.audit.check: false`, or a build with any failure), and otherwise `{ checked, ignored, skipped, findings: [{ check, page, detail }] }` as `lib/audit.js` decided it (`AIKB/audit.md`). Every finding's `page` goes through `real()`, like `links.broken[].page`; `detail` is a value out of a template and is never mapped. `ignored` and `skipped` are there so a clean audit with checks turned off is never read as a clean site.
|
|
25
|
-
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the
|
|
25
|
+
- `formatReport(report)` → the summary line `ok|FAIL <buildDir> (<mode>) — N pages, M failed, K assets, Xms`, followed by one indented `<buildTo|view>: <message>` line per failure, then — only when a page named its canonical elsewhere — one ` N page(s) canonical elsewhere — not in sitemap.xml, llms.txt or the feed` line (a count rather than a line per page: a site mirroring a sister catalogue has a hundred, and a hundred lines would bury the findings below), then the four note findings in order: ` note missing: <path>` per subject with no note, ` note dead: <path>` per note whose subject is gone, ` note stale: <path>` per note whose stamp is no longer its subject's hash, and ` note dangling: <path>: <token>` per file reference in a note (or in `site.md`) that resolves to nothing — and then the link/redirect findings: ` broken link: <page> -> <href>`, one ` orphan pages (N, no other page links to them): <the first five>, …` line (one line, not one per page: a site with campaign landing pages has several on purpose, and the JSON holds them all), ` removed without redirect: <path>`, ` moved without redirect: <from> -> <to> (<id>)` and ` alias collides with a page: <path>`. The move is printed beside the removal rather than in report-key order (where `moved` is last): the two answer one question — what did this rename leave behind — and are read together. The one-line fix (`add "<from>" to that page's aliases`) is on the `notice` the build logs, not on this line, which is grepped. Last come the audit lines, from `auditLines()`, indented. What `kiss-ssg check --summary` prints instead of the JSON.
|
|
26
26
|
- `auditLines(audit)` → `string[]`, unindented, empty for `null` and for a clean audit: one line per check that fired, in `CHECKS` order — `audit <check>: N pages (a, b, c, …)` naming up to three distinct paths (`files` for `debug-dump`, `stray-file` and `console-log`, which are about walked files; singular for one), `audit <check>` for a site-wide finding, or `audit <check>: <detail>` when that finding carries a value; the four `VALUE_CHECKS` instead print one line per value, `audit title-duplicate: "<value>" on N pages (…)`, the value capped at 60 characters. For the two duplicate checks, that is because a duplicate is only actionable once you know what is duplicated (the operator's call, 2026-09-27). For `viewport-zoom-lock` and `fragment-missing`, the value (the viewport content, the href) is what to grep the templates for; a page list alone sends you reading every link (2026-10-20) — plus `audit skipped (cleanBuild: false): <ids>` when `skipped` is non-empty. Shared by `formatReport` and `Kiss._runAudit()`'s `notice` lines, so the build log and `check --summary` cannot word a finding differently.
|
|
27
27
|
- `reportedPath(target, buildDir, stagingDir)` and `reportedView(view)` — the staging→real path mapping and the inline-template elision, exported so `lib/aikb.js` reports the same paths and the same views as the report does.
|
|
28
28
|
|
|
@@ -49,7 +49,7 @@ Nothing.
|
|
|
49
49
|
- **The report is not quite what `AIKB/last-build.json` holds.** That file is `lastBuildRecord(report)` (`lib/aikb.js`): the same keys in the same order, minus `duration` and minus every `pipeline[].duration`, so two identical records write the same bytes and a committed snapshot only ever changes when the site does. `readReportsFile` and `diffReports` read it happily — neither looks at a timing. Its `mode` is `'check'`, because `kiss-ssg aikb` stages and discards the build the way `check` does.
|
|
50
50
|
- **`hash` is read off the `KissPage` the stack entry carries** (`entry.page?.hash ?? null`), not computed here — this module never sees bytes, and the page is the only thing that knows what it actually wrote (`AIKB/kiss-page.md`). It is appended after `ok` for the reason `pipeline` and `llms` were appended to the report: one new key beats a reshuffle of the three that were there. `null` covers three cases that all mean the same thing — nothing is on disk for this page: it failed, it was never rendered, or it was registered with `generate: false`. A stack entry with no `page` at all (a hand-built one in a test) reports `null` too, so the shape never depends on who assembled the stack. What reads it back is `diffReports` in `lib/check.js`, which is why "no hash" has to be distinguishable from "some hash": it counts a `null` on either side as _changed_, never as unchanged.
|
|
51
51
|
- **`redirects.moved` is appended after `collisions`, and defaults to `[]` rather than to `undefined`.** The `redirects` object is rebuilt key by key here rather than spread, so a key the module derives and this one does not copy is silently dropped from the report _and_ from every `last-build.json` written from it. `moved: redirects.moved ?? []` also lets a hand-built `redirects` (a test fixture, an older caller) through without a hole in the shape.
|
|
52
|
-
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. `lib/robots.js` has the rest.
|
|
52
|
+
- **`robots` is an object where `sitemap`, `llms` and `feed` are bare paths, and that asymmetry is deliberate.** `disallowAll` is a fact that removes the site from search, and a path string cannot carry it. Putting it in the report is what lets a `kiss-ssg check` diff show a staging crawl policy reaching production, rather than leaving it to be found in Search Console. Since 2.8.6 `disallowAll` means `*` or a search engine's crawler is blocked, and `blocked` (appended after it) names every agent kept out of the whole site; an older record has no `blocked`. `lib/robots.js` has the rest.
|
|
53
53
|
|
|
54
54
|
- **The four derived-output keys are appended, and `null` is not the same as empty.** `links`, `redirects`, `feed` and `robots` sit after `aikb` in that fixed order, for the reason every key before them was appended: a consumer diffing two reports should see new keys at the end rather than a reshuffle of the eleven that were there. Each is `null` until the build actually did that piece of work — no scan ran, no page has an alias, `.feed()` or `.robots()` was never called — which is a different answer from "it ran and found nothing" (`{ checked: 0, broken: [] }`, or an empty `removed`). A consumer that treats `null` as "clean" reports a dev rebuild as a site with no broken links.
|
|
55
55
|
- **`redirects.file`, `redirects.json`, every `redirects.files[]`, `feed` and every `broken[].page` go through `real()`, and `removed`/`collisions`/`moved`/`rules` do not.** The first three are files this build wrote, so under `'atomic'` and under check they were written into the staging sibling and have to be named against the folder the site asked for — the same rule as `sitemap` and `llms`, and what keeps `kiss-staging` out of every committed `last-build.json`. `removed`, `collisions` and `moved`'s `from`/`to` are derived from build-relative paths and a previous record — and reduced to served URLs by `lib/redirects.js` before they get here — so there is no staging prefix on them to map. `rules` is URL paths on both sides for the same reason.
|
package/AIKB/check.md
CHANGED
|
@@ -25,6 +25,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
25
25
|
- `exitCodeFor(reports, scriptStatus)` → `0` only when the script exited `0`, at least one build was reported, and every report is `ok`. Everything else is `1`.
|
|
26
26
|
|
|
27
27
|
- `describeEngine({ cwd, from = cwd })` → `{ version, dir, linked, target } | null` — the nearest `node_modules/kiss-ssg` walking up from `from`, which the bin sets to the **script's** folder because that is where Node resolves its imports (a caller standing elsewhere would otherwise be told about its own copy — Codex, on review), with `dir` relative to `cwd`: its `package.json` version, its path relative to `cwd`, whether `lstat` says it is a symbolic link (a Windows junction counts), and the link's real target when it is. `null` when there is none above `cwd`.
|
|
28
|
+
- `isWorkingTreeLink(engine)` → whether that engine is a link to a working tree: a link whose target is outside any `node_modules`. The one statement of the rule: `engineLine` warns on it, and `lib/upgrade.js` refuses to start from it and fails an install that leaves it.
|
|
28
29
|
- `engineLine(engine)` → the line the bin prints on stderr before the build, in both output modes: `kiss-ssg 2.5.0 from node_modules/kiss-ssg`, or `… — a link to C:/Code/kiss-ssg, not the registry package` for a target outside any `node_modules` (a working tree), or the neutral `… — a link into <target>` for one inside (pnpm lays every package out as a link into `node_modules/.pnpm/…`, and that is the registry package — Codex, on the close's review), or the honest `kiss-ssg: no node_modules/kiss-ssg found from here — the script resolves the package some other way`.
|
|
29
30
|
|
|
30
31
|
## Depends on
|
|
@@ -33,7 +34,7 @@ Two commands share all of it. `check <script>` runs the site's build staged and
|
|
|
33
34
|
|
|
34
35
|
## Depended on by
|
|
35
36
|
|
|
36
|
-
`bin/kiss-ssg.js
|
|
37
|
+
`bin/kiss-ssg.js`; `lib/upgrade.js` (`isWorkingTreeLink`). And the engine, for the other half of the protocol the bin starts:
|
|
37
38
|
`lib/kiss.js` reads `checkModeRequested()` in its constructor, and `lib/build-finish.js` reads
|
|
38
39
|
`aikbRecordRequested()` and `readReportsFile()` (the redirects baseline, read by the same code
|
|
39
40
|
`check --against` uses).
|
package/AIKB/feed.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Builds an RSS 2.0 feed from the page stack and writes it to the build folder. The third file derived from the registry, beside `sitemap.js` and `llms.js`: same pages, same titles, same URL derivation, a different reader — a feed client rather than a crawler or an
|
|
5
|
+
Builds an RSS 2.0 feed from the page stack and writes it to the build folder. The third file derived from the registry, beside `sitemap.js` and `llms.js`: same pages, same titles, same URL derivation, a different reader — a feed client rather than a crawler or an agent.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ Builds an RSS 2.0 feed from the page stack and writes it to the build folder. Th
|
|
|
18
18
|
|
|
19
19
|
## Depended on by
|
|
20
20
|
|
|
21
|
-
`lib/kiss.js` (`Kiss.feed()`, and `feedFileName` for the `_feedPath` it records for the build report). `lib/sitemap.js` and `lib/llms.js` are siblings rather than consumers: all three read the same stack and make the same `buildTo` → `toCanonicalPath` → `toAbsoluteUrl` join, and `ignoreSitemap` excludes from all three.
|
|
21
|
+
`lib/kiss.js` (`Kiss.feed()`, and `feedFileName` for the `_feedPath` it records for the build report). `lib/sitemap.js` (`rawDate` and `toDate`, for a page's `<lastmod>`, so a date is read by one rule in both files). `lib/sitemap.js` and `lib/llms.js` are otherwise siblings rather than consumers: all three read the same stack and make the same `buildTo` → `toCanonicalPath` → `toAbsoluteUrl` join, and `ignoreSitemap` excludes from all three.
|
|
22
22
|
|
|
23
23
|
## Non-obvious behavior
|
|
24
24
|
|
package/AIKB/git.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# git.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
The git questions kiss's commands ask, asked one way: run git and get its output or a one-line refusal, and say whether a branch or tag exists by its exact name. `lib/sessions.js` and `lib/base.js` are built on it, and this repository's own `scripts/base-branch.mjs` imports `branchRefs` and `refExists` from it, so a rule about refs exists once.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `git(args, { cwd, input })` — stdout as text, or throws `GitError` with the first line git wrote to stderr.
|
|
10
|
+
- `tryGit(args, cwd)` — trimmed stdout, or `null` when git refused: for a question whose "no" is an answer.
|
|
11
|
+
- `refExists(full, cwd, run = tryGit)` — whether a ref exists, by its full name.
|
|
12
|
+
- `branchRefs(name, exists)` — `[{ full, short }]` for `refs/heads/<name>` and `refs/remotes/origin/<name>`, whichever exist, local first.
|
|
13
|
+
- `class GitError` — git refused.
|
|
14
|
+
|
|
15
|
+
## Depends on
|
|
16
|
+
|
|
17
|
+
`node:child_process` only.
|
|
18
|
+
|
|
19
|
+
## Depended on by
|
|
20
|
+
|
|
21
|
+
`lib/sessions.js`, `lib/base.js`, and the repository's dev tooling `scripts/base-branch.mjs` (which passes its own cached runner as `run`).
|
|
22
|
+
|
|
23
|
+
## Non-obvious behavior
|
|
24
|
+
|
|
25
|
+
- **`refExists` asks `git show-ref --verify`, never `rev-parse`.** `rev-parse` resolves a revision, so it reads `refs/tags/v2~1` as the tag `v2` and `refs/heads/v2~1` as the branch `v2`. That misread an override and named a revision as a branch, one code review apart (2026-10-31, rounds 16 and 17).
|
|
26
|
+
- **Branches are named in full** (`refs/heads/…`, `refs/remotes/origin/…`) because a short name resolves a tag before a branch, and this repository's tags `v1` and `v2` share the integration branches' names. Every short-name use of a base in the branch skills went wrong at least once during the 2026-10-31 reviews.
|
|
27
|
+
- **A refusal is one line.** A skill reading a command's output cannot tell a stack trace from "nothing found", so every caller reports `GitError`'s message, never the error itself.
|
|
28
|
+
- Why this module exists at all: the same rules had been written as shell in four skills and as JavaScript in two scripts, and each review found a case one copy missed. The root cause analysis is in `planning/sessions/2026-10-30-branch-merged-and-privacy.md`.
|
|
29
|
+
|
|
30
|
+
## Types
|
|
31
|
+
|
|
32
|
+
`GitError extends Error`. `branchRefs` returns `{ full: string, short: string }[]`.
|
|
@@ -12,13 +12,13 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
12
12
|
- `offset(index)` — `index + 1`.
|
|
13
13
|
- `stringify(obj)` — `JSON.stringify(obj, null, 3)`.
|
|
14
14
|
- `lookup(obj, field, options)` — same signature and return as Handlebars' built-in (falsy `obj` returned as-is, otherwise `options.lookupProperty(obj, field)`). An `undefined` result additionally logs one warning: `lookup: '<field>' is undefined` plus ` in <view>` when the root context carries a `view`.
|
|
15
|
-
- `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href.
|
|
15
|
+
- `isActive(pageOptions, options)` — block helper; renders the block with `context.active` set to the configured active class (`options.hash.active`, default `'active'`) when `pageOptions.pageURL` matches `options.hash.href`. Exact match by default; `folderMatch=true` also matches pages below the href. The block also gets private data, `@current`: `true` only on the exact match, whatever `folderMatch` says, and only when the hash has an `href` (not `null` or `undefined`; a `SafeString` from a helper counts, round 20): a missing one is coalesced to `''`, the home page's key, which put `aria-current` on a nav item with no `href` on the home page (round 19). From round 21, `active` follows the same rule: a missing `href` matched the home page under `folderMatch`, so a nav item with no `href` (a label, a separator) got the class there while `@current` said no. A missing `href` now names no page for both; an explicit `href=""` is the home page.
|
|
16
16
|
- `canonical(options)` — the current page's absolute URL (any leading argument is ignored; the options object is taken from the end of the argument list, so `{{canonical}}` and `{{canonical this}}` behave alike): the page's own `siteUrl` joined to `toCanonicalPath(root.pageURL)` through `toAbsoluteUrl` (both from `utils.js`). A page built to a file is the bare URL (`courses/bronze.html` → `https://site/courses/bronze`); a directory index keeps a trailing slash (`courses/index.html` → `https://site/courses/`). Renders `''` and warns when there is no `siteUrl`, or when the root context carries no `pageURL`. **A page's own `canonical` option wins outright**: when the root context carries a non-empty `canonical` string it is rendered verbatim, before `siteUrl` or `pageURL` are consulted — the page has named another URL as the real one (learna-kiss: 104 course pages whose canonical is the same course on diploma-msc.com). The value is validated at registration (`Kiss._preparePage`, `AIKB/kiss.md`), not here, because the sitemap, `llms.txt` and the feed read the same option and withdraw the page.
|
|
17
17
|
- `absUrl(urlPath, options)` — `siteUrl` joined to any site-relative path through `toAbsoluteUrl`: a bare path stays bare (`/courses` → `https://site/courses`), an explicit trailing slash is preserved (`/courses/` → `https://site/courses/`), a trailing `index` segment becomes one (`/courses/index.html` → `https://site/courses/`), and a file extension is kept (`css/site.css`). A path already carrying a scheme (`https://…`) is returned unchanged, with or without a `siteUrl`. Called with no argument it is `canonical`. A non-string path logs a warning and renders `''`.
|
|
18
18
|
- `asset(urlPath, options)` — the path of an emitted asset, resolved through the manifest: `css/site.a1b2c3d4.css` under `config.assets.hash`, `css/site.css?v=1.2.3` under `config.assets.version`, and the plain `css/site.css` under neither. Site-relative with **no** leading slash, so the template supplies the base (`/{{asset …}}`, `{{root}}{{asset …}}`, `{{absUrl (asset …)}}`); a leading slash on the argument is optional and is stripped. A reference that is not a path into this build is returned unchanged: any scheme (`https:`, and also `data:`/`mailto:`, which carry no `//`) and the protocol-relative `//cdn/x.css`. The original test required `://`, so the last two fell through to a manifest lookup they could never satisfy — harmless while the helper warned, a failed build once it threw. A `?query`/`#fragment` is split off before the lookup and re-appended after, because the manifest is keyed by the path a copy emitted: `img/logo.svg#symbol` is an SVG sprite reference to a file that IS in the build, and looking the whole string up failed the build while telling the author the file was missing. `lib/links.js` splits references the same way, for the same reason. The failure message names the reference **as the template wrote it**, and the key underneath it when the two differ (`asset: '/css/x.css#a' … no .copyAssets() emitted 'css/x.css'`, `emitted it` when they are the same string); it named the key alone until an independent review pointed out that the key — slash stripped, suffix gone — is a string the author never typed and cannot search for, while the dev warning had always named the reference. A path the manifest does not know **fails the build**, the same shape as `link` on an id no page claims — they are the two halves of "never hand-write an internal URL" and were checked to different depths until R25. Warning and rendering meant the build passed, `report().ok` stayed true and the site shipped a 404; it also stripped the leading slash first, so `{{asset "/missing.css"}}` became `missing.css`, which on a nested page is a different URL. In **dev** it warns once per page per path (deduped on the page's root data frame, so a shared layout does not warn per page render) and renders the path **as written** rather than the stripped form, so the browser's 404 names what the template asked for. A non-string path logs a warning and renders `''`.
|
|
19
|
-
- `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/
|
|
19
|
+
- `link(id, options)` — the served path of the page whose identity is `id` (`AIKB/kiss.md` § identity): `servedPathFor(entry.page.pageURL())` from `utils.js`, root-relative, so `/`, `/about.html`, `/courses/`, `/journal/the-cascara-experiment/`. `slug=` is sugar for the joined id (`{{link "blog/post" slug=post.slug}}` → `blog/post/<toSlug(slug)>`), normalised the same way the default id was. `absolute=true` string-joins `siteUrl`'s origin and path prefix to that served path (never `toAbsoluteUrl` over the page URL); `canonical=true` is `toAbsoluteUrl('', toCanonicalPath(pageURL))`, the pretty form the sitemap, the feed and `{{canonical}}` emit. `canonical` decides the shape of the path and `absolute` whether it carries the origin, so the two **compose** — `toAbsoluteUrl(siteUrl, toCanonicalPath(pageURL))` when both are set, which is what `{{canonical}}` itself emits. (Before `links.canonical` existed, `canonical=true` short-circuited and silently ignored `absolute=true`.) The default for `canonical` is `config.links.canonical`, and the per-call hash always wins over it, `canonical=false` included. **The one helper that fails the page**: an unknown id, a withdrawn default id, a `generate: false` page's id and a call with no id all throw `link: no page with id "<id>" (asked by <view> / <buildTo>)` (or the withdrawn message naming the colliding views) in a real build, and under `config.dev` warn with that same text and render `#`. No `siteUrl` under `absolute=true` is the one degrade: one warning (`siteUrlFor`'s) and the root-relative form. **On a page of a learning package** (the asking page's root context carries a string `package` and `config.packages` is not `false`) the link is `relativePathFor(asker.pageURL, pageURL)` instead: the file, relative to the page, with no collapse, canonical shape or trailing-slash policy, because an LMS serves a package's files as they are from wherever it unpacks them. `relative=` on the call decides it either way for any page; `absolute=true` always wins (code review, 2026-10-25).
|
|
20
20
|
- `env(options)` — block helper gated on `options.hash.is` (`'dev'`/`'prod'`, case-insensitive substring match) against `config.dev`; renders `options.fn(this)` or `options.inverse(this)`. A missing or non-string `is` is an error: it logs and returns `''` (neither branch).
|
|
21
|
-
- `scormBridge(options)` — the SCORM 1.2 bridge as one inline `<script>`, from `scormBridgeScript()` (`AIKB/scorm-bridge.md`). `complete="
|
|
21
|
+
- `scormBridge(options)` — the SCORM 1.2 bridge as one inline `<script>`, from `scormBridgeScript()` (`AIKB/scorm-bridge.md`). `complete="manual"` (the default) leaves completion to `kissScorm.status()`; `complete="seen"` marks the page complete when it loads in an LMS, which in Moodle opens every later launch in review mode, where nothing is saved (`AIKB/upstream.md`). The default was `seen` until 2026-10-26 (`CHANGELOG.md`). Any other value is an error: it logs and returns `''`, so a typo never ships a page that tracks differently from what the author asked.
|
|
22
22
|
|
|
23
23
|
## Depends on
|
|
24
24
|
|
|
@@ -37,8 +37,9 @@ Registers `kiss-ssg`'s built-in Handlebars helpers (`markdown`, `sass`, `offset`
|
|
|
37
37
|
- **KNOWN ISSUE, not yet addressed: `{{sass '<path>'}}` is an unconstrained build-time file read, and `lib/html-split.js` made it reachable from a document somebody else wrote.** The helper hands any path it is given to the sass compiler and inlines the result, so `{{sass '/etc/…'}}` in a template reads a file from outside the site and puts it in the built page — anything Dart Sass can parse. Until this branch that was unremarkable: a template is code the site's author wrote, and an author who can write a template can already read their own disk. `splitDocument` changed the premise by turning a **foreign** document into compiled `.hbs`, which is why that module escapes `{{` by default (`AIKB/html-split.md`) — measured closing this path along with a config dump and an attribute exfiltration. The escape is a guard at the one door the branch opened, not a fix: the helper is still unconstrained for any other caller, and a site that opts out with `escapeExpressions: false` on input it did not write re-opens it. The proper fix — confining file mode to the site's own folders, or requiring an explicit allow-list — is a change to the helper's contract and belongs on its own branch. Judged out of scope here by the operator on 2026-10-01, recorded rather than left implicit. Re-check by reading `sassHelper`'s file branch in `lib/handlebars-helpers.js`: while it still calls `path.isAbsolute(context)` and compiles the result with no containment check, this entry is current.
|
|
38
38
|
- The `sass` helper's file mode resolves a _relative_ path against `process.cwd()`, not against the view or the assets folder; an **absolute** path is passed to `sass.compile` untouched (`path.isAbsolute(context)` — an unconditional `path.join(process.cwd(), context)` mangled a path a controller had resolved into `<cwd>/abs/path/main.scss` and failed the build, review finding C7). Block mode compiles the block content directly with `sass.compileString` (no `process.cwd()` involved).
|
|
39
39
|
- `isActive` compares **normalised keys**, not raw strings: `toURLKey()` (in `utils.js` — page identity, and nothing emitted is built from it) drops leading and trailing slashes, the file extension and a trailing `index` segment from both `pageOptions.pageURL` and `href`, so the home page is `''` and `/about`, `/about/` and `about/index.html` are one key. That is what makes a nav `href` survive an `extensionLess` flip — with a raw comparison, `href="/about"` matched only with the flag off and `href="/about/"` only with it on (review finding D-03). The key is exposed to the block as `{{pageURL}}`.
|
|
40
|
+
- **`@current` exists because `aria-current="page"` and a section's mark are different questions.** A nav item with `folderMatch` stays `active` on every page inside its section, but `aria-current="page"` names only the page itself, so every shipped nav called `isActive` twice per link, once with `folderMatch` for the class and once without it for the attribute, ten example layouts and the docs header alike (code review, 2026-10-31, round 9). The exact comparison was already computed before the folder fallback, so the block gets it, and one call answers both. **It is private data (`hbs.createFrame`, read as `{{@current}}`), not a context key, and that is the lesson of four review rounds.** As a key, `current`, it hid a site's own `current` (a nav item's field), which needed a warning (round 10); the warning then fired on a nested `isActive`, which needed a mark on the helper's own contexts (round 11); a partial called with hash parameters copies the context and lost the mark, which needed a check of the context's shape (round 12); and a `SafeString` `active` defeated the shape (round 14). Private data cannot collide with a key: it reaches nested blocks and partials, and stops at the block. Covered through a real build in `test/integration/is-active.test.js`, and in `test/unit/handlebars-helpers.test.js` beside a site key called `current`.
|
|
40
41
|
- The match is by **path segment**: active iff `pageKey === hrefKey` or, under `folderMatch=true`, `pageKey.startsWith(hrefKey + '/')`. It was a bare `pageURL.includes(href)`, which lit `/blog` on `my-blog-post.html` and — because `''.includes()` is always true — lit every item of a nav that omitted `href` (D-02). An empty `href` under `folderMatch` now matches the home page only, which falls out of the same rule.
|
|
41
|
-
- Helpers **degrade rather than throw**: a page dropped from the build is a worse answer to a template mistake than a missing highlight. `isActive` coalesces (`hash.href ?? ''`) instead of spreading over a default, because `{{#isActive page href=href}}` on a model with no `href` key passes an explicit `undefined` that wins a spread; a missing or non-string `pageURL` logs a warning and renders the block as not-active; and when the page argument is omitted altogether Handlebars passes its own options object as the first parameter, so the block is located by shape (`options ?? pageOptions`) rather than by position (D-05). `markdown` gates its block branch on `typeof obj.fn === 'function'` rather than `typeof obj === 'object'` so a `null`, object or array model field takes the warn-and-render-nothing path, and `env` requires `is` to be a string before calling `.toLowerCase()` (D-04).
|
|
42
|
+
- Helpers **degrade rather than throw**: a page dropped from the build is a worse answer to a template mistake than a missing highlight. `isActive` coalesces (`hash.href ?? ''`) instead of spreading over a default, and then marks nothing for a missing `href` (round 21), because `{{#isActive page href=href}}` on a model with no `href` key passes an explicit `undefined` that wins a spread; a missing or non-string `pageURL` logs a warning and renders the block as not-active; and when the page argument is omitted altogether Handlebars passes its own options object as the first parameter, so the block is located by shape (`options ?? pageOptions`) rather than by position (D-05). `markdown` gates its block branch on `typeof obj.fn === 'function'` rather than `typeof obj === 'object'` so a `null`, object or array model field takes the warn-and-render-nothing path, and `env` requires `is` to be a string before calling `.toLowerCase()` (D-04).
|
|
42
43
|
- `env` reads `config.dev` at _render_ time (helpers close over the `config` object, not a snapshot), so `{{#env is="dev"}}`/`{{#env is="prod"}}` blocks reflect the live config.
|
|
43
44
|
- The sass compiler is imported from `./sass.js`, never from `sass` directly: which export carries the modern API depends on the installed sass version, and that detection lives in one place (see `AIKB/sass.md`).
|
|
44
45
|
- `lookup` **shadows the Handlebars built-in on purpose**, and its only difference is the log line. A dynamic partial — `{{> (lookup . 'moodleAccess')}}` — whose key is absent from the data fails as `The partial undefined could not be found`, which names neither the key nor the page: diagnosing 40 such pages in a consumer site needed a hand-written probe helper (review finding G-2). The helper warns and then returns `undefined` anyway, so the render fails exactly as before; the fix is a name in the log, not a change of behaviour.
|
package/AIKB/init.md
CHANGED
|
@@ -11,7 +11,8 @@ It exists so that a new user reaches a site through a coding agent — Claude Co
|
|
|
11
11
|
- `INIT_HELP` — the usage text `init --help` and a usage error print.
|
|
12
12
|
- `parseInitArgs(argv)` → `{ install, help, error }`. `--no-install` clears `install`; `--help`/`-h` sets `help`; anything else — including a script name, which `check` would take — is a usage `error`.
|
|
13
13
|
- `packageName(folderName)` → a valid npm package name for a new `package.json`: lower case, runs of anything npm refuses collapsed to one hyphen, no leading `.`/`_`/`-`; `kiss-site` when nothing is left (`..`).
|
|
14
|
-
- `planInit(state)` → `InitAction[]`, in order: `package.json`, the starter files (or one `skip` for `router.js`), `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, then the install (or a `skip` for `node_modules/kiss-ssg` when `package.json` already asks for a version or under `--no-install`, or nothing at all when the engine is installed). An action is `{ kind: 'write', path, content, verb: 'create'|'merge'|'append', detail? }`, `{ kind: 'skip', path, reason }` or `{ kind: 'install', spec }`. `state` is `InitState`: the folder name, the running version, `install`, `hasEngine`/`hasRouter`/`hasSrc`, the current text (or `null`) of whether `package.json`'s `main` names a real file (`hasMain`), the current text (or `null`) of the six files it may touch (`package.json`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, `.gitignore`), and the starter as `{ relativePath: text }`.
|
|
14
|
+
- `planInit(state)` → `InitAction[]`, in order: `package.json`, the starter files (or one `skip` for `router.js`), `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, then the install (or a `skip` for `node_modules/kiss-ssg` when `package.json` already asks for a version or under `--no-install`, or nothing at all when the engine is installed). An action is `{ kind: 'write', path, content, verb: 'create'|'merge'|'append', detail? }`, `{ kind: 'skip', path, reason }` or `{ kind: 'install', spec }`. `state` is `InitState`: the folder name, the running version, `install`, `hasEngine`/`hasRouter`/`hasSrc`/`hasServer`, `routerIsModule` (from `isModuleSyntax`, below), `entry` (the file Node resolves the folder to as a package, or `null`), `otherScript` (the first other `.js` that parses as CommonJS, or `null`), the current text (or `null`) of whether `package.json`'s `main` names a real file (`hasMain`), the current text (or `null`) of the six files it may touch (`package.json`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`, `.codex/config.toml`, `.gitignore`), and the starter as `{ relativePath: text }`.
|
|
15
|
+
- `isModuleSyntax(text, parses)` → `true` when `parses(text, 'module')` and not `parses(text, 'commonjs')`. The bin passes Node's `--check` as `parses`; see Non-obvious behavior.
|
|
15
16
|
- `describeAction(action)` → the printed line: ` create router.js`, ` merge package.json (added scripts.dev; kept scripts.build)`, ` skip CLAUDE.md — already points at llms.txt`, ` install kiss-ssg@2.7.0`.
|
|
16
17
|
- `nextSteps()` → the closing text: the Claude Code block (`CLAUDE_STEPS`, then "Run \`claude\` in this folder."), the Codex block (`CODEX_STEPS`, said to install once per user, then "Run \`codex\` in this folder."), then paste `FIRST_PROMPT` to start a site or `IMPORT_PROMPT` to turn one the person already has — most often a link to a Claude or ChatGPT site — into a kiss one. Only the first was offered until 2026-10-02, so the person arriving with a site was told how to describe it. The commands come first because Claude Code does not offer to install plugins a project's `.claude/settings.json` declares (observed 2026-09-26 on a clean profile) — the declaration records which skills the site uses; it installs nothing on its own.
|
|
17
18
|
- `CLAUDE_STEPS` / `CODEX_STEPS` (`string[]`) — each agent's three plugin lines and nothing else (no `mkdir`, no `init`, no bare `claude`/`codex`): `claude plugin marketplace add cprobert/kiss-ssg --scope project` plus a `claude plugin install <plugin> --scope project` per plugin, and `codex plugin marketplace add cprobert/kiss-ssg` plus a `codex plugin add <plugin>` per plugin. Both are derived from `MARKETPLACE_REPO` and `PLUGINS`. They are the one source the README's two Quick start blocks, the docs homepage's Claude Code and Codex boxes and `llms.txt` are tested against.
|
|
@@ -31,8 +32,14 @@ Nothing. It is pure: text in, actions out. The bin supplies the folder's state a
|
|
|
31
32
|
- **The Codex review of this branch found the promise broken in five places, all fixed test-first:** an existing `.gitignore` was replaced by the starter's (now `planGitignore` appends only the missing lines); a site's own `kiss-ssg` range was replaced by the running version (now `declaredRange` skips the install and says to run `npm install`); a `scripts` that was not an object was spread into character keys (now skipped); a `null` marketplace entry was overwritten (now `in`); and `"type": "module"` plus `router.js` scripts were added to sites `init` did not write (now only with the starter, and never without a `router.js`).
|
|
32
33
|
- **Nothing is ever overwritten.** `package.json` and `.claude/settings.json` are merged key by key — an existing key is kept and named in the action's `detail` ("kept scripts.build") — and a file that is not a JSON object is skipped with a reason rather than replaced. `CLAUDE.md`/`AGENTS.md` are appended to only when they lack something, on a line of their own even when the file has no trailing newline, in the file's own line ending.
|
|
33
34
|
- **`AGENTS.md` has two parts, checked one at a time** (`planPointer`'s `PointerPart[]`). The pointer is marked by `node_modules/kiss-ssg/llms.txt`; the skills-and-Codex section by its first line, `codex plugin marketplace add cprobert/kiss-ssg` (`CODEX_STEPS[0]`). A fresh file gets both. An existing one gets each part it lacks appended, in order, and is skipped when it has both. This matters because every `AGENTS.md` an `init` wrote before 2.8.0 holds the pointer and nothing else, and the old rule — skip anything that mentions `llms.txt` — would never have added the Codex section to it; the operator's decision (2026-10-02) was to append if missing. Existing text is never rewritten, so a site that edited the section keeps its edit as long as the marker line survives. `CLAUDE.md` uses the same function with its one part.
|
|
34
|
-
- **The
|
|
35
|
-
- **
|
|
35
|
+
- **The skills section names every skill by hand, and a test holds it to the plugins.** The plugins ship from the marketplace, not the npm package, so `lib/init.js` cannot read their folders and `AGENTS_CODEX` lists the skills as text. That list missed the six domain skills and `kiss-branch-merged` for a release (found 2026-10-31), so a Codex agent in a site `init` set up never heard of them. `test/unit/init.test.js` now reads `plugins/*/skills/` and fails on any skill the text leaves out. A site `init` wrote earlier keeps its old list, because existing text is never rewritten (above).
|
|
36
|
+
- **The module type and `main` are the starter's, not the site's — with one exception.** `"type": "module"` and `"main": "router.js"` are added only when this run writes the starter; a site that already has a `router.js` gets the scripts and nothing else, because switching its module type would change how Node reads a file `init` did not write. When it does write the starter, `npm init -y`'s boilerplate is replaced, with a note saying so: `"type": "commonjs"` becomes `"module"` (the starter's `router.js` uses `import`), and a `main` naming a file that does not exist (`"index.js"`) becomes `"router.js"`. Both were found by clean rooms that ran `npm init -y` first (2026-10-02). Starting implies no `main` names a real file, since `existingSite` would otherwise have found a project, so neither replacement can override a choice the site made. The exception (2026-10-26): an explicit `"type": "commonjs"` beside a `router.js` written as an ES module is changed to `"module"`, and the detail says so. Node refuses that router's first `import` (exit 1, measured on Node 24.22), so the site has never built and nothing that works depends on the value; it is `npm init -y`'s default met beside a router written first (the webapp-testing trial, 2026-10-13). `isModuleSyntax(text, parses)` is the rule: the file parses as a module and does not parse as CommonJS. A dynamic `import()` is valid CommonJS, and a file that fails as a module too is broken whatever the type says, so changing the type would only change how every other `.js` file in the folder is read. The parser is injected so this module stays pure: the bin passes `node --input-type=<type> --check` over stdin (never running the file, and never letting the folder's own `"type"` decide), and sets `routerIsModule` from it. A first version matched the error messages `vm.compileFunction` throws for module-only syntax. Codex found it called `import …` followed by a syntax error a module, because CommonJS reports the import's error first; `test/integration/init.test.js` pins that case through the bin. A `router.js` with no `"type"` at all is left alone, because Node detects its syntax and runs it.
|
|
37
|
+
- **A replaced value is written even when nothing is added.** `planPackageJson` keeps `changed` apart from `added` and `notes`, and a merge with nothing added but something changed is still a `write`. Before 2026-10-26 it returned `skip` whenever no key was added, which silently threw away a type or `main` change it had already planned and reported — on the starter path and on the site path's second run alike.
|
|
38
|
+
- **The type is the whole package's, so it moves only when the package runs nothing but `router.js` (`runsOnlyRouter`).** This holds for both type changes above, and for the starter adding `"type"` where there was none. The test is an allow-list of the shapes known to be safe: no `bin`, `directories` (whose `bin` makes every file in it an npm executable), `exports` or `workspaces`; no package entry but `router.js` (`entry`: the bin asks Node itself, `createRequire(…).resolve(folder)`, so `main` with its extensions and index files and the default `index.js` all count, and nothing is loaded); no `server.js` (`hasServer`), which npm's default `start` runs when no start script is declared — the one implicit npm script that runs the package's JavaScript (the other, `install` with a `binding.gyp`, builds native code); and every script is one of ours, as we write it, or npm init's placeholder `test`. Anything else keeps the type, with a note to set `"module"` by hand or rename the router `router.mjs`. It began as a deny-list of `main` and `build`, and Codex's adversarial review broke it five times (2026-10-26): a working CommonJS `server.js` behind `main`/`build`, then behind a `start` script, then behind npm's implicit `start`, stopped running once the package said `"module"`; the fourth round found Node's entry resolution (a literal `main` check missed `index.js`, `main: "lib"`) and `directories.bin`. The guard is now checked against what npm and Node document — the package.json fields npm executes, npm's implicit scripts, Node's package entry resolution — rather than grown one finding at a time. The fifth round showed the limit of reading package.json at all: `node --test` finds `test/*.js` with nothing naming it. So the files are asked too (`otherScript`): the bin walks every `.js` outside `node_modules` and `.git`, skipping the walk when the package already says `"module"`, and reports the first besides `router.js` that parses as CommonJS — or the first thing it cannot vouch for: a link, whose target it does not follow, or a folder or file it cannot read. The walk first skipped every dot-folder and silently passed over links, because `readdir` reports a link as neither file nor folder; Codex's sixth round put a CommonJS script behind both (2026-10-26), and `.github/scripts/` is where real ones live. The seventh round widened which files are read, to every kind Node applies `"type"` to: `.js`; an extensionless entry (`node tools/smoke`), read as a script only when it starts with `#!` or mentions `require(`/`module.exports`, because `CNAME`'s `www.example.com` parses as JavaScript too, and dotfiles (`.nvmrc`'s `22`) never are; and `.ts`, which Node 24 runs with its types stripped, blocking outright because there is no cheap parse. The test is now "parses as CommonJS", so a file broken either way no longer blocks. **That round was the last one chased (the operator's call): anything a later review finds here is recorded as known risk, not fixed one finding at a time.** The printed note already says every other file is now read as an ES module. The final round found the guard's own gap, which was fixed: a starter `package.json` _created_ from nothing still wrote `"type": "module"` and `"main": "router.js"` without asking either layer. It now asks `runsOnlyRouter({}, state)` and leaves both unset, with a detail, when the folder runs other files. `main` is held back as well, because beside an existing `index.js` it would change what `require('./')` loads.
|
|
39
|
+
|
|
40
|
+
**Known risk, recorded rather than fixed (2026-10-26, Codex final round, rated low likelihood):** an unreadable `router.js` — a permissions failure, or a folder named `router.js` — throws from the bin's `read()` before the walk can treat it as a blocker, and `init` exits with an uncaught error. `CLAUDE.md`, `AGENTS.md` and the other files the bin reads behave the same way, so this is the bin's existing read contract rather than something new to `router.js`. Re-check by making `router.js` a folder and running `node bin/kiss-ssg.js init --no-install` there. A file that only parses as an ES module could never have run under `"commonjs"`, so it never blocks: kiss helpers written with `export` still let the change through. Anything ambiguous (CommonJS, or a plain script valid either way) blocks it, conservatively, and so does a browser script under `src/assets/`. Each file costs two `node --check` runs, and the walk stops at the first blocker. The starter path had the same gap from 2026-10-02 (a folder with no `router.js` but a `start` script was given the starter and a package-wide `"module"`). Neither the guard nor the type change protects a CommonJS helper `.js` that the router itself imports. Under `"commonjs"` that router never ran, so the change does not break a working build, but it moves the failure to the helper.
|
|
41
|
+
|
|
42
|
+
- **A kept `"type"` other than `"module"` is reported, not fixed — except `"commonjs"` when the starter is being written, or beside a site's ES-module `router.js` (the bullet above).** The starter's `router.js` uses `import`, so the detail says to set `"type": "module"` or rename it `router.mjs`; changing a site's module type behind its back could break everything else it runs. The exception (2026-10-02): npm 11's `npm init -y` writes `"type": "commonjs"` by default, and the starter is only written into a folder with no code, so that value is npm's boilerplate rather than the site's choice. Keeping it made the starter crash on its first `import` for anyone who ran `npm init -y` first — the clean-room import run hit exactly that. It is changed to `"module"`, and the detail says so.
|
|
36
43
|
- **`.codex/config.toml` is appended to by table, with no TOML parser** (`planCodexConfig`). It is the counterpart of `.claude/settings.json`, in the form Codex writes itself: `[marketplaces.kiss-ssg]` (`source_type = "git"`, `source = "https://github.com/cprobert/kiss-ssg.git"`) and one `[plugins."<plugin>"]` table with `enabled = true` per plugin. An absent file is written whole; an existing one gains each table it does not define, in its own line ending, and is skipped when it has all three. A table already there is the site's whatever it says, so `enabled = false` stays. TOML forbids defining a table twice, so a duplicate makes the whole file unloadable: headers are compared by meaning, not spelling (`tomlHeaderKey`: whitespace, quote style and a trailing comment do not matter, so `[ plugins . 'kiss-ssg@kiss-ssg' ] # off` is recognised), and the rest is an **allow-list**: a line that mentions `plugins` or `marketplaces` is safe only as a plain header under it (`[plugins]`, `[plugins."…"]`, `[marketplaces.…]`), and a file with any other such line (an inline `plugins = {}`, an array of tables `[[plugins]]`, a dotted or quoted key, a `[marketplaces]` table, or an inline entry naming a kiss plugin) is **left exactly as it is**. It was a deny-list until two review rounds in a row each found a shape it missed (an inline parent table, then `[[plugins]]`): every such shape stops `plugins` being a plain table, and a plain table is the only thing an appended header can extend. The cost is conservative: a string value that merely contains the word (a path with `\plugins\` in it) also leaves the file alone, which costs a note, never a broken file, skipped with "add the rest by hand", because appending beside a shape the check cannot read could define it twice. The first version compared exact trimmed lines; Codex's review (2026-10-02) found that a valid header with a trailing comment slipped past it, and `init` appended a duplicate. The file only _enables_: Codex reads it once the folder is trusted, and the plugins must still be installed per user with `CODEX_STEPS` (measured 2026-10-02, `AIKB/upstream.md`).
|
|
37
44
|
- **A plugin the user switched off stays off.** The merge tests `plugin in enabledPlugins`, not truthiness, so `"kiss-memory@kiss-ssg": false` survives.
|
|
38
45
|
- **The starter is all or nothing, and kiss's shape is not the only sign of a site.** It is written only when `existingSite` finds nothing: no `router.js`, no `src/`, no `package.json` `main` naming a real file, and no `build` script other than ours — the final review found a CommonJS app with `main: index.js` being given `"type": "module"`. `npm init -y` names an `index.js` it never writes, so a folder it has just made still gets the starter. Otherwise one `skip` line names what it found. That is what makes `init` safe to run in an existing site.
|
package/AIKB/links.md
CHANGED
|
@@ -13,7 +13,7 @@ The finding is **advisory**. It never moves `ok`, never moves an exit code, and
|
|
|
13
13
|
- `classifyReference(ref, { siteUrl })` → `{ kind: 'external' | 'internal', path: string|null }`. Reads `ref` through `browserReference` first. Strips the query and the fragment; an absolute URL whose **origin equals `siteUrl`'s** is internal (with `siteUrl`'s path prefix stripped too); any other scheme, and a protocol-relative `//host/x`, is external; anything else is internal with `path` kept as written. `path` is `null` for everything external.
|
|
14
14
|
- `resolveReference(ref, { pageBuildTo, buildDir, extensionLess, exists, known })` → `boolean`. Resolves root-relative against `buildDir` and relative against the page's own directory, `..` included, then accepts the path itself, `<path>/index.html` for a trailing slash, `<path>.html` or `<path>/index.html` for an extension-less path — each either on disk (`exists`) or in `known`. A path that escapes the build folder is always broken.
|
|
15
15
|
- `resolveTarget(ref, { pageBuildTo, buildDir, extensionLess, exists, known })` → `string|null`. `resolveReference`'s answer with its working shown: the build-relative candidate that matched, or `null`. `resolveReference` is `resolveTarget(…) !== null`, so the two cannot disagree. Used by `lib/audit.js` to name the page a cross-page `#fragment` lands on.
|
|
16
|
-
- `checkLinks({ pages, buildDir, siteUrl, extensionLess, manifestTargets, hostServed, exists })` → `{ checked, hostServed, broken: [{ page, href }] }`. A reference that resolves to nothing in the build is tested against `hostServed` (`config.links.hostServed`) as the path a browser would request — `requestPath()` resolves the reference **as written** with WHATWG `URL` against the page's own URL (`siteUrl`'s origin and path prefix, or `http://kiss.invalid/` with none, plus the page's build-relative path), so dot segments resolve, `..` above the root stops at the origin root, and a prefixed deployment's requests carry the prefix — and counted in `hostServed` instead of `broken` when a pattern matches. Only unresolved references are tested, so the count is exactly what the patterns kept off the broken list. `pages` is `[{ buildTo, links }]` for the pages that actually wrote bytes; `known` is built inside, from those `buildTo`s (made build-relative) plus `manifestTargets`. `checked` counts every reference classified internal; `broken` is sorted by page, then href, and records the href exactly as the page wrote it.
|
|
16
|
+
- `checkLinks({ pages, buildDir, siteUrl, extensionLess, manifestTargets, hostServed, exists })` → `{ checked, hostServed, broken: [{ page, href }], orphans }`. A reference that resolves to nothing in the build is tested against `hostServed` (`config.links.hostServed`) as the path a browser would request — `requestPath()` resolves the reference **as written** with WHATWG `URL` against the page's own URL (`siteUrl`'s origin and path prefix, or `http://kiss.invalid/` with none, plus the page's build-relative path), so dot segments resolve, `..` above the root stops at the origin root, and a prefixed deployment's requests carry the prefix — and counted in `hostServed` instead of `broken` when a pattern matches. Only unresolved references are tested, so the count is exactly what the patterns kept off the broken list. `pages` is `[{ buildTo, links }]` for the pages that actually wrote bytes; `known` is built inside, from those `buildTo`s (made build-relative) plus `manifestTargets`. `checked` counts every reference classified internal; `broken` is sorted by page, then href, and records the href exactly as the page wrote it. `orphans` (sorted `buildTo`s) are the `.html` pages no **other** page's reference resolved to, using `resolveTarget`, so every spelling of a link counts: relative, root-relative, absolute to `siteUrl`. Never the build root's `index.html`, `404.html`, or a page whose `package` is set (an LMS opens a learning package's pages from its manifest; all three of example 13's lesson pages read as orphans before that rule). It replaced a 94-line scraper the information architecture skill shipped for sites to copy, which re-parsed HTML with regexes and was wrong in a new way each code review (2026-10-31).
|
|
17
17
|
- `hostServedMatcher(patterns)` → `(sitePath: string|null) => boolean`. Compiles `config.links.hostServed` once per scan. A pattern is the **whole** root-relative path, never a prefix: `**` matches across `/`, `*` within one segment, every other character literally — the two wildcards a host's own rewrite rules use, so a pattern can be copied out of `firebase.json` and read without a reference. `null` (a relative path that climbed out of the build folder) never matches. Hand-rolled rather than `path.matchesGlob` or `glob`'s minimatch: two wildcards are ten lines, and the one character set is the one the docs state.
|
|
18
18
|
- The `LinkPage` typedef (`{ buildTo, links }`).
|
|
19
19
|
- `attribute(name)` → `RegExp` and `decodeEntities(value)` → `string`: the attribute matcher (whitespace-anchored, value in group 1, 2 or 3 for double-quoted, single-quoted or unquoted) and the entity subset below. Exported for `lib/audit.js`, so the audit reads a tag's attributes and decodes a title exactly as this scan reads an `href` — two readings of the same bytes that disagreed would be worse than either being wrong alone. They stay here because this is where the compromise they encode is explained.
|
package/AIKB/llms.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Responsibility
|
|
4
4
|
|
|
5
|
-
Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the page stack and writes it to the build folder. The
|
|
5
|
+
Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the page stack and writes it to the build folder. The agent-facing sibling of `sitemap.js`: same registry, same titles, same URL derivation, a different file for a different reader — an agent or a tool pointed at the site, not a search engine.
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
@@ -24,11 +24,12 @@ Builds the [llmstxt.org](https://llmstxt.org) index — `llms.txt` — from the
|
|
|
24
24
|
|
|
25
25
|
## Non-obvious behavior
|
|
26
26
|
|
|
27
|
+
- **It is not search optimisation, and the docs must not sell it as such.** No search or answer engine documents reading `llms.txt`; Google says its AI features need no "AI text files"; SE Ranking (about 300,000 domains, May 2026) found no correlation between having one and being cited by AI, and server-log studies find AI crawlers almost never request it. Its real reader is an agent or tool someone points at the site (`planning/reviews/2026-11-01-seo-best-practices-2026.md`, C2). No study has found the `.md` copies help or harm search either. Until 2.8.6 GUIDE claimed answer engines read it before crawling; `test/unit/skill-coverage.test.js` bans that sentence.
|
|
27
28
|
- The optional `outputs` dependency is Kiss's shared output registry. Each successful file write claims its actual path at the write site; skipped or failed writes claim nothing. Collisions warn, and later asset cleanup cannot delete this writer's output. Custom filenames participate without a whitelist.
|
|
28
29
|
|
|
29
30
|
- **A page with a Markdown copy is linked to the copy, not to itself.** The llmstxt.org spec says the file's links "should point to LLM-friendly content, such as the markdown versions of pages", and `config.markdownCopies` writes them by default exactly when the build calls `.llms()` (`write: 'llms'`, 2.8.0), so every entry ends `.md` (`https://e.com/courses/index.md`) unless a site or page turns copies off. That URL is joined by `toFileUrl`, a file-path join, and **deliberately not by `toAbsoluteUrl`**: its `trailingSlash` policy is about how a host serves a directory index, and it would turn `courses/index.md` into `courses/` — the HTML page again. So with copies on, `llms.txt` is not one of the derivations that must agree with the canonical; a site that turns copies off (or one page that does, with `config: { markdownCopies: false }`) gets the page URL below, and the agreement in the next bullet. `markdownTo` is decided from the page's settings and from whether the build called `.llms()` (`KissPage.llmsRequested`, which the page registry points at `_llmsRequest`) — `.llms()` waits on `_promises`, not on the writes, and rendering runs after the build script's chain, so a call anywhere in it is seen by the write and by `llms.txt` alike — and a copy that then fails to convert fails its page, so a passing build never links a copy that is not there. The link scan reads neither `llms.txt` nor the copies; these links are correct by construction.
|
|
30
31
|
- **The URL derivation is copied from `sitemap.js` on purpose, not shared by accident.** Both call the same two `utils` functions over the same `buildTo`, which is what makes an `llms.txt` entry, a `<loc>` and a page's own `{{canonical}}` one string — a directory index keeps its trailing slash in all three, or loses it in all three under `config.links.trailingSlash: false`. `writeLlms` reads that policy off the config and passes it to `buildLlmsEntries`; an entry here emitting the slashed form while the canonical emitted the bare one would be the same page advertised at two URLs, one of them a redirect. `test/integration/llms.test.js` asserts the three agree under `extensionLess` both ways; changing the join here without changing it there breaks that.
|
|
31
|
-
- **`ignoreSitemap` excludes a page from `llms.txt` too.** The
|
|
32
|
+
- **`ignoreSitemap` excludes a page from `llms.txt` too.** The agent index is a curated subset of the site the sitemap describes, never a superset: a page the author has already kept out of the sitemap has no business being recommended to an agent either. `ignoreLlms` is the one-way opt-out — out of `llms.txt`, still in the sitemap.
|
|
32
33
|
- **A page's title is its own `title`, else its slug title-cased with hyphens as spaces** (`puppy-classes` → `Puppy Classes`) — the same rule `KissPage.prepare()` uses for the default, so an entry and the page's `<title>` agree. Until the launch-readiness release there was also an `UNTITLED = 'Index'` guard, because `KissPage` filled every untitled page's title with `'Index'` (from the placeholder slug); once that default was fixed the guard only rewrote a title somebody had written, and the operator removed it (2026-09-28). An explicit "Index" is now kept as written.
|
|
33
34
|
- 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.
|
|
34
35
|
- `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`.
|
package/AIKB/partials.md
CHANGED
|
@@ -22,6 +22,7 @@ Registers Handlebars partials and layouts from `config.folders.partials` and `co
|
|
|
22
22
|
|
|
23
23
|
- A partial's registered name is its path relative to the globbed folder with the extension stripped (leading `/` trimmed) — e.g. `partials/nav/header.hbs` under `folders.partials` registers as `nav/header`.
|
|
24
24
|
- `.md` files are rendered to HTML _at registration time_, not at render time — editing a Markdown partial requires re-registering (i.e. a rebuild), not just re-rendering the page.
|
|
25
|
+
- **Markdown runs before Handlebars, so it escapes what Handlebars needs.** Remarkable turns `"` into `"` and `>` into `>` before the HTML is compiled as a template: a double-quoted argument (`{{link "x"}}`) and every partial call (`{{> "nav"}}`, `{{> nav}}`) fail the page with a parse error, while `{{title}}` and single-quoted arguments survive. A Markdown link whose URL is a `{{ }}` expression is not parsed as a link and ships as literal text. The documented workaround is a `.hbs` partial wrapping its prose in `{{#markdown}}`; the fix is the deferred partial-transforms spec (`planning/specs/2026-10-24-partial-transforms-design.md`). Measured against the real engine on 2026-10-26.
|
|
25
26
|
- **Registration-time rendering is also why `config.markdown` exists.** The `markdown` renderer arrives through `deps` already configured, and whatever options it carries at that moment are baked into every `.md` partial's HTML. `Kiss` therefore builds its Remarkable from `config.markdown` _before_ calling this module; changing the renderer afterwards needs a fresh `registerPartials()` pass to take effect on partials, even though the `{{markdown}}` helper would pick it up immediately. See `AIKB/config.md`.
|
|
26
27
|
- **The registered set mirrors disk, and that is why all five passes re-run.** Handlebars keeps a registration until it is explicitly unregistered, so before the fix a partial deleted from disk kept rendering its last-known content for the life of the process — dev and a fresh production build disagreed (review finding B1). The recipe is: re-run all five passes — `.html`, `.md`, `.txt` and `.hbs` under `folders.partials`, then `.hbs` under `folders.layouts`, in that order, so the later pass wins the name — then unregister every name the previous run produced that this one did not. It cannot be done per file. `foo.html`, `foo.md`, `foo.hbs` and a layout `foo` all derive the name `foo` and the last pass wins (below), so unregistering by file would delete a winner that is still on disk, and re-registering by file would install a loser. The caller owns the "previous names" state: `Kiss.registerPartials()` keeps the last returned array on `_partialNames` and passes it back in.
|
|
27
28
|
- A page that still references a deleted partial now fails to render (`The partial foo could not be found`) instead of rendering a ghost. Under `watch()` that surfaces as a replay failure via the logger, and the page's stale output is left in place — see `AIKB/kiss.md`.
|
package/AIKB/robots.md
CHANGED
|
@@ -14,10 +14,11 @@ Unlike `_redirects` there **is** a method to call. A crawl policy is a statement
|
|
|
14
14
|
|
|
15
15
|
- `normaliseRobotsPath(value)` → the path with a leading `/`, or `null` for an empty one or one containing whitespace. A leading `*` is left alone (a wildcard pattern, not a path).
|
|
16
16
|
- `collectRobotsAgents(options)` → `[{ userAgent, allow, disallow, crawlDelay }]`, always at least one block. `options.agents` wins; failing that the `userAgent`/`allow`/`disallow` shorthand builds a single block; failing that the default is one `*` block allowing `/`. Paths go through `normaliseRobotsPath`, a non-finite `crawlDelay` becomes `null`.
|
|
17
|
-
- `disallowsEverything(agents)` → whether
|
|
17
|
+
- `disallowsEverything(agents)` → whether the `*` block, or a block for a search engine's own crawler (`SEARCH_CRAWLERS`: Googlebot, Bingbot, Applebot, DuckDuckBot, Yandex, Baiduspider, OAI-SearchBot, Claude-SearchBot, PerplexityBot; matched case-insensitively), carries `Disallow: /`.
|
|
18
|
+
- `blockedAgents(agents)` → every named agent (not `*`) whose block carries `Disallow: /`, search crawler or not, in file order.
|
|
18
19
|
- `collectSitemapUrls(sitemap, { siteUrl, hasSitemap, trailingSlash })` → the absolute sitemap URLs to advertise. `false` or no `siteUrl` gives none; a string or array is taken at its word (a value carrying a scheme is passed through, anything else is joined to `siteUrl`); `true`/absent gives this build's own `sitemap.xml` **only when `hasSitemap`**.
|
|
19
20
|
- `renderRobotsTxt(agents, sitemaps)` → the file's text: one block per agent separated by a blank line, `Sitemap:` lines last, newline-terminated.
|
|
20
|
-
- `writeRobots({ config, logger, options, hasSitemap, overwrite })` → `{ status: 'written' | 'skipped', text, agents, disallowAll, sitemaps }`.
|
|
21
|
+
- `writeRobots({ config, logger, options, hasSitemap, overwrite })` → `{ status: 'written' | 'skipped', text, agents, disallowAll, blocked, sitemaps }`.
|
|
21
22
|
- The `RobotsAgent`, `RobotsOptions` and `RobotsWriteResult` typedefs.
|
|
22
23
|
|
|
23
24
|
## Depends on
|
|
@@ -35,6 +36,7 @@ Unlike `_redirects` there **is** a method to call. A crawl policy is a statement
|
|
|
35
36
|
- **`ignoreSitemap` deliberately does NOT imply `Disallow`, and must never be wired to it.** It is the obvious-looking connection and it is actively harmful: blocking a crawler stops it fetching the page, which stops it seeing a `noindex` meta tag, so the URL can stay indexed with no snippet — worse than leaving it crawlable. Excluded-from-the-sitemap and blocked-from-crawling are different intents, and this module takes the second only from an explicit `disallow`.
|
|
36
37
|
|
|
37
38
|
- **`Disallow: /` is the sharpest edge in the package, so it is reported rather than merely written.** It removes a site from search, it is one character from the bare `Disallow:` that means the opposite, and nothing about the build looks wrong afterwards. `disallowsEverything` is what lets `Kiss.robots()` log a `notice` on **every** build that emits it and put `disallowAll` on `report().robots`, so a staging policy promoted to production shows up in a `kiss-ssg check` diff rather than in Search Console a month later. That is also why the report's `robots` key is an object where `sitemap`, `llms` and `feed` are bare path strings: a path cannot carry the fact.
|
|
39
|
+
- **Only `*` and a search engine's own crawler raise the alarm (2.8.6); every other whole-site block is named, not alarmed.** Until 2.8.6 a `Disallow: /` under any agent set `disallowAll` and logged "no crawler will index it", so the usual answer to "stop AI scraping us" (blocking `GPTBot`, `ClaudeBot`, `CCBot`, `Bytespider`…) told the owner the site had left search when it had not. A with/without arm on 2026-11-01 blocked `CCBot`, `meta-externalagent` and `Bytespider` and had to tell the owner to ignore the notice. A first fix exempted an allow-list of four documented training tokens, and that arm showed it was the wrong class: the next unlisted bot hits the same false alarm. So the alarm asks the question it states, whether search is blocked: `*`, or a crawler on `SEARCH_CRAWLERS`, the user agents the engines document for their search results (Applebot is Apple's search; Applebot-Extended only its training opt-out). Every named agent kept out of the whole site, search crawler or not, is listed in `blocked` on the report and in one `info` line, so nothing is silent. The cost, accepted by the operator: a search engine missing from the list (a regional one) is named in `blocked` but raises no notice. Add a crawler only with its engine's documentation.
|
|
38
40
|
|
|
39
41
|
- **`Sitemap:` is gated on `.sitemap()` having been called, not on the option alone.** `sitemap: true` is the default, but `hasSitemap` is what decides: advertising a `sitemap.xml` that was never written is a fetch error in every crawler that reads the line, and a site that removed `.sitemap()` should stop claiming one. `Kiss.robots()` reads `_sitemapRequest !== null` **inside** the queued `.then()` rather than at call time, so `.robots().sitemap()` and `.sitemap().robots()` give the same file — both requests are recorded synchronously on the chain, and the check runs after the queue drains.
|
|
40
42
|
|
package/AIKB/scorm-bridge.md
CHANGED
|
@@ -8,7 +8,7 @@ It is one inline `<script>`, not a file: the `{{scormBridge}}` helper renders it
|
|
|
8
8
|
|
|
9
9
|
## Public interface
|
|
10
10
|
|
|
11
|
-
- `scormBridgeScript({
|
|
11
|
+
- `scormBridgeScript({ complete = 'manual' })` → `string`: `<script>(<the bridge>)(window,<manual>)</script>`, where `<manual>` is `false` only for `complete: 'seen'`. The same bytes on every call. The option takes the helper's own words, so `{{scormBridge complete=…}}` passes its value straight through. The `scormBridge` helper (`AIKB/handlebars-helpers.md`) is its only caller in kiss.
|
|
12
12
|
- In the browser, `window.kissScorm`:
|
|
13
13
|
- `available`: `true` while a session with an LMS is open.
|
|
14
14
|
- `interaction({ id, type = 'choice', response, correct, result })`: one `cmi.interactions.n` entry at `n = cmi.interactions._count`: `id` first (SCORM 1.2 requires it of a new entry), then `type`, `student_response`, `correct_responses.n.pattern` (one per correct answer), `result` (`true` or `'correct'` is `correct`, anything else `wrong`) and `time` (CMITime, `HH:MM:SS`); then a commit. A `choice` answer is a letter: an option index (`0` is `a`) or a letter given; a `response` array is several, comma-separated (`c,a`). `correct` is a **pattern**, encoded apart from the response (`keys`): an array is a set every choice of which is required, `{a,c}` in SCORM 1.2's CMIFeedback, where a bare `a,c` means something else; `{ anyOf: [...] }` lists alternatives, each its own `correct_responses.n`, each a letter or a set. Before the Codex adversarial review of 2026-10-25 both went through one encoder, so a multi-select key was recorded as `a,c`. A non-`choice` type takes `{ anyOf }` the same way, each value as a string.
|
|
@@ -17,7 +17,7 @@ It is one inline `<script>`, not a file: the `{{scormBridge}}` helper renders it
|
|
|
17
17
|
- `resumed`: `{ location, state }` from `cmi.core.lesson_location` and `cmi.suspend_data` when the LMS launched the SCO with `cmi.core.entry` `resume`; `null` otherwise, and off an LMS — and `null` on the waiting placeholder until it hands over. A field the LMS failed to return is `null` (unknown), never `''` (read back empty): the drop rule at handover protects anything not confirmed empty (fourth Codex adversarial review of `ready()`, 2026-10-25: a failed read became `''` and a queued `bookmark('')` overwrote the saved place). Whether the learner is resuming is a read as well: when `cmi.core.entry` cannot be read, `resumed` is `{ location: null, state: null }` rather than `null`, so the page restores nothing and both fields are protected (fifth round: a failed entry read let queued defaults overwrite both). A queued `bookmark()`/`save()` is replayed with the bridge's own second argument, `queued`: its caller was told `true` when it queued, so a refusal at replay is kept in `unsent` and written again before the next commit, like any write the page never saw refused, rather than lost (same round). Pages never pass it; the embedded proxy and a waiting placeholder pass it on.
|
|
18
18
|
- `ready(fn)`: calls `fn(kissScorm)` once the bridge has decided and returns a promise of the same object. The live bridge (an LMS or none) calls back at once; the embedded page's proxy when its owner is ready, with the embedded page's own `kissScorm`; the waiting placeholder at handover, after its queue — and its handover completes only when the bridge it hands to is itself ready (`next.ready(() => finish(next))`), so an embedded page whose bridge decided first, as a proxy to an owner still waiting, calls back once, with what is resumed (Codex adversarial review, 2026-10-25: it called back at once with nothing), and the placeholder keeps queueing meanwhile, so nothing is reordered. It also stays `window.kissScorm` until then (`finish` publishes the decided bridge): published early, a script running between two handovers called the new proxy, whose calls reached the owner's queue ahead of the ones still in this placeholder, and wrote them in reverse (third Codex adversarial review of `ready()`, 2026-10-25). Requested by a consumer (2026-10-25): a page that read `resumed` once at start, under a parent still loading, restored nothing, and its first `bookmark('')` was replayed over the saved place. A callback that throws does not stop the others; the first error is rethrown after them all.
|
|
19
19
|
- `bookmark(location)` / `save(state)`: write `cmi.core.lesson_location` (at most 255 characters) / `cmi.suspend_data` (printable ASCII, at most 4,096 bytes), returning whether they did. A value past the limit is refused, never truncated. The first successful one sets `cmi.core.exit` to `suspend`, so the next launch is a resume. A session that was itself a resume sets `suspend` at the start, in any mode but `review`: without it, a learner who comes back and leaves without saving again exits normally, and the LMS starts them afresh next time with what they left unreadable (code review, 2026-10-25). Neither commits on the call: each successful one schedules a commit 2.5 s later (`commitSoon`) on the window's own `setTimeout`. The first uncommitted write starts the timer and later ones ride on it, so a burst of view changes commits once, and a learner who keeps moving is never more than 2.5 s uncommitted, which a strict trailing debounce would not promise. Any other commit (an interaction, a score, a status, the load-time completion, a checkpoint) cancels the timer, since it carried the writes. What is still unsaved is known exactly: `unsaved`, every value written since the last commit or finish the LMS **accepted**. A commit that fails leaves them there and is retried on the same timer, at most five times in a row, so persisting never waits for the learner's next action or the exit (Codex adversarial review, 2026-10-25: a failed commit cleared the flag and nothing retried). **On Moodle a retry cannot recover a commit lost in transit**: its API moves each element's baseline before sending, so the retry sends nothing and reports success, and `unsaved` is cleared with the values not on the server. Recorded, not worked around (`AIKB/upstream.md`, the operator's decision). A value a restarted session refused to take back (`unsent`) stays unsaved through a commit that succeeds, is written again before the next commit, and keeps the retry going (second Codex adversarial review, 2026-10-25: it was cleared with the rest, and lost); a timer that fires after `LMSFinish`, with the bridge off, or in review mode commits nothing, and review mode never schedules one. Requested from a consumer's Moodle test (2026-10-25): Moodle commits with synchronous XHR, which a browser may block while the page is dismissed, so a place written moments before leaving and left to the `pagehide` commit was lost on relaunch. A window without timers keeps the hidden and `pagehide` checkpoints only. A write the LMS refuses returns `false` and does not turn the bridge off.
|
|
20
|
-
- `mode`: `cmi.core.lesson_mode` (`normal` when the LMS gives none), `null` off an LMS. In `review` every write is a no-op; only in `normal` is a page
|
|
20
|
+
- `mode`: `cmi.core.lesson_mode` (`normal` when the LMS gives none), `null` off an LMS. In `review` every write is a no-op; only in `normal` is a page given a status on load (`incomplete` by default, `completed` with `complete: 'seen'`).
|
|
21
21
|
- `log`: the last 50 events, `{ call, element, ok }` per LMS call, with `code`, `message` (`LMSGetErrorString`) and `diagnostic` (`LMSGetDiagnostic`) on a failure, read straight after it; and `{ event }` for the bridge's decisions. **Never a value**, written or read: learner data, answers, bookmarks and state stay out of it.
|
|
22
22
|
|
|
23
23
|
## What it does
|
|
@@ -26,7 +26,7 @@ It is one inline `<script>`, not a file: the `{{scormBridge}}` helper renders it
|
|
|
26
26
|
2. **It waits for a parent still loading.** At handover, if the decided bridge finds the learner resuming, the `bookmark()` and `save()` calls queued meanwhile are dropped and logged (`… dropped: the learner is resuming`): they were written blind to the place the learner left and would overwrite it; answers, the score and the status are still sent. Only a resumed field the LMS did not confirm empty is protected: a queued `bookmark` is dropped unless the resumed location read back `''`, a queued `save` unless the resumed state did (same review: an empty resume lost the learner's new progress). A page that waits on `ready()` never queues one. It walks up `parent` (at most 20 frames, in `try`/`catch`). A same-origin parent whose document is still loading may yet run its own bridge (placed after an iframe in its `<body>`), so the bridge waits for each such parent's `DOMContentLoaded` before it decides, with a placeholder `kissScorm` that queues the page's calls (`available` is false meanwhile). Then it runs again, and sends the queued calls. The placeholder stays a working object afterwards: a page that kept it (`const scorm = window.kissScorm`) has every later call and property (`available`, `resumed`, `mode`, `log`) forwarded to the bridge that replaced it. Before the Codex review of 2026-10-25 it kept queueing into a queue nobody drained again, and that page's score and status were lost. Without the wait, whichever bridge ran first owned the session: a child that ran before its parent started it, the parent's `LMSInitialize` failed and turned the parent off, and the child's unload finished the session (found by a Codex adversarial review, 2026-10-24). A bridge in `<head>` runs before the page's iframes are parsed, so the usual case waits for nothing.
|
|
27
27
|
3. **Embedded pages proxy.** If a same-origin ancestor has `kissScorm`, this copy forwards every call to it and never touches the LMS: only the outermost kiss page of a SCO owns the session. It looks the ancestor's `kissScorm` up at each call, because an ancestor still waiting replaces its placeholder when it decides.
|
|
28
28
|
4. **Finds the API** up `parent`, then up `opener`, at most 20 frames each (10 until 2026-10-25; 20 is the depth the SCORM 1.2 best-practice report the operator supplied uses); a frame of another origin is skipped, never thrown on.
|
|
29
|
-
5. **Starts the session** with `LMSInitialize('')`, then reads `cmi.core.lesson_mode`, and `cmi.core.entry` with the bookmark and state when it is `resume`. Only in `normal` mode, it reads `cmi.core.lesson_status`:
|
|
29
|
+
5. **Starts the session** with `LMSInitialize('')`, then reads `cmi.core.lesson_mode`, and `cmi.core.entry` with the bookmark and state when it is `resume`. Only in `normal` mode, it reads `cmi.core.lesson_status`: with `manual` (the default), it sets `incomplete` only from `not attempted`; with `complete: 'seen'`, it sets `completed` only from `not attempted`, `browsed` or `incomplete` (never downgrading `passed` or `failed`). `incomplete` on a new attempt matters because an LMS (Moodle among them) may record a status the SCO never set as completed at `LMSFinish`, and a learner who leaves early would count as done (Codex review, 2026-10-25). Then it commits at once.
|
|
30
30
|
6. **On `pagehide`**: sets `cmi.core.session_time` (CMITimespan, the time the page was live) and commits; it calls `LMSFinish` only when `event.persisted` is false. A persisted `pagehide` stops the session clock and a persisted `pageshow` restarts it, so an hour in the back/forward cache is not an hour of session time (Codex adversarial review, 2026-10-25: it was). A page frozen in the back/forward cache keeps its session, because Moodle's API refuses every call after `LMSFinish`. It registers no `unload` or `beforeunload`, which would keep a page out of that cache.
|
|
31
31
|
7. **On `visibilitychange` to `hidden`**: sets `cmi.core.session_time` and commits, never finishes. A phone that switches apps, or a browser that discards a background tab, may never fire `pagehide`; this is the checkpoint that saves the learner's answers anyway.
|
|
32
32
|
|
|
@@ -40,6 +40,7 @@ Nothing: the bridge runs in the browser, on `window` alone.
|
|
|
40
40
|
|
|
41
41
|
## Non-obvious behavior
|
|
42
42
|
|
|
43
|
+
- **Completion is the page's by default, because completing on load breaks resume in Moodle.** Moodle opens a SCO whose attempt is completed (or passed or failed) in `review` mode on every later launch while "Force new attempt" is No, and in review the bridge writes nothing. Moodle still hands back the first launch's `entry`, `lesson_location` and `suspend_data`, so resume _looks_ as if it works: the page restores launch 1's place every time, and nothing from launch 2 on is saved (`AIKB/upstream.md`, the Moodle entry). "Force new attempt" set otherwise starts a new attempt with no saved place, so no Moodle setting rescues it. Reported from a consumer's Moodle test (2026-10-26): "resume does not work". `complete="seen"` stays for a page that keeps no place or state, where review mode costs nothing. An adaptive default (complete on load until the page bookmarks) was considered and declined: a page whose tracking changes with what its script happened to call first is one a person cannot predict from its markup.
|
|
43
44
|
- **Supported shape: one recording page per SCO, and it is a decision, not an oversight.** The bridge coordinates one kiss page that owns a SCO's session, the kiss pages it embeds (which proxy to it), and a page waiting for a still-loading parent (a placeholder with its own queue). Several kiss pages recording into one session from frames that do not nest (siblings under a wrapper, a page and a pop-up) are unsupported and documented in GUIDE. Rounds 6 to 15 of Codex's adversarial review of `ready()` (2026-10-25) built peer coordination across such frames (a shared queue on the API window, an elected owner, held owners, proxies that re-decide, launch numbers), and every round found a new ordering it got wrong: there was no single authority, only pages guessing at each other's state. The operator parked it (the commits `d7825b7..95c4b10` stay in history; the bridge was restored to `a55267e`). Doing it properly is a redesign, not a fix: one session controller per SCO on the window holding the `API`, which every page registers with and which alone owns the queue, the order and the lifecycle, specified as a state machine and tested across load and leave orderings. Start there, not from the parked commits.
|
|
44
45
|
- **The bridge is a real function, inlined by its source.** `scormBridgeScript()` uses `Function.prototype.toString`, so the bridge is linted, type-checked and formatted like the rest of `lib/`, and must use nothing from the module's scope. A production build minifies it with the page; the tests run both the rendered and the terser-minified script.
|
|
45
46
|
- **Every call is guarded.** After each call it reads `LMSGetLastError()`. On 301 (not initialised: the LMS ended the session underneath the page) it calls `LMSInitialize('')` once, **writes back every `unsaved` value** in the order first written (logged as `restored n of m values`), and retries once. Moodle's `LMSInitialize` rebuilds its data model from what was persisted, so a bookmark written before the session ended and committed by a timer after it was lost while the bridge reported nothing wrong (Codex adversarial review, 2026-10-25). Codex also offered never restarting from a background commit; restoring keeps amendment (4)'s restart and makes it safe for every caller, the timer among them. `unsaved` is cleared only by an accepted `LMSCommit` or `LMSFinish`. Only a session that stays lost (301 after the retry) or a call that throws turns the bridge off for the rest of the page's life. **Any other error fails that call alone**, logged with its code: the session is still open, so the next write and the next checkpoint's commit try again, and `LMSFinish` still runs on `pagehide`. Turning off on any error was the rule until the Codex adversarial review of 2026-10-25 showed one refused commit (101) losing every score, status and the finish after it. `call()` returns the error `code` for the callers that decide by it. Nothing ever throws into the page.
|
package/AIKB/sessions.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# sessions.js
|
|
2
|
+
|
|
3
|
+
## Responsibility
|
|
4
|
+
|
|
5
|
+
A site's session logs (`planning/sessions/<date>-<slug>.md`, one per piece of work) and `kiss-ssg session`, the command that finds and lists them. The kiss-memory skills call the command rather than carry their own copy of the rules.
|
|
6
|
+
|
|
7
|
+
## Public interface
|
|
8
|
+
|
|
9
|
+
- `SESSIONS` — `'planning/sessions'`, relative to the site's folder.
|
|
10
|
+
- `frontmatter(text)` — a log's frontmatter as `{ key: string }`, or `null` when it has none.
|
|
11
|
+
- `matching(entries, branch)` — the entries whose `branch:` is exactly `branch`.
|
|
12
|
+
- `newestFirst(entries)` — sorted by the date the file name starts with.
|
|
13
|
+
- `listLines(entries)` — `--list`'s tab-separated table.
|
|
14
|
+
- `branchCommits(branch, exists)` / `commitsOf(branch, cwd)` — the full ref to read a branch's own logs from: the local branch, else origin's.
|
|
15
|
+
- `readSessions({ dir, cwd, ref })` — every log under `dir`, subfolders included, from the working tree, or from `ref`'s commits; each `{ file, show, fields }`.
|
|
16
|
+
- `runSession(argv, { cwd })` — the whole command: `{ out, err, code }`. `bin/kiss-ssg.js` only prints it.
|
|
17
|
+
- `SESSION_HELP` — the command's help text.
|
|
18
|
+
|
|
19
|
+
## Depends on
|
|
20
|
+
|
|
21
|
+
`lib/git.js`; `node:fs`, `node:path`, `node:child_process` (one `git cat-file --batch` for a branch's blobs).
|
|
22
|
+
|
|
23
|
+
## Depended on by
|
|
24
|
+
|
|
25
|
+
`lib/base.js` (a branch's `base:` line), `bin/kiss-ssg.js` (`kiss-ssg session`), and through the command every `kiss-memory` skill and this repository's own branch skills.
|
|
26
|
+
|
|
27
|
+
## Non-obvious behavior
|
|
28
|
+
|
|
29
|
+
Every rule below was a defect in an inline copy before this module existed (code reviews, 2026-10-31):
|
|
30
|
+
|
|
31
|
+
- **From the branch, the folder's place in the repository comes from git's `--show-prefix` for cwd, never from comparing the folder with `--show-toplevel`.** The two can spell one folder differently: a symlinked checkout, or Windows, where Node's temp folder is `C:/Users/RUNNER~1/…` and git's is `C:/Users/runneradmin/…`. Compared across spellings, the folder lay outside the repository and `--from-branch` found nothing, on `windows-latest` only (CI, PR #47). The test reaches the site through a symlink, which reproduces it on any platform.
|
|
32
|
+
- **The branch is read from the frontmatter only, and compared as text.** A log's body may quote another log's frontmatter, and a grep matched `fix/v2.8` against `fix/v2x8`. A frontmatter that is never closed is none.
|
|
33
|
+
- **What an editor or a YAML habit adds reads as the plain value:** CRLF endings, a byte-order mark, spaces after `---`, a quoted value, a trailing ` # comment`. A `#` with no space before it is part of the value, as in YAML.
|
|
34
|
+
- **An empty branch matches nothing.** On a detached HEAD the current branch is `''`, and a shell copy matched every log that had no `branch:` line.
|
|
35
|
+
- **Subfolders are read, and only files:** an archived log counts, while a folder named `old.md` or a symlink to nothing is skipped.
|
|
36
|
+
- **Paths are printed from where the caller stands** (`show`), so a path opens where it is read. Read from a branch's commits, a log is printed as `<ref>:<path>`, which `git show` reads as it stands, so a later command needs no shell state.
|
|
37
|
+
- **With no `planning/sessions/` under the folder, it says so on stderr** rather than answering with nothing, which a skill would read as "no log": the usual cause is being run outside the site's folder.
|
|
38
|
+
- **A branch's logs are read from the local branch, else origin's copy**, by full ref, so a tag of the same name is never read, and a branch opened on another machine is still found. Names are listed with `ls-tree -z`, because git quotes a non-ASCII name otherwise.
|
|
39
|
+
- **`--get` refuses rather than guesses:** no log, or more than one, exits 1 and says which; a log without the key prints nothing.
|
|
40
|
+
- **Newest first is by file name, not modification time:** a fresh clone gives every file the same time.
|
|
41
|
+
|
|
42
|
+
## Types
|
|
43
|
+
|
|
44
|
+
`SessionEntry` `{ file, show, fields }`; `CommandResult` `{ out, err, code }`, shared with `lib/base.js`.
|
package/AIKB/sitemap.md
CHANGED
|
@@ -6,13 +6,13 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
|
|
|
6
6
|
|
|
7
7
|
## Public interface
|
|
8
8
|
|
|
9
|
-
- `buildSitemapEntries(stack, { siteUrl, buildDir,
|
|
10
|
-
- `renderSitemapXml(urls)` → the `sitemap.xml` string (standard `<urlset>`/`<url>` schema; `<changefreq>`
|
|
9
|
+
- `buildSitemapEntries(stack, { siteUrl, buildDir, trailingSlash = true, logger })` → array of `{ loc, lastmod?, changefreq?, priority? }`. Skips entries where `entry.page.options.ignoreSitemap` is truthy, where `entry.page.options.canonical` is set (the page has named another URL as the real one, and a sitemap that listed it would advertise a duplicate), and where `entry.page.options.generate === false`. `loc` = `siteUrl` joined to the page's URL path (derived from `buildTo`) by `toAbsoluteUrl` from `utils.js`. `lastmod` is the page's `sitemapLastmod` as given, else its `updated`, else its `date` (each read from the options, then the resolved model, by `feed.js`'s `rawDate`), and is absent when the page has none. `priority` and `changefreq` are present only when the page sets `sitemapPriority` / `sitemapChangefreq`.
|
|
10
|
+
- `renderSitemapXml(urls)` → the `sitemap.xml` string (standard `<urlset>`/`<url>` schema; `<lastmod>`, `<changefreq>` and `<priority>` each emitted only when the entry has it).
|
|
11
11
|
- `async writeSitemap(stack, { config, logger, overwrite = true })` → `Promise<{ status, urls }>`. `status` is `'no-site-url'` (no `config.siteUrl`, nothing written), `'skipped'` (`overwrite: false` and a `sitemap.xml` already exists), or `'written'` (file written, `urls` populated).
|
|
12
12
|
|
|
13
13
|
## Depends on
|
|
14
14
|
|
|
15
|
-
`fs-extra`; `./utils.js` (`toCanonicalPath`, `toAbsoluteUrl`).
|
|
15
|
+
`fs-extra`; `./utils.js` (`toCanonicalPath`, `toAbsoluteUrl`); `./feed.js` (`rawDate`, `toDate`), so a page's date is read by one rule in both files.
|
|
16
16
|
|
|
17
17
|
## Depended on by
|
|
18
18
|
|
|
@@ -20,6 +20,7 @@ Builds `sitemap.xml` entries from the page stack and writes the file to the buil
|
|
|
20
20
|
|
|
21
21
|
## Non-obvious behavior
|
|
22
22
|
|
|
23
|
+
- **A page with no date states no `<lastmod>`, and no page gets a `<priority>` it did not ask for (2.8.6).** Until then every entry carried the build time and `1.00`. Google uses `lastmod` only when it is "consistently and verifiably" accurate and ignores `priority` and `changefreq`; Bing warns against stamping the generation time (`planning/reviews/2026-11-01-seo-best-practices-2026.md`, C1). A date that moves on every build teaches both to ignore the field for the whole site, so the sitemap now says only what a page knows, and two builds of an unchanged site write the same bytes. A date-only string (`2026-10-14`) is written as it is, so no time zone can move it to another day; a `Date`, a timestamp or any other readable string becomes an ISO datetime; an unreadable `updated` warns once and the page falls back to `date`. `sitemapLastmod` is the author's word and is written verbatim. Alternatives considered and rejected: the last git commit to a page's sources (a layout edit re-dates every page, and shallow CI clones break it) and a content hash against `AIKB/last-build.json` (a deploy build would depend on that committed file being current).
|
|
23
24
|
- The optional `outputs` dependency is Kiss's shared output registry. Each successful file write claims its actual path at the write site; skipped or failed writes claim nothing. Collisions warn, and later asset cleanup cannot delete this writer's output. Custom filenames participate without a whitelist.
|
|
24
25
|
|
|
25
26
|
- URL derivation from `buildTo`: strip the leading `buildDir` (`entry.buildTo.slice(buildDir.length)`), then `toCanonicalPath` (the file extension dropped, the `index` segment kept) and `toAbsoluteUrl` (joined to `siteUrl`, a trailing `index` segment becoming a trailing `/`, an empty path giving the site root with one trailing slash). **Those two functions are shared with the `canonical` helper on purpose** — a page's `<loc>` and its own `<link rel="canonical">` are built by the same code, so they cannot drift apart (`AIKB/handlebars-helpers.md`). Changing the derivation here changes both; an integration test asserts they stay equal (it builds a site with a section index under `extensionLess` on and off). The one deliberate exception is a page that overrides `config.siteUrl` for itself: its canonical URL follows the override, its `<loc>` keeps the site's own domain.
|