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 +104 -24
- package/bin/passkey-invite.mjs +43 -0
- package/dist/analytics.d.ts +52 -2
- package/dist/analytics.js +45 -1
- package/dist/base.css +54 -0
- package/dist/blocks.d.ts +139 -0
- package/dist/blocks.js +68 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +27 -0
- package/dist/feed.d.ts +0 -1
- package/dist/feed.js +1 -1
- package/dist/freshness.d.ts +6 -1
- package/dist/freshness.js +7 -3
- package/dist/http.d.ts +10 -0
- package/dist/http.js +13 -0
- package/dist/link-check.d.ts +0 -24
- package/dist/link-check.js +3 -3
- package/dist/mcp.d.ts +6 -5
- package/dist/mcp.js +4 -3
- package/dist/passkey-button.d.ts +9 -0
- package/dist/passkey-button.js +36 -0
- package/dist/passkey.d.ts +27 -0
- package/dist/passkey.js +130 -0
- package/dist/rate-limit.d.ts +28 -2
- package/dist/rate-limit.js +55 -3
- package/dist/react-server.d.ts +20 -0
- package/dist/react-server.js +55 -0
- package/dist/validation.d.ts +29 -0
- package/dist/validation.js +51 -0
- package/package.json +26 -25
- package/dist/index.d.ts +0 -22
- package/dist/index.js +0 -27
- package/dist/rola.d.ts +0 -127
- package/dist/rola.js +0 -150
- package/dist/x402.d.ts +0 -95
- package/dist/x402.js +0 -212
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
|
-
|
|
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 = (
|
|
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.
|
|
578
|
-
deferral to the framework, so this package keeps
|
|
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,
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
+
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|