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 +56 -8
- package/dist/blocks.d.ts +31 -1
- package/dist/blocks.js +66 -7
- package/dist/corpus.d.ts +65 -0
- package/dist/corpus.js +82 -0
- package/dist/link-check.d.ts +25 -0
- package/dist/link-check.js +53 -0
- package/dist/maps.d.ts +25 -2
- package/dist/maps.js +91 -5
- package/dist/mcp.d.ts +37 -0
- package/dist/mcp.js +61 -0
- package/dist/react-server.d.ts +16 -0
- package/dist/react-server.js +29 -10
- package/dist/revisions.d.ts +7 -3
- package/dist/revisions.js +5 -3
- package/dist/sanitize.d.ts +44 -0
- package/dist/sanitize.js +191 -0
- package/dist/text.d.ts +16 -1
- package/dist/text.js +26 -0
- package/dist/validation.d.ts +19 -1
- package/dist/validation.js +82 -28
- package/package.json +16 -2
package/README.md
CHANGED
|
@@ -145,6 +145,12 @@ handler: async (args, ctx) => {
|
|
|
145
145
|
|
|
146
146
|
`config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope.
|
|
147
147
|
|
|
148
|
+
### Tool arguments and the corpus tool
|
|
149
|
+
|
|
150
|
+
`readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
|
|
151
|
+
|
|
152
|
+
`wiki-formant/corpus` is the arithmetic behind a `get_full_corpus` tool: a `sizeOnly` preflight with per-branch and largest-page breakdowns, then page-aligned slices under a `maxChars` budget (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. The corpus itself — which rows, how a page becomes a section — is the wiki's; `sliceCorpus` takes the sections it builds.
|
|
153
|
+
|
|
148
154
|
## Conformance
|
|
149
155
|
|
|
150
156
|
`wiki-formant/conformance` is the half of an MCP conformance run that is not about any one server's tools: a JSON-RPC client that backs off on a 429, the transport assertions (CORS preflight, `GET`→405, a notification answering 202 with no body, `-32700`, the batch cap, honest `capabilities`, version negotiation), version coherence across every descriptor a surface publishes, A2A card parity, conditional-GET and `robots` checks, and a pass/fail table with an exit code.
|
|
@@ -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:
|
|
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
|
|
334
|
-
what a reviewing UI anchors on
|
|
335
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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 =>
|
|
119
|
+
const mapped = await Promise.all(groups.map(group => mapBlockTreeAsync(group, map, shape)));
|
|
61
120
|
return shape.rebuild(block, mapped);
|
|
62
121
|
}));
|
|
63
122
|
}
|
package/dist/corpus.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/link-check.d.ts
CHANGED
|
@@ -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 `&` 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[]>;
|
package/dist/link-check.js
CHANGED
|
@@ -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 `&` 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
|
|
18
|
-
export declare
|
|
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
|
|
4
|
-
// pure string work over Google and Apple Maps URL shapes
|
|
5
|
-
//
|
|
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
|
-
|
|
77
|
-
|
|
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
|
+
}
|