wiki-formant 0.31.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,6 +170,12 @@ process.exit(t.summary());
170
170
 
171
171
  What stays in your repo is fixtures: which tools you expect, what a good answer from each looks like, and which text surfaces you publish. `payloadBudget` weighs every listed call **and** every read-only tool that takes no required arguments, because the one tool nobody thought to list is the one that answers with 3.3 MB; pass a per-call `maxBytes` for a bulk-export tool that is deliberately large. It also asserts that a JSON answer arrived with `structuredContent`.
172
172
 
173
+ `standardVersionSources(t, openapiPaths)` is the `versionCoherence` map every S10 surface serves — both agent-card paths, `mcp.json`, the server card and each path the OpenAPI document answers at — so a suite names only the paths it adds. `t.rpcAt(endpoint, …)` and `t.callAt` drive a second MCP endpoint on the same surface with the same backoff, User-Agent and tally.
174
+
175
+ ```ts
176
+ await versionCoherence(t, server.version, standardVersionSources(t, ['/openapi.json', '/api/openapi.json']), init.result?.serverInfo?.version);
177
+ ```
178
+
173
179
  `descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves, and `distinctEtagChecks(t, ['llms.txt', 'llms-index.txt', 'llms-full.txt'])` asserts that depths projecting one corpus carry different ETags — every one present, which a bare Set-size check misses.
174
180
 
175
181
  ## Markdown twins
@@ -220,6 +226,45 @@ const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true
220
226
  corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
221
227
  ```
222
228
 
229
+ ### Route errors
230
+
231
+ `json`, `errors` and `handleRoute` are the answers a route handler gives when it is not answering with data, as web-standard `Response`s a Next handler returns as they are. Every error body is `{ error, ...extra }`: one origin had these helpers, one had a second convention with a third copy inside a single route, and two answered with whatever `Response.json` was nearest, so one mistake came back three ways depending on the origin.
232
+
233
+ ```ts
234
+ import { errors, handleRoute, HttpError } from 'wiki-formant/http';
235
+
236
+ export const GET = (req: Request) => handleRoute(async () => {
237
+ const q = new URL(req.url).searchParams.get('q');
238
+ if (!q) return errors.badRequest('q is required');
239
+ if (q.length > 200) throw new HttpError(400, 'q is too long', { code: 'INVALID_PARAMS' });
240
+ return Response.json(await search(q));
241
+ }, 'Search failed');
242
+ ```
243
+
244
+ A thrown `HttpError` answers with its own status and message. Anything else thrown is logged and answered with a 500 carrying only `errorMsg`, so an exception's text — a database error naming a table — never reaches the caller. `errors.tooManyRequests(sec)` states `Retry-After`.
245
+
246
+ ## Response headers
247
+
248
+ `wiki-formant/headers` holds the six headers every route sends and the Content-Security-Policy they share, for `next.config.ts` to import:
249
+
250
+ ```ts
251
+ import { contentSecurityPolicy, securityHeaders } from 'wiki-formant/headers';
252
+
253
+ const csp = contentSecurityPolicy({ 'connect-src': ["'self'", 'https://*.radixdlt.com'] });
254
+
255
+ const nextConfig = {
256
+ async headers() {
257
+ return [{ source: '/:path*', headers: securityHeaders(process.env.NODE_ENV === 'production' ? csp : null) }];
258
+ },
259
+ };
260
+ ```
261
+
262
+ Four configs had restated these by hand on the grounds that a config could not import an ESM-only package. It could not because no subpath had a `default` condition, and Next resolves a config's imports the way `require` does; every subpath has one now, and Node 24 loads the module there unchanged.
263
+
264
+ `contentSecurityPolicy` starts from `BASE_CSP`, the policy all four origins shared line for line, and each directive passed replaces its default rather than appending, so the call site reads as the whole list. An empty list drops a directive.
265
+
266
+ The default `frame-src` is `frameSources(DEFAULT_IFRAME_HOSTS)` — the same list the sanitiser's `iframeHosts` defaults to. The sanitiser's comment had asked every config to keep the two matched by hand, and one did not: its policy lacked the bare `youtube.com` and `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning and then rendered blank. A site with its own host list passes it to both.
267
+
223
268
  ## Pagination and versioning
224
269
 
225
270
  `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.
@@ -333,10 +378,10 @@ group. Both copies were byte-identical across three repos, and they were
333
378
  different lists.
334
379
 
335
380
  ```ts
336
- import { detectAiBot, aiCrawlerRules } from 'wiki-formant/crawlers';
381
+ import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
337
382
 
338
- const bot = detectAiBot(request.headers.get('user-agent')); // label, or null
339
- const rules = aiCrawlerRules({ allow: '/', disallow, aiAllow });
383
+ trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: label, or null
384
+ const rules = crawlerRules({ allow: '/', disallow, aiAllow });
340
385
  ```
341
386
 
342
387
  Only one direction of that difference was deliberate: `Applebot-Extended` never
@@ -347,7 +392,11 @@ and a crawler obeys only its most-specific matching group, so an agent with no
347
392
  group of its own falls through to `*`. Three wikis were measuring five crawlers
348
393
  they had never addressed.
349
394
 
350
- Every group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
395
+ Every agent group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
396
+
397
+ `crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one each for `SEARCH_ENGINES`. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. `RSC_DISALLOW`, the `<Link>` prefetch payload, is refused in every group.
398
+
399
+ `trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
351
400
 
352
401
  ## Page metadata
353
402
 
@@ -366,6 +415,14 @@ The schema.org nodes sit beside it, because they state the same facts to a diffe
366
415
  <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
367
416
  ```
368
417
 
418
+ `sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
419
+
420
+ ```ts
421
+ export const card = sitePageMetadata({ siteName: SITE_NAME, handle: '@example' });
422
+ export const SITE = siteRefs(SITE_URL);
423
+ <JsonLd data={siteGraphLd({ name, url: SITE_URL, logo, sameAs, searchUrl, api })} />
424
+ ```
425
+
369
426
  ## Revisions
370
427
 
371
428
  What changed between two versions of a page, and therefore which semver bump to
@@ -540,7 +597,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
540
597
  ```ts
541
598
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
542
599
 
543
- const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // pair with CSP frame-src
600
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and frameSources(FRAME_HOSTS) in the CSP
544
601
  const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
545
602
  ```
546
603
 
@@ -584,8 +641,10 @@ address and browser under a salt that is replaced every UTC day, and the old
584
641
  salt is deleted, so a visitor is told apart within a day and never followed
585
642
  into the next. Rows are kept 400 days. The package owns the SQL and the app
586
643
  owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
587
- placeholders, and the three tables it needs are stated as Prisma models at the
588
- top of `src/analytics.ts`.
644
+ placeholders, and the four tables it needs are stated as Prisma models at the
645
+ top of `src/analytics.ts`. The fourth, `ViewDay`, holds days a site counted
646
+ elsewhere before it counted its own; `digest` adds them to any window that
647
+ reaches back that far, and a site with no such history leaves it empty.
589
648
 
590
649
  ```ts
591
650
  // src/lib/track.ts
@@ -644,15 +703,16 @@ its readers asked for. The queries that return zero rows are the valuable ones
644
703
  `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
645
704
  nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
646
705
  returns the whole of it. `gate.route` is the sign-in route,
647
- `gate.signedIn(cookieValue)` is the check a page makes, and `gate.cookie` is the
648
- cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
706
+ `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
707
+ while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
649
708
  models at the top of `src/passkey.ts`, and every token is kept only as its hash.
650
709
  `@simplewebauthn/server` is an optional peer.
651
710
 
652
- There is no sign-up. The first passkey is saved from a single-use link that the
653
- `passkey-invite` bin prints from a machine holding `DATABASE_URL`, so the only
654
- way in is through someone who can already reach the database. A passkey is bound
655
- to the host it was saved on, so localhost needs its own.
711
+ There is no sign-up. While no passkey is saved, the page saves the first one
712
+ from whoever opens it, so the owner should do that as soon as the door ships.
713
+ Every passkey after that is saved from a single-use link that the
714
+ `passkey-invite` bin prints from a machine holding `DATABASE_URL`. A passkey is
715
+ bound to the host it was saved on, so localhost needs its own.
656
716
 
657
717
  ```ts
658
718
  // src/lib/admin.ts
@@ -663,8 +723,8 @@ export const POST = gate.route;
663
723
 
664
724
  // src/app/stats/page.tsx
665
725
  if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
666
- return <PasskeyButton endpoint="/api/passkey" invite={invite} />;
667
- const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
726
+ return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
727
+ const days = statsDays((await searchParams).days); // 1, 7, 30, 90 or 365, else 30
668
728
  return <Stats digest={await digest(sql, { days })} days={days} />;
669
729
  ```
670
730
 
@@ -676,7 +736,7 @@ ranked list, and `?days=` links between windows. It ships no JavaScript.
676
736
 
677
737
  ## Base stylesheet
678
738
 
679
- `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.
739
+ `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 table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
680
740
 
681
741
  ```css
682
742
  @import "wiki-formant/base.css" layer(components);
@@ -693,6 +753,12 @@ already re-exported one module twice. The emitted `.d.ts` files are the referenc
693
753
  There is no hand-maintained symbol list here, because the one that used to be here
694
754
  drifted from them.
695
755
 
756
+ `wiki-formant/tsconfig.base.json` is the compiler floor: an app's `tsconfig.json` extends it and keeps only `paths`, `include`, `exclude` and any licensed extra, since tsconfig resolves those against the file that declares them.
757
+
758
+ ```json
759
+ { "extends": "wiki-formant/tsconfig.base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/.well-known/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["node_modules"] }
760
+ ```
761
+
696
762
  `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
697
763
  `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
698
764
  5432 session pooler, whose fifteen-client cap a build's workers share.
@@ -722,12 +788,35 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
722
788
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
723
789
  ```
724
790
 
725
- `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:
791
+ `passkey-invite` prints a single-use link that saves another passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
726
792
 
727
793
  ```sh
728
794
  npx passkey-invite https://caper.network/stats
729
795
  ```
730
796
 
797
+ `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
798
+
799
+ ```sh
800
+ npx fts-ddl pages
801
+ ```
802
+
803
+ ## A reusable workflow
804
+
805
+ `.github/workflows/publish-mcp-registry.yml` republishes an origin's server to the MCP registry when the version in its `server.json` moves. Each app keeps its own trigger and calls it with its domain:
806
+
807
+ ```yaml
808
+ on:
809
+ push: { branches: [main], paths: ['server.json'] }
810
+ workflow_dispatch:
811
+ jobs:
812
+ publish:
813
+ uses: tutmoses/wiki-formant/.github/workflows/publish-mcp-registry.yml@main
814
+ with:
815
+ domain: example.com
816
+ secrets:
817
+ MCP_REGISTRY_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_PRIVATE_KEY }}
818
+ ```
819
+
731
820
  ## Licence
732
821
 
733
822
  MIT.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // bin/fts-ddl.mjs – (re)build a table's `search_tsv` generated column and its
3
+ // GIN index, from the same expression `wiki-formant/search` queries.
4
+ //
5
+ // Run from the app's root, where `pg` is installed and `.env` holds the
6
+ // database URL. Two wikis carried this script with nothing between them but
7
+ // the table name.
8
+ //
9
+ // npx fts-ddl pages
10
+ // npx fts-ddl wiki_pages
11
+ //
12
+ // The column is rebuilt every run rather than skipped when it exists: a skip
13
+ // is blind to a changed EXPRESSION, and Postgres normalises the stored one, so
14
+ // comparing it to the string can only produce false rebuilds. DROP+ADD in one
15
+ // transaction means no reader sees the table without it. Run this BEFORE
16
+ // declaring the column in schema.prisma, never after:
17
+ //
18
+ // 1. `prisma db push` drops the column if it is not declared
19
+ // (`Unsupported("tsvector")?`), and drops the index if
20
+ // `@@index([searchTsv], type: Gin)` is not declared either.
21
+ // 2. Push must never create the column: Prisma emits a plain
22
+ // `ADD COLUMN search_tsv tsvector` with no generation expression, which
23
+ // stays empty forever and fails silently.
24
+ // 3. With both declared, push still wants `ALTER COLUMN "search_tsv" DROP
25
+ // DEFAULT`, and Postgres refuses it (42601, "is a generated column"). That
26
+ // error is the expected outcome, not a problem to fix.
27
+ //
28
+ // Connection: DIRECT_URL (the session pooler on 5432) when set, else
29
+ // DATABASE_URL moved from 6543 to 5432. Both run DDL; the session port is the
30
+ // one that never hangs on a long statement.
31
+ import pg from 'pg';
32
+ import { searchTsvDdl } from '../dist/search.js';
33
+
34
+ const table = process.argv[2];
35
+ if (!table || !/^[a-z_][a-z0-9_]*$/.test(table)) {
36
+ console.error('usage: npx fts-ddl <table>');
37
+ process.exit(1);
38
+ }
39
+
40
+ if (!process.env.DIRECT_URL && !process.env.DATABASE_URL) {
41
+ try {
42
+ process.loadEnvFile('.env');
43
+ } catch {
44
+ // reported below
45
+ }
46
+ }
47
+ const url = process.env.DIRECT_URL || process.env.DATABASE_URL?.replace(':6543', ':5432');
48
+ if (!url) {
49
+ console.error('no DIRECT_URL or DATABASE_URL in the environment or ./.env');
50
+ process.exit(1);
51
+ }
52
+
53
+ // A bad route hangs rather than erroring without a connect timeout.
54
+ const client = new pg.Client({
55
+ connectionString: url.replace(/\?.*$/, ''),
56
+ ssl: { rejectUnauthorized: false },
57
+ connectionTimeoutMillis: 15000,
58
+ });
59
+ await client.connect();
60
+ const q = s => client.query(s).then(r => r.rows);
61
+ try {
62
+ const ddl = searchTsvDdl(table);
63
+ await q('BEGIN');
64
+ for (const statement of ddl.column) await q(statement);
65
+ await q('COMMIT');
66
+ console.log('rebuilt generated column search_tsv');
67
+ await q(ddl.index);
68
+ console.log('GIN index present');
69
+ console.table(await q(`select column_name, data_type, is_generated from information_schema.columns
70
+ where table_name='${table}' and column_name='search_tsv'`));
71
+ console.table(await q(`select count(*)::int rows, count(*) filter (where search_tsv is null)::int null_tsv from ${table}`));
72
+ console.table(await q(`select pg_size_pretty(pg_relation_size('${table}_search_tsv_idx')) index_size`));
73
+ } catch (e) {
74
+ await q('ROLLBACK').catch(() => {});
75
+ console.error(e.message);
76
+ process.exitCode = 1;
77
+ } finally {
78
+ await client.end();
79
+ }
@@ -111,7 +111,8 @@ export interface Digest {
111
111
  /**
112
112
  * Everything a report needs about one site's last `days` days, in one query.
113
113
  * Visitor figures count distinct visitors, so a person who comes back on
114
- * another day counts once for each day.
114
+ * another day counts once for each day. Days in `ViewDay` add to the visitor,
115
+ * page, source, country, device and per-day figures; events have no history.
115
116
  */
116
117
  export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
117
118
  /**
@@ -196,9 +197,9 @@ export interface Tracker {
196
197
  * The tracker holds `sql`, so the module that builds it imports the app's
197
198
  * database client. A proxy must keep reaching it through a dynamic import
198
199
  * inside `waitUntil`, never a static one, or every request the proxy sees
199
- * loads Prisma:
200
+ * loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
200
201
  *
201
- * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
202
+ * trackAiBot(request, event, () => import('@/lib/track'));
202
203
  */
203
204
  export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
204
205
  export {};
package/dist/analytics.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // The package owns the SQL; the app owns the connection. `Sql` is one function
16
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,
17
+ // all supply in a line. The app declares the four tables in its own schema,
18
18
  // so `prisma db push` keeps them:
19
19
  //
20
20
  // model View {
@@ -45,6 +45,28 @@
45
45
  // value String
46
46
  // }
47
47
  //
48
+ // model ViewDay {
49
+ // siteId String @default("")
50
+ // day DateTime @db.Date
51
+ // visitors Int
52
+ // pageviews Int
53
+ // visits Int
54
+ // bounces Int
55
+ // seconds Int
56
+ // pages Json @default("{}")
57
+ // entryPages Json @default("{}")
58
+ // sources Json @default("{}")
59
+ // countries Json @default("{}")
60
+ // devices Json @default("{}")
61
+ // @@id([siteId, day])
62
+ // }
63
+ //
64
+ // `ViewDay` holds days a site counted somewhere else before it counted its
65
+ // own, one row a day with each breakdown as `{ value: visitors }`. Nothing
66
+ // writes or deletes it: the app loads it once, and `digest` adds the days in a
67
+ // window to the views. Summing days agrees with the views, whose visitor is
68
+ // one browser on one day. A site with no history leaves it empty.
69
+ //
48
70
  // `siteId` is "" in an app that serves one site. A multi-tenant app passes its
49
71
  // own site key to every call.
50
72
  //
@@ -169,13 +191,16 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
169
191
  /**
170
192
  * Everything a report needs about one site's last `days` days, in one query.
171
193
  * Visitor figures count distinct visitors, so a person who comes back on
172
- * another day counts once for each day.
194
+ * another day counts once for each day. Days in `ViewDay` add to the visitor,
195
+ * page, source, country, device and per-day figures; events have no history.
173
196
  */
174
197
  export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
175
198
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
176
199
  const values = [site, Math.max(1, Math.round(days))];
177
200
  const $ = (v) => `$${values.push(v)}`;
178
201
  const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
202
+ /** A ranked list from the views, plus the same breakdown from `ViewDay`. */
203
+ const merged = (label, column, live) => `SELECT k AS ${label}, sum(n) AS visitors FROM (${live} UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a."${column}")) t(k, n) GROUP BY 1`;
179
204
  const gapSelects = gaps.map(g => {
180
205
  const prop = $(g.prop);
181
206
  return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
@@ -195,23 +220,41 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
195
220
  FROM numbered GROUP BY visitor, visit
196
221
  ), e AS (
197
222
  SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
223
+ ), a AS (
224
+ SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
225
+ ), sessions AS (
226
+ SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
227
+ SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
228
+ UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a
229
+ ) s(bounces, visits, seconds)
230
+ ), src AS (
231
+ SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
232
+ UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
233
+ ), archived AS (
234
+ SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a
198
235
  )
199
236
  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),
237
+ 'visitors', (SELECT count(DISTINCT visitor) FROM v) + (SELECT visitors FROM archived),
238
+ 'pageviews', (SELECT count(*) FROM v) + (SELECT pageviews FROM archived),
239
+ 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
240
+ 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
241
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
205
242
  '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)},
243
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
244
+ + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
245
+ AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
246
+ AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
247
+ 'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
248
+ 'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
249
+ 'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
210
250
  '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')},
251
+ 'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
252
+ 'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
253
+ 'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
254
+ 'by_day', ${list(`SELECT date, sum(n) AS visitors FROM (
255
+ SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), count(DISTINCT visitor) FROM v GROUP BY 1
256
+ UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors FROM a
257
+ ) t(date, n) GROUP BY 1`, undefined, '1')},
215
258
  'follow_clicks', ${handle
216
259
  ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
217
260
  WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
@@ -272,9 +315,9 @@ export function searchQueryProps({ query, results, surface, }) {
272
315
  * The tracker holds `sql`, so the module that builds it imports the app's
273
316
  * database client. A proxy must keep reaching it through a dynamic import
274
317
  * inside `waitUntil`, never a static one, or every request the proxy sees
275
- * loads Prisma:
318
+ * loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
276
319
  *
277
- * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
320
+ * trackAiBot(request, event, () => import('@/lib/track'));
278
321
  */
279
322
  export function createTracker({ sql, defer, enabled = typeof process !== 'undefined' && process.env.VERCEL_ENV === 'production', siteUrl = '/', }) {
280
323
  const trackEvent = (name, url, props, headers) => enabled ? recordEvent(sql, { name, url, props, headers }) : Promise.resolve();
package/dist/base.css CHANGED
@@ -46,10 +46,63 @@
46
46
  .tab-panel:not(.active) { display: none; }
47
47
  [data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
48
48
 
49
- /* ---- table scroll boxes (scrollTables) -----------------------------------
50
- A stored table wider than its column scrolls inside its own box, at every
51
- width, instead of painting past the column. */
49
+ /* ---- table scroll boxes (scrollTables, React tables) -----------------------
50
+ A table wider than its column scrolls inside its own box, at every width,
51
+ instead of painting past the column. */
52
52
  .table-scroll { overflow-x: auto; }
53
+ .data-table-wrap { overflow-x: auto; }
54
+
55
+ /* ---- Breadcrumbs ------------------------------------------------------------
56
+ The trail is a <nav> around an <ol>; the row is the list, not the nav.
57
+ Three sites restated this row, each a little differently. */
58
+ .breadcrumbs ol {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ gap: 0.25rem;
63
+ margin: 0;
64
+ padding-left: 0;
65
+ list-style: none;
66
+ }
67
+ .breadcrumbs .separator { user-select: none; }
68
+
69
+ /* ---- spinner, skeleton, empty state ----------------------------------------
70
+ The shape and the motion; colour, size and surface are the site's. The
71
+ spinner is a ring in currentColor sized by `--spinner-size`, the skeleton a
72
+ pulse over whatever background the site gives it. Four stylesheets had
73
+ forked the spinner three ways: 0.6s and 0.8s, currentColor and a hard-coded
74
+ accent, one with no keyframe in any layer. */
75
+ @keyframes spin { to { transform: rotate(360deg); } }
76
+ @keyframes pulse { 50% { opacity: 0.5; } }
77
+ .spinner {
78
+ display: inline-block;
79
+ flex: none;
80
+ width: var(--spinner-size, 1em);
81
+ height: var(--spinner-size, 1em);
82
+ border: 2px solid currentColor;
83
+ border-top-color: transparent;
84
+ border-radius: 9999px;
85
+ animation: spin 0.6s linear infinite;
86
+ }
87
+ .skeleton { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
88
+ .empty-state { padding-block: 3rem; text-align: center; }
89
+
90
+ /* ---- long tokens ----------------------------------------------------------
91
+ An address or hash is one word, so its min-content width is the whole
92
+ string and it runs off a phone. A <pre> keeps scrolling: wrapping a listing
93
+ is not reading it. */
94
+ :not(pre) > code { overflow-wrap: anywhere; word-break: break-word; }
95
+
96
+ /* ---- reduced motion ---------------------------------------------------------
97
+ No site here honoured it. The spinner slows rather than stops, because a
98
+ still ring no longer says anything is happening; the pulse and smooth
99
+ scrolling stop. `!important` because a site's own later rule in the same
100
+ layer would otherwise put the motion back. */
101
+ @media (prefers-reduced-motion: reduce) {
102
+ html { scroll-behavior: auto !important; }
103
+ .spinner { animation-duration: 2.4s !important; }
104
+ .skeleton { animation: none !important; }
105
+ }
53
106
 
54
107
  /* ---- code copy buttons (addCopyButtons) -----------------------------------
55
108
  The script positions the <pre>; this pins the button in its corner. Shown
@@ -133,7 +186,8 @@ pre:hover > .code-copy-btn,
133
186
  .stats-chart {
134
187
  display: flex;
135
188
  align-items: flex-end;
136
- gap: 2px;
189
+ /* A year's bars on a phone would be all gap at 2px each. */
190
+ gap: min(2px, 100% / var(--bars, 1) / 4);
137
191
  height: 8rem;
138
192
  margin: 0;
139
193
  padding: 0;
@@ -70,6 +70,14 @@ export interface Tester {
70
70
  rpc(method: string, params?: unknown): Promise<Rpc>;
71
71
  /** `tools/call` shorthand. */
72
72
  call(name: string, args?: Record<string, unknown>): Promise<Rpc>;
73
+ /**
74
+ * `rpc` against another MCP endpoint on the same surface — a per-tenant
75
+ * server beside the main one — with the same backoff, User-Agent and tally.
76
+ * The one surface with a second endpoint had re-implemented the backoff for
77
+ * it, without the User-Agent.
78
+ */
79
+ rpcAt(endpoint: string, method: string, params?: unknown): Promise<Rpc>;
80
+ callAt(endpoint: string, name: string, args?: Record<string, unknown>): Promise<Rpc>;
73
81
  /** The decoded body of a tool result: parsed JSON, raw text, or the error. */
74
82
  payload(r: Rpc): unknown;
75
83
  /** Record an assertion and print it as it happens. */
@@ -107,6 +115,13 @@ export declare function versionCoherence(t: Tester, expected: string, sources: R
107
115
  url: string;
108
116
  at: (json: Record<string, unknown>) => unknown;
109
117
  }>, initVersion?: string): Promise<void>;
118
+ /** The version sources every S10 surface serves, for `versionCoherence`. */
119
+ export declare function standardVersionSources(t: Pick<Tester, 'base' | 'endpoint'>,
120
+ /** Every path the OpenAPI document is served at. */
121
+ openapi?: readonly string[]): Record<string, {
122
+ url: string;
123
+ at: (json: Record<string, unknown>) => unknown;
124
+ }>;
110
125
  /**
111
126
  * Both A2A well-known paths must serve the identical card. v0.3 renamed
112
127
  * agent.json to agent-card.json and defined no fallback either way, so an origin
@@ -45,10 +45,10 @@ export function createTester(opts) {
45
45
  // bounded number of times. This used to say "once" and recurse forever at a
46
46
  // fixed 5s, so a suite pointed at a budget it could never get under hung.
47
47
  const RETRIES = 4;
48
- const rpc = async (method, params) => {
48
+ const rpcAt = async (url, method, params) => {
49
49
  for (let attempt = 1;; attempt++) {
50
50
  calls++;
51
- const res = await fetch(endpoint, {
51
+ const res = await fetch(url, {
52
52
  method: 'POST',
53
53
  headers: { 'Content-Type': 'application/json', 'User-Agent': opts.clientName },
54
54
  body: JSON.stringify({ jsonrpc: '2.0', id: calls, method, params }),
@@ -61,11 +61,14 @@ export function createTester(opts) {
61
61
  await new Promise(r => setTimeout(r, Math.min(60, Number.isFinite(wait) && wait > 0 ? wait : 5) * 1000));
62
62
  }
63
63
  };
64
+ const rpc = (method, params) => rpcAt(endpoint, method, params);
64
65
  return {
65
66
  base,
66
67
  endpoint,
67
68
  rpc,
68
69
  call: (name, args = {}) => rpc('tools/call', { name, arguments: args }),
70
+ rpcAt,
71
+ callAt: (url, name, args = {}) => rpcAt(url, 'tools/call', { name, arguments: args }),
69
72
  payload(r) {
70
73
  const text = r.result?.content?.[0]?.text;
71
74
  if (text == null)
@@ -241,6 +244,22 @@ export async function versionCoherence(t, expected, sources, initVersion) {
241
244
  const all = Object.values(found).filter(v => v !== undefined);
242
245
  t.check('version coherence', all.every(v => v === expected), `expected=${expected} ` + Object.entries(found).map(([k, v]) => `${k}=${v}`).join(' '));
243
246
  }
247
+ /** The version sources every S10 surface serves, for `versionCoherence`. */
248
+ export function standardVersionSources(t,
249
+ /** Every path the OpenAPI document is served at. */
250
+ openapi = ['/openapi.json', '/.well-known/openapi.json']) {
251
+ const version = (j) => j.version;
252
+ return {
253
+ card: { url: `${t.base}/.well-known/agent-card.json`, at: version },
254
+ legacyCard: { url: `${t.base}/.well-known/agent.json`, at: version },
255
+ mcpManifest: { url: `${t.base}/.well-known/mcp.json`, at: version },
256
+ serverCard: { url: `${t.endpoint}/server-card`, at: version },
257
+ ...Object.fromEntries(openapi.map(path => [
258
+ `openapi:${path}`,
259
+ { url: `${t.base}${path}`, at: (j) => j.info?.version },
260
+ ])),
261
+ };
262
+ }
244
263
  /**
245
264
  * Both A2A well-known paths must serve the identical card. v0.3 renamed
246
265
  * agent.json to agent-card.json and defined no fallback either way, so an origin