wiki-formant 0.20.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 +84 -7
- package/dist/block-views.d.ts +8 -11
- package/dist/block-views.js +10 -8
- package/dist/blocks.d.ts +31 -1
- package/dist/blocks.js +66 -7
- package/dist/conformance.d.ts +13 -1
- package/dist/conformance.js +23 -1
- package/dist/corpus.d.ts +65 -0
- package/dist/corpus.js +82 -0
- package/dist/crawlers.d.ts +8 -1
- package/dist/crawlers.js +14 -1
- package/dist/editor.d.ts +5 -0
- package/dist/editor.js +24 -0
- package/dist/freshness.d.ts +14 -0
- package/dist/freshness.js +13 -0
- package/dist/headings.d.ts +7 -0
- package/dist/headings.js +15 -7
- package/dist/http.d.ts +28 -1
- package/dist/http.js +29 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/license.d.ts +9 -0
- package/dist/license.js +8 -0
- package/dist/link-check.d.ts +25 -0
- package/dist/link-check.js +56 -1
- 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/metadata.d.ts +69 -0
- package/dist/metadata.js +45 -0
- package/dist/react-server.d.ts +21 -1
- package/dist/react-server.js +43 -13
- package/dist/react.d.ts +12 -2
- package/dist/react.js +31 -0
- 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/search.d.ts +20 -0
- package/dist/search.js +22 -0
- package/dist/text.d.ts +18 -1
- package/dist/text.js +30 -0
- package/dist/tiptap.d.ts +14 -1
- package/dist/tiptap.js +41 -1
- package/dist/validation.d.ts +19 -1
- package/dist/validation.js +82 -28
- package/dist/well-known.d.ts +47 -8
- package/dist/well-known.js +58 -2
- package/package.json +24 -2
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
|
*
|
package/dist/freshness.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/headings.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
package/dist/index.js
CHANGED
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
|
+
}
|
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.
|
|
@@ -180,7 +182,9 @@ export async function probeYouTube(videoId, opts = {}) {
|
|
|
180
182
|
}
|
|
181
183
|
/** An external anchor, with a video treated as a video. */
|
|
182
184
|
export async function probeExternal(url, opts = {}) {
|
|
183
|
-
|
|
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);
|
|
184
188
|
if (yt?.[1])
|
|
185
189
|
return { url, videoId: yt[1], ...(await probeYouTube(yt[1], opts)) };
|
|
186
190
|
return probeUrl(url, opts);
|
|
@@ -228,6 +232,57 @@ export function extractEmbeds(html) {
|
|
|
228
232
|
}
|
|
229
233
|
return out;
|
|
230
234
|
}
|
|
235
|
+
const records = (v) => Array.isArray(v) ? v.filter((x) => !!x && typeof x === 'object') : [];
|
|
236
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
237
|
+
/**
|
|
238
|
+
* Every link in a block tree, from every core type that carries one.
|
|
239
|
+
*
|
|
240
|
+
* The two checkers that walked this had each fixed a bug the other still had.
|
|
241
|
+
* One read reference URLs, which live in `items[].url` and never in an anchor;
|
|
242
|
+
* the other missed every citation. The other decoded `&` before probing —
|
|
243
|
+
* a stored href is an attribute, and probing it raw turns query-sensitive APIs
|
|
244
|
+
* into false failures — and stopped a bare `/` collapsing to `''`, the
|
|
245
|
+
* homepage reported broken. Neither read link-grid hrefs. This does all four.
|
|
246
|
+
*/
|
|
247
|
+
export function collectBlockLinks(blocks, containers = coreBlockShape().containers) {
|
|
248
|
+
const out = { external: [], internal: [], embeds: [] };
|
|
249
|
+
const href = (raw) => {
|
|
250
|
+
const url = decodeEntities(raw).trim();
|
|
251
|
+
if (/^https?:\/\//i.test(url))
|
|
252
|
+
out.external.push(url);
|
|
253
|
+
else if (url.startsWith('/') && !url.startsWith('//'))
|
|
254
|
+
out.internal.push(url.split('#')[0].replace(/(.)\/+$/, '$1'));
|
|
255
|
+
};
|
|
256
|
+
const html = (fragment) => {
|
|
257
|
+
for (const link of extractLinks(fragment))
|
|
258
|
+
href(link.href);
|
|
259
|
+
for (const embed of extractEmbeds(fragment)) {
|
|
260
|
+
const url = decodeEntities(embed.url).trim();
|
|
261
|
+
if (/^https?:\/\//i.test(url))
|
|
262
|
+
out.embeds.push({ kind: embed.kind, url });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
for (const block of leafBlocks(blocks, containers)) {
|
|
266
|
+
if (block.type === 'content')
|
|
267
|
+
html(text(block.text));
|
|
268
|
+
if (block.type === 'references') {
|
|
269
|
+
for (const item of records(block.items)) {
|
|
270
|
+
html(text(item.text));
|
|
271
|
+
if (text(item.url))
|
|
272
|
+
href(text(item.url));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (block.type === 'linkGrid') {
|
|
276
|
+
for (const group of records(block.groups)) {
|
|
277
|
+
html(text(group.description));
|
|
278
|
+
for (const link of records(group.links))
|
|
279
|
+
if (text(link.href))
|
|
280
|
+
href(text(link.href));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
231
286
|
// ---- concurrency ------------------------------------------------------------
|
|
232
287
|
/** `Promise.all` with a ceiling, preserving input order in the results. */
|
|
233
288
|
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
|
+
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -202,6 +202,43 @@ export declare class McpToolError extends Error {
|
|
|
202
202
|
readonly details?: Record<string, unknown> | undefined;
|
|
203
203
|
constructor(message: string, details?: Record<string, unknown> | undefined);
|
|
204
204
|
}
|
|
205
|
+
/** A value the server used in place of the one the caller sent, and why. */
|
|
206
|
+
export interface ArgAdjustment {
|
|
207
|
+
param: string;
|
|
208
|
+
requested: unknown;
|
|
209
|
+
used: unknown;
|
|
210
|
+
reason: string;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Typed, clamped reads over a tool's raw arguments, recording every value it
|
|
214
|
+
* had to override.
|
|
215
|
+
*
|
|
216
|
+
* Defaults belong in the tool's description, where a model reads them; what a
|
|
217
|
+
* result should echo is only the case where the server overrode what the
|
|
218
|
+
* caller actually asked for, so `adjustments` stays signal. The three servers
|
|
219
|
+
* here had one of these, one inline `clamp`, and one server coercing per tool,
|
|
220
|
+
* where `Math.min(Number(limit) || 12, 50)` let a negative limit through.
|
|
221
|
+
*/
|
|
222
|
+
export declare function readArgs(raw: Record<string, unknown>): {
|
|
223
|
+
adjustments: ArgAdjustment[];
|
|
224
|
+
note(a: ArgAdjustment): void;
|
|
225
|
+
/** A whole number in `[min, max]`; `def` when absent or not a number at all. */
|
|
226
|
+
num: (name: string, def: number, min: number, max: number) => number;
|
|
227
|
+
/** Any number in `[min, max]` — an age that is a cohort mean, a price; `def` (often null) when absent. */
|
|
228
|
+
decimal: <D extends number | null>(name: string, def: D, min: number, max: number) => number | D;
|
|
229
|
+
/** Trimmed text; `def` when absent. */
|
|
230
|
+
str(name: string, def?: string): string;
|
|
231
|
+
/** True only for a JSON `true`. */
|
|
232
|
+
bool(name: string): boolean;
|
|
233
|
+
/** The string members of an array argument; `[]` when absent. */
|
|
234
|
+
list(name: string): string[];
|
|
235
|
+
};
|
|
236
|
+
/** `result`, plus `adjustments` when `readArgs` had to override anything. */
|
|
237
|
+
export declare const withAdjustments: <T extends object>(args: {
|
|
238
|
+
adjustments: readonly ArgAdjustment[];
|
|
239
|
+
}, result: T) => T | (T & {
|
|
240
|
+
adjustments: readonly ArgAdjustment[];
|
|
241
|
+
});
|
|
205
242
|
/** One entry in a JSON-RPC envelope. Exported because `wiki-formant/x402`
|
|
206
243
|
* gates the envelope before `handleMcp` ever sees it. */
|
|
207
244
|
export type RpcRequest = {
|