wiki-formant 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +84 -7
  2. package/dist/block-views.d.ts +8 -11
  3. package/dist/block-views.js +10 -8
  4. package/dist/blocks.d.ts +31 -1
  5. package/dist/blocks.js +66 -7
  6. package/dist/conformance.d.ts +13 -1
  7. package/dist/conformance.js +23 -1
  8. package/dist/corpus.d.ts +65 -0
  9. package/dist/corpus.js +82 -0
  10. package/dist/crawlers.d.ts +8 -1
  11. package/dist/crawlers.js +14 -1
  12. package/dist/editor.d.ts +5 -0
  13. package/dist/editor.js +24 -0
  14. package/dist/freshness.d.ts +14 -0
  15. package/dist/freshness.js +13 -0
  16. package/dist/headings.d.ts +7 -0
  17. package/dist/headings.js +15 -7
  18. package/dist/http.d.ts +28 -1
  19. package/dist/http.js +29 -4
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/license.d.ts +9 -0
  23. package/dist/license.js +8 -0
  24. package/dist/link-check.d.ts +25 -0
  25. package/dist/link-check.js +56 -1
  26. package/dist/maps.d.ts +25 -2
  27. package/dist/maps.js +91 -5
  28. package/dist/mcp.d.ts +37 -0
  29. package/dist/mcp.js +61 -0
  30. package/dist/metadata.d.ts +69 -0
  31. package/dist/metadata.js +45 -0
  32. package/dist/react-server.d.ts +21 -1
  33. package/dist/react-server.js +43 -13
  34. package/dist/react.d.ts +12 -2
  35. package/dist/react.js +31 -0
  36. package/dist/revisions.d.ts +7 -3
  37. package/dist/revisions.js +5 -3
  38. package/dist/sanitize.d.ts +44 -0
  39. package/dist/sanitize.js +191 -0
  40. package/dist/search.d.ts +20 -0
  41. package/dist/search.js +22 -0
  42. package/dist/text.d.ts +18 -1
  43. package/dist/text.js +30 -0
  44. package/dist/tiptap.d.ts +14 -1
  45. package/dist/tiptap.js +41 -1
  46. package/dist/validation.d.ts +19 -1
  47. package/dist/validation.js +82 -28
  48. package/dist/well-known.d.ts +47 -8
  49. package/dist/well-known.js +58 -2
  50. package/package.json +24 -2
package/README.md CHANGED
@@ -145,6 +145,12 @@ handler: async (args, ctx) => {
145
145
 
146
146
  `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.
147
147
 
148
+ ### Tool arguments and the corpus tool
149
+
150
+ `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.
151
+
152
+ `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.
153
+
148
154
  ## Conformance
149
155
 
150
156
  `wiki-formant/conformance` is the half of an MCP conformance run that is not about any one server's tools: a JSON-RPC client that backs off on a 429, the transport assertions (CORS preflight, `GET`→405, a notification answering 202 with no body, `-32700`, the batch cap, honest `capabilities`, version negotiation), version coherence across every descriptor a surface publishes, A2A card parity, conditional-GET and `robots` checks, and a pass/fail table with an exit code.
@@ -159,6 +165,8 @@ process.exit(t.summary());
159
165
 
160
166
  What stays in your repo is fixtures: which tools you expect, what a good answer from each looks like, and which text surfaces you publish. `payloadBudget` weighs every listed call **and** every read-only tool that takes no required arguments, because the one tool nobody thought to list is the one that answers with 3.3 MB; pass a per-call `maxBytes` for a bulk-export tool that is deliberately large. It also asserts that a JSON answer arrived with `structuredContent`.
161
167
 
168
+ `descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves, and `distinctEtagChecks(t, ['llms.txt', 'llms-index.txt', 'llms-full.txt'])` asserts that depths projecting one corpus carry different ETags — every one present, which a bare Set-size check misses.
169
+
162
170
  ## Markdown twins
163
171
 
164
172
  Pass an `etag` and answer `notModified` before rendering: a twin is the single most recrawled URL a page has, so a twin with no validator is a full render on every pass, forever — the same arithmetic that justifies the corpus ETag, applied per page.
@@ -186,6 +194,8 @@ Block trees stay in your app — every project owns its own type set. Give this
186
194
 
187
195
  The `llms.txt` / `llms-index.txt` / `llms-full.txt` trio are the most-recrawled URLs a wiki serves and the most expensive to render. Without a corpus-derived ETag, every AI crawler pays full price on every pass, forever.
188
196
 
197
+ 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.
198
+
189
199
  ```ts
190
200
  import { corpusEtag, notModified, textHeaders } from 'wiki-formant/http';
191
201
 
@@ -264,6 +274,7 @@ are unit-tested without a DOM.
264
274
  `wiki-formant/react` carries `'use client'`, and that is a module-level boundary: anything exported from it hydrates in the consumer's tree whether or not it uses a hook. `wiki-formant/react-server` is the same React, without the directive — for the parts of a wiki that are pure functions of their props and should ship no JavaScript at all.
265
275
 
266
276
  `FacetBar` renders the rows `createTaxonomy` already produces. This is why the taxonomy exports a rows model rather than markup: the rows could always cross the boundary and, until this subpath existed, the markup could not, so all three wikis hand-rendered it and two put `aria-pressed` on an `<a>`. `Breadcrumbs` renders the trail and its `BreadcrumbList` JSON-LD together, because a trail whose structured data is written somewhere else is a trail that will one day disagree with its own markup — which is the case Google penalises. It takes a `base` origin: structured-data URLs must be absolute and a package cannot know the site.
277
+ `FacetBar` takes a `count` class to render each count as its own element, and `alphaFirst` to lead with the A–Z row. Its JSON-LD goes out through `JsonLd`, which is exported for every other payload a page emits: it re-encodes each `<` as `\u003c`, because these payloads carry authored titles and an authored `</script>` would otherwise close the tag.
267
278
 
268
279
  `PageNav` is the previous/next pair at the foot of an article — the sequential read the infobox rail's lateral links do not cover. Ordering is the caller's, because it is the one part that is never portable: a wiki's sequence is its section's configured sort, a knowledge base's is a taxonomy walk. Pair it with `adjacentPages` from `wiki-formant/pagination` over a list you already hold — neither wiki needs a query for it, and the two indexed lookups the neighbours used to cost were the reason one of them dropped the control.
269
280
 
@@ -308,6 +319,19 @@ and a crawler obeys only its most-specific matching group, so an agent with no
308
319
  group of its own falls through to `*`. Three wikis were measuring five crawlers
309
320
  they had never addressed.
310
321
 
322
+ Every group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
323
+
324
+ ## Page metadata
325
+
326
+ `wiki-formant/metadata`'s `pageMetadata` builds a page's canonical, markdown-twin alternate, Open Graph and Twitter card from one input. Next replaces those objects per route segment rather than merging them, and fills a missing twitter card from `openGraph` only when no `twitter` object was inherited — so under a layout that sets its own card, a page that sets one and forgets the other shows the layout's. Two wikis wrote a helper around that, covering different halves.
327
+
328
+ ```ts
329
+ export const generateMetadata = () => ({
330
+ title,
331
+ ...pageMetadata({ title, description, url, type: 'article', image: ogImageUrl(title), siteName, handle, markdownTwin: true }),
332
+ });
333
+ ```
334
+
311
335
  ## Revisions
312
336
 
313
337
  What changed between two versions of a page, and therefore which semver bump to
@@ -318,10 +342,7 @@ between the copies; they differed only in what each had learned since.
318
342
  const diff = computeRevisionDiff({
319
343
  currentVersion: page.version,
320
344
  oldContent, newContent, oldTitle, newTitle,
321
- containers: b =>
322
- b.type === 'infobox' ? [{ path: 'blocks', blocks: b.blocks }]
323
- : b.type === 'columns' ? b.columns.map((c, i) => ({ path: `columns.${i}.blocks`, blocks: c.blocks }))
324
- : null,
345
+ // containers: defaults to coreBlockGroups — `columns.i.blocks` and an infobox's `blocks`
325
346
  });
326
347
  ```
327
348
 
@@ -330,9 +351,9 @@ const diff = computeRevisionDiff({
330
351
  - **Container keys are never diffed as attributes.** They are walked as their own
331
352
  entries, and comparing them here would report an infobox as edited every time
332
353
  anything inside it changed — turning a prose edit into a structural bump.
333
- - **The path segment is a parameter**, because `root.1.columns.0.blocks.2` is
334
- what a reviewing UI anchors on and only the consumer knows how its containers
335
- are addressed.
354
+ - **The path segment is part of the contract**, because `root.1.columns.0.blocks.2`
355
+ is what a reviewing UI anchors on. A wiki with a container beyond the core two
356
+ passes its own `containers` and says how it is addressed.
336
357
 
337
358
  One copy also built two Maps keyed by a recursive `JSON.stringify` of every
338
359
  block, on every save, and never read either one. Matching is by id and always
@@ -355,6 +376,7 @@ carries no build date rather than a fictional one.
355
376
 
356
377
  ## Licence declarations
357
378
 
379
+
358
380
  S10 wants a licence on every surface, and each repo satisfied that by writing the
359
381
  same block again. What is genuinely per-project is the *scope* — which half of a
360
382
  site the grant covers and what it excludes — so that is the parameter.
@@ -364,6 +386,8 @@ const license = ccBy40({ siteName: 'AcuiQ', siteUrl: SITE_URL });
364
386
  const block = licenseBlock({ license, scope: 'The protocol compilation and prose', excludes });
365
387
  ```
366
388
 
389
+ 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.
390
+
367
391
  ## Rendered-article passes
368
392
 
369
393
  `wiki-formant/dom` holds what runs against an article element after it is in the document. Not React, so not in `react.tsx`.
@@ -377,12 +401,57 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
377
401
 
378
402
  `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.
379
403
 
404
+ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetEmbeds(ref, [html])` the embed pair, from `wiki-formant/react`. 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.
405
+
380
406
  `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.
381
407
 
382
408
  `sortTables` makes the tables stored in article HTML sortable by their headers. They arrive as a string a `dangerouslySetInnerHTML` wrote, so React never sees their rows and cannot sort them. A column is dates if every filled cell starts with one, numbers if every one does, and text otherwise — one stray value makes the whole column text, which beats sorting half of it by one rule and half by another. A third press restores the author's order, which is often chronological or ranked and otherwise needs a reload. Label/value tables and tables with merged cells are left alone. The markup it writes — `aria-sort` on the cell, a `.sort-header` button inside it — is the markup a React sortable header should write too, so both kinds of table draw their arrows from one stylesheet rule.
383
409
 
384
410
  `TWITTER_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.
385
411
 
412
+ ## Search
413
+
414
+ `proseSql`, `searchTsvSql`, `HEADLINE_OPTIONS` and `FTS_RANK_NORMALIZATION` are what a literal tier and a full-text tier must agree on. `searchTsvDdl(table)` is the statements that build the generated `search_tsv` column and its index — dropped and re-added in one transaction every run, because a skip-if-present script is blind to a changed expression.
415
+
416
+ ## Block trees
417
+
418
+ Every wiki here stores the same two containers the same way — `columns[i].blocks` and an infobox's `blocks` — and between them had hand-written that walk seven times. Bind it once and hand it to every walk:
419
+
420
+ ```ts
421
+ import { coreBlockShape, leafBlocks, mapBlockTree } from 'wiki-formant/blocks';
422
+
423
+ export const BLOCK_SHAPE = coreBlockShape<Block>();
424
+ mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);
425
+ leafBlocks(blocks, BLOCK_SHAPE.containers);
426
+ ```
427
+
428
+ `computeRevisionDiff` defaults to the same shape, path-addressed, so a repo whose containers are the core two passes none.
429
+
430
+ The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
431
+
432
+ `createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
433
+
434
+ ```ts
435
+ const issues = blockIssues(body.content);
436
+ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(issues)}`);
437
+ // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
438
+ ```
439
+
440
+ ## Sanitising stored HTML
441
+
442
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render three HTML fields themselves, and a repo renders `content.text` beside them, so the guard ships with the renderer. `sanitize-html` is an optional peer; run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
443
+
444
+ ```ts
445
+ import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
446
+
447
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // pair with CSP frame-src
448
+ const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
449
+ ```
450
+
451
+ The default list is derived from what the editor nodes in `wiki-formant/tiptap` store — the embed wrappers' data attributes, the tab markup `activateTabGroups` reads back, the table classes — plus the presentational SVG subset the infographics pipeline embeds. Extend it with `tags`, `attributes`, `classes` and `schemesByTag` derived from your stored HTML, never from memory: a list written from memory erases content on the first render.
452
+
453
+ 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.
454
+
386
455
  ## Editor nodes
387
456
 
388
457
  `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. The four `@tiptap/*` packages are optional peers, so a consumer taking only the taxonomy still installs a package with no runtime dependencies.
@@ -399,6 +468,14 @@ The ones that take config take it because that is exactly where the two copies d
399
468
 
400
469
  `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
401
470
 
471
+ `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
472
+
473
+ 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.
474
+
475
+ ```ts
476
+ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
477
+ ```
478
+
402
479
  ## Analytics
403
480
 
404
481
  Two lanes, one events helper. `mcpCallProps` reads a tool name out of a JSON-RPC
@@ -79,19 +79,16 @@ export declare function StatsView({ items, columns, }: {
79
79
  /**
80
80
  * A maintenance notice.
81
81
  *
82
- * The label and the fallback message arrive as `meta` because they are this
83
- * wiki's editorial voice — "You can help RADIX Wiki by expanding it" has a name
84
- * in it. The markup, the `role="note"` and the variant class are shared, and
85
- * were identical in both repos. `icon` is optional: one wiki sets one, and a
86
- * component library that hardcoded an icon set would make its consumers install
87
- * that icon set.
82
+ * The label is `BANNER_LABELS`, looked up here: three renderers each did the
83
+ * unknown-variant fallback and the lookup, and one restated all six labels.
84
+ * The fallback `message` arrives as a prop because it is the wiki's editorial
85
+ * voice — "You can help RADIX Wiki by expanding it" has a name in it. `icon`
86
+ * is optional: a component library that hardcoded an icon set would make its
87
+ * consumers install that icon set.
88
88
  */
89
- export declare function BannerView({ variant, text, meta, icon, }: {
89
+ export declare function BannerView({ variant, text, message, icon, }: {
90
90
  variant: string;
91
91
  text?: string | null;
92
- meta: {
93
- label: string;
94
- message: string;
95
- };
92
+ message: string;
96
93
  icon?: ReactNode;
97
94
  }): import("react").JSX.Element;
@@ -22,6 +22,7 @@ import { Fragment, useState } from 'react';
22
22
  import { Anchor } from './react-server.js';
23
23
  import { safeLinkHref } from './validation.js';
24
24
  import { cx } from './html.js';
25
+ import { BANNER_LABELS, bannerVariant } from './text.js';
25
26
  // ---- codeTabs ---------------------------------------------------------------
26
27
  /**
27
28
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
@@ -106,13 +107,14 @@ export function StatsView({ items, columns = 4, }) {
106
107
  /**
107
108
  * A maintenance notice.
108
109
  *
109
- * The label and the fallback message arrive as `meta` because they are this
110
- * wiki's editorial voice — "You can help RADIX Wiki by expanding it" has a name
111
- * in it. The markup, the `role="note"` and the variant class are shared, and
112
- * were identical in both repos. `icon` is optional: one wiki sets one, and a
113
- * component library that hardcoded an icon set would make its consumers install
114
- * that icon set.
110
+ * The label is `BANNER_LABELS`, looked up here: three renderers each did the
111
+ * unknown-variant fallback and the lookup, and one restated all six labels.
112
+ * The fallback `message` arrives as a prop because it is the wiki's editorial
113
+ * voice — "You can help RADIX Wiki by expanding it" has a name in it. `icon`
114
+ * is optional: a component library that hardcoded an icon set would make its
115
+ * consumers install that icon set.
115
116
  */
116
- export function BannerView({ variant, text, meta, icon, }) {
117
- return (_jsxs("div", { className: cx('editorial-banner', `editorial-banner-${variant}`), role: "note", children: [icon, _jsxs("p", { className: "editorial-banner-body", children: [_jsxs("strong", { children: [meta.label, "."] }), " ", text?.trim() || meta.message] })] }));
117
+ export function BannerView({ variant, text, message, icon, }) {
118
+ const known = bannerVariant(variant);
119
+ return (_jsxs("div", { className: cx('editorial-banner', `editorial-banner-${known}`), role: "note", children: [icon, _jsxs("p", { className: "editorial-banner-body", children: [_jsxs("strong", { children: [BANNER_LABELS[known], "."] }), " ", text?.trim() || message] })] }));
118
120
  }
package/dist/blocks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BlockGroup } from './revisions.js';
1
2
  export interface CodeTab {
2
3
  label: string;
3
4
  language?: string;
@@ -57,13 +58,42 @@ export interface BlockTreeShape<B> {
57
58
  rebuild: (block: B, groups: B[][]) => B;
58
59
  }
59
60
  /**
60
- * Every block in the tree, transformed, with the tree's shape preserved.
61
+ * The core containers' nested groups, each with the path segment that
62
+ * addresses it: `columns` holds `columns[i].blocks`, `infobox` holds `blocks`.
63
+ * Every other type is a leaf.
64
+ *
65
+ * All three wikis store these two identically, and between them had written
66
+ * this walk seven times — once per markdown twin, text export, revision diff,
67
+ * resolver and checker. A walk that forgot a container shipped a code block
68
+ * unhighlighted or dropped a link from the checker. The path-addressed form is
69
+ * what `computeRevisionDiff` defaults to.
70
+ */
71
+ export declare function coreBlockGroups<B extends {
72
+ type: string;
73
+ }>(block: B): BlockGroup<B>[] | null;
74
+ /**
75
+ * `coreBlockGroups` as a `BlockTreeShape`: read the groups, and put mapped
76
+ * groups back where they came from. Bind it once per repo,
77
+ * `const BLOCK_SHAPE = coreBlockShape<Block>()`, and hand it to every walk.
78
+ */
79
+ export declare function coreBlockShape<B extends {
80
+ type: string;
81
+ }>(): BlockTreeShape<B>;
82
+ /** Every leaf in document order, with the containers flattened away. */
83
+ export declare function leafBlocks<B>(blocks: readonly B[], containers: (block: B) => B[][] | null): B[];
84
+ /**
85
+ * Every leaf in the tree, transformed, with the tree's shape preserved.
61
86
  *
62
87
  * The three-branch walk this replaces — columns to `columns[].blocks`, infobox
63
88
  * to `.blocks`, everything else atomic — was written by hand for heading
64
89
  * injection, syntax highlighting, server-side data resolution, feed rendering
65
90
  * and twice more besides. Each copy was correct and each had to be found again
66
91
  * whenever a container type was added.
92
+ *
93
+ * It recurses, so `map` only ever sees leaves, however deep. It used to stop
94
+ * one level down and hand a nested container to `map` as if it were a leaf —
95
+ * harmless while every validator here rejects nesting, and a hole the moment a
96
+ * sanitising pass is the thing being mapped.
67
97
  */
68
98
  export declare function mapBlockTree<B>(blocks: readonly B[], map: (block: B) => B, shape: BlockTreeShape<B>): B[];
69
99
  /** The async twin. Highlighting and data resolution both await per leaf. */
package/dist/blocks.js CHANGED
@@ -19,36 +19,95 @@ import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
19
19
  */
20
20
  export function renderBlockTree(blocks, opts) {
21
21
  const { atomic, containers, groupSeparator = '\n\n' } = opts;
22
- return blocks
23
- .map(block => {
22
+ const render = (block) => {
24
23
  const groups = containers?.(block);
25
24
  if (!groups)
26
25
  return atomic(block);
27
26
  return groups
28
- .map(group => group.map(atomic).filter(Boolean).join(groupSeparator))
27
+ .map(group => group.map(render).filter(Boolean).join(groupSeparator))
29
28
  .filter(Boolean)
30
29
  .join(groupSeparator);
31
- })
30
+ };
31
+ return blocks
32
+ .map(render)
32
33
  .filter(Boolean)
33
34
  .join('\n\n')
34
35
  .replace(/\n{3,}/g, '\n\n')
35
36
  .trim();
36
37
  }
38
+ const list = (v) => (Array.isArray(v) ? v : []);
37
39
  /**
38
- * Every block in the tree, transformed, with the tree's shape preserved.
40
+ * The core containers' nested groups, each with the path segment that
41
+ * addresses it: `columns` holds `columns[i].blocks`, `infobox` holds `blocks`.
42
+ * Every other type is a leaf.
43
+ *
44
+ * All three wikis store these two identically, and between them had written
45
+ * this walk seven times — once per markdown twin, text export, revision diff,
46
+ * resolver and checker. A walk that forgot a container shipped a code block
47
+ * unhighlighted or dropped a link from the checker. The path-addressed form is
48
+ * what `computeRevisionDiff` defaults to.
49
+ */
50
+ export function coreBlockGroups(block) {
51
+ const b = block;
52
+ if (b.type === 'columns') {
53
+ return list(b.columns).map((col, i) => ({
54
+ path: `columns.${i}.blocks`,
55
+ blocks: list(col?.blocks),
56
+ }));
57
+ }
58
+ if (b.type === 'infobox')
59
+ return [{ path: 'blocks', blocks: list(b.blocks) }];
60
+ return null;
61
+ }
62
+ /**
63
+ * `coreBlockGroups` as a `BlockTreeShape`: read the groups, and put mapped
64
+ * groups back where they came from. Bind it once per repo,
65
+ * `const BLOCK_SHAPE = coreBlockShape<Block>()`, and hand it to every walk.
66
+ */
67
+ export function coreBlockShape() {
68
+ return {
69
+ containers: block => coreBlockGroups(block)?.map(group => group.blocks) ?? null,
70
+ rebuild: (block, groups) => {
71
+ const b = block;
72
+ if (b.type === 'columns') {
73
+ return {
74
+ ...block,
75
+ columns: list(b.columns).map((col, i) => ({ ...col, blocks: groups[i] ?? [] })),
76
+ };
77
+ }
78
+ if (b.type === 'infobox')
79
+ return { ...block, blocks: groups[0] ?? [] };
80
+ return block;
81
+ },
82
+ };
83
+ }
84
+ /** Every leaf in document order, with the containers flattened away. */
85
+ export function leafBlocks(blocks, containers) {
86
+ return blocks.flatMap(block => {
87
+ const groups = containers(block);
88
+ return groups ? groups.flatMap(group => leafBlocks(group, containers)) : [block];
89
+ });
90
+ }
91
+ /**
92
+ * Every leaf in the tree, transformed, with the tree's shape preserved.
39
93
  *
40
94
  * The three-branch walk this replaces — columns to `columns[].blocks`, infobox
41
95
  * to `.blocks`, everything else atomic — was written by hand for heading
42
96
  * injection, syntax highlighting, server-side data resolution, feed rendering
43
97
  * and twice more besides. Each copy was correct and each had to be found again
44
98
  * whenever a container type was added.
99
+ *
100
+ * It recurses, so `map` only ever sees leaves, however deep. It used to stop
101
+ * one level down and hand a nested container to `map` as if it were a leaf —
102
+ * harmless while every validator here rejects nesting, and a hole the moment a
103
+ * sanitising pass is the thing being mapped.
45
104
  */
46
105
  export function mapBlockTree(blocks, map, shape) {
47
106
  return blocks.map(block => {
48
107
  const groups = shape.containers(block);
49
108
  if (!groups)
50
109
  return map(block);
51
- return shape.rebuild(block, groups.map(group => group.map(map)));
110
+ return shape.rebuild(block, groups.map(group => mapBlockTree(group, map, shape)));
52
111
  });
53
112
  }
54
113
  /** The async twin. Highlighting and data resolution both await per leaf. */
@@ -57,7 +116,7 @@ export async function mapBlockTreeAsync(blocks, map, shape) {
57
116
  const groups = shape.containers(block);
58
117
  if (!groups)
59
118
  return map(block);
60
- const mapped = await Promise.all(groups.map(group => Promise.all(group.map(map))));
119
+ const mapped = await Promise.all(groups.map(group => mapBlockTreeAsync(group, map, shape)));
61
120
  return shape.rebuild(block, mapped);
62
121
  }));
63
122
  }
@@ -134,7 +134,19 @@ export declare function conditionalGetChecks(t: Tester, paths: readonly string[]
134
134
  * document moving, which is worse than none. The body-derived ETag is the whole
135
135
  * validator, and it is enough.
136
136
  */
137
- export declare function descriptorChecks(t: Tester, paths: readonly string[]): Promise<void>;
137
+ /** The JSON descriptors every S10 origin serves: the two agent-card paths, the OpenAPI pair, the MCP manifest and the server card. */
138
+ export declare const S10_DESCRIPTORS: readonly string[];
139
+ export declare function descriptorChecks(t: Tester, paths?: readonly string[]): Promise<void>;
140
+ /**
141
+ * Documents that project one corpus at different depths carry different ETags.
142
+ *
143
+ * Depths sharing a validator pass every conditional-GET check and still never
144
+ * move when only one of them changes. Every one must also HAVE an ETag: a Set
145
+ * of three nulls is size one and fails, but one null among two tags would pass
146
+ * a size check alone — the copy of this check one repo carried had exactly
147
+ * that hole.
148
+ */
149
+ export declare function distinctEtagChecks(t: Tester, paths: readonly string[], label?: string): Promise<void>;
138
150
  /**
139
151
  * Every tool carries behavioural hints, and the ones that write say so.
140
152
  *
@@ -312,7 +312,16 @@ export async function conditionalGetChecks(t, paths) {
312
312
  * document moving, which is worse than none. The body-derived ETag is the whole
313
313
  * validator, and it is enough.
314
314
  */
315
- export async function descriptorChecks(t, paths) {
315
+ /** The JSON descriptors every S10 origin serves: the two agent-card paths, the OpenAPI pair, the MCP manifest and the server card. */
316
+ export const S10_DESCRIPTORS = [
317
+ '.well-known/agent-card.json',
318
+ '.well-known/agent.json',
319
+ '.well-known/openapi.json',
320
+ 'openapi.json',
321
+ '.well-known/mcp.json',
322
+ 'api/mcp/server-card',
323
+ ];
324
+ export async function descriptorChecks(t, paths = S10_DESCRIPTORS) {
316
325
  for (const path of paths) {
317
326
  const url = path.startsWith('http') ? path : `${t.base}/${path.replace(/^\//, '')}`;
318
327
  const label = path.replace(t.base, '');
@@ -326,6 +335,19 @@ export async function descriptorChecks(t, paths) {
326
335
  t.check(`${label} states a freshness`, /max-age=\d+/.test(fresh.headers.get('cache-control') ?? ''), `cache-control: ${fresh.headers.get('cache-control')}`);
327
336
  }
328
337
  }
338
+ /**
339
+ * Documents that project one corpus at different depths carry different ETags.
340
+ *
341
+ * Depths sharing a validator pass every conditional-GET check and still never
342
+ * move when only one of them changes. Every one must also HAVE an ETag: a Set
343
+ * of three nulls is size one and fails, but one null among two tags would pass
344
+ * a size check alone — the copy of this check one repo carried had exactly
345
+ * that hole.
346
+ */
347
+ export async function distinctEtagChecks(t, paths, label = 'llms depths have distinct ETags') {
348
+ const tags = await Promise.all(paths.map(p => fetch(`${t.base}/${p.replace(/^\//, '')}`).then(r => r.headers.get('etag'))));
349
+ t.check(label, tags.every(Boolean) && new Set(tags).size === paths.length, tags.join(' '));
350
+ }
329
351
  /**
330
352
  * Every tool carries behavioural hints, and the ones that write say so.
331
353
  *
@@ -0,0 +1,65 @@
1
+ /** One page as it appears in the document. */
2
+ export interface CorpusSection {
3
+ /** The page's address, `tag/path/slug`. */
4
+ path: string;
5
+ /** Its branch, for the preflight's per-branch breakdown. */
6
+ tagPath: string;
7
+ /** Heading, URL, date and body, exactly as the document will carry them. */
8
+ section: string;
9
+ }
10
+ /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
11
+ export declare const CORPUS_BUDGET: {
12
+ readonly default: 200000;
13
+ readonly min: 1000;
14
+ readonly max: 1000000;
15
+ };
16
+ export interface CorpusSliceOptions {
17
+ /** The returned document's heading. */
18
+ title: string;
19
+ /** Sizes and breakdowns only, no document. */
20
+ sizeOnly?: boolean;
21
+ /** First section to include: the previous call's `nextSkip`. */
22
+ skip?: number;
23
+ /** Character budget for `document`. */
24
+ maxChars?: number;
25
+ /** The preflight's closing advice: what is cheaper than pulling everything. */
26
+ hint?: string;
27
+ }
28
+ /**
29
+ * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
30
+ *
31
+ * `truncated` is true whenever content was cut: whole pages left over, or one
32
+ * page clipped mid-way because it alone exceeds the budget. `nextSkip` appears
33
+ * only when paging forward can return something; a clipped page is skipped
34
+ * past, since resuming on it would stall paging forever.
35
+ */
36
+ export declare function sliceCorpus(sections: readonly CorpusSection[], opts: CorpusSliceOptions): {
37
+ branches: {
38
+ pages: number;
39
+ chars: number;
40
+ path: string;
41
+ }[];
42
+ largestPages: {
43
+ path: string;
44
+ chars: number;
45
+ }[];
46
+ hint: string;
47
+ totalPages: number;
48
+ characters: number;
49
+ estimatedTokens: number;
50
+ tokenNote: string;
51
+ } | {
52
+ document: string;
53
+ clippedPage?: string | undefined;
54
+ clippedHint?: string | undefined;
55
+ nextSkip?: number | undefined;
56
+ skip: number;
57
+ includedPages: number;
58
+ omittedPages: number;
59
+ returnedCharacters: number;
60
+ truncated: boolean;
61
+ totalPages: number;
62
+ characters: number;
63
+ estimatedTokens: number;
64
+ tokenNote: string;
65
+ };
package/dist/corpus.js ADDED
@@ -0,0 +1,82 @@
1
+ // corpus.ts — a wiki's whole corpus, sliced to something a tool result can hold.
2
+ //
3
+ // /llms-full.txt can afford to be 3 MB: a fetch streams to disk under an ETag.
4
+ // A tool result lands in a context window, so the MCP twin of that export needs
5
+ // a preflight, a character budget and a page-aligned resume. Two wikis wrote
6
+ // that loop and called the second a copy of the first "field for field". It was
7
+ // not: the fields had different names, one reported `truncated: false` after
8
+ // clipping the last page — while its own prompt told agents to page until
9
+ // `truncated` was false — and the other sized its preflight on page bodies but
10
+ // sliced on whole sections, so the preflight undercounted what it would send.
11
+ //
12
+ // The corpus itself — which rows, in what order, how a page becomes a section —
13
+ // stays with each wiki. This is the arithmetic over the sections it hands in.
14
+ /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
15
+ export const CORPUS_BUDGET = { default: 200_000, min: 1_000, max: 1_000_000 };
16
+ const CLIP_MARKER = '\n\n[…page clipped at maxChars…]';
17
+ /**
18
+ * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
19
+ *
20
+ * `truncated` is true whenever content was cut: whole pages left over, or one
21
+ * page clipped mid-way because it alone exceeds the budget. `nextSkip` appears
22
+ * only when paging forward can return something; a clipped page is skipped
23
+ * past, since resuming on it would stall paging forever.
24
+ */
25
+ export function sliceCorpus(sections, opts) {
26
+ const { title, sizeOnly = false, skip = 0, maxChars = CORPUS_BUDGET.default, hint } = opts;
27
+ const characters = sections.reduce((n, s) => n + s.section.length, 0);
28
+ const head = {
29
+ totalPages: sections.length,
30
+ characters,
31
+ estimatedTokens: Math.round(characters / 4),
32
+ tokenNote: 'estimatedTokens is characters/4, a rough guide only.',
33
+ };
34
+ if (sizeOnly) {
35
+ const branches = new Map();
36
+ for (const s of sections) {
37
+ const b = branches.get(s.tagPath) ?? { pages: 0, chars: 0 };
38
+ branches.set(s.tagPath, { pages: b.pages + 1, chars: b.chars + s.section.length });
39
+ }
40
+ return {
41
+ ...head,
42
+ branches: [...branches.entries()].map(([path, b]) => ({ path, ...b })).sort((a, b) => b.chars - a.chars),
43
+ largestPages: sections
44
+ .map(s => ({ path: s.path, chars: s.section.length }))
45
+ .sort((a, b) => b.chars - a.chars)
46
+ .slice(0, 5),
47
+ hint: hint ?? 'Pull with maxChars, or one branch at a time with tagPath.',
48
+ };
49
+ }
50
+ const parts = [];
51
+ let used = 0;
52
+ let index = skip;
53
+ let clippedPage;
54
+ for (; index < sections.length; index++) {
55
+ const s = sections[index];
56
+ if (used + s.section.length > maxChars) {
57
+ if (!parts.length) {
58
+ parts.push(`${s.section.slice(0, maxChars)}${CLIP_MARKER}`);
59
+ clippedPage = s.path;
60
+ used = maxChars;
61
+ index++;
62
+ }
63
+ break;
64
+ }
65
+ parts.push(s.section);
66
+ used += s.section.length;
67
+ }
68
+ const morePages = index < sections.length;
69
+ return {
70
+ ...head,
71
+ skip,
72
+ includedPages: parts.length,
73
+ omittedPages: sections.length - index,
74
+ returnedCharacters: used,
75
+ truncated: morePages || clippedPage !== undefined,
76
+ ...(morePages ? { nextSkip: index } : {}),
77
+ ...(clippedPage
78
+ ? { clippedPage, clippedHint: 'This page alone exceeds maxChars; raise maxChars to read the rest of it.' }
79
+ : {}),
80
+ document: [`# ${title}\n\n> pages ${skip + 1}-${index} of ${sections.length}`, ...parts].join('\n\n'),
81
+ };
82
+ }
@@ -29,6 +29,12 @@ export interface RobotsGroup {
29
29
  allow?: string | string[];
30
30
  disallow?: string | string[];
31
31
  }
32
+ /**
33
+ * The agent surface an S10 origin serves, allowed in every group. All three
34
+ * `robots.ts` files listed these by hand, and a path missing from one list is
35
+ * an endpoint the origin advertises and then closes to the callers it named.
36
+ */
37
+ export declare const AGENT_SURFACE_PATHS: readonly string[];
32
38
  /**
33
39
  * The wildcard group followed by one group per crawler.
34
40
  *
@@ -40,5 +46,6 @@ export interface RobotsGroup {
40
46
  export declare function aiCrawlerRules(opts: {
41
47
  allow: string | string[];
42
48
  disallow: string | string[];
43
- aiAllow: string | string[];
49
+ /** Beyond `AGENT_SURFACE_PATHS`, which every group gets regardless. */
50
+ aiAllow?: string | string[];
44
51
  }): RobotsGroup[];