wiki-formant 0.13.0 → 0.16.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 (137) hide show
  1. package/README.md +12 -25
  2. package/bin/check-classes.mjs +2 -3
  3. package/dist/analytics.d.ts +0 -1
  4. package/dist/analytics.js +0 -1
  5. package/dist/block-views.d.ts +97 -0
  6. package/dist/block-views.js +119 -0
  7. package/dist/blocks.d.ts +0 -1
  8. package/dist/blocks.js +1 -2
  9. package/dist/combobox.d.ts +0 -1
  10. package/dist/combobox.js +0 -1
  11. package/dist/conformance.d.ts +0 -3
  12. package/dist/conformance.js +40 -38
  13. package/dist/crawlers.d.ts +0 -1
  14. package/dist/crawlers.js +0 -1
  15. package/dist/dom.d.ts +0 -1
  16. package/dist/dom.js +0 -1
  17. package/dist/editor.d.ts +101 -0
  18. package/dist/editor.js +218 -0
  19. package/dist/feed.d.ts +0 -1
  20. package/dist/feed.js +0 -1
  21. package/dist/freshness.d.ts +0 -1
  22. package/dist/freshness.js +2 -2
  23. package/dist/headings.d.ts +0 -1
  24. package/dist/headings.js +1 -3
  25. package/dist/html.d.ts +16 -0
  26. package/dist/html.js +23 -0
  27. package/dist/http.d.ts +20 -1
  28. package/dist/http.js +23 -4
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +2 -2
  31. package/dist/license.d.ts +0 -1
  32. package/dist/license.js +0 -1
  33. package/dist/link-check.d.ts +92 -0
  34. package/dist/link-check.js +249 -0
  35. package/dist/links.d.ts +0 -1
  36. package/dist/links.js +1 -6
  37. package/dist/maps.d.ts +0 -1
  38. package/dist/maps.js +2 -2
  39. package/dist/markdown.d.ts +1 -1
  40. package/dist/markdown.js +34 -3
  41. package/dist/mcp.d.ts +17 -7
  42. package/dist/mcp.js +8 -8
  43. package/dist/pagination.d.ts +19 -1
  44. package/dist/pagination.js +21 -1
  45. package/dist/rate-limit.d.ts +18 -1
  46. package/dist/rate-limit.js +22 -1
  47. package/dist/react-server.d.ts +91 -9
  48. package/dist/react-server.js +68 -16
  49. package/dist/react.d.ts +37 -48
  50. package/dist/react.js +44 -23
  51. package/dist/revisions.d.ts +0 -1
  52. package/dist/revisions.js +0 -1
  53. package/dist/rola.d.ts +0 -1
  54. package/dist/rola.js +0 -1
  55. package/dist/seeded.d.ts +11 -0
  56. package/dist/seeded.js +32 -0
  57. package/dist/sidebar.d.ts +0 -1
  58. package/dist/sidebar.js +0 -1
  59. package/dist/taxonomy.d.ts +0 -1
  60. package/dist/taxonomy.js +0 -1
  61. package/dist/text.d.ts +14 -1
  62. package/dist/text.js +15 -2
  63. package/dist/tiptap.d.ts +1 -1
  64. package/dist/tiptap.js +1 -2
  65. package/dist/validation.d.ts +2 -3
  66. package/dist/validation.js +4 -5
  67. package/dist/versioning.d.ts +0 -1
  68. package/dist/versioning.js +0 -1
  69. package/dist/well-known.d.ts +19 -2
  70. package/dist/well-known.js +28 -2
  71. package/dist/x402.d.ts +2 -11
  72. package/dist/x402.js +4 -16
  73. package/package.json +52 -27
  74. package/dist/analytics.d.ts.map +0 -1
  75. package/dist/analytics.js.map +0 -1
  76. package/dist/blocks.d.ts.map +0 -1
  77. package/dist/blocks.js.map +0 -1
  78. package/dist/combobox.d.ts.map +0 -1
  79. package/dist/combobox.js.map +0 -1
  80. package/dist/conformance.d.ts.map +0 -1
  81. package/dist/conformance.js.map +0 -1
  82. package/dist/crawlers.d.ts.map +0 -1
  83. package/dist/crawlers.js.map +0 -1
  84. package/dist/dom.d.ts.map +0 -1
  85. package/dist/dom.js.map +0 -1
  86. package/dist/entities.d.ts +0 -3
  87. package/dist/entities.d.ts.map +0 -1
  88. package/dist/entities.js +0 -30
  89. package/dist/entities.js.map +0 -1
  90. package/dist/feed.d.ts.map +0 -1
  91. package/dist/feed.js.map +0 -1
  92. package/dist/freshness.d.ts.map +0 -1
  93. package/dist/freshness.js.map +0 -1
  94. package/dist/headings.d.ts.map +0 -1
  95. package/dist/headings.js.map +0 -1
  96. package/dist/http.d.ts.map +0 -1
  97. package/dist/http.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/license.d.ts.map +0 -1
  101. package/dist/license.js.map +0 -1
  102. package/dist/links.d.ts.map +0 -1
  103. package/dist/links.js.map +0 -1
  104. package/dist/maps.d.ts.map +0 -1
  105. package/dist/maps.js.map +0 -1
  106. package/dist/markdown.d.ts.map +0 -1
  107. package/dist/markdown.js.map +0 -1
  108. package/dist/mcp.d.ts.map +0 -1
  109. package/dist/mcp.js.map +0 -1
  110. package/dist/pagination.d.ts.map +0 -1
  111. package/dist/pagination.js.map +0 -1
  112. package/dist/rate-limit.d.ts.map +0 -1
  113. package/dist/rate-limit.js.map +0 -1
  114. package/dist/react-server.d.ts.map +0 -1
  115. package/dist/react-server.js.map +0 -1
  116. package/dist/react.d.ts.map +0 -1
  117. package/dist/react.js.map +0 -1
  118. package/dist/revisions.d.ts.map +0 -1
  119. package/dist/revisions.js.map +0 -1
  120. package/dist/rola.d.ts.map +0 -1
  121. package/dist/rola.js.map +0 -1
  122. package/dist/sidebar.d.ts.map +0 -1
  123. package/dist/sidebar.js.map +0 -1
  124. package/dist/taxonomy.d.ts.map +0 -1
  125. package/dist/taxonomy.js.map +0 -1
  126. package/dist/text.d.ts.map +0 -1
  127. package/dist/text.js.map +0 -1
  128. package/dist/tiptap.d.ts.map +0 -1
  129. package/dist/tiptap.js.map +0 -1
  130. package/dist/validation.d.ts.map +0 -1
  131. package/dist/validation.js.map +0 -1
  132. package/dist/versioning.d.ts.map +0 -1
  133. package/dist/versioning.js.map +0 -1
  134. package/dist/well-known.d.ts.map +0 -1
  135. package/dist/well-known.js.map +0 -1
  136. package/dist/x402.d.ts.map +0 -1
  137. package/dist/x402.js.map +0 -1
@@ -0,0 +1,249 @@
1
+ // link-check.ts — the dead-link probe both wikis' sweep scripts had written.
2
+ //
3
+ // Node-only (fetch, AbortSignal, URL). No framework, no database: what a
4
+ // checker does with the verdicts — which pages to walk, what counts as an
5
+ // internal path, how to report — stays with the caller, because that is the
6
+ // half that is genuinely per-wiki.
7
+ //
8
+ // EVERY GUARD BELOW WAS PAID FOR BY A FALSE POSITIVE, and the two copies had
9
+ // each learned a different half of the lesson. One knew that npmjs.com 403s
10
+ // scripted requests, that an expired certificate is not a dead host, and that a
11
+ // YouTube /embed/ URL answers 200 for a deleted video. The other knew that a
12
+ // connect refusal is usually concurrency rather than death, and that
13
+ // serialising per hostname is what fixes it. Neither copy was behind. A sweep
14
+ // running either one alone strips good citations for reasons the other repo had
15
+ // already written down — which is the entire argument for this file.
16
+ import { stripTags } from './html.js';
17
+ const DEFAULTS = { timeoutMs: 12_000, slowTimeoutMs: 40_000 };
18
+ /**
19
+ * TLS-verification failures are NOT death.
20
+ *
21
+ * A Let's Encrypt certificate on a DAO's own consultation platform — cited 20
22
+ * times across 7 pages — expired at 13:47 UTC, and every one of those citations
23
+ * started reading as `status: 0, "fetch failed"`, indistinguishable from a
24
+ * vanished host. Behind the interstitial the site answered 200. undici buries
25
+ * the reason in `err.cause`, so it is surfaced and labelled: a run that cannot
26
+ * tell an expired cert from a dead domain will eventually strip good citations
27
+ * over a lapsed renewal.
28
+ */
29
+ const TLS_CODES = new Set([
30
+ 'CERT_HAS_EXPIRED',
31
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
32
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
33
+ 'SELF_SIGNED_CERT_IN_CHAIN',
34
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
35
+ ]);
36
+ /**
37
+ * Codes that flap. A sweep found 16 status-0 rows and 15 were one host, every
38
+ * one answering 200 when probed alone. Carry the code through so a caller can
39
+ * treat these as retryable rather than banking a death.
40
+ */
41
+ export const TRANSIENT_CODES = new Set([
42
+ 'UND_ERR_CONNECT_TIMEOUT',
43
+ 'UND_ERR_SOCKET',
44
+ 'UND_ERR_HEADERS_TIMEOUT',
45
+ 'UND_ERR_BODY_TIMEOUT',
46
+ 'ECONNRESET',
47
+ 'ECONNREFUSED',
48
+ 'ETIMEDOUT',
49
+ 'EAI_AGAIN',
50
+ 'EPIPE',
51
+ ]);
52
+ /** A failure as a report row: the message, the code, and the TLS caveat. */
53
+ export function describeFailure(err) {
54
+ const e = err;
55
+ if (e?.name === 'AbortError')
56
+ return { error: 'timeout' };
57
+ const code = e?.cause?.code ?? e?.code;
58
+ const error = code ? `${e.message} (${code})` : (e?.message ?? 'fetch failed');
59
+ if (code && TLS_CODES.has(code)) {
60
+ return {
61
+ error,
62
+ code,
63
+ tls: code,
64
+ note: 'TLS verification failed — the host may well be serving fine behind the interstitial; confirm before touching the citation',
65
+ };
66
+ }
67
+ return code ? { error, code } : { error };
68
+ }
69
+ /**
70
+ * The URL to probe INSTEAD, where the public page lies about its own health.
71
+ *
72
+ * npmjs.com serves 403 to scripted requests whether or not the package exists,
73
+ * which made every package citation a permanent false positive. The registry
74
+ * answers honestly.
75
+ */
76
+ export function probeUrlFor(url) {
77
+ const npm = url.match(/^https:\/\/(?:www\.)?npmjs\.com\/package\/(.+?)\/?$/);
78
+ return npm ? `https://registry.npmjs.org/${npm[1]}` : url;
79
+ }
80
+ // ---- per-host serialisation -------------------------------------------------
81
+ const hostQueues = new Map();
82
+ /**
83
+ * Run `fn` with at most one request in flight against this hostname.
84
+ *
85
+ * ECONNREFUSED looks deterministic and is not. A delayed retry was tried first
86
+ * and made things worse — 269 to 277 broken, the refusals unchanged and 8 fresh
87
+ * timeouts on hosts that had been fine — because the problem is not timing, it
88
+ * is how many sockets one host is asked for at once. The global pool still runs
89
+ * wide across DIFFERENT hosts; any single host is probed one request at a time,
90
+ * exactly as a hand re-probe does it.
91
+ */
92
+ export function perHost(url, fn) {
93
+ let host;
94
+ try {
95
+ host = new URL(url).hostname;
96
+ }
97
+ catch {
98
+ return fn();
99
+ }
100
+ const prev = hostQueues.get(host) ?? Promise.resolve();
101
+ // `.then(fn, fn)` so one rejection does not poison the rest of the queue.
102
+ const next = prev.then(fn, fn);
103
+ hostQueues.set(host, next.catch(() => undefined));
104
+ return next;
105
+ }
106
+ async function attempt(url, budgetMs) {
107
+ // One signal for both requests, so the budget covers the HEAD *and* the GET
108
+ // rather than resetting between them. `engines.node` is >= 20, so this needs
109
+ // no controller and no `clearTimeout` in a `finally` that could be forgotten.
110
+ const signal = AbortSignal.timeout(budgetMs);
111
+ try {
112
+ let res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal });
113
+ // Some hosts reject HEAD outright; retry with GET before believing a 4xx.
114
+ if (res.status >= 400) {
115
+ res = await fetch(url, { method: 'GET', redirect: 'follow', signal });
116
+ }
117
+ return {
118
+ url,
119
+ status: res.status,
120
+ ok: res.status < 400,
121
+ contentType: (res.headers.get('content-type') || '').split(';')[0]?.trim(),
122
+ bytes: Number(res.headers.get('content-length') || 0),
123
+ };
124
+ }
125
+ catch (err) {
126
+ return { url, status: 0, ok: false, ...describeFailure(err) };
127
+ }
128
+ }
129
+ /**
130
+ * Probe one URL, honestly.
131
+ *
132
+ * A timeout is not a death and neither is a dropped connection: slow replays —
133
+ * web.archive.org above all, which a sweep leans on to rescue dying citations —
134
+ * exceed the ordinary budget and land as status 0. So a status-0 result with a
135
+ * transient code earns one more attempt on the longer budget before it is
136
+ * reported. A 404 does not: it is an answer.
137
+ */
138
+ export async function probeUrl(url, opts = {}) {
139
+ const { timeoutMs, slowTimeoutMs } = { ...DEFAULTS, ...opts };
140
+ const target = probeUrlFor(url);
141
+ const first = await perHost(target, () => attempt(target, timeoutMs));
142
+ const retryable = !first.ok && first.status === 0 && (!first.code || TRANSIENT_CODES.has(first.code));
143
+ if (!retryable)
144
+ return { ...first, url };
145
+ const second = await perHost(target, () => attempt(target, slowTimeoutMs));
146
+ return { ...second, url };
147
+ }
148
+ // ---- YouTube ----------------------------------------------------------------
149
+ export const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?(?:youtube-nocookie\.com|youtube\.com)\/embed\/([\w-]+)/;
150
+ /**
151
+ * The same video cited as a LINK rather than an iframe. `youtu.be/<id>` 303s and
152
+ * `youtube.com/watch?v=<id>` 200s for deleted and private videos alike, so an
153
+ * anchor carrying a dead video is invisible to a status check — 55 of them went
154
+ * unprobed corpus-wide until one sweep resolved them and found 7 unwatchable.
155
+ */
156
+ const YOUTUBE_WATCH = /^https?:\/\/(?:(?:www\.)?youtube\.com\/(?:watch\?(?:[^#]*&)?v=|shorts\/|live\/)|youtu\.be\/)([\w-]{6,})/;
157
+ /**
158
+ * A YouTube `/embed/<id>` URL answers 200 for private, deleted and
159
+ * playback-restricted videos alike, so HEADing it can never spot a dead hero
160
+ * video. oEmbed can:
161
+ *
162
+ * 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
163
+ */
164
+ export async function probeYouTube(videoId, opts = {}) {
165
+ const { timeoutMs } = { ...DEFAULTS, ...opts };
166
+ const oembed = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D${videoId}&format=json`;
167
+ try {
168
+ const res = await fetch(oembed, {
169
+ method: 'GET',
170
+ redirect: 'follow',
171
+ signal: AbortSignal.timeout(timeoutMs),
172
+ });
173
+ if (res.status === 404)
174
+ return { status: 404, ok: false, reason: 'deleted' };
175
+ if (res.status === 401 || res.status === 403) {
176
+ return { status: res.status, ok: false, reason: 'restricted (private or embedding disabled)' };
177
+ }
178
+ return { status: res.status, ok: res.status < 400 };
179
+ }
180
+ catch (err) {
181
+ return { status: 0, ok: false, ...describeFailure(err) };
182
+ }
183
+ }
184
+ /** An external anchor, with a video treated as a video. */
185
+ export async function probeExternal(url, opts = {}) {
186
+ const yt = url.match(YOUTUBE_WATCH);
187
+ if (yt?.[1])
188
+ return { url, videoId: yt[1], ...(await probeYouTube(yt[1], opts)) };
189
+ return probeUrl(url, opts);
190
+ }
191
+ // ---- unverifiable hosts -----------------------------------------------------
192
+ /**
193
+ * Hosts that answer 200 with a JavaScript loader shell regardless of whether
194
+ * the deck / store / dataset behind the query string still exists. A status
195
+ * check on these is meaningless, so a caller should report them as unverifiable
196
+ * rather than healthy — a green row is worse than an unknown one, because
197
+ * nobody looks at it again.
198
+ */
199
+ export function unverifiableReason(url, hosts) {
200
+ let host;
201
+ try {
202
+ host = new URL(url).hostname.replace(/^www\./, '');
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ for (const [h, reason] of hosts) {
208
+ if (host === h || host.endsWith(`.${h}`))
209
+ return reason;
210
+ }
211
+ return null;
212
+ }
213
+ // ---- extraction -------------------------------------------------------------
214
+ const LINK_RE = /<a\s+[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
215
+ const EMBED_RE = /<(iframe|img)\b[^>]*?\ssrc="([^"]*)"/gi;
216
+ /** Every anchor in a fragment, as `{ href, text }`. */
217
+ export function extractLinks(html) {
218
+ const out = [];
219
+ for (const m of html.matchAll(LINK_RE)) {
220
+ if (m[1])
221
+ out.push({ href: m[1], text: stripTags(m[2] ?? '') });
222
+ }
223
+ return out;
224
+ }
225
+ /** Every `<iframe>` and `<img>` source in a fragment. */
226
+ export function extractEmbeds(html) {
227
+ const out = [];
228
+ for (const m of html.matchAll(EMBED_RE)) {
229
+ if (m[1] && m[2])
230
+ out.push({ kind: m[1].toLowerCase(), url: m[2] });
231
+ }
232
+ return out;
233
+ }
234
+ // ---- concurrency ------------------------------------------------------------
235
+ /** `Promise.all` with a ceiling, preserving input order in the results. */
236
+ export async function mapLimit(items, limit, fn) {
237
+ const results = new Array(items.length);
238
+ let cursor = 0;
239
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
240
+ for (;;) {
241
+ const i = cursor++;
242
+ if (i >= items.length)
243
+ return;
244
+ results[i] = await fn(items[i], i);
245
+ }
246
+ });
247
+ await Promise.all(workers);
248
+ return results;
249
+ }
package/dist/links.d.ts CHANGED
@@ -28,4 +28,3 @@ export interface NormaliseLinkOptions {
28
28
  * nested `<a>` is not handled, being invalid HTML anyway.
29
29
  */
30
30
  export declare function normaliseLinks(html: string, options?: NormaliseLinkOptions): string;
31
- //# sourceMappingURL=links.d.ts.map
package/dist/links.js CHANGED
@@ -14,11 +14,7 @@
14
14
  // What they drifted on: only radix-wiki synthesised a label for an anchor with
15
15
  // no visible text. That is an accessibility fix, not a preference, so it is the
16
16
  // default here and caper gains it by adopting this module.
17
- /** Read one double-quoted attribute out of a tag's attribute string. */
18
- const getAttr = (attrs, name) => attrs.match(new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? null;
19
- /** Drop every occurrence of one attribute from a tag's attribute string. */
20
- const removeAttr = (attrs, name) => attrs.replace(new RegExp(`\\s${name}\\s*=\\s*"[^"]*"`, 'gi'), '');
21
- const stripTags = (s) => s.replace(/<[^>]*>/g, '').trim();
17
+ import { getAttr, removeAttr, stripTags } from './html.js';
22
18
  /**
23
19
  * A readable label for an anchor whose text is empty, derived from its href.
24
20
  * Without one, crawlers and screen readers meet a link with nothing in it.
@@ -78,4 +74,3 @@ export function normaliseLinks(html, options = {}) {
78
74
  const self = options.selfHost ? selfHostPattern(options.selfHost) : null;
79
75
  return html.replace(ANCHOR, (_m, attrs, inner) => normaliseAnchor(attrs, inner, options, self));
80
76
  }
81
- //# sourceMappingURL=links.js.map
package/dist/maps.d.ts CHANGED
@@ -18,4 +18,3 @@ export declare function extractCoordsFromUrl(url: string): MapCoords | null;
18
18
  export declare function toMapEmbedUrl(url: string): string | null;
19
19
  /** True for the shortener forms that only a redirect can resolve. */
20
20
  export declare const isShortMapUrl: (url: string) => boolean;
21
- //# sourceMappingURL=maps.d.ts.map
package/dist/maps.js CHANGED
@@ -23,7 +23,8 @@ export function extractCoordsFromUrl(url) {
23
23
  if (data)
24
24
  return { lat: +data[1], lon: +data[2] };
25
25
  try {
26
- const ll = new URL(url).searchParams.get('ll') ?? new URL(url).searchParams.get('sll');
26
+ const params = new URL(url).searchParams;
27
+ const ll = params.get('ll') ?? params.get('sll');
27
28
  if (ll) {
28
29
  const [lat, lon] = ll.split(',').map(Number);
29
30
  if (lat !== undefined && lon !== undefined && !isNaN(lat) && !isNaN(lon))
@@ -74,4 +75,3 @@ export function toMapEmbedUrl(url) {
74
75
  }
75
76
  /** True for the shortener forms that only a redirect can resolve. */
76
77
  export const isShortMapUrl = (url) => /maps\.app\.goo\.gl|goo\.gl\/maps/.test(url);
77
- //# sourceMappingURL=maps.js.map
@@ -1,3 +1,4 @@
1
+ export declare function decodeEntities(s: string): string;
1
2
  /** Inline-level HTML → markdown. Applied inside cells, list items, headings. */
2
3
  export declare function inlineToMarkdown(html: string): string;
3
4
  /** One `<table>` → a GFM table. Falls back to nothing when there are no rows. */
@@ -22,4 +23,3 @@ export interface FrontmatterFields {
22
23
  export declare function frontmatter(fields: FrontmatterFields): string;
23
24
  /** A complete markdown document: frontmatter, an H1, and a body you supply. */
24
25
  export declare function markdownDocument(fields: FrontmatterFields, body: string): string;
25
- //# sourceMappingURL=markdown.d.ts.map
package/dist/markdown.js CHANGED
@@ -9,7 +9,40 @@
9
9
  // so `blocksToMarkdown` belongs in the app. Give this module HTML and it gives
10
10
  // you markdown; give `frontmatter` the fields and it gives you the document
11
11
  // head. That is the whole portable half.
12
- import { decodeEntities } from './entities.js';
12
+ import { isoDate } from './html.js';
13
+ // ---- entity decoding --------------------------------------------------------
14
+ //
15
+ // Rich-text editors store typographic punctuation as named entities, so pages
16
+ // carry raw `&mdash;` / `&ldquo;` / `&rsquo;` into every text export — ampersand
17
+ // soup an agent has to read through. Numeric forms decode generically; the named
18
+ // table covers what wiki corpora actually use (punctuation, arrows, maths,
19
+ // accented Latin, Greek) rather than attempting the full HTML5 list.
20
+ //
21
+ // Lived in its own `entities.ts` with no subpath of its own, so the only way to
22
+ // reach `decodeEntities` was the root barrel — and the README claimed it came
23
+ // from here, which is where it now actually is.
24
+ const NAMED_ENTITIES = {
25
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
26
+ mdash: '—', ndash: '–', hellip: '…', middot: '·', bull: '•', sect: '§', para: '¶', dagger: '†',
27
+ ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', laquo: '«', raquo: '»',
28
+ larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔',
29
+ times: '×', divide: '÷', minus: '−', plusmn: '±', ne: '≠', le: '≤', ge: '≥',
30
+ asymp: '≈', radic: '√', infin: '∞', sum: '∑', prod: '∏', int: '∫', deg: '°', permil: '‰',
31
+ sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾',
32
+ euro: '€', pound: '£', yen: '¥', cent: '¢', copy: '©', reg: '®', trade: '™',
33
+ eacute: 'é', egrave: 'è', ecirc: 'ê', aacute: 'á', agrave: 'à', acirc: 'â', aring: 'å',
34
+ auml: 'ä', ouml: 'ö', uuml: 'ü', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ',
35
+ ccedil: 'ç', oslash: 'ø', szlig: 'ß', aelig: 'æ',
36
+ alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', theta: 'θ', lambda: 'λ',
37
+ mu: 'μ', nu: 'ν', pi: 'π', rho: 'ρ', sigma: 'σ', tau: 'τ', phi: 'φ', omega: 'ω',
38
+ Delta: 'Δ', Sigma: 'Σ', Omega: 'Ω', Lambda: 'Λ', Phi: 'Φ', Pi: 'Π',
39
+ };
40
+ export function decodeEntities(s) {
41
+ return s
42
+ .replace(/&#x([0-9a-fA-F]{1,6});/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
43
+ .replace(/&#(\d{1,7});/g, (_m, dec) => String.fromCodePoint(Number(dec)))
44
+ .replace(/&([a-zA-Z][a-zA-Z0-9]{1,9});/g, (m, name) => NAMED_ENTITIES[name] ?? m);
45
+ }
13
46
  /** Inline-level HTML → markdown. Applied inside cells, list items, headings. */
14
47
  export function inlineToMarkdown(html) {
15
48
  return decodeEntities(html
@@ -81,7 +114,6 @@ export function htmlToMarkdown(html) {
81
114
  .replace(/\n{3,}/g, '\n\n')
82
115
  .trim();
83
116
  }
84
- const isoDate = (d) => (typeof d === 'string' ? d : d.toISOString()).split('T')[0];
85
117
  /** YAML frontmatter block, `---` fences included. */
86
118
  export function frontmatter(fields) {
87
119
  const esc = (s) => s.replace(/"/g, '\\"');
@@ -104,4 +136,3 @@ export function frontmatter(fields) {
104
136
  export function markdownDocument(fields, body) {
105
137
  return `${frontmatter(fields)}\n\n# ${fields.title}\n\n${body.trim()}\n`;
106
138
  }
107
- //# sourceMappingURL=markdown.js.map
package/dist/mcp.d.ts CHANGED
@@ -42,12 +42,6 @@ export type ToolSchema = {
42
42
  * calls has no way to tell a lookup from one that reaches a payment processor.
43
43
  */
44
44
  export type ToolAnnotations = {
45
- /**
46
- * Human-facing label — the pre-2025-06-18 slot. `tools/list` hoists it to
47
- * the top-level `title` when that is absent, so a manifest written against
48
- * either revision renders the same in a client. Prefer `McpTool.title`.
49
- */
50
- title?: string;
51
45
  /** Does not modify anything. */
52
46
  readOnlyHint?: boolean;
53
47
  /** May destroy or overwrite state (only meaningful when not read-only). */
@@ -192,6 +186,23 @@ export type RpcRequest = {
192
186
  method: string;
193
187
  params?: unknown;
194
188
  };
189
+ /** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
190
+ export declare const DEFAULT_MAX_BATCH = 20;
191
+ /**
192
+ * A tool result envelope. Exported because `x402.ts` builds the same shape when
193
+ * it withholds a paid call, and had grown two more copies of it doing so.
194
+ */
195
+ export declare const toolText: (id: RpcRequest["id"], text: string, isError?: boolean, extra?: Record<string, unknown>) => {
196
+ jsonrpc: "2.0";
197
+ id: string | number | null;
198
+ result: {
199
+ isError?: boolean | undefined;
200
+ content: {
201
+ type: string;
202
+ text: string;
203
+ }[];
204
+ };
205
+ };
195
206
  /** Everything the dispatcher needs that is not in the JSON-RPC entry itself. */
196
207
  interface Dispatch {
197
208
  protocolVersion: McpProtocolVersion;
@@ -222,4 +233,3 @@ export declare function mcpRateLimited(retryAfterSec: number, message?: string):
222
233
  /** The whole POST leg: parse, gate, track, dispatch, and answer with the right status. */
223
234
  export declare function mcpResponse(request: Request, config: McpServerConfig): Promise<Response>;
224
235
  export {};
225
- //# sourceMappingURL=mcp.d.ts.map
package/dist/mcp.js CHANGED
@@ -52,7 +52,8 @@ export class McpToolError extends Error {
52
52
  this.name = 'McpToolError';
53
53
  }
54
54
  }
55
- const DEFAULT_MAX_BATCH = 20;
55
+ /** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
56
+ export const DEFAULT_MAX_BATCH = 20;
56
57
  const quote = (list) => list.map(s => `"${s}"`).join(', ');
57
58
  function typeOf(v) {
58
59
  if (Array.isArray(v))
@@ -66,7 +67,11 @@ const rpcError = (id, code, message, data) => ({
66
67
  id,
67
68
  error: { code, message, ...(data ? { data } : {}) },
68
69
  });
69
- const toolText = (id, text, isError = false, extra = {}) => ({
70
+ /**
71
+ * A tool result envelope. Exported because `x402.ts` builds the same shape when
72
+ * it withholds a paid call, and had grown two more copies of it doing so.
73
+ */
74
+ export const toolText = (id, text, isError = false, extra = {}) => ({
70
75
  jsonrpc: '2.0',
71
76
  id,
72
77
  result: { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}), ...extra },
@@ -188,11 +193,7 @@ async function handleRpc(req, config, dispatch) {
188
193
  result: {
189
194
  tools: config.tools.map(({ name, title, description, inputSchema, outputSchema, annotations }) => ({
190
195
  name,
191
- // 2025-06-18 promoted `title` out of `annotations`. Hoisting
192
- // rather than requiring every manifest to be rewritten: a
193
- // client reading only the new slot showed a raw tool name for
194
- // every surface that had not moved its label yet.
195
- ...(title ?? annotations?.title ? { title: title ?? annotations.title } : {}),
196
+ ...(title ? { title } : {}),
196
197
  description,
197
198
  inputSchema,
198
199
  ...(outputSchema ? { outputSchema } : {}),
@@ -433,4 +434,3 @@ export async function mcpResponse(request, config) {
433
434
  }
434
435
  return Response.json(result, { headers });
435
436
  }
436
- //# sourceMappingURL=mcp.js.map
@@ -49,4 +49,22 @@ export declare function toOffset({ page, pageSize }: Pagination): {
49
49
  skip: number;
50
50
  take: number;
51
51
  };
52
- //# sourceMappingURL=pagination.d.ts.map
52
+ /**
53
+ * The entries either side of the current one in an already-ordered list.
54
+ *
55
+ * The ordering is the caller's, deliberately — it is the one thing here that is
56
+ * never portable. A wiki's sequence is its section's configured sort, a
57
+ * knowledge base's is a taxonomy walk, and a company log's is `updatedAt` desc.
58
+ * What both wikis had written twice is this scan, not the sort.
59
+ *
60
+ * `null` on both sides when the page is not in the list, so a page reached by a
61
+ * URL its own section does not list renders no nav rather than a wrong one.
62
+ *
63
+ * No query. Both callers already hold the ordered siblings for something else —
64
+ * a related-pages panel, a section listing — and the two indexed lookups the
65
+ * neighbours used to cost were the reason one wiki dropped the control.
66
+ */
67
+ export declare function adjacentPages<T>(ordered: readonly T[], isCurrent: (page: T) => boolean): {
68
+ prev: T | null;
69
+ next: T | null;
70
+ };
@@ -56,4 +56,24 @@ export function listEnvelope(pages, total, page, pageSize, emptyNote) {
56
56
  export function toOffset({ page, pageSize }) {
57
57
  return { skip: (page - 1) * pageSize, take: pageSize };
58
58
  }
59
- //# sourceMappingURL=pagination.js.map
59
+ /**
60
+ * The entries either side of the current one in an already-ordered list.
61
+ *
62
+ * The ordering is the caller's, deliberately — it is the one thing here that is
63
+ * never portable. A wiki's sequence is its section's configured sort, a
64
+ * knowledge base's is a taxonomy walk, and a company log's is `updatedAt` desc.
65
+ * What both wikis had written twice is this scan, not the sort.
66
+ *
67
+ * `null` on both sides when the page is not in the list, so a page reached by a
68
+ * URL its own section does not list renders no nav rather than a wrong one.
69
+ *
70
+ * No query. Both callers already hold the ordered siblings for something else —
71
+ * a related-pages panel, a section listing — and the two indexed lookups the
72
+ * neighbours used to cost were the reason one wiki dropped the control.
73
+ */
74
+ export function adjacentPages(ordered, isCurrent) {
75
+ const i = ordered.findIndex(isCurrent);
76
+ if (i < 0)
77
+ return { prev: null, next: null };
78
+ return { prev: ordered[i - 1] ?? null, next: ordered[i + 1] ?? null };
79
+ }
@@ -63,4 +63,21 @@ export declare function rateLimitHeaders(result: RateLimitResult, opts: RateLimi
63
63
  export declare function withRateLimit<T extends Response>(res: T, result: RateLimitResult, opts: RateLimitOptions): T;
64
64
  /** Test seam: the bucket map is module state and outlives a single test. */
65
65
  export declare function resetRateLimits(): void;
66
- //# sourceMappingURL=rate-limit.d.ts.map
66
+ /**
67
+ * The one MCP rate-limit number, for every surface that states one.
68
+ *
69
+ * `.well-known/mcp.json`, the OpenAPI spec, agents.md, llms.txt and the
70
+ * `initialize` instructions all quote this, so the endpoint enforces exactly
71
+ * what the documents claim. It is a cross-surface contract, which is why it
72
+ * lives here rather than three times over: it had already drifted to 200 on one
73
+ * surface with nothing recorded about why, and that is the state that makes a
74
+ * number impossible to change safely later — nobody can tell a considered
75
+ * difference from a stale one.
76
+ *
77
+ * A surface with a genuine reason to differ passes its own `RateLimitOptions`.
78
+ * What it must not do is restate this one.
79
+ */
80
+ export declare const MCP_RATE_LIMIT_PER_MIN = 60;
81
+ export declare const MCP_RATE_LIMIT_TEXT = "60 requests per minute per IP";
82
+ /** The budget as `mcpResponse` takes it, so a route states it once. */
83
+ export declare const MCP_RATE_LIMIT: RateLimitOptions;
@@ -110,4 +110,25 @@ export function withRateLimit(res, result, opts) {
110
110
  export function resetRateLimits() {
111
111
  buckets.clear();
112
112
  }
113
- //# sourceMappingURL=rate-limit.js.map
113
+ // ---- the MCP budget ---------------------------------------------------------
114
+ /**
115
+ * The one MCP rate-limit number, for every surface that states one.
116
+ *
117
+ * `.well-known/mcp.json`, the OpenAPI spec, agents.md, llms.txt and the
118
+ * `initialize` instructions all quote this, so the endpoint enforces exactly
119
+ * what the documents claim. It is a cross-surface contract, which is why it
120
+ * lives here rather than three times over: it had already drifted to 200 on one
121
+ * surface with nothing recorded about why, and that is the state that makes a
122
+ * number impossible to change safely later — nobody can tell a considered
123
+ * difference from a stale one.
124
+ *
125
+ * A surface with a genuine reason to differ passes its own `RateLimitOptions`.
126
+ * What it must not do is restate this one.
127
+ */
128
+ export const MCP_RATE_LIMIT_PER_MIN = 60;
129
+ export const MCP_RATE_LIMIT_TEXT = `${MCP_RATE_LIMIT_PER_MIN} requests per minute per IP`;
130
+ /** The budget as `mcpResponse` takes it, so a route states it once. */
131
+ export const MCP_RATE_LIMIT = {
132
+ capacity: MCP_RATE_LIMIT_PER_MIN,
133
+ refillPerSec: MCP_RATE_LIMIT_PER_MIN / 60,
134
+ };