wiki-formant 0.31.0 → 0.33.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 +101 -14
- package/bin/fts-ddl.mjs +79 -0
- package/dist/analytics.d.ts +2 -2
- package/dist/analytics.js +2 -2
- package/dist/base.css +56 -3
- 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/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
|
|
|
@@ -644,15 +701,16 @@ its readers asked for. The queries that return zero rows are the valuable ones
|
|
|
644
701
|
`wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
|
|
645
702
|
nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
|
|
646
703
|
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
|
|
704
|
+
`gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
|
|
705
|
+
while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
|
|
649
706
|
models at the top of `src/passkey.ts`, and every token is kept only as its hash.
|
|
650
707
|
`@simplewebauthn/server` is an optional peer.
|
|
651
708
|
|
|
652
|
-
There is no sign-up.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
709
|
+
There is no sign-up. While no passkey is saved, the page saves the first one
|
|
710
|
+
from whoever opens it, so the owner should do that as soon as the door ships.
|
|
711
|
+
Every passkey after that is saved from a single-use link that the
|
|
712
|
+
`passkey-invite` bin prints from a machine holding `DATABASE_URL`. A passkey is
|
|
713
|
+
bound to the host it was saved on, so localhost needs its own.
|
|
656
714
|
|
|
657
715
|
```ts
|
|
658
716
|
// src/lib/admin.ts
|
|
@@ -663,7 +721,7 @@ export const POST = gate.route;
|
|
|
663
721
|
|
|
664
722
|
// src/app/stats/page.tsx
|
|
665
723
|
if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
|
|
666
|
-
return <PasskeyButton endpoint="/api/passkey" invite={invite} />;
|
|
724
|
+
return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
|
|
667
725
|
const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
|
|
668
726
|
return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
669
727
|
```
|
|
@@ -676,7 +734,7 @@ ranked list, and `?days=` links between windows. It ships no JavaScript.
|
|
|
676
734
|
|
|
677
735
|
## Base stylesheet
|
|
678
736
|
|
|
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
|
|
737
|
+
`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
738
|
|
|
681
739
|
```css
|
|
682
740
|
@import "wiki-formant/base.css" layer(components);
|
|
@@ -693,6 +751,12 @@ already re-exported one module twice. The emitted `.d.ts` files are the referenc
|
|
|
693
751
|
There is no hand-maintained symbol list here, because the one that used to be here
|
|
694
752
|
drifted from them.
|
|
695
753
|
|
|
754
|
+
`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.
|
|
755
|
+
|
|
756
|
+
```json
|
|
757
|
+
{ "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"] }
|
|
758
|
+
```
|
|
759
|
+
|
|
696
760
|
`wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
|
|
697
761
|
`new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
|
|
698
762
|
5432 session pooler, whose fifteen-client cap a build's workers share.
|
|
@@ -722,12 +786,35 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
|
|
|
722
786
|
npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
|
|
723
787
|
```
|
|
724
788
|
|
|
725
|
-
`passkey-invite` prints a single-use link that saves
|
|
789
|
+
`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
790
|
|
|
727
791
|
```sh
|
|
728
792
|
npx passkey-invite https://caper.network/stats
|
|
729
793
|
```
|
|
730
794
|
|
|
795
|
+
`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.
|
|
796
|
+
|
|
797
|
+
```sh
|
|
798
|
+
npx fts-ddl pages
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
## A reusable workflow
|
|
802
|
+
|
|
803
|
+
`.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:
|
|
804
|
+
|
|
805
|
+
```yaml
|
|
806
|
+
on:
|
|
807
|
+
push: { branches: [main], paths: ['server.json'] }
|
|
808
|
+
workflow_dispatch:
|
|
809
|
+
jobs:
|
|
810
|
+
publish:
|
|
811
|
+
uses: tutmoses/wiki-formant/.github/workflows/publish-mcp-registry.yml@main
|
|
812
|
+
with:
|
|
813
|
+
domain: example.com
|
|
814
|
+
secrets:
|
|
815
|
+
MCP_REGISTRY_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_PRIVATE_KEY }}
|
|
816
|
+
```
|
|
817
|
+
|
|
731
818
|
## Licence
|
|
732
819
|
|
|
733
820
|
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
|
@@ -196,9 +196,9 @@ export interface Tracker {
|
|
|
196
196
|
* The tracker holds `sql`, so the module that builds it imports the app's
|
|
197
197
|
* database client. A proxy must keep reaching it through a dynamic import
|
|
198
198
|
* inside `waitUntil`, never a static one, or every request the proxy sees
|
|
199
|
-
* loads Prisma:
|
|
199
|
+
* loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
|
|
200
200
|
*
|
|
201
|
-
* event
|
|
201
|
+
* trackAiBot(request, event, () => import('@/lib/track'));
|
|
202
202
|
*/
|
|
203
203
|
export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
|
|
204
204
|
export {};
|
package/dist/analytics.js
CHANGED
|
@@ -272,9 +272,9 @@ export function searchQueryProps({ query, results, surface, }) {
|
|
|
272
272
|
* The tracker holds `sql`, so the module that builds it imports the app's
|
|
273
273
|
* database client. A proxy must keep reaching it through a dynamic import
|
|
274
274
|
* inside `waitUntil`, never a static one, or every request the proxy sees
|
|
275
|
-
* loads Prisma:
|
|
275
|
+
* loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
|
|
276
276
|
*
|
|
277
|
-
* event
|
|
277
|
+
* trackAiBot(request, event, () => import('@/lib/track'));
|
|
278
278
|
*/
|
|
279
279
|
export function createTracker({ sql, defer, enabled = typeof process !== 'undefined' && process.env.VERCEL_ENV === 'production', siteUrl = '/', }) {
|
|
280
280
|
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
|
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
|
package/dist/crawlers.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Tracker } from './analytics.js';
|
|
2
|
+
import type { HeaderReader } from './rate-limit.js';
|
|
1
3
|
export interface AiCrawler {
|
|
2
4
|
/** Matched as a substring of the User-Agent, and the robots.txt token. */
|
|
3
5
|
token: string;
|
|
@@ -36,16 +38,48 @@ export interface RobotsGroup {
|
|
|
36
38
|
*/
|
|
37
39
|
export declare const AGENT_SURFACE_PATHS: readonly string[];
|
|
38
40
|
/**
|
|
39
|
-
* The
|
|
41
|
+
* The React Server Components payload a `<Link>` prefetches. A crawler that
|
|
42
|
+
* renders a page runs those prefetches too — 43% of Googlebot's requests on
|
|
43
|
+
* one wiki, against 10% for HTML — and nothing that reads robots.txt can use
|
|
44
|
+
* one. Every group gets it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const RSC_DISALLOW: readonly string[];
|
|
47
|
+
/** The search engines that get the HTML site and none of its twins. */
|
|
48
|
+
export declare const SEARCH_ENGINES: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Every group robots.txt needs: the wildcard, one per AI crawler, one per
|
|
51
|
+
* search engine.
|
|
40
52
|
*
|
|
41
53
|
* Every named agent gets an explicit `disallow`. Omitting it is the failure the
|
|
42
54
|
* comment in all three `robots.ts` files warned about and all three then
|
|
43
55
|
* committed for five agents: a named group that disallows nothing grants that
|
|
44
56
|
* agent everything, because it stops matching `*` the moment it matches itself.
|
|
57
|
+
*
|
|
58
|
+
* The search-engine groups exist because `*` keeps the machine surface open to
|
|
59
|
+
* an agent the origin has never heard of, which is right for an agent and wrong
|
|
60
|
+
* for Google: a `.md` twin or its JSON is a page it already has in another
|
|
61
|
+
* format. Two origins wrote that group by hand and the third never did, so
|
|
62
|
+
* Google crawled its twins.
|
|
45
63
|
*/
|
|
46
|
-
export declare function
|
|
64
|
+
export declare function crawlerRules(opts: {
|
|
47
65
|
allow: string | string[];
|
|
48
66
|
disallow: string | string[];
|
|
49
67
|
/** Beyond `AGENT_SURFACE_PATHS`, which every group gets regardless. */
|
|
50
68
|
aiAllow?: string | string[];
|
|
69
|
+
/** What search engines are refused beyond `disallow`. `/*.md$` always is. */
|
|
70
|
+
searchDisallow?: string | string[];
|
|
51
71
|
}): RobotsGroup[];
|
|
72
|
+
/**
|
|
73
|
+
* Count an AI crawler's visit from a proxy, without holding up its response.
|
|
74
|
+
*
|
|
75
|
+
* `load` is a dynamic import of the module that records it, because that
|
|
76
|
+
* module pulls in a database client, and a static import would load it for
|
|
77
|
+
* every request the proxy sees, bots or not. Three proxies wrote these lines.
|
|
78
|
+
* Returns the bot's label, or null.
|
|
79
|
+
*/
|
|
80
|
+
export declare function trackAiBot(request: {
|
|
81
|
+
url: string;
|
|
82
|
+
headers: HeaderReader;
|
|
83
|
+
}, event: {
|
|
84
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
85
|
+
}, load: () => Promise<Pick<Tracker, 'trackEvent'>>): string | null;
|
package/dist/crawlers.js
CHANGED
|
@@ -67,15 +67,33 @@ export const AGENT_SURFACE_PATHS = [
|
|
|
67
67
|
'/.well-known/',
|
|
68
68
|
];
|
|
69
69
|
/**
|
|
70
|
-
* The
|
|
70
|
+
* The React Server Components payload a `<Link>` prefetches. A crawler that
|
|
71
|
+
* renders a page runs those prefetches too — 43% of Googlebot's requests on
|
|
72
|
+
* one wiki, against 10% for HTML — and nothing that reads robots.txt can use
|
|
73
|
+
* one. Every group gets it.
|
|
74
|
+
*/
|
|
75
|
+
export const RSC_DISALLOW = ['/*?_rsc=', '/*&_rsc='];
|
|
76
|
+
/** The search engines that get the HTML site and none of its twins. */
|
|
77
|
+
export const SEARCH_ENGINES = ['Googlebot', 'Bingbot'];
|
|
78
|
+
/**
|
|
79
|
+
* Every group robots.txt needs: the wildcard, one per AI crawler, one per
|
|
80
|
+
* search engine.
|
|
71
81
|
*
|
|
72
82
|
* Every named agent gets an explicit `disallow`. Omitting it is the failure the
|
|
73
83
|
* comment in all three `robots.ts` files warned about and all three then
|
|
74
84
|
* committed for five agents: a named group that disallows nothing grants that
|
|
75
85
|
* agent everything, because it stops matching `*` the moment it matches itself.
|
|
86
|
+
*
|
|
87
|
+
* The search-engine groups exist because `*` keeps the machine surface open to
|
|
88
|
+
* an agent the origin has never heard of, which is right for an agent and wrong
|
|
89
|
+
* for Google: a `.md` twin or its JSON is a page it already has in another
|
|
90
|
+
* format. Two origins wrote that group by hand and the third never did, so
|
|
91
|
+
* Google crawled its twins.
|
|
76
92
|
*/
|
|
77
|
-
export function
|
|
93
|
+
export function crawlerRules(opts) {
|
|
78
94
|
const aiAllow = [...new Set([...[opts.aiAllow ?? []].flat(), ...AGENT_SURFACE_PATHS])];
|
|
95
|
+
const disallow = [...new Set([...[opts.disallow].flat(), ...RSC_DISALLOW])];
|
|
96
|
+
const searchDisallow = [...new Set([...disallow, '/*.md$', ...[opts.searchDisallow ?? []].flat()])];
|
|
79
97
|
return [
|
|
80
98
|
// `aiAllow` rides on the default group too, not only on the named roster.
|
|
81
99
|
// The agent surface an origin advertises has to be reachable by a caller it
|
|
@@ -83,11 +101,22 @@ export function aiCrawlerRules(opts) {
|
|
|
83
101
|
// their own agent card named `/api/mcp`, so an MCP client that identified
|
|
84
102
|
// honestly as itself was told the endpoint was off limits. The roster is
|
|
85
103
|
// for indexing policy, not for hiding a documented endpoint.
|
|
86
|
-
{ userAgent: '*', allow: [...new Set([...[opts.allow].flat(), ...aiAllow])], disallow
|
|
87
|
-
...aiCrawlerTokens().map(userAgent => ({
|
|
88
|
-
|
|
89
|
-
allow: aiAllow,
|
|
90
|
-
disallow: opts.disallow,
|
|
91
|
-
})),
|
|
104
|
+
{ userAgent: '*', allow: [...new Set([...[opts.allow].flat(), ...aiAllow])], disallow },
|
|
105
|
+
...aiCrawlerTokens().map(userAgent => ({ userAgent, allow: aiAllow, disallow })),
|
|
106
|
+
...SEARCH_ENGINES.map(userAgent => ({ userAgent, allow: '/', disallow: searchDisallow })),
|
|
92
107
|
];
|
|
93
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Count an AI crawler's visit from a proxy, without holding up its response.
|
|
111
|
+
*
|
|
112
|
+
* `load` is a dynamic import of the module that records it, because that
|
|
113
|
+
* module pulls in a database client, and a static import would load it for
|
|
114
|
+
* every request the proxy sees, bots or not. Three proxies wrote these lines.
|
|
115
|
+
* Returns the bot's label, or null.
|
|
116
|
+
*/
|
|
117
|
+
export function trackAiBot(request, event, load) {
|
|
118
|
+
const bot = detectAiBot(request.headers.get('user-agent'));
|
|
119
|
+
if (bot)
|
|
120
|
+
event.waitUntil(load().then(m => m.trackEvent('AI Bot Visit', request.url, { bot }, request.headers)));
|
|
121
|
+
return bot;
|
|
122
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A header as Next's `headers()` wants it. */
|
|
2
|
+
export interface HeaderEntry {
|
|
3
|
+
key: string;
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The embed hosts the editor's iframe, YouTube, tweet and map nodes produce:
|
|
8
|
+
* the sanitiser's default `iframeHosts` and the default `frame-src`, so an
|
|
9
|
+
* embed that clears one clears the other.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_IFRAME_HOSTS: readonly string[];
|
|
12
|
+
/** `frame-src` sources for a host list — the one a sanitiser was given. */
|
|
13
|
+
export declare const frameSources: (hosts?: readonly string[]) => string[];
|
|
14
|
+
/** CSP directive names this builder writes, in the order it writes them. */
|
|
15
|
+
export type CspDirective = 'default-src' | 'base-uri' | 'object-src' | 'frame-ancestors' | 'form-action' | 'script-src' | 'style-src' | 'font-src' | 'img-src' | 'media-src' | 'connect-src' | 'frame-src' | 'worker-src' | 'manifest-src';
|
|
16
|
+
/**
|
|
17
|
+
* The policy all four origins shared line for line. Everything a site adds is
|
|
18
|
+
* a source list it hands `contentSecurityPolicy`, which replaces the default
|
|
19
|
+
* for that directive rather than appending, so a reader sees the whole list.
|
|
20
|
+
*/
|
|
21
|
+
export declare const BASE_CSP: Readonly<Partial<Record<CspDirective, readonly string[]>>>;
|
|
22
|
+
/**
|
|
23
|
+
* The policy string: `BASE_CSP` with each directive in `directives` replacing
|
|
24
|
+
* its default. An empty list drops the directive.
|
|
25
|
+
*/
|
|
26
|
+
export declare function contentSecurityPolicy(directives?: Partial<Record<CspDirective, readonly string[]>>): string;
|
|
27
|
+
/**
|
|
28
|
+
* The six headers every route sends, plus the policy when one is given.
|
|
29
|
+
*
|
|
30
|
+
* Pass the policy only where it is enforced — production. Next's dev runtime
|
|
31
|
+
* is inline and `eval`, so every origin here exempts dev, and the switch is the
|
|
32
|
+
* caller's because only the caller knows what dev means for it. An origin that
|
|
33
|
+
* sets its policy per request (a nonce) passes none.
|
|
34
|
+
*/
|
|
35
|
+
export declare function securityHeaders(csp?: string | null): HeaderEntry[];
|