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/README.md +106 -31
- package/bin/analytics-digest.mjs +57 -0
- package/dist/analytics.d.ts +166 -26
- package/dist/analytics.js +267 -47
- 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/dom.d.ts +15 -0
- package/dist/dom.js +57 -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/rate-limit.d.ts +28 -2
- package/dist/rate-limit.js +55 -3
- package/dist/react.d.ts +7 -0
- package/dist/react.js +9 -1
- package/dist/validation.d.ts +29 -0
- package/dist/validation.js +51 -0
- package/package.json +12 -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/dist/analytics.js
CHANGED
|
@@ -1,49 +1,235 @@
|
|
|
1
|
-
// analytics.ts
|
|
1
|
+
// analytics.ts – page views and events, counted in the site's own Postgres.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
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
|
-
*
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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;
|