wiki-formant 0.97.1 → 0.98.1
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 +409 -824
- package/dist/base.css +1 -0
- package/dist/category.d.ts +3 -2
- package/dist/category.js +7 -8
- package/dist/conformance.d.ts +63 -0
- package/dist/conformance.js +74 -0
- package/dist/corpus.d.ts +6 -2
- package/dist/corpus.js +6 -2
- package/dist/editor.js +2 -2
- package/dist/headers.d.ts +13 -2
- package/dist/headers.js +13 -2
- package/dist/react.d.ts +11 -0
- package/dist/react.js +9 -0
- package/dist/rola.d.ts +39 -0
- package/dist/rola.js +81 -0
- package/dist/taxonomy.d.ts +2 -3
- package/dist/wiki-tools.d.ts +2 -1
- package/package.json +1 -11
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/*`)
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
## API
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
- **The bump is the diff's
|
|
42
|
-
- **History is diffed on read.** A revision stores the page as
|
|
43
|
-
- **Listings carry a snippet, not the article
|
|
44
|
-
- **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres'
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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
|
-
`
|
|
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`
|
|
63
|
-
- A path that is itself a category wins over a mode suffix
|
|
64
|
-
- `crumbs` makes one crumb per URL segment, linked only where the tree knows
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
93
|
-
-
|
|
94
|
-
- `text` should be the
|
|
95
|
-
- `
|
|
96
|
-
- `metadata`, `label`, `
|
|
97
|
-
- Writes stay with each app
|
|
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
|
-
`
|
|
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
|
|
114
|
-
const
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
-
|
|
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
|
-
|
|
122
|
+
`wiki-formant/headings`: heading ids, permalink anchors, and the list an "on this page" rail renders.
|
|
150
123
|
|
|
151
124
|
```ts
|
|
152
|
-
import {
|
|
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:
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
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
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
265
|
-
- `
|
|
266
|
-
- `
|
|
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
|
-
|
|
205
|
+
The drift checks hold a server to what its own site does:
|
|
274
206
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
275
|
+
`withWikiDefaults(config, { csp })` adds:
|
|
373
276
|
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
+
## Pagination, versioning and rate limits
|
|
383
284
|
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
+
bump(page.version, 'minor'); // tolerant of null or junk
|
|
389
294
|
|
|
390
|
-
|
|
295
|
+
const limit = limitClient('search', request.headers, { capacity: 30, refillPerSec: 0.5 });
|
|
296
|
+
if (!limit.ok) return rateLimitedResponse(limit.retryAfterSec);
|
|
297
|
+
```
|
|
391
298
|
|
|
392
|
-
|
|
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
|
-
|
|
302
|
+
## React
|
|
395
303
|
|
|
396
|
-
|
|
304
|
+
`wiki-formant/react` carries `'use client'`. What is shared is behaviour; class names and icons are passed in.
|
|
397
305
|
|
|
398
|
-
|
|
306
|
+
```tsx
|
|
307
|
+
import { LinkPreviewCard, SidebarProvider, RailShell, TableOfContents, useLinkPreview } from 'wiki-formant/react';
|
|
399
308
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
-
|
|
417
|
-
-
|
|
418
|
-
-
|
|
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
|
-
`
|
|
329
|
+
`useClickOutside`, `useSnapRow` and the editor hooks ship no class names.
|
|
421
330
|
|
|
422
|
-
|
|
331
|
+
## Server components
|
|
423
332
|
|
|
424
|
-
`
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
are unit-tested without a DOM.
|
|
351
|
+
## Block trees
|
|
466
352
|
|
|
467
|
-
`
|
|
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
|
-
|
|
355
|
+
```ts
|
|
356
|
+
import { coreBlockShape, mapBlockTree, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
|
|
470
357
|
|
|
471
|
-
|
|
358
|
+
export const BLOCK_SHAPE = coreBlockShape<Block>();
|
|
359
|
+
mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);
|
|
472
360
|
|
|
473
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
379
|
+
## Validation
|
|
479
380
|
|
|
480
|
-
`
|
|
381
|
+
`wiki-formant/validation`: the block model in one declaration, and the checks under it.
|
|
481
382
|
|
|
482
|
-
|
|
383
|
+
```ts
|
|
384
|
+
import { defineBlocks, describeBlockIssues } from 'wiki-formant/validation';
|
|
385
|
+
import { coreBlockDefaults } from 'wiki-formant/blocks';
|
|
483
386
|
|
|
484
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
402
|
+
## Block views
|
|
494
403
|
|
|
495
|
-
|
|
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
|
-
```
|
|
501
|
-
import {
|
|
406
|
+
```tsx
|
|
407
|
+
import { CodeTabsView, ColumnsView, LinkGridView, PageRefsView, StatsView, EventsView, LocationsView, BannerView } from 'wiki-formant/block-views';
|
|
502
408
|
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
##
|
|
420
|
+
## Text
|
|
522
421
|
|
|
523
|
-
|
|
422
|
+
`wiki-formant/text`: a block tree as plain prose, for an LLM export or a description.
|
|
524
423
|
|
|
525
|
-
|
|
424
|
+
```ts
|
|
425
|
+
import { openingText, plainText, stripHtml } from 'wiki-formant/text';
|
|
426
|
+
import { describe } from 'wiki-formant/metadata';
|
|
526
427
|
|
|
527
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
452
|
+
`wiki-formant/og`: the `/og` card, as the element `next/og`'s `ImageResponse` takes.
|
|
576
453
|
|
|
577
454
|
```tsx
|
|
578
|
-
|
|
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
|
-
-
|
|
610
|
-
|
|
611
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
##
|
|
514
|
+
## Agent descriptors
|
|
707
515
|
|
|
708
|
-
`wiki-formant/
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
528
|
+
## AI crawlers
|
|
734
529
|
|
|
735
|
-
|
|
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 {
|
|
533
|
+
import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
|
|
739
534
|
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
+
## Rendered-article passes
|
|
748
544
|
|
|
749
|
-
|
|
545
|
+
`wiki-formant/dom`: what runs against an article element once it is in the document.
|
|
750
546
|
|
|
751
547
|
```ts
|
|
752
|
-
import {
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
574
|
+
`wiki-formant/freshness` and `wiki-formant/queues`.
|
|
792
575
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
576
|
+
```ts
|
|
577
|
+
import { relativeTime, formatDay, freshnessBanner } from 'wiki-formant/freshness';
|
|
578
|
+
import { maintenanceQueues } from 'wiki-formant/queues';
|
|
796
579
|
|
|
797
|
-
|
|
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
|
-
|
|
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
|
|
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 }); //
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
622
|
+
## Maps
|
|
834
623
|
|
|
835
|
-
`wiki-formant/
|
|
624
|
+
`wiki-formant/maps`: a pasted Google or Apple Maps URL as one an `<iframe>` accepts.
|
|
836
625
|
|
|
837
626
|
```ts
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
+
## Editor
|
|
849
652
|
|
|
850
|
-
`
|
|
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
|
-
`
|
|
867
|
-
|
|
868
|
-
`
|
|
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
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
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);
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
`
|
|
1027
|
-
headline
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
the
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
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/
|
|
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
|
-
`
|
|
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
|
|
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
|
|
817
|
+
`wiki-formant/knip`: the shared knip configuration, spread into each app's `knip.ts`.
|
|
1237
818
|
|
|
1238
|
-
|
|
819
|
+
## Bins
|
|
1239
820
|
|
|
1240
|
-
|
|
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
|
|
1249
|
-
npx
|
|
1250
|
-
npx
|
|
1251
|
-
npx
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
|
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:
|