wiki-formant 0.20.0 → 0.21.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
@@ -145,6 +145,12 @@ handler: async (args, ctx) => {
145
145
 
146
146
  `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope.
147
147
 
148
+ ### Tool arguments and the corpus tool
149
+
150
+ `readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
151
+
152
+ `wiki-formant/corpus` is the arithmetic behind a `get_full_corpus` tool: a `sizeOnly` preflight with per-branch and largest-page breakdowns, then page-aligned slices under a `maxChars` budget (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. The corpus itself — which rows, how a page becomes a section — is the wiki's; `sliceCorpus` takes the sections it builds.
153
+
148
154
  ## Conformance
149
155
 
150
156
  `wiki-formant/conformance` is the half of an MCP conformance run that is not about any one server's tools: a JSON-RPC client that backs off on a 429, the transport assertions (CORS preflight, `GET`→405, a notification answering 202 with no body, `-32700`, the batch cap, honest `capabilities`, version negotiation), version coherence across every descriptor a surface publishes, A2A card parity, conditional-GET and `robots` checks, and a pass/fail table with an exit code.
@@ -263,7 +269,7 @@ are unit-tested without a DOM.
263
269
 
264
270
  `wiki-formant/react` carries `'use client'`, and that is a module-level boundary: anything exported from it hydrates in the consumer's tree whether or not it uses a hook. `wiki-formant/react-server` is the same React, without the directive — for the parts of a wiki that are pure functions of their props and should ship no JavaScript at all.
265
271
 
266
- `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.
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.
267
273
 
268
274
  `PageNav` is the previous/next pair at the foot of an article — the sequential read the infobox rail's lateral links do not cover. Ordering is the caller's, because it is the one part that is never portable: a wiki's sequence is its section's configured sort, a knowledge base's is a taxonomy walk. Pair it with `adjacentPages` from `wiki-formant/pagination` over a list you already hold — neither wiki needs a query for it, and the two indexed lookups the neighbours used to cost were the reason one of them dropped the control.
269
275
 
@@ -318,10 +324,7 @@ between the copies; they differed only in what each had learned since.
318
324
  const diff = computeRevisionDiff({
319
325
  currentVersion: page.version,
320
326
  oldContent, newContent, oldTitle, newTitle,
321
- containers: b =>
322
- b.type === 'infobox' ? [{ path: 'blocks', blocks: b.blocks }]
323
- : b.type === 'columns' ? b.columns.map((c, i) => ({ path: `columns.${i}.blocks`, blocks: c.blocks }))
324
- : null,
327
+ // containers: defaults to coreBlockGroups — `columns.i.blocks` and an infobox's `blocks`
325
328
  });
326
329
  ```
327
330
 
@@ -330,9 +333,9 @@ const diff = computeRevisionDiff({
330
333
  - **Container keys are never diffed as attributes.** They are walked as their own
331
334
  entries, and comparing them here would report an infobox as edited every time
332
335
  anything inside it changed — turning a prose edit into a structural bump.
333
- - **The path segment is a parameter**, because `root.1.columns.0.blocks.2` is
334
- what a reviewing UI anchors on and only the consumer knows how its containers
335
- are addressed.
336
+ - **The path segment is part of the contract**, because `root.1.columns.0.blocks.2`
337
+ is what a reviewing UI anchors on. A wiki with a container beyond the core two
338
+ passes its own `containers` and says how it is addressed.
336
339
 
337
340
  One copy also built two Maps keyed by a recursive `JSON.stringify` of every
338
341
  block, on every save, and never read either one. Matching is by id and always
@@ -383,6 +386,45 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
383
386
 
384
387
  `TWITTER_ORIGIN` is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
385
388
 
389
+ ## Block trees
390
+
391
+ 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:
392
+
393
+ ```ts
394
+ import { coreBlockShape, leafBlocks, mapBlockTree } from 'wiki-formant/blocks';
395
+
396
+ export const BLOCK_SHAPE = coreBlockShape<Block>();
397
+ mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);
398
+ leafBlocks(blocks, BLOCK_SHAPE.containers);
399
+ ```
400
+
401
+ `computeRevisionDiff` defaults to the same shape, path-addressed, so a repo whose containers are the core two passes none.
402
+
403
+ The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
404
+
405
+ `createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
406
+
407
+ ```ts
408
+ const issues = blockIssues(body.content);
409
+ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(issues)}`);
410
+ // Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
411
+ ```
412
+
413
+ ## Sanitising stored HTML
414
+
415
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render three HTML fields themselves, and a repo renders `content.text` beside them, so the guard ships with the renderer. `sanitize-html` is an optional peer; run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
416
+
417
+ ```ts
418
+ import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
419
+
420
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // pair with CSP frame-src
421
+ const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
422
+ ```
423
+
424
+ The default list is derived from what the editor nodes in `wiki-formant/tiptap` store — the embed wrappers' data attributes, the tab markup `activateTabGroups` reads back, the table classes — plus the presentational SVG subset the infographics pipeline embeds. Extend it with `tags`, `attributes`, `classes` and `schemesByTag` derived from your stored HTML, never from memory: a list written from memory erases content on the first render.
425
+
426
+ Classes pass by name only. Limiting `style` to layout and paint is worth nothing while `class` is free, because every site's own stylesheet ships `fixed inset-0 z-50`, and those draw a fake prompt over the chrome as well as `position` does.
427
+
386
428
  ## Editor nodes
387
429
 
388
430
  `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. The four `@tiptap/*` packages are optional peers, so a consumer taking only the taxonomy still installs a package with no runtime dependencies.
@@ -399,6 +441,12 @@ The ones that take config take it because that is exactly where the two copies d
399
441
 
400
442
  `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
401
443
 
444
+ 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
+
446
+ ```ts
447
+ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
448
+ ```
449
+
402
450
  ## Analytics
403
451
 
404
452
  Two lanes, one events helper. `mcpCallProps` reads a tool name out of a JSON-RPC
package/dist/blocks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BlockGroup } from './revisions.js';
1
2
  export interface CodeTab {
2
3
  label: string;
3
4
  language?: string;
@@ -57,13 +58,42 @@ export interface BlockTreeShape<B> {
57
58
  rebuild: (block: B, groups: B[][]) => B;
58
59
  }
59
60
  /**
60
- * Every block in the tree, transformed, with the tree's shape preserved.
61
+ * The core containers' nested groups, each with the path segment that
62
+ * addresses it: `columns` holds `columns[i].blocks`, `infobox` holds `blocks`.
63
+ * Every other type is a leaf.
64
+ *
65
+ * All three wikis store these two identically, and between them had written
66
+ * this walk seven times — once per markdown twin, text export, revision diff,
67
+ * resolver and checker. A walk that forgot a container shipped a code block
68
+ * unhighlighted or dropped a link from the checker. The path-addressed form is
69
+ * what `computeRevisionDiff` defaults to.
70
+ */
71
+ export declare function coreBlockGroups<B extends {
72
+ type: string;
73
+ }>(block: B): BlockGroup<B>[] | null;
74
+ /**
75
+ * `coreBlockGroups` as a `BlockTreeShape`: read the groups, and put mapped
76
+ * groups back where they came from. Bind it once per repo,
77
+ * `const BLOCK_SHAPE = coreBlockShape<Block>()`, and hand it to every walk.
78
+ */
79
+ export declare function coreBlockShape<B extends {
80
+ type: string;
81
+ }>(): BlockTreeShape<B>;
82
+ /** Every leaf in document order, with the containers flattened away. */
83
+ export declare function leafBlocks<B>(blocks: readonly B[], containers: (block: B) => B[][] | null): B[];
84
+ /**
85
+ * Every leaf in the tree, transformed, with the tree's shape preserved.
61
86
  *
62
87
  * The three-branch walk this replaces — columns to `columns[].blocks`, infobox
63
88
  * to `.blocks`, everything else atomic — was written by hand for heading
64
89
  * injection, syntax highlighting, server-side data resolution, feed rendering
65
90
  * and twice more besides. Each copy was correct and each had to be found again
66
91
  * whenever a container type was added.
92
+ *
93
+ * It recurses, so `map` only ever sees leaves, however deep. It used to stop
94
+ * one level down and hand a nested container to `map` as if it were a leaf —
95
+ * harmless while every validator here rejects nesting, and a hole the moment a
96
+ * sanitising pass is the thing being mapped.
67
97
  */
68
98
  export declare function mapBlockTree<B>(blocks: readonly B[], map: (block: B) => B, shape: BlockTreeShape<B>): B[];
69
99
  /** The async twin. Highlighting and data resolution both await per leaf. */
package/dist/blocks.js CHANGED
@@ -19,36 +19,95 @@ import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
19
19
  */
20
20
  export function renderBlockTree(blocks, opts) {
21
21
  const { atomic, containers, groupSeparator = '\n\n' } = opts;
22
- return blocks
23
- .map(block => {
22
+ const render = (block) => {
24
23
  const groups = containers?.(block);
25
24
  if (!groups)
26
25
  return atomic(block);
27
26
  return groups
28
- .map(group => group.map(atomic).filter(Boolean).join(groupSeparator))
27
+ .map(group => group.map(render).filter(Boolean).join(groupSeparator))
29
28
  .filter(Boolean)
30
29
  .join(groupSeparator);
31
- })
30
+ };
31
+ return blocks
32
+ .map(render)
32
33
  .filter(Boolean)
33
34
  .join('\n\n')
34
35
  .replace(/\n{3,}/g, '\n\n')
35
36
  .trim();
36
37
  }
38
+ const list = (v) => (Array.isArray(v) ? v : []);
37
39
  /**
38
- * Every block in the tree, transformed, with the tree's shape preserved.
40
+ * The core containers' nested groups, each with the path segment that
41
+ * addresses it: `columns` holds `columns[i].blocks`, `infobox` holds `blocks`.
42
+ * Every other type is a leaf.
43
+ *
44
+ * All three wikis store these two identically, and between them had written
45
+ * this walk seven times — once per markdown twin, text export, revision diff,
46
+ * resolver and checker. A walk that forgot a container shipped a code block
47
+ * unhighlighted or dropped a link from the checker. The path-addressed form is
48
+ * what `computeRevisionDiff` defaults to.
49
+ */
50
+ export function coreBlockGroups(block) {
51
+ const b = block;
52
+ if (b.type === 'columns') {
53
+ return list(b.columns).map((col, i) => ({
54
+ path: `columns.${i}.blocks`,
55
+ blocks: list(col?.blocks),
56
+ }));
57
+ }
58
+ if (b.type === 'infobox')
59
+ return [{ path: 'blocks', blocks: list(b.blocks) }];
60
+ return null;
61
+ }
62
+ /**
63
+ * `coreBlockGroups` as a `BlockTreeShape`: read the groups, and put mapped
64
+ * groups back where they came from. Bind it once per repo,
65
+ * `const BLOCK_SHAPE = coreBlockShape<Block>()`, and hand it to every walk.
66
+ */
67
+ export function coreBlockShape() {
68
+ return {
69
+ containers: block => coreBlockGroups(block)?.map(group => group.blocks) ?? null,
70
+ rebuild: (block, groups) => {
71
+ const b = block;
72
+ if (b.type === 'columns') {
73
+ return {
74
+ ...block,
75
+ columns: list(b.columns).map((col, i) => ({ ...col, blocks: groups[i] ?? [] })),
76
+ };
77
+ }
78
+ if (b.type === 'infobox')
79
+ return { ...block, blocks: groups[0] ?? [] };
80
+ return block;
81
+ },
82
+ };
83
+ }
84
+ /** Every leaf in document order, with the containers flattened away. */
85
+ export function leafBlocks(blocks, containers) {
86
+ return blocks.flatMap(block => {
87
+ const groups = containers(block);
88
+ return groups ? groups.flatMap(group => leafBlocks(group, containers)) : [block];
89
+ });
90
+ }
91
+ /**
92
+ * Every leaf in the tree, transformed, with the tree's shape preserved.
39
93
  *
40
94
  * The three-branch walk this replaces — columns to `columns[].blocks`, infobox
41
95
  * to `.blocks`, everything else atomic — was written by hand for heading
42
96
  * injection, syntax highlighting, server-side data resolution, feed rendering
43
97
  * and twice more besides. Each copy was correct and each had to be found again
44
98
  * whenever a container type was added.
99
+ *
100
+ * It recurses, so `map` only ever sees leaves, however deep. It used to stop
101
+ * one level down and hand a nested container to `map` as if it were a leaf —
102
+ * harmless while every validator here rejects nesting, and a hole the moment a
103
+ * sanitising pass is the thing being mapped.
45
104
  */
46
105
  export function mapBlockTree(blocks, map, shape) {
47
106
  return blocks.map(block => {
48
107
  const groups = shape.containers(block);
49
108
  if (!groups)
50
109
  return map(block);
51
- return shape.rebuild(block, groups.map(group => group.map(map)));
110
+ return shape.rebuild(block, groups.map(group => mapBlockTree(group, map, shape)));
52
111
  });
53
112
  }
54
113
  /** The async twin. Highlighting and data resolution both await per leaf. */
@@ -57,7 +116,7 @@ export async function mapBlockTreeAsync(blocks, map, shape) {
57
116
  const groups = shape.containers(block);
58
117
  if (!groups)
59
118
  return map(block);
60
- const mapped = await Promise.all(groups.map(group => Promise.all(group.map(map))));
119
+ const mapped = await Promise.all(groups.map(group => mapBlockTreeAsync(group, map, shape)));
61
120
  return shape.rebuild(block, mapped);
62
121
  }));
63
122
  }
@@ -0,0 +1,65 @@
1
+ /** One page as it appears in the document. */
2
+ export interface CorpusSection {
3
+ /** The page's address, `tag/path/slug`. */
4
+ path: string;
5
+ /** Its branch, for the preflight's per-branch breakdown. */
6
+ tagPath: string;
7
+ /** Heading, URL, date and body, exactly as the document will carry them. */
8
+ section: string;
9
+ }
10
+ /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
11
+ export declare const CORPUS_BUDGET: {
12
+ readonly default: 200000;
13
+ readonly min: 1000;
14
+ readonly max: 1000000;
15
+ };
16
+ export interface CorpusSliceOptions {
17
+ /** The returned document's heading. */
18
+ title: string;
19
+ /** Sizes and breakdowns only, no document. */
20
+ sizeOnly?: boolean;
21
+ /** First section to include: the previous call's `nextSkip`. */
22
+ skip?: number;
23
+ /** Character budget for `document`. */
24
+ maxChars?: number;
25
+ /** The preflight's closing advice: what is cheaper than pulling everything. */
26
+ hint?: string;
27
+ }
28
+ /**
29
+ * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
30
+ *
31
+ * `truncated` is true whenever content was cut: whole pages left over, or one
32
+ * page clipped mid-way because it alone exceeds the budget. `nextSkip` appears
33
+ * only when paging forward can return something; a clipped page is skipped
34
+ * past, since resuming on it would stall paging forever.
35
+ */
36
+ export declare function sliceCorpus(sections: readonly CorpusSection[], opts: CorpusSliceOptions): {
37
+ branches: {
38
+ pages: number;
39
+ chars: number;
40
+ path: string;
41
+ }[];
42
+ largestPages: {
43
+ path: string;
44
+ chars: number;
45
+ }[];
46
+ hint: string;
47
+ totalPages: number;
48
+ characters: number;
49
+ estimatedTokens: number;
50
+ tokenNote: string;
51
+ } | {
52
+ document: string;
53
+ clippedPage?: string | undefined;
54
+ clippedHint?: string | undefined;
55
+ nextSkip?: number | undefined;
56
+ skip: number;
57
+ includedPages: number;
58
+ omittedPages: number;
59
+ returnedCharacters: number;
60
+ truncated: boolean;
61
+ totalPages: number;
62
+ characters: number;
63
+ estimatedTokens: number;
64
+ tokenNote: string;
65
+ };
package/dist/corpus.js ADDED
@@ -0,0 +1,82 @@
1
+ // corpus.ts — a wiki's whole corpus, sliced to something a tool result can hold.
2
+ //
3
+ // /llms-full.txt can afford to be 3 MB: a fetch streams to disk under an ETag.
4
+ // A tool result lands in a context window, so the MCP twin of that export needs
5
+ // a preflight, a character budget and a page-aligned resume. Two wikis wrote
6
+ // that loop and called the second a copy of the first "field for field". It was
7
+ // not: the fields had different names, one reported `truncated: false` after
8
+ // clipping the last page — while its own prompt told agents to page until
9
+ // `truncated` was false — and the other sized its preflight on page bodies but
10
+ // sliced on whole sections, so the preflight undercounted what it would send.
11
+ //
12
+ // The corpus itself — which rows, in what order, how a page becomes a section —
13
+ // stays with each wiki. This is the arithmetic over the sections it hands in.
14
+ /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
15
+ export const CORPUS_BUDGET = { default: 200_000, min: 1_000, max: 1_000_000 };
16
+ const CLIP_MARKER = '\n\n[…page clipped at maxChars…]';
17
+ /**
18
+ * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
19
+ *
20
+ * `truncated` is true whenever content was cut: whole pages left over, or one
21
+ * page clipped mid-way because it alone exceeds the budget. `nextSkip` appears
22
+ * only when paging forward can return something; a clipped page is skipped
23
+ * past, since resuming on it would stall paging forever.
24
+ */
25
+ export function sliceCorpus(sections, opts) {
26
+ const { title, sizeOnly = false, skip = 0, maxChars = CORPUS_BUDGET.default, hint } = opts;
27
+ const characters = sections.reduce((n, s) => n + s.section.length, 0);
28
+ const head = {
29
+ totalPages: sections.length,
30
+ characters,
31
+ estimatedTokens: Math.round(characters / 4),
32
+ tokenNote: 'estimatedTokens is characters/4, a rough guide only.',
33
+ };
34
+ if (sizeOnly) {
35
+ const branches = new Map();
36
+ for (const s of sections) {
37
+ const b = branches.get(s.tagPath) ?? { pages: 0, chars: 0 };
38
+ branches.set(s.tagPath, { pages: b.pages + 1, chars: b.chars + s.section.length });
39
+ }
40
+ return {
41
+ ...head,
42
+ branches: [...branches.entries()].map(([path, b]) => ({ path, ...b })).sort((a, b) => b.chars - a.chars),
43
+ largestPages: sections
44
+ .map(s => ({ path: s.path, chars: s.section.length }))
45
+ .sort((a, b) => b.chars - a.chars)
46
+ .slice(0, 5),
47
+ hint: hint ?? 'Pull with maxChars, or one branch at a time with tagPath.',
48
+ };
49
+ }
50
+ const parts = [];
51
+ let used = 0;
52
+ let index = skip;
53
+ let clippedPage;
54
+ for (; index < sections.length; index++) {
55
+ const s = sections[index];
56
+ if (used + s.section.length > maxChars) {
57
+ if (!parts.length) {
58
+ parts.push(`${s.section.slice(0, maxChars)}${CLIP_MARKER}`);
59
+ clippedPage = s.path;
60
+ used = maxChars;
61
+ index++;
62
+ }
63
+ break;
64
+ }
65
+ parts.push(s.section);
66
+ used += s.section.length;
67
+ }
68
+ const morePages = index < sections.length;
69
+ return {
70
+ ...head,
71
+ skip,
72
+ includedPages: parts.length,
73
+ omittedPages: sections.length - index,
74
+ returnedCharacters: used,
75
+ truncated: morePages || clippedPage !== undefined,
76
+ ...(morePages ? { nextSkip: index } : {}),
77
+ ...(clippedPage
78
+ ? { clippedPage, clippedHint: 'This page alone exceeds maxChars; raise maxChars to read the rest of it.' }
79
+ : {}),
80
+ document: [`# ${title}\n\n> pages ${skip + 1}-${index} of ${sections.length}`, ...parts].join('\n\n'),
81
+ };
82
+ }
@@ -88,5 +88,30 @@ export declare function extractEmbeds(html: string): Array<{
88
88
  kind: string;
89
89
  url: string;
90
90
  }>;
91
+ /** What a page links to, split the way a checker probes it. */
92
+ export interface BlockLinks {
93
+ /** Absolute http(s) targets. */
94
+ external: string[];
95
+ /** Site-relative paths, fragment and trailing slash dropped. `/` stays `/`. */
96
+ internal: string[];
97
+ /** `<iframe>` and `<img>` sources. */
98
+ embeds: Array<{
99
+ kind: string;
100
+ url: string;
101
+ }>;
102
+ }
103
+ /**
104
+ * Every link in a block tree, from every core type that carries one.
105
+ *
106
+ * The two checkers that walked this had each fixed a bug the other still had.
107
+ * One read reference URLs, which live in `items[].url` and never in an anchor;
108
+ * the other missed every citation. The other decoded `&amp;` before probing —
109
+ * a stored href is an attribute, and probing it raw turns query-sensitive APIs
110
+ * into false failures — and stopped a bare `/` collapsing to `''`, the
111
+ * homepage reported broken. Neither read link-grid hrefs. This does all four.
112
+ */
113
+ export declare function collectBlockLinks<B extends {
114
+ type: string;
115
+ }>(blocks: readonly B[], containers?: (block: B) => B[][] | null): BlockLinks;
91
116
  /** `Promise.all` with a ceiling, preserving input order in the results. */
92
117
  export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -11,6 +11,8 @@
11
11
  // rather than death, which serialising per hostname fixes. A sweep missing any
12
12
  // one of these strips good citations.
13
13
  import { stripTags } from './html.js';
14
+ import { decodeEntities } from './markdown.js';
15
+ import { coreBlockShape, leafBlocks } from './blocks.js';
14
16
  const DEFAULTS = { timeoutMs: 12_000, slowTimeoutMs: 40_000 };
15
17
  /**
16
18
  * TLS-verification failures are NOT death.
@@ -228,6 +230,57 @@ export function extractEmbeds(html) {
228
230
  }
229
231
  return out;
230
232
  }
233
+ const records = (v) => Array.isArray(v) ? v.filter((x) => !!x && typeof x === 'object') : [];
234
+ const text = (v) => (typeof v === 'string' ? v : '');
235
+ /**
236
+ * Every link in a block tree, from every core type that carries one.
237
+ *
238
+ * The two checkers that walked this had each fixed a bug the other still had.
239
+ * One read reference URLs, which live in `items[].url` and never in an anchor;
240
+ * the other missed every citation. The other decoded `&amp;` before probing —
241
+ * a stored href is an attribute, and probing it raw turns query-sensitive APIs
242
+ * into false failures — and stopped a bare `/` collapsing to `''`, the
243
+ * homepage reported broken. Neither read link-grid hrefs. This does all four.
244
+ */
245
+ export function collectBlockLinks(blocks, containers = coreBlockShape().containers) {
246
+ const out = { external: [], internal: [], embeds: [] };
247
+ const href = (raw) => {
248
+ const url = decodeEntities(raw).trim();
249
+ if (/^https?:\/\//i.test(url))
250
+ out.external.push(url);
251
+ else if (url.startsWith('/') && !url.startsWith('//'))
252
+ out.internal.push(url.split('#')[0].replace(/(.)\/+$/, '$1'));
253
+ };
254
+ const html = (fragment) => {
255
+ for (const link of extractLinks(fragment))
256
+ href(link.href);
257
+ for (const embed of extractEmbeds(fragment)) {
258
+ const url = decodeEntities(embed.url).trim();
259
+ if (/^https?:\/\//i.test(url))
260
+ out.embeds.push({ kind: embed.kind, url });
261
+ }
262
+ };
263
+ for (const block of leafBlocks(blocks, containers)) {
264
+ if (block.type === 'content')
265
+ html(text(block.text));
266
+ if (block.type === 'references') {
267
+ for (const item of records(block.items)) {
268
+ html(text(item.text));
269
+ if (text(item.url))
270
+ href(text(item.url));
271
+ }
272
+ }
273
+ if (block.type === 'linkGrid') {
274
+ for (const group of records(block.groups)) {
275
+ html(text(group.description));
276
+ for (const link of records(group.links))
277
+ if (text(link.href))
278
+ href(text(link.href));
279
+ }
280
+ }
281
+ }
282
+ return out;
283
+ }
231
284
  // ---- concurrency ------------------------------------------------------------
232
285
  /** `Promise.all` with a ceiling, preserving input order in the results. */
233
286
  export async function mapLimit(items, limit, fn) {
package/dist/maps.d.ts CHANGED
@@ -14,5 +14,28 @@ export declare function extractCoordsFromUrl(url: string): MapCoords | null;
14
14
  * understands. Already-embeddable URLs pass through untouched.
15
15
  */
16
16
  export declare function toMapEmbedUrl(url: string): string | null;
17
- /** True for the shortener forms that only a redirect can resolve. */
18
- export declare const isShortMapUrl: (url: string) => boolean;
17
+ /** True for the shortener forms only a redirect can resolve. Exact hostnames, never a substring. */
18
+ export declare function isShortMapUrl(url: string): boolean;
19
+ /**
20
+ * Follow a shortened maps link ONE hop, server-side, and return where it
21
+ * lands — or null unless both ends are on the allowlists. Never follows a
22
+ * redirect chain: every hop is a request to a host this code did not choose.
23
+ */
24
+ export declare function resolveShortMapUrl(url: string, { timeoutMs }?: {
25
+ timeoutMs?: number;
26
+ }): Promise<string | null>;
27
+ /**
28
+ * The whole `GET /api/resolve-map?url=…` route. `authorize` is the wiki's own
29
+ * sign-in check: the route makes an outbound request per call, so it belongs
30
+ * behind the same gate as the editor that calls it.
31
+ */
32
+ export declare function resolveMapHandler(opts: {
33
+ authorize: (request: Request) => boolean | Promise<boolean>;
34
+ timeoutMs?: number;
35
+ }): (request: Request) => Promise<Response>;
36
+ /**
37
+ * A pasted map URL as an embeddable one, following a shortener through the
38
+ * wiki's resolve route when the URL cannot be read directly. The editor's map
39
+ * node and embed dialog both take this as their `resolveMapUrl`.
40
+ */
41
+ export declare function resolveMapUrl(url: string, endpoint?: string): Promise<string | null>;
package/dist/maps.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // maps.ts — turn a map URL a human pasted into one an <iframe> will accept.
2
2
  //
3
- // Both wikis carried this byte-for-byte apart from one `export` keyword. It is
4
- // pure string work over Google and Apple Maps URL shapes, with no framework or
5
- // database in it, which is why neither copy had a reason to diverge.
3
+ // Both wikis carried the parsing byte-for-byte apart from one `export`
4
+ // keyword. It is pure string work over Google and Apple Maps URL shapes. The
5
+ // shortener hop at the bottom is the one part that makes a request, and the
6
+ // part the two copies did NOT agree on.
6
7
  /** A plain embed URL for a coordinate pair. */
7
8
  function mapsEmbedUrl(lat, lon, zoom = 15) {
8
9
  return `https://maps.google.com/maps?q=${lat},${lon}&z=${zoom}&output=embed`;
@@ -73,5 +74,90 @@ export function toMapEmbedUrl(url) {
73
74
  }
74
75
  return null;
75
76
  }
76
- /** True for the shortener forms that only a redirect can resolve. */
77
- export const isShortMapUrl = (url) => /maps\.app\.goo\.gl|goo\.gl\/maps/.test(url);
77
+ // ---- shortened links ---------------------------------------------------------
78
+ //
79
+ // A `maps.app.goo.gl` link only resolves through a redirect, which the editor
80
+ // cannot read cross-origin, so each wiki runs a route that follows it. One of
81
+ // the two copies matched its host as a SUBSTRING — `https://evil.example/?goo.gl`
82
+ // passed — then followed every redirect with no timeout and no login: an
83
+ // anonymous fetcher for any URL. The other followed one hop, between exact
84
+ // host lists, with a timeout, for signed-in members only. That one is below.
85
+ const SHORTLINK_HOSTS = new Set(['goo.gl', 'maps.app.goo.gl']);
86
+ /**
87
+ * Where a resolved shortlink may land: Google Maps on a Google country domain
88
+ * (google.com, maps.google.com, www.google.co.uk, www.google.com.au). An
89
+ * anchored list, because the suffix match it replaced, `google\.[a-z.]+`,
90
+ * admitted google.evil.com.
91
+ */
92
+ const RESOLVED_HOST = /^(?:www\.|maps\.)?google\.(?:com|com?\.[a-z]{2}|[a-z]{2})$/;
93
+ const parse = (url, base) => {
94
+ try {
95
+ return new URL(url, base);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ };
101
+ /** True for the shortener forms only a redirect can resolve. Exact hostnames, never a substring. */
102
+ export function isShortMapUrl(url) {
103
+ const u = parse(url);
104
+ if (!u || !SHORTLINK_HOSTS.has(u.hostname))
105
+ return false;
106
+ return u.hostname === 'maps.app.goo.gl' || u.pathname.startsWith('/maps');
107
+ }
108
+ /**
109
+ * Follow a shortened maps link ONE hop, server-side, and return where it
110
+ * lands — or null unless both ends are on the allowlists. Never follows a
111
+ * redirect chain: every hop is a request to a host this code did not choose.
112
+ */
113
+ export async function resolveShortMapUrl(url, { timeoutMs = 5_000 } = {}) {
114
+ const source = parse(url);
115
+ if (!source || source.protocol !== 'https:' || !isShortMapUrl(url))
116
+ return null;
117
+ try {
118
+ const res = await fetch(source, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
119
+ const location = res.headers.get('location');
120
+ const target = location ? parse(location, source) : null;
121
+ return target && target.protocol === 'https:' && RESOLVED_HOST.test(target.hostname) ? target.toString() : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * The whole `GET /api/resolve-map?url=…` route. `authorize` is the wiki's own
129
+ * sign-in check: the route makes an outbound request per call, so it belongs
130
+ * behind the same gate as the editor that calls it.
131
+ */
132
+ export function resolveMapHandler(opts) {
133
+ return async (request) => {
134
+ if (!(await opts.authorize(request)))
135
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
136
+ const url = new URL(request.url).searchParams.get('url') ?? '';
137
+ if (!url.startsWith('https:') || !isShortMapUrl(url)) {
138
+ return Response.json({ error: 'Invalid URL' }, { status: 400 });
139
+ }
140
+ const resolved = await resolveShortMapUrl(url, opts);
141
+ return resolved
142
+ ? Response.json({ resolved })
143
+ : Response.json({ error: 'Failed to resolve' }, { status: 502 });
144
+ };
145
+ }
146
+ /**
147
+ * A pasted map URL as an embeddable one, following a shortener through the
148
+ * wiki's resolve route when the URL cannot be read directly. The editor's map
149
+ * node and embed dialog both take this as their `resolveMapUrl`.
150
+ */
151
+ export async function resolveMapUrl(url, endpoint = '/api/resolve-map') {
152
+ const direct = toMapEmbedUrl(url);
153
+ if (direct || !isShortMapUrl(url))
154
+ return direct;
155
+ try {
156
+ const res = await fetch(`${endpoint}?url=${encodeURIComponent(url)}`);
157
+ const { resolved } = (await res.json());
158
+ return resolved ? toMapEmbedUrl(resolved) : null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }