wiki-formant 0.97.1 → 0.98.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/README.md CHANGED
@@ -1,26 +1,28 @@
1
1
  # wiki-formant
2
2
 
3
- The portable half of a wiki: derived taxonomy, a spec-correct MCP transport, markdown twins, and the conditional-GET plumbing an agent surface needs.
3
+ The portable half of a wiki: a page store, derived taxonomy, a spec-correct MCP transport, markdown twins, block rendering and editing, and the conditional-GET plumbing an agent surface needs.
4
4
 
5
- The editor, history, sanitiser, chart and passkey modules bring their libraries as dependencies (`@tiptap/*`, `fast-diff`, `sanitize-html`, `lightweight-charts`, `@simplewebauthn/*`), since every wiki here uses all five. What only some apps use stays an optional peer: `pg`, `react`, `jose` and `@radixdlt/rola` for ROLA, `sharp` and `@vercel/blob` for uploads. Web-standard `Request`/`Response`, so it runs unchanged on Next route handlers, Hono, Bun, Deno and workers.
5
+ The editor, history, sanitiser, chart and passkey modules bring their libraries as dependencies (`@tiptap/*`, `fast-diff`, `sanitize-html`, `lightweight-charts`, `@simplewebauthn/*`). What only some apps use is an optional peer: `pg`, `react`, `jose` and `@radixdlt/rola` for ROLA, `sharp` and `@vercel/blob` for uploads. Web-standard `Request`/`Response` throughout, so it runs on Next route handlers, Hono, Bun, Deno and workers.
6
6
 
7
7
  ```bash
8
8
  npm install wiki-formant
9
9
  ```
10
10
 
11
- ## What this is, and what it deliberately is not
11
+ What stays the app's: its design system, its block types beyond the core, its connection, cache and auth. Everything else here is shared.
12
12
 
13
- Every wiki needs a design system and a set of block types beyond the core. Those are the parts you must own: they encode your brand and your content model, and a package that tried to own them would fight you.
13
+ ## API
14
14
 
15
- The page store is no longer one of them. Three wikis kept their own pages and revisions on three schemas that had drifted apart (cuid ids against int ids, a version column on one, no author on another, a tenant key on the third), and each wrote its page and its revision its own way. The tables are now the package's (`prisma/wiki.prisma`), and so is the SQL that reads and writes them (`wiki-formant/store`), so one wiki engine serves a standalone wiki and a multi-tenant host alike. The connection, the cache and the auth stay the app's.
15
+ Every module has a subpath (`wiki-formant/taxonomy`, `wiki-formant/mcp`, …) and there is no root export: a barrel loads every module for the one symbol a route wanted. The emitted `.d.ts` files are the reference; this README shows one use of each subpath and the rules a signature cannot say. A function a module uses only itself is marked `@internal` and left out of the `.d.ts`.
16
16
 
17
- What every wiki *also* needs, rebuilds by hand, and then lets drift, is the layer above: the second browsing axis derived from metadata you are already storing, the HTML→markdown converter behind every `.md` twin, the JSON-RPC edges that decide whether an MCP client can talk to you at all, and the ETag arithmetic that turns a recrawl into a 304.
17
+ `wiki-formant/tsconfig.base.json` is the compiler floor: an app's `tsconfig.json` extends it and keeps only `paths`, `include`, `exclude` and any licensed extra, since tsconfig resolves those against the file that declares them.
18
18
 
19
- This package was extracted from three production wikis that had each grown their own copy. By the time it was lifted, the copies had already diverged — one had lost the alphabetical index and the related-page ranking; one had `ToolAnnotations` the others lacked; one had MCP prompts the others lacked. Everything here is the union, with tests pinning the specific bugs that shipped.
19
+ ```json
20
+ { "extends": "wiki-formant/tsconfig.base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/.well-known/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["node_modules"] }
21
+ ```
20
22
 
21
23
  ## Store
22
24
 
23
- `wikiStore` reads and writes `WikiPage` and `WikiRevision` through one `Sql` function the app binds to its own client, scoped to one site.
25
+ `wiki-formant/store`: `wikiStore` reads and writes `WikiPage` and `WikiRevision` through one `Sql` function the app binds to its own client, scoped to one site.
24
26
 
25
27
  ```ts
26
28
  import { wikiStore } from 'wiki-formant/store';
@@ -32,23 +34,21 @@ const store = wikiStore({
32
34
  });
33
35
 
34
36
  const page = await store.get('contents/tech', 'radix-engine');
35
- const cards = await store.list('contents/tech', { sort: 'recent' });
36
37
  const { items, total } = await store.search('validators', { take: 20 });
37
38
  await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.version });
38
39
  ```
39
40
 
40
- - **A save is one statement.** The page update and its revision are a data-modifying CTE guarded on the version the save read. A save that lost a race writes nothing and throws a 409 `HttpError`; pass `baseVersion` and a save opened on an old version, or on a page deleted since, is refused the same way, instead of erasing the edit in between or re-creating the page. A rename onto a slug another save took meanwhile is a 409 too.
41
- - **The bump is the diff's.** Added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it for a script that knows better. A metadata-only save moves no version and writes no revision.
42
- - **History is diffed on read.** A revision stores the page as it was saved, never a diff, so a script's revision and the editor's carry the same history. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown is diffed against its real predecessor. It and `revision` answer only for a page of the store's own site.
43
- - **Listings carry a snippet, not the article.** `list`, `recent` and `byIds` drop `content` for one line of it; a category of 200 cards otherwise ships 200 block arrays.
44
- - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' own headline as the snippet. Hidden paths are left out unless the search names one; `content: true` keeps each row's blocks.
45
- - **A path under a hidden one is hidden too**, in every listing, whether or not the tree names it.
46
- - **`branch` is an agent's walk**: a path and everything under it, newest first or A–Z, with the total a pager needs and an optional `since`. `counts` is pages per tag path, the back office and the home page left out.
47
- - Ids and timestamps default in the database, so the store's SQL and a Prisma client write the same rows. Copy the models with `npx prisma-models wiki` (a tenant-keyed app checks its own copy with `--tenant`), then build the full-text column with `npx fts-ddl '"WikiPage"'`.
41
+ - **A save is one statement**: the page update and its revision in a CTE guarded on the version read. A lost race, a stale `baseVersion`, a page deleted since or a rename onto a taken slug writes nothing and throws a 409 `HttpError`.
42
+ - **The bump is the diff's**: added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it. A metadata-only save moves no version and writes no revision.
43
+ - **History is diffed on read.** A revision stores the page as saved. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown has its real predecessor.
44
+ - **Listings carry a snippet, not the article**: `list`, `recent` and `byIds` drop `content`.
45
+ - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' headline as the snippet. Hidden paths, and paths under them, are left out unless named.
46
+ - `branch` is an agent's walk of a path and everything under it; `counts` is pages per tag path.
47
+ - Copy the models with `npx prisma-models wiki` (`--tenant` for a tenant-keyed copy), then build the full-text column with `npx fts-ddl '"WikiPage"'`.
48
48
 
49
49
  ## Tree
50
50
 
51
- `createTree` is the category tree every wiki declares, and the walks each had written by hand.
51
+ `wiki-formant/tree`: the category tree a wiki declares, and its walks.
52
52
 
53
53
  ```ts
54
54
  import { createTree, parseWikiPath } from 'wiki-formant/tree';
@@ -59,13 +59,13 @@ parseWikiPath(tree, ['contents', 'tech', 'radix-engine', 'history']);
59
59
  // { type: 'page', tagPath: 'contents/tech', slug: 'radix-engine', mode: 'history' }
60
60
  ```
61
61
 
62
- - Metadata keys accumulate down the trail; `sort`, `mainArticle` and `hidden` are inherited, the nearest declaration winning. A field only one wiki reads (an XRD threshold, a band) rides on the node, and the app folds it from `trail`.
63
- - A path that is itself a category wins over a mode suffix, so a category named `history` stays reachable. The empty slug is a category's hub article, and `/<category>/edit` edits it.
64
- - `crumbs` makes one crumb per URL segment, linked only where the tree knows the segment, so a trail can never be shorter than the address bar.
62
+ - Metadata keys accumulate down the trail; `sort`, `mainArticle` and `hidden` inherit, the nearest declaration winning. A field only one wiki reads rides on the node.
63
+ - A path that is itself a category wins over a mode suffix. The empty slug is a category's hub article.
64
+ - `crumbs` makes one crumb per URL segment, linked only where the tree knows it.
65
65
 
66
66
  ## Category view
67
67
 
68
- `categoryView` is a category page as radix.wiki browses one, from the tree, the taxonomy and anything that reads like the store: the count, a sort that shows only once the set needs an A–Z index (by a `date` key where the category declares one), the facets its `select` keys give, a letter that exists, and a card per visible section listing its own pages, then its sections.
68
+ `wiki-formant/category`: a category page from the tree, the taxonomy and anything that reads like the store.
69
69
 
70
70
  ```ts
71
71
  import { categoryView } from 'wiki-formant/category';
@@ -74,33 +74,30 @@ const view = await categoryView({ tree, taxonomy, pages: store }, 'ecosystem', s
74
74
  // { sort, total, pages, all, filters, letter, facets, letters, sortable, clearHref, sortHref, sections }
75
75
  ```
76
76
 
77
- - `pages` is the store, or the app's cached and decorated wrapper over its `list` and `subtree`.
78
- - A link carries `sort` only when it is not the category's own, so the bare category and its default are one address.
77
+ A link carries `sort` only when it is not the category's default, so the bare category and its default are one address.
79
78
 
80
79
  ## Wiki read tools
81
80
 
82
- `wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes`, `get_full_corpus` and `get_page_history`. `searchWiki` is the same rows for an app's plain-GET `?q=` and its search page, so the three never disagree.
81
+ `wiki-formant/wiki-tools`: the read half of a wiki's MCP server: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes`, `get_full_corpus` and `get_page_history`. `searchWiki` is the same rows for a plain-GET `?q=` and a search page.
83
82
 
84
83
  ```ts
85
- import { wikiReadTools, searchWiki } from 'wiki-formant/wiki-tools';
84
+ import { wikiReadTools, searchWiki, type CorpusSection } from 'wiki-formant/wiki-tools';
86
85
 
87
86
  const reading = { store, tree, url: (tagPath, slug) => `${SITE}/${tagPath}/${slug}`, text: extractText, noun: 'the Radix Wiki', corpusTitle: 'Radix Wiki' };
88
87
  const tools = [...wikiReadTools(reading), ...myOwnTools];
89
88
  const { items, total } = await searchWiki(reading, 'validators', { page: 1, size: 20 });
90
89
  ```
91
90
 
92
- - Every row carries `path`, which every read tool takes back, and `chars` where the page's text was read. A pasted URL resolves, with `pathPrefix` stripped; a category's path reads its hub article; a miss names the nearest pages.
93
- - Every row carries the page `id` too, which a `pageList` block takes. `get_page` with `blocks: true` returns the stored blocks, the page `id`, its whole metadata and `version`: what an edit starts from. `forEdit` adds what the app's write tool takes beside them, and `authors` names each revision's author in `get_page_history`.
94
- - `text` should be the serializer the page's markdown twin uses, and `resolve` the resolver its page uses to fill what is read live (a page list's pages): `get_page`, `get_pages` and the default corpus read text through both, so a page reads the same over MCP as at its twin, which `twinParity` checks.
95
- - `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
96
- - `metadata`, `label`, `corpus`, `noMatch` and `skills` are the app's: what a row adds, how a category is named, the sections `get_full_corpus` slices, what an empty search says, and the A2A skills.
97
- - Writes stay with each app, because who may write is the one thing no two of them agree on.
91
+ - Every row carries `path`, which every read tool takes back, its `id` (what a `pageList` block takes), and `chars` where the text was read. A pasted URL resolves, with `pathPrefix` stripped; a miss names the nearest pages.
92
+ - `get_page` with `blocks: true` returns the stored blocks, `id`, metadata and `version`: what an edit starts from. `forEdit` adds the app's own edit fields; `authors` names revision authors.
93
+ - `text` should be the twin's serializer and `resolve` the page's live resolver, so a page reads the same over MCP as at its twin.
94
+ - `get_full_corpus` is a `sizeOnly` preflight, then page-aligned slices under `maxChars` (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. `corpus` returns the app's own `CorpusSection`s.
95
+ - `privateHidden` closes the back office to every tool. `metadata`, `label`, `noMatch` and `skills` are the app's.
96
+ - Writes stay with each app.
98
97
 
99
98
  ## Taxonomy
100
99
 
101
- `tagPath` puts a page in exactly one place in a tree. The `select`-typed metadata it already carries is a *cross-cutting* axis — and it is almost always stored, rendered once in an infobox as dead text, and never made pressable. The failure mode is a 141-page category rendered as one flat grid while the data to split it sits unread in a JSON column.
102
-
103
- The tree is yours; supply `getMetadataKeys` and the rest is derived.
100
+ `wiki-formant/taxonomy`: the cross-cutting browsing axis derived from the `select` metadata pages already carry. The tree is yours; supply `getMetadataKeys` and one `href` builder.
104
101
 
105
102
  ```ts
106
103
  import { createTaxonomy } from 'wiki-formant/taxonomy';
@@ -109,70 +106,40 @@ const taxonomy = createTaxonomy({
109
106
  getMetadataKeys: tagPath => TAGS[tagPath]?.metadataKeys ?? [],
110
107
  href: (tagPath, { sort, filters, letter }) => /* your URL contract */,
111
108
  });
112
-
113
- const filters = taxonomy.facetFilters('ecosystem', searchParams);
114
- const pages = taxonomy.filterPages(allPages, filters, letter);
115
- const facets = taxonomy.buildFacets('ecosystem', allPages, filters, letter);
116
- const index = taxonomy.needsAlphaIndex(allPages.length)
117
- ? taxonomy.alphaIndex(allPages, filters) : [];
109
+ const state = { sort, filters, letter: taxonomy.resolveLetter(all, filters, query.letter) };
110
+ const groups = taxonomy.facetControls('ecosystem', all, state); // [{ key, label, options }]
111
+ const letters = taxonomy.alphaControls('ecosystem', all, state); // [] below the threshold
118
112
  ```
119
113
 
120
- Four behaviours worth knowing, because each replaces a plausible wrong answer:
121
-
122
- - **Each facet is counted over the set narrowed by every *other* active filter**, so its own options stay switchable instead of collapsing to the one already chosen.
123
- - **Values come from the data, not from the declared `options`.** A key that declares four values while the pages hold seven would otherwise hide three behind a bar claiming to cover everything. Render what is there and the drift becomes visible.
124
- - **A single-valued facet hides — unless it is the active one.** An infobox row can set a filter the chips never offered; without its chip the reader lands on a narrowed list with nothing to press to widen it.
125
- - **`metadataRows` returns rows, not markup.** A page's populated keys in schema order, with `href` set exactly on the facet ones. The three wikis each rendered this selection their own way — an HTML table folded into a block, a React `<aside>`, a markdown twin — and differed only in how a *value* is formatted; which keys appear and where each links is the part they had rebuilt three times.
126
- - **`rankRelated` ranks by shared facet values** and returns the shared axis as `{key, value}`, so the *See also* heading can be the link into the filtered set. The behaviour it replaces — `pages.slice(0, 5)` — shows every page in a large category the same five links.
127
-
128
- One `href` builder is passed in and used by every chip, letter and sort button. A sort button that drops the active filters is the tell that a project grew a second one.
129
-
130
- ### The controls, not just the counts
131
-
132
- `buildFacets` and `alphaIndex` hand back numbers; something still has to turn each one into a pressable thing with a destination. That step was rebuilt in all three wikis, and by the time this was written they no longer agreed on what the row *contains*: one shipped no A–Z index at all, one had no way back to the unfiltered set, and two marked the active chip with `aria-pressed` on an `<a>` — which is not a toggle button and does not take that attribute.
133
-
134
- ```ts
135
- const state = { sort, filters, letter: taxonomy.resolveLetter(all, filters, query.letter) };
136
- const groups = taxonomy.facetControls('ecosystem', all, state); // [{key, label, options}]
137
- const letters = taxonomy.alphaControls('ecosystem', all, state); // [] below the threshold
138
- ```
139
-
140
- Every control arrives as `{value, label, count, href, active}`, its `href` already built through your one builder with **every other axis carried along** — the arithmetic that, done by hand, drops the reader's letter the first time they press a chip.
141
-
142
- - **The reset control leads the row**, flagged `reset`, so a consumer that maps the array cannot ship a narrowed view with nothing to press to widen it.
143
- - **`alphaControls` returns `[]` below the index threshold**, folding in `needsAlphaIndex` — one call is either the index you needed or nothing, rather than a decision each caller makes and one caller forgets. A hub's sort toggle reads the same predicate over the same unfiltered count (`needsAlphaIndex(all.length)`; 40 pages unless `alphaIndexMinPages` says otherwise), so a set small enough to need no index offers no sort either.
144
- - **`resolveLetter` drops a letter no page starts with.** A stray `?letter=Q` would otherwise empty the grid with no control marked to explain why.
145
-
114
+ - Each facet is counted over the set narrowed by every *other* filter, so its options stay switchable.
115
+ - Values come from the data, not the declared `options`. A single-valued facet hides unless it is active.
116
+ - Every control is `{ value, label, count, href, active }` with every other axis carried in `href`. The reset control leads the row, flagged `reset`.
117
+ - `alphaControls` is `[]` below `needsAlphaIndex` (40 pages unless `alphaIndexMinPages`); `resolveLetter` drops a letter no page starts with.
118
+ - `metadataRows` returns a page's populated keys as rows with `href` on the facet ones; `rankRelated` ranks by shared facet values and returns the shared axis for the *See also* heading.
146
119
 
147
120
  ## Headings
148
121
 
149
- Heading ids, permalink anchors, and the list an "on this page" rail renders — one pass over the HTML you already have.
122
+ `wiki-formant/headings`: heading ids, permalink anchors, and the list an "on this page" rail renders.
150
123
 
151
124
  ```ts
152
- import { injectHeadingIds, headingsFrom } from 'wiki-formant/headings';
153
-
154
- const html = injectHeadingIds(page.body); // ids + anchors, idempotent
155
- const toc = headingsFrom(html); // [{ id, text, level }]
156
- ```
125
+ import { articleHtml, headingsFrom } from 'wiki-formant/headings';
157
126
 
158
- Two behaviours worth knowing:
159
-
160
- - **The slug rule is a parameter.** A heading id is a live URL — readers link to `#the-shape-of-a-code`, and so does the page's own permalink anchor. The two wikis this was lifted from had drifted onto different rules, and unifying them would have silently moved every published anchor on whichever one lost. Pass `slug` to keep the rule you already ship.
161
- - **Deduping is not a parameter, but its scope is.** Two headings with the same text otherwise mint the same id twice, and every link to the second lands on the first. The unit that must be unique is the page, so a block wiki that injects block by block passes one `used` set to every call — seeded with any id the template renders itself, such as the title's. All three consumers once passed none, and a heading repeated across two blocks shipped two identical ids.
162
-
163
- ```ts
164
127
  const used = new Set([slug(page.title)]);
165
- const blocks = page.blocks.map(b => ({ ...b, text: injectHeadingIds(b.text, { used }) }));
128
+ const blocks = page.blocks.map(b => ({ ...b, text: articleHtml(b.text, { used, links: { selfHost: 'radix.wiki' } }) }));
129
+ const toc = headingsFrom(blocks.map(b => b.text).join('')); // [{ id, text, level }]
166
130
  ```
167
131
 
168
- `headingsFrom` reads the string, not the rendered DOM — possible only where the body IS a string at render time. A wiki whose content streams in as blocks after mount has to query the DOM, and uses only the injector.
132
+ - `injectHeadingIds` is idempotent; `slug` is a parameter, since a heading id is a published URL.
133
+ - Pass one `used` set across a page's blocks, seeded with ids the template renders, or a repeated heading mints a duplicate id.
134
+ - `articleHtml` is the render-path chain: heading ids, `scrollTables`, and with `links` the link pass: an internal link loses `target` and `rel`, an external one gets them, and a bare-URL anchor gets readable text.
169
135
 
170
136
  ## MCP
171
137
 
172
- A minimal [Model Context Protocol](https://modelcontextprotocol.io) server over Streamable HTTP, with the transport edges most implementations get wrong.
138
+ `wiki-formant/mcp`: a [Model Context Protocol](https://modelcontextprotocol.io) server over Streamable HTTP.
173
139
 
174
140
  ```ts
175
141
  import { mcpResponse, mcpGet, mcpOptions, McpToolError } from 'wiki-formant/mcp';
142
+ import { MCP_RATE_LIMIT } from 'wiki-formant/rate-limit';
176
143
 
177
144
  const config = {
178
145
  serverInfo: { name: 'my-wiki', version: '1.4.0' },
@@ -181,14 +148,11 @@ const config = {
181
148
  tools: [{
182
149
  name: 'search_pages',
183
150
  description: 'Full-text search across the wiki.',
184
- inputSchema: {
185
- type: 'object',
186
- properties: { q: { type: 'string', description: 'query' } },
187
- required: ['q'],
188
- },
151
+ inputSchema: { type: 'object', properties: { q: { type: 'string', description: 'query' } }, required: ['q'] },
189
152
  annotations: { readOnlyHint: true },
190
153
  handler: async ({ q }) => search(String(q)),
191
154
  }],
155
+ rateLimit: MCP_RATE_LIMIT,
192
156
  onCall: (req, body) => track(req, body),
193
157
  };
194
158
 
@@ -197,153 +161,93 @@ export const GET = () => mcpGet(config.docsUrl);
197
161
  export const OPTIONS = () => mcpOptions();
198
162
  ```
199
163
 
200
- What it gets right:
201
-
202
- - **A caller-fixable mistake is a tool result with `isError`, never `-32603`.** Bad arguments come back naming every bad field at once, quoting the legal values and appending the schema, so one retry can fix all of them.
203
- - **Capabilities advertise only what the config populates.** An advertised `resources` whose list comes back empty reads as a bug, not as honesty. The `-32601` method list narrows the same way.
204
- - **A notification-only POST answers a bare `202`**, not a `200` carrying JSON `null`.
205
- - **Malformed JSON is `-32700` with a `400`**, never a 500.
206
- - **`GET` is an explicit 405 with CORS headers.** A framework's automatic 405 carries none, so a browser client cannot even read the refusal.
207
- - **The preflight allow-list includes `Accept` and `Mcp-Protocol-Version`.** One missing entry fails the preflight rather than the POST, which presents as "the server is down".
208
- - **Batches are capped** (default 20) with a teaching error, because the rate limiter charges one token per HTTP request before the body is parsed. From 2025-06-18 on they are refused outright, because that revision removed them.
209
- - **Both protocol eras, one endpoint.** A request carrying `io.modelcontextprotocol/protocolVersion` in `params._meta` is served as `2026-07-28`: `server/discover`, per-request version and header validation (`-32022` naming every version spoken, `-32020` on a routing header that disagrees with the body), `resultType` and cache hints on results, and 404 for the methods that revision removed. Everything else is the legacy era, where `initialize` echoes the client's version when it is one of `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05` and offers the newest otherwise. The spec permits a dual-era server, and it is the only way to adopt the new revision without failing the handshake of every client already in the field.
210
- - **The A2A call an agent card invites is answered.** Every card names this endpoint as its JSON-RPC `url`, so `message/send` (v0.3) and `SendMessage` (v1.0) get a message back: the result of the tool `answer` names (`{ tool: 'search_wiki', argument: 'query' }`), or `instructions` and the tool names without one. The task methods are `-32001`, since no task is ever created; streaming and push are `-32004`.
211
- - **The version is negotiated, not asserted.** Every response carries it back in `MCP-Protocol-Version`. Answering a constant is legal and still costs the caller structured output, tool titles and `_meta` without ever saying so.
212
- - **`Access-Control-Expose-Headers` is set.** Allow-Headers governs what a browser may send, Expose-Headers what it may read — without the second a browser client cannot see `Retry-After` on a 429, and a rate limit presents as a hang.
213
- - **The rate limit is declared, not wired.** Put `rateLimit: {capacity, refillPerSec}` on the config and `mcpResponse` enforces it before parsing the body, refuses with a JSON-RPC envelope, and states `RateLimit-Limit`/`-Remaining`/`-Reset` on every answer. Four routes had each transcribed that by hand through three differently-named local helpers, which is how a headroom header gets added to one surface and forgotten on the next.
214
- - **`mcpRateLimited(retryAfterSec)` is a JSON-RPC envelope.** A 429 whose body is `{"error": "..."}` is a string where the client's parser expects `{code, message}`, on the one response an agent meets exactly when it is working hard.
215
-
216
- ### Structured output, `_meta`, and the envelope gate
217
-
218
- **Every object a handler returns comes back as `structuredContent` beside the text block**, so a client reads the answer rather than scraping prose for it. This used to be gated on declaring an `outputSchema`, and the result was that across four live servers and thirty-two tools — every one of them answering in JSON — not a single response ever carried it. `outputSchema` remains the stronger contract, because a client validates against it; it is no longer the price of admission. A handler returning a string is left alone.
219
-
220
- `inputSchema.requireOneOf` names a set of which at least one must be present. `required` cannot express "query or popular", so the one tool needing it checked in its handler and the caller learned at execution time — the single class of argument mistake this module was otherwise catching before dispatch.
221
-
222
- A handler's second argument is its context:
223
-
224
- ```ts
225
- handler: async (args, ctx) => {
226
- ctx.meta; // params._meta, as it arrived
227
- ctx.setMeta('x402/payment-response', receipt); // rides back on result._meta
228
- return { hits: 2 };
229
- }
230
- ```
231
-
232
- `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
233
-
234
- ### Agent feedback
235
-
236
- `feedbackTool(record, noun)` is `send_feedback`: an agent says what it needed and did not find, what is wrong or what broke (`kind` is `missing`, `wrong`, `broken` or `suggestion`), in `FEEDBACK_MAX` characters. It asks for something to act on rather than for a rating, which an agent answers with praise. List it among a server's tools and the transport does the inviting: `initialize` appends `FEEDBACK_INVITE` to `instructions`, and an empty `listEnvelope` page or an `McpToolError` carries a one-line `feedback` pointer, because a miss is when an agent knows what it was after. A tool whose miss has another shape quotes `FEEDBACK_NUDGE` in its own note. A server without the tool mentions it nowhere. Pass `tracker.trackFeedback` as `record` and each report is an "Agent Feedback" event, listed newest first in the digest's `agent_feedback`. Nothing is published: a report is a stranger's text, read as data.
237
-
238
- ```ts
239
- tools: [...TOOLS, feedbackTool(tracker.trackFeedback, 'the Radix Wiki')]
240
- ```
241
-
242
- ### Tool arguments and the corpus tool
243
-
244
- `readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
245
-
246
- `wiki-formant/corpus` is the arithmetic behind a `get_full_corpus` tool: a `sizeOnly` preflight with per-branch and largest-page breakdowns, then page-aligned slices under a `maxChars` budget (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. The corpus itself — which rows, how a page becomes a section — is the wiki's; `sliceCorpus` takes the sections it builds.
164
+ - A caller-fixable mistake is a tool result with `isError`, never `-32603`: bad arguments name every bad field, quote legal values and append the schema. Throw `McpToolError(message, details)` from a handler for the same.
165
+ - Capabilities, and the `-32601` method list, advertise only what the config populates.
166
+ - A notification-only POST is a bare `202`; malformed JSON is `-32700` with a `400`; `GET` is an explicit 405 with CORS; the preflight allow-list and `Access-Control-Expose-Headers` cover what MCP clients send and read.
167
+ - Batches are capped (`DEFAULT_MAX_BATCH`, 20) and refused from 2025-06-18 on.
168
+ - Both protocol eras on one endpoint: a request with `io.modelcontextprotocol/protocolVersion` in `params._meta` is served as `2026-07-28` (`server/discover`, per-request version checks, `resultType` and cache hints); everything else is the legacy era, which echoes a supported client version. Every response carries `MCP-Protocol-Version`.
169
+ - The A2A call an agent card invites (`message/send`, `SendMessage`) is answered from the tool `answer` names.
170
+ - `rateLimit: { capacity, refillPerSec }` is enforced before the body is parsed, with `RateLimit-*` headers on every answer; `mcpRateLimited(sec)` is the JSON-RPC 429.
171
+ - Every object a handler returns comes back as `structuredContent` beside the text; `outputSchema` is the stronger contract when declared. `inputSchema.requireOneOf` names a set of which one must be present.
172
+ - A handler's second argument is its context: `ctx.meta` (the request's `params._meta`) and `ctx.setMeta(key, value)` (onto `result._meta`). `config.gate` is envelope-level middleware, for a payment gate whose demand replaces the call.
173
+ - `readArgs` reads raw arguments as typed, clamped values and records every override; `withAdjustments` puts them on the result as `adjustments`.
174
+ - `feedbackTool(record, noun)` is `send_feedback`. Listed, `initialize` appends `FEEDBACK_INVITE` and an empty `listEnvelope` page or an `McpToolError` carries a one-line pointer to it.
247
175
 
248
176
  ## Conformance
249
177
 
250
- `wiki-formant/conformance` is the half of an MCP conformance run that is not about any one server's tools: a JSON-RPC client that backs off on a 429, the transport assertions (CORS preflight, `GET`→405, a notification answering 202 with no body, `-32700`, the batch cap, honest `capabilities`, version negotiation), version coherence across every descriptor a surface publishes, A2A card parity, conditional-GET and `robots` checks, and a pass/fail table with an exit code.
178
+ `wiki-formant/conformance`: the half of an MCP conformance run that is not about one server's tools. Every value it compares against is imported from `wiki-formant/mcp`.
251
179
 
252
180
  ```ts
181
+ import { createTester, standardSuite, wikiReadChecks } from 'wiki-formant/conformance';
182
+
253
183
  const t = createTester({ base: 'https://example.com', clientName: 'my-mcp-test' });
254
- await transportChecks(t, 'my-mcp-test');
255
- await annotationChecks(t, { writes: ['create_page'] });
256
- await payloadBudget(t, [{ name: 'search', args: { query: 'x' } }]);
184
+ const init = await standardSuite(t, {
185
+ version: serverManifest.version,
186
+ openapi: ['/openapi.json', '/.well-known/openapi.json'],
187
+ writes: ['create_page', 'send_feedback'],
188
+ manifest: { writeAuth: true },
189
+ budget: [{ name: 'search_wiki', args: { query: 'x' } }, { name: 'get_full_corpus', maxBytes: 260_000 }],
190
+ conditionalGet: ['llms.txt'],
191
+ robots: ['/api/mcp', '/llms.txt', '/.well-known/agent-card.json'],
192
+ });
193
+ const { first } = await wikiReadChecks(t, { query: 'radix', address: r => ({ tagPath: r.tagPath, slug: r.slug }), twin: r => `${r.path}.md` });
257
194
  process.exit(t.summary());
258
195
  ```
259
196
 
260
- What stays in your repo is fixtures: which tools you expect, what a good answer from each looks like, and which text surfaces you publish. `payloadBudget` weighs every listed call **and** every read-only tool that takes no required arguments, because the one tool nobody thought to list is the one that answers with 3.3 MB; pass a per-call `maxBytes` for a bulk-export tool that is deliberately large. It also asserts that a JSON answer arrived with `structuredContent`.
197
+ `standardSuite(t, cfg)` runs the suites every surface runs, in order: `transportChecks` (CORS preflight, `GET`→405, 202 for a notification, `-32700`, the batch cap, honest capabilities, version negotiation), `versionCoherence` over `standardVersionSources`, `agentCardParity`, `annotationChecks`, `manifestParity`, `payloadBudget`, `conditionalGetChecks`, `descriptorChecks` (default `S10_DESCRIPTORS`) and `robotsChecks`. `budget`, `conditionalGet` and `robots` run only when given, for a surface whose fixtures exist only later in its run. It returns the `initialize` response. Each suite is exported for a surface that needs it alone.
261
198
 
262
- The drift checks hold an MCP server to what its own site does, so a tool cannot describe, accept or return something the site does not:
199
+ `wikiReadChecks(t, opts)` checks the `wiki-tools` read tools: categories, a search and the page it finds (by `address`, default `{ path }`), its twin (given `twin`), a listing, recent changes, a corpus preflight and a slice that is cut and resumes when the corpus exceeds `corpusChars`, and the teaching answers to no hits, a missing argument, an unknown tool and a missing page. `endpoint` and `label` run it against a second server. It returns `{ rows, first, page }`.
263
200
 
264
- - `manifestParity(t, { writeAuth? })`: `/.well-known/mcp.json` is `tools/list` tool for tool, names the endpoint under test, and states write auth only where a write tool is listed (both ways, given `writeAuth`).
265
- - `rowParity(t, label, mcp, rest)`: a tool's answer and the GET API's or `?q=` page's are identical, and not both empty.
266
- - `twinParity(t, label, twinUrl, text)`: a read tool's text is the page's markdown twin, past its frontmatter and the title line.
267
- - `editReadChecks(t, { path, rows?, validate?, endpoint? })`: rows carry page ids, `get_page` with `blocks: true` returns blocks the site's own `validate` accepts and a version, and `get_page_history`'s newest revision is that version.
268
- - `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
269
- - `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
270
- - `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
271
- - `freshAfterWriteChecks(t, urls, write, { shows?, gone? })`: each URL is read, `write` runs, and each then carries `shows` and not `gone`. It catches a twin or `llms.txt` the CDN keeps past the save that changed it, which every check that reads a surface once passes. Run it against a deployment: with no CDN in front it passes trivially.
201
+ - `payloadBudget` weighs every listed call and every read-only tool with no required arguments, and asserts a JSON answer carries `structuredContent`.
202
+ - `conditionalGetChecks` wants a 304 on `If-None-Match` (alone and in a list) and a `Last-Modified`; `descriptorChecks` wants an ETag and a `max-age` on a JSON descriptor.
203
+ - `t.rpcAt(endpoint, …)` and `t.callAt` drive another endpoint with the same backoff, User-Agent and tally.
272
204
 
273
- `standardVersionSources(t, openapiPaths)` is the `versionCoherence` map every S10 surface serves — both agent-card paths, `mcp.json`, the server card and each path the OpenAPI document answers at — so a suite names only the paths it adds. `t.rpcAt(endpoint, …)` and `t.callAt` drive a second MCP endpoint on the same surface with the same backoff, User-Agent and tally.
205
+ The drift checks hold a server to what its own site does:
274
206
 
275
- ```ts
276
- await versionCoherence(t, server.version, standardVersionSources(t, ['/openapi.json', '/api/openapi.json']), init.result?.serverInfo?.version);
277
- ```
278
-
279
- `descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves.
207
+ - `manifestParity(t, { writeAuth? })`: `/.well-known/mcp.json` is `tools/list`, names the endpoint, and states write auth only beside a write tool.
208
+ - `rowParity(t, label, mcp, rest)`: a tool's rows and the GET API's are identical and not empty.
209
+ - `twinParity(t, label, twinUrl, text)`: a tool's text is the page's markdown twin, past frontmatter and title.
210
+ - `editReadChecks(t, { path, rows?, validate?, endpoint?, label? })`: rows carry ids, `get_page` with `blocks: true` returns blocks the site's `validate` accepts, and the history's newest revision is that version.
211
+ - `roundTrip(t, label, { read, write })`: a write takes its read back unchanged.
212
+ - `openapiWriteParity(t, [[path, method, tool], …], specUrl?)`: every field an OpenAPI write documents, the tool takes.
213
+ - `rolaOriginCheck(t)`: the ROLA recipe names the origin under test.
214
+ - `freshAfterWriteChecks(t, urls, write, { shows?, gone? })`: each URL shows the write within five seconds. Run it against a deployment, since with no CDN it passes trivially.
280
215
 
281
216
  ## Markdown twins
282
217
 
283
- Pass an `etag` and answer `notModified` before rendering: a twin is the single most recrawled URL a page has, so a twin with no validator is a full render on every pass, forever — the same arithmetic that justifies the corpus ETag, applied per page.
284
-
285
- `htmlToMarkdown` preserves the structure an agent cites by — headings, lists, tables, code, emphasis — rather than flattening to prose. Tables convert first so the generic rules cannot eat their markup, ordered lists number per list, pipes inside cells are escaped, and a headerless table gets a synthesised header because GFM has no other form.
218
+ `wiki-formant/markdown`: `htmlToMarkdown` keeps the structure an agent cites by (headings, lists, tables, code, emphasis); `markdownDocument` adds the frontmatter.
286
219
 
287
220
  ```ts
288
221
  import { htmlToMarkdown, markdownDocument } from 'wiki-formant/markdown';
222
+ import { markdownHeaders, cacheTagHeaders, VARY_ACCEPT } from 'wiki-formant/http';
289
223
 
290
- return new Response(
224
+ return notModified(request, etag, lastModified) ?? new Response(
291
225
  markdownDocument(
292
- { title: page.title, url, updated: page.updatedAt, lastVerified: page.lastVerifiedAt,
293
- license: { spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/' } },
294
- blocksToMarkdown(page.content), // your block types, your function
226
+ { title: page.title, url, updated: page.updatedAt, lastVerified: page.lastVerifiedAt, license },
227
+ blocksToMarkdown(page.content),
295
228
  ),
296
- { headers: markdownHeaders(lastModified, { etag, noindex: true }) },
229
+ { headers: markdownHeaders(lastModified, { etag, noindex: true, extra: { ...VARY_ACCEPT, ...cacheTagHeaders(pageTag(path)) } }) },
297
230
  );
298
231
  ```
299
232
 
300
- `noindex` sends `X-Robots-Tag: noindex`, for a twin of an HTML page: a search engine that reads it has the page already. A document with no HTML page, an AGENTS.md, leaves it off.
301
-
302
- Put `cacheTagHeaders(tags)` in `extra`, with the tags your save path already revalidates. A route handler is held at Vercel's CDN by its `s-maxage` alone, so without `Vercel-Cache-Tag` the twin outlives every save by its edge window while the ISR page beside it is fresh. The same goes for `llms.txt`, a search index and a sitemap: any text an agent reads back after writing.
303
-
304
- ```ts
305
- headers: markdownHeaders(lastModified, { etag, noindex: true, extra: { ...VARY_ACCEPT, ...cacheTagHeaders(pageTag(path)) } })
306
- ```
307
-
308
- Block trees stay in your app — every project owns its own type set. Give this module HTML and it gives you markdown.
309
-
310
- > A trap worth naming: if you serve twins via a rewrite, Next drops the destination query string. The rewrite must carry the `.md` extension through to the destination path, or the twin silently serves JSON.
311
-
312
- ## Conditional GET
313
-
314
- `llms.txt` and the markdown twins are the most-recrawled URLs a wiki serves, and a corpus export the most expensive to render. Without a corpus-derived ETag, every AI crawler pays full price on every pass, forever.
315
-
316
- A URL that answers JSON or markdown by `Accept` asks `wantsMarkdown(request)` and sends `VARY_ACCEPT` on both branches. Without the Vary, a shared cache hands one client the other's format.
233
+ - Tables convert first, ordered lists number per list, cell pipes are escaped, and a headerless table gets a header.
234
+ - `noindex` is for a twin of an HTML page; a document with no HTML page (AGENTS.md) leaves it off.
235
+ - `cacheTagHeaders(tags)` with the tags the save path revalidates, or the CDN holds a route handler's twin past every save. The same goes for `llms.txt`, a search index and a sitemap.
236
+ - Next drops a rewrite destination's query string, so a twin rewrite carries the `.md` extension through to its destination.
317
237
 
318
- ```ts
319
- import { corpusEtag, notModified, textHeaders } from 'wiki-formant/http';
320
-
321
- const etag = corpusEtag([pageCount, newestUpdatedAt]);
322
- const lastModified = newestUpdatedAt.toUTCString();
323
-
324
- export async function GET(request: Request) {
325
- return notModified(request, etag, lastModified)
326
- ?? new Response(buildCorpus(), { headers: textHeaders(etag, lastModified) });
327
- }
328
- ```
238
+ ## Conditional GET and route errors
329
239
 
330
- `descriptorResponse` and `descriptorHandler` serve a JSON descriptor (an agent card, a manifest, an OpenAPI document) with its own ETag and `Access-Control-Allow-Origin: *`, since every descriptor is public and browser agents read them; six of nine routes had passed the header by hand.
331
-
332
- `corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
240
+ `wiki-formant/http`: validators, the headers of every text surface, and the answers a route gives when it is not answering with data.
333
241
 
334
242
  ```ts
335
- const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
336
- corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
337
- ```
338
-
339
- ### Route errors
340
-
341
- `json`, `errors` and `handleRoute` are the answers a route handler gives when it is not answering with data, as web-standard `Response`s a Next handler returns as they are. Every error body is `{ error, ...extra }`: one origin had these helpers, one had a second convention with a third copy inside a single route, and two answered with whatever `Response.json` was nearest, so one mistake came back three ways depending on the origin.
243
+ import { corpusValidatorsFrom, corpusRoute, errors, handleRoute, HttpError } from 'wiki-formant/http';
342
244
 
343
- ```ts
344
- import { errors, handleRoute, HttpError } from 'wiki-formant/http';
245
+ export const GET = corpusRoute(async () => {
246
+ const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
247
+ return corpusValidatorsFrom([agg._count], [agg._max.updatedAt]);
248
+ }, buildCorpus);
345
249
 
346
- export const GET = (req: Request) => handleRoute(async () => {
250
+ export const POST = (req: Request) => handleRoute(async () => {
347
251
  const q = new URL(req.url).searchParams.get('q');
348
252
  if (!q) return errors.badRequest('q is required');
349
253
  if (q.length > 200) throw new HttpError(400, 'q is too long', { code: 'INVALID_PARAMS' });
@@ -351,325 +255,238 @@ export const GET = (req: Request) => handleRoute(async () => {
351
255
  }, 'Search failed');
352
256
  ```
353
257
 
354
- A thrown `HttpError` answers with its own status and message. Anything else thrown is logged and answered with a 500 carrying only `errorMsg`, so an exception's text — a database error naming a table — never reaches the caller. `errors.tooManyRequests(sec)` states `Retry-After`.
258
+ - `corpusEtag`, `notModified` and `textHeaders` are the parts; `corpusRoute` skips the build on a 304.
259
+ - A URL answering JSON or markdown by `Accept` asks `wantsMarkdown(request)` and sends `VARY_ACCEPT` on both branches.
260
+ - `descriptorResponse` serves a JSON descriptor with its own ETag and `Access-Control-Allow-Origin: *` (`descriptorHandler` in `wiki-formant/well-known` is the route).
261
+ - Every error body is `{ error, ...extra }`. A thrown `HttpError` answers with its status; anything else is logged and answered with a 500 carrying only the handler's message (`'Search failed'`), so no exception text reaches a caller. `errors.tooManyRequests(sec)` states `Retry-After`.
355
262
 
356
- ## Response headers
263
+ ## Response headers and the Next config
357
264
 
358
- `wiki-formant/headers` holds the six headers every route sends and the Content-Security-Policy they share, for `next.config.ts` to import:
265
+ `wiki-formant/headers`: the headers every route sends, the shared CSP, and the Next config defaults. It imports nothing, so `next.config.ts` pays for nothing it does not use.
359
266
 
360
267
  ```ts
361
- import { contentSecurityPolicy, securityHeaders } from 'wiki-formant/headers';
268
+ import type { NextConfig } from 'next';
269
+ import { contentSecurityPolicy, withWikiDefaults } from 'wiki-formant/headers';
362
270
 
363
271
  const csp = contentSecurityPolicy({ 'connect-src': ["'self'", 'https://*.radixdlt.com'] });
364
-
365
- const nextConfig = {
366
- async headers() {
367
- return [{ source: '/:path*', headers: securityHeaders(process.env.NODE_ENV === 'production' ? csp : null) }];
368
- },
369
- };
272
+ export default withWikiDefaults({ async redirects() { /* … */ } } satisfies NextConfig, { csp });
370
273
  ```
371
274
 
372
- Four configs had restated these by hand on the grounds that a config could not import an ESM-only package. It could not because no subpath had a `default` condition, and Next resolves a config's imports the way `require` does; every subpath has one now, and Node 24 loads the module there unchanged.
275
+ `withWikiDefaults(config, { csp })` adds:
373
276
 
374
- `withWikiDefaults(config, { csp })` wraps the whole config: the headers on every route with `csp` enforced in production, AVIF before WebP from the image optimiser, Turbopack's build cache off (Vercel restored a stale one and shipped a stylesheet two commits old), and `distDir` from `STUDIO_DIST_DIR` for radix-studio's second dev server. What the app sets wins, and its own `headers()` follow these.
277
+ - `securityHeaders` on every route, `csp` enforced in production only (Next's dev runtime is inline and `eval`); an origin with a per-request nonce passes none.
278
+ - `/openapi.json` rewritten to `/.well-known/openapi.json` first in `beforeFiles`, merged with the app's own `rewrites()` in either form.
279
+ - AVIF before WebP, `turbopackFileSystemCacheForBuild: false` (Vercel restores a stale build cache), and `distDir` from `STUDIO_DIST_DIR` for radix-studio's second dev server.
375
280
 
376
- ```ts
377
- export default withWikiDefaults<NextConfig>({ async redirects() { /* … */ } }, { csp });
378
- ```
379
-
380
- `contentSecurityPolicy` starts from the policy all four origins shared line for line, and each directive passed replaces its default rather than appending, so the call site reads as the whole list. An empty list drops a directive.
281
+ What the app sets wins, and its `headers()` follow these. `contentSecurityPolicy` starts from the shared base and each directive passed replaces its default; an empty list drops one. The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the sanitiser's default `iframeHosts`, so an embed that clears one clears the other; a site with its own hosts passes the same list to both.
381
282
 
382
- The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the same list the sanitiser's `iframeHosts` defaults to. The sanitiser's comment had asked every config to keep the two matched by hand, and one did not: its policy lacked the bare `youtube.com` and `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning and then rendered blank. A site with its own host list passes it to the sanitiser and states the same hosts in its `frame-src`.
283
+ ## Pagination, versioning and rate limits
383
284
 
384
- ## Pagination and versioning
285
+ ```ts
286
+ import { parsePagination, paginatedResponse, listEnvelope } from 'wiki-formant/pagination';
287
+ import { bump, parseVersion } from 'wiki-formant/versioning';
288
+ import { limitClient, rateLimitedResponse } from 'wiki-formant/rate-limit';
385
289
 
386
- `parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
290
+ const { page, pageSize } = parsePagination(searchParams); // page ≥ 1, pageSize 1–100
291
+ return Response.json(paginatedResponse(items, total, page, pageSize)); // always carries totalPages
387
292
 
388
- `parseVersion` / `bump` handle revision semver tolerantly — a page always has a version, even when the column holds `null` or junk.
293
+ bump(page.version, 'minor'); // tolerant of null or junk
389
294
 
390
- ## React
295
+ const limit = limitClient('search', request.headers, { capacity: 30, refillPerSec: 0.5 });
296
+ if (!limit.ok) return rateLimitedResponse(limit.retryAfterSec);
297
+ ```
391
298
 
392
- `wiki-formant/react` is the one part that needs React, so it is the one part behind its own subpath, and React is an *optional* peer. What is shared is behaviour; every class name and icon is passed in, because these wikis style their rails differently and always will.
299
+ - `listEnvelope` is the shape every list tool answers with, its `note` on an empty page.
300
+ - `rateLimit` is an in-memory token bucket; `blockMs` blocks an abusive key outright across every bucket naming it. `clientIp` reads `x-real-ip` first, then the leftmost `x-forwarded-for`. `MCP_RATE_LIMIT` is the MCP servers' budget.
393
301
 
394
- `SidebarProvider` / `useSidebar` hold the rail's collapse state — remembered across loads, defaulted from the viewport only when the reader has never chosen. Pair it with `sidebarBootScript` from `wiki-formant/sidebar` (framework-free, so a server component can stamp it into `<head>`) or a remembered-closed rail paints open and animates shut on every load. `TableOfContents` is the "on this page" list, with scroll-spy, reading either headings you already know or the rendered article.
302
+ ## React
395
303
 
396
- The rail's breakpoint is necessarily known twice — `matchMedia` here, a media query in a stylesheet — because a media query cannot read a JS constant and a JS constant cannot read a media query. `base.css` holds the CSS half: with `RailShell prefix="wiki-sidebar"` inside a `.wiki-layout` beside a `.wiki-layout__main`, the rail is a sticky column from 1024px and, below it, a drawer stacked above the article that the same open/closed choice shows or removes, table of contents included. 1024 is the default `breakpoint` of `SidebarProvider` and `sidebarBootScript`, so a wiki on `base.css`'s rail passes none. It is measured: an 18rem rail, a full `--measure` of prose (63ch of Inter is 636px) and the page gutters need 972px, and 1024 is the gutter tier above that. The site supplies `--rail-top` (what the sticky rail clears), `--rail-border` (the rule beside the column, which becomes the drawer's foot) and the surface, padding and gap.
304
+ `wiki-formant/react` carries `'use client'`. What is shared is behaviour; class names and icons are passed in.
397
305
 
398
- What the two halves can be stopped from doing is parting in silence. `base.css` sets `--rail-floating: 1` inside its media query, and the hook checks the two agree on every breakpoint crossing, in development, naming both sides and the number to change. A wiki that lays its rail out itself opts in the same way:
306
+ ```tsx
307
+ import { LinkPreviewCard, SidebarProvider, RailShell, TableOfContents, useLinkPreview } from 'wiki-formant/react';
399
308
 
400
- ```css
401
- @media (max-width: 767px) { /* the edge of breakpoint={768} */
402
- :root { --rail-floating: 1; }
403
- .sidebar { /* … however this wiki lays its rail out */ }
309
+ function LinkPreview() {
310
+ const { preview, cardProps } = useLinkPreview<Preview>({ eligible, fetch: fetchPreview, cardHeight: 200 });
311
+ return preview && <LinkPreviewCard {...cardProps} title={preview.title} excerpt={preview.excerpt} bannerImage={preview.bannerImage} />;
404
312
  }
405
313
  ```
406
314
 
407
- Without the property the check stays quiet, since a package cannot require CSS it does not ship. The mechanism is `railBreakpointMismatch` and `readRailFloating` from `wiki-formant/sidebar`, both exported, the first pure.
408
-
409
- `useTypeahead` is the search field's state machine. Five surfaces across the three wikis had three implementations and no two agreed on what a search field does; this is their union, because each had a piece the others lacked:
410
-
411
- ```ts
412
- const { query, setQuery, items, highlight, setHighlight, onKeyDown, reset } =
413
- useTypeahead({ fetch: searchPages, onPick: page => router.push(page.href) });
414
- ```
415
-
416
- - **A request-id guard**, so a slow response cannot paint over a newer one. Two of the five surfaces had none: type fast enough and you are reading results for a query you have already replaced.
417
- - **Keyboard navigation.** Two surfaces had none — including the header dropdown of a wiki whose search *is* its primary navigation.
418
- - **Abort and a per-query cache**, which only the third had. The cache is per mount, so a stale result cannot outlive the page.
315
+ - `SidebarProvider` / `useSidebar` hold the rail's collapse state, remembered across loads and defaulted from the viewport only when the reader never chose. Pair it with `sidebarBootScript` (`wiki-formant/sidebar`) in `<head>`, or a remembered-closed rail paints open first.
316
+ - `RailShell` renders the rail's landmark, scroll wrapper and collapse states, and closes a mobile drawer on a followed link; mark the active link with `isRailLinkActive`. With `RailShell prefix="wiki-sidebar"` in a `.wiki-layout` beside `.wiki-layout__main`, `base.css` lays it out: a sticky column from 1024px, a drawer above the article below it. The site supplies `--rail-top`, `--rail-border` and the surface.
317
+ - The breakpoint is known twice, in `matchMedia` and in CSS. `base.css` sets `--rail-floating: 1` inside its media query and the hook warns in development when the two disagree; a rail laid out by its own CSS sets the property the same way. `railBreakpointMismatch` and `readRailFloating` (`wiki-formant/sidebar`) are the mechanism.
318
+ - `TableOfContents` is the "on this page" list with scroll-spy.
319
+ - `useLinkPreview` is the hover card: one delegated listener, an intent delay, a grace period into the card, viewport clamping and a per-href cache. `LinkPreviewCard` is its markup (`link-preview-card`, `-banner`, `-body`, `-title`, `-excerpt`); a caller that must move the card passes its own `style`.
320
+ - `useTypeahead` is the search field's state machine: a request-id guard, keyboard navigation, abort and a per-mount cache. `fetch` must be referentially stable. Its `combobox(listKey)` hands out `inputProps`, `listProps` and `optionProps(i)`, with `aria-activedescendant` omitted rather than emptied; `listKey` keeps two rendered lists' ids apart. The arithmetic is `wiki-formant/combobox`, which imports nothing.
321
+ - `useSnapRow` is a carousel as a scroll-snapped row: `step`, `go(i)` and `at`, no markup.
322
+ - `useClickOutside` ignores a container hidden at the current breakpoint (`offsetParent`).
323
+ - `useUnsavedChanges(dirty)` asks before leaving and returns `confirmLeave()` for a client-side navigation; `useSaveShortcut(save)` binds ⌘S / Ctrl+S; `useSearchShortcut(open)` is "/" to search, with `focusShown(input)`.
324
+ - `SortHeader` renders `useTableSort`'s `headerProps(key)` as a `<th aria-sort>` holding a `<button>`, the markup `sortTables` writes.
325
+ - `useBlockOperations(blocks, setBlocks, { duplicate })` and `BlockActions` are a block list's five edits and their buttons; `useRevisionRestore(restore, { onRestored })` confirms and restores, `restoreViaPost(endpoint)` being the usual `restore`.
326
+ - `useArticlePasses(ref, [blocks])` runs `wiki-formant/dom`'s passes and `renderMath` (KaTeX, an optional peer, loaded only when a page has `$$…$$`, `\[…\]` or `\(…\)`).
327
+ - `<Beacon />` sends page views and outbound clicks to `wiki-formant/analytics`.
419
328
 
420
- `fetch` must be referentially stable; it is an effect dependency. Its `signal` is optional to implement — forward it from a REST fetcher, ignore it in a server action.
329
+ `useClickOutside`, `useSnapRow` and the editor hooks ship no class names.
421
330
 
422
- `useLinkPreview` is the Wikipedia-style hover card: one delegated listener rather than a component per link, which is what makes it viable over an article holding hundreds of anchors. Two wikis had written the same ninety lines — same intent delay, same grace period for the cursor to cross into the card, same clamp arithmetic, same cache. They differed in three things, and those three are the options: which anchors are eligible, what fetches a preview, and how tall the card is.
331
+ ## Server components
423
332
 
424
- `useSnapRow` is a carousel as a row scrolled sideways under `scroll-snap-type: x mandatory`, so a finger swipes it with no script; AcuiQ's protocol card, which swapped one point for the next in state, could not be swiped at all. The hook is the rest: `step` for arrows and arrow keys, wrapping round at either end, `go(i)` for a dot or for opening a lightbox on the picture clicked, and `at` for the counter. It ships no markup and no class names, since miow's rows hold pictures and AcuiQ's hold whole acupoints.
333
+ `wiki-formant/react-server`: the same React without the directive, for parts that are pure functions of their props. Each takes the router's link component, since a bare `<a>` turns every press into a full load.
425
334
 
426
335
  ```tsx
427
- const { ref, onScroll, at, step, go } = useSnapRow();
428
- <div ref={ref} onScroll={onScroll} className="row">{items}</div>
429
- ```
430
-
431
- `useClickOutside` is fifteen lines and was in all three. Only one had the `offsetParent` check, and without it a container hidden at the current breakpoint still answers outside-clicks — so on a phone, a tap anywhere dismisses the popover the reader is looking at, because the hidden desktop copy got there first.
432
-
433
- `useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed. `useSearchShortcut(open)` is Wikipedia's "/" to search, ignored while the reader types in a field: `open` focuses the field or opens what holds it, and returns false to leave the key alone, which `focusShown(input)` does for a field hidden at this width.
434
-
435
- ### The listbox contract
436
-
437
- `useTypeahead` shared the state machine across five surfaces and left the ARIA
438
- behind, so all five drifted into different wrongness: one put `aria-selected` on
439
- a plain `<button>`, which is not a role that takes it; one gave the rows
440
- `role="option"` and the container no `role="listbox"`, so the options had no
441
- owner; two had no roles at all; one used a `data-highlighted` attribute, which
442
- nothing reads. None of the five set `aria-activedescendant`, which is the
443
- attribute that announces the highlighted row as the reader arrows through it.
336
+ import Link from 'next/link';
337
+ import { Breadcrumbs, FacetBar, FacetSummary, InfoboxFacts, JsonLd, Pager } from 'wiki-formant/react-server';
444
338
 
445
- ```tsx
446
- const { items, highlight, setHighlight, combobox } = useTypeahead({ fetch, onPick });
447
- const { inputProps, listProps, optionProps } = combobox();
448
-
449
- <input {...inputProps} />
450
- <ul {...listProps}>
451
- {items.map((item, i) => (
452
- <li key={item.id} {...optionProps(i)} onMouseEnter={() => setHighlight(i)}>…</li>
453
- ))}
454
- </ul>
339
+ <Breadcrumbs link={Link} items={tree.crumbs(path)} base={SITE_URL} />
340
+ <FacetBar link={Link} facets={groups} letters={letters} alphaFirst />
341
+ <Pager href={p => `?page=${p}`} page={page} total={totalPages} noun="Page" />
455
342
  ```
456
343
 
457
- - **`aria-activedescendant` is omitted, never emptied.** An id resolving to no
458
- element is worse than no attribute: the field claims an active option and the
459
- reader announces nothing.
460
- - **`combobox(listKey)` takes a key** because one hook often feeds two rendered
461
- lists — a header with a desktop field and a mobile one puts both in the same
462
- document, and a single id set duplicates every option id.
344
+ - `FacetBar` renders `facetControls` and `alphaControls`; `letterRow`, `letterControl`, `text` and `letterCounts="title"` separate the A–Z row and let a label truncate while its count stays.
345
+ - `Breadcrumbs` renders the trail and its `BreadcrumbList` JSON-LD together; `base` makes the URLs absolute.
346
+ - `JsonLd` escapes every `<`, so an authored `</script>` cannot close the tag.
347
+ - `Pager` steps by links (`href`) or buttons (`onPage`, `wrap`); a step with nowhere to go stays in place, inert. `SnapDots` are a snap row's dots.
348
+ - `FacetSummary` is the count and Clear line under the facets; `RelatedPages` the see-also aside; `InfoboxAside` the facts landmark with its series line; `InfoboxFacts` renders `metadataRows` with `formatFactValue` as its default cell.
349
+ - `RevisionChanges` renders `withChanges` rows; `MaintenanceQueues` renders `maintenanceQueues`; `Cite` is an article's citation in a `<details>`.
463
350
 
464
- The arithmetic is `wiki-formant/combobox`, which imports nothing, so the rules
465
- are unit-tested without a DOM.
351
+ ## Block trees
466
352
 
467
- `SortHeader` is the React half of `sortTables`: spread `useTableSort`'s `headerProps(key)` onto it and it renders a `<th aria-sort>` holding a `<button>`, the markup `sortTables` writes into stored tables, so one stylesheet rule draws both arrows. One of the copies it replaced put `onClick` on the cell, which no keyboard can reach.
353
+ `wiki-formant/blocks`: the two core containers (`columns[i].blocks`, an infobox's `blocks`), the walks over them, and the core leaves' text, markdown and defaults.
468
354
 
469
- `useBlockOperations(blocks, setBlocks, { duplicate })` is the five list edits every block editor makes, with ref-stable callbacks, and `BlockActions` is their button bar — every button named, the moves disabled at the ends, clicks kept from bubbling into a row that selects on click. `useRevisionRestore(restore, { onRestored })` confirms, restores and reports an error; `restoreViaPost(endpoint)` is the `restore` for a wiki whose route takes `{ revisionId }`. Of the three restores it replaced, one asked no confirmation and one dropped a failure silently.
355
+ ```ts
356
+ import { coreBlockShape, mapBlockTree, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
470
357
 
471
- ## Server components
358
+ export const BLOCK_SHAPE = coreBlockShape<Block>();
359
+ mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);
472
360
 
473
- `wiki-formant/react` carries `'use client'`, and that is a module-level boundary: anything exported from it hydrates in the consumer's tree whether or not it uses a hook. `wiki-formant/react-server` is the same React, without the directive — for the parts of a wiki that are pure functions of their props and should ship no JavaScript at all.
361
+ switch (block.type) {
362
+ case 'content': case 'codeTabs': case 'banner': case 'references':
363
+ case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
364
+ return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
365
+ case 'corpusStats':
366
+ return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
367
+ }
368
+ ```
474
369
 
475
- `FacetBar` renders the rows `createTaxonomy` already produces. This is why the taxonomy exports a rows model rather than markup: the rows could always cross the boundary and, until this subpath existed, the markup could not, so all three wikis hand-rendered it and two put `aria-pressed` on an `<a>`. `Breadcrumbs` renders the trail and its `BreadcrumbList` JSON-LD together, because a trail whose structured data is written somewhere else is a trail that will one day disagree with its own markup — which is the case Google penalises. It takes a `base` origin: structured-data URLs must be absolute and a package cannot know the site.
476
- `FacetBar` takes a `count` class to render each count as its own element, and `alphaFirst` to lead with the A–Z row. A chip row above the grid and a panel beside it are the same markup laid out by different stylesheets, so the class names are what tell them apart: `letterRow`, `letterControl` and `letterControlActive` style the A–Z row apart from the facet rows (each falls back to `row`, `control` or `controlActive`); a `text` class wraps each label in its own element and gives a facet option its value as `title`, so a long value can truncate while its count stays in view; and `letterCounts="title"` keeps each letter a bare glyph with its count in the tooltip. With none of them set the markup is what it was. Its JSON-LD goes out through `JsonLd`, which is exported for every other payload a page emits: it re-encodes each `<` as `\u003c`, because these payloads carry authored titles and an authored `</script>` would otherwise close the tag.
370
+ - The dispatch over a repo's own union stays in that repo, so a new type is a compile error there; the core bodies come from here (`coreAtomicText`, `coreAtomicToMarkdown`, `coreBlockDefaults`).
371
+ - The core leaves are `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `events`, `locations`, `recentPages`, `pageList` and `list`. `CoreLeafBlock` is their union (less the list and containers) and `CoreLeafType` its types; a wiki's atomic union is `CoreLeafBlock | ListBlock | its native types`, and a leaf added here fails to compile in every wiki until wired.
372
+ - `coreAtomicToMarkdown` makes a resolved page's `href` absolute with `siteUrl`, since a twin is read off-site.
373
+ - A `codeTabs` tab's `code` is source text: escaped by its view, fenced verbatim by the twin. Pass `highlighted` to the view for server-highlighted markup. `CODE_LANGS` and `DEFAULT_LANG` are the language list.
374
+ - `recentPages`, `pageList` and `list` resolve to `ResolvedPageRef`s built with `pageRef(page, now)`.
375
+ - `ListBlock` is the pages under a section (`source: 'under'`), the newest (`'newest'`, `count`) or chosen ones (`'chosen'`, `pages`), shown as links or cards.
376
+ - An `events` item is `{ id, date, end?, text }`, dated to the year, month or day (`EVENT_DATE`); `splitEvents`, `eventWhen`, `eventBegun` and `eventEnded` split upcoming from past by a `day`, which the browser re-splits by the visitor's own.
377
+ - A `testimonial` is `{ quote, author, role?, avatarUrl? }`. A `locations` item carries an address, `hours` rows of `{ days, opens, closes }` and a `timeZone`.
477
378
 
478
- `Pager` is the "← Previous · Page 2 of 9 · Next →" line, for paged results and for a carousel. Pass `href` and the steps are links (pair it with `paginatedResponse`'s `page` and `totalPages`); pass `onPage` and they are buttons, `wrap` stepping round from either end as a snap row does. A step with nowhere to go stays in place, inert, so the count never jumps sideways, and `spread` puts the steps at the ends of the line. `noun` names what a page is ("Point 2 of 21", "Previous point"). `SnapDots` is a snap row's dots: with `onGo` (`useSnapRow`'s `go`) each is a named button, without it they only count and are hidden from assistive technology. Three sites had written the pager by hand, each stopping at the ends differently, and two had drawn their own dots around the shared hook. `base.css` lays both out; the colours are the site's, the dots' through `--snap-dot` and `--snap-dot-on`.
379
+ ## Validation
479
380
 
480
- `RailShell` stays in `wiki-formant/react`, because it calls `useSidebar` and genuinely is a client component. It renders the rail's landmark, its scroll wrapper and the three collapse states — including the `--instant` class that keeps a remembered-closed rail from animating shut on first paint, and the close-on-navigate that a mobile drawer needs. Only a followed link closes it: a search field or a table-of-contents entry inside the drawer leaves it open. Compose your own rail inside it and mark the active link with `isRailLinkActive`; all three wikis do, and the pre-composed component that used to sit here had one consumer and had already lost both of those behaviours.
381
+ `wiki-formant/validation`: the block model in one declaration, and the checks under it.
481
382
 
482
- `FacetSummary` is the line under the facet bar — how much of the section is showing, and a Clear link when anything is narrowed — with the filtered-empty and empty-section states. `RelatedPages` is the see-also list: a labelled `<aside>` over a `<ul>`, its heading linked to the set `rankRelated` found in common. `InfoboxAside` is the facts panel's named landmark with its "Part of a series on" line, and `InfoboxFacts` renders `metadataRows` as a row-headed table. That table was an HTML string in two wikis, injected as a fake content block; one re-escaped a `"` before each href by hand and the other did not. As React nodes there is nothing to escape. `formatFactValue` is its default cell: a date as its day, a URL or bare domain as an outbound link, `<br>`-separated values as lines.
383
+ ```ts
384
+ import { defineBlocks, describeBlockIssues } from 'wiki-formant/validation';
385
+ import { coreBlockDefaults } from 'wiki-formant/blocks';
483
386
 
484
- All three take the router's link component as a prop:
387
+ const model = defineBlocks<Block>({
388
+ defaults: { ...coreBlockDefaults(), corpusStats: () => ({ type: 'corpusStats', view: 'sourceTiers' }) },
389
+ checks: { corpusStats: b => isCorpusView(b.view), list: (b, core) => core(b) && isSection(b.under) },
390
+ });
391
+ export const { shape: BLOCK_SHAPE, createBlock, duplicateBlock, validateBlocks, blockIssues } = model;
485
392
 
486
- ```tsx
487
- import Link from 'next/link';
488
- <FacetBar link={Link} facets={facets} letters={letters} />
393
+ const issues = blockIssues(body.content);
394
+ if (issues.length) return errors.badRequest(`Invalid content: ${describeBlockIssues(issues)}`);
395
+ // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
489
396
  ```
490
397
 
491
- There is no `next` peer dependency here and there is not going to be one, but a rail that falls back to a bare `<a>` turns every press into a full page load — which is the whole reason a wiki has a persistent rail. So the router arrives through the door.
398
+ - A check for a core type narrows the package's (`core` is passed in). `coreAtomicValidator` and `createBlockValidator` are the parts.
399
+ - `MAX_BLOCK_ROWS` caps `recentPages.limit` and `pageIds`, since both end in a database `take:`; `blockLimit` is the read side.
400
+ - `safeLinkHref` and `okUrl` are the link checks the views rely on.
492
401
 
493
- ## AI crawlers
402
+ ## Block views
494
403
 
495
- Every wiki kept this roster twice — once in the proxy, keyed by user-agent, to
496
- count an `AI Bot Visit`; once in `robots.ts`, as the agents that get their own
497
- group. Both copies were byte-identical across three repos, and they were
498
- different lists.
404
+ `wiki-formant/block-views`: the views of the core leaves, as client components that render server-side.
499
405
 
500
- ```ts
501
- import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
406
+ ```tsx
407
+ import { CodeTabsView, ColumnsView, LinkGridView, PageRefsView, StatsView, EventsView, LocationsView, BannerView } from 'wiki-formant/block-views';
502
408
 
503
- trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: whether it counted
504
- const rules = crawlerRules({ allow: '/', disallow, aiAllow });
409
+ case 'codeTabs': return <CodeTabsView tabs={block.tabs} />;
410
+ case 'columns': return <ColumnsView columns={block.columns} gap={block.gap} render={renderBlock} />;
411
+ case 'pageList': return <PageRefsView refs={block.items} link={Link} renderItem={row} />;
505
412
  ```
506
413
 
507
- Only one direction of that difference was deliberate: `Applebot-Extended` never
508
- fetches a page, so it belongs in robots and not in the matcher. The other
509
- direction was not. Bytespider, CCBot, cohere-ai, Claude-Web and
510
- Meta-ExternalFetcher were matched by every proxy and named by no robots.txt —
511
- and a crawler obeys only its most-specific matching group, so an agent with no
512
- group of its own falls through to `*`. Three wikis were measuring five crawlers
513
- they had never addressed.
514
-
515
- Every agent group also allows the agent surface — `/api/mcp`, `/llms.txt`, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
516
-
517
- `crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
518
-
519
- `trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`: a crawler on the roster wherever it goes, and anything but a search engine that asks for what only agents read (a `.md` twin or `Accept: text/markdown`, the `llms` exports, `/openapi.json`, `/.well-known/`), since most agents are on no roster. Its user agent is kept, so `digest` can name it. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
414
+ - `ColumnsView` states its gap and alignment as data attributes and an inactive code tab is `hidden`, so no view needs Tailwind to scan the package.
415
+ - `TestimonialView` is `figure.testimonial`; pass `photo` for sized images.
416
+ - `EventsView` sets upcoming soonest first and past latest first, with "Now on" beside one begun; `day` fills the server's split.
417
+ - `LocationsView` draws each place's address, week (`PlaceHours`), note and map; the browser adds whether it is open by the place's clock, once a minute. `wiki-formant/hours` holds those rules (`hoursRows`, `openState`, `clock`, `hoursLd`, `mapHref`, `mapEmbed`) for a twin or structured data; a closing time at or before the opening one is the next morning, and `DEFAULT_ZONE` is `Europe/London`.
418
+ - `BannerView` shows `BANNER_MESSAGES` (`wiki-formant/text`) unless `message` overrides it.
520
419
 
521
- ## IndexNow
420
+ ## Text
522
421
 
523
- Bing, Yandex, Seznam, Naver and Yep take a POST of changed URLs instead of waiting to recrawl. The `indexnow` bin reads an origin's live sitemap and posts the rows whose `lastModified` falls inside the window, so the editor, the MCP write tools and the sweep scripts that write straight to the database are all covered with no hook in any of them. It runs where the sweeps run, never as a cron on the host: a submission costs the origin one sitemap render and no scheduled function. The sitemap must render per request. A cached one froze at the last deploy and listed none of the pages edited since, so this bin submitted nothing.
422
+ `wiki-formant/text`: a block tree as plain prose, for an LLM export or a description.
524
423
 
525
- The key is public by design, since an engine checks it by fetching `/<key>.txt`. The file in the app's `public/` is the only copy, and the bin finds it there, so run it from the app's root. It checks that the live origin serves the key before posting anything.
424
+ ```ts
425
+ import { openingText, plainText, stripHtml } from 'wiki-formant/text';
426
+ import { describe } from 'wiki-formant/metadata';
526
427
 
527
- ```sh
528
- npx indexnow https://radix.wiki # rows modified in the last 24 hours
529
- npx indexnow https://radix.wiki --hours 6
530
- npx indexnow https://radix.wiki --all # every row: a new origin or a new key
428
+ const description = describe(page.metadata?.excerpt || openingText(page.content));
531
429
  ```
532
430
 
533
- Rows with no `lastModified` go only in a full submission. A 403 or 422 from the engine exits 1. A sitemap index is read one level down, as `check-pages` reads it.
431
+ `openingText` is the first top-level `content` block with prose once headings are out, so a page opening on an infobox is not described by its table. `matchSnippet` is a search hit's line.
534
432
 
535
433
  ## Page metadata
536
434
 
537
- `wiki-formant/metadata`'s `pageMetadata` builds a page's canonical, markdown-twin alternate, Open Graph and Twitter card from one input. Next replaces those objects per route segment rather than merging them, and fills a missing twitter card from `openGraph` only when no `twitter` object was inherited — so under a layout that sets its own card, a page that sets one and forgets the other shows the layout's. Two wikis wrote a helper around that, covering different halves.
435
+ `wiki-formant/metadata`: a page's canonical, markdown alternate, Open Graph and Twitter card from one input, and the schema.org nodes beside them.
538
436
 
539
437
  ```ts
540
438
  export const generateMetadata = () => ({
541
439
  title,
542
440
  ...pageMetadata({ title, description, url, type: 'article', image: ogImageUrl(title), siteName, handle, markdownTwin: true }),
543
441
  });
442
+ <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: SITE.publisher, citation: citationsFromReferences(refs) })} />
544
443
  ```
545
444
 
546
- The schema.org nodes sit beside it, because they state the same facts to a different reader. `articleLd` requires `image` — two of three wikis shipped articles without one while their og:image named a card — so pass it the card `pageMetadata` got. `collectionLd` is an index page over an `ItemList`, and `citationsFromReferences` turns a `references` block into `Article.citation`.
547
-
548
- ```tsx
549
- <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
550
- ```
551
-
552
- `questionPairs(html)` reads a page's FAQ out of its own headings: every `h2`/`h3` ending in `?`, answered by the text beneath it. It never rewrites a statement heading as a question, so a page earns `FAQPage` by being written as one. `faqPageLd(pairs, url)` is that node, or null under two pairs, and takes an authored list just as well.
553
-
554
- ```tsx
555
- const faq = faqPageLd(questionPairs(html), url);
556
- {faq && <JsonLd data={faq} />}
557
- ```
558
-
559
- `describe(text)` is a page's meta description from any prose, HTML, markdown or plain: tags, entities, link targets and bare URLs go, and the rest fits `DESCRIPTION_LIMIT` (160) as whole sentences where they fill half of it, else cut at a word with an ellipsis. Four sites had four rules, one cutting mid-word at 150 and one slicing at 160 with no mark. `openingText(blocks)` (`wiki-formant/text`) is what a page without an excerpt is described by: its first top-level `content` block with prose once the headings are out, since a page that opens with an infobox would otherwise be described by its metadata table.
560
-
561
- ```ts
562
- const description = describe(page.metadata?.excerpt || openingText(page.content));
563
- ```
564
-
565
- `sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
566
-
567
- ```ts
568
- export const card = sitePageMetadata({ siteName: SITE_NAME, handle: '@example' });
569
- export const SITE = siteRefs(SITE_URL);
570
- <JsonLd data={siteGraphLd({ name, url: SITE_URL, logo, sameAs, searchUrl, api })} />
571
- ```
445
+ - Next replaces `openGraph` and `twitter` per segment rather than merging, so `pageMetadata` sets both; `sitePageMetadata({ siteName, handle })` binds the fields every page's card needs.
446
+ - `articleLd` requires `image`. `collectionLd` is an index over an `ItemList`. `siteGraphLd` is the Organization, WebSite and `WebAPI` `@graph` for the root layout, and `siteRefs(url)` hands out the same `@id`s for a page's `publisher` and `isPartOf`.
447
+ - `questionPairs(html)` reads a FAQ from headings ending in `?`; `faqPageLd(pairs, url)` is null under two pairs.
448
+ - `describe(text)` is a meta description from any prose within `DESCRIPTION_LIMIT` (160): whole sentences where they fill half, else cut at a word with an ellipsis.
572
449
 
573
450
  ## Social cards
574
451
 
575
- `wiki-formant/og` is the card at `/og`, as the element `next/og`'s `ImageResponse` takes. `SocialCard` is the frame: an eyebrow, a title fitted with `describe`, anything under it, then the description and the site's mark along the foot, bottom-aligned on a 1200×630 field. The field and the art are arguments, because they were all that differed: two wikis had each written the same seeded gradient with translucent circles, which is `bubbles(rand, { field, colors })`, and a third draws its own art and passes it as `art`. `cardRandom(...text)` seeds either from the page, so one page always draws the same card. `OG_SIZE` and `OG_HEADERS` (a day at the CDN, a week stale) go on the response.
452
+ `wiki-formant/og`: the `/og` card, as the element `next/og`'s `ImageResponse` takes.
576
453
 
577
454
  ```tsx
578
- const rand = cardRandom(title, eyebrow);
579
- const { background, art } = bubbles(rand, { field: [BG, NAVY], colors: [GREEN, ORANGE] });
580
- return new ImageResponse(<SocialCard {...{ title, eyebrow, description, background, art }} mark="example.org" colors={{ text, muted, accent }} fontFamily="Inter" />, { ...OG_SIZE, headers: OG_HEADERS, fonts: await fonts });
581
- ```
582
-
583
- Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
455
+ import { SocialCard, bannerField, bubbles, cardRandom, OG_SIZE, OG_HEADERS } from 'wiki-formant/og';
584
456
 
585
- **A page's banner is its card.** The generated field exists so a page with no banner still unfurls with something; it never stands in for a banner the page has. Every route picks the same way, and every page's metadata passes its banner to the card URL, the homepage and category hubs included:
586
-
587
- ```tsx
588
457
  const field = (await bannerField(searchParams.get('banner'), /\.public\.blob\.vercel-storage\.com$/))
589
- ?? bubbles(cardRandom(title), { field, colors });
590
- return new ImageResponse(<SocialCard {...{ title, description }} {...field} colors={colors} />, { ...OG_SIZE, headers: OG_HEADERS });
591
- ```
592
-
593
- `bannerField` fetches the banner only from the hosts you name, so `/og` is no proxy to any URL a caller writes, crops it to cover the card, inlines it as PNG (satori decodes no WebP or AVIF) and turns the scrim on. A missing, foreign or undecodable banner returns null and the generated field draws instead. It loads `sharp`, an optional peer, so the route runs on Node.
594
-
595
- ## Revisions
596
-
597
- What changed between two versions of a page, and therefore which semver bump to
598
- ask for. Both wikis had written it and `extractBlocks` was byte-identical
599
- between the copies; they differed only in what each had learned since.
600
-
601
- ```ts
602
- const diff = computeRevisionDiff({
603
- currentVersion: page.version,
604
- oldContent, newContent, oldTitle, newTitle,
605
- // containers: defaults to coreBlockGroups — `columns.i.blocks` and an infobox's `blocks`
606
- });
458
+ ?? bubbles(cardRandom(title), { field: [BG, NAVY], colors: [GREEN, ORANGE] });
459
+ return new ImageResponse(<SocialCard {...{ title, eyebrow, description }} {...field} mark="example.org" colors={colors} fontFamily="Inter" />, { ...OG_SIZE, headers: OG_HEADERS, fonts });
607
460
  ```
608
461
 
609
- - **Blocks match by id**, so a block that moved is one `moved` and not a removal
610
- plus an addition.
611
- - **Container keys are never diffed as attributes.** They are walked as their own
612
- entries, and comparing them here would report an infobox as edited every time
613
- anything inside it changed — turning a prose edit into a structural bump.
614
- - **The path segment is part of the contract**, because `root.1.columns.0.blocks.2`
615
- is what a reviewing UI anchors on. A wiki with a container beyond the core two
616
- passes its own `containers` and says how it is addressed.
462
+ - A page's banner is its card; the generated field is for a page without one. `bannerField` fetches only from the hosts named, crops, inlines PNG and turns the scrim on, or returns null. It loads `sharp`.
463
+ - Fonts stay with the site: Next traces a font file only from a literal path in the app, and satori reads TTF or OTF.
464
+ - `wiki-formant/seeded`'s `hashStr` and `seededRandom` are the deterministic randomness the art draws from, identical on server and browser.
617
465
 
618
- One copy also built two Maps keyed by a recursive `JSON.stringify` of every
619
- block, on every save, and never read either one. Matching is by id and always
620
- was. That half is not here.
466
+ ## Revisions and history
621
467
 
622
- ## History
623
-
624
- What each revision changed, as a history view shows it. `revisions` stays free
625
- of dependencies because every save imports it; this is the reading half, and it
626
- needs `fast-diff`.
468
+ `wiki-formant/revisions`: what changed between two versions of a page, and which bump to ask for. `wiki-formant/history`: what each revision changed, as a history view shows it.
627
469
 
628
470
  ```tsx
471
+ import { computeRevisionDiff } from 'wiki-formant/revisions';
629
472
  import { withChanges } from 'wiki-formant/history';
630
- import { RevisionChanges } from 'wiki-formant/react-server';
631
473
 
632
- const rows = withChanges(revisionsNewestFirst); // each row needs its `content`
474
+ const diff = computeRevisionDiff({ currentVersion: page.version, oldContent, newContent, oldTitle, newTitle });
475
+ const rows = withChanges(revisionsNewestFirst); // each row needs its `content`; it leaves without it
633
476
  <RevisionChanges changes={rows[0].changes} label={type => BLOCK_LABELS[type]} />
634
477
  ```
635
478
 
636
- - **Diffed on the server, from stored content.** radix-wiki used to send both
637
- HTML bodies of every modified block to the browser and diff them there: 887 KB
638
- of payload for a 13-revision page whose drawn diffs came to 25 KB. A row
639
- leaves `withChanges` without its content.
640
- - **Words, not letters.** Semantic cleanup is on, so a rewritten sentence reads
641
- as the old words struck and the new ones added.
642
- - **Leaves only.** A container's own entry restates its children. A leaf whose
643
- type changed is a replacement and gets no text diff. `leafText` names the prose
644
- field of a block beyond the core `content` one.
645
- - **A window diffs against what precedes it.** The oldest row is diffed against
646
- an empty page, so fetch one revision more than you show and drop the last row.
647
-
648
- `RevisionChanges` marks struck and added text as `<del>` and `<ins>` and keys
649
- each change on `data-action`; `base.css` only resets the list.
479
+ - Blocks match by id, so a moved block is one `moved`. Container keys are walked as their own entries, never diffed as attributes. A path like `root.1.columns.0.blocks.2` is part of the contract; pass `containers` beyond the core two.
480
+ - `withChanges` diffs on the server, by words, leaves only. The oldest row is diffed against an empty page, so fetch one more than you show.
650
481
 
651
- ## Feeds
652
-
653
- Three repos, four feeds, 47 of 64 significant lines identical — including,
654
- verbatim in two of them, the comment explaining why the apostrophe escapes
655
- numerically. `feedResponse` is the union.
482
+ ## Feeds and sitemaps
656
483
 
657
484
  ```ts
658
- export const GET = async (request: Request) => feedResponse(request, channel, await items());
659
- ```
660
-
661
- `feedResponse` renders the channel and answers a conditional GET: the ETag is the XML's own, Last-Modified the build date. All four feeds used to return the XML bare, so a poller could never be told the channel was unchanged.
662
-
663
- **`lastBuildDate` comes from the newest item, not the clock.** One copy stamped
664
- `new Date()` on every request, telling every poller the feed had changed when it
665
- had not — the recrawl the conditional-GET helpers exist to prevent. An empty feed
666
- carries no build date rather than a fictional one.
485
+ import { feedResponse } from 'wiki-formant/feed';
486
+ import { treeSitemap, tagPrefixes } from 'wiki-formant/sitemap';
667
487
 
668
- ## Sitemaps
669
-
670
- `treeSitemap(pages, { sections, sectionRow, pageRow })` returns the rows a wiki's tree adds to its sitemap: a row per section, dated by the newest page at or beneath it, then a row per page, dated by its own `updatedAt`. All three wikis listed their sections, and each dated them differently. One used the newest page beneath, one did the same from a derived tree, and one used no date at all. A section with no page beneath it carries no date rather than an invented one.
488
+ export const GET = async (request: Request) => feedResponse(request, channel, await items());
671
489
 
672
- ```ts
673
490
  ...treeSitemap(pages, {
674
491
  sections: tagPrefixes(pages.map(p => p.tagPath)),
675
492
  sectionRow: path => ({ url: `${SITE_URL}/wiki/${path}`, changeFrequency: 'weekly', priority: 0.6 }),
@@ -677,147 +494,117 @@ carries no build date rather than a fictional one.
677
494
  }),
678
495
  ```
679
496
 
680
- Every URL appears once. An empty-slug page is its section's own article, so it dates the section and gets no row of its own. A section whose URL a page already holds is served as that page, so the section row is dropped. `tagPrefixes` lists every path at or above the given ones, for a wiki whose sections are wherever its pages sit rather than a configured list. Which sections exist, the URLs, and each row's frequency and priority stay with the caller.
681
-
682
- ## Licence declarations
683
-
497
+ - `feedResponse` answers a conditional GET; `lastBuildDate` is the newest item's date, and an empty feed carries none.
498
+ - A section is dated by the newest page at or beneath it, or not at all. Every URL appears once: an empty-slug page dates its section and gets no row.
684
499
 
685
- S10 wants a licence on every surface, and each repo satisfied that by writing the
686
- same block again. What is genuinely per-project is the *scope* — which half of a
687
- site the grant covers and what it excludes — so that is the parameter.
500
+ ## Licences and the X account
688
501
 
689
502
  ```ts
503
+ import { ccBy40, licenseBlock, openApiLicense } from 'wiki-formant/license';
504
+ import { xAccount, followLines, followNote, followLinkProps } from 'wiki-formant/social';
505
+
690
506
  const license = ccBy40({ siteName: 'AcuiQ', siteUrl: SITE_URL });
691
507
  const block = licenseBlock({ license, scope: 'The protocol compilation and prose', excludes });
692
- ```
693
-
694
- The same `License` goes to `agentCard({ license, licenseScope })` and to `openApiLicense(license)` for a spec's `info.license`. The three cards and three specs here had each projected it differently, one from a hand-typed name.
695
-
696
- ## The X account
697
-
698
- A site names its X account in five places: the footer link, the JSON-LD `sameAs`, llms.txt, AGENTS.md and the MCP `initialize` instructions. Written by hand, one repo's `sameAs` still pointed at twitter.com, and only one site told an agent the account existed. `wiki-formant/social` builds all five from the handle and a fragment saying what the account posts.
699
-
700
- ```ts
701
508
  export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a named trial's prescription with its citation" });
702
509
  ```
703
510
 
704
- `X.url` goes to `sameAs`, `X.handle` to `sitePageMetadata`, `followLines(X)` into llms.txt and AGENTS.md, and `followNote(X)` into the MCP instructions. The note tells an agent to give the link to the person it is helping, since an agent cannot follow an account. The footer is `<a {...followLinkProps(X)}>{X.label}</a>`, whose `rel="me"` confirms the `sameAs` claim from the page the account points back at.
511
+ - One `License` goes to `licenseBlock`, `agentCard({ license, licenseScope })` and `openApiLicense` for `info.license`.
512
+ - `X.url` is the `sameAs`, `X.handle` the card's, `followLines(X)` goes into llms.txt and AGENTS.md, `followNote(X)` into the MCP instructions, and `<a {...followLinkProps(X)}>` is the footer link with `rel="me"`.
705
513
 
706
- ## Rendered-article passes
514
+ ## Agent descriptors
707
515
 
708
- `wiki-formant/dom` holds what runs against an article element after it is in the document. Not React, so not in `react.tsx`.
516
+ `wiki-formant/well-known`: the A2A agent card, the MCP manifest and server card, and the registry's domain proof.
709
517
 
710
518
  ```ts
711
- addCopyButtons(el); // every <pre> gets one, once
712
- sortTables(el); // every column-headed table sorts by its headers
713
- hydrateTweetEmbeds(el); // placeholders get a live src
714
- const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
715
- ```
716
-
717
- `addCopyButton` was **byte-identical** in two BlockRenderers, down to the SVG path data. Its idempotence guard now lives inside the function rather than in a `pre:not(:has(…))` at the call site, where it can be — and was — retyped.
718
-
719
- In React, `useArticlePasses(ref, [blocks])` (`wiki-formant/react`) runs them all, the embed pair included, and typesets maths: `renderMath` (also `wiki-formant/react`, since its stylesheet import cannot sit in a module server code reaches) sets `$$…$$` and `\[…\]` as a display and `\(…\)` inline. KaTeX is an optional peer: an app installs `katex`, and a page with no delimiter never fetches it. There is no single-`$` delimiter, since prose writes $XRD. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
720
-
721
- `activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
519
+ import { agentCard, mcpManifest, skillsFromTools, wellKnownHandler, serverCardHandler } from 'wiki-formant/well-known';
722
520
 
723
- `sortTables` makes the tables stored in article HTML sortable by their headers. They arrive as a string a `dangerouslySetInnerHTML` wrote, so React never sees their rows and cannot sort them. A column is dates if every filled cell starts with one, numbers if every one does, and text otherwise — one stray value makes the whole column text, which beats sorting half of it by one rule and half by another. A third press restores the author's order, which is often chronological or ranked and otherwise needs a reload. Label/value tables and tables with merged cells are left alone. The markup it writes — `aria-sort` on the cell, a `.sort-header` button inside it — is the markup a React sortable header should write too, so both kinds of table draw their arrows from one stylesheet rule.
724
-
725
- `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once. `articleHtml(html, { used, slug, anchor, links })` (`wiki-formant/headings`) is that chain: heading ids, the scroll boxes, and, given `links`, `normaliseLinks`.
726
-
727
- The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed. Every embed on a page posts to the same listener, so `onTweetResize` hands over the window that posted, and `sizeTweetEmbed` sizes only that one's iframe: two posts on a page had both been drawn at whichever height arrived last.
728
-
729
- ## Search
521
+ const AGENT_CARD = agentCard({ name, description, url: `${SITE_URL}/api/mcp`, version, skills: skillsFromTools(TOOLS), license });
522
+ // src/app/.well-known/[doc]/route.ts
523
+ export const GET = wellKnownHandler({ agentCard: AGENT_CARD, mcpManifest: mcpManifest({ … }), openapi: SPEC });
524
+ ```
730
525
 
731
- `proseSql`, `HEADLINE_OPTIONS` and `FTS_RANK_NORMALIZATION` are what a literal tier and a full-text tier must agree on. `searchTsvDdl(table)` is the statements that build the generated `search_tsv` column and its index — dropped and re-added in one transaction every run, because a skip-if-present script is blind to a changed expression.
526
+ The card is served identically at `agent-card.json` and `agent.json`, and its `url` is the MCP endpoint, which answers A2A. `registryAuthHandler` serves `MCP_REGISTRY_PUBLIC_KEY` at `/.well-known/mcp-registry-auth`, 404 when unset.
732
527
 
733
- ## Block trees
528
+ ## AI crawlers
734
529
 
735
- Every wiki here stores the same two containers the same way — `columns[i].blocks` and an infobox's `blocks` — and between them had hand-written that walk seven times. Bind it once and hand it to every walk:
530
+ `wiki-formant/crawlers`: one roster of AI crawlers, for the proxy that counts them and the robots.txt that addresses them.
736
531
 
737
532
  ```ts
738
- import { coreBlockShape, leafBlocks, mapBlockTree } from 'wiki-formant/blocks';
533
+ import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
739
534
 
740
- export const BLOCK_SHAPE = coreBlockShape<Block>();
741
- mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);
742
- leafBlocks(blocks, BLOCK_SHAPE.containers);
535
+ trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts
536
+ const rules = crawlerRules({ allow: '/', disallow, aiAllow });
743
537
  ```
744
538
 
745
- `computeRevisionDiff` defaults to the same shape, path-addressed, so a repo whose containers are the core two passes none.
539
+ - Every agent group allows `AGENT_SURFACE_PATHS` (`/api/mcp`, `/llms.txt`, `/openapi.json`, `/.well-known/`); `aiAllow` adds the origin's own.
540
+ - Search engines get the HTML site and no twins (`/*.md$`, plus `searchDisallow`). The `?_rsc=` prefetch payload is refused in every group.
541
+ - `trackAiBot` counts a rostered crawler anywhere, and anything but a search engine asking for what only agents read. `load` is a dynamic import so the proxy does not load a database client.
746
542
 
747
- The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
543
+ ## Rendered-article passes
748
544
 
749
- The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `events`, `locations`, `recentPages`, `pageList`, and `list`, which does what the two page lists do between them — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
545
+ `wiki-formant/dom`: what runs against an article element once it is in the document.
750
546
 
751
547
  ```ts
752
- import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
753
- import { coreAtomicValidator } from 'wiki-formant/validation';
754
-
755
- switch (block.type) {
756
- case 'content': case 'codeTabs': case 'banner': case 'references':
757
- case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
758
- return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
759
- case 'corpusStats':
760
- return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
761
- }
548
+ import { addCopyButtons, sortTables, activateTabGroups, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbed } from 'wiki-formant/dom';
762
549
 
763
- const BLOCK_DEFAULTS = { ...coreBlockDefaults({ stats }), daoTimeline: () => ({ type: 'daoTimeline', limit: 25 }) };
764
- const core = coreAtomicValidator({ pageId: id => /^\d{1,9}$/.test(id) });
765
- // validateAtomic: b => { switch (b.type) { case 'daoTimeline': …; default: return core(b); } }
550
+ addCopyButtons(el);
551
+ sortTables(el);
552
+ activateTabGroups(el);
553
+ hydrateTweetEmbeds(el);
554
+ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
766
555
  ```
767
556
 
768
- `coreAtomicToMarkdown` joins a resolved page's site-relative `href` to `siteUrl`, because a twin is read off-site; a wiki whose lists hold its own page rows keeps those two cases. `coreAtomicValidator` caps `recentPages.limit` and `pageIds` at `MAX_BLOCK_ROWS`, a whole number of rows, because both end in a database `take:`; `blockLimit` is the same bound on the read side.
557
+ - Each pass is idempotent.
558
+ - `sortTables` sorts a column as dates, numbers or text (one stray value makes it text), restores the author's order on a third press, and leaves label/value and merged-cell tables alone.
559
+ - `activateTabGroups` makes stored `[data-tabs]` markup a working tab group; without script every tab shows in order.
560
+ - `onTweetResize` believes only the embed host and hands over the posting window, so `sizeTweetEmbed` sizes that iframe alone.
561
+ - `scrollTables(html)` (`wiki-formant/headings`) wraps each table in a `.table-scroll` box on the string, before layout.
562
+ - `track(name, props)` sends a browser event to the analytics beacon.
769
563
 
770
- `defineBlocks` (in `wiki-formant/validation`) is the whole model in one declaration: the shape, the types, `createBlock`, `duplicateBlock`, `validateBlocks` and `blockIssues`, from a record of defaults and the wiki's own leaf checks. A check for a core type narrows the package's: `list: (b, core) => core(b) && isSection(b.under)`. `BlockTree<Atomic>` is the union over the two containers.
771
-
772
- ```ts
773
- const model = defineBlocks<Block>({ defaults: { ...coreBlockDefaults(), corpusStats: () => ({ type: 'corpusStats', view: 'sourceTiers' }) }, checks: { corpusStats: b => isCorpusView(b.view) } });
774
- export const { shape: BLOCK_SHAPE, createBlock, duplicateBlock, validateBlocks, blockIssues } = model;
775
- ```
564
+ ## Search
776
565
 
777
- `createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
566
+ `wiki-formant/search`: what a literal tier and a full-text tier must agree on: `proseSql`, `HEADLINE_OPTIONS`, `FTS_RANK_NORMALIZATION`, and `searchTsvDdl(table)`, the statements that drop and re-add the generated `search_tsv` column and its index in one transaction.
778
567
 
779
568
  ```ts
780
- const issues = blockIssues(body.content);
781
- if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(issues)}`);
782
- // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
569
+ const { column, index } = searchTsvDdl('"WikiPage"');
783
570
  ```
784
571
 
785
- A `codeTabs` tab's `code` is source text in every wiki here, and every view treats it that way: `CodeTabsView` escapes it into a `<pre><code>`, the sanitiser leaves it alone, the markdown twin fences it verbatim. A wiki that escapes and highlights on the server passes `highlighted` to the view. The package used to assume editors stored highlighted markup; none ever had, and every twin printed `Vec<u8>` as `Vec`.
786
-
787
- `recentPages`, `pageList` and a `list` resolve to `ResolvedPageRef`s — build each with `pageRef(page, now)`, which carries the ISO date a `<time>` needs — and `PageRefsView` renders them as a list, with a `renderItem` slot for a wiki that has its own row.
788
-
789
- ## Dates
572
+ ## Dates, freshness and the maintenance queues
790
573
 
791
- `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
574
+ `wiki-formant/freshness` and `wiki-formant/queues`.
792
575
 
793
- ## Freshness and the maintenance queues
794
-
795
- `freshnessBanner(page, nowMs, { maxAgeDays, against })` is the synthetic `outdated` banner a page carries once it has gone unchecked for `maxAgeDays` (180), or null. Its notice asks the reader to re-check the facts against `current sources`; the two Radix wikis pass `against: 'current sources and the live ledger'`, which a shop's wiki has no use for.
576
+ ```ts
577
+ import { relativeTime, formatDay, freshnessBanner } from 'wiki-formant/freshness';
578
+ import { maintenanceQueues } from 'wiki-formant/queues';
796
579
 
797
- `wiki-formant/queues` gathers the signals each page shows into Wikipedia's tracking categories: `maintenanceQueues(pages, { href, required, nowMs, verify, listedIds })` returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped, from one pass over the pages' content. Pass every page but the home page and the back office, since an orphan is a page no other page links to. `href` is a page's address as its links name it, `required` the facts its category requires, `verify` how a page is stamped as checked here (it closes the outdated queue's sentence), and `listedIds` the ids a list block names its pages by. `MaintenanceQueues` (`wiki-formant/react-server`) draws them, a heading and a count each, under classes `maintenance-*`; the page around them is the wiki's, `noindex`. All four apps serve one at `/maintenance` (on miow, the owner page).
580
+ relativeTime(then, now, { style: 'short' }); // '3h ago'; `now` is always passed in
581
+ formatDay(day, { locale: 'en-GB', month: 'long' }); // '19 September 2026', always UTC
582
+ const banner = freshnessBanner(page, Date.now(), { against: 'current sources and the live ledger' });
583
+ const queues = maintenanceQueues(pages, { href, required, nowMs, verify, listedIds });
584
+ ```
798
585
 
799
- `Cite` (`wiki-formant/react-server`) is "Cite" in an article's meta line, a `<details>` opening on `citation({ publisher, title, date, url })`: `AcuiQ. "Point codes." October 2, 2026. https://…`.
586
+ - `freshnessBanner` is the `outdated` banner once a page goes unchecked for `maxAgeDays` (180), or null.
587
+ - `maintenanceQueues` returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped. Pass every page but the home page and the back office. `MaintenanceQueues` draws them on a `noindex` page.
588
+ - `citation({ publisher, title, date, url })` (`wiki-formant/react-server`) is the line `Cite` shows.
800
589
 
801
590
  ## Sanitising stored HTML
802
591
 
803
- `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render four HTML fields themselves — `linkGrid` descriptions, `references` and `events` text, and a place's note — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
592
+ `wiki-formant/sanitize`: the allowlist between saved HTML and a reader's browser. Run it server-side on the render path.
804
593
 
805
594
  ```ts
806
595
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
807
596
 
808
- const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and the same hosts in the CSP's frame-src
597
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // the same hosts in the CSP's frame-src
809
598
  const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
810
599
  ```
811
600
 
812
- The default list is derived from what the editor nodes in `wiki-formant/tiptap` store — the embed wrappers' data attributes, the tab markup `activateTabGroups` reads back, the table classes — plus the presentational SVG subset the infographics pipeline embeds. Extend it with `tags`, `attributes`, `classes` and `schemesByTag` derived from your stored HTML, never from memory: a list written from memory erases content on the first render.
813
-
814
- A YouTube iframe comes out as the `YouTube` node's markup, `<div data-youtube-video><iframe …></iframe></div>`, whoever wrote it: an agent, an import, a pasted embed code. The editor then opens it as that node, and `base.css` draws it as the same full-width 16:9 player as one the editor wrote, as it does a map embed. Two wikis had drawn that player in their own stylesheets and a third drew any iframe its own way; spacing, corners, borders and the editor's selection ring stay the site's.
815
-
816
- Classes pass by name only. Limiting `style` to layout and paint is worth nothing while `class` is free, because every site's own stylesheet ships `fixed inset-0 z-50`, and those draw a fake prompt over the chrome as well as `position` does.
601
+ - The default list is what `wiki-formant/tiptap`'s nodes store plus a presentational SVG subset. Extend `tags`, `attributes`, `classes` and `schemesByTag` from your stored HTML, never from memory.
602
+ - A YouTube iframe comes out as the `YouTube` node's markup, whoever wrote it, and `base.css` draws it as a 16:9 player.
603
+ - Classes pass by name only, since a site's own utilities can draw over the chrome.
817
604
 
818
605
  ## Image uploads
819
606
 
820
- `wiki-formant/upload` is the route an editor's image upload posts to. Two wikis had written it and their limits had drifted: one took 25 MB with no cap on decoded pixels and trusted the Content-Type the client sent, the other 4 MB, 40 megapixels and the file's own leading bytes. These are the second one's defaults.
607
+ `wiki-formant/upload`: the route an editor's image upload posts to.
821
608
 
822
609
  ```ts
823
610
  // src/app/api/upload/route.ts
@@ -828,62 +615,67 @@ export const POST = uploadHandler({
828
615
  });
829
616
  ```
830
617
 
831
- The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget keyed on `authorize`'s id and address, and each address one of its own, so an id minted fresh per burst (a self-signed wallet) refills nothing. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers. `normalizeImage(bytes, { maxPixels, encode })` is the same sniff, cap and re-encode without the route, for images that arrive another way – a server action, or a picture copied in from another site; it resolves to `{ buffer, format, contentType }` and throws a message fit to show the uploader.
618
+ - The file's own bytes say what it is; SVG is refused. Every image is re-encoded upright, so no source metadata (GPS included) is kept. Defaults: 4 MB, 40 megapixels.
619
+ - Each uploader has a budget on `authorize`'s id and address, and each address its own.
620
+ - `encode`, `maxBytes`, `maxPixels`, `rateLimit` and `store` (Vercel Blob by default) are options. `normalizeImage(bytes, opts)` is the sniff, cap and re-encode without the route.
832
621
 
833
- ## Editor nodes
622
+ ## Maps
834
623
 
835
- `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `embedNodes({ resolveMapUrl })` is the first four as one list, which an editor puts in its `nodes`: a pasted YouTube, X or map link becomes its player as it lands, and the toolbar's embed prompt has a node for every link it recognises. `resolveMapUrl` defaults to `wiki-formant/maps`', through `/api/resolve-map`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
624
+ `wiki-formant/maps`: a pasted Google or Apple Maps URL as one an `<iframe>` accepts.
836
625
 
837
626
  ```ts
838
- const CodeBlock = createCodeBlock({
839
- classNames: { button: 'lang-btn', option: 'lang-option', optionActive: 'text-accent' },
840
- icons: { chevron: open => <ChevronDown className={open ? 'rotate-180' : ''} /> },
841
- });
627
+ import { toMapEmbedUrl, resolveMapHandler } from 'wiki-formant/maps';
628
+
629
+ toMapEmbedUrl('https://maps.apple.com/?ll=51.5,-0.12');
630
+ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) }); // /api/resolve-map
842
631
  ```
843
632
 
844
- The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant/blocks`, which two wikis had typed identically; a wiki with its own passes `langs` and `defaultLang`. The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
633
+ A short link resolves through the wiki's own route (`resolveMapUrl` is the client half): exact shortener hosts, one hop, an allowlisted landing host, a timeout and your sign-in check.
634
+
635
+ ## Editor nodes
636
+
637
+ `wiki-formant/tiptap`: the custom nodes: `Iframe`, `YouTube` (with its paste rule), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock`, `createTabs` and `createHeadingIds`.
638
+
639
+ ```ts
640
+ import { embedNodes, createCodeBlock, createTabs, createHeadingIds } from 'wiki-formant/tiptap';
641
+
642
+ const { TabGroup, TabItem } = createTabs();
643
+ const nodes = [...embedNodes({ resolveMapUrl }), createCodeBlock({ classNames, icons }), TabGroup, TabItem, createHeadingIds({ slug })];
644
+ ```
845
645
 
846
- Both node views draw in default classes (`code-block…`, `tabs-editor`, `tab-button`…) that `base.css` styles plainly enough for any site, the editor's rule showing the tab the writer picked included; pass `classNames` and `icons` to draw them your own way. `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
646
+ - `embedNodes` turns a pasted YouTube, X or map link into its player. `Iframe` and the map node draw only an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since the editor draws stored HTML where its author is signed in.
647
+ - The node views draw in default classes `base.css` styles; `classNames` and `icons` replace them.
648
+ - `createTabs` returns both nodes, since one is unsatisfiable without the other. A short map link inserts at once and swaps its `src` when it resolves.
649
+ - `createHeadingIds` gives each heading the id its published copy will carry.
847
650
 
848
- `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
651
+ ## Editor
849
652
 
850
- `WikiToolbar` shows what the editor can do and nothing else: each command whose node, mark or command the editor's extensions include, so an app's buttons follow from its nodes and no app keeps a second list. They come in groups – undo and redo; bold, italic, inline code and link; the three headings; lists, quote and code block; then upload (given `onUpload`), embed (where there are embed nodes), table, tabs and divider – with Remove link after Link while the cursor is in a link, and the table controls while it is in a table. Four apps had each listed buttons by hand: one had no numbered list, one had a code-block node and no button for it, and only one had undo. Every button is a `ToolbarButton`, named and `aria-pressed`; the icons, the classes and anything after the buttons (`children`) are yours.
653
+ `wiki-formant/editor`: the rich-text editor's engine, minus its chrome.
851
654
 
852
655
  ```tsx
656
+ import { useWikiEditor, useUrlPrompt, WikiToolbar, UrlPromptField, WikiLine, uploadImageTo } from 'wiki-formant/editor';
657
+
658
+ const url = useUrlPrompt(editor, { resolveMapUrl });
853
659
  <WikiToolbar editor={editor} prompt={url} onUpload={triggerUpload}
854
660
  icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
855
661
  classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
856
662
  <UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
857
- ```
858
-
859
- `WikiLine` is a one-line field – a title, a heading, a caption, a button – edited in the element that shows it. Its schema is the line and the `marks` it may keep (`bold`, `italic`, `link`), so a typed "- " makes no list and a pasted heading runs into the line; its value is that HTML, or plain text with no marks. It has its own undo, and `onFocus` hands over its editor, so one `WikiToolbar` can drive whichever field was last touched. `base.css` gives it a placeholder from `data-placeholder`.
860
-
861
- ```tsx
862
663
  <WikiLine as="h1" value={title} onChange={setTitle} placeholder="The page's title" />
863
- <WikiLine as="p" marks={['bold', 'italic', 'link']} value={note} onChange={setNote} placeholder="A note" />
864
664
  ```
865
665
 
866
- `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
867
-
868
- `UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
869
-
870
- A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
871
-
872
- The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
873
-
874
- ```ts
875
- export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
876
- ```
666
+ - `WikiToolbar` shows each command the editor's extensions support (`supports`) and nothing else, in fixed groups, with the table controls while the cursor is in a table.
667
+ - `useUrlPrompt` is the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.
668
+ - `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched.
669
+ - A link is stored as its `href` and the `link` class only; whether it is external is decided at render by `articleHtml`'s `links` pass. `uploadImageTo('/api/upload')` is the `uploadImage` an editor passes.
877
670
 
878
671
  ## Block editor
879
672
 
880
- `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. An option's `group` lists it under that heading, groups in the order they first appear; a menu of more than eight opens on a box that finds a type by name, Enter taking the first found (on a touch screen it waits to be tapped, so the keyboard does not cover the sheet). The fields' look arrives as a kit.
673
+ `wiki-formant/block-editor`: the block list an author edits and the fields of the core types. The look arrives as a kit.
881
674
 
882
675
  ```tsx
883
676
  const KIT: BlockEditorKit = {
884
- classNames: { action: 'icon-btn', input: 'input', row: 'row', stack: 'stack-sm', … },
677
+ classNames: { action: 'icon-btn', input: 'input', row: 'row', stack: 'stack-sm' },
885
678
  icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
886
- Choice: ToggleGroup, // optional: a <select> otherwise
887
679
  };
888
680
  const SCHEMA: BlockSchema<Block> = {
889
681
  meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
@@ -893,116 +685,39 @@ const SCHEMA: BlockSchema<Block> = {
893
685
  case 'codeTabs': return <CodeTabsFields block={block} onUpdate={onUpdate} />;
894
686
  case 'columns': return <ColumnsFields block={block} onUpdate={onUpdate} />;
895
687
  case 'daoTimeline': return <DaoTimelineEdit block={block} onUpdate={onUpdate} />;
896
- …
897
688
  }
898
689
  },
899
690
  };
900
691
  <BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
901
692
  ```
902
693
 
903
- The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `TestimonialFields`, `EventsFields`, `LocationsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
904
-
905
- An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list. It wears the same chrome: `<BlockToolbar>` over each block (an editor's own controls go in as `children`, before add below) and `<AddBlockMenu>` at a list's foot, each offering `BlockOption`s.
906
-
907
- A `testimonial` is `{ quote, author, role?, avatarUrl? }`, drawn by `TestimonialView` as `figure.testimonial`: the words in a `blockquote`, then a `figcaption` holding the photo and a `span` of `strong` (who) and `small` (their role). radix-wiki and miow each had one under its own field names; a site with sized images passes `photo`, the `<img>` attributes (`src`, `srcSet`, `sizes`) it made from `avatarUrl`. They are data, because a server component renders the view.
908
-
909
- An `events` block is `{ upcoming?, past?, items: [{ id, date, end?, text }] }`: exhibitions, shows, talks, each dated as precisely as it is known (`EVENT_DATE`: `2026`, `2026-11` or `2026-11-12`), its end no earlier than its start, and its `text` inline HTML saying what and where. The items are stored in any order. `EventsView` sets those to come or on now soonest first under `upcoming` ("Upcoming"), with "Now on" beside one that has begun, and those that have ended latest first under `past` ("Past"), as `section.events` holding an `h2` and an `ol.event-list` for each; `base.css` gives every row's dates one column, shared by both lists. A date compares at its own precision, so `2026-08` is on until August ends. The page is split twice: by `day`, the day it was rendered (`today()` on the server), and again in the browser by the visitor's own day, because a cached page can be a day old and a show that closed since then is past. A wiki whose renderer is a client component fills `day` on the server when the page is read (it is never stored), so hydration matches what the server drew. `splitEvents`, `eventWhen` ("12 – 18 Nov 2026"), `eventBegun`, `eventEnded` and `eventEndsBefore` are the same rules for a twin, a feed or an editor; `coreAtomicText` and `coreAtomicToMarkdown` split by `day` too. `EventsFields` edits the rows in the order they were written, each with how its dates read and where the page sets it today, and `sanitizeCoreLeaf` cleans each event's text. miow had it first.
910
-
911
- A `locations` block is `{ heading?, map?, items: [{ id, name?, image?, address?, map?, phone?, email?, hours?, timeZone?, note? }] }`: where a business is and when it is open, each place with its address or its hours. `hours` are rows of `{ days, opens, closes }`, days `mon` to `sun` and times 24-hour; a closing time at or before the opening one is the next morning, and one equal to it is open all day. `LocationsView` draws `section.locations` holding `ul.places`, as many to a row as fit: each place's picture, name (an `h2`, or an `h3` under `heading`), address linked to its map (`mapHref`: the link it was given, or a search for its words), its week as grouped rows ("Mon–Fri 8am–6pm", "Sun Closed"), its note, phone and email, and with `map` an embedded map (`mapEmbed`, from a host on `DEFAULT_IFRAME_HOSTS`). Whether it is open and which row is today depend on the moment it is read, so the server draws the hours alone and the browser adds `.place-state` ("Open · closes 6pm", "Closed · opens Mon 8am") and the row's `aria-current`, by the place's own clock (`timeZone`, `DEFAULT_ZONE` `Europe/London` unset), once a minute. `wiki-formant/hours` holds those rules — `hoursRows`, `openState`, `clock`, `hoursLd` (schema.org's `OpeningHoursSpecification`), `DAYS`, `TIME`, `isZone` — for a twin, a feed or structured data. `LocationsFields` edits the places, their hours day by day; `sanitizeCoreLeaf` cleans each note. A site whose places are its own business states each as a `LocalBusiness` with `hoursLd`; a wiki describing places does not, since they are not the wiki's own. miow had it first.
912
-
913
- A banner with no text of its own shows `BANNER_MESSAGES` from `wiki-formant/text`, a sentence per variant that names no wiki; `BannerView` takes it unless `message` overrides it.
914
-
915
- The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `EventsBlock`, `LocationsBlock`, `ListBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. `CoreLeafBlock` is every one of them but the list and the containers, as one union, and `CoreLeafType` its types. A wiki builds its atomic union from it, `CoreLeafBlock | ListBlock | its native types`, with its own `list` holding its own page rows, and derives its `BlockType` from the result. A leaf added here then reaches every wiki with the release, and fails to compile there until its label, its view and its fields are wired: a `Record` keyed by the block types is missing a key, and a switch closed with `block satisfies never` has a case left over. miow, whose union is zod's, asserts that it carries every `CoreLeafType`.
916
-
917
- `ListBlock` is one list of the wiki's pages where those two were two: the pages in a section (`source: 'under'`), the newest, in a section or across the wiki (`'newest'`, `count`), or pages picked by hand (`'chosen'`, `pages`, keyed as the wiki keys a page, an id or a path), shown as links with their age or as cards (`show`). A wiki resolves it to `items` as it does a page list. `ListFields` edits it, with `pages` to pick from and `sections` to name; a wiki with a source of its own, a shop's products say, passes it in `sources` and draws its fields as `children`.
694
+ - Each block is a card with a toolbar (name, add below, move, duplicate, delete); an add menu of more than eight types opens on a filter box. An option's `group` heads it.
695
+ - The core fields are `CodeTabsFields`, `StatsFields`, `TestimonialFields`, `EventsFields`, `LocationsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields`. `Field`, `Choice` and `LimitField` are for a wiki's own types.
696
+ - A type the build does not know is shown by its raw type and carried through untouched.
697
+ - Keep the kit and schema stable (a module constant or `useMemo`): both reach every card through context.
698
+ - An editor that draws blocks in place hosts the fields in `<BlockFieldsKit kit={KIT}>` with `<BlockToolbar>` and `<AddBlockMenu>`.
918
699
 
919
700
  ## Analytics
920
701
 
921
- Page views and events, counted in the site's own Postgres instead of a hosted
922
- service. No cookie is set and no address is kept: a visitor is a hash of the
923
- address and browser under a salt that is replaced every UTC day, and the old
924
- salt is deleted, so a visitor is told apart within a day and never followed
925
- into the next. Rows are kept 400 days. The package owns the SQL and the app
926
- owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
927
- placeholders, and the four tables it needs ship as Prisma models in
928
- `prisma/analytics.prisma` (see `prisma-models` below). The fourth, `ViewDay`, holds days a site counted
929
- elsewhere before it counted its own; `digest` adds them to any window that
930
- reaches back that far, and a site with no such history leaves it empty.
702
+ `wiki-formant/analytics`: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. The tables ship in `prisma/analytics.prisma`; `ViewDay` holds days a site counted elsewhere first.
931
703
 
932
704
  ```ts
933
705
  // src/lib/track.ts
934
706
  const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
935
- export const { trackEvent, trackMcpCall, trackSearch, viewRoute } =
936
- createTracker({ sql, defer: after, siteUrl: SITE_URL });
707
+ export const { trackEvent, trackMcpCall, trackFeedback, trackSearch, viewRoute } = createTracker({ sql, defer: after, siteUrl: SITE_URL });
937
708
 
938
- // src/app/api/view/route.ts – the whole beacon route
709
+ // src/app/api/view/route.ts
939
710
  export const POST = viewRoute();
940
-
941
- // the root layout, once
942
- <Beacon />
943
- ```
944
-
945
- `createTracker` records only on a Vercel production deployment unless told
946
- otherwise (`enabled`), because dev and previews write to production's
947
- database. `trackMcpCall(request, body, server?)` fits `McpServerConfig.onCall`;
948
- `trackSearch(headers, query, results, surface?)` takes the headers or Next's
949
- `headers` itself, which it reads inside the deferred write. `viewRoute({ site })`
950
- passes a site resolver to `collect`. The module that builds the tracker
951
- imports the app's database client, so a proxy reaches it through a dynamic
952
- `import()` inside `event.waitUntil`, never a static one.
953
-
954
- `<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
955
- client-side moves included, and a click on a link to another host as an
956
- "Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
957
- any other browser event. Underneath the tracker, server code calls
958
- `recordEvent` and leaves the deferral to the framework, so this package keeps
959
- its zero-dependency guarantee:
960
-
961
- ```ts
962
- after(() => recordEvent(sql, { name: 'Signup', props: { plan }, url, headers }));
963
711
  ```
964
712
 
965
- `mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
966
- and may be a batch; `trackSearch` normalises a search box's free text so
967
- the same question aggregates as one row, and drops an empty one. Pass the request's headers and the
968
- event joins whoever sent it, person or agent.
969
-
970
- `digest(sql, { days })` reads a site back as one JSON object: visitors, agents,
971
- page views, bounce rate, visit length, sources (a link's `utm_source`, else the
972
- referring host), top and entry pages, countries, devices, visitors and agents by
973
- day, the agents by name, MCP tool calls, and the searches that found nothing.
974
- An agent is counted as a visitor is, one address and user agent a day, from its
975
- `AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
976
- among the visitors, and `visitors_incl_agents` counts the two at once.
977
- `ownAgents` leaves the site's own out of both. A visit is a run of one
978
- visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
979
- with no event of the reader's inside it: a search that keeps the path, a click
980
- out or a share makes the visit count. A browser under automation
981
- (`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
982
- key to every call and a `site` resolver to `collect`.
983
-
984
- Instrumenting the agent lane and not the human one is the easy mistake: it
985
- leaves a wiki able to say what every crawler asked for and nothing about what
986
- its readers asked for. The queries that return zero rows are the valuable ones
987
- – they name a gap in the corpus in the reader's own words.
713
+ - `createTracker` records only on a Vercel production deployment unless `enabled` says otherwise. `trackMcpCall` fits `McpServerConfig.onCall`; `trackSearch` normalises a query and drops an empty one; `recordEvent` is the undeferred write.
714
+ - A proxy reaches the tracker through a dynamic `import()` inside `event.waitUntil`, since it imports the database client.
715
+ - `digest(sql, { days })` is the site as one JSON object: visitors, agents, views, bounce rate, visit length, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit with no event of the reader's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
716
+ - A browser under automation (`navigator.webdriver`) sends no beacon.
988
717
 
989
718
  ## Passkeys and the stats page
990
719
 
991
- `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
992
- nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
993
- returns the whole of it. `gate.route` is the sign-in route, `gate.signOut` the
994
- sign-out route, `gate.signedIn(cookieValue)` is the check a page makes,
995
- `gate.allows(request)` the same check for a write route's own request, and
996
- `gate.cookie` is the cookie's name. An editor with one writer needs no
997
- other door: acuiq2's wiki editor opens on the passkey that opens its stats. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
998
- models in `prisma/passkey.prisma`, and every token is kept only as its hash.
999
-
1000
- There is no sign-up. Every passkey, the first among them, is saved from a
1001
- single-use link that the `passkey-invite` bin prints from a machine holding
1002
- `DATABASE_URL`, so a fresh deployment cannot be claimed by whoever reaches it
1003
- first. A passkey is bound to the host it was saved on, so localhost needs its
1004
- own. A session names the passkey that signed in: deleting a `Passkey` row signs
1005
- out every session it opened.
720
+ `wiki-formant/passkey`: the door to a site's own admin pages, a passkey and nothing else. `wiki-formant/stats`: the stats page behind it.
1006
721
 
1007
722
  ```ts
1008
723
  // src/lib/admin.ts
@@ -1013,7 +728,7 @@ export const POST = gate.route;
1013
728
  export const DELETE = gate.signOut;
1014
729
 
1015
730
  // src/app/stats/page.tsx
1016
- const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
731
+ const q = statsQuery(params);
1017
732
  return (
1018
733
  <StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
1019
734
  {async () => <Stats digest={await digest(sql, q)} {...q} link={Link} />}
@@ -1021,207 +736,73 @@ return (
1021
736
  );
1022
737
  ```
1023
738
 
1024
- `StatsGate` (`wiki-formant/stats`) renders `children` and a sign-out button for a signed-in reader and, for anyone else, the line saying what the prompt will do and the passkey button that does it; `classNames` places the text, the button's box, the button and its error. Those buttons are the browser half of `wiki-formant/passkey`, and they have no subpath of their own.
1025
-
1026
- `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
1027
- headline figures, each with its change from the window before
1028
- (`.stats-delta[data-better]`, a share or for the bounce rate points, for the
1029
- site's stylesheet to colour), one of them by day, week or month (a count's
1030
- total, a rate's mean), every non-empty
1031
- ranked list, and `?days=` links between windows, all time among them, pinned in
1032
- the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
1033
- figure is a button that charts it: visitors, page views, bounce rate,
1034
- visit length and clicks to X. Visitors count agents while the switch beside the windows, drawn as a robot's
1035
- head, is lit; turning it off (`?agents=apart`) splits them into people and agents, and every
1036
- other link on the page keeps the choice. `digest`'s `by_day` carries every
1037
- one of those per UTC day, a visit on the day it began, so the days add up to the
1038
- totals. A day with no visits has no bounce rate or visit length and is left out
1039
- of those charts rather than drawn as zero. Everything but the figures and the
1040
- chart is server-rendered. Every link on the page is `link`, a bare `<a>` unless
1041
- the site passes its router's: with `next/link` a window, the switch or a
1042
- narrowing re-renders the page in place rather than reloading it, and the chart
1043
- keeps its figure and step.
1044
-
1045
- Every row in a list is a link that narrows the page to it, and the narrowings
1046
- stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
1047
- a chip that drops it. A narrowing keeps the visits that pass – that saw the
1048
- page, began on it, came from the source, or were made from the country or
1049
- device – and recounts every figure, list and day from those visits alone, with
1050
- events only from their visitors. Agent tool calls and missed searches are
1051
- events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
1052
- not visits, so a narrowed page starts at `counted_from`, the first day the site
1053
- counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
1054
- axis from zero.
739
+ - There is no sign-up: every passkey is saved from a single-use link `npx passkey-invite` prints. A passkey is bound to its host. Deleting a `Passkey` row signs out every session it opened. Tokens are kept only as hashes (`prisma/passkey.prisma`).
740
+ - `gate.signedIn(cookie)` is a page's check and `gate.allows(request)` a write route's.
741
+ - `statsQuery` reads `?days=` (1, 7, 30, 90, 365 or all), `?agents=apart` and the narrowings `?page=`, `?entry=`, `?source=`, `?country=`, `?device=`, which stack and recount every figure from the visits that pass.
742
+ - Each headline figure shows its change from the window before (`.stats-delta[data-better]`) and charts on press. A narrowed page over `ViewDay` totals starts at `counted_from` and says so.
1055
743
 
1056
744
  ## ROLA sign-in
1057
745
 
1058
- `wiki-formant/rola` is the part of a Radix wallet sign-in two sites had each written: a single-use challenge, the wallet's proof of it, and the HS256 token a verified address is handed. How a site keeps the session stays with it, because the two keep it differently: radix-wiki a `Session` row behind a seven-day cookie, caper a stateless token it slides forward, with a short one for agents.
746
+ `wiki-formant/rola`: a Radix wallet sign-in up to the token: a single-use challenge, the wallet's proof, the HS256 session, and the two MCP tools that sign an agent in. How a site keeps the session stays with it.
1059
747
 
1060
748
  ```ts
749
+ import { createRola, sessionSecret, signSession, verifySession, bearerToken, rolaLoginTools } from 'wiki-formant/rola';
750
+
1061
751
  const rola = createRola({ expectedOrigin, dAppDefinitionAddress, networkId, applicationName, challenges: prisma.challenge });
1062
- const { challenge } = await rola.generateChallenge();
1063
752
  const v = await rola.verifySignedChallenge(signed); // { isValid, error? }
1064
-
1065
753
  const secret = sessionSecret(process.env.JWT_SECRET); // throws under 32 characters
1066
754
  const token = await signSession({ id: signed.address }, secret, 7 * 86400);
1067
755
  const claims = await verifySession<{ id: string }>(bearerToken(request.headers) ?? cookie, secret);
756
+
757
+ const tools = rolaLoginTools({
758
+ site: 'the wiki', dAppDefinitionAddress, origin: SITE_URL, tokenTtlSec: 7 * 86400, writeTools: 'create_page / edit_page',
759
+ generateChallenge: rola.generateChallenge,
760
+ login: async signed => issueToken(signed), // { token, expiresAt, ...shown }; throw McpToolError to refuse
761
+ notes: { sign: { address: 'your virtual account; its key must be an on-ledger owner_keys entry' } },
762
+ });
1068
763
  ```
1069
764
 
1070
- Where the two copies differed, this takes the stricter. A challenge is spent by one conditional delete, so two requests racing with one signature cannot both pass, and it is spent whatever the outcome; making one purges the expired. Only an account proof signs in. Verification pins HS256. `jose` and `@radixdlt/rola` are optional peers.
765
+ - A challenge is spent by one conditional delete whatever the outcome, so one signature cannot pass twice; making one purges the expired. Only an account proof signs in. Verification pins HS256.
766
+ - `rolaLoginTools` gives `get_challenge` (the exact message to sign, naming the dApp definition address and the origin `rolaOriginCheck` looks for) and `login`, whose refusal says to fetch a new challenge. `notes.challenge`, `notes.sign` and `notes.address` add a surface's own lines.
1071
767
 
1072
768
  ## Charts
1073
769
 
1074
- `TimeChart` (`wiki-formant/chart`) is every time-series chart on the four sites:
1075
- token prices, ledger activity, a caper's price, visitors per day. One area
1076
- series in the box's own `color`, its axis in `--chart-axis` if set, with range
1077
- buttons — 24H, 7D, 30D, 90D, 1Y, All — then, after a rule, the day, week and
1078
- month steps. The steps show wherever a range is drawn by day, so the buttons
1079
- never shift; every one is enabled, however few points it makes, so 90D draws
1080
- monthly as readily as daily (none under 24H and 7D, which are drawn by the hour
1081
- and the four hours, and none finer than the points: weekly readings have no
1082
- day). The default is the finest that makes 200 or fewer, so a year opens weekly
1083
- and five years monthly, and a step picked holds across the ranges.
1084
-
1085
- ```tsx
1086
- // Daily points: the chart slices and steps them itself; 30D up.
1087
- <TimeChart series={days} label="Transactions" aggregate="sum" fromZero />
1088
-
1089
- // A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
1090
- // range), once each.
1091
- <TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
1092
- ```
1093
-
1094
- A price point may carry `open`, `high` and `low` (its `value` is the close)
1095
- and `volume`. Points that carry the candle get a candles switch, a candlestick icon
1096
- pressed while they show, opening on candles with `candles`; points that carry volume get a volume pane under the
1097
- price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
1098
- the canvas box, else its `color`. A week of candles opens on its first, closes
1099
- on its last, spans the highest high and lowest low, and sums its volume. A point
1100
- with a volume of zero is a stretch nothing traded in: its candle and bar leave
1101
- the slot empty, except the last, which is the price now. A price's line is
1102
- straight between closes and a count's is curved.
1103
-
1104
- `aggregate` is how a week or month is made from its days: `last` for a price,
1105
- `sum` for a count, so a week reads as the week's total, and `mean` for a rate,
1106
- which no total makes sense of. A stepped range starts on a whole week or month,
1107
- so its first point is not a few days of one; the week in progress is its total
1108
- so far. `fromZero` starts a count's axis at zero and stops it there, with
1109
- no negative ticks under it. A single range draws no range buttons and shows every point.
1110
- No step is finer than the points' own spacing, so weekly readings offer W and M
1111
- and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
1112
-
1113
- `format` writes the axis and the crosshair label. An axis tick also gets the
1114
- spacing between ticks as a second argument, so a formatter can write every tick
1115
- to the same precision and two never read alike: `stepDecimals(step)` is the
1116
- decimal places that takes, and `formatChartValue`, the default, uses it.
1117
-
1118
- `markers` are events, `{ time, label }`, each drawn as an arrow on the last
1119
- point at or before it, so one mid-week lands on its week and two on one point
1120
- share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
1121
- the crosshair is on the point, one line an event, so it can be the event's full
1122
- name. `step` opens on a step where the range can make it, and `onView` hears the
1123
- range, and the step the reader picked (null while the chart picks its own), once
1124
- the points are in and on every change, for a page that keeps them in its URL:
770
+ `wiki-formant/chart`: `TimeChart`, every time-series chart: one area series in the box's `color`, range buttons (24H to All) and day, week and month steps.
1125
771
 
1126
772
  ```tsx
1127
773
  <TimeChart series={days} label="Transactions" aggregate="sum" fromZero
1128
774
  markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
775
+ <TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
1129
776
  ```
1130
- `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
1131
- canvas's top-left corner, rules a line between groups and dims a disabled step;
1132
- the site's stylesheet gives them and the box a look.
777
+
778
+ - `series` is the points or a loader asked for `'hour'` (24H), `'4h'` (7D) or `'day'`, once each.
779
+ - The default step is the finest that makes 200 points or fewer; no step is finer than the points. `aggregate` is `last` for a price, `sum` for a count, `mean` for a rate. A stepped range starts on a whole week or month.
780
+ - Points with `open`, `high` and `low` get a candles switch; with `volume`, a volume pane. A zero-volume point leaves its slot empty.
781
+ - `format` gets the tick spacing too; `stepDecimals` and `formatChartValue` keep ticks at one precision. `markers` are tooltips on the last point at or before each event.
782
+ - `chartSteps` is exported for a chart drawn elsewhere.
1133
783
 
1134
784
  ## Base stylesheet
1135
785
 
1136
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the events block's shared date column, the locations block's row of places, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
786
+ `wiki-formant/base.css`: the layout the package's markup needs and the package's components drawn on eleven `--ui-*` tokens (`--ui-surface`, `--ui-surface-2`, `--ui-border`, `--ui-radius`, `--ui-pad`, `--ui-small`, `--ui-muted`, `--ui-accent`, `--ui-on-accent`, `--ui-up`, `--ui-down`) a site maps from its palette in `:root`. No colour and no scale, so a design system's rules override it at equal specificity.
1137
787
 
1138
788
  ```css
1139
789
  @import "wiki-formant/base.css" layer(components);
1140
790
  ```
1141
791
 
1142
- Since 0.86 it also draws the package's components — the infobox, section and stat cards, see-also and page-ref lists, the hover card, the maintenance queues, the chart's frame and buttons, code tabs, the editorial banner, references, heading anchors, link grids, revision diffs and the rail's contents — on eleven `--ui-*` tokens (`--ui-surface`, `--ui-surface-2`, `--ui-border`, `--ui-radius`, `--ui-pad`, `--ui-small`, `--ui-muted`, `--ui-accent`, `--ui-on-accent`, `--ui-up`, `--ui-down`) that a site maps from its own palette in `:root`. Four stylesheets had restated sixty of those classes with four token vocabularies; now a site states the rule only where it differs. The split of a page beside its infobox stays the site's, as does the rail's surface.
1143
-
1144
- No view emits a Tailwind utility any more — `ColumnsView` states its gap and alignment as data attributes, and an inactive code tab is `hidden` — so a site no longer needs its Tailwind to scan this package for the markup to work.
792
+ It declares the page frame (`--content-width` 72rem, `--sidebar-width` 18rem, `--page-pad` 16/24/32px) in the components layer, so a site never restates them in `@theme`; the primitives `.spinner`, `.skeleton`, `.empty-state`; and the reduced-motion guard. The split of a page beside its infobox and the rail's surface stay the site's.
1145
793
 
1146
- ## API
1147
-
1148
- Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
1149
- and there is no root export: `import … from 'wiki-formant'` does not resolve. A
1150
- barrel loads every module it names for the one symbol a route wanted, and had
1151
- already re-exported one module twice. The emitted `.d.ts` files are the reference.
1152
- There is no hand-maintained symbol list here, because the one that used to be here
1153
- drifted from them. A function a module uses only itself is marked `@internal`: the
1154
- package's tests still reach it, and the `.d.ts` leaves it out.
794
+ ## Database and scripts
1155
795
 
1156
- `wiki-formant/tsconfig.base.json` is the compiler floor: an app's `tsconfig.json` extends it and keeps only `paths`, `include`, `exclude` and any licensed extra, since tsconfig resolves those against the file that declares them.
1157
-
1158
- ```json
1159
- { "extends": "wiki-formant/tsconfig.base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/.well-known/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["node_modules"] }
1160
- ```
1161
-
1162
- `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
1163
- `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
1164
- 5432 session pooler, whose fifteen-client cap a build's workers share. It also
1165
- holds `prismaConfig(root, schema?)`, what an app's prisma.config.ts hands
1166
- `defineConfig`: `.env` loaded from `root`, the schema folder (or one file) and
1167
- migrations under it, and DIRECT_URL preferred, because the Prisma CLI's
1168
- connections hang on the transaction pooler.
796
+ `wiki-formant/db`: `pgPoolConfig(url)`, ten clients on the 6543 transaction pooler and three on the 5432 session pooler, and `prismaConfig(root, schema?)`, which prefers `DIRECT_URL`, since the Prisma CLI hangs on the transaction pooler.
1169
797
 
1170
798
  ```ts
1171
799
  // prisma.config.ts
1172
800
  import { defineConfig } from 'prisma/config';
1173
801
  import { prismaConfig } from 'wiki-formant/db';
1174
-
1175
802
  export default defineConfig(prismaConfig(import.meta.dirname));
1176
803
  ```
1177
804
 
1178
- `rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
1179
- caller already shown to be abusive: running dry blocks the key outright for that
1180
- long, across every bucket that names it, rather than letting the refill trickle
1181
- one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
1182
- and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
1183
- `x-real-ip` was set, since a proxy that appends hands the client that slot.
1184
- `limitClient(prefix, headers, opts)` is the per-address gate, `rateLimit` over
1185
- `clientKey`, which a dozen call sites had written out.
1186
-
1187
- ## A bin
1188
-
1189
- `check-classes` fails a build on a `className` token that resolves to nothing, and counts the inverse fault — 3+ Tailwind utilities inline, which is a component class that was never named. Both sets are derived from the emitted stylesheets rather than listed, so nothing has to be maintained per project. Run it from a repo root after a build:
1190
-
1191
- ```sh
1192
- npx check-classes # exit 1 on any dead token
1193
- npx check-classes --warn # report and exit 0
1194
- npx check-classes --compositions # also fail on the inline compositions
1195
- ```
1196
-
1197
- `analytics-digest` prints a site's `digest` as JSON for a script outside the app. Run it from the app's root, where `pg` is installed and `.env` holds `DATABASE_URL`:
1198
-
1199
- ```sh
1200
- npx analytics-digest --days 7
1201
- npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
1202
- npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
1203
- ```
1204
-
1205
- `passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). No routine calls it: the site's owner runs it by hand, from an app's checkout, for the first passkey and whenever another device or person needs into the stats page. Run it the same way:
1206
-
1207
- ```sh
1208
- npx passkey-invite https://caper.network/stats
1209
- ```
1210
-
1211
- `prisma-models` copies the models the analytics and passkey SQL reads, `prisma/analytics.prisma` and `prisma/passkey.prisma`, into an app's schema folder, and `--check` exits 1 when a copy is missing or has drifted. Prisma reads a schema split across one folder's files, so the app moves `prisma/schema.prisma` into `prisma/schema/`, points `prisma.config.ts`'s `schema` at the folder, and keeps its generator, datasource and own models there beside the shipped files. A generator `output` is relative to the file that declares it, so it gains a `../`.
1212
-
1213
- ```sh
1214
- npx prisma-models # write both into prisma/schema
1215
- npx prisma-models --check # in the build, before prisma generate
1216
- ```
1217
-
1218
- An app that keeps one schema file and writes the models into it by hand, because each needs a relation the shipped one cannot carry, checks them in place: `--in` names the file and compares model by model, ignoring comments and spacing, and `--tenant` lets each `siteId` drop its default and gain a `@relation` on it.
1219
-
1220
- ```sh
1221
- npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1222
- ```
1223
-
1224
- `wiki-formant/scripts` is what a seed or sweep script shares with the next one: `withClient` (a `pg` client on `DATABASE_URL` that rolls back on a throw and expires the wiki's cache tag after a write), `readPage` and `writeRevision` (one revision over a page the script read, refused if the page moved on), `upsertWikiPage`, the block builders `content`, `banner` and `infobox`, and `assertLinkShapes`, which refuses a link or reference the views would silently drop. radix-wiki and caper had each written it in a different language.
805
+ `wiki-formant/scripts`: what a seed or sweep script shares: `withClient` (a `pg` client that rolls back on a throw and expires the cache tag after a write), `readPage`, `writeRevision` (refused if the page moved on), `upsertWikiPage`, the block builders `content`, `banner` and `infobox`, and `assertLinkShapes`.
1225
806
 
1226
807
  ```ts
1227
808
  import { withClient, readPage, writeRevision } from 'wiki-formant/scripts';
@@ -1231,31 +812,35 @@ await withClient(async client => {
1231
812
  }, { tag: 'wiki' });
1232
813
  ```
1233
814
 
1234
- `wiki-formant/link-check`'s `auditLinks` is the walk every dead-link checker makes — each link once, with the pages that carry it, probed with `probeExternal` or the checker's own — and `printLinkAudit` its first lines; what a checker keeps is its live paths, its special cases and the rest of its report.
815
+ `wiki-formant/link-check`: `auditLinks`, each link once with the pages that carry it, probed with `probeExternal` or the checker's own, and `printLinkAudit`.
1235
816
 
1236
- `wiki-formant/knip` is the knip configuration the apps share, spread into each one's `knip.ts` with what is its own.
817
+ `wiki-formant/knip`: the shared knip configuration, spread into each app's `knip.ts`.
1237
818
 
1238
- `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why. A PascalCase Prisma table goes in quoted, as SQL names it.
819
+ ## Bins
1239
820
 
1240
- ```sh
1241
- npx fts-ddl pages
1242
- npx fts-ddl '"WikiPage"'
1243
- ```
1244
-
1245
- `mark-verified` stamps `lastVerifiedAt` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which is how another routine files an item. Every mode holds the row lock from read to write, and `backlog-add` records what it filed in `state.filed`, so a `write` of a state read before the filing merges the item back in rather than dropping it (`lateBacklog`). Only `metadata.state` is written; the page's other metadata is kept. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Both work on the package's own `WikiPage` and `WikiRevision`. Run them from the app's root, like `passkey-invite`:
821
+ Run each from the app's root, where `.env` holds `DATABASE_URL`.
1246
822
 
1247
823
  ```sh
1248
- npx mark-verified contents/tech/radix-engine scrypto # one page
1249
- npx mark-verified --all ecosystem --invalidate wiki # a tag path, then the cache tag
1250
- npx maintenance-log read --at contents/tech/operations/wiki-maintenance-log --routine wiki-sweep --author <id>
1251
- npx maintenance-log backlog-add '[{"item": "…"}]' --at operations/wiki-maintenance-log --routine caper-wiki-sweep
1252
- ```
1253
-
1254
- `--site <siteId>` names the wiki in a multi-tenant app (default `""`), and `--invalidate <tag>` runs `vercel cache invalidate` after a write.
824
+ npx check-classes [--warn] [--compositions] # a className token no stylesheet defines; 3+ inline utilities
825
+ npx check-pages https://radix.wiki # the served HTML of every sitemap page
826
+ npx analytics-digest --days 7 [--gap "Symptom Miss:term"]
827
+ npx passkey-invite https://caper.network/stats [--days n]
828
+ npx prisma-models [analytics|passkey|wiki] [--check] [--in prisma/schema.prisma --tenant]
829
+ npx fts-ddl '"WikiPage"' # rebuild search_tsv and its index over DIRECT_URL
830
+ npx indexnow https://radix.wiki [--hours 6 | --all]
831
+ npx mark-verified <tagPath> <slug> | --all <tagPath> [--invalidate wiki]
832
+ npx maintenance-log read|write|compact|backlog-add --at <path> --routine <name> [--author <id>]
833
+ ```
834
+
835
+ - `prisma-models` copies the shipped models into `prisma/schema/` (Prisma reads a folder of files; a generator `output` gains a `../`), and `--check` fails on a missing or drifted copy. `--in` compares models in one file, and `--tenant` lets `siteId` drop its default and gain a relation.
836
+ - `fts-ddl` runs before the column is declared in `schema.prisma`; a PascalCase table goes in quoted.
837
+ - `indexnow` posts the sitemap rows modified in the window to IndexNow, after checking the origin serves the key file in `public/`. The sitemap must render per request.
838
+ - `mark-verified` stamps `lastVerifiedAt` so `freshnessBanner` stops calling a page outdated.
839
+ - `maintenance-log` is the state of a sweep routine kept in one page's `metadata.state`: every mode holds the row lock from read to write, `backlog-add` files an item another routine raised and a stale `write` merges it back, and a `write` whose `totalRuns` is below the stored one is refused. `--site` names the wiki in a multi-tenant app.
1255
840
 
1256
841
  ## A reusable workflow
1257
842
 
1258
- `.github/workflows/publish-mcp-registry.yml` republishes an origin's server to the MCP registry when the version in its `server.json` moves. Each app keeps its own trigger and calls it with its domain:
843
+ `.github/workflows/publish-mcp-registry.yml` republishes an origin's server to the MCP registry when its `server.json` version moves.
1259
844
 
1260
845
  ```yaml
1261
846
  on: