wiki-formant 0.28.0 → 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/README.md CHANGED
@@ -148,7 +148,7 @@ handler: async (args, ctx) => {
148
148
  }
149
149
  ```
150
150
 
151
- `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope.
151
+ `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
152
152
 
153
153
  ### Tool arguments and the corpus tool
154
154
 
@@ -213,6 +213,13 @@ export async function GET(request: Request) {
213
213
  }
214
214
  ```
215
215
 
216
+ `corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
217
+
218
+ ```ts
219
+ const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
220
+ corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
221
+ ```
222
+
216
223
  ## Pagination and versioning
217
224
 
218
225
  `parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
@@ -311,14 +318,6 @@ import Link from 'next/link';
311
318
 
312
319
  There is no `next` peer dependency here and there is not going to be one, but a rail that falls back to a bare `<a>` turns every press into a full page load — which is the whole reason a wiki has a persistent rail. So the router arrives through the door.
313
320
 
314
- ## Payment gating
315
-
316
- `wiki-formant/x402` gates an MCP envelope on payment, for a wiki that charges for a bulk export over HTTP and would otherwise hand the same bytes over free through the equivalent MCP tool. Payment travels in-band — `params._meta["x402/payment"]` in, `result._meta["x402/payment-response"]` out — because one HTTP 402 cannot answer a batch of twenty in which one call is priced and nineteen are not.
317
-
318
- `gatePaidCalls` returns what `handleMcp` should dispatch and a `finish` that puts the answer back together. The ordering is the part worth owning once: verify before dispatch, settle only after a non-error answer, splice the challenges back into the caller's original order. A tool that raises cancels rather than settles — the caller pays for an answer, not an attempt.
319
-
320
- It imports nothing. `@x402/core`, `@x402/evm`, `@x402/extensions`, `@x402/next` and `viem` are optional peers, and the resource server arrives as a structural port instead of an import, so a wiki that never imports this subpath never resolves any of them. Which surfaces cost what, the prices and the terms text stay with the caller: those are policy, and this is the envelope surgery.
321
-
322
321
  ## AI crawlers
323
322
 
324
323
  Every wiki kept this roster twice — once in the proxy, keyed by user-agent, to
@@ -490,6 +489,27 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
490
489
 
491
490
  The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
492
491
 
492
+ The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
493
+
494
+ ```ts
495
+ import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
496
+ import { coreAtomicValidator } from 'wiki-formant/validation';
497
+
498
+ switch (block.type) {
499
+ case 'content': case 'codeTabs': case 'banner': case 'references':
500
+ case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
501
+ return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
502
+ case 'corpusStats':
503
+ return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
504
+ }
505
+
506
+ const BLOCK_DEFAULTS = { ...coreBlockDefaults({ stats }), daoTimeline: () => ({ type: 'daoTimeline', limit: 25 }) };
507
+ const core = coreAtomicValidator({ pageId: id => /^\d{1,9}$/.test(id) });
508
+ // validateAtomic: b => { switch (b.type) { case 'daoTimeline': …; default: return core(b); } }
509
+ ```
510
+
511
+ `coreAtomicToMarkdown` joins a resolved page's site-relative `href` to `siteUrl`, because a twin is read off-site; a wiki whose lists hold its own page rows keeps those two cases. `coreAtomicValidator` caps `recentPages.limit` and `pageIds` at `MAX_BLOCK_ROWS`, a whole number of rows, because both end in a database `take:`; `blockLimit` is the same bound on the read side.
512
+
493
513
  `createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
494
514
 
495
515
  ```ts
@@ -504,7 +524,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
504
524
 
505
525
  ## Dates
506
526
 
507
- `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. Both are in `wiki-formant/freshness`, with `isoDate`.
527
+ `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
508
528
 
509
529
  ## Sanitising stored HTML
510
530
 
@@ -562,21 +582,32 @@ top of `src/analytics.ts`.
562
582
 
563
583
  ```ts
564
584
  // src/lib/track.ts
565
- export const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
585
+ const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
586
+ export const { trackEvent, trackMcpCall, trackSearch, viewRoute } =
587
+ createTracker({ sql, defer: after, siteUrl: SITE_URL });
566
588
 
567
589
  // src/app/api/view/route.ts – the whole beacon route
568
- export const POST = (request: Request) => collect(request, { sql, defer: after });
590
+ export const POST = viewRoute();
569
591
 
570
592
  // the root layout, once
571
593
  <Beacon />
572
594
  ```
573
595
 
596
+ `createTracker` records only on a Vercel production deployment unless told
597
+ otherwise (`enabled`), because dev and previews write to production's
598
+ database. `trackMcpCall(request, body, server?)` fits `McpServerConfig.onCall`;
599
+ `trackSearch(headers, query, results, surface?)` takes the headers or Next's
600
+ `headers` itself, which it reads inside the deferred write. `viewRoute({ site })`
601
+ passes a site resolver to `collect`. The module that builds the tracker
602
+ imports the app's database client, so a proxy reaches it through a dynamic
603
+ `import()` inside `event.waitUntil`, never a static one.
604
+
574
605
  `<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
575
606
  client-side moves included, and a click on a link to another host as an
576
607
  "Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
577
- any other browser event. Server code calls `recordEvent` and leaves the
578
- deferral to the framework, so this package keeps its zero-dependency
579
- guarantee:
608
+ any other browser event. Underneath the tracker, server code calls
609
+ `recordEvent` and leaves the deferral to the framework, so this package keeps
610
+ its zero-dependency guarantee:
580
611
 
581
612
  ```ts
582
613
  const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
@@ -613,15 +644,23 @@ No view emits a Tailwind utility any more — `ColumnsView` states its gap and a
613
644
 
614
645
  ## API
615
646
 
616
- Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on.
617
- The emitted `.d.ts` files are the reference. There is no hand-maintained symbol list
618
- here, because the one that used to be here drifted from them.
619
-
620
- The package root re-exports the runtime modules that need no peer dependency and no
621
- client boundary, so `import … from 'wiki-formant'` stays importable from a route
622
- handler with nothing else installed. Everything that reaches for React, a browser
623
- global, tiptap or a wallet — and the tooling modules, which no route imports — is
624
- subpath-only.
647
+ Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
648
+ and there is no root export: `import … from 'wiki-formant'` does not resolve. A
649
+ barrel loads every module it names for the one symbol a route wanted, and had
650
+ already re-exported one module twice. The emitted `.d.ts` files are the reference.
651
+ There is no hand-maintained symbol list here, because the one that used to be here
652
+ drifted from them.
653
+
654
+ `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
655
+ `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
656
+ 5432 session pooler, whose fifteen-client cap a build's workers share.
657
+
658
+ `rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
659
+ caller already shown to be abusive: running dry blocks the key outright for that
660
+ long, across every bucket that names it, rather than letting the refill trickle
661
+ one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
662
+ and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
663
+ `x-real-ip` was set, since a proxy that appends hands the client that slot.
625
664
 
626
665
  ## A bin
627
666
 
@@ -4,8 +4,6 @@ import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
4
4
  * Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
5
5
  */
6
6
  export type Sql = (query: string, ...values: unknown[]) => Promise<unknown>;
7
- /** Long enough to compare a month with the same month a year before. */
8
- export declare const KEEP_DAYS = 400;
9
7
  /** iPadOS reports itself as a Mac, so its views count as a computer's. */
10
8
  export declare const deviceOf: (ua: string) => "phone" | "tablet" | "computer";
11
9
  /**
@@ -151,4 +149,56 @@ export interface SearchQueryInput {
151
149
  * keeps the prop and the query that produced it the same string.
152
150
  */
153
151
  export declare function searchQueryProps({ query, results, surface, }: SearchQueryInput): Record<string, string> | null;
152
+ export interface TrackerOptions {
153
+ sql: Sql;
154
+ /** `after` in Next: the write runs once the response has gone. */
155
+ defer: (task: () => Promise<unknown>) => void;
156
+ /**
157
+ * Whether anything is recorded. Default: only on a Vercel production
158
+ * deployment, because dev and previews write to production's database.
159
+ */
160
+ enabled?: boolean;
161
+ /** Where an event with no page of its own is filed: a search with no referer. Default `/`. */
162
+ siteUrl?: string;
163
+ }
164
+ /** Headers in hand, or a reader for them run inside the deferred write: Next's `headers`. */
165
+ export type HeaderSource = HeaderReader | (() => HeaderReader | Promise<HeaderReader>);
166
+ export interface Tracker {
167
+ /**
168
+ * Record one event. `headers` are the request of whoever caused it, so the
169
+ * event joins their visitor, an agent's included; omit them for an event no
170
+ * visitor caused. Never rejects, and the caller defers it: `event.waitUntil`
171
+ * in a proxy, `after` in a route.
172
+ */
173
+ trackEvent(name: string, url: string, props: Record<string, string | number>, headers?: HeaderReader): Promise<void>;
174
+ /**
175
+ * An "MCP Call" event, deferred. The signature is `McpServerConfig.onCall`'s
176
+ * plus a server name, so a one-server site passes it as `onCall` as it is.
177
+ */
178
+ trackMcpCall(request: Request, body: unknown, server?: string): void;
179
+ /**
180
+ * A "Search Query" event, deferred, filed against the page the reader
181
+ * searched from. A function for `headers` is called inside the deferred
182
+ * write, and a failure there drops the event rather than the response.
183
+ */
184
+ trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
185
+ /**
186
+ * The beacon route: `export const POST = tracker.viewRoute()`. `site` is
187
+ * `collect`'s resolver, for an app that serves many sites or drops a path.
188
+ */
189
+ viewRoute(opts?: Pick<CollectOptions, 'site' | 'rateLimit'>): (request: Request) => Promise<Response>;
190
+ }
191
+ /**
192
+ * Everything an app's `track.ts` had written around the functions above: the
193
+ * production gate, the deferral and the three named events. Four apps carried
194
+ * it, two with a search signature of their own.
195
+ *
196
+ * The tracker holds `sql`, so the module that builds it imports the app's
197
+ * database client. A proxy must keep reaching it through a dynamic import
198
+ * inside `waitUntil`, never a static one, or every request the proxy sees
199
+ * loads Prisma:
200
+ *
201
+ * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
202
+ */
203
+ export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
154
204
  export {};
package/dist/analytics.js CHANGED
@@ -54,7 +54,7 @@
54
54
  // runs anywhere Node 20, a browser or an edge runtime does.
55
55
  import { clientIp, clientKey, rateLimit } from './rate-limit.js';
56
56
  /** Long enough to compare a month with the same month a year before. */
57
- export const KEEP_DAYS = 400;
57
+ const KEEP_DAYS = 400;
58
58
  const DAY_MS = 86_400_000;
59
59
  /** A script that runs a page's JavaScript and says what it is. */
60
60
  const BOT = /bot|crawl|spider|slurp|headless|lighthouse|prerender|preview/i;
@@ -264,3 +264,47 @@ export function searchQueryProps({ query, results, surface, }) {
264
264
  ...(surface ? { surface } : {}),
265
265
  };
266
266
  }
267
+ /**
268
+ * Everything an app's `track.ts` had written around the functions above: the
269
+ * production gate, the deferral and the three named events. Four apps carried
270
+ * it, two with a search signature of their own.
271
+ *
272
+ * The tracker holds `sql`, so the module that builds it imports the app's
273
+ * database client. A proxy must keep reaching it through a dynamic import
274
+ * inside `waitUntil`, never a static one, or every request the proxy sees
275
+ * loads Prisma:
276
+ *
277
+ * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
278
+ */
279
+ export function createTracker({ sql, defer, enabled = typeof process !== 'undefined' && process.env.VERCEL_ENV === 'production', siteUrl = '/', }) {
280
+ const trackEvent = (name, url, props, headers) => enabled ? recordEvent(sql, { name, url, props, headers }) : Promise.resolve();
281
+ return {
282
+ trackEvent,
283
+ trackMcpCall(request, body, server) {
284
+ if (!enabled)
285
+ return;
286
+ const props = mcpCallProps(request, body, server);
287
+ const { url, headers } = request;
288
+ defer(() => trackEvent('MCP Call', url, props, headers));
289
+ },
290
+ trackSearch(source, query, results, surface) {
291
+ if (!enabled)
292
+ return;
293
+ const props = searchQueryProps({ query, results, surface });
294
+ if (!props)
295
+ return;
296
+ defer(async () => {
297
+ try {
298
+ const headers = typeof source === 'function' ? await source() : source;
299
+ await trackEvent('Search Query', headers.get('referer') || siteUrl, props, headers);
300
+ }
301
+ catch {
302
+ // A context with no readable headers loses the event, never the response.
303
+ }
304
+ });
305
+ },
306
+ viewRoute(opts = {}) {
307
+ return async (request) => enabled ? collect(request, { sql, defer, ...opts }) : new Response(null, { status: 204 });
308
+ },
309
+ };
310
+ }
package/dist/blocks.d.ts CHANGED
@@ -147,3 +147,142 @@ export declare function statsToMarkdown(items: readonly StatItem[]): string;
147
147
  export declare function linkGridToMarkdown(groups: readonly LinkGridGroup[], intro?: string | null): string;
148
148
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
149
149
  export declare function linkList(items: readonly LinkGridLink[]): string;
150
+ /**
151
+ * The core leaves as the helpers below read them: the shape every wiki here
152
+ * stores, built from the leaf types above. `P` is what a resolved page list
153
+ * carries, because radix-wiki's lists hold its own page rows and the other two
154
+ * hold `ResolvedPageRef`s.
155
+ *
156
+ * Structural on purpose. A repo's own switch narrows its block to one of these
157
+ * cases and hands it over — `case 'stats': case 'linkGrid': … return
158
+ * coreAtomicText(block)` — so the switch stays exhaustive over the repo's union
159
+ * and the case bodies stop being written three times.
160
+ */
161
+ export type CoreAtomicBlock<P = ResolvedPageRef> = {
162
+ type: 'content';
163
+ text: string;
164
+ } | {
165
+ type: 'codeTabs';
166
+ tabs: readonly CodeTab[];
167
+ } | {
168
+ type: 'banner';
169
+ variant: string;
170
+ text?: string | null;
171
+ } | {
172
+ type: 'references';
173
+ title?: string | null;
174
+ items: readonly ReferenceItem[];
175
+ } | {
176
+ type: 'stats';
177
+ items: readonly StatItem[];
178
+ } | {
179
+ type: 'linkGrid';
180
+ intro?: string | null;
181
+ groups: readonly LinkGridGroup[];
182
+ } | {
183
+ type: 'recentPages' | 'pageList';
184
+ resolvedPages?: readonly P[] | null;
185
+ };
186
+ /**
187
+ * One core leaf as prose, for the LLM and MCP exports. A page list reads only
188
+ * after the server has resolved it; unresolved, it extracts to nothing.
189
+ */
190
+ export declare function coreAtomicText(block: CoreAtomicBlock<{
191
+ title: string;
192
+ }>): string;
193
+ export interface CoreMarkdownOptions {
194
+ /**
195
+ * The origin a resolved page's site-relative `href` is joined to. A twin is
196
+ * read off-site, where a bare `/wiki/…` resolves to nothing.
197
+ */
198
+ siteUrl: string;
199
+ /** A references block's heading when it stores none. Default `References`. */
200
+ referencesTitle?: string;
201
+ }
202
+ /** One core leaf as markdown, for a page's `.md` twin. */
203
+ export declare function coreAtomicToMarkdown(block: CoreAtomicBlock, opts: CoreMarkdownOptions): string;
204
+ export interface CoreBlockDefaultsOptions {
205
+ /** Ids for the nested rows a new block starts with. Default `crypto.randomUUID`. */
206
+ newId?: () => string;
207
+ /** A new references block's title. Default `References`. */
208
+ referencesTitle?: string;
209
+ /** A new code-tabs block's tabs, each empty. Default Rust and TypeScript. */
210
+ codeTabs?: ReadonlyArray<{
211
+ label: string;
212
+ language: string;
213
+ }>;
214
+ /** A new stats block's three placeholder cards. */
215
+ stats?: ReadonlyArray<{
216
+ value: string;
217
+ label: string;
218
+ }>;
219
+ }
220
+ /** What inserting each core type makes, without its `id`. */
221
+ export interface CoreBlockDefaults {
222
+ content: () => {
223
+ type: 'content';
224
+ text: string;
225
+ };
226
+ recentPages: () => {
227
+ type: 'recentPages';
228
+ limit: number;
229
+ };
230
+ pageList: () => {
231
+ type: 'pageList';
232
+ pageIds: string[];
233
+ };
234
+ columns: () => {
235
+ type: 'columns';
236
+ columns: {
237
+ id: string;
238
+ blocks: never[];
239
+ }[];
240
+ gap: 'md';
241
+ align: 'start';
242
+ };
243
+ infobox: () => {
244
+ type: 'infobox';
245
+ blocks: never[];
246
+ };
247
+ codeTabs: () => {
248
+ type: 'codeTabs';
249
+ tabs: {
250
+ label: string;
251
+ language: string;
252
+ code: string;
253
+ }[];
254
+ };
255
+ stats: () => {
256
+ type: 'stats';
257
+ items: {
258
+ id: string;
259
+ value: string;
260
+ label: string;
261
+ }[];
262
+ columns: 3;
263
+ };
264
+ linkGrid: () => {
265
+ type: 'linkGrid';
266
+ groups: {
267
+ id: string;
268
+ heading: string;
269
+ links: never[];
270
+ }[];
271
+ };
272
+ references: () => {
273
+ type: 'references';
274
+ title: string;
275
+ items: never[];
276
+ };
277
+ banner: () => {
278
+ type: 'banner';
279
+ variant: 'stub';
280
+ };
281
+ }
282
+ /**
283
+ * The new-block factories for the core types, which all three wikis had typed
284
+ * out identically but for a few labels. Spread into a repo's own record beside
285
+ * its native types, so that record stays keyed by the repo's union:
286
+ * `{ ...coreBlockDefaults({ stats }), daoTimeline: () => … }`.
287
+ */
288
+ export declare function coreBlockDefaults(opts?: CoreBlockDefaultsOptions): CoreBlockDefaults;
package/dist/blocks.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // keeps this file free of any one repo's type union.
13
13
  import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
14
14
  import { relativeTime } from './freshness.js';
15
+ import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, } from './text.js';
15
16
  export function pageRef(page, now, style = 'long') {
16
17
  return {
17
18
  title: page.title,
@@ -194,3 +195,70 @@ export function linkGridToMarkdown(groups, intro) {
194
195
  export function linkList(items) {
195
196
  return items.map(l => `- [${l.label}](${l.href})`).join('\n');
196
197
  }
198
+ const bannerLabel = (variant) => BANNER_LABELS[variant] ?? variant;
199
+ /**
200
+ * One core leaf as prose, for the LLM and MCP exports. A page list reads only
201
+ * after the server has resolved it; unresolved, it extracts to nothing.
202
+ */
203
+ export function coreAtomicText(block) {
204
+ switch (block.type) {
205
+ case 'content': return stripHtml(block.text);
206
+ case 'codeTabs': return codeTabsToText(block.tabs);
207
+ case 'banner': return bannerToText(bannerLabel(block.variant), block.text);
208
+ case 'references': return referencesToText(block.items);
209
+ case 'stats': return statsToText(block.items);
210
+ case 'linkGrid': return linkGridToText(block.groups, block.intro);
211
+ case 'recentPages':
212
+ case 'pageList': return pageListToText(block.resolvedPages ?? []);
213
+ }
214
+ }
215
+ /** One core leaf as markdown, for a page's `.md` twin. */
216
+ export function coreAtomicToMarkdown(block, opts) {
217
+ switch (block.type) {
218
+ case 'content': return htmlToMarkdown(block.text);
219
+ case 'codeTabs': return codeTabsToMarkdown(block.tabs);
220
+ case 'banner': return bannerToMarkdown(bannerLabel(block.variant), block.text);
221
+ case 'references': return referencesToMarkdown(block.items, block.title || opts.referencesTitle || 'References');
222
+ case 'stats': return statsToMarkdown(block.items);
223
+ case 'linkGrid': return linkGridToMarkdown(block.groups, block.intro);
224
+ case 'recentPages':
225
+ case 'pageList':
226
+ return linkList((block.resolvedPages ?? []).map(p => ({
227
+ label: p.title,
228
+ href: p.href.startsWith('/') ? `${opts.siteUrl}${p.href}` : p.href,
229
+ })));
230
+ }
231
+ }
232
+ /**
233
+ * The new-block factories for the core types, which all three wikis had typed
234
+ * out identically but for a few labels. Spread into a repo's own record beside
235
+ * its native types, so that record stays keyed by the repo's union:
236
+ * `{ ...coreBlockDefaults({ stats }), daoTimeline: () => … }`.
237
+ */
238
+ export function coreBlockDefaults(opts = {}) {
239
+ const { newId = () => crypto.randomUUID(), referencesTitle = 'References', codeTabs = [
240
+ { label: 'Rust', language: 'rust' },
241
+ { label: 'TypeScript', language: 'typescript' },
242
+ ], stats = [
243
+ { value: '100+', label: 'Customers' },
244
+ { value: '$1M', label: 'Revenue' },
245
+ { value: '99%', label: 'Uptime' },
246
+ ], } = opts;
247
+ return {
248
+ content: () => ({ type: 'content', text: '' }),
249
+ recentPages: () => ({ type: 'recentPages', limit: 5 }),
250
+ pageList: () => ({ type: 'pageList', pageIds: [] }),
251
+ columns: () => ({
252
+ type: 'columns',
253
+ columns: [{ id: newId(), blocks: [] }, { id: newId(), blocks: [] }],
254
+ gap: 'md',
255
+ align: 'start',
256
+ }),
257
+ infobox: () => ({ type: 'infobox', blocks: [] }),
258
+ codeTabs: () => ({ type: 'codeTabs', tabs: codeTabs.map(t => ({ ...t, code: '' })) }),
259
+ stats: () => ({ type: 'stats', items: stats.map(s => ({ id: newId(), ...s })), columns: 3 }),
260
+ linkGrid: () => ({ type: 'linkGrid', groups: [{ id: newId(), heading: 'Group', links: [] }] }),
261
+ references: () => ({ type: 'references', title: referencesTitle, items: [] }),
262
+ banner: () => ({ type: 'banner', variant: 'stub' }),
263
+ };
264
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** The options `new pg.Pool()` takes, for the fields set here. */
2
+ export interface PgPoolConfig {
3
+ connectionString: string | undefined;
4
+ max: number;
5
+ idleTimeoutMillis: number;
6
+ connectionTimeoutMillis: number;
7
+ }
8
+ /**
9
+ * Pool options sized by which Supabase pooler the URL names, because the
10
+ * ceiling belongs to the pooler and not to the app. Port 6543 is the
11
+ * TRANSACTION pooler, which exists to hold many short-lived clients; anything
12
+ * else is treated as the SESSION pooler on 5432, capped at 15 clients per
13
+ * project and shared with a build's workers, so three each is what fits.
14
+ *
15
+ * `pg` reports an exhausted pool as "timeout exceeded when trying to connect",
16
+ * which reads as a network fault and is not one: connecting takes well under a
17
+ * second, while an acquire queued behind busy clients waits the full
18
+ * `connectionTimeoutMillis`. `max: 1` was serial, and three everywhere rendered
19
+ * a page that fans out more than three queries in thirty seconds.
20
+ */
21
+ export declare function pgPoolConfig(url: string | undefined): PgPoolConfig;
package/dist/db.js ADDED
@@ -0,0 +1,27 @@
1
+ // db.ts — the pool settings every app here hands to `pg`.
2
+ //
3
+ // Four Prisma client modules carried the same block, comment and all. It is a
4
+ // value, not a client: the app still owns `new pg.Pool(...)`, the adapter and
5
+ // the globalThis cache, so this imports nothing.
6
+ /**
7
+ * Pool options sized by which Supabase pooler the URL names, because the
8
+ * ceiling belongs to the pooler and not to the app. Port 6543 is the
9
+ * TRANSACTION pooler, which exists to hold many short-lived clients; anything
10
+ * else is treated as the SESSION pooler on 5432, capped at 15 clients per
11
+ * project and shared with a build's workers, so three each is what fits.
12
+ *
13
+ * `pg` reports an exhausted pool as "timeout exceeded when trying to connect",
14
+ * which reads as a network fault and is not one: connecting takes well under a
15
+ * second, while an acquire queued behind busy clients waits the full
16
+ * `connectionTimeoutMillis`. `max: 1` was serial, and three everywhere rendered
17
+ * a page that fans out more than three queries in thirty seconds.
18
+ */
19
+ export function pgPoolConfig(url) {
20
+ return {
21
+ connectionString: url,
22
+ max: (url ?? '').includes(':6543') ? 10 : 3,
23
+ idleTimeoutMillis: 20_000,
24
+ // The acquire timeout, despite the message it fails with.
25
+ connectionTimeoutMillis: 30_000,
26
+ };
27
+ }
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
  *