wiki-formant 0.27.1 → 0.29.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/dom.js CHANGED
@@ -322,3 +322,60 @@ export function scrollTables(html) {
322
322
  return hadBox ? tag : SCROLL_BOX + tag;
323
323
  });
324
324
  }
325
+ // ---- analytics beacon -------------------------------------------------------
326
+ let beaconEndpoint = '/api/view';
327
+ let beaconStarted = false;
328
+ const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
329
+ /**
330
+ * Report each page view to the site's own `endpoint`, whose route hands it to
331
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
332
+ * runs and no blocklist names the request.
333
+ *
334
+ * A client-side router moves between pages with `history.pushState`, which no
335
+ * load event marks, so both history writes are wrapped and a view is sent
336
+ * whenever the path changes; a change to the query or hash alone is not a page.
337
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
338
+ * carrying its `url`. Starts once per page load however often it is called, so
339
+ * React's doubled effects in development send one view, not two.
340
+ */
341
+ export function startBeacon(endpoint = '/api/view') {
342
+ if (beaconStarted || typeof window === 'undefined')
343
+ return;
344
+ beaconStarted = true;
345
+ beaconEndpoint = endpoint;
346
+ let last = '';
347
+ let first = true;
348
+ const view = () => {
349
+ if (location.pathname === last)
350
+ return;
351
+ last = location.pathname;
352
+ // document.referrer belongs to the page load, so only its first view carries it.
353
+ sendBeacon({
354
+ path: last,
355
+ referrer: first ? document.referrer : undefined,
356
+ utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
357
+ });
358
+ first = false;
359
+ };
360
+ for (const method of ['pushState', 'replaceState']) {
361
+ const write = history[method];
362
+ history[method] = function (...args) {
363
+ write.apply(this, args);
364
+ view();
365
+ };
366
+ }
367
+ addEventListener('popstate', view);
368
+ document.addEventListener('click', e => {
369
+ const a = e.target?.closest?.('a[href]');
370
+ if (a instanceof HTMLAnchorElement && /^https?:$/.test(a.protocol) && a.host !== location.host) {
371
+ track('Outbound Link: Click', { url: a.href });
372
+ }
373
+ }, { capture: true });
374
+ view();
375
+ }
376
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
377
+ export function track(name, props) {
378
+ if (typeof window === 'undefined')
379
+ return;
380
+ sendBeacon({ name, path: location.pathname, props });
381
+ }
package/dist/feed.d.ts CHANGED
@@ -53,7 +53,6 @@ export interface FeedChannel {
53
53
  * exist to prevent. An empty feed has no build date rather than a fictional one.
54
54
  */
55
55
  export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
56
- export declare const FEED_HEADERS: Record<string, string>;
57
56
  /**
58
57
  * The feed as a response, with the validators a poller revalidates against.
59
58
  *
package/dist/feed.js CHANGED
@@ -89,7 +89,7 @@ export function renderFeed(channel, items) {
89
89
  ].join('\n');
90
90
  }
91
91
  const lastBuild = (channel, items) => channel.lastBuild ?? items.reduce((max, item) => (!max || item.date > max ? item.date : max), null);
92
- export const FEED_HEADERS = {
92
+ const FEED_HEADERS = {
93
93
  'Content-Type': 'application/rss+xml; charset=utf-8',
94
94
  'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
95
95
  };
@@ -39,8 +39,13 @@ type When = Date | string | number;
39
39
  * timestamp formatted in the server's zone, or the browser's, lands on the
40
40
  * previous day for half the world, and the server and the hydrating client
41
41
  * disagree about which day it is.
42
+ *
43
+ * `locale` defaults to `en-US`; `{ locale: 'en-GB', month: 'long' }` reads
44
+ * `19 September 2026`.
42
45
  */
43
- export declare function formatDay(date: When, options?: Intl.DateTimeFormatOptions): string;
46
+ export declare function formatDay(date: When, options?: Intl.DateTimeFormatOptions & {
47
+ locale?: string;
48
+ }): string;
44
49
  /**
45
50
  * - `compact`: `now`, `2m`, `3h`, `5d`, `2mo`, `1y` — for dense rows.
46
51
  * - `short`: `just now`, `2m ago` … `1y ago`.
package/dist/freshness.js CHANGED
@@ -60,14 +60,18 @@ const toMs = (d) => (typeof d === 'number' ? d : d instanceof Date ? d.getTime()
60
60
  * timestamp formatted in the server's zone, or the browser's, lands on the
61
61
  * previous day for half the world, and the server and the hydrating client
62
62
  * disagree about which day it is.
63
+ *
64
+ * `locale` defaults to `en-US`; `{ locale: 'en-GB', month: 'long' }` reads
65
+ * `19 September 2026`.
63
66
  */
64
- export function formatDay(date, options) {
65
- return new Date(toMs(date)).toLocaleDateString('en-US', {
67
+ export function formatDay(date, options = {}) {
68
+ const { locale = 'en-US', ...format } = options;
69
+ return new Date(toMs(date)).toLocaleDateString(locale, {
66
70
  year: 'numeric',
67
71
  month: 'short',
68
72
  day: 'numeric',
69
73
  timeZone: 'UTC',
70
- ...options,
74
+ ...format,
71
75
  });
72
76
  }
73
77
  const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
package/dist/http.d.ts CHANGED
@@ -123,6 +123,16 @@ export interface CorpusValidators {
123
123
  etag: string;
124
124
  lastModified: string;
125
125
  }
126
+ /**
127
+ * The validators for an aggregate: the ETag seeded from `seed` (the depth or
128
+ * scope, then the row count) with the newest stamp appended, and that stamp as
129
+ * Last-Modified. A corpus with no rows, or stamps all null, dates from the
130
+ * epoch, so an empty corpus still revalidates rather than throwing.
131
+ *
132
+ * Every wiki here wrote this after its `aggregate` call: newest of the `_max`
133
+ * columns, `?? new Date(0)`, then the same two lines.
134
+ */
135
+ export declare function corpusValidatorsFrom(seed: ReadonlyArray<string | number | null | undefined>, stamps: ReadonlyArray<Date | null | undefined>): CorpusValidators;
126
136
  /**
127
137
  * A GET handler serving `build()` under corpus validators — or a 304 instead.
128
138
  *
package/dist/http.js CHANGED
@@ -191,6 +191,19 @@ export function pageLine(opts) {
191
191
  const stamp = opts.updated ? isoDate(opts.updated) : '';
192
192
  return `- [${opts.title}](${opts.url})${excerpt}${stamp ? ` _(updated ${stamp})_` : ''}`;
193
193
  }
194
+ /**
195
+ * The validators for an aggregate: the ETag seeded from `seed` (the depth or
196
+ * scope, then the row count) with the newest stamp appended, and that stamp as
197
+ * Last-Modified. A corpus with no rows, or stamps all null, dates from the
198
+ * epoch, so an empty corpus still revalidates rather than throwing.
199
+ *
200
+ * Every wiki here wrote this after its `aggregate` call: newest of the `_max`
201
+ * columns, `?? new Date(0)`, then the same two lines.
202
+ */
203
+ export function corpusValidatorsFrom(seed, stamps) {
204
+ const newest = stamps.reduce((max, d) => (d instanceof Date && d.getTime() > max.getTime() ? d : max), new Date(0));
205
+ return { etag: corpusEtag([...seed, newest]), lastModified: newest.toUTCString() };
206
+ }
194
207
  /**
195
208
  * A GET handler serving `build()` under corpus validators — or a 304 instead.
196
209
  *
@@ -42,30 +42,6 @@ export declare function probeUrlFor(url: string): string;
42
42
  * exactly as a hand re-probe does it.
43
43
  */
44
44
  export declare function perHost<T>(url: string, fn: () => Promise<T>): Promise<T>;
45
- /**
46
- * Probe one URL, honestly.
47
- *
48
- * A timeout is not a death and neither is a dropped connection: slow replays —
49
- * web.archive.org above all, which a sweep leans on to rescue dying citations —
50
- * exceed the ordinary budget and land as status 0. So a status-0 result with a
51
- * transient code earns one more attempt on the longer budget before it is
52
- * reported. A 404 does not: it is an answer.
53
- */
54
- export declare function probeUrl(url: string, opts?: ProbeOptions): Promise<Probe>;
55
- export declare const YOUTUBE_EMBED: RegExp;
56
- /**
57
- * A YouTube `/embed/<id>` URL answers 200 for private, deleted and
58
- * playback-restricted videos alike, so HEADing it can never spot a dead hero
59
- * video. oEmbed can:
60
- *
61
- * 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
62
- */
63
- export declare function probeYouTube(videoId: string, opts?: ProbeOptions): Promise<{
64
- status: number;
65
- ok: boolean;
66
- reason?: string;
67
- error?: string;
68
- }>;
69
45
  /** An external anchor, with a video treated as a video. */
70
46
  export declare function probeExternal(url: string, opts?: ProbeOptions): Promise<Probe & {
71
47
  videoId?: string;
@@ -134,7 +134,7 @@ async function attempt(url, budgetMs) {
134
134
  * transient code earns one more attempt on the longer budget before it is
135
135
  * reported. A 404 does not: it is an answer.
136
136
  */
137
- export async function probeUrl(url, opts = {}) {
137
+ async function probeUrl(url, opts = {}) {
138
138
  const { timeoutMs, slowTimeoutMs } = { ...DEFAULTS, ...opts };
139
139
  const target = probeUrlFor(url);
140
140
  const first = await perHost(target, () => attempt(target, timeoutMs));
@@ -145,7 +145,7 @@ export async function probeUrl(url, opts = {}) {
145
145
  return { ...second, url };
146
146
  }
147
147
  // ---- YouTube ----------------------------------------------------------------
148
- export const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?(?:youtube-nocookie\.com|youtube\.com)\/embed\/([\w-]+)/;
148
+ const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?(?:youtube-nocookie\.com|youtube\.com)\/embed\/([\w-]+)/;
149
149
  /**
150
150
  * The same video cited as a LINK rather than an iframe. `youtu.be/<id>` 303s and
151
151
  * `youtube.com/watch?v=<id>` 200s for deleted and private videos alike, so an
@@ -160,7 +160,7 @@ const YOUTUBE_WATCH = /^https?:\/\/(?:(?:www\.)?youtube\.com\/(?:watch\?(?:[^#]*
160
160
  *
161
161
  * 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
162
162
  */
163
- export async function probeYouTube(videoId, opts = {}) {
163
+ async function probeYouTube(videoId, opts = {}) {
164
164
  const { timeoutMs } = { ...DEFAULTS, ...opts };
165
165
  const oembed = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D${videoId}&format=json`;
166
166
  try {
package/dist/mcp.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare const MCP_MODERN_VERSIONS: readonly ["2026-07-28"];
10
10
  * endpoint speak both, and a legacy client meeting a dual-era server works;
11
11
  * going modern-only would fail the handshake of every client in the field.
12
12
  */
13
- export declare const MCP_LEGACY_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
13
+ declare const MCP_LEGACY_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
14
14
  /**
15
15
  * Every version this transport speaks, newest first. It is the `supported` list
16
16
  * a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
@@ -239,8 +239,8 @@ export declare const withAdjustments: <T extends object>(args: {
239
239
  }, result: T) => T | (T & {
240
240
  adjustments: readonly ArgAdjustment[];
241
241
  });
242
- /** One entry in a JSON-RPC envelope. Exported because `wiki-formant/x402`
243
- * gates the envelope before `handleMcp` ever sees it. */
242
+ /** One entry in a JSON-RPC envelope. Exported for an `McpServerConfig.gate`,
243
+ * which rewrites the envelope before `handleMcp` sees it (acuiq2's x402 gate). */
244
244
  export type RpcRequest = {
245
245
  jsonrpc: '2.0';
246
246
  id: string | number | null;
@@ -250,8 +250,9 @@ export type RpcRequest = {
250
250
  /** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
251
251
  export declare const DEFAULT_MAX_BATCH = 20;
252
252
  /**
253
- * A tool result envelope. Exported because `x402.ts` builds the same shape when
254
- * it withholds a paid call, and had grown two more copies of it doing so.
253
+ * A tool result envelope. Exported for an envelope gate, which builds the same
254
+ * shape when it withholds a call: acuiq2's x402 gate had grown two more copies
255
+ * of it doing so.
255
256
  */
256
257
  export declare const toolText: (id: RpcRequest["id"], text: string, isError?: boolean, extra?: Record<string, unknown>) => {
257
258
  jsonrpc: "2.0";
package/dist/mcp.js CHANGED
@@ -25,7 +25,7 @@ export const MCP_MODERN_VERSIONS = ['2026-07-28'];
25
25
  * endpoint speak both, and a legacy client meeting a dual-era server works;
26
26
  * going modern-only would fail the handshake of every client in the field.
27
27
  */
28
- export const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
28
+ const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
29
29
  /**
30
30
  * Every version this transport speaks, newest first. It is the `supported` list
31
31
  * a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
@@ -157,8 +157,9 @@ const rpcError = (id, code, message, data) => ({
157
157
  error: { code, message, ...(data ? { data } : {}) },
158
158
  });
159
159
  /**
160
- * A tool result envelope. Exported because `x402.ts` builds the same shape when
161
- * it withholds a paid call, and had grown two more copies of it doing so.
160
+ * A tool result envelope. Exported for an envelope gate, which builds the same
161
+ * shape when it withholds a call: acuiq2's x402 gate had grown two more copies
162
+ * of it doing so.
162
163
  */
163
164
  export const toolText = (id, text, isError = false, extra = {}) => ({
164
165
  jsonrpc: '2.0',
@@ -3,19 +3,38 @@ export interface RateLimitOptions {
3
3
  capacity: number;
4
4
  /** Tokens added per second: the sustained rate. */
5
5
  refillPerSec: number;
6
+ /**
7
+ * Running dry blocks `blockKey` outright for this long, rather than letting
8
+ * the bucket refill a token at a time. For a caller already established as
9
+ * abusive, the trickle the refill allows is the wrong answer.
10
+ */
11
+ blockMs?: number;
12
+ /**
13
+ * What a block shuts out. Default the bucket's own key; pass the bare
14
+ * address (`rest:1.2.3.4`) to lock a caller out of every endpoint that names
15
+ * the same `blockKey` once it blows one endpoint's budget.
16
+ */
17
+ blockKey?: string;
6
18
  }
7
19
  export type RateLimitResult = {
8
20
  ok: true;
9
21
  remaining: number;
10
- } | {
22
+ }
23
+ /** `blocked` is true when an earlier block refused the call, rather than this one's bucket. */
24
+ | {
11
25
  ok: false;
12
26
  retryAfterSec: number;
27
+ blocked?: boolean;
13
28
  };
14
29
  /**
15
30
  * Spend one token against `key`.
16
31
  *
17
32
  * Keys are namespaced by the caller (`"mcp:1.2.3.4"`, `"write:page:17"`), so
18
33
  * one map backs every limiter in a process without them colliding.
34
+ *
35
+ * With `blockMs`, a block on `blockKey` is checked before any token is spent,
36
+ * and running the bucket dry starts one; the refusal then states the block's
37
+ * length as its wait.
19
38
  */
20
39
  export declare function rateLimit(key: string, opts: RateLimitOptions): RateLimitResult;
21
40
  /** Anything with a header getter: a `Request`'s headers, or Next's `headers()`. */
@@ -23,7 +42,14 @@ export interface HeaderReader {
23
42
  get(name: string): string | null | undefined;
24
43
  }
25
44
  /**
26
- * The caller's address, from the proxy header the edge sets.
45
+ * The caller's address, from the header the edge sets.
46
+ *
47
+ * `x-real-ip` first: Vercel sets it to the connecting client and overwrites any
48
+ * inbound copy, so a client cannot choose it. The leftmost `x-forwarded-for`
49
+ * entry is only as trustworthy as every proxy in front of the app — one that
50
+ * appends rather than replaces hands the client the first slot, and with it a
51
+ * fresh bucket and a fresh visitor per request. It stays as the fallback for a
52
+ * host that sets no `x-real-ip`.
27
53
  *
28
54
  * Falls back to a single shared bucket rather than to per-caller buckets, so an
29
55
  * unidentifiable caller is still limited — collectively, but limited. Keying on
@@ -14,14 +14,53 @@
14
14
  const buckets = new Map();
15
15
  /** Bounds memory under high-cardinality keying — one bucket per attacker IP. */
16
16
  const MAX_KEYS = 10_000;
17
+ /**
18
+ * Blocks, held as an expiry timestamp rather than a setTimeout: a pending timer
19
+ * keeps a serverless instance's event loop alive for no reason.
20
+ */
21
+ const blocks = new Map();
22
+ /** Seconds left on a block, or 0. An expired block is dropped on the way past. */
23
+ function blockedFor(key, now) {
24
+ const until = blocks.get(key);
25
+ if (until === undefined)
26
+ return 0;
27
+ if (now < until)
28
+ return Math.ceil((until - now) / 1000);
29
+ blocks.delete(key);
30
+ return 0;
31
+ }
32
+ function block(key, until, now) {
33
+ if (blocks.size >= MAX_KEYS) {
34
+ for (const [k, expiry] of blocks)
35
+ if (expiry <= now)
36
+ blocks.delete(k);
37
+ // Still full of live blocks: the oldest goes, as with buckets.
38
+ if (blocks.size >= MAX_KEYS) {
39
+ const oldest = blocks.keys().next().value;
40
+ if (oldest !== undefined)
41
+ blocks.delete(oldest);
42
+ }
43
+ }
44
+ blocks.set(key, until);
45
+ }
17
46
  /**
18
47
  * Spend one token against `key`.
19
48
  *
20
49
  * Keys are namespaced by the caller (`"mcp:1.2.3.4"`, `"write:page:17"`), so
21
50
  * one map backs every limiter in a process without them colliding.
51
+ *
52
+ * With `blockMs`, a block on `blockKey` is checked before any token is spent,
53
+ * and running the bucket dry starts one; the refusal then states the block's
54
+ * length as its wait.
22
55
  */
23
56
  export function rateLimit(key, opts) {
24
57
  const now = Date.now();
58
+ const blockKey = opts.blockKey ?? key;
59
+ if (opts.blockMs) {
60
+ const blockedSec = blockedFor(blockKey, now);
61
+ if (blockedSec)
62
+ return { ok: false, retryAfterSec: blockedSec, blocked: true };
63
+ }
25
64
  const existing = buckets.get(key);
26
65
  if (!existing && buckets.size >= MAX_KEYS) {
27
66
  // Map iteration is insertion-ordered, so this evicts the oldest key.
@@ -35,13 +74,24 @@ export function rateLimit(key, opts) {
35
74
  bucket.updatedAt = now;
36
75
  buckets.set(key, bucket);
37
76
  if (bucket.tokens < 1) {
77
+ if (opts.blockMs) {
78
+ block(blockKey, now + opts.blockMs, now);
79
+ return { ok: false, retryAfterSec: Math.ceil(opts.blockMs / 1000) };
80
+ }
38
81
  return { ok: false, retryAfterSec: Math.ceil((1 - bucket.tokens) / opts.refillPerSec) };
39
82
  }
40
83
  bucket.tokens -= 1;
41
84
  return { ok: true, remaining: Math.floor(bucket.tokens) };
42
85
  }
43
86
  /**
44
- * The caller's address, from the proxy header the edge sets.
87
+ * The caller's address, from the header the edge sets.
88
+ *
89
+ * `x-real-ip` first: Vercel sets it to the connecting client and overwrites any
90
+ * inbound copy, so a client cannot choose it. The leftmost `x-forwarded-for`
91
+ * entry is only as trustworthy as every proxy in front of the app — one that
92
+ * appends rather than replaces hands the client the first slot, and with it a
93
+ * fresh bucket and a fresh visitor per request. It stays as the fallback for a
94
+ * host that sets no `x-real-ip`.
45
95
  *
46
96
  * Falls back to a single shared bucket rather than to per-caller buckets, so an
47
97
  * unidentifiable caller is still limited — collectively, but limited. Keying on
@@ -50,8 +100,9 @@ export function rateLimit(key, opts) {
50
100
  * meant to catch.
51
101
  */
52
102
  export function clientIp(headers) {
53
- const forwarded = headers.get('x-forwarded-for');
54
- return forwarded?.split(',')[0]?.trim() || headers.get('x-real-ip')?.trim() || 'anon';
103
+ return (headers.get('x-real-ip')?.trim() ||
104
+ headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
105
+ 'anon');
55
106
  }
56
107
  /** `prefix:ip` — the key `rateLimit` expects for a per-IP gate. */
57
108
  export function clientKey(prefix, headers) {
@@ -109,6 +160,7 @@ export function withRateLimit(res, result, opts) {
109
160
  /** Test seam: the bucket map is module state and outlives a single test. */
110
161
  export function resetRateLimits() {
111
162
  buckets.clear();
163
+ blocks.clear();
112
164
  }
113
165
  // ---- the MCP budget ---------------------------------------------------------
114
166
  /**
package/dist/react.d.ts CHANGED
@@ -444,3 +444,10 @@ export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
444
444
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
445
445
  /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
446
446
  export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
447
+ /**
448
+ * Counts the page views of the app it is mounted in: place it once, in the root
449
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
450
+ */
451
+ export declare function Beacon({ endpoint }: {
452
+ endpoint?: string;
453
+ }): null;
package/dist/react.js CHANGED
@@ -27,7 +27,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
27
27
  // state on <html> before first paint. The hook keeps that attribute in
28
28
  // sync afterwards, so CSS has one source of truth either side of hydration.
29
29
  import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
30
- import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
30
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
31
31
  import { comboboxAria } from './combobox.js';
32
32
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
33
33
  const readStored = (key) => {
@@ -820,3 +820,11 @@ export function useTweetEmbeds(ref, deps) {
820
820
  // eslint-disable-next-line react-hooks/exhaustive-deps
821
821
  }, deps);
822
822
  }
823
+ /**
824
+ * Counts the page views of the app it is mounted in: place it once, in the root
825
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
826
+ */
827
+ export function Beacon({ endpoint = '/api/view' }) {
828
+ useEffect(() => startBeacon(endpoint), [endpoint]);
829
+ return null;
830
+ }
@@ -19,6 +19,35 @@ export declare function validateLinkGroups(groups: unknown): boolean;
19
19
  export declare function validateCodeTabs(tabs: unknown): boolean;
20
20
  /** `[{ value, label, suffix? }]` — a stats block's cards. */
21
21
  export declare function validateStatItems(items: unknown): boolean;
22
+ /**
23
+ * The most rows an author-supplied count may ask for. Every `limit` and every
24
+ * `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
25
+ * between the editor and the query: a fractional `take` is a validation error
26
+ * against an Int column and an unbounded one is a table scan on every render.
27
+ */
28
+ export declare const MAX_BLOCK_ROWS = 100;
29
+ /** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
30
+ export declare function blockLimit(value: unknown, fallback: number, max?: number): number;
31
+ export interface CoreAtomicValidatorOptions {
32
+ /**
33
+ * Whether one `pageIds` entry is an id this wiki can look up. The format is
34
+ * each wiki's own — a cuid, an Int read back with `Number()`, a
35
+ * `section/slug` — so the default accepts any string.
36
+ */
37
+ pageId?: (id: string) => boolean;
38
+ /** Default `MAX_BLOCK_ROWS`. */
39
+ maxRows?: number;
40
+ }
41
+ /**
42
+ * The leaf checks for the core types, as one function a repo's `validateAtomic`
43
+ * falls through to: `default: return core(b)`. False for any type outside the
44
+ * core, so the repo's own cases still decide its native types.
45
+ *
46
+ * `recentPages.limit` must be a whole number within the cap and `pageIds` no
47
+ * longer than it — caper's rule, which the other two lacked. A scope field
48
+ * (`tagPath`, `section`) is a string when present.
49
+ */
50
+ export declare function coreAtomicValidator(opts?: CoreAtomicValidatorOptions): (block: Record<string, unknown>) => boolean;
22
51
  export interface BlockValidatorOptions {
23
52
  /** True for a type this wiki knows at all. */
24
53
  isKnownType: (type: string) => boolean;
@@ -72,6 +72,57 @@ export function validateStatItems(items) {
72
72
  (typeof s.value === 'string' || typeof s.value === 'number') &&
73
73
  (s.suffix === undefined || s.suffix === null || typeof s.suffix === 'string')));
74
74
  }
75
+ // ---- the core leaf set ------------------------------------------------------
76
+ /**
77
+ * The most rows an author-supplied count may ask for. Every `limit` and every
78
+ * `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
79
+ * between the editor and the query: a fractional `take` is a validation error
80
+ * against an Int column and an unbounded one is a table scan on every render.
81
+ */
82
+ export const MAX_BLOCK_ROWS = 100;
83
+ /** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
84
+ export function blockLimit(value, fallback, max = MAX_BLOCK_ROWS) {
85
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
86
+ return Math.min(Math.max(n, 1), max);
87
+ }
88
+ const okLimit = (value, max) => Number.isSafeInteger(value) && value > 0 && value <= max;
89
+ const optionalString = (v) => v === undefined || typeof v === 'string';
90
+ /**
91
+ * The leaf checks for the core types, as one function a repo's `validateAtomic`
92
+ * falls through to: `default: return core(b)`. False for any type outside the
93
+ * core, so the repo's own cases still decide its native types.
94
+ *
95
+ * `recentPages.limit` must be a whole number within the cap and `pageIds` no
96
+ * longer than it — caper's rule, which the other two lacked. A scope field
97
+ * (`tagPath`, `section`) is a string when present.
98
+ */
99
+ export function coreAtomicValidator(opts = {}) {
100
+ const { pageId = () => true, maxRows = MAX_BLOCK_ROWS } = opts;
101
+ return b => {
102
+ switch (b.type) {
103
+ case 'content':
104
+ return typeof b.text === 'string';
105
+ case 'recentPages':
106
+ return okLimit(b.limit, maxRows) && optionalString(b.tagPath) && optionalString(b.section);
107
+ case 'pageList':
108
+ return (Array.isArray(b.pageIds) &&
109
+ b.pageIds.length <= maxRows &&
110
+ b.pageIds.every(id => typeof id === 'string' && pageId(id)));
111
+ case 'codeTabs':
112
+ return validateCodeTabs(b.tabs);
113
+ case 'stats':
114
+ return validateStatItems(b.items);
115
+ case 'banner':
116
+ return typeof b.variant === 'string';
117
+ case 'references':
118
+ return validateReferenceItems(b.items);
119
+ case 'linkGrid':
120
+ return validateLinkGroups(b.groups);
121
+ default:
122
+ return false;
123
+ }
124
+ };
125
+ }
75
126
  /**
76
127
  * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
77
128
  * keeps its own type parameter, so this stays free of any repo's types while