wiki-formant 0.17.1 → 0.19.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/dist/mcp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // mcp.ts — a minimal Model Context Protocol server over Streamable HTTP
2
- // (JSON-RPC). Spec: https://modelcontextprotocol.io/specification/2025-06-18
2
+ // (JSON-RPC), serving both protocol eras from one endpoint.
3
+ // Spec: https://modelcontextprotocol.io/specification/2026-07-28
3
4
  //
4
5
  // Web-standard `Request`/`Response` only, so this runs unchanged on Next route
5
6
  // handlers (NextResponse extends Response), Hono, Bun, Deno and workers.
@@ -13,31 +14,58 @@
13
14
  // A caller-fixable mistake is a tool result with `isError`, never a -32603.
14
15
  // That split is the one most implementations get wrong.
15
16
  /**
16
- * The versions this transport speaks, newest first.
17
+ * The modern era: every request carries its own version and client
18
+ * capabilities in `params._meta`. No handshake, no session, no `ping`, no batch.
19
+ */
20
+ export const MCP_MODERN_VERSIONS = ['2026-07-28'];
21
+ /**
22
+ * The legacy era, negotiated by an `initialize` handshake.
17
23
  *
18
- * This was a constant answered unconditionally, which is legal and still wrong:
19
- * a client asking for 2025-06-18 was told 2025-03-26 and silently gave up tool
20
- * titles, structured output and `_meta`. The last one is where a payment
21
- * receipt rides, so the one surface that needed it rebuilt this module's HTTP
22
- * shell by hand to reach it.
24
+ * Served beside the modern era rather than replaced by it. The spec lets one
25
+ * endpoint speak both, and a legacy client meeting a dual-era server works;
26
+ * going modern-only would fail the handshake of every client in the field.
27
+ */
28
+ export const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
29
+ /**
30
+ * Every version this transport speaks, newest first. It is the `supported` list
31
+ * a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
32
+ * client that cannot use the newest still learns a legacy fallback exists.
23
33
  */
24
- export const MCP_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
25
- /** The newest version spoken here, and what an unrecognised ask falls back to. */
34
+ export const MCP_PROTOCOL_VERSIONS = [...MCP_MODERN_VERSIONS, ...MCP_LEGACY_VERSIONS];
35
+ /** The newest version spoken here. */
26
36
  export const MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0];
27
- /** What a request carrying no `MCP-Protocol-Version` header means, per the spec. */
37
+ /**
38
+ * The newest version `initialize` can negotiate, and what an unrecognised ask is
39
+ * offered. A handshake cannot land on a modern version — the modern era has no
40
+ * handshake — so this, not `MCP_PROTOCOL_VERSION`, is what a legacy client gets.
41
+ */
42
+ export const MCP_LEGACY_PROTOCOL_VERSION = MCP_LEGACY_VERSIONS[0];
43
+ /** The reserved `_meta` keys the modern era is spelled in. */
44
+ export const MCP_META = {
45
+ protocolVersion: 'io.modelcontextprotocol/protocolVersion',
46
+ clientCapabilities: 'io.modelcontextprotocol/clientCapabilities',
47
+ serverInfo: 'io.modelcontextprotocol/serverInfo',
48
+ };
49
+ /** What a legacy request carrying no `MCP-Protocol-Version` header means, per the spec. */
28
50
  const ASSUMED_VERSION = '2025-03-26';
29
- const speaks = (v) => MCP_PROTOCOL_VERSIONS.includes(v);
30
- /** Echo the client's version when it is one we speak, else offer the newest. */
31
- export function negotiateProtocol(requested) {
32
- return speaks(requested) ? requested : MCP_PROTOCOL_VERSION;
51
+ const member = (list) => (v) => list.includes(v);
52
+ const speaksLegacy = member(MCP_LEGACY_VERSIONS);
53
+ const speaksModern = member(MCP_MODERN_VERSIONS);
54
+ /**
55
+ * Echo the client's version when it is one we speak, else offer the newest.
56
+ * Answering a constant instead is legal and still wrong: it silently held every
57
+ * caller below the version that carries structured output and `_meta`.
58
+ */
59
+ function negotiateProtocol(requested) {
60
+ return speaksLegacy(requested) ? requested : MCP_LEGACY_PROTOCOL_VERSION;
33
61
  }
34
62
  /** The version a post-initialize request is operating under. */
35
- export function requestProtocol(request) {
63
+ function requestProtocol(request) {
36
64
  const header = request.headers.get('mcp-protocol-version');
37
- return speaks(header) ? header : ASSUMED_VERSION;
65
+ return speaksLegacy(header) ? header : ASSUMED_VERSION;
38
66
  }
39
- /** JSON-RPC batching was removed in 2025-06-18; it stays legal below that. */
40
- const allowsBatch = (v) => v !== '2025-06-18';
67
+ /** JSON-RPC batching was removed in 2025-06-18 and stays removed; it is legal only below that. */
68
+ const allowsBatch = (v) => v === '2025-03-26' || v === '2024-11-05';
41
69
  import { clientKey, rateLimit, rateLimitHeaders, withRateLimit } from './rate-limit.js';
42
70
  /**
43
71
  * A caller-fixable failure inside a handler (page not found, empty input).
@@ -137,15 +165,19 @@ function validateArgs(tool, args) {
137
165
  `Expected schema: ${JSON.stringify(tool.inputSchema)}`,
138
166
  ].join('\n');
139
167
  }
140
- const BASE_METHODS = ['initialize', 'ping', 'tools/list', 'tools/call'];
141
- function methodsFor(config) {
168
+ /** The methods one era has and the other does not. Everything else is shared. */
169
+ const ERA_METHODS = {
170
+ legacy: ['initialize', 'ping'],
171
+ modern: ['server/discover'],
172
+ };
173
+ function methodsFor(config, era) {
142
174
  return [
143
- ...BASE_METHODS,
175
+ ...ERA_METHODS[era],
176
+ 'tools/list',
177
+ 'tools/call',
144
178
  // The list methods answer whether or not anything is registered: an empty
145
179
  // list is a better answer to a client that asked than a -32601 it has to
146
- // interpret. This enumeration used to omit them while the dispatch below
147
- // answered them, so the error message contradicted the server describing
148
- // itself.
180
+ // interpret.
149
181
  'resources/list',
150
182
  'resources/templates/list',
151
183
  'prompts/list',
@@ -153,11 +185,30 @@ function methodsFor(config) {
153
185
  ...(config.prompts?.length ? ['prompts/get'] : []),
154
186
  ];
155
187
  }
188
+ const methodNotFound = (id, method, config, era) => {
189
+ const methods = methodsFor(config, era);
190
+ return rpcError(id, -32601, `Method not found: "${method}". This server implements: ${quote(methods)}.`, { supportedMethods: methods });
191
+ };
192
+ /**
193
+ * Only the capabilities the config actually populates — an advertised
194
+ * `resources` whose list comes back empty reads as a bug to a client, not as
195
+ * honesty. One function, so `initialize` and `server/discover` cannot disagree.
196
+ */
197
+ const capabilitiesOf = (config) => ({
198
+ tools: {},
199
+ ...(config.resources?.length ? { resources: {} } : {}),
200
+ ...(config.prompts?.length ? { prompts: {} } : {}),
201
+ });
156
202
  async function handleRpc(req, config, dispatch) {
157
203
  const { id, method, params } = req;
158
204
  const p = (params ?? {});
159
205
  const resources = config.resources ?? [];
160
206
  const prompts = config.prompts ?? [];
207
+ // A method the other era owns is not found in this one, however well the
208
+ // server knows it: a modern `ping` is a removed method, not a pong.
209
+ const foreign = ERA_METHODS[dispatch.era === 'modern' ? 'legacy' : 'modern'];
210
+ if (foreign.includes(method))
211
+ return methodNotFound(id, method, config, dispatch.era);
161
212
  try {
162
213
  switch (method) {
163
214
  case 'initialize':
@@ -165,21 +216,24 @@ async function handleRpc(req, config, dispatch) {
165
216
  jsonrpc: '2.0',
166
217
  id,
167
218
  result: {
168
- // Echo what the client asked for when we speak it. Answering a
169
- // constant is what silently held every caller at 2025-03-26.
170
219
  protocolVersion: negotiateProtocol(p.protocolVersion),
171
- // Only advertise capabilities the config actually populates — an
172
- // advertised `resources` whose list comes back empty reads as a bug
173
- // to a client, not as honesty.
174
- capabilities: {
175
- tools: {},
176
- ...(resources.length ? { resources: {} } : {}),
177
- ...(prompts.length ? { prompts: {} } : {}),
178
- },
220
+ capabilities: capabilitiesOf(config),
179
221
  serverInfo: config.serverInfo,
180
222
  instructions: config.instructions,
181
223
  },
182
224
  };
225
+ // What `initialize` tells a legacy client, asked the modern way. The
226
+ // transport adds `resultType`, the cache hints and `_meta` serverInfo.
227
+ case 'server/discover':
228
+ return {
229
+ jsonrpc: '2.0',
230
+ id,
231
+ result: {
232
+ supportedVersions: [...MCP_PROTOCOL_VERSIONS],
233
+ capabilities: capabilitiesOf(config),
234
+ instructions: config.instructions,
235
+ },
236
+ };
183
237
  // Notifications have no response at all.
184
238
  case 'notifications/initialized':
185
239
  case 'notifications/cancelled':
@@ -286,13 +340,12 @@ async function handleRpc(req, config, dispatch) {
286
340
  try {
287
341
  const data = await tool.handler(args, ctx);
288
342
  const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
289
- // Every object answer, not only the schema-bearing ones. Gating this
290
- // on `outputSchema` meant that across four live servers and thirty-two
291
- // tools — every one of them returning JSON — not a single result ever
292
- // carried `structuredContent`, and every agent parsed prose to reach
293
- // data the server already had in hand. A declared `outputSchema` is
294
- // still the stronger contract (a client validates against it); it is
295
- // no longer the price of admission. The text block stays regardless:
343
+ // Every object answer, not only the schema-bearing ones. Gated on
344
+ // `outputSchema`, a JSON-returning tool without one would carry no
345
+ // `structuredContent`, and agents would parse prose to reach data the
346
+ // server has in hand. A declared `outputSchema` is the stronger
347
+ // contract (a client validates against it), not the price of
348
+ // admission. The text block stays regardless:
296
349
  // the spec asks for the serialised twin, and a client that reads only
297
350
  // content still has to be able to read the answer.
298
351
  const structured = data !== null && typeof data === 'object' && !Array.isArray(data)
@@ -309,10 +362,8 @@ async function handleRpc(req, config, dispatch) {
309
362
  return toolText(id, JSON.stringify({ error: err.message, ...err.details }, null, 2), true);
310
363
  }
311
364
  }
312
- default: {
313
- const methods = methodsFor(config);
314
- return rpcError(id, -32601, `Method not found: "${method}". This server implements: ${quote(methods)}.`, { supportedMethods: methods });
315
- }
365
+ default:
366
+ return methodNotFound(id, method, config, dispatch.era);
316
367
  }
317
368
  }
318
369
  catch (err) {
@@ -321,7 +372,7 @@ async function handleRpc(req, config, dispatch) {
321
372
  }
322
373
  }
323
374
  /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */
324
- export async function handleMcp(body, config, dispatch = { protocolVersion: ASSUMED_VERSION }) {
375
+ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSUMED_VERSION, era: 'legacy' }) {
325
376
  const maxBatch = config.maxBatch ?? DEFAULT_MAX_BATCH;
326
377
  const isBatch = Array.isArray(body);
327
378
  if (isBatch && body.length > maxBatch) {
@@ -340,12 +391,13 @@ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSU
340
391
  // the spec's Origin-validation MUST exists to protect localhost servers from
341
392
  // DNS rebinding, which is the opposite situation. The allow-headers list
342
393
  // matters more than it looks — MCP clients preflight with `Accept` and
343
- // `Mcp-Protocol-Version`, and one missing entry fails the preflight, not the
344
- // POST, which reads as "the server is down".
394
+ // `Mcp-Protocol-Version` — and a modern client adds `Mcp-Method` and `Mcp-Name`
395
+ // on every call — and one missing entry fails the preflight, not the POST,
396
+ // which reads as "the server is down".
345
397
  export const MCP_CORS = {
346
398
  'Access-Control-Allow-Origin': '*',
347
399
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
348
- 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID',
400
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID',
349
401
  // Allow-Headers governs what a browser may send; without Expose-Headers it
350
402
  // may read none of what comes back. A browser client could not see the
351
403
  // negotiated version, and could not see `Retry-After` on the 429 telling it
@@ -389,7 +441,6 @@ export function mcpRateLimited(retryAfterSec, message) {
389
441
  }
390
442
  /** The whole POST leg: parse, gate, track, dispatch, and answer with the right status. */
391
443
  export async function mcpResponse(request, config) {
392
- const protocolVersion = requestProtocol(request);
393
444
  // Before `request.json()`: an unparsed body must not cost a query, which is
394
445
  // also why the refusal carries a null id.
395
446
  let headroom = {};
@@ -400,12 +451,10 @@ export async function mcpResponse(request, config) {
400
451
  }
401
452
  headroom = rateLimitHeaders(verdict, config.rateLimit);
402
453
  }
403
- // Echoed on every response so a client can see which version it is actually
404
- // being answered under, rather than inferring it from the initialize it sent
405
- // some requests ago. The headroom rides alongside on every answer, not only
406
- // on the 429 — a budget discoverable only by exceeding it is one an agent
407
- // meets when it is least able to act on it.
408
- const headers = { ...MCP_CORS, 'MCP-Protocol-Version': protocolVersion, ...headroom };
454
+ // The headroom rides on every answer, not only on the 429 — a budget
455
+ // discoverable only by exceeding it is one an agent meets when it is least
456
+ // able to act on it.
457
+ const base = { ...MCP_CORS, ...headroom };
409
458
  let body;
410
459
  try {
411
460
  body = await request.json();
@@ -413,20 +462,49 @@ export async function mcpResponse(request, config) {
413
462
  catch {
414
463
  return Response.json(rpcError(null, -32700, 'Parse error: request body is not valid JSON.'), {
415
464
  status: 400,
416
- headers,
465
+ headers: { ...base, 'MCP-Protocol-Version': requestProtocol(request) },
417
466
  });
418
467
  }
468
+ // Tracked before either era refuses anything: a probe the server turns away,
469
+ // and a call an agent walked away from behind a gate, are exactly the ones
470
+ // worth counting.
471
+ config.onCall?.(request, body);
472
+ return isModern(request, body)
473
+ ? modernResponse(request, body, config, base)
474
+ : legacyResponse(request, body, config, base);
475
+ }
476
+ /**
477
+ * Which era a POST belongs to. The spec's rule: modern `_meta` selects the modern
478
+ * era and `initialize` selects the legacy one. A modern version header and
479
+ * `server/discover` can only mean one thing too. Anything else is legacy,
480
+ * because a body without `_meta` is exactly what every client in the field sends.
481
+ */
482
+ function isModern(request, body) {
483
+ if (!body || typeof body !== 'object' || Array.isArray(body))
484
+ return false;
485
+ const { method, params } = body;
486
+ if (method === 'initialize')
487
+ return false;
488
+ const meta = params?._meta;
489
+ return ((!!meta && typeof meta === 'object' && MCP_META.protocolVersion in meta) ||
490
+ method === 'server/discover' ||
491
+ speaksModern(request.headers.get('mcp-protocol-version')));
492
+ }
493
+ async function dispatchThroughGate(body, config, dispatch) {
494
+ const gate = await config.gate?.(body);
495
+ const dispatched = gate?.empty ? null : await handleMcp(gate ? gate.body : body, config, dispatch);
496
+ return gate ? gate.finish(dispatched) : dispatched;
497
+ }
498
+ async function legacyResponse(request, body, config, base) {
499
+ const protocolVersion = requestProtocol(request);
500
+ // Echoed on every response so a client can see which version it is actually
501
+ // being answered under, rather than inferring it from the initialize it sent
502
+ // some requests ago.
503
+ const headers = { ...base, 'MCP-Protocol-Version': protocolVersion };
419
504
  if (Array.isArray(body) && !allowsBatch(protocolVersion)) {
420
505
  return Response.json(rpcError(null, -32600, `JSON-RPC batching was removed in MCP ${protocolVersion}. Send one request per POST, or negotiate ${ASSUMED_VERSION} to keep batching.`), { status: 400, headers });
421
506
  }
422
- // Tracked on the original body, before a gate withholds anything: a call an
423
- // agent walked away from is exactly the one worth counting.
424
- config.onCall?.(request, body);
425
- const gate = await config.gate?.(body);
426
- const dispatched = gate?.empty
427
- ? null
428
- : await handleMcp(gate ? gate.body : body, config, { protocolVersion, request });
429
- const result = gate ? await gate.finish(dispatched) : dispatched;
507
+ const result = await dispatchThroughGate(body, config, { protocolVersion, era: 'legacy', request });
430
508
  // Notification-only input produces no response bodies; the spec requires a
431
509
  // bare 202 there, not a 200 carrying a JSON `null`.
432
510
  if (result == null || (Array.isArray(result) && !result.length)) {
@@ -434,3 +512,125 @@ export async function mcpResponse(request, config) {
434
512
  }
435
513
  return Response.json(result, { headers });
436
514
  }
515
+ /** The modern era maps a protocol error to an HTTP status; the legacy era answered 200. */
516
+ const MODERN_STATUS = {
517
+ [-32601]: 404,
518
+ [-32602]: 400,
519
+ [-32020]: 400,
520
+ [-32021]: 400,
521
+ [-32022]: 400,
522
+ };
523
+ /** The results a modern client may cache. A call or a prompt is never among them. */
524
+ const CACHEABLE = new Set([
525
+ 'server/discover',
526
+ 'tools/list',
527
+ 'prompts/list',
528
+ 'resources/list',
529
+ 'resources/templates/list',
530
+ 'resources/read',
531
+ ]);
532
+ const DEFAULT_CACHE_TTL_MS = 300_000;
533
+ /** `Mcp-Name` carries the target of the three calls that have one, for a gateway to route on. */
534
+ const NAME_PARAM = {
535
+ 'tools/call': 'name',
536
+ 'prompts/get': 'name',
537
+ 'resources/read': 'uri',
538
+ };
539
+ /** A header value, decoded from the `=?base64?…?=` form a client uses for non-ASCII. */
540
+ function headerValue(raw) {
541
+ const encoded = raw?.match(/^=\?base64\?(.*)\?=$/i)?.[1];
542
+ if (encoded === undefined)
543
+ return raw;
544
+ try {
545
+ return new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0)));
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ }
551
+ /** The first routing header that disagrees with the body, described. */
552
+ function headerMismatch(request, method, params, version) {
553
+ const expected = [
554
+ ['MCP-Protocol-Version', version],
555
+ ['Mcp-Method', method],
556
+ ];
557
+ const nameKey = NAME_PARAM[method];
558
+ // A call missing its name is reported by the dispatcher, which can list the names.
559
+ if (nameKey && typeof params[nameKey] === 'string')
560
+ expected.push(['Mcp-Name', params[nameKey]]);
561
+ for (const [name, want] of expected) {
562
+ const got = headerValue(request.headers.get(name));
563
+ if (got !== want) {
564
+ return `Header ${name} must equal ${JSON.stringify(want)}; received ${got === null ? 'none' : JSON.stringify(got)}.`;
565
+ }
566
+ }
567
+ return null;
568
+ }
569
+ /**
570
+ * A modern result: `resultType` on every one, cache hints on the cacheable ones,
571
+ * and serverInfo in `_meta` now that no handshake carries it. Applied after the
572
+ * gate, so an answer a gate builds in place of a call is shaped the same way.
573
+ */
574
+ function modernResult(response, method, config) {
575
+ if (!('result' in response))
576
+ return response;
577
+ const result = response.result;
578
+ return {
579
+ ...response,
580
+ result: {
581
+ resultType: 'complete',
582
+ ...result,
583
+ ...(CACHEABLE.has(method)
584
+ ? {
585
+ ttlMs: config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
586
+ // Nothing here varies by caller, except a read a gate may have
587
+ // charged for, which a shared cache must not hand to the next one.
588
+ cacheScope: method === 'resources/read' ? 'private' : 'public',
589
+ }
590
+ : {}),
591
+ _meta: { ...result._meta, [MCP_META.serverInfo]: config.serverInfo },
592
+ },
593
+ };
594
+ }
595
+ async function modernResponse(request, body, config, base) {
596
+ const params = (body.params ?? {});
597
+ const meta = (params._meta ?? {});
598
+ const requested = meta[MCP_META.protocolVersion];
599
+ const headers = {
600
+ ...base,
601
+ 'MCP-Protocol-Version': speaksModern(requested) ? requested : MCP_MODERN_VERSIONS[0],
602
+ };
603
+ const refuse = (code, message, data) => Response.json(rpcError(body.id ?? null, code, message, data), {
604
+ status: MODERN_STATUS[code] ?? 400,
605
+ headers,
606
+ });
607
+ if (typeof requested !== 'string') {
608
+ return refuse(-32602, `Missing params._meta["${MCP_META.protocolVersion}"]. A ${MCP_MODERN_VERSIONS[0]} request carries its version and client capabilities on every call; to use ${MCP_LEGACY_PROTOCOL_VERSION} instead, send "initialize".`);
609
+ }
610
+ if (!speaksModern(requested)) {
611
+ return refuse(-32022, 'Unsupported protocol version', {
612
+ supported: [...MCP_PROTOCOL_VERSIONS],
613
+ requested,
614
+ });
615
+ }
616
+ const capabilities = meta[MCP_META.clientCapabilities];
617
+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {
618
+ return refuse(-32602, `Missing params._meta["${MCP_META.clientCapabilities}"]. Send {} when the client declares none.`);
619
+ }
620
+ const mismatch = headerMismatch(request, body.method, params, requested);
621
+ if (mismatch)
622
+ return refuse(-32020, mismatch);
623
+ const result = await dispatchThroughGate(body, config, {
624
+ protocolVersion: requested,
625
+ era: 'modern',
626
+ request,
627
+ });
628
+ if (result == null)
629
+ return new Response(null, { status: 202, headers });
630
+ const response = modernResult(result, body.method, config);
631
+ const code = response.error?.code;
632
+ return Response.json(response, {
633
+ status: (code !== undefined && MODERN_STATUS[code]) || 200,
634
+ headers,
635
+ });
636
+ }
@@ -7,8 +7,6 @@ export interface PaginatedResponse<T> extends Pagination {
7
7
  total: number;
8
8
  totalPages: number;
9
9
  }
10
- export declare const MAX_PAGE_SIZE = 100;
11
- export declare const DEFAULT_PAGE_SIZE = 20;
12
10
  /**
13
11
  * `page` clamped to ≥1, `pageSize` clamped to 1–`max`. Never trust either.
14
12
  *
@@ -55,14 +53,12 @@ export declare function toOffset({ page, pageSize }: Pagination): {
55
53
  * The ordering is the caller's, deliberately — it is the one thing here that is
56
54
  * never portable. A wiki's sequence is its section's configured sort, a
57
55
  * 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
56
  *
60
57
  * `null` on both sides when the page is not in the list, so a page reached by a
61
58
  * URL its own section does not list renders no nav rather than a wrong one.
62
59
  *
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.
60
+ * No query. Callers already hold the ordered siblings for something else, a
61
+ * related-pages panel or a section listing, so the neighbours cost no lookups.
66
62
  */
67
63
  export declare function adjacentPages<T>(ordered: readonly T[], isCurrent: (page: T) => boolean): {
68
64
  prev: T | null;
@@ -3,8 +3,8 @@
3
3
  // Forked three ways once: a re-typed clamp that dropped `totalPages`, and an
4
4
  // offset redone by hand in raw SQL. The response shape is the contract a client
5
5
  // codes against, so reshaping it per repo is a breaking change nobody declared.
6
- export const MAX_PAGE_SIZE = 100;
7
- export const DEFAULT_PAGE_SIZE = 20;
6
+ const MAX_PAGE_SIZE = 100;
7
+ const DEFAULT_PAGE_SIZE = 20;
8
8
  /**
9
9
  * `page` clamped to ≥1, `pageSize` clamped to 1–`max`. Never trust either.
10
10
  *
@@ -62,14 +62,12 @@ export function toOffset({ page, pageSize }) {
62
62
  * The ordering is the caller's, deliberately — it is the one thing here that is
63
63
  * never portable. A wiki's sequence is its section's configured sort, a
64
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
65
  *
67
66
  * `null` on both sides when the page is not in the list, so a page reached by a
68
67
  * URL its own section does not list renders no nav rather than a wrong one.
69
68
  *
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.
69
+ * No query. Callers already hold the ordered siblings for something else, a
70
+ * related-pages panel or a section listing, so the neighbours cost no lookups.
73
71
  */
74
72
  export function adjacentPages(ordered, isCurrent) {
75
73
  const i = ordered.findIndex(isCurrent);
@@ -69,10 +69,9 @@ export declare function resetRateLimits(): void;
69
69
  * `.well-known/mcp.json`, the OpenAPI spec, agents.md, llms.txt and the
70
70
  * `initialize` instructions all quote this, so the endpoint enforces exactly
71
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.
72
+ * lives here rather than three times over. A second copy is how a number
73
+ * becomes impossible to change safely: nobody can tell a considered difference
74
+ * from a stale one.
76
75
  *
77
76
  * A surface with a genuine reason to differ passes its own `RateLimitOptions`.
78
77
  * What it must not do is restate this one.
@@ -117,10 +117,9 @@ export function resetRateLimits() {
117
117
  * `.well-known/mcp.json`, the OpenAPI spec, agents.md, llms.txt and the
118
118
  * `initialize` instructions all quote this, so the endpoint enforces exactly
119
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.
120
+ * lives here rather than three times over. A second copy is how a number
121
+ * becomes impossible to change safely: nobody can tell a considered difference
122
+ * from a stale one.
124
123
  *
125
124
  * A surface with a genuine reason to differ passes its own `RateLimitOptions`.
126
125
  * What it must not do is restate this one.
@@ -54,7 +54,7 @@ export interface FacetBarProps {
54
54
  * dropping the active facets — and the A–Z row leads with the reset control.
55
55
  *
56
56
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
57
- * take that attribute. Two of the three copies this replaces had it wrong.
57
+ * take that attribute.
58
58
  */
59
59
  export declare function FacetBar({ link: Link, facets, letters, alphaLabel, classNames, }: FacetBarProps): import("react").JSX.Element | null;
60
60
  export interface BreadcrumbItem {
@@ -114,7 +114,7 @@ export declare function Breadcrumbs({ items, base, className, link: Link, lastAs
114
114
  * The standard page-top row: the trail, plus optional right-aligned actions.
115
115
  *
116
116
  * The column it sits in is the one its route declares — the row reads the
117
- * page's own max-width by inheritance, so a trail can no longer disagree with
117
+ * page's own max-width by inheritance, so a trail cannot disagree with
118
118
  * the content it titles.
119
119
  */
120
120
  export declare function BreadcrumbsRow({ actions, className, ...props }: BreadcrumbsProps & {
@@ -146,15 +146,11 @@ export interface PageNavProps {
146
146
  * lateral links do not cover.
147
147
  *
148
148
  * Ordering is entirely the caller's; pair this with `adjacentPages` from
149
- * `wiki-formant/pagination` over a list you already hold. Both wikis had this
150
- * markup and had already drifted on the parts that matter rather than the parts
151
- * that show: one carried `rel="prev"`/`rel="next"` and an `aria-label` and the
152
- * other carried neither, and the one without expressed its right-hand alignment
153
- * as two inline utilities instead of the modifier its own stylesheet defines.
149
+ * `wiki-formant/pagination` over a list you already hold.
154
150
  *
155
- * The `page-nav__*` class names are NOT props, for the reason `WikiRail`'s are
156
- * not: they are the convention both stylesheets already implement, and making
157
- * them configurable is how a convention forks. The empty `<div>` holds the
158
- * first article's left column so the next link stays in the right one.
151
+ * The `page-nav__*` class names are NOT props: they are the convention every
152
+ * stylesheet implements, and making them configurable is how a convention
153
+ * forks. The empty `<div>` holds the first article's left column so the next
154
+ * link stays in the right one.
159
155
  */
160
156
  export declare function PageNav({ prev, next, link: Link, label, prevLabel, nextLabel, prevGlyph, nextGlyph, }: PageNavProps): import("react").JSX.Element | null;
@@ -34,7 +34,7 @@ export const Anchor = ({ href, className, children }) => _jsx("a", { href: href,
34
34
  * dropping the active facets — and the A–Z row leads with the reset control.
35
35
  *
36
36
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
37
- * take that attribute. Two of the three copies this replaces had it wrong.
37
+ * take that attribute.
38
38
  */
39
39
  export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', classNames = {}, }) {
40
40
  if (!facets.length && !letters.length)
@@ -85,7 +85,7 @@ export function Breadcrumbs({ items, base, className = '', link: Link = Anchor,
85
85
  * The standard page-top row: the trail, plus optional right-aligned actions.
86
86
  *
87
87
  * The column it sits in is the one its route declares — the row reads the
88
- * page's own max-width by inheritance, so a trail can no longer disagree with
88
+ * page's own max-width by inheritance, so a trail cannot disagree with
89
89
  * the content it titles.
90
90
  */
91
91
  export function BreadcrumbsRow({ actions, className = '', ...props }) {
@@ -96,16 +96,12 @@ export function BreadcrumbsRow({ actions, className = '', ...props }) {
96
96
  * lateral links do not cover.
97
97
  *
98
98
  * Ordering is entirely the caller's; pair this with `adjacentPages` from
99
- * `wiki-formant/pagination` over a list you already hold. Both wikis had this
100
- * markup and had already drifted on the parts that matter rather than the parts
101
- * that show: one carried `rel="prev"`/`rel="next"` and an `aria-label` and the
102
- * other carried neither, and the one without expressed its right-hand alignment
103
- * as two inline utilities instead of the modifier its own stylesheet defines.
99
+ * `wiki-formant/pagination` over a list you already hold.
104
100
  *
105
- * The `page-nav__*` class names are NOT props, for the reason `WikiRail`'s are
106
- * not: they are the convention both stylesheets already implement, and making
107
- * them configurable is how a convention forks. The empty `<div>` holds the
108
- * first article's left column so the next link stays in the right one.
101
+ * The `page-nav__*` class names are NOT props: they are the convention every
102
+ * stylesheet implements, and making them configurable is how a convention
103
+ * forks. The empty `<div>` holds the first article's left column so the next
104
+ * link stays in the right one.
109
105
  */
110
106
  export function PageNav({ prev, next, link: Link = Anchor, label = 'Article navigation', prevLabel = 'Previous', nextLabel = 'Next', prevGlyph = '\u2190', nextGlyph = '\u2192', }) {
111
107
  if (!prev && !next)
package/dist/react.d.ts CHANGED
@@ -22,14 +22,6 @@ export interface SidebarState {
22
22
  */
23
23
  ready: boolean;
24
24
  }
25
- /**
26
- * Collapse state for a wiki rail: remembered across loads, defaulted from the
27
- * viewport only when the reader has never chosen.
28
- *
29
- * The reader's choice outranks the breakpoint. Someone who collapses the rail
30
- * on a laptop and then narrows the window has still collapsed the rail.
31
- */
32
- export declare function useCollapsibleSidebar(options?: SidebarOptions): SidebarState;
33
25
  export interface SidebarProviderProps extends SidebarOptions {
34
26
  children: ReactNode;
35
27
  }
@@ -286,8 +278,7 @@ export declare class ErrorBoundary extends Component<{
286
278
  children: ReactNode;
287
279
  /**
288
280
  * `error` is passed as well as `retry` because a fallback that cannot see
289
- * what failed can only say "something went wrong" — which is what one of
290
- * the copies this replaced said, while the other had the message and used it.
281
+ * what failed can only say "something went wrong".
291
282
  */
292
283
  fallback: (retry: () => void, error: unknown) => ReactNode;
293
284
  onError?: (error: unknown) => void;
package/dist/react.js CHANGED
@@ -47,7 +47,7 @@ const readStored = (key) => {
47
47
  * The reader's choice outranks the breakpoint. Someone who collapses the rail
48
48
  * on a laptop and then narrows the window has still collapsed the rail.
49
49
  */
50
- export function useCollapsibleSidebar(options = {}) {
50
+ function useCollapsibleSidebar(options = {}) {
51
51
  const { storageKey = 'wiki:sidebar', breakpoint = 1024 } = options;
52
52
  // Start open on the server and on the first client paint. SSR has no viewport
53
53
  // and no storage, so any other guess is a guaranteed mismatch on some loads.