wiki-formant 0.21.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.
package/README.md CHANGED
@@ -165,6 +165,8 @@ process.exit(t.summary());
165
165
 
166
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`.
167
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
+
168
170
  ## Markdown twins
169
171
 
170
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.
@@ -192,6 +194,8 @@ Block trees stay in your app — every project owns its own type set. Give this
192
194
 
193
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.
194
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
+
195
199
  ```ts
196
200
  import { corpusEtag, notModified, textHeaders } from 'wiki-formant/http';
197
201
 
@@ -269,7 +273,8 @@ are unit-tested without a DOM.
269
273
 
270
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.
271
275
 
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.
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.
273
278
 
274
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.
275
280
 
@@ -314,6 +319,19 @@ and a crawler obeys only its most-specific matching group, so an agent with no
314
319
  group of its own falls through to `*`. Three wikis were measuring five crawlers
315
320
  they had never addressed.
316
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
+
317
335
  ## Revisions
318
336
 
319
337
  What changed between two versions of a page, and therefore which semver bump to
@@ -358,6 +376,7 @@ carries no build date rather than a fictional one.
358
376
 
359
377
  ## Licence declarations
360
378
 
379
+
361
380
  S10 wants a licence on every surface, and each repo satisfied that by writing the
362
381
  same block again. What is genuinely per-project is the *scope* — which half of a
363
382
  site the grant covers and what it excludes — so that is the parameter.
@@ -367,6 +386,8 @@ const license = ccBy40({ siteName: 'AcuiQ', siteUrl: SITE_URL });
367
386
  const block = licenseBlock({ license, scope: 'The protocol compilation and prose', excludes });
368
387
  ```
369
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
+
370
391
  ## Rendered-article passes
371
392
 
372
393
  `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 +401,18 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
380
401
 
381
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.
382
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
+
383
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.
384
407
 
385
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.
386
409
 
387
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.
388
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
+
389
416
  ## Block trees
390
417
 
391
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:
@@ -441,6 +468,8 @@ The ones that take config take it because that is exactly where the two copies d
441
468
 
442
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.
443
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
+
444
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.
445
474
 
446
475
  ```ts
@@ -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
  }
@@ -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
  *
@@ -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[];
package/dist/crawlers.js CHANGED
@@ -53,6 +53,19 @@ export function detectAiBot(userAgent) {
53
53
  export function aiCrawlerTokens() {
54
54
  return AI_CRAWLERS.map(c => c.token);
55
55
  }
56
+ /**
57
+ * The agent surface an S10 origin serves, allowed in every group. All three
58
+ * `robots.ts` files listed these by hand, and a path missing from one list is
59
+ * an endpoint the origin advertises and then closes to the callers it named.
60
+ */
61
+ export const AGENT_SURFACE_PATHS = [
62
+ '/api/mcp',
63
+ '/llms.txt',
64
+ '/llms-index.txt',
65
+ '/llms-full.txt',
66
+ '/openapi.json',
67
+ '/.well-known/',
68
+ ];
56
69
  /**
57
70
  * The wildcard group followed by one group per crawler.
58
71
  *
@@ -62,7 +75,7 @@ export function aiCrawlerTokens() {
62
75
  * agent everything, because it stops matching `*` the moment it matches itself.
63
76
  */
64
77
  export function aiCrawlerRules(opts) {
65
- const aiAllow = [opts.aiAllow].flat();
78
+ const aiAllow = [...new Set([...[opts.aiAllow ?? []].flat(), ...AGENT_SURFACE_PATHS])];
66
79
  return [
67
80
  // `aiAllow` rides on the default group too, not only on the named roster.
68
81
  // The agent surface an origin advertises has to be reachable by a caller it
package/dist/editor.d.ts CHANGED
@@ -46,6 +46,11 @@ 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
+ /**
50
+ * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
51
+ * back. Two editors carried it character for character, `alert` included.
52
+ */
53
+ export declare function uploadImageTo(endpoint: string): (file: File) => Promise<string | null>;
49
54
  export interface WikiEditorOptions {
50
55
  value: string;
51
56
  onChange: (html: string) => void;
package/dist/editor.js CHANGED
@@ -136,6 +136,30 @@ export const TABLE_ACTIONS = [
136
136
  ['deleteRow', '-Row', true],
137
137
  ['deleteTable', '-Tbl', true],
138
138
  ];
139
+ // ---- the editor -------------------------------------------------------------
140
+ /**
141
+ * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
142
+ * back. Two editors carried it character for character, `alert` included.
143
+ */
144
+ export function uploadImageTo(endpoint) {
145
+ return async (file) => {
146
+ const form = new FormData();
147
+ form.append('file', file);
148
+ try {
149
+ const res = await fetch(endpoint, { method: 'POST', body: form });
150
+ const body = (await res.json());
151
+ if (!res.ok) {
152
+ alert(body.error || 'Upload failed');
153
+ return null;
154
+ }
155
+ return body.url ?? null;
156
+ }
157
+ catch {
158
+ alert('Upload failed');
159
+ return null;
160
+ }
161
+ };
162
+ }
139
163
  /**
140
164
  * The editor, its upload plumbing and the state a toolbar reads.
141
165
  *
@@ -17,3 +17,17 @@ export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?:
17
17
  * being re-typed either side of the extraction.
18
18
  */
19
19
  export declare function freshnessNotice(page: FreshnessInput): string;
20
+ /**
21
+ * A synthetic `outdated` banner block for a stale page, or null when it is
22
+ * fresh — the shape every wiki's `banner` type already stores, so it drops
23
+ * into the tree the renderer walks. Two wikis carried this function
24
+ * identically. `nowMs` comes from a server component: reading the clock
25
+ * during a client render would let a page near the boundary be stale on the
26
+ * server and fresh in the browser.
27
+ */
28
+ export declare function freshnessBanner(page: FreshnessInput, nowMs: number, maxAgeDays?: number): {
29
+ id: string;
30
+ type: 'banner';
31
+ variant: 'outdated';
32
+ text: string;
33
+ } | null;
package/dist/freshness.js CHANGED
@@ -40,3 +40,16 @@ export function freshnessNotice(page) {
40
40
  : 'not yet verified against sources';
41
41
  return `This page was ${when} and may be out of date. Please help re-check its facts against current sources and the live ledger.`;
42
42
  }
43
+ /**
44
+ * A synthetic `outdated` banner block for a stale page, or null when it is
45
+ * fresh — the shape every wiki's `banner` type already stores, so it drops
46
+ * into the tree the renderer walks. Two wikis carried this function
47
+ * identically. `nowMs` comes from a server component: reading the clock
48
+ * during a client render would let a page near the boundary be stale on the
49
+ * server and fresh in the browser.
50
+ */
51
+ export function freshnessBanner(page, nowMs, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
52
+ if (!isStale(page, nowMs, maxAgeDays))
53
+ return null;
54
+ return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page) };
55
+ }
@@ -7,6 +7,13 @@ export interface Heading {
7
7
  }
8
8
  /** The default slug rule: lowercase words joined by hyphens. */
9
9
  export declare function slugifyHeading(text: string): string;
10
+ /**
11
+ * `base`, or `base-2`, `base-3`… — the first one `used` does not hold. Records
12
+ * nothing: the caller adds the id it keeps. Shared with the editor's heading
13
+ * decoration, so the id a heading shows while it is being written is the id it
14
+ * is published under.
15
+ */
16
+ export declare function uniqueHeadingId(base: string, used: ReadonlySet<string>): string;
10
17
  export interface HeadingIdOptions {
11
18
  /**
12
19
  * How heading text becomes an id. Defaults to `slugifyHeading`. Pass the rule
package/dist/headings.js CHANGED
@@ -20,6 +20,20 @@ export function slugifyHeading(text) {
20
20
  .replace(/[\s_-]+/g, '-')
21
21
  .replace(/^-+|-+$/g, '');
22
22
  }
23
+ /**
24
+ * `base`, or `base-2`, `base-3`… — the first one `used` does not hold. Records
25
+ * nothing: the caller adds the id it keeps. Shared with the editor's heading
26
+ * decoration, so the id a heading shows while it is being written is the id it
27
+ * is published under.
28
+ */
29
+ export function uniqueHeadingId(base, used) {
30
+ if (!base)
31
+ return base;
32
+ let id = base;
33
+ for (let n = 2; used.has(id); n++)
34
+ id = `${base}-${n}`;
35
+ return id;
36
+ }
23
37
  const HEADING = /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi;
24
38
  const defaultAnchor = (id) => `<a class="heading-anchor" href="#${id}" aria-label="Permalink to this section" tabindex="-1"></a>`;
25
39
  /**
@@ -37,15 +51,9 @@ export function injectHeadingIds(html, options = {}) {
37
51
  if (content.includes('heading-anchor'))
38
52
  return match;
39
53
  const existing = getAttr(attrs, 'id');
40
- let id = existing || slug(stripTags(content));
54
+ const id = existing || uniqueHeadingId(slug(stripTags(content)), used);
41
55
  if (!id)
42
56
  return match;
43
- if (!existing) {
44
- const base = id;
45
- let n = 2;
46
- while (used.has(id))
47
- id = `${base}-${n++}`;
48
- }
49
57
  used.add(id);
50
58
  return `<${tag}${existing ? attrs : `${attrs} id="${id}"`}>${content}${anchor(id)}</${tag}>`;
51
59
  });
package/dist/http.d.ts CHANGED
@@ -9,7 +9,15 @@ export declare function corpusEtag(parts: Array<string | number | Date | null |
9
9
  * prefix, and a crawler reformats the date. Each of those took a full render
10
10
  * from a response that was already fresh.
11
11
  */
12
- export declare function notModified(request: Request, etag: string, lastModified?: string | null): Response | null;
12
+ export declare function notModified(request: Request, etag: string, lastModified?: string | null,
13
+ /**
14
+ * The headers the 200 would carry. RFC 9110 15.4.5 requires a 304 to send
15
+ * the Cache-Control and Vary a 200 would have, and a cross-origin client
16
+ * needs the CORS header on it too; without them a revalidated copy loses its
17
+ * freshness and a negotiated URL loses its Vary. The body's own headers
18
+ * (Content-Type, Content-Length) are dropped.
19
+ */
20
+ sent?: Record<string, string>): Response | null;
13
21
  /**
14
22
  * A 404 that teaches, for the plain-GET half of an agent surface.
15
23
  *
@@ -49,6 +57,25 @@ export declare function markdownHeaders(lastModified?: string | null, opts?: {
49
57
  etag?: string;
50
58
  extra?: Record<string, string>;
51
59
  }): Record<string, string>;
60
+ /**
61
+ * Whether a request to a URL that serves both JSON and markdown asked for the
62
+ * markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
63
+ * The `.md` suffix is the caller's to add — it lives in the path, not here.
64
+ *
65
+ * Both branches of such a route must send `VARY_ACCEPT`. Two wikis answered
66
+ * one URL in two formats under `public, s-maxage` with no Vary, so a shared
67
+ * cache could hand the markdown to the next JSON client, or the reverse.
68
+ */
69
+ export declare function wantsMarkdown(request: {
70
+ url: string;
71
+ headers: {
72
+ get(name: string): string | null;
73
+ };
74
+ }): boolean;
75
+ /** The header every response from a content-negotiated URL carries. */
76
+ export declare const VARY_ACCEPT: {
77
+ readonly Vary: "Accept";
78
+ };
52
79
  /**
53
80
  * Headers for a JSON descriptor — an agent card, an OpenAPI document, a
54
81
  * registry manifest. These are the documents a client refetches most and the
package/dist/http.js CHANGED
@@ -30,8 +30,17 @@ const bareTag = (tag) => tag.trim().replace(/^W\//, '');
30
30
  * prefix, and a crawler reformats the date. Each of those took a full render
31
31
  * from a response that was already fresh.
32
32
  */
33
- export function notModified(request, etag, lastModified) {
33
+ export function notModified(request, etag, lastModified,
34
+ /**
35
+ * The headers the 200 would carry. RFC 9110 15.4.5 requires a 304 to send
36
+ * the Cache-Control and Vary a 200 would have, and a cross-origin client
37
+ * needs the CORS header on it too; without them a revalidated copy loses its
38
+ * freshness and a negotiated URL loses its Vary. The body's own headers
39
+ * (Content-Type, Content-Length) are dropped.
40
+ */
41
+ sent = {}) {
34
42
  const headers = {
43
+ ...Object.fromEntries(Object.entries(sent).filter(([k]) => !/^content-(type|length)$/i.test(k))),
35
44
  ETag: etag,
36
45
  ...(lastModified ? { 'Last-Modified': lastModified } : {}),
37
46
  };
@@ -110,6 +119,21 @@ export function markdownHeaders(lastModified, opts = {}) {
110
119
  ...opts.extra,
111
120
  };
112
121
  }
122
+ /**
123
+ * Whether a request to a URL that serves both JSON and markdown asked for the
124
+ * markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
125
+ * The `.md` suffix is the caller's to add — it lives in the path, not here.
126
+ *
127
+ * Both branches of such a route must send `VARY_ACCEPT`. Two wikis answered
128
+ * one URL in two formats under `public, s-maxage` with no Vary, so a shared
129
+ * cache could hand the markdown to the next JSON client, or the reverse.
130
+ */
131
+ export function wantsMarkdown(request) {
132
+ return (new URL(request.url).searchParams.get('format') === 'text' ||
133
+ /text\/(markdown|plain)/.test(request.headers.get('accept') ?? ''));
134
+ }
135
+ /** The header every response from a content-negotiated URL carries. */
136
+ export const VARY_ACCEPT = { Vary: 'Accept' };
113
137
  /**
114
138
  * Headers for a JSON descriptor — an agent card, an OpenAPI document, a
115
139
  * registry manifest. These are the documents a client refetches most and the
@@ -137,7 +161,8 @@ export function descriptorHeaders(etag, opts = {}) {
137
161
  export function descriptorResponse(request, body, opts = {}) {
138
162
  const text = JSON.stringify(body);
139
163
  const etag = corpusEtag([text]);
140
- return notModified(request, etag) ?? new Response(text, { headers: descriptorHeaders(etag, opts) });
164
+ const sent = descriptorHeaders(etag, opts);
165
+ return notModified(request, etag, null, sent) ?? new Response(text, { headers: sent });
141
166
  }
142
167
  /** Strip URLs and collapse whitespace so an excerpt stays one readable line. */
143
168
  export function cleanSnippet(text, max = 160) {
@@ -172,7 +197,7 @@ export function pageLine(opts) {
172
197
  export function corpusRoute(validators, build, headers = textHeaders) {
173
198
  return async (request) => {
174
199
  const { etag, lastModified } = await validators();
175
- return (notModified(request, etag, lastModified) ??
176
- new Response(await build(), { headers: headers(etag, lastModified) }));
200
+ const sent = headers(etag, lastModified);
201
+ return notModified(request, etag, lastModified, sent) ?? new Response(await build(), { headers: sent });
177
202
  };
178
203
  }
package/dist/index.d.ts CHANGED
@@ -19,3 +19,4 @@ export * from './revisions.js';
19
19
  export * from './feed.js';
20
20
  export * from './license.js';
21
21
  export * from './seeded.js';
22
+ export * from './metadata.js';
package/dist/index.js CHANGED
@@ -24,3 +24,4 @@ export * from './revisions.js';
24
24
  export * from './feed.js';
25
25
  export * from './license.js';
26
26
  export * from './seeded.js';
27
+ export * from './metadata.js';
package/dist/license.d.ts CHANGED
@@ -43,3 +43,12 @@ export declare function licenseLines({ license, scope, scopeVerb, excludes, head
43
43
  export declare function licenseBlock(opts: LicenseBlockOptions): string;
44
44
  /** The one-line form, for a frontmatter field or a feed's `<copyright>`. */
45
45
  export declare function licenseNote(license: License): string;
46
+ /**
47
+ * The OpenAPI 3.1 `info.license` object. Name plus SPDX `identifier` — the
48
+ * spec makes `identifier` and `url` mutually exclusive, and the three specs
49
+ * here had each picked a different pair, one of them a hand-typed name.
50
+ */
51
+ export declare function openApiLicense(license: License): {
52
+ name: string;
53
+ identifier: string;
54
+ };
package/dist/license.js CHANGED
@@ -47,3 +47,11 @@ export function licenseBlock(opts) {
47
47
  export function licenseNote(license) {
48
48
  return `${license.name} (${license.spdx}): ${license.url}`;
49
49
  }
50
+ /**
51
+ * The OpenAPI 3.1 `info.license` object. Name plus SPDX `identifier` — the
52
+ * spec makes `identifier` and `url` mutually exclusive, and the three specs
53
+ * here had each picked a different pair, one of them a hand-typed name.
54
+ */
55
+ export function openApiLicense(license) {
56
+ return { name: license.name, identifier: license.spdx };
57
+ }
@@ -182,7 +182,9 @@ export async function probeYouTube(videoId, opts = {}) {
182
182
  }
183
183
  /** An external anchor, with a video treated as a video. */
184
184
  export async function probeExternal(url, opts = {}) {
185
- const yt = url.match(YOUTUBE_WATCH);
185
+ // Watch and embed URLs alike: an /embed/ page answers 200 for a deleted
186
+ // video, so a checker that probed embeds as plain URLs never saw one die.
187
+ const yt = url.match(YOUTUBE_WATCH) ?? url.match(YOUTUBE_EMBED);
186
188
  if (yt?.[1])
187
189
  return { url, videoId: yt[1], ...(await probeYouTube(yt[1], opts)) };
188
190
  return probeUrl(url, opts);
@@ -0,0 +1,69 @@
1
+ export interface ArticleMeta {
2
+ publishedTime?: string;
3
+ modifiedTime?: string;
4
+ section?: string;
5
+ tags?: string[];
6
+ }
7
+ export interface PageMetadataOptions {
8
+ /** The social title. */
9
+ title: string;
10
+ description?: string;
11
+ /** Absolute canonical URL. Omit on a page that should not declare one (a layout default). */
12
+ url?: string;
13
+ type?: 'website' | 'article';
14
+ /** Absolute URL of the card. Omit to let a file-convention `opengraph-image` supply it. */
15
+ image?: string;
16
+ /** Its intrinsic size. Defaults to the 1200×630 of a summary_large_image card. */
17
+ imageSize?: {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ /** Defaults to `title`. */
22
+ imageAlt?: string;
23
+ siteName?: string;
24
+ locale?: string;
25
+ /** `@handle`, as both site and creator. */
26
+ handle?: string;
27
+ /**
28
+ * Advertise the page's markdown twin at `${url}.md`, beside the canonical —
29
+ * the only place it can go, since a separate `alternates` would replace this
30
+ * one. Pages only: a section or index has no twin.
31
+ */
32
+ markdownTwin?: boolean;
33
+ /** Open Graph article fields, for `type: 'article'`. */
34
+ article?: ArticleMeta;
35
+ }
36
+ export declare function pageMetadata(o: PageMetadataOptions): {
37
+ openGraph: {
38
+ publishedTime?: string;
39
+ modifiedTime?: string;
40
+ section?: string;
41
+ tags?: string[];
42
+ images?: {
43
+ alt: string;
44
+ width: number;
45
+ height: number;
46
+ url: string;
47
+ }[] | undefined;
48
+ locale?: string | undefined;
49
+ siteName?: string | undefined;
50
+ url?: string | undefined;
51
+ description?: string | undefined;
52
+ type: "article" | "website";
53
+ title: string;
54
+ };
55
+ twitter: {
56
+ images?: string[] | undefined;
57
+ site?: string | undefined;
58
+ creator?: string | undefined;
59
+ description?: string | undefined;
60
+ card: "summary_large_image";
61
+ title: string;
62
+ };
63
+ alternates?: {
64
+ types?: {
65
+ 'text/markdown': string;
66
+ } | undefined;
67
+ canonical: string;
68
+ } | undefined;
69
+ };
@@ -0,0 +1,45 @@
1
+ // metadata.ts — one page's canonical, markdown twin, Open Graph and Twitter
2
+ // card, from one input.
3
+ //
4
+ // Next *replaces* rather than merges these objects per route segment. It does
5
+ // fill a missing twitter title, description and image from `openGraph`, but
6
+ // only when no `twitter` object was inherited — so under a layout that sets its
7
+ // own card, a page that sets openGraph and forgets twitter shows the layout's
8
+ // generic card, and a page that restates `alternates` for its twin clobbers its
9
+ // canonical. Two
10
+ // wikis wrote a helper around exactly that and covered different halves: one
11
+ // had the twin and no siteName, the other the siteName and no twin. The third
12
+ // wrote each page's metadata by hand.
13
+ //
14
+ // Plain objects in the shape Next's `Metadata` wants, so no `next` import.
15
+ export function pageMetadata(o) {
16
+ const { title, description, url, type = 'website', image, imageSize = { width: 1200, height: 630 }, siteName, locale, handle, markdownTwin, article } = o;
17
+ const images = image ? [{ url: image, ...imageSize, alt: o.imageAlt ?? title }] : undefined;
18
+ return {
19
+ ...(url
20
+ ? {
21
+ alternates: {
22
+ canonical: url,
23
+ ...(markdownTwin ? { types: { 'text/markdown': `${url}.md` } } : {}),
24
+ },
25
+ }
26
+ : {}),
27
+ openGraph: {
28
+ type,
29
+ title,
30
+ ...(description ? { description } : {}),
31
+ ...(url ? { url } : {}),
32
+ ...(siteName ? { siteName } : {}),
33
+ ...(locale ? { locale } : {}),
34
+ ...(images ? { images } : {}),
35
+ ...(type === 'article' ? article : {}),
36
+ },
37
+ twitter: {
38
+ card: 'summary_large_image',
39
+ title,
40
+ ...(description ? { description } : {}),
41
+ ...(handle ? { site: handle, creator: handle } : {}),
42
+ ...(images ? { images: images.map(i => i.url) } : {}),
43
+ },
44
+ };
45
+ }
@@ -37,6 +37,8 @@ export interface FacetBarClassNames {
37
37
  label?: string;
38
38
  control?: string;
39
39
  controlActive?: string;
40
+ /** Set it and each count is its own `<span>` with this class, not ` (n)` text. */
41
+ count?: string;
40
42
  }
41
43
  export interface FacetBarProps {
42
44
  link: WikiLinkComponent;
@@ -44,6 +46,8 @@ export interface FacetBarProps {
44
46
  letters: Control[];
45
47
  /** The label on the A–Z row. */
46
48
  alphaLabel?: string;
49
+ /** Lead with the A–Z row: two lines against a facet block's twenty. */
50
+ alphaFirst?: boolean;
47
51
  classNames?: FacetBarClassNames;
48
52
  }
49
53
  /**
@@ -56,7 +60,7 @@ export interface FacetBarProps {
56
60
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
57
61
  * take that attribute.
58
62
  */
59
- export declare function FacetBar({ link: Link, facets, letters, alphaLabel, classNames, }: FacetBarProps): import("react").JSX.Element | null;
63
+ export declare function FacetBar({ link: Link, facets, letters, alphaLabel, alphaFirst, classNames, }: FacetBarProps): import("react").JSX.Element | null;
60
64
  /**
61
65
  * A JSON-LD payload, safe to place inside `<script type="application/ld+json">`.
62
66
  *
@@ -36,11 +36,22 @@ export const Anchor = ({ href, className, children }) => _jsx("a", { href: href,
36
36
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
37
37
  * take that attribute.
38
38
  */
39
- export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', classNames = {}, }) {
39
+ export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', alphaFirst = false, classNames = {}, }) {
40
40
  if (!facets.length && !letters.length)
41
41
  return null;
42
- const { root = 'stack tight', row = 'cluster', label = 'form-label', control = 'tag', controlActive = 'tag tag-removable', } = classNames;
43
- return (_jsxs("div", { className: root, children: [facets.map(facet => (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: facet.label }), facet.options.map(option => (_jsxs(Link, { href: option.href, className: option.active ? controlActive : control, "aria-current": option.active ? 'true' : undefined, children: [option.value, " (", option.count, ")"] }, option.value)))] }, facet.key))), letters.length > 0 && (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: alphaLabel }), letters.map(letter => (_jsxs(Link, { href: letter.href, className: letter.active ? controlActive : control, "aria-current": letter.active ? 'true' : undefined, children: [letter.label, " (", letter.count, ")"] }, letter.value || 'all')))] }))] }));
42
+ const { root = 'stack tight', row = 'cluster', label = 'form-label', control = 'tag', controlActive = 'tag tag-removable', count, } = classNames;
43
+ const chip = (c) => (_jsxs(Link, { href: c.href, className: c.active ? controlActive : control, "aria-current": c.active ? 'true' : undefined, ...(c.title ? { title: c.title } : {}), children: [c.text, count ? _jsx("span", { className: count, children: c.n }) : ` (${c.n})`] }, c.key));
44
+ const facetRows = facets.map(facet => (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: facet.label }), facet.options.map(o => chip({ key: o.value, href: o.href, active: o.active, text: o.value, n: o.count }))] }, facet.key)));
45
+ // The reset control leads the letters and says what it resets to.
46
+ const alphaRow = letters.length > 0 && (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: alphaLabel }), letters.map(l => chip({
47
+ key: l.value || 'all',
48
+ href: l.href,
49
+ active: l.active,
50
+ text: l.label,
51
+ n: l.count,
52
+ ...(l.reset ? { title: `All ${l.count} pages` } : {}),
53
+ }))] }, "alpha"));
54
+ return _jsx("div", { className: root, children: alphaFirst ? [alphaRow, ...facetRows] : [...facetRows, alphaRow] });
44
55
  }
45
56
  // ---- structured data --------------------------------------------------------
46
57
  /**
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ComboboxAria } from './combobox.js';
2
- import { Component, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
2
+ import { Component, type CSSProperties, type DependencyList, type RefObject, type KeyboardEvent, type ReactNode } from 'react';
3
3
  export interface SidebarOptions {
4
4
  /**
5
5
  * Where the reader's choice is remembered, in `localStorage`. Give each wiki
@@ -110,7 +110,7 @@ export declare function TableOfContents({ containerSelector, headings: providedH
110
110
  * `onClose` is an effect dependency: wrap it in `useCallback` or hoist it, or
111
111
  * the listener is torn down and rebuilt on every render.
112
112
  */
113
- export declare function useClickOutside<T extends HTMLElement>(onClose: () => void): import("react").RefObject<T | null>;
113
+ export declare function useClickOutside<T extends HTMLElement>(onClose: () => void): RefObject<T | null>;
114
114
  export interface TypeaheadOptions<T> {
115
115
  /**
116
116
  * Runs the search. MUST be referentially stable — a module function, a server
@@ -344,3 +344,13 @@ export declare function RailShell({ children, prefix, label, className }: RailSh
344
344
  */
345
345
  export declare function isRailLinkActive(activePath: string, href: string, tree?: boolean): boolean;
346
346
  export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
347
+ /**
348
+ * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
349
+ * buttons, sortable tables. All three renderers ran the same three in an
350
+ * effect, and they disagreed on when: two ran it once on mount, so a page
351
+ * swapped in without a remount kept its old tables unsortable. Each pass is
352
+ * idempotent, so running on every `deps` change is safe.
353
+ */
354
+ export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
355
+ /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
356
+ export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
package/dist/react.js CHANGED
@@ -27,6 +27,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
27
27
  // state on <html> before first paint. The hook keeps that attribute in
28
28
  // sync afterwards, so CSS has one source of truth either side of hydration.
29
29
  import { resolveSidebarOpen, SIDEBAR_ATTRIBUTE } from './sidebar.js';
30
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
30
31
  import { comboboxAria } from './combobox.js';
31
32
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
32
33
  const readStored = (key) => {
@@ -622,3 +623,33 @@ export function RailShell({ children, prefix, label, className }) {
622
623
  export function isRailLinkActive(activePath, href, tree = false) {
623
624
  return tree ? activePath === href || activePath.startsWith(`${href}/`) : activePath === href;
624
625
  }
626
+ // ---- rendered-article passes --------------------------------------------------
627
+ /**
628
+ * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
629
+ * buttons, sortable tables. All three renderers ran the same three in an
630
+ * effect, and they disagreed on when: two ran it once on mount, so a page
631
+ * swapped in without a remount kept its old tables unsortable. Each pass is
632
+ * idempotent, so running on every `deps` change is safe.
633
+ */
634
+ export function useArticlePasses(ref, deps) {
635
+ useEffect(() => {
636
+ const root = ref.current;
637
+ if (!root)
638
+ return;
639
+ activateTabGroups(root);
640
+ addCopyButtons(root);
641
+ sortTables(root);
642
+ // eslint-disable-next-line react-hooks/exhaustive-deps
643
+ }, deps);
644
+ }
645
+ /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
646
+ export function useTweetEmbeds(ref, deps) {
647
+ useEffect(() => {
648
+ const root = ref.current;
649
+ if (!root)
650
+ return;
651
+ hydrateTweetEmbeds(root);
652
+ return onTweetResize(height => sizeTweetEmbeds(root, height));
653
+ // eslint-disable-next-line react-hooks/exhaustive-deps
654
+ }, deps);
655
+ }
package/dist/search.d.ts CHANGED
@@ -32,6 +32,26 @@ export declare const PROSE_PATTERN = "<[^>]*>|&nbsp;|\\\\[nrt\"\\\\/]|\", \"|\\[
32
32
  * tiers.
33
33
  */
34
34
  export declare function searchTsvSql(content?: string, title?: string): string;
35
+ /**
36
+ * The statements that (re)build a table's `search_tsv` column and its GIN index.
37
+ * Run `column` inside one transaction: DROP then ADD, so no reader ever sees
38
+ * the table without it; then `index`.
39
+ *
40
+ * Rebuilt unconditionally rather than skipped when the column exists. A skip
41
+ * is idempotent about the column and blind to its EXPRESSION: a changed prose
42
+ * expression re-runs clean and changes nothing, and Postgres normalises the
43
+ * stored expression, so comparing it to this string would only produce false
44
+ * rebuilds. The column is derived from `content`, so dropping it loses nothing.
45
+ * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
46
+ * a generated column it does not know about as drift and drops it.
47
+ */
48
+ export declare function searchTsvDdl(table: string, { content, title }?: {
49
+ content?: string;
50
+ title?: string;
51
+ }): {
52
+ column: string[];
53
+ index: string;
54
+ };
35
55
  /**
36
56
  * `ts_headline` options for a search result snippet: one fragment, wide enough
37
57
  * to read as a sentence, with no highlight markers (the caller styles it).
package/dist/search.js CHANGED
@@ -60,6 +60,28 @@ export function searchTsvSql(content = 'content', title = 'title') {
60
60
  return `setweight(to_tsvector('english', coalesce(${title},'')), 'A') || ` +
61
61
  `setweight(to_tsvector('english', coalesce(${proseSql(content)}, '')), 'B')`;
62
62
  }
63
+ /**
64
+ * The statements that (re)build a table's `search_tsv` column and its GIN index.
65
+ * Run `column` inside one transaction: DROP then ADD, so no reader ever sees
66
+ * the table without it; then `index`.
67
+ *
68
+ * Rebuilt unconditionally rather than skipped when the column exists. A skip
69
+ * is idempotent about the column and blind to its EXPRESSION: a changed prose
70
+ * expression re-runs clean and changes nothing, and Postgres normalises the
71
+ * stored expression, so comparing it to this string would only produce false
72
+ * rebuilds. The column is derived from `content`, so dropping it loses nothing.
73
+ * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
74
+ * a generated column it does not know about as drift and drops it.
75
+ */
76
+ export function searchTsvDdl(table, { content = 'content', title = 'title' } = {}) {
77
+ return {
78
+ column: [
79
+ `ALTER TABLE ${table} DROP COLUMN IF EXISTS search_tsv`,
80
+ `ALTER TABLE ${table} ADD COLUMN search_tsv tsvector GENERATED ALWAYS AS (${searchTsvSql(content, title)}) STORED`,
81
+ ],
82
+ index: `CREATE INDEX IF NOT EXISTS ${table}_search_tsv_idx ON ${table} USING GIN (search_tsv)`,
83
+ };
84
+ }
63
85
  /**
64
86
  * `ts_headline` options for a search result snippet: one fragment, wide enough
65
87
  * to read as a sentence, with no highlight markers (the caller styles it).
package/dist/text.d.ts CHANGED
@@ -27,6 +27,8 @@ export declare const BANNER_VARIANTS: ReadonlyArray<{
27
27
  value: BannerVariant;
28
28
  label: string;
29
29
  }>;
30
+ /** A stored variant this package knows, or `cleanup` for one it does not. */
31
+ export declare function bannerVariant(variant: string): BannerVariant;
30
32
  /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
31
33
  export declare function bannerToText(label: string, text?: string | null): string;
32
34
  /** Each tab under its label, tags stripped — highlighted markup is noise here. */
package/dist/text.js CHANGED
@@ -50,6 +50,10 @@ export const BANNER_VARIANTS = Object.keys(BANNER_LABELS).map(value => ({
50
50
  value,
51
51
  label: BANNER_LABELS[value],
52
52
  }));
53
+ /** A stored variant this package knows, or `cleanup` for one it does not. */
54
+ export function bannerVariant(variant) {
55
+ return Object.hasOwn(BANNER_LABELS, variant) ? variant : 'cleanup';
56
+ }
53
57
  /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
54
58
  export function bannerToText(label, text) {
55
59
  return `[Notice: ${label}]${text ? ' ' + stripHtml(text) : ''}`;
package/dist/tiptap.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import { Node as TiptapNode } from '@tiptap/core';
2
+ import { Extension, Node as TiptapNode } from '@tiptap/core';
3
3
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
4
4
  export declare const Iframe: TiptapNode<any, any>;
5
5
  /** The stock extension plus the paste rule it does not ship with. */
@@ -76,3 +76,16 @@ export declare function createTabs({ classNames, icons, tabLabel, }?: TabsOption
76
76
  TabGroup: TiptapNode<any, any>;
77
77
  TabItem: TiptapNode<any, any>;
78
78
  };
79
+ /**
80
+ * Gives each heading in the editor the id its published copy will carry, so an
81
+ * "on this page" rail can list it while the page is being written.
82
+ *
83
+ * A decoration, not an attribute: the id is in the editor's DOM and never in
84
+ * `getHTML()`, so stored HTML stays id-free and `injectHeadingIds` still mints
85
+ * the published ids. Pass the same `slug` the wiki passes there. The dedupe is
86
+ * `uniqueHeadingId`, the one `injectHeadingIds` uses; the copy this replaced
87
+ * restated it, and would have drifted the first time either changed.
88
+ */
89
+ export declare function createHeadingIds({ slug }?: {
90
+ slug?: (text: string) => string;
91
+ }): Extension<any, any>;
package/dist/tiptap.js CHANGED
@@ -12,7 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // and the other `text-accent` without either forking the node. It also keeps
13
13
  // this file from dragging an icon library in behind it.
14
14
  import { useCallback, useEffect, useRef, useState } from 'react';
15
- import { Node as TiptapNode, mergeAttributes } from '@tiptap/core';
15
+ import { Extension, Node as TiptapNode, mergeAttributes } from '@tiptap/core';
16
+ import { Plugin } from '@tiptap/pm/state';
17
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
16
18
  import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
17
19
  import TiptapYoutube from '@tiptap/extension-youtube';
18
20
  import TiptapCodeBlock from '@tiptap/extension-code-block';
@@ -20,6 +22,7 @@ import { onTweetResize, tweetEmbedSrc } from './dom.js';
20
22
  import { toMapEmbedUrl } from './maps.js';
21
23
  import { useClickOutside } from './react.js';
22
24
  import { cx } from './html.js';
25
+ import { slugifyHeading, uniqueHeadingId } from './headings.js';
23
26
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
24
27
  // ---- iframe -----------------------------------------------------------------
25
28
  export const Iframe = TiptapNode.create({
@@ -336,3 +339,40 @@ export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab $
336
339
  });
337
340
  return { TabGroup, TabItem };
338
341
  }
342
+ // ---- heading ids while editing ---------------------------------------------------
343
+ /**
344
+ * Gives each heading in the editor the id its published copy will carry, so an
345
+ * "on this page" rail can list it while the page is being written.
346
+ *
347
+ * A decoration, not an attribute: the id is in the editor's DOM and never in
348
+ * `getHTML()`, so stored HTML stays id-free and `injectHeadingIds` still mints
349
+ * the published ids. Pass the same `slug` the wiki passes there. The dedupe is
350
+ * `uniqueHeadingId`, the one `injectHeadingIds` uses; the copy this replaced
351
+ * restated it, and would have drifted the first time either changed.
352
+ */
353
+ export function createHeadingIds({ slug = slugifyHeading } = {}) {
354
+ return Extension.create({
355
+ name: 'headingIds',
356
+ addProseMirrorPlugins: () => [
357
+ new Plugin({
358
+ props: {
359
+ decorations: ({ doc }) => {
360
+ const used = new Set();
361
+ const decorations = [];
362
+ doc.descendants((node, pos) => {
363
+ if (node.type.name !== 'heading')
364
+ return;
365
+ const id = uniqueHeadingId(slug(node.textContent), used);
366
+ if (!id)
367
+ return false;
368
+ used.add(id);
369
+ decorations.push(Decoration.node(pos, pos + node.nodeSize, { id }));
370
+ return false;
371
+ });
372
+ return DecorationSet.create(doc, decorations);
373
+ },
374
+ },
375
+ }),
376
+ ],
377
+ });
378
+ }
@@ -1,3 +1,4 @@
1
+ import type { License } from './license.js';
1
2
  export interface RegistryAuthRecord {
2
3
  body: string;
3
4
  contentType: string;
@@ -48,13 +49,6 @@ export interface SkillSource {
48
49
  * capability the server does not have. `search_pages` becomes "Search Pages".
49
50
  */
50
51
  export declare function skillsFromTools(tools: readonly SkillSource[]): AgentSkill[];
51
- export interface AgentCardLicense {
52
- name: string;
53
- url: string;
54
- spdx?: string;
55
- /** What the licence covers, e.g. `'content'`. */
56
- scope?: string;
57
- }
58
52
  export interface AgentCardConfig {
59
53
  name: string;
60
54
  description: string;
@@ -62,7 +56,10 @@ export interface AgentCardConfig {
62
56
  url: string;
63
57
  version: string;
64
58
  skills: AgentSkill[];
65
- license?: AgentCardLicense;
59
+ /** The grant, as `ccBy40()` builds it. Projected once here, not per repo. */
60
+ license?: License;
61
+ /** What the grant covers, e.g. `'content'`, or which half of the site. */
62
+ licenseScope?: string;
66
63
  /** Defaults to `name`. */
67
64
  organization?: string;
68
65
  /** Defaults to `${url}/llms.txt`. */
@@ -127,3 +124,45 @@ protocolVersions: readonly string[]): Record<string, unknown>;
127
124
  * configured" instead of failing verification for a reason nobody sees.
128
125
  */
129
126
  export declare function registryAuthHandler(publicKey?: string | undefined, keyType?: string | undefined): () => Response;
127
+ /** A tool as `/.well-known/mcp.json` lists it: what tools/list says, no handler. */
128
+ export interface ManifestTool {
129
+ name: string;
130
+ title?: string;
131
+ description: string;
132
+ inputSchema: unknown;
133
+ annotations?: unknown;
134
+ }
135
+ export interface McpManifestConfig {
136
+ /** The server's display name. */
137
+ name: string;
138
+ /** Its registry name, from server.json. */
139
+ registryName: string;
140
+ version: string;
141
+ description: string;
142
+ /** Origin, no trailing slash. */
143
+ url: string;
144
+ /** Who runs it. Defaults to `name`. */
145
+ provider?: string;
146
+ tools: readonly ManifestTool[];
147
+ /** Auth notes, per-tenant endpoints, payment terms — what this origin has that others do not. */
148
+ extra?: Record<string, unknown>;
149
+ }
150
+ /**
151
+ * `/.well-known/mcp.json`: the third path an arriving agent probes, after the
152
+ * agent card and the OpenAPI spec. All three origins wrote the same envelope,
153
+ * and one had already dropped the tool titles the other two listed. The
154
+ * protocol versions and the rate limit come from the transport that answers,
155
+ * so the manifest cannot advertise a version the server does not speak.
156
+ */
157
+ export declare function mcpManifest(config: McpManifestConfig): Record<string, unknown>;
158
+ /**
159
+ * A GET handler serving a descriptor built from code: ETag, 304, freshness.
160
+ * The agent cards, the manifest and the server card were each a three-line
161
+ * route around `descriptorResponse`, written out nine times.
162
+ */
163
+ export declare function descriptorHandler(body: unknown, opts?: {
164
+ maxAge?: number;
165
+ extra?: Record<string, string>;
166
+ }): (request: Request) => Response;
167
+ /** The whole `<mcp-url>/server-card` route, projected from server.json. */
168
+ export declare const serverCardHandler: (manifest: ServerManifest) => ((request: Request) => Response);
@@ -9,6 +9,9 @@
9
9
  // Nothing here touches a framework: these return plain values, and the caller
10
10
  // wraps them in whatever its router wants. That keeps this package free of a
11
11
  // Next dependency and keeps each app's route a two-liner.
12
+ import { MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS } from './mcp.js';
13
+ import { MCP_RATE_LIMIT_TEXT } from './rate-limit.js';
14
+ import { descriptorResponse } from './http.js';
12
15
  /**
13
16
  * The `v=MCPv1` record the official MCP registry fetches from
14
17
  * `/.well-known/mcp-registry-auth` to prove domain ownership. `mcp-publisher
@@ -70,7 +73,7 @@ const A2A_PROTOCOL_VERSION = '0.3.0';
70
73
  * means half the callers conclude the origin has no agent at all.
71
74
  */
72
75
  export function agentCard(config) {
73
- const { name, description, url, version, skills, license, organization, documentationUrl, mcpEndpoint, extra } = config;
76
+ const { name, description, url, version, skills, license, licenseScope, organization, documentationUrl, mcpEndpoint, extra } = config;
74
77
  const endpoint = mcpEndpoint === null ? url : (mcpEndpoint ?? `${url}/api/mcp`);
75
78
  return {
76
79
  protocolVersion: A2A_PROTOCOL_VERSION,
@@ -89,7 +92,14 @@ export function agentCard(config) {
89
92
  provider: { organization: organization ?? name, url },
90
93
  documentationUrl: documentationUrl ?? `${url}/llms.txt`,
91
94
  ...(mcpEndpoint === null ? {} : { mcpEndpoint: endpoint }),
92
- ...(license ? { license } : {}),
95
+ // The same two discovery URLs on every card. The three cards here named the
96
+ // spec `apiSpecUrl`, `apiSpecUrl` and `openapiUrl`, and one omitted the
97
+ // server card, so a client reading one card could not read the next.
98
+ apiSpecUrl: `${url}/.well-known/openapi.json`,
99
+ ...(mcpEndpoint === null ? {} : { mcpServerCard: `${endpoint}/server-card` }),
100
+ ...(license
101
+ ? { license: { name: license.name, spdx: license.spdx, url: license.url, ...(licenseScope ? { scope: licenseScope } : {}) } }
102
+ : {}),
93
103
  ...extra,
94
104
  defaultInputModes: ['text/plain', 'application/json'],
95
105
  defaultOutputModes: ['text/plain', 'application/json', 'text/markdown'],
@@ -155,3 +165,49 @@ export function registryAuthHandler(publicKey = process.env.MCP_REGISTRY_PUBLIC_
155
165
  });
156
166
  };
157
167
  }
168
+ /**
169
+ * `/.well-known/mcp.json`: the third path an arriving agent probes, after the
170
+ * agent card and the OpenAPI spec. All three origins wrote the same envelope,
171
+ * and one had already dropped the tool titles the other two listed. The
172
+ * protocol versions and the rate limit come from the transport that answers,
173
+ * so the manifest cannot advertise a version the server does not speak.
174
+ */
175
+ export function mcpManifest(config) {
176
+ const { name, registryName, version, description, url, provider, tools, extra } = config;
177
+ return {
178
+ schema_version: '1.0',
179
+ name,
180
+ registryName,
181
+ version,
182
+ description,
183
+ url,
184
+ provider: { name: provider ?? name, url },
185
+ api: { type: 'openapi', url: `${url}/.well-known/openapi.json` },
186
+ mcp: {
187
+ endpoint: `${url}/api/mcp`,
188
+ transport: 'streamable-http',
189
+ protocol: 'JSON-RPC 2.0',
190
+ protocolVersion: MCP_PROTOCOL_VERSION,
191
+ supportedProtocolVersions: MCP_PROTOCOL_VERSIONS,
192
+ rateLimit: MCP_RATE_LIMIT_TEXT,
193
+ },
194
+ ...extra,
195
+ tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
196
+ name,
197
+ ...(title ? { title } : {}),
198
+ description,
199
+ inputSchema,
200
+ ...(annotations ? { annotations } : {}),
201
+ })),
202
+ };
203
+ }
204
+ /**
205
+ * A GET handler serving a descriptor built from code: ETag, 304, freshness.
206
+ * The agent cards, the manifest and the server card were each a three-line
207
+ * route around `descriptorResponse`, written out nine times.
208
+ */
209
+ export function descriptorHandler(body, opts = {}) {
210
+ return request => descriptorResponse(request, body, opts);
211
+ }
212
+ /** The whole `<mcp-url>/server-card` route, projected from server.json. */
213
+ export const serverCardHandler = (manifest) => descriptorHandler(serverCard(manifest, MCP_PROTOCOL_VERSIONS));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -167,6 +167,10 @@
167
167
  "./corpus": {
168
168
  "types": "./dist/corpus.d.ts",
169
169
  "import": "./dist/corpus.js"
170
+ },
171
+ "./metadata": {
172
+ "types": "./dist/metadata.d.ts",
173
+ "import": "./dist/metadata.js"
170
174
  }
171
175
  },
172
176
  "bin": {
@@ -223,7 +227,8 @@
223
227
  "@tiptap/starter-kit": ">=2.10",
224
228
  "jose": ">=5",
225
229
  "react": ">=18",
226
- "sanitize-html": ">=2"
230
+ "sanitize-html": ">=2",
231
+ "@tiptap/pm": ">=2.10"
227
232
  },
228
233
  "peerDependenciesMeta": {
229
234
  "react": {
@@ -273,6 +278,9 @@
273
278
  },
274
279
  "sanitize-html": {
275
280
  "optional": true
281
+ },
282
+ "@tiptap/pm": {
283
+ "optional": true
276
284
  }
277
285
  }
278
286
  }