wiki-formant 0.22.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
 
@@ -269,6 +274,10 @@ const { inputProps, listProps, optionProps } = combobox();
269
274
  The arithmetic is `wiki-formant/combobox`, which imports nothing, so the rules
270
275
  are unit-tested without a DOM.
271
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
+
272
281
  ## Server components
273
282
 
274
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.
@@ -280,6 +289,8 @@ are unit-tested without a DOM.
280
289
 
281
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.
282
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
+
283
294
  All three take the router's link component as a prop:
284
295
 
285
296
  ```tsx
@@ -332,6 +343,12 @@ export const generateMetadata = () => ({
332
343
  });
333
344
  ```
334
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
+
335
352
  ## Revisions
336
353
 
337
354
  What changed between two versions of a page, and therefore which semver bump to
@@ -366,9 +383,11 @@ verbatim in two of them, the comment explaining why the apostrophe escapes
366
383
  numerically. `renderFeed` is the union.
367
384
 
368
385
  ```ts
369
- return new Response(renderFeed(channel, items), { headers: FEED_HEADERS });
386
+ export const GET = async (request: Request) => feedResponse(request, channel, await items());
370
387
  ```
371
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
+
372
391
  **`lastBuildDate` comes from the newest item, not the clock.** One copy stamped
373
392
  `new Date()` on every request, telling every poller the feed had changed when it
374
393
  had not — the recrawl the conditional-GET helpers exist to prevent. An empty feed
@@ -437,9 +456,17 @@ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(iss
437
456
  // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
438
457
  ```
439
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
+
440
467
  ## Sanitising stored HTML
441
468
 
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.
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.
443
470
 
444
471
  ```ts
445
472
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
@@ -470,6 +497,8 @@ The ones that take config take it because that is exactly where the two copies d
470
497
 
471
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.
472
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
+
473
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.
474
503
 
475
504
  ```ts
@@ -500,6 +529,16 @@ leaves a wiki able to say what every crawler asked for and nothing about what
500
529
  its readers asked for. The queries that return zero rows are the valuable ones —
501
530
  they name a gap in the corpus in the reader's own words.
502
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
+
503
542
  ## API
504
543
 
505
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
@@ -23,18 +23,16 @@ import { Anchor } from './react-server.js';
23
23
  import { safeLinkHref } from './validation.js';
24
24
  import { cx } from './html.js';
25
25
  import { BANNER_LABELS, bannerVariant } from './text.js';
26
- // ---- codeTabs ---------------------------------------------------------------
27
26
  /**
28
27
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
29
- * hidden rather than unmounted: the panels carry pre-highlighted markup, and
30
- * remounting one would re-run whatever the consumer's highlighter attached to
31
- * 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.
32
30
  */
33
- export function CodeTabsView({ tabs }) {
31
+ export function CodeTabsView({ tabs, highlighted = false }) {
34
32
  const [activeTab, setActiveTab] = useState(0);
35
33
  if (!tabs.length)
36
34
  return null;
37
- 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)))] }));
38
36
  }
39
37
  /**
40
38
  * A row of columns, each holding blocks the caller renders.
@@ -43,16 +41,13 @@ export function CodeTabsView({ tabs }) {
43
41
  * it was a layout bug rather than a style: `.column-view` is the flow context
44
42
  * its stylesheet spaces children in, and an extra element between them means
45
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.
46
48
  */
47
49
  export function ColumnsView({ columns, gap = 'md', align = 'start', render, }) {
48
- const gapClass = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[gap];
49
- const alignClass = {
50
- start: 'items-start',
51
- center: 'items-center',
52
- end: 'items-end',
53
- stretch: 'items-stretch',
54
- }[align];
55
- 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))) }));
56
51
  }
57
52
  // ---- linkGrid ---------------------------------------------------------------
58
53
  /**
@@ -71,6 +66,23 @@ export function LinkGridView({ groups, intro, link: Link = Anchor, }) {
71
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));
72
67
  }) })] }, group.id ?? gi)))] }));
73
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
+ }
74
86
  // ---- references -------------------------------------------------------------
75
87
  /**
76
88
  * The numbered citation list, with the `#cite-n` back-links the inline markers
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>;
@@ -40,20 +40,26 @@ export function createTester(opts) {
40
40
  const endpoint = opts.endpoint ?? `${base}/api/mcp`;
41
41
  const results = [];
42
42
  let calls = 0;
43
+ // The server's own rate limit is part of what is under test elsewhere; here
44
+ // it is noise, so it is waited out — for as long as `Retry-After` says, and a
45
+ // bounded number of times. This used to say "once" and recurse forever at a
46
+ // fixed 5s, so a suite pointed at a budget it could never get under hung.
47
+ const RETRIES = 4;
43
48
  const rpc = async (method, params) => {
44
- calls++;
45
- const res = await fetch(endpoint, {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json', 'User-Agent': opts.clientName },
48
- body: JSON.stringify({ jsonrpc: '2.0', id: calls, method, params }),
49
- });
50
- // The server's own rate limit is part of what is under test elsewhere; here
51
- // it is just noise, so wait it out once rather than failing the run.
52
- if (res.status === 429) {
53
- await new Promise(r => setTimeout(r, 5000));
54
- return rpc(method, params);
49
+ for (let attempt = 1;; attempt++) {
50
+ calls++;
51
+ const res = await fetch(endpoint, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json', 'User-Agent': opts.clientName },
54
+ body: JSON.stringify({ jsonrpc: '2.0', id: calls, method, params }),
55
+ });
56
+ if (res.status !== 429)
57
+ return (await res.json());
58
+ if (attempt === RETRIES)
59
+ throw new Error(`${method}: still rate-limited after ${RETRIES} tries`);
60
+ const wait = Number(res.headers.get('retry-after'));
61
+ await new Promise(r => setTimeout(r, Math.min(60, Number.isFinite(wait) && wait > 0 ? wait : 5) * 1000));
55
62
  }
56
- return (await res.json());
57
63
  };
58
64
  return {
59
65
  base,
package/dist/editor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AnyExtension, Editor } from '@tiptap/core';
2
- import { type ChangeEvent, type RefObject } from 'react';
2
+ import { type ChangeEvent, type ReactNode, type RefObject } from 'react';
3
3
  /**
4
4
  * Re-exported so the COMMAND AUGMENTATIONS reach consumers.
5
5
  *
@@ -46,6 +46,38 @@ export declare function insertEmbed(editor: Editor, url: string, { resolveMapUrl
46
46
  export declare const TABLE_ACTIONS: ReadonlyArray<[command: string, label: string, danger?: boolean]>;
47
47
  /** `editor.isActive` as a toolbar button's `active` descriptor expresses it. */
48
48
  export type ActiveDescriptor = string | [string, Record<string, unknown>];
49
+ /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
50
+ export declare function runTableAction(editor: Editor, command: string): void;
51
+ export type ToolbarKey = 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
52
+ export interface ToolbarAction {
53
+ key: ToolbarKey;
54
+ /** What a screen reader announces and a tooltip shows. */
55
+ label: string;
56
+ active?: ActiveDescriptor;
57
+ /** Absent on `link`, which needs a URL: call `withUrl` once you have one. */
58
+ run?: (editor: Editor) => void;
59
+ withUrl?: (editor: Editor, url: string) => void;
60
+ }
61
+ /**
62
+ * The formatting commands, labelled. Three toolbars ran these identically and
63
+ * two titled their buttons with the internal key — a screen reader said
64
+ * "codeBlock", and inline code and code block shared one icon and one name.
65
+ * Upload and embed stay with the caller: they are the parts that differ.
66
+ */
67
+ export declare const TOOLBAR_ACTIONS: readonly ToolbarAction[];
68
+ /** The actions a toolbar shows, in the order it shows them. */
69
+ export declare function toolbarActions(keys: readonly ToolbarKey[]): ToolbarAction[];
70
+ /**
71
+ * A toolbar button that says what it is and whether it is on. `aria-pressed`
72
+ * is right here, unlike on a facet link: this is a toggle.
73
+ */
74
+ export declare function ToolbarButton({ label, pressed, onPress, className, children, }: {
75
+ label: string;
76
+ pressed?: boolean;
77
+ onPress: () => void;
78
+ className?: string;
79
+ children: ReactNode;
80
+ }): import("react").JSX.Element;
49
81
  /**
50
82
  * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
51
83
  * back. Two editors carried it character for character, `alert` included.
package/dist/editor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
2
3
  // editor.tsx — the wiki rich-text editor's engine, minus its chrome.
3
4
  //
4
5
  // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
@@ -136,6 +137,63 @@ export const TABLE_ACTIONS = [
136
137
  ['deleteRow', '-Row', true],
137
138
  ['deleteTable', '-Tbl', true],
138
139
  ];
140
+ /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
141
+ export function runTableAction(editor, command) {
142
+ const chain = editor.chain().focus();
143
+ chain[command]?.().run();
144
+ }
145
+ /**
146
+ * The formatting commands, labelled. Three toolbars ran these identically and
147
+ * two titled their buttons with the internal key — a screen reader said
148
+ * "codeBlock", and inline code and code block shared one icon and one name.
149
+ * Upload and embed stay with the caller: they are the parts that differ.
150
+ */
151
+ export const TOOLBAR_ACTIONS = [
152
+ { key: 'bold', label: 'Bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
153
+ { key: 'italic', label: 'Italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
154
+ { key: 'code', label: 'Inline code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
155
+ { key: 'link', label: 'Link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
156
+ { key: 'h2', label: 'Heading 2', active: ['heading', { level: 2 }], run: e => e.chain().focus().toggleHeading({ level: 2 }).run() },
157
+ { key: 'h3', label: 'Heading 3', active: ['heading', { level: 3 }], run: e => e.chain().focus().toggleHeading({ level: 3 }).run() },
158
+ { key: 'h4', label: 'Heading 4', active: ['heading', { level: 4 }], run: e => e.chain().focus().toggleHeading({ level: 4 }).run() },
159
+ { key: 'bulletList', label: 'Bulleted list', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
160
+ { key: 'orderedList', label: 'Numbered list', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
161
+ { key: 'blockquote', label: 'Quote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
162
+ { key: 'codeBlock', label: 'Code block', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
163
+ { key: 'divider', label: 'Divider', run: e => e.chain().focus().setHorizontalRule().run() },
164
+ {
165
+ key: 'table',
166
+ label: 'Table',
167
+ active: 'table',
168
+ run: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
169
+ },
170
+ {
171
+ key: 'tabs',
172
+ label: 'Tabs',
173
+ active: 'tabGroup',
174
+ // Two, not one: a group of one is a heading with extra steps, and the node
175
+ // refuses to delete its last tab anyway.
176
+ run: e => e
177
+ .chain()
178
+ .focus()
179
+ .insertContent({
180
+ type: 'tabGroup',
181
+ content: [1, 2].map(n => ({ type: 'tabItem', attrs: { title: `Tab ${n}` }, content: [{ type: 'paragraph' }] })),
182
+ })
183
+ .run(),
184
+ },
185
+ ];
186
+ /** The actions a toolbar shows, in the order it shows them. */
187
+ export function toolbarActions(keys) {
188
+ return keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key));
189
+ }
190
+ /**
191
+ * A toolbar button that says what it is and whether it is on. `aria-pressed`
192
+ * is right here, unlike on a facet link: this is a toggle.
193
+ */
194
+ export function ToolbarButton({ label, pressed, onPress, className, children, }) {
195
+ return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
196
+ }
139
197
  // ---- the editor -------------------------------------------------------------
140
198
  /**
141
199
  * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
package/dist/feed.d.ts CHANGED
@@ -54,3 +54,13 @@ export interface FeedChannel {
54
54
  */
55
55
  export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
56
56
  export declare const FEED_HEADERS: Record<string, string>;
57
+ /**
58
+ * The feed as a response, with the validators a poller revalidates against.
59
+ *
60
+ * All four feeds in the workspace were `new Response(renderFeed(...))` with
61
+ * `Cache-Control: public` and no ETag or Last-Modified, so a reader polling
62
+ * hourly could never be told "unchanged" and took the whole channel every time.
63
+ * The tag comes from the rendered XML, so it moves exactly when the feed does;
64
+ * Last-Modified is the channel's build date.
65
+ */
66
+ export declare function feedResponse(request: Request, channel: FeedChannel, items: readonly FeedItem[], headers?: Record<string, string>): Response;