wiki-formant 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,7 +78,12 @@ const toc = headingsFrom(html); // [{ id, text, level }]
78
78
  Two behaviours worth knowing:
79
79
 
80
80
  - **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.
81
- - **Deduping is not a parameter.** Two headings with the same text otherwise mint the same id twice, and every link to the second lands on the first. The copy that lacked it had that bug.
81
+ - **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.
82
+
83
+ ```ts
84
+ const used = new Set([slug(page.title)]);
85
+ const blocks = page.blocks.map(b => ({ ...b, text: injectHeadingIds(b.text, { used }) }));
86
+ ```
82
87
 
83
88
  `headingsFrom` reads the string, not the rendered DOM — possible only where the body IS a string at render time. A wiki whose content streams in as blocks after mount has to query the DOM, and uses only the injector.
84
89
 
@@ -165,6 +170,8 @@ process.exit(t.summary());
165
170
 
166
171
  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`.
167
172
 
173
+ `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.
174
+
168
175
  ## Markdown twins
169
176
 
170
177
  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.
@@ -192,6 +199,8 @@ Block trees stay in your app — every project owns its own type set. Give this
192
199
 
193
200
  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.
194
201
 
202
+ 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.
203
+
195
204
  ```ts
196
205
  import { corpusEtag, notModified, textHeaders } from 'wiki-formant/http';
197
206
 
@@ -265,16 +274,23 @@ const { inputProps, listProps, optionProps } = combobox();
265
274
  The arithmetic is `wiki-formant/combobox`, which imports nothing, so the rules
266
275
  are unit-tested without a DOM.
267
276
 
277
+ `SortHeader` is the React half of `sortTables`: spread `useTableSort`'s `headerProps(key)` onto it and it renders a `<th aria-sort>` holding a `<button>`, the markup `sortTables` writes into stored tables, so one stylesheet rule draws both arrows. One of the copies it replaced put `onClick` on the cell, which no keyboard can reach.
278
+
279
+ `useBlockOperations(blocks, setBlocks, { duplicate })` is the five list edits every block editor makes, with ref-stable callbacks, and `BlockActions` is their button bar — every button named, the moves disabled at the ends, clicks kept from bubbling into a row that selects on click. `useRevisionRestore(restore, { onRestored })` confirms, restores and reports an error; `restoreViaPost(endpoint)` is the `restore` for a wiki whose route takes `{ revisionId }`. Of the three restores it replaced, one asked no confirmation and one dropped a failure silently.
280
+
268
281
  ## Server components
269
282
 
270
283
  `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.
271
284
 
272
- `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. 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.
285
+ `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.
286
+ `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.
273
287
 
274
288
  `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.
275
289
 
276
290
  `RailShell` stays in `wiki-formant/react`, because it calls `useSidebar` and genuinely is a client component. It renders the rail's landmark, its scroll wrapper and the three collapse states — including the `--instant` class that keeps a remembered-closed rail from animating shut on first paint, and the close-on-tap that a mobile rail needs. Compose your own rail inside it and mark the active link with `isRailLinkActive`; all three wikis do, and the pre-composed component that used to sit here had one consumer and had already lost both of those behaviours.
277
291
 
292
+ `FacetSummary` is the line under the facet bar — how much of the section is showing, and a Clear link when anything is narrowed — with the filtered-empty and empty-section states. `RelatedPages` is the see-also list: a labelled `<aside>` over a `<ul>`, its heading linked to the set `rankRelated` found in common. `InfoboxAside` is the facts panel's named landmark with its "Part of a series on" line, and `InfoboxFacts` renders `metadataRows` as a row-headed table. That table was an HTML string in two wikis, injected as a fake content block; one re-escaped a `"` before each href by hand and the other did not. As React nodes there is nothing to escape. `formatFactValue` is its default cell: a date as its day, a URL or bare domain as an outbound link, `<br>`-separated values as lines.
293
+
278
294
  All three take the router's link component as a prop:
279
295
 
280
296
  ```tsx
@@ -314,6 +330,25 @@ and a crawler obeys only its most-specific matching group, so an agent with no
314
330
  group of its own falls through to `*`. Three wikis were measuring five crawlers
315
331
  they had never addressed.
316
332
 
333
+ 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.
334
+
335
+ ## Page metadata
336
+
337
+ `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.
338
+
339
+ ```ts
340
+ export const generateMetadata = () => ({
341
+ title,
342
+ ...pageMetadata({ title, description, url, type: 'article', image: ogImageUrl(title), siteName, handle, markdownTwin: true }),
343
+ });
344
+ ```
345
+
346
+ The schema.org nodes sit beside it, because they state the same facts to a different reader. `articleLd` requires `image` — two of three wikis shipped articles without one while their og:image named a card — so pass it the card `pageMetadata` got. `collectionLd` is an index page over an `ItemList`, and `citationsFromReferences` turns a `references` block into `Article.citation`.
347
+
348
+ ```tsx
349
+ <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
350
+ ```
351
+
317
352
  ## Revisions
318
353
 
319
354
  What changed between two versions of a page, and therefore which semver bump to
@@ -348,9 +383,11 @@ verbatim in two of them, the comment explaining why the apostrophe escapes
348
383
  numerically. `renderFeed` is the union.
349
384
 
350
385
  ```ts
351
- return new Response(renderFeed(channel, items), { headers: FEED_HEADERS });
386
+ export const GET = async (request: Request) => feedResponse(request, channel, await items());
352
387
  ```
353
388
 
389
+ `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.
390
+
354
391
  **`lastBuildDate` comes from the newest item, not the clock.** One copy stamped
355
392
  `new Date()` on every request, telling every poller the feed had changed when it
356
393
  had not — the recrawl the conditional-GET helpers exist to prevent. An empty feed
@@ -358,6 +395,7 @@ carries no build date rather than a fictional one.
358
395
 
359
396
  ## Licence declarations
360
397
 
398
+
361
399
  S10 wants a licence on every surface, and each repo satisfied that by writing the
362
400
  same block again. What is genuinely per-project is the *scope* — which half of a
363
401
  site the grant covers and what it excludes — so that is the parameter.
@@ -367,6 +405,8 @@ const license = ccBy40({ siteName: 'AcuiQ', siteUrl: SITE_URL });
367
405
  const block = licenseBlock({ license, scope: 'The protocol compilation and prose', excludes });
368
406
  ```
369
407
 
408
+ 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.
409
+
370
410
  ## Rendered-article passes
371
411
 
372
412
  `wiki-formant/dom` holds what runs against an article element after it is in the document. Not React, so not in `react.tsx`.
@@ -380,12 +420,18 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
380
420
 
381
421
  `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.
382
422
 
423
+ 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.
424
+
383
425
  `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.
384
426
 
385
427
  `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.
386
428
 
387
429
  `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.
388
430
 
431
+ ## Search
432
+
433
+ `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.
434
+
389
435
  ## Block trees
390
436
 
391
437
  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:
@@ -410,9 +456,17 @@ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(iss
410
456
  // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
411
457
  ```
412
458
 
459
+ A `codeTabs` tab's `code` is source text in every wiki here, and every view treats it that way: `CodeTabsView` escapes it into a `<pre><code>`, the sanitiser leaves it alone, the markdown twin fences it verbatim. A wiki that escapes and highlights on the server passes `highlighted` to the view. The package used to assume editors stored highlighted markup; none ever had, and every twin printed `Vec<u8>` as `Vec`.
460
+
461
+ `recentPages` and `pageList` 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.
462
+
463
+ ## Dates
464
+
465
+ `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. Both are in `wiki-formant/freshness`, with `isoDate`.
466
+
413
467
  ## Sanitising stored HTML
414
468
 
415
- `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.
469
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. `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.
416
470
 
417
471
  ```ts
418
472
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
@@ -441,6 +495,10 @@ The ones that take config take it because that is exactly where the two copies d
441
495
 
442
496
  `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.
443
497
 
498
+ `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.
499
+
500
+ `TOOLBAR_ACTIONS` are the formatting commands with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Pick yours in order with `toolbarActions([...])`, render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
501
+
444
502
  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.
445
503
 
446
504
  ```ts
@@ -471,6 +529,16 @@ leaves a wiki able to say what every crawler asked for and nothing about what
471
529
  its readers asked for. The queries that return zero rows are the valuable ones —
472
530
  they name a gap in the corpus in the reader's own words.
473
531
 
532
+ ## Base stylesheet
533
+
534
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, and the `aria-sort` arrow as a mask over `currentColor`. No colour and no scale, so a design system's own rules override it at equal specificity.
535
+
536
+ ```css
537
+ @import "wiki-formant/base.css" layer(components);
538
+ ```
539
+
540
+ 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.
541
+
474
542
  ## API
475
543
 
476
544
  Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on.
package/dist/base.css ADDED
@@ -0,0 +1,100 @@
1
+ /* wiki-formant/base.css — the layout the package's markup does not work without.
2
+
3
+ Structure only: no colour, no type scale, no design token. Every value that
4
+ is a look rather than a mechanism is left to the importing stylesheet, which
5
+ loads this first and overrides anything here with an equally specific rule.
6
+
7
+ @import "wiki-formant/base.css" layer(components);
8
+
9
+ Three wikis had each restated these rules, and each had one missing: one
10
+ emitted the stored-tab and copy-button markup with no CSS for it, so every
11
+ panel showed at once; one never received the utility classes ColumnsView
12
+ used to emit, so two of its four alignments did nothing. */
13
+
14
+ /* ---- ColumnsView ----------------------------------------------------------
15
+ Stacked until there is room for a row. `data-gap` and `data-align` replace
16
+ the Tailwind utilities the view used to emit, which only reached a site whose
17
+ Tailwind happened to scan node_modules. */
18
+ .columns-layout {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--columns-gap, 1rem);
22
+ }
23
+ .columns-layout[data-gap='sm'] { --columns-gap: 0.5rem; }
24
+ .columns-layout[data-gap='lg'] { --columns-gap: 1.5rem; }
25
+ .columns-layout[data-align='start'] { align-items: flex-start; }
26
+ .columns-layout[data-align='center'] { align-items: center; }
27
+ .columns-layout[data-align='end'] { align-items: flex-end; }
28
+ .columns-layout[data-align='stretch'] { align-items: stretch; }
29
+ .column-view {
30
+ display: flex;
31
+ flex-direction: column;
32
+ min-width: 0;
33
+ }
34
+ @media (min-width: 48rem) {
35
+ .columns-layout { flex-direction: row; }
36
+ .column-view { flex: 1 1 0%; }
37
+ }
38
+ /* A stacked column is as wide as the row, whatever the alignment. */
39
+ @media (max-width: 47.99rem) {
40
+ .columns-layout > .column-view { align-self: stretch; }
41
+ }
42
+
43
+ /* ---- stored tab groups (activateTabGroups) --------------------------------
44
+ Only the active panel shows. Before the script runs, the first tab's content
45
+ stands in for the group rather than every tab at once. */
46
+ .tab-panel:not(.active) { display: none; }
47
+ [data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
48
+
49
+ /* ---- code copy buttons (addCopyButtons) -----------------------------------
50
+ The script positions the <pre>; this pins the button in its corner. Shown
51
+ on hover, on keyboard focus, and always where there is no hover to show it. */
52
+ .code-copy-btn {
53
+ position: absolute;
54
+ top: 0.5rem;
55
+ right: 0.5rem;
56
+ display: inline-flex;
57
+ cursor: pointer;
58
+ opacity: 0;
59
+ transition: opacity 120ms ease;
60
+ }
61
+ pre:hover > .code-copy-btn,
62
+ .code-copy-btn:focus-visible { opacity: 1; }
63
+ @media (hover: none) {
64
+ .code-copy-btn { opacity: 1; }
65
+ }
66
+
67
+ /* ---- sortable headers (SortHeader, sortTables) ----------------------------
68
+ The arrow is read from `aria-sort` on the cell, so it cannot point one way
69
+ while the rows go the other. It is a mask over currentColor, so it takes the
70
+ header's colour, whatever that is. */
71
+ .sort-header {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ gap: 0.25em;
75
+ padding: 0;
76
+ border: 0;
77
+ border-radius: 0;
78
+ background: none;
79
+ color: inherit;
80
+ font: inherit;
81
+ text-align: inherit;
82
+ cursor: pointer;
83
+ }
84
+ .sort-header::after {
85
+ content: '';
86
+ flex: none;
87
+ width: 0.75em;
88
+ height: 0.75em;
89
+ background-color: currentColor;
90
+ opacity: 0.4;
91
+ --sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E");
92
+ mask: var(--sort-icon) center / contain no-repeat;
93
+ }
94
+ :is([aria-sort='ascending'], [aria-sort='descending']) > .sort-header::after { opacity: 1; }
95
+ [aria-sort='ascending'] > .sort-header::after {
96
+ --sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 7-7 7 7'/%3E%3Cpath d='M12 19V5'/%3E%3C/svg%3E");
97
+ }
98
+ [aria-sort='descending'] > .sort-header::after {
99
+ --sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='m19 12-7 7-7-7'/%3E%3C/svg%3E");
100
+ }
@@ -1,15 +1,22 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { CodeTab, LinkGridGroup, ReferenceItem, StatItem } from './blocks.js';
2
+ import type { CodeTab, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
3
3
  import type { WikiLinkComponent } from './react-server.js';
4
+ export interface CodeTabsViewProps {
5
+ tabs: readonly CodeTab[];
6
+ /**
7
+ * Every tab's `code` has already been escaped and highlighted into markup on
8
+ * the server, and is written as HTML. Only a wiki whose render path does that
9
+ * for every tree reaching this view may pass it: without it, `code` is source
10
+ * and renders escaped in a `<pre><code>`, which is the safe default.
11
+ */
12
+ highlighted?: boolean;
13
+ }
4
14
  /**
5
15
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
6
- * hidden rather than unmounted: the panels carry pre-highlighted markup, and
7
- * remounting one would re-run whatever the consumer's highlighter attached to
8
- * it on every tab press.
16
+ * hidden rather than unmounted: remounting a highlighted panel would re-run
17
+ * whatever the consumer's highlighter attached to it on every tab press.
9
18
  */
10
- export declare function CodeTabsView({ tabs }: {
11
- tabs: readonly CodeTab[];
12
- }): import("react").JSX.Element | null;
19
+ export declare function CodeTabsView({ tabs, highlighted }: CodeTabsViewProps): import("react").JSX.Element | null;
13
20
  export interface ColumnsViewProps<B extends {
14
21
  id: string;
15
22
  }> {
@@ -29,6 +36,10 @@ export interface ColumnsViewProps<B extends {
29
36
  * it was a layout bug rather than a style: `.column-view` is the flow context
30
37
  * its stylesheet spaces children in, and an extra element between them means
31
38
  * every gap rule inside a column silently matches nothing.
39
+ *
40
+ * Gap and alignment are data attributes that `wiki-formant/base.css` reads.
41
+ * They were Tailwind utilities, which reached a page only if the site's
42
+ * Tailwind scanned this package — and on one site `end` and `stretch` did not.
32
43
  */
33
44
  export declare function ColumnsView<B extends {
34
45
  id: string;
@@ -48,6 +59,23 @@ export declare function LinkGridView({ groups, intro, link: Link, }: {
48
59
  intro?: string;
49
60
  link?: WikiLinkComponent;
50
61
  }): import("react").JSX.Element;
62
+ /**
63
+ * A list of resolved page references — the body of a `recentPages` or
64
+ * `pageList` block. A real list, with each age in a `<time>` carrying its ISO
65
+ * date: the copies this replaced were bare `<div>` rows, one emitted a
66
+ * `<time>` with no `dateTime`, and their empty states were three different
67
+ * things (a sentence, a different sentence, nothing).
68
+ *
69
+ * `renderItem` replaces the row's inner markup for a wiki with its own row
70
+ * component; the list, its semantics and the empty state stay here.
71
+ */
72
+ export declare function PageRefsView({ refs, empty, link: Link, renderItem, }: {
73
+ refs: readonly ResolvedPageRef[];
74
+ /** Said when there are none. Omit to render nothing. */
75
+ empty?: string;
76
+ link?: WikiLinkComponent;
77
+ renderItem?: (ref: ResolvedPageRef) => ReactNode;
78
+ }): import("react").JSX.Element | null;
51
79
  /**
52
80
  * The numbered citation list, with the `#cite-n` back-links the inline markers
53
81
  * point at. `processHtml` is a prop because only one wiki runs reference text
@@ -79,19 +107,16 @@ export declare function StatsView({ items, columns, }: {
79
107
  /**
80
108
  * A maintenance notice.
81
109
  *
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.
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.
88
116
  */
89
- export declare function BannerView({ variant, text, meta, icon, }: {
117
+ export declare function BannerView({ variant, text, message, icon, }: {
90
118
  variant: string;
91
119
  text?: string | null;
92
- meta: {
93
- label: string;
94
- message: string;
95
- };
120
+ message: string;
96
121
  icon?: ReactNode;
97
122
  }): import("react").JSX.Element;
@@ -22,18 +22,17 @@ 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
- // ---- codeTabs ---------------------------------------------------------------
25
+ import { BANNER_LABELS, bannerVariant } from './text.js';
26
26
  /**
27
27
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
28
- * hidden rather than unmounted: the panels carry pre-highlighted markup, and
29
- * remounting one would re-run whatever the consumer's highlighter attached to
30
- * it on every tab press.
28
+ * hidden rather than unmounted: remounting a highlighted panel would re-run
29
+ * whatever the consumer's highlighter attached to it on every tab press.
31
30
  */
32
- export function CodeTabsView({ tabs }) {
31
+ export function CodeTabsView({ tabs, highlighted = false }) {
33
32
  const [activeTab, setActiveTab] = useState(0);
34
33
  if (!tabs.length)
35
34
  return null;
36
- return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => (_jsx("div", { className: i === activeTab ? 'block' : 'hidden', dangerouslySetInnerHTML: { __html: tab.code } }, i)))] }));
35
+ return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => highlighted ? (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, dangerouslySetInnerHTML: { __html: tab.code } }, i)) : (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, children: _jsx("pre", { children: _jsx("code", { className: tab.language ? `language-${tab.language}` : undefined, children: tab.code }) }) }, i)))] }));
37
36
  }
38
37
  /**
39
38
  * A row of columns, each holding blocks the caller renders.
@@ -42,16 +41,13 @@ export function CodeTabsView({ tabs }) {
42
41
  * it was a layout bug rather than a style: `.column-view` is the flow context
43
42
  * its stylesheet spaces children in, and an extra element between them means
44
43
  * every gap rule inside a column silently matches nothing.
44
+ *
45
+ * Gap and alignment are data attributes that `wiki-formant/base.css` reads.
46
+ * They were Tailwind utilities, which reached a page only if the site's
47
+ * Tailwind scanned this package — and on one site `end` and `stretch` did not.
45
48
  */
46
49
  export function ColumnsView({ columns, gap = 'md', align = 'start', render, }) {
47
- const gapClass = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[gap];
48
- const alignClass = {
49
- start: 'items-start',
50
- center: 'items-center',
51
- end: 'items-end',
52
- stretch: 'items-stretch',
53
- }[align];
54
- return (_jsx("div", { className: cx('columns-layout', gapClass, alignClass), children: columns.map(col => (_jsx("div", { className: "column-view", children: (col.blocks ?? []).map(bl => (_jsx(Fragment, { children: render(bl) }, bl.id))) }, col.id))) }));
50
+ return (_jsx("div", { className: "columns-layout", "data-gap": gap, "data-align": align, children: columns.map(col => (_jsx("div", { className: "column-view", children: (col.blocks ?? []).map(bl => (_jsx(Fragment, { children: render(bl) }, bl.id))) }, col.id))) }));
55
51
  }
56
52
  // ---- linkGrid ---------------------------------------------------------------
57
53
  /**
@@ -70,6 +66,23 @@ export function LinkGridView({ groups, intro, link: Link = Anchor, }) {
70
66
  return /^https?:\/\//.test(href) ? (_jsx("a", { href: href, target: "_blank", rel: "noopener", children: item.label }, i)) : (_jsx(Link, { href: href, children: item.label }, i));
71
67
  }) })] }, group.id ?? gi)))] }));
72
68
  }
69
+ // ---- recentPages / pageList -------------------------------------------------
70
+ /**
71
+ * A list of resolved page references — the body of a `recentPages` or
72
+ * `pageList` block. A real list, with each age in a `<time>` carrying its ISO
73
+ * date: the copies this replaced were bare `<div>` rows, one emitted a
74
+ * `<time>` with no `dateTime`, and their empty states were three different
75
+ * things (a sentence, a different sentence, nothing).
76
+ *
77
+ * `renderItem` replaces the row's inner markup for a wiki with its own row
78
+ * component; the list, its semantics and the empty state stay here.
79
+ */
80
+ export function PageRefsView({ refs, empty, link: Link = Anchor, renderItem, }) {
81
+ if (!refs.length)
82
+ return empty ? _jsx("p", { className: "page-refs-empty", children: empty }) : null;
83
+ return (_jsx("ul", { className: "page-refs", children: refs.map(ref => (_jsx("li", { children: renderItem ? (renderItem(ref)) : (_jsxs(Link, { href: ref.href, className: "page-ref", children: [_jsx("span", { className: "page-ref-title", children: ref.title }), ref.timeAgo &&
84
+ (ref.updated ? (_jsx("time", { className: "page-ref-time", dateTime: ref.updated, children: ref.timeAgo })) : (_jsx("span", { className: "page-ref-time", children: ref.timeAgo })))] })) }, ref.href))) }));
85
+ }
73
86
  // ---- references -------------------------------------------------------------
74
87
  /**
75
88
  * The numbered citation list, with the `#cite-n` back-links the inline markers
@@ -106,13 +119,14 @@ export function StatsView({ items, columns = 4, }) {
106
119
  /**
107
120
  * A maintenance notice.
108
121
  *
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.
122
+ * The label is `BANNER_LABELS`, looked up here: three renderers each did the
123
+ * unknown-variant fallback and the lookup, and one restated all six labels.
124
+ * The fallback `message` arrives as a prop because it is the wiki's editorial
125
+ * voice — "You can help RADIX Wiki by expanding it" has a name in it. `icon`
126
+ * is optional: a component library that hardcoded an icon set would make its
127
+ * consumers install that icon set.
115
128
  */
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] })] }));
129
+ export function BannerView({ variant, text, message, icon, }) {
130
+ const known = bannerVariant(variant);
131
+ 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
132
  }
package/dist/blocks.d.ts CHANGED
@@ -1,9 +1,30 @@
1
+ import { type RelativeTimeStyle } from './freshness.js';
1
2
  import type { BlockGroup } from './revisions.js';
2
3
  export interface CodeTab {
3
4
  label: string;
4
5
  language?: string;
6
+ /** Source text, never markup: every view escapes it unless told otherwise. */
5
7
  code: string;
6
8
  }
9
+ /**
10
+ * A page a `recentPages` or `pageList` block points at, resolved on the server
11
+ * so the list renders with no client fetch and no client clock. Build it with
12
+ * `pageRef`, which is what fills `updated`: a `<time>` with no `dateTime`
13
+ * states an age no machine can read.
14
+ */
15
+ export interface ResolvedPageRef {
16
+ title: string;
17
+ href: string;
18
+ /** `relativeTime` of `updated`, as of the render. */
19
+ timeAgo?: string;
20
+ /** ISO timestamp. */
21
+ updated?: string;
22
+ }
23
+ export declare function pageRef(page: {
24
+ title: string;
25
+ href: string;
26
+ updatedAt: Date | string;
27
+ }, now: number, style?: RelativeTimeStyle): ResolvedPageRef;
7
28
  export interface ReferenceItem {
8
29
  text: string;
9
30
  url?: string | null;
@@ -109,9 +130,11 @@ export declare function someBlock<B>(blocks: readonly B[], predicate: (block: B)
109
130
  /**
110
131
  * Fenced blocks, one per tab, each under its label.
111
132
  *
112
- * The editor stores highlighted code, so the tags come out before the fence
113
- * goes on — a fenced block full of `<span class="hljs-keyword">` is worse than
114
- * no code at all.
133
+ * `code` is source, so it goes in verbatim. This used to strip tags on the
134
+ * belief that editors stored highlighted markup; none of the three wikis ever
135
+ * did, and the strip turned `Vec<u8>` into `Vec` in every markdown twin. The
136
+ * fence outgrows any backtick run in the code, so a sample that itself
137
+ * contains a fence cannot close this one early.
115
138
  */
116
139
  export declare function codeTabsToMarkdown(tabs: readonly CodeTab[]): string;
117
140
  /** A maintenance notice as a blockquote. `label` is the caller's display string. */
package/dist/blocks.js CHANGED
@@ -10,8 +10,16 @@
10
10
  // new block type is a compile error until it is handled) and the bodies come
11
11
  // from here. Each renderer takes the block's DATA rather than the block, which
12
12
  // keeps this file free of any one repo's type union.
13
- import { decodeEntities } from './markdown.js';
14
13
  import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
14
+ import { relativeTime } from './freshness.js';
15
+ export function pageRef(page, now, style = 'long') {
16
+ return {
17
+ title: page.title,
18
+ href: page.href,
19
+ timeAgo: relativeTime(page.updatedAt, now, { style }),
20
+ updated: new Date(page.updatedAt).toISOString(),
21
+ };
22
+ }
15
23
  /**
16
24
  * The whole tree as markdown. Containers flatten in document order, because a
17
25
  * reader following prose does not have columns — and an agent quoting the page
@@ -139,13 +147,19 @@ export function someBlock(blocks, predicate, containers) {
139
147
  /**
140
148
  * Fenced blocks, one per tab, each under its label.
141
149
  *
142
- * The editor stores highlighted code, so the tags come out before the fence
143
- * goes on — a fenced block full of `<span class="hljs-keyword">` is worse than
144
- * no code at all.
150
+ * `code` is source, so it goes in verbatim. This used to strip tags on the
151
+ * belief that editors stored highlighted markup; none of the three wikis ever
152
+ * did, and the strip turned `Vec<u8>` into `Vec` in every markdown twin. The
153
+ * fence outgrows any backtick run in the code, so a sample that itself
154
+ * contains a fence cannot close this one early.
145
155
  */
146
156
  export function codeTabsToMarkdown(tabs) {
147
157
  return tabs
148
- .map(t => `**${t.label}**\n\n\`\`\`${t.language || ''}\n${decodeEntities(t.code.replace(/<[^>]+>/g, '')).trim()}\n\`\`\``)
158
+ .map(t => {
159
+ const longest = Math.max(0, ...(t.code.match(/`+/g) ?? []).map(run => run.length));
160
+ const fence = '`'.repeat(Math.max(3, longest + 1));
161
+ return `**${t.label}**\n\n${fence}${t.language || ''}\n${t.code.trim()}\n${fence}`;
162
+ })
149
163
  .join('\n\n');
150
164
  }
151
165
  /** A maintenance notice as a blockquote. `label` is the caller's display string. */
@@ -66,7 +66,7 @@ export interface TesterOptions {
66
66
  export interface Tester {
67
67
  base: string;
68
68
  endpoint: string;
69
- /** One JSON-RPC call. Backs off once and retries on a 429. */
69
+ /** One JSON-RPC call. Waits out a 429 as `Retry-After` asks, up to four tries. */
70
70
  rpc(method: string, params?: unknown): Promise<Rpc>;
71
71
  /** `tools/call` shorthand. */
72
72
  call(name: string, args?: Record<string, unknown>): Promise<Rpc>;
@@ -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
  *