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.
Files changed (50) hide show
  1. package/README.md +84 -7
  2. package/dist/block-views.d.ts +8 -11
  3. package/dist/block-views.js +10 -8
  4. package/dist/blocks.d.ts +31 -1
  5. package/dist/blocks.js +66 -7
  6. package/dist/conformance.d.ts +13 -1
  7. package/dist/conformance.js +23 -1
  8. package/dist/corpus.d.ts +65 -0
  9. package/dist/corpus.js +82 -0
  10. package/dist/crawlers.d.ts +8 -1
  11. package/dist/crawlers.js +14 -1
  12. package/dist/editor.d.ts +5 -0
  13. package/dist/editor.js +24 -0
  14. package/dist/freshness.d.ts +14 -0
  15. package/dist/freshness.js +13 -0
  16. package/dist/headings.d.ts +7 -0
  17. package/dist/headings.js +15 -7
  18. package/dist/http.d.ts +28 -1
  19. package/dist/http.js +29 -4
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/license.d.ts +9 -0
  23. package/dist/license.js +8 -0
  24. package/dist/link-check.d.ts +25 -0
  25. package/dist/link-check.js +56 -1
  26. package/dist/maps.d.ts +25 -2
  27. package/dist/maps.js +91 -5
  28. package/dist/mcp.d.ts +37 -0
  29. package/dist/mcp.js +61 -0
  30. package/dist/metadata.d.ts +69 -0
  31. package/dist/metadata.js +45 -0
  32. package/dist/react-server.d.ts +21 -1
  33. package/dist/react-server.js +43 -13
  34. package/dist/react.d.ts +12 -2
  35. package/dist/react.js +31 -0
  36. package/dist/revisions.d.ts +7 -3
  37. package/dist/revisions.js +5 -3
  38. package/dist/sanitize.d.ts +44 -0
  39. package/dist/sanitize.js +191 -0
  40. package/dist/search.d.ts +20 -0
  41. package/dist/search.js +22 -0
  42. package/dist/text.d.ts +18 -1
  43. package/dist/text.js +30 -0
  44. package/dist/tiptap.d.ts +14 -1
  45. package/dist/tiptap.js +41 -1
  46. package/dist/validation.d.ts +19 -1
  47. package/dist/validation.js +82 -28
  48. package/dist/well-known.d.ts +47 -8
  49. package/dist/well-known.js +58 -2
  50. 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
  *
@@ -17,3 +17,17 @@ export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?:
17
17
  * being re-typed either side of the extraction.
18
18
  */
19
19
  export declare function freshnessNotice(page: FreshnessInput): string;
20
+ /**
21
+ * A synthetic `outdated` banner block for a stale page, or null when it is
22
+ * fresh — the shape every wiki's `banner` type already stores, so it drops
23
+ * into the tree the renderer walks. Two wikis carried this function
24
+ * identically. `nowMs` comes from a server component: reading the clock
25
+ * during a client render would let a page near the boundary be stale on the
26
+ * server and fresh in the browser.
27
+ */
28
+ export declare function freshnessBanner(page: FreshnessInput, nowMs: number, maxAgeDays?: number): {
29
+ id: string;
30
+ type: 'banner';
31
+ variant: 'outdated';
32
+ text: string;
33
+ } | null;
package/dist/freshness.js CHANGED
@@ -40,3 +40,16 @@ export function freshnessNotice(page) {
40
40
  : 'not yet verified against sources';
41
41
  return `This page was ${when} and may be out of date. Please help re-check its facts against current sources and the live ledger.`;
42
42
  }
43
+ /**
44
+ * A synthetic `outdated` banner block for a stale page, or null when it is
45
+ * fresh — the shape every wiki's `banner` type already stores, so it drops
46
+ * into the tree the renderer walks. Two wikis carried this function
47
+ * identically. `nowMs` comes from a server component: reading the clock
48
+ * during a client render would let a page near the boundary be stale on the
49
+ * server and fresh in the browser.
50
+ */
51
+ export function freshnessBanner(page, nowMs, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
52
+ if (!isStale(page, nowMs, maxAgeDays))
53
+ return null;
54
+ return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page) };
55
+ }
@@ -7,6 +7,13 @@ export interface Heading {
7
7
  }
8
8
  /** The default slug rule: lowercase words joined by hyphens. */
9
9
  export declare function slugifyHeading(text: string): string;
10
+ /**
11
+ * `base`, or `base-2`, `base-3`… — the first one `used` does not hold. Records
12
+ * nothing: the caller adds the id it keeps. Shared with the editor's heading
13
+ * decoration, so the id a heading shows while it is being written is the id it
14
+ * is published under.
15
+ */
16
+ export declare function uniqueHeadingId(base: string, used: ReadonlySet<string>): string;
10
17
  export interface HeadingIdOptions {
11
18
  /**
12
19
  * How heading text becomes an id. Defaults to `slugifyHeading`. Pass the rule
package/dist/headings.js CHANGED
@@ -20,6 +20,20 @@ export function slugifyHeading(text) {
20
20
  .replace(/[\s_-]+/g, '-')
21
21
  .replace(/^-+|-+$/g, '');
22
22
  }
23
+ /**
24
+ * `base`, or `base-2`, `base-3`… — the first one `used` does not hold. Records
25
+ * nothing: the caller adds the id it keeps. Shared with the editor's heading
26
+ * decoration, so the id a heading shows while it is being written is the id it
27
+ * is published under.
28
+ */
29
+ export function uniqueHeadingId(base, used) {
30
+ if (!base)
31
+ return base;
32
+ let id = base;
33
+ for (let n = 2; used.has(id); n++)
34
+ id = `${base}-${n}`;
35
+ return id;
36
+ }
23
37
  const HEADING = /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi;
24
38
  const defaultAnchor = (id) => `<a class="heading-anchor" href="#${id}" aria-label="Permalink to this section" tabindex="-1"></a>`;
25
39
  /**
@@ -37,15 +51,9 @@ export function injectHeadingIds(html, options = {}) {
37
51
  if (content.includes('heading-anchor'))
38
52
  return match;
39
53
  const existing = getAttr(attrs, 'id');
40
- let id = existing || slug(stripTags(content));
54
+ const id = existing || uniqueHeadingId(slug(stripTags(content)), used);
41
55
  if (!id)
42
56
  return match;
43
- if (!existing) {
44
- const base = id;
45
- let n = 2;
46
- while (used.has(id))
47
- id = `${base}-${n++}`;
48
- }
49
57
  used.add(id);
50
58
  return `<${tag}${existing ? attrs : `${attrs} id="${id}"`}>${content}${anchor(id)}</${tag}>`;
51
59
  });
package/dist/http.d.ts CHANGED
@@ -9,7 +9,15 @@ export declare function corpusEtag(parts: Array<string | number | Date | null |
9
9
  * prefix, and a crawler reformats the date. Each of those took a full render
10
10
  * from a response that was already fresh.
11
11
  */
12
- export declare function notModified(request: Request, etag: string, lastModified?: string | null): Response | null;
12
+ export declare function notModified(request: Request, etag: string, lastModified?: string | null,
13
+ /**
14
+ * The headers the 200 would carry. RFC 9110 15.4.5 requires a 304 to send
15
+ * the Cache-Control and Vary a 200 would have, and a cross-origin client
16
+ * needs the CORS header on it too; without them a revalidated copy loses its
17
+ * freshness and a negotiated URL loses its Vary. The body's own headers
18
+ * (Content-Type, Content-Length) are dropped.
19
+ */
20
+ sent?: Record<string, string>): Response | null;
13
21
  /**
14
22
  * A 404 that teaches, for the plain-GET half of an agent surface.
15
23
  *
@@ -49,6 +57,25 @@ export declare function markdownHeaders(lastModified?: string | null, opts?: {
49
57
  etag?: string;
50
58
  extra?: Record<string, string>;
51
59
  }): Record<string, string>;
60
+ /**
61
+ * Whether a request to a URL that serves both JSON and markdown asked for the
62
+ * markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
63
+ * The `.md` suffix is the caller's to add — it lives in the path, not here.
64
+ *
65
+ * Both branches of such a route must send `VARY_ACCEPT`. Two wikis answered
66
+ * one URL in two formats under `public, s-maxage` with no Vary, so a shared
67
+ * cache could hand the markdown to the next JSON client, or the reverse.
68
+ */
69
+ export declare function wantsMarkdown(request: {
70
+ url: string;
71
+ headers: {
72
+ get(name: string): string | null;
73
+ };
74
+ }): boolean;
75
+ /** The header every response from a content-negotiated URL carries. */
76
+ export declare const VARY_ACCEPT: {
77
+ readonly Vary: "Accept";
78
+ };
52
79
  /**
53
80
  * Headers for a JSON descriptor — an agent card, an OpenAPI document, a
54
81
  * registry manifest. These are the documents a client refetches most and the
package/dist/http.js CHANGED
@@ -30,8 +30,17 @@ const bareTag = (tag) => tag.trim().replace(/^W\//, '');
30
30
  * prefix, and a crawler reformats the date. Each of those took a full render
31
31
  * from a response that was already fresh.
32
32
  */
33
- export function notModified(request, etag, lastModified) {
33
+ export function notModified(request, etag, lastModified,
34
+ /**
35
+ * The headers the 200 would carry. RFC 9110 15.4.5 requires a 304 to send
36
+ * the Cache-Control and Vary a 200 would have, and a cross-origin client
37
+ * needs the CORS header on it too; without them a revalidated copy loses its
38
+ * freshness and a negotiated URL loses its Vary. The body's own headers
39
+ * (Content-Type, Content-Length) are dropped.
40
+ */
41
+ sent = {}) {
34
42
  const headers = {
43
+ ...Object.fromEntries(Object.entries(sent).filter(([k]) => !/^content-(type|length)$/i.test(k))),
35
44
  ETag: etag,
36
45
  ...(lastModified ? { 'Last-Modified': lastModified } : {}),
37
46
  };
@@ -110,6 +119,21 @@ export function markdownHeaders(lastModified, opts = {}) {
110
119
  ...opts.extra,
111
120
  };
112
121
  }
122
+ /**
123
+ * Whether a request to a URL that serves both JSON and markdown asked for the
124
+ * markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
125
+ * The `.md` suffix is the caller's to add — it lives in the path, not here.
126
+ *
127
+ * Both branches of such a route must send `VARY_ACCEPT`. Two wikis answered
128
+ * one URL in two formats under `public, s-maxage` with no Vary, so a shared
129
+ * cache could hand the markdown to the next JSON client, or the reverse.
130
+ */
131
+ export function wantsMarkdown(request) {
132
+ return (new URL(request.url).searchParams.get('format') === 'text' ||
133
+ /text\/(markdown|plain)/.test(request.headers.get('accept') ?? ''));
134
+ }
135
+ /** The header every response from a content-negotiated URL carries. */
136
+ export const VARY_ACCEPT = { Vary: 'Accept' };
113
137
  /**
114
138
  * Headers for a JSON descriptor — an agent card, an OpenAPI document, a
115
139
  * registry manifest. These are the documents a client refetches most and the
@@ -137,7 +161,8 @@ export function descriptorHeaders(etag, opts = {}) {
137
161
  export function descriptorResponse(request, body, opts = {}) {
138
162
  const text = JSON.stringify(body);
139
163
  const etag = corpusEtag([text]);
140
- return notModified(request, etag) ?? new Response(text, { headers: descriptorHeaders(etag, opts) });
164
+ const sent = descriptorHeaders(etag, opts);
165
+ return notModified(request, etag, null, sent) ?? new Response(text, { headers: sent });
141
166
  }
142
167
  /** Strip URLs and collapse whitespace so an excerpt stays one readable line. */
143
168
  export function cleanSnippet(text, max = 160) {
@@ -172,7 +197,7 @@ export function pageLine(opts) {
172
197
  export function corpusRoute(validators, build, headers = textHeaders) {
173
198
  return async (request) => {
174
199
  const { etag, lastModified } = await validators();
175
- return (notModified(request, etag, lastModified) ??
176
- new Response(await build(), { headers: headers(etag, lastModified) }));
200
+ const sent = headers(etag, lastModified);
201
+ return notModified(request, etag, lastModified, sent) ?? new Response(await build(), { headers: sent });
177
202
  };
178
203
  }
package/dist/index.d.ts CHANGED
@@ -19,3 +19,4 @@ export * from './revisions.js';
19
19
  export * from './feed.js';
20
20
  export * from './license.js';
21
21
  export * from './seeded.js';
22
+ export * from './metadata.js';
package/dist/index.js CHANGED
@@ -24,3 +24,4 @@ export * from './revisions.js';
24
24
  export * from './feed.js';
25
25
  export * from './license.js';
26
26
  export * from './seeded.js';
27
+ export * from './metadata.js';
package/dist/license.d.ts CHANGED
@@ -43,3 +43,12 @@ export declare function licenseLines({ license, scope, scopeVerb, excludes, head
43
43
  export declare function licenseBlock(opts: LicenseBlockOptions): string;
44
44
  /** The one-line form, for a frontmatter field or a feed's `<copyright>`. */
45
45
  export declare function licenseNote(license: License): string;
46
+ /**
47
+ * The OpenAPI 3.1 `info.license` object. Name plus SPDX `identifier` — the
48
+ * spec makes `identifier` and `url` mutually exclusive, and the three specs
49
+ * here had each picked a different pair, one of them a hand-typed name.
50
+ */
51
+ export declare function openApiLicense(license: License): {
52
+ name: string;
53
+ identifier: string;
54
+ };
package/dist/license.js CHANGED
@@ -47,3 +47,11 @@ export function licenseBlock(opts) {
47
47
  export function licenseNote(license) {
48
48
  return `${license.name} (${license.spdx}): ${license.url}`;
49
49
  }
50
+ /**
51
+ * The OpenAPI 3.1 `info.license` object. Name plus SPDX `identifier` — the
52
+ * spec makes `identifier` and `url` mutually exclusive, and the three specs
53
+ * here had each picked a different pair, one of them a hand-typed name.
54
+ */
55
+ export function openApiLicense(license) {
56
+ return { name: license.name, identifier: license.spdx };
57
+ }
@@ -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.
@@ -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
- const yt = url.match(YOUTUBE_WATCH);
185
+ // Watch and embed URLs alike: an /embed/ page answers 200 for a deleted
186
+ // video, so a checker that probed embeds as plain URLs never saw one die.
187
+ const yt = url.match(YOUTUBE_WATCH) ?? url.match(YOUTUBE_EMBED);
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 `&amp;` 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 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
+ }
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 = {