wiki-formant 0.28.0 → 0.30.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' });
@@ -601,9 +632,44 @@ leaves a wiki able to say what every crawler asked for and nothing about what
601
632
  its readers asked for. The queries that return zero rows are the valuable ones
602
633
  – they name a gap in the corpus in the reader's own words.
603
634
 
635
+ ## Passkeys and the stats page
636
+
637
+ `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
638
+ nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
639
+ returns the whole of it. `gate.route` is the sign-in route,
640
+ `gate.signedIn(cookieValue)` is the check a page makes, and `gate.cookie` is the
641
+ cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
642
+ models at the top of `src/passkey.ts`, and every token is kept only as its hash.
643
+ `@simplewebauthn/server` is an optional peer.
644
+
645
+ There is no sign-up. The first passkey is saved from a single-use link that the
646
+ `passkey-invite` bin prints from a machine holding `DATABASE_URL`, so the only
647
+ way in is through someone who can already reach the database. A passkey is bound
648
+ to the host it was saved on, so localhost needs its own.
649
+
650
+ ```ts
651
+ // src/lib/admin.ts
652
+ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
653
+
654
+ // src/app/api/passkey/route.ts
655
+ export const POST = gate.route;
656
+
657
+ // src/app/stats/page.tsx
658
+ if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
659
+ return <PasskeyButton endpoint="/api/passkey" invite={invite} />;
660
+ const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
661
+ return <Stats digest={await digest(sql, { days })} days={days} />;
662
+ ```
663
+
664
+ `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
665
+ own subpath, because it imports the optional peer `@simplewebauthn/browser`.
666
+ `Stats` (`wiki-formant/react-server`) renders a `digest` as one page: the
667
+ headline figures against the window before, visitors per day, every non-empty
668
+ ranked list, and `?days=` links between windows. It ships no JavaScript.
669
+
604
670
  ## Base stylesheet
605
671
 
606
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a stored table's scroll box, and the `aria-sort` arrow as a mask over `currentColor`. No colour and no scale, so a design system's own rules override it at equal specificity.
672
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a stored table's scroll box, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. No colour and no scale, so a design system's own rules override it at equal specificity.
607
673
 
608
674
  ```css
609
675
  @import "wiki-formant/base.css" layer(components);
@@ -613,15 +679,23 @@ No view emits a Tailwind utility any more — `ColumnsView` states its gap and a
613
679
 
614
680
  ## API
615
681
 
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.
682
+ Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
683
+ and there is no root export: `import … from 'wiki-formant'` does not resolve. A
684
+ barrel loads every module it names for the one symbol a route wanted, and had
685
+ already re-exported one module twice. The emitted `.d.ts` files are the reference.
686
+ There is no hand-maintained symbol list here, because the one that used to be here
687
+ drifted from them.
619
688
 
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.
689
+ `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
690
+ `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
691
+ 5432 session pooler, whose fifteen-client cap a build's workers share.
692
+
693
+ `rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
694
+ caller already shown to be abusive: running dry blocks the key outright for that
695
+ long, across every bucket that names it, rather than letting the refill trickle
696
+ one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
697
+ and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
698
+ `x-real-ip` was set, since a proxy that appends hands the client that slot.
625
699
 
626
700
  ## A bin
627
701
 
@@ -641,6 +715,12 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
641
715
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
642
716
  ```
643
717
 
718
+ `passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
719
+
720
+ ```sh
721
+ npx passkey-invite https://caper.network/stats
722
+ ```
723
+
644
724
  ## Licence
645
725
 
646
726
  MIT.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // bin/passkey-invite.mjs – print a single-use link that saves a passkey for a
3
+ // site's admin pages (`wiki-formant/passkey`). The first passkey can only come
4
+ // from here, so the only way in is through someone who holds the database URL.
5
+ //
6
+ // Run from the app's root, where `pg` is installed and `.env` holds
7
+ // DATABASE_URL (read from the environment first). The link is good for a day.
8
+ //
9
+ // npx passkey-invite https://caper.network/stats
10
+ // npx passkey-invite http://localhost:3000/stats --days 7
11
+ import pg from 'pg';
12
+ import { createPasskeyGate } from '../dist/passkey.js';
13
+
14
+ const args = process.argv.slice(2);
15
+ const page = args.find(a => /^https?:\/\//.test(a));
16
+ const days = Number(args[args.indexOf('--days') + 1] ?? 1);
17
+
18
+ if (!page) {
19
+ console.error('usage: passkey-invite <page url> [--days n]');
20
+ process.exit(1);
21
+ }
22
+ if (!process.env.DATABASE_URL) {
23
+ try {
24
+ process.loadEnvFile('.env');
25
+ } catch {
26
+ // reported below
27
+ }
28
+ }
29
+ if (!process.env.DATABASE_URL) {
30
+ console.error('no DATABASE_URL in the environment or ./.env');
31
+ process.exit(1);
32
+ }
33
+
34
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
35
+ try {
36
+ await client.connect();
37
+ const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
38
+ const url = new URL(page);
39
+ url.searchParams.set('invite', await createPasskeyGate({ sql, rpName: '' }).invite(args.includes('--days') ? days : 1));
40
+ console.log(url.href);
41
+ } finally {
42
+ await client.end().catch(() => {});
43
+ }
@@ -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/base.css CHANGED
@@ -116,3 +116,57 @@ pre:hover > .code-copy-btn,
116
116
  list-style: none;
117
117
  }
118
118
  .revision-diff { margin: 0.25em 0 0; }
119
+
120
+ /* ---- Stats ----------------------------------------------------------------
121
+ `--share` is a row's value over the largest in its list, 0 to 1. Bars take
122
+ `currentColor` so they show before a stylesheet colours them. */
123
+ .stats { display: grid; gap: 2rem; }
124
+ .stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
125
+ .stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
126
+ .stats-figures {
127
+ display: grid;
128
+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
129
+ gap: 1rem;
130
+ margin: 0;
131
+ }
132
+ .stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
133
+ .stats-chart {
134
+ display: flex;
135
+ align-items: flex-end;
136
+ gap: 2px;
137
+ height: 8rem;
138
+ margin: 0;
139
+ padding: 0;
140
+ list-style: none;
141
+ }
142
+ .stats-chart li {
143
+ flex: 1;
144
+ height: calc(var(--share) * 100%);
145
+ min-height: 1px;
146
+ background: currentColor;
147
+ opacity: 0.4;
148
+ }
149
+ .stats-lists {
150
+ display: grid;
151
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
152
+ gap: 2rem;
153
+ }
154
+ .stats-list ol { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
155
+ .stats-list li {
156
+ position: relative;
157
+ display: flex;
158
+ justify-content: space-between;
159
+ gap: 1rem;
160
+ padding: 0.25em 0.5em;
161
+ }
162
+ .stats-list li::before {
163
+ content: '';
164
+ position: absolute;
165
+ inset: 0 auto 0 0;
166
+ width: calc(var(--share) * 100%);
167
+ background: currentColor;
168
+ opacity: 0.08;
169
+ pointer-events: none;
170
+ }
171
+ .stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
172
+ .stats-count { font-variant-numeric: tabular-nums; }
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
+ }