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 +106 -17
- package/bin/fts-ddl.mjs +79 -0
- package/dist/analytics.d.ts +4 -3
- package/dist/analytics.js +60 -17
- package/dist/base.css +58 -4
- package/dist/conformance.d.ts +15 -0
- package/dist/conformance.js +21 -2
- package/dist/crawlers.d.ts +36 -2
- package/dist/crawlers.js +37 -8
- package/dist/headers.d.ts +35 -0
- package/dist/headers.js +78 -0
- package/dist/http.d.ts +38 -0
- package/dist/http.js +57 -1
- package/dist/metadata.d.ts +82 -0
- package/dist/metadata.js +74 -0
- package/dist/passkey-button.d.ts +3 -1
- package/dist/passkey-button.js +8 -6
- package/dist/passkey.d.ts +3 -1
- package/dist/passkey.js +16 -9
- package/dist/react-server.d.ts +1 -1
- package/dist/react-server.js +2 -2
- package/dist/sanitize.d.ts +0 -6
- package/dist/sanitize.js +1 -12
- package/package.json +91 -44
- package/tsconfig.base.json +25 -0
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 {
|
|
381
|
+
import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
|
|
337
382
|
|
|
338
|
-
|
|
339
|
-
const rules =
|
|
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 }); //
|
|
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
|
|
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,
|
|
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.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/bin/fts-ddl.mjs
ADDED
|
@@ -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
|
+
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 *
|
|
203
|
-
'visit_duration', (SELECT round(
|
|
204
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e)
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
212
|
-
'countries', ${list('SELECT country, count(DISTINCT visitor)
|
|
213
|
-
'devices', ${list('SELECT device, count(DISTINCT visitor)
|
|
214
|
-
'by_day', ${list(`SELECT
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
|
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;
|
package/dist/conformance.d.ts
CHANGED
|
@@ -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
|
package/dist/conformance.js
CHANGED
|
@@ -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
|
|
48
|
+
const rpcAt = async (url, method, params) => {
|
|
49
49
|
for (let attempt = 1;; attempt++) {
|
|
50
50
|
calls++;
|
|
51
|
-
const res = await fetch(
|
|
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
|