wiki-formant 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/analytics.js CHANGED
@@ -1,49 +1,235 @@
1
- // analytics.ts — server-side Plausible events for a wiki's agent and human lanes.
1
+ // analytics.ts – page views and events, counted in the site's own Postgres.
2
2
  //
3
- // The proxy counts an "AI Bot Visit" by matching user agents, which cannot see
4
- // inside a JSON-RPC envelope. So an MCP call is only countable by tool name at
5
- // the route, and that extraction — from a body that is untrusted and may be a
6
- // batch — is the part worth sharing. Every wiki in the workspace had its own
7
- // copy of it, differing only in the default hostname.
3
+ // It replaces a hosted analytics service. A page reports its own view to the
4
+ // site's own address (`startBeacon` in `dom.ts`, `<Beacon>` in `react.tsx`), a
5
+ // route hands the beacon to `collect`, and server code records events with
6
+ // `recordEvent`. `digest` reads it all back as one JSON object, and the
7
+ // `analytics-digest` bin prints that object for scripts outside the app.
8
8
  //
9
- // `searchQueryProps` is the human-side twin: the same shape problem, from the
10
- // other direction. A search box is untrusted free text on every wiki, and the
11
- // normalisation that makes it aggregatable — and keeps an empty field from
12
- // firing an event — is the part worth sharing rather than re-deriving.
9
+ // No cookie is set and no address is kept. A visitor is a hash of the address
10
+ // and the browser under a salt that changes every UTC day, and the old salt is
11
+ // deleted when the new one is made, so a visitor can be told apart within a day
12
+ // and never followed into the next. A visit is a run of one visitor's views
13
+ // with no gap over 30 minutes.
13
14
  //
14
- // Deferral stays with the caller: `after()` in a route handler,
15
- // `event.waitUntil` in a proxy. Both are framework calls, and importing one
16
- // here would cost this package its zero-dependency guarantee.
17
- const DEFAULT_ENDPOINT = 'https://plausible.io/api/event';
15
+ // The package owns the SQL; the app owns the connection. `Sql` is one function
16
+ // that runs a parameterised statement, which Prisma, `pg` and postgres.js can
17
+ // all supply in a line. The app declares the three tables in its own schema,
18
+ // so `prisma db push` keeps them:
19
+ //
20
+ // model View {
21
+ // id BigInt @id @default(autoincrement())
22
+ // siteId String @default("")
23
+ // path String
24
+ // source String?
25
+ // country String?
26
+ // device String
27
+ // visitor String
28
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
29
+ // @@index([siteId, createdAt])
30
+ // }
31
+ //
32
+ // model Event {
33
+ // id BigInt @id @default(autoincrement())
34
+ // siteId String @default("")
35
+ // name String
36
+ // path String?
37
+ // props Json @default("{}")
38
+ // visitor String?
39
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
40
+ // @@index([siteId, name, createdAt])
41
+ // }
42
+ //
43
+ // model Salt {
44
+ // day String @id
45
+ // value String
46
+ // }
47
+ //
48
+ // `siteId` is "" in an app that serves one site. A multi-tenant app passes its
49
+ // own site key to every call.
50
+ //
51
+ // Deferral stays with the caller: `after()` in a route, `event.waitUntil` in a
52
+ // proxy. Both are framework calls, and importing one here would cost this
53
+ // package its zero-dependency guarantee. Hashing is Web Crypto, so the module
54
+ // runs anywhere Node 20, a browser or an edge runtime does.
55
+ import { clientIp, clientKey, rateLimit } from './rate-limit.js';
56
+ /** Long enough to compare a month with the same month a year before. */
57
+ const KEEP_DAYS = 400;
58
+ const DAY_MS = 86_400_000;
59
+ /** A script that runs a page's JavaScript and says what it is. */
60
+ const BOT = /bot|crawl|spider|slurp|headless|lighthouse|prerender|preview/i;
61
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
62
+ export const deviceOf = (ua) => /iPad|Tablet|Android(?!.*Mobile)/i.test(ua) ? 'tablet' : /Mobi|iPhone|Android/i.test(ua) ? 'phone' : 'computer';
63
+ const bareHost = (host) => host.toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
18
64
  /**
19
- * The exact request shape Plausible's events API expects.
20
- *
21
- * Never rejects: analytics must not be able to fail a response the reader is
22
- * waiting on.
65
+ * Where a visit came from: its `utm_source` when the link carried one, else the
66
+ * referring host, and null for a referrer that is this site or none.
23
67
  */
24
- export function plausibleEvent(config, name, url, props, headers, extra = {}) {
25
- return fetch(config.endpoint ?? DEFAULT_ENDPOINT, {
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- 'User-Agent': extra.userAgent || 'Mozilla/5.0 (compatible; BotTracker/1.0)',
30
- 'X-Forwarded-For': headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1',
31
- },
32
- body: JSON.stringify({
33
- name,
34
- url,
35
- domain: config.domain,
36
- props: JSON.stringify(props),
37
- ...(extra.revenue ? { revenue: extra.revenue } : {}),
38
- }),
39
- }).catch(() => undefined);
68
+ export function sourceOf(referrer, utm, host) {
69
+ if (typeof utm === 'string' && utm.trim())
70
+ return utm.trim().toLowerCase().slice(0, 100);
71
+ if (typeof referrer !== 'string' || !referrer)
72
+ return null;
73
+ try {
74
+ const from = bareHost(new URL(referrer).hostname);
75
+ return from && from !== bareHost(host) ? from.slice(0, 200) : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
82
+ export function normalisePath(path) {
83
+ try {
84
+ const p = decodeURIComponent(path.split(/[?#]/)[0].slice(0, 500));
85
+ return `/${p.replace(/^\/+|\/+$/g, '')}`;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ const hex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
92
+ const salts = new WeakMap();
93
+ /** Today's salt. Whoever makes it also deletes yesterday's, and rows past keeping. */
94
+ async function todaysSalt(sql) {
95
+ const day = new Date().toISOString().slice(0, 10);
96
+ const cached = salts.get(sql);
97
+ if (cached?.day === day)
98
+ return cached.value;
99
+ const made = (await sql('INSERT INTO "Salt" (day, value) VALUES ($1, $2) ON CONFLICT (day) DO NOTHING RETURNING day', day, hex(crypto.getRandomValues(new Uint8Array(32)))));
100
+ if (made.length) {
101
+ const cutoff = new Date(Date.now() - KEEP_DAYS * DAY_MS);
102
+ await sql('DELETE FROM "Salt" WHERE day < $1', day);
103
+ await sql('DELETE FROM "View" WHERE "createdAt" < $1', cutoff);
104
+ await sql('DELETE FROM "Event" WHERE "createdAt" < $1', cutoff);
105
+ }
106
+ const [row] = (await sql('SELECT value FROM "Salt" WHERE day = $1', day));
107
+ salts.set(sql, { day, value: row.value });
108
+ return row.value;
109
+ }
110
+ async function visitorOf(sql, site, headers) {
111
+ const text = [await todaysSalt(sql), site, clientIp(headers), headers.get('user-agent') ?? ''].join('\n');
112
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
113
+ return hex(new Uint8Array(digest)).slice(0, 32);
114
+ }
115
+ /** Analytics must not be able to fail a response, so a failed write is logged and dropped. */
116
+ const quietly = (task) => task().then(() => undefined, (e) => console.error(`analytics: ${e.message}`));
117
+ /** Count one page view, unless a bot sent it. Never rejects. */
118
+ export function recordView(sql, { headers, path, referrer, utm, site = '' }) {
119
+ const ua = headers.get('user-agent') ?? '';
120
+ const page = normalisePath(path);
121
+ if (!ua || BOT.test(ua) || page === null)
122
+ return Promise.resolve();
123
+ return quietly(async () => sql('INSERT INTO "View" ("siteId", path, source, country, device, visitor) VALUES ($1, $2, $3, $4, $5, $6)', site, page, sourceOf(referrer, utm, headers.get('host') ?? ''), headers.get('x-vercel-ip-country')?.slice(0, 2) || null, deviceOf(ua), await visitorOf(sql, site, headers)));
124
+ }
125
+ /** Record one named event. Never rejects. */
126
+ export function recordEvent(sql, { name, props = {}, url, headers, site = '' }) {
127
+ return quietly(async () => {
128
+ let path = null;
129
+ if (url)
130
+ path = normalisePath(new URL(url, 'http://x').pathname);
131
+ return sql('INSERT INTO "Event" ("siteId", name, path, props, visitor) VALUES ($1, $2, $3, $4::jsonb, $5)', site, name.slice(0, 64), path, JSON.stringify(props), headers?.get('user-agent') ? await visitorOf(sql, site, headers) : null);
132
+ });
133
+ }
134
+ /** A browser-sent event's props: eight at most, each value a short string or a number. */
135
+ function beaconProps(props) {
136
+ if (!props || typeof props !== 'object' || Array.isArray(props))
137
+ return {};
138
+ return Object.fromEntries(Object.entries(props)
139
+ .filter(([, v]) => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)))
140
+ .slice(0, 8)
141
+ .map(([k, v]) => [k.slice(0, 40), typeof v === 'string' ? v.slice(0, 300) : v]));
142
+ }
143
+ // A person reads a few pages a minute. This leaves room for a fast reader and
144
+ // stops one address filling a site's figures.
145
+ const BEACONS = { capacity: 30, refillPerSec: 0.5 };
146
+ /**
147
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
148
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
149
+ */
150
+ export async function collect(request, { sql, defer, site, rateLimit: limit = BEACONS }) {
151
+ const done = new Response(null, { status: 204 });
152
+ const { headers } = request;
153
+ if (BOT.test(headers.get('user-agent') ?? '') || !rateLimit(clientKey('beacon', headers), limit).ok)
154
+ return done;
155
+ const body = (await request.json().catch(() => null));
156
+ const path = typeof body?.path === 'string' ? normalisePath(body.path) : null;
157
+ if (!body || !path)
158
+ return done;
159
+ const key = site ? await site(request, path) : '';
160
+ if (key === null)
161
+ return done;
162
+ defer(() => typeof body.name === 'string' && body.name
163
+ ? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: path, headers, site: key })
164
+ : recordView(sql, { headers, path, referrer: body.referrer, utm: body.utm, site: key }));
165
+ return done;
166
+ }
167
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
168
+ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
169
+ /**
170
+ * Everything a report needs about one site's last `days` days, in one query.
171
+ * Visitor figures count distinct visitors, so a person who comes back on
172
+ * another day counts once for each day.
173
+ */
174
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
175
+ // The window is the database's clock, which stamped the rows; the app's can run behind it.
176
+ const values = [site, Math.max(1, Math.round(days))];
177
+ const $ = (v) => `$${values.push(v)}`;
178
+ const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
179
+ const gapSelects = gaps.map(g => {
180
+ const prop = $(g.prop);
181
+ return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
182
+ WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
183
+ GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
184
+ });
185
+ const [row] = (await sql(`WITH v AS (
186
+ SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
187
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
188
+ ), marked AS (
189
+ SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM v
190
+ ), numbered AS (
191
+ SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
192
+ ), visits AS (
193
+ SELECT visitor, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
194
+ (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
195
+ FROM numbered GROUP BY visitor, visit
196
+ ), e AS (
197
+ SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
198
+ )
199
+ SELECT json_build_object(
200
+ 'visitors', (SELECT count(DISTINCT visitor) FROM v),
201
+ 'pageviews', (SELECT count(*) FROM v),
202
+ 'bounce_rate', (SELECT round(100.0 * count(*) FILTER (WHERE views = 1) / nullif(count(*), 0)) FROM visits),
203
+ 'visit_duration', (SELECT round(avg(seconds)) FROM visits),
204
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) a),
205
+ 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
206
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int)),
207
+ 'top_sources', ${list(`SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1`, 6)},
208
+ 'top_pages', ${list('SELECT path AS page, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1', 6)},
209
+ 'entry_pages', ${list('SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1', 6)},
210
+ 'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
211
+ 'sibling_referrals', ${list(`SELECT source, count(DISTINCT visitor) AS visitors FROM visits WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
212
+ 'countries', ${list('SELECT country, count(DISTINCT visitor) AS visitors FROM v WHERE country IS NOT NULL GROUP BY 1', 10)},
213
+ 'devices', ${list('SELECT device, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1')},
214
+ 'by_day', ${list(`SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1`, undefined, '1')},
215
+ 'follow_clicks', ${handle
216
+ ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
217
+ WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
218
+ : 'NULL'},
219
+ 'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
220
+ WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
221
+ AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
222
+ GROUP BY 1`, 10)},
223
+ 'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
224
+ ) AS digest`, ...values));
225
+ return row.digest;
40
226
  }
41
227
  /**
42
228
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
43
229
  *
44
230
  * The body is untrusted and may be a batch, a notification, or malformed
45
231
  * entirely, so every extraction is defensive and the result is always a
46
- * complete props object. A batch is attributed to its first member — the
232
+ * complete props object. A batch is attributed to its first member – the
47
233
  * alternative is one event per member, which would make a fan-out look like
48
234
  * traffic it is not.
49
235
  */
@@ -55,7 +241,7 @@ export function mcpCallProps(request, body, server) {
55
241
  return { ...(server ? { server } : {}), method, ...(tool ? { tool } : {}), ua };
56
242
  }
57
243
  /**
58
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
244
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
59
245
  *
60
246
  * A wiki instruments its agent surface and then counts every question an agent
61
247
  * asks while recording none of the questions a person asks. The queries that
@@ -64,7 +250,7 @@ export function mcpCallProps(request, body, server) {
64
250
  *
65
251
  * Returns `null` for a query with no content, so an empty field cannot fire an
66
252
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
67
- * and truncated — the same 64-character bound search itself applies, which
253
+ * and truncated – the same 64-character bound search itself applies, which
68
254
  * keeps the prop and the query that produced it the same string.
69
255
  */
70
256
  export function searchQueryProps({ query, results, surface, }) {
@@ -73,18 +259,52 @@ export function searchQueryProps({ query, results, surface, }) {
73
259
  return null;
74
260
  return {
75
261
  q,
76
- // A string because Plausible props are strings; `results == "0"` is the
77
- // filter that yields the gap list.
262
+ // A string, so `{ results: '0' }` is the filter that yields the gap list.
78
263
  results: String(Math.max(0, Math.trunc(results) || 0)),
79
264
  ...(surface ? { surface } : {}),
80
265
  };
81
266
  }
82
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
83
- export function plausibleDomain(siteUrl, fallback) {
84
- try {
85
- return new URL(siteUrl || fallback).hostname;
86
- }
87
- catch {
88
- return fallback.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
89
- }
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
+ };
90
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/dom.d.ts CHANGED
@@ -103,3 +103,18 @@ export declare function sortTables(root: ParentNode, options?: SortTablesOptions
103
103
  * A table already in a box keeps it, so a second pass changes nothing.
104
104
  */
105
105
  export declare function scrollTables(html: string): string;
106
+ /**
107
+ * Report each page view to the site's own `endpoint`, whose route hands it to
108
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
109
+ * runs and no blocklist names the request.
110
+ *
111
+ * A client-side router moves between pages with `history.pushState`, which no
112
+ * load event marks, so both history writes are wrapped and a view is sent
113
+ * whenever the path changes; a change to the query or hash alone is not a page.
114
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
115
+ * carrying its `url`. Starts once per page load however often it is called, so
116
+ * React's doubled effects in development send one view, not two.
117
+ */
118
+ export declare function startBeacon(endpoint?: string): void;
119
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
120
+ export declare function track(name: string, props?: Record<string, string | number>): void;