wiki-formant 0.65.1 → 0.69.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 +31 -5
- package/dist/analytics.d.ts +1 -1
- package/dist/analytics.js +2 -2
- package/dist/conformance.d.ts +67 -0
- package/dist/conformance.js +125 -0
- package/dist/mcp.d.ts +24 -3
- package/dist/mcp.js +1 -1
- package/dist/og.d.ts +54 -0
- package/dist/og.js +36 -0
- package/dist/stats-figures.d.ts +3 -2
- package/dist/stats-figures.js +18 -6
- package/dist/stats.d.ts +5 -2
- package/dist/stats.js +12 -9
- package/dist/wiki-tools.d.ts +8 -3
- package/dist/wiki-tools.js +66 -26
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ const view = await categoryView({ tree, taxonomy, pages: store }, 'ecosystem', s
|
|
|
79
79
|
|
|
80
80
|
## Wiki read tools
|
|
81
81
|
|
|
82
|
-
`wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes` and `
|
|
82
|
+
`wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes`, `get_full_corpus` and `get_page_history`. `searchWiki` is the same rows for an app's plain-GET `?q=` and its search page, so the three never disagree.
|
|
83
83
|
|
|
84
84
|
```ts
|
|
85
85
|
import { wikiReadTools, searchWiki } from 'wiki-formant/wiki-tools';
|
|
@@ -90,6 +90,7 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
|
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
- Every row carries `path`, which every read tool takes back, and `chars` where the page's text was read. A pasted URL resolves, with `pathPrefix` stripped; a category's path reads its hub article; a miss names the nearest pages.
|
|
93
|
+
- Every row carries the page `id` too, which a `pageList` block takes. `get_page` with `blocks: true` returns the stored blocks, the page `id`, its whole metadata and `version`: what an edit starts from. `forEdit` adds what the app's write tool takes beside them, and `authors` names each revision's author in `get_page_history`.
|
|
93
94
|
- `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
|
|
94
95
|
- `metadata`, `label`, `corpus`, `noMatch` and `skills` are the app's: what a row adds, how a category is named, the sections its llms-full.txt is made of, what an empty search says, and the A2A skills.
|
|
95
96
|
- Writes stay with each app, because who may write is the one thing no two of them agree on.
|
|
@@ -248,6 +249,16 @@ process.exit(t.summary());
|
|
|
248
249
|
|
|
249
250
|
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`.
|
|
250
251
|
|
|
252
|
+
The drift checks hold an MCP server to what its own site does, so a tool cannot describe, accept or return something the site does not:
|
|
253
|
+
|
|
254
|
+
- `manifestParity(t, { writeAuth? })`: `/.well-known/mcp.json` is `tools/list` tool for tool, names the endpoint under test, and states write auth only where a write tool is listed (both ways, given `writeAuth`).
|
|
255
|
+
- `rowParity(t, label, mcp, rest)`: a tool's answer and the GET API's or `?q=` page's are identical, and not both empty.
|
|
256
|
+
- `twinParity(t, label, twinUrl, text)`: a read tool's text is the page's markdown twin, past its frontmatter and the title line.
|
|
257
|
+
- `editReadChecks(t, { path, rows?, validate?, endpoint? })`: rows carry page ids, `get_page` with `blocks: true` returns blocks the site's own `validate` accepts and a version, and `get_page_history`'s newest revision is that version.
|
|
258
|
+
- `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
|
|
259
|
+
- `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
|
|
260
|
+
- `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
|
|
261
|
+
|
|
251
262
|
`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.
|
|
252
263
|
|
|
253
264
|
```ts
|
|
@@ -542,6 +553,18 @@ export const SITE = siteRefs(SITE_URL);
|
|
|
542
553
|
<JsonLd data={siteGraphLd({ name, url: SITE_URL, logo, sameAs, searchUrl, api })} />
|
|
543
554
|
```
|
|
544
555
|
|
|
556
|
+
## Social cards
|
|
557
|
+
|
|
558
|
+
`wiki-formant/og` is the card at `/og`, as the element `next/og`'s `ImageResponse` takes. `SocialCard` is the frame: an eyebrow, a title fitted with `describe`, anything under it, then the description and the site's mark along the foot, bottom-aligned on a 1200×630 field. The field and the art are arguments, because they were all that differed: two wikis had each written the same seeded gradient with translucent circles, which is `bubbles(rand, { field, colors })`, and a third draws its own art and passes it as `art`. `cardRandom(...text)` seeds either from the page, so one page always draws the same card. `OG_SIZE` and `OG_HEADERS` (a day at the CDN, a week stale) go on the response.
|
|
559
|
+
|
|
560
|
+
```tsx
|
|
561
|
+
const rand = cardRandom(title, eyebrow);
|
|
562
|
+
const { background, art } = bubbles(rand, { field: [BG, NAVY], colors: [GREEN, ORANGE] });
|
|
563
|
+
return new ImageResponse(<SocialCard {...{ title, eyebrow, description, background, art }} mark="example.org" colors={{ text, muted, accent }} fontFamily="Inter" />, { ...OG_SIZE, headers: OG_HEADERS, fonts: await fonts });
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
|
|
567
|
+
|
|
545
568
|
## Revisions
|
|
546
569
|
|
|
547
570
|
What changed between two versions of a page, and therefore which semver bump to
|
|
@@ -884,7 +907,8 @@ referring host), top and entry pages, countries, devices, visitors and agents by
|
|
|
884
907
|
day, the agents by name, MCP tool calls, and the searches that found nothing.
|
|
885
908
|
An agent is counted as a visitor is, one address and user agent a day, from its
|
|
886
909
|
`AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
|
|
887
|
-
among the visitors
|
|
910
|
+
among the visitors, and `visitors_incl_agents` counts the two at once.
|
|
911
|
+
`ownAgents` leaves the site's own out of both. A visit is a run of one
|
|
888
912
|
visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
|
|
889
913
|
key to every call and a `site` resolver to `collect`.
|
|
890
914
|
|
|
@@ -918,7 +942,7 @@ export const POST = gate.route;
|
|
|
918
942
|
export const DELETE = gate.signOut;
|
|
919
943
|
|
|
920
944
|
// src/app/stats/page.tsx
|
|
921
|
-
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
|
|
945
|
+
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
|
|
922
946
|
return (
|
|
923
947
|
<StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
|
|
924
948
|
{async () => <Stats digest={await digest(sql, q)} {...q} />}
|
|
@@ -935,8 +959,10 @@ site's stylesheet to colour), one of them by day, week or month (a count's
|
|
|
935
959
|
total, a rate's mean), every non-empty
|
|
936
960
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
937
961
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
938
|
-
figure is a button that charts it: visitors,
|
|
939
|
-
visit length and clicks to X.
|
|
962
|
+
figure is a button that charts it: visitors, page views, bounce rate,
|
|
963
|
+
visit length and clicks to X. Visitors count agents; `?agents=apart`, a pair
|
|
964
|
+
of links beside the windows, splits them into people and agents, and every
|
|
965
|
+
other link on the page keeps the choice. `digest`'s `by_day` carries every
|
|
940
966
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
941
967
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
942
968
|
of those charts rather than drawn as zero. Everything but the figures and the
|
package/dist/analytics.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ export interface Digest {
|
|
|
112
112
|
* never among `visitors`.
|
|
113
113
|
*/
|
|
114
114
|
agents: number;
|
|
115
|
-
/** Visitors
|
|
115
|
+
/** Visitors and `agents` as one count, which a stats page shows unless asked to keep them apart. */
|
|
116
116
|
visitors_incl_agents: number;
|
|
117
117
|
/**
|
|
118
118
|
* The headline figures over the same number of days before, narrowed alike.
|
package/dist/analytics.js
CHANGED
|
@@ -240,7 +240,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
240
240
|
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
|
|
241
241
|
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
|
|
242
242
|
'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
|
|
243
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM
|
|
243
|
+
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM ag${s}) u) + (SELECT visitors FROM archived${s})`;
|
|
244
244
|
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
245
245
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
246
246
|
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
@@ -261,7 +261,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
261
261
|
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
262
262
|
UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
|
|
263
263
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
|
|
264
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM
|
|
264
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
|
|
265
265
|
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
266
266
|
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
|
|
267
267
|
) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
|
package/dist/conformance.d.ts
CHANGED
|
@@ -193,3 +193,70 @@ export declare function payloadBudget(t: Tester, calls: ReadonlyArray<{
|
|
|
193
193
|
* every unnamed crawler is telling two different stories to the same caller.
|
|
194
194
|
*/
|
|
195
195
|
export declare function robotsChecks(t: Tester, paths: readonly string[]): Promise<void>;
|
|
196
|
+
/**
|
|
197
|
+
* `/.well-known/mcp.json` is `tools/list`, tool for tool, names the endpoint
|
|
198
|
+
* under test, and states write auth only where the server lists a write tool.
|
|
199
|
+
*
|
|
200
|
+
* Both lists are `toolCard` projections, so anything short of equality is a
|
|
201
|
+
* manifest built from something other than the server's own tools. Pass
|
|
202
|
+
* `writeAuth` where the server's writes take a credential the manifest should
|
|
203
|
+
* describe, and the check runs both ways.
|
|
204
|
+
*/
|
|
205
|
+
export declare function manifestParity(t: Tester, opts?: {
|
|
206
|
+
url?: string;
|
|
207
|
+
writeAuth?: boolean;
|
|
208
|
+
}): Promise<void>;
|
|
209
|
+
/**
|
|
210
|
+
* Two surfaces answering one question answer it identically: a tool's rows and
|
|
211
|
+
* the GET API's, a tool's search and the `?q=` page's. Two empty answers are
|
|
212
|
+
* not agreement, so an empty one fails.
|
|
213
|
+
*/
|
|
214
|
+
export declare function rowParity(t: Tester, label: string, mcp: unknown, rest: unknown): void;
|
|
215
|
+
/**
|
|
216
|
+
* A page reads the same over MCP as at its markdown twin: the tool's text is
|
|
217
|
+
* the twin's body, after the frontmatter, and after the `# Title` line the twin
|
|
218
|
+
* adds where the page's own blocks open with no h1.
|
|
219
|
+
*/
|
|
220
|
+
export declare function twinParity(t: Tester, label: string, twinUrl: string, content: string): Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* What an edit starts from, on any server built on the wiki read tools: rows
|
|
223
|
+
* carry the id a pageList block takes, `get_page` with `blocks: true` returns
|
|
224
|
+
* the stored blocks and the version a save takes as its base, and the history's
|
|
225
|
+
* newest revision is that version. `validate` is the site's own block check,
|
|
226
|
+
* so blocks the tool hands out are blocks the site's write takes back.
|
|
227
|
+
*/
|
|
228
|
+
export declare function editReadChecks(t: Tester, opts: {
|
|
229
|
+
path: string;
|
|
230
|
+
endpoint?: string;
|
|
231
|
+
rows?: ReadonlyArray<{
|
|
232
|
+
id?: unknown;
|
|
233
|
+
}>;
|
|
234
|
+
validate?: (blocks: unknown[]) => true | string;
|
|
235
|
+
label?: string;
|
|
236
|
+
}): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* What a read tool hands out, a write tool takes back unchanged, and the page
|
|
239
|
+
* reads the same after: the read's shape is the write's. `write` gets the read
|
|
240
|
+
* as it came; `read` is called again after.
|
|
241
|
+
*/
|
|
242
|
+
export declare function roundTrip(t: Tester, label: string, io: {
|
|
243
|
+
read: () => Promise<unknown>;
|
|
244
|
+
write: (value: unknown) => Promise<Rpc>;
|
|
245
|
+
}): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* Every field a REST write documents, the matching tool takes. An edit tool
|
|
248
|
+
* missing the version field the editor sends overwrites whatever landed between
|
|
249
|
+
* an agent's read and its write. `ops` pairs an OpenAPI path and method with
|
|
250
|
+
* the tool that does the same write.
|
|
251
|
+
*/
|
|
252
|
+
export declare function openapiWriteParity(t: Tester, ops: ReadonlyArray<readonly [path: string, method: string, tool: string]>, specUrl?: string): Promise<void>;
|
|
253
|
+
/**
|
|
254
|
+
* The ROLA recipe a challenge tool hands out names the origin the verifier
|
|
255
|
+
* checks: the origin under test. One that named the canonical site while the
|
|
256
|
+
* verifier read another variable failed every signature off that site.
|
|
257
|
+
*/
|
|
258
|
+
export declare function rolaOriginCheck(t: Tester, opts?: {
|
|
259
|
+
origin?: string;
|
|
260
|
+
endpoint?: string;
|
|
261
|
+
tool?: string;
|
|
262
|
+
}): Promise<void>;
|
package/dist/conformance.js
CHANGED
|
@@ -468,3 +468,128 @@ export async function robotsChecks(t, paths) {
|
|
|
468
468
|
t.check(`robots allows ${path}`, !match || match.allow, match ? `${match.allow ? 'Allow' : 'Disallow'}: ${match.path}` : 'no matching rule');
|
|
469
469
|
}
|
|
470
470
|
}
|
|
471
|
+
// ---- drift: what the MCP server says against what the site does -----------
|
|
472
|
+
//
|
|
473
|
+
// Each app had written these against its own surfaces after an audit found its
|
|
474
|
+
// MCP server describing, accepting or returning something its site did not:
|
|
475
|
+
// an edit tool without the version guard the editor sends, page reads that
|
|
476
|
+
// flattened what the markdown twin kept, a manifest stating write auth on a
|
|
477
|
+
// deployment with no writes. The same check, once per app, is how the checks
|
|
478
|
+
// themselves drift, so they live here beside the transport they test.
|
|
479
|
+
/** A JSON value that says nothing: comparing two of them proves nothing. */
|
|
480
|
+
const vacuous = (v) => v == null || (Array.isArray(v) ? v.length === 0 : typeof v === 'object' && Object.keys(v).length === 0);
|
|
481
|
+
/** Where two JSON values first part, for a check's note. */
|
|
482
|
+
const firstDifference = (a, b) => {
|
|
483
|
+
const [x, y] = [JSON.stringify(a) ?? 'undefined', JSON.stringify(b) ?? 'undefined'];
|
|
484
|
+
let i = 0;
|
|
485
|
+
while (i < x.length && x[i] === y[i])
|
|
486
|
+
i++;
|
|
487
|
+
return `at char ${i}: MCP …${x.slice(Math.max(0, i - 20), i + 40)}… vs …${y.slice(Math.max(0, i - 20), i + 40)}…`;
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* `/.well-known/mcp.json` is `tools/list`, tool for tool, names the endpoint
|
|
491
|
+
* under test, and states write auth only where the server lists a write tool.
|
|
492
|
+
*
|
|
493
|
+
* Both lists are `toolCard` projections, so anything short of equality is a
|
|
494
|
+
* manifest built from something other than the server's own tools. Pass
|
|
495
|
+
* `writeAuth` where the server's writes take a credential the manifest should
|
|
496
|
+
* describe, and the check runs both ways.
|
|
497
|
+
*/
|
|
498
|
+
export async function manifestParity(t, opts = {}) {
|
|
499
|
+
const manifest = (await (await fetch(opts.url ?? `${t.base}/.well-known/mcp.json`)).json());
|
|
500
|
+
const listed = (await t.rpc('tools/list')).result?.tools ?? [];
|
|
501
|
+
const names = (xs) => xs.map(x => x.name).sort().join(' ');
|
|
502
|
+
const same = JSON.stringify(manifest.tools) === JSON.stringify(listed);
|
|
503
|
+
t.check('mcp.json is tools/list', same && listed.length > 0, same ? `${listed.length} tools` : names(manifest.tools ?? []) === names(listed) ? firstDifference(manifest.tools, listed) : `mcp.json: ${names(manifest.tools ?? [])} | tools/list: ${names(listed)}`);
|
|
504
|
+
// By path: the manifest names the canonical origin, and a run against a dev server is not it.
|
|
505
|
+
const path = (u) => (u ? new URL(u, t.base).pathname : '');
|
|
506
|
+
t.check('mcp.json names the endpoint', path(manifest.mcp?.endpoint) === path(t.endpoint), `${manifest.mcp?.endpoint}`);
|
|
507
|
+
const writes = listed.filter(x => x.annotations?.readOnlyHint === false).length;
|
|
508
|
+
const states = !!manifest.auth?.writes;
|
|
509
|
+
t.check('mcp.json write auth', (!states || writes > 0) && (opts.writeAuth === undefined || states === opts.writeAuth), `auth.writes ${states ? 'stated' : 'absent'}, ${writes} write tools listed${opts.writeAuth === undefined ? '' : `, expected ${opts.writeAuth ? 'stated' : 'absent'}`}`);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Two surfaces answering one question answer it identically: a tool's rows and
|
|
513
|
+
* the GET API's, a tool's search and the `?q=` page's. Two empty answers are
|
|
514
|
+
* not agreement, so an empty one fails.
|
|
515
|
+
*/
|
|
516
|
+
export function rowParity(t, label, mcp, rest) {
|
|
517
|
+
const same = JSON.stringify(mcp) === JSON.stringify(rest);
|
|
518
|
+
t.check(label, same && !vacuous(mcp), vacuous(mcp) ? 'nothing to compare: the MCP answer is empty' : same ? 'identical' : firstDifference(mcp, rest));
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A page reads the same over MCP as at its markdown twin: the tool's text is
|
|
522
|
+
* the twin's body, after the frontmatter, and after the `# Title` line the twin
|
|
523
|
+
* adds where the page's own blocks open with no h1.
|
|
524
|
+
*/
|
|
525
|
+
export async function twinParity(t, label, twinUrl, content) {
|
|
526
|
+
const url = twinUrl.startsWith('http') ? twinUrl : `${t.base}/${twinUrl.replace(/^\//, '')}`;
|
|
527
|
+
const res = await fetch(url);
|
|
528
|
+
let body = (await res.text()).replace(/^---\n[\s\S]*?\n---\n+/, '').trim();
|
|
529
|
+
const text = content.trim();
|
|
530
|
+
if (!text.startsWith('# '))
|
|
531
|
+
body = body.replace(/^# [^\n]*\n+/, '');
|
|
532
|
+
t.check(label, res.ok && !!text && body === text, !res.ok ? `${res.status} ${url}` : !text ? 'nothing to compare: the MCP text is empty' : body === text ? `${text.length} chars` : firstDifference(text, body));
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* What an edit starts from, on any server built on the wiki read tools: rows
|
|
536
|
+
* carry the id a pageList block takes, `get_page` with `blocks: true` returns
|
|
537
|
+
* the stored blocks and the version a save takes as its base, and the history's
|
|
538
|
+
* newest revision is that version. `validate` is the site's own block check,
|
|
539
|
+
* so blocks the tool hands out are blocks the site's write takes back.
|
|
540
|
+
*/
|
|
541
|
+
export async function editReadChecks(t, opts) {
|
|
542
|
+
const endpoint = opts.endpoint ?? t.endpoint;
|
|
543
|
+
const label = (s) => (opts.label ? `[${opts.label}] ${s}` : s);
|
|
544
|
+
if (opts.rows) {
|
|
545
|
+
const rows = opts.rows;
|
|
546
|
+
t.check(label('rows carry the page id'), rows.length > 0 && rows.every(r => typeof r.id === 'string' && r.id.length > 0), `${rows.length} rows, first id=${String(rows[0]?.id)}`);
|
|
547
|
+
}
|
|
548
|
+
const page = t.payload(await t.callAt(endpoint, 'get_page', { path: opts.path, blocks: true }));
|
|
549
|
+
const blocks = Array.isArray(page?.content) ? page.content : null;
|
|
550
|
+
const verdict = blocks && opts.validate ? opts.validate(blocks) : true;
|
|
551
|
+
t.check(label('get_page(blocks)'), !!page?.id && !!page.version && !!blocks && typeof page.metadata === 'object' && verdict === true, !blocks ? `no block array: ${JSON.stringify(page).slice(0, 80)}` : verdict !== true ? `the site's block check refuses them: ${verdict}` : `${blocks.length} blocks, v${page.version}`);
|
|
552
|
+
const history = t.payload(await t.callAt(endpoint, 'get_page_history', { path: opts.path, limit: 3 }));
|
|
553
|
+
const newest = history?.revisions?.[0];
|
|
554
|
+
t.check(label('get_page_history'), !!newest?.id && newest.version === history.currentVersion && newest.version === page?.version && 'author' in newest, `${history?.revisions?.length ?? 0} revisions, newest v${newest?.version} of v${history?.currentVersion}, page v${page?.version}`);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* What a read tool hands out, a write tool takes back unchanged, and the page
|
|
558
|
+
* reads the same after: the read's shape is the write's. `write` gets the read
|
|
559
|
+
* as it came; `read` is called again after.
|
|
560
|
+
*/
|
|
561
|
+
export async function roundTrip(t, label, io) {
|
|
562
|
+
const before = await io.read();
|
|
563
|
+
const wrote = await io.write(before);
|
|
564
|
+
const after = await io.read();
|
|
565
|
+
const failed = wrote.error ? `-${wrote.error.code} ${wrote.error.message}` : wrote.result?.isError ? String(t.payload(wrote)).slice(0, 100) : '';
|
|
566
|
+
t.check(label, !vacuous(before) && !failed && JSON.stringify(after) === JSON.stringify(before), vacuous(before) ? 'nothing to compare: the read is empty' : failed ? `the write refused its own read: ${failed}` : JSON.stringify(after) === JSON.stringify(before) ? 'unchanged' : firstDifference(after, before));
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Every field a REST write documents, the matching tool takes. An edit tool
|
|
570
|
+
* missing the version field the editor sends overwrites whatever landed between
|
|
571
|
+
* an agent's read and its write. `ops` pairs an OpenAPI path and method with
|
|
572
|
+
* the tool that does the same write.
|
|
573
|
+
*/
|
|
574
|
+
export async function openapiWriteParity(t, ops, specUrl = `${t.base}/.well-known/openapi.json`) {
|
|
575
|
+
const spec = (await (await fetch(specUrl)).json());
|
|
576
|
+
const tools = (await t.rpc('tools/list')).result?.tools ?? [];
|
|
577
|
+
for (const [path, method, tool] of ops) {
|
|
578
|
+
const content = spec.paths?.[path]?.[method.toLowerCase()]?.requestBody?.content ?? {};
|
|
579
|
+
const fields = [...new Set(Object.values(content).flatMap(c => Object.keys(c.schema?.properties ?? {})))];
|
|
580
|
+
const props = Object.keys(tools.find(x => x.name === tool)?.inputSchema?.properties ?? {});
|
|
581
|
+
const missing = fields.filter(f => !props.includes(f));
|
|
582
|
+
t.check(`${tool} takes ${method.toUpperCase()} ${path}`, fields.length > 0 && missing.length === 0, !fields.length ? `the spec documents no body for ${method.toUpperCase()} ${path}` : missing.length ? `missing: ${missing.join(', ')}` : `${fields.length} fields`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The ROLA recipe a challenge tool hands out names the origin the verifier
|
|
587
|
+
* checks: the origin under test. One that named the canonical site while the
|
|
588
|
+
* verifier read another variable failed every signature off that site.
|
|
589
|
+
*/
|
|
590
|
+
export async function rolaOriginCheck(t, opts = {}) {
|
|
591
|
+
const origin = opts.origin ?? new URL(t.base).origin;
|
|
592
|
+
const answer = t.payload(await t.callAt(opts.endpoint ?? t.endpoint, opts.tool ?? 'get_challenge'));
|
|
593
|
+
const message = answer?.sign?.message ?? '';
|
|
594
|
+
t.check('ROLA recipe names this origin', message.includes(`"${origin}"`), `expects "${origin}" in: …${message.slice(-90)}`);
|
|
595
|
+
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -34,13 +34,34 @@ export declare const MCP_META: {
|
|
|
34
34
|
readonly serverInfo: "io.modelcontextprotocol/serverInfo";
|
|
35
35
|
};
|
|
36
36
|
import { type RateLimitOptions } from './rate-limit.js';
|
|
37
|
+
/**
|
|
38
|
+
* JSON Schema as far as a tool's parameters go. Open past the named keywords,
|
|
39
|
+
* because an array's items may be a whole block type, which a narrower shape
|
|
40
|
+
* made every app type as `{ type: 'object' }` and leave the rest unsaid.
|
|
41
|
+
*/
|
|
42
|
+
export type JsonSchema = {
|
|
43
|
+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
|
|
44
|
+
description?: string;
|
|
45
|
+
enum?: readonly (string | number)[];
|
|
46
|
+
items?: JsonSchema;
|
|
47
|
+
properties?: Record<string, JsonSchema>;
|
|
48
|
+
required?: readonly string[];
|
|
49
|
+
minimum?: number;
|
|
50
|
+
maximum?: number;
|
|
51
|
+
[keyword: string]: unknown;
|
|
52
|
+
};
|
|
37
53
|
export type ToolParam = {
|
|
38
54
|
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
|
39
55
|
description: string;
|
|
40
56
|
enum?: string[];
|
|
41
|
-
items?:
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
items?: JsonSchema;
|
|
58
|
+
/**
|
|
59
|
+
* Bounds a client can read before it calls. The handler still clamps (an
|
|
60
|
+
* argument out of range comes back adjusted, not refused), so these state the
|
|
61
|
+
* clamp rather than enforce it.
|
|
62
|
+
*/
|
|
63
|
+
minimum?: number;
|
|
64
|
+
maximum?: number;
|
|
44
65
|
};
|
|
45
66
|
export type ToolSchema = {
|
|
46
67
|
type: 'object';
|
package/dist/mcp.js
CHANGED
|
@@ -233,7 +233,7 @@ function validateArgs(tool, args) {
|
|
|
233
233
|
// No `items` means strings. Items with no one type (a `oneOf` of shapes,
|
|
234
234
|
// as a zod union emits) are the tool's own to check: read as strings, every
|
|
235
235
|
// well-formed call was refused.
|
|
236
|
-
const itemType = spec.items ? spec.items.type : 'string';
|
|
236
|
+
const itemType = spec.items ? (spec.items.type === 'integer' ? 'number' : spec.items.type) : 'string';
|
|
237
237
|
if (actual !== 'array') {
|
|
238
238
|
problems.push(`Parameter "${key}" must be an array${itemType ? ` of ${itemType}s` : ''}; received ${actual} (${JSON.stringify(value)}).`);
|
|
239
239
|
}
|
package/dist/og.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
export declare const OG_SIZE: {
|
|
3
|
+
readonly width: 1200;
|
|
4
|
+
readonly height: 630;
|
|
5
|
+
};
|
|
6
|
+
/** A day at the CDN, a week stale: a card changes when its page's title does, and crawlers cache it for longer anyway. */
|
|
7
|
+
export declare const OG_HEADERS: {
|
|
8
|
+
readonly 'Cache-Control': "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800";
|
|
9
|
+
};
|
|
10
|
+
/** The card's generator, seeded from its text, so one page always draws the same card. */
|
|
11
|
+
export declare function cardRandom(...parts: string[]): () => number;
|
|
12
|
+
export interface BubbleOptions {
|
|
13
|
+
/** The two ends of the field's gradient; its angle is seeded. */
|
|
14
|
+
field: readonly [string, string];
|
|
15
|
+
/** Circle colours, picked at random per circle. */
|
|
16
|
+
colors: readonly string[];
|
|
17
|
+
count?: number;
|
|
18
|
+
/** Smallest radius and the spread above it, in px. */
|
|
19
|
+
radius?: readonly [number, number];
|
|
20
|
+
/** Lowest opacity and the spread above it. */
|
|
21
|
+
opacity?: readonly [number, number];
|
|
22
|
+
}
|
|
23
|
+
/** The seeded gradient and translucent circles: a field and its art, for `SocialCard`. */
|
|
24
|
+
export declare function bubbles(rand: () => number, o: BubbleOptions): {
|
|
25
|
+
background: string;
|
|
26
|
+
art: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
export interface SocialCardProps {
|
|
29
|
+
title: string;
|
|
30
|
+
/** The line above the title: a section, a code, a handle. */
|
|
31
|
+
eyebrow?: string;
|
|
32
|
+
/** The line along the foot. */
|
|
33
|
+
description?: string;
|
|
34
|
+
/** The site's name, at the foot's right. */
|
|
35
|
+
mark?: string;
|
|
36
|
+
colors: {
|
|
37
|
+
text: string;
|
|
38
|
+
/** The description. */
|
|
39
|
+
muted: string;
|
|
40
|
+
/** The eyebrow and the mark. */
|
|
41
|
+
accent: string;
|
|
42
|
+
};
|
|
43
|
+
/** The field: any CSS background satori draws, a gradient from `bubbles` or a flat colour. */
|
|
44
|
+
background: string;
|
|
45
|
+
/** Absolutely positioned layers between the field and the text. */
|
|
46
|
+
art?: ReactNode;
|
|
47
|
+
/** A dark gradient up from the foot, for art that is a photograph. */
|
|
48
|
+
scrim?: boolean;
|
|
49
|
+
fontFamily?: string;
|
|
50
|
+
/** Anything that belongs under the title: chips, a figure. */
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
}
|
|
53
|
+
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
54
|
+
export declare function SocialCard(p: SocialCardProps): ReactElement;
|
package/dist/og.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { describe } from './metadata.js';
|
|
3
|
+
import { hashStr, seededRandom } from './seeded.js';
|
|
4
|
+
export const OG_SIZE = { width: 1200, height: 630 };
|
|
5
|
+
/** A day at the CDN, a week stale: a card changes when its page's title does, and crawlers cache it for longer anyway. */
|
|
6
|
+
export const OG_HEADERS = { 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800' };
|
|
7
|
+
/** The card's generator, seeded from its text, so one page always draws the same card. */
|
|
8
|
+
export function cardRandom(...parts) {
|
|
9
|
+
return seededRandom(hashStr(parts.join('')));
|
|
10
|
+
}
|
|
11
|
+
/** The seeded gradient and translucent circles: a field and its art, for `SocialCard`. */
|
|
12
|
+
export function bubbles(rand, o) {
|
|
13
|
+
const [r0, rSpan] = o.radius ?? [60, 180];
|
|
14
|
+
const [a0, aSpan] = o.opacity ?? [0.12, 0.2];
|
|
15
|
+
const background = `linear-gradient(${Math.floor(rand() * 360)}deg, ${o.field[0]}, ${o.field[1]})`;
|
|
16
|
+
const art = Array.from({ length: o.count ?? 7 }, (_, i) => {
|
|
17
|
+
const cx = rand() * OG_SIZE.width;
|
|
18
|
+
const cy = rand() * OG_SIZE.height;
|
|
19
|
+
const r = r0 + rand() * rSpan;
|
|
20
|
+
const color = o.colors[Math.floor(rand() * o.colors.length)] ?? o.field[1];
|
|
21
|
+
return (_jsx("div", { style: { position: 'absolute', left: cx - r, top: cy - r, width: r * 2, height: r * 2, borderRadius: '50%', background: color, opacity: a0 + rand() * aSpan } }, i));
|
|
22
|
+
});
|
|
23
|
+
return { background, art };
|
|
24
|
+
}
|
|
25
|
+
const SCRIM = 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)';
|
|
26
|
+
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
27
|
+
export function SocialCard(p) {
|
|
28
|
+
const title = describe(p.title, 90);
|
|
29
|
+
// Three lines at most at either size; the step is where 68px would wrap a fourth.
|
|
30
|
+
const titleSize = title.length > 48 ? 56 : 72;
|
|
31
|
+
return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%', position: 'relative', background: p.background, ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}) }, children: [p.art, _jsxs("div", { style: {
|
|
32
|
+
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
|
|
33
|
+
width: '100%', height: '100%', padding: '56px 64px', position: 'relative',
|
|
34
|
+
...(p.scrim ? { background: SCRIM } : {}),
|
|
35
|
+
}, children: [p.eyebrow ? _jsx("div", { style: { display: 'flex', fontSize: 30, fontWeight: 700, color: p.colors.accent, marginBottom: 14 }, children: describe(p.eyebrow, 48) }) : null, _jsx("div", { style: { display: 'flex', fontSize: titleSize, fontWeight: 700, color: p.colors.text, lineHeight: 1.08, letterSpacing: '-0.02em' }, children: title }), p.children ? _jsx("div", { style: { display: 'flex', marginTop: 28 }, children: p.children }) : null, p.description || p.mark ? (_jsxs("div", { style: { display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 40, marginTop: 28 }, children: [_jsx("div", { style: { display: 'flex', flex: 1, fontSize: 26, lineHeight: 1.35, color: p.colors.muted }, children: p.description ? describe(p.description, 110) : '' }), p.mark ? _jsx("div", { style: { display: 'flex', fontSize: 28, fontWeight: 700, color: p.colors.accent }, children: p.mark }) : null] })) : null] })] }));
|
|
36
|
+
}
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -6,10 +6,11 @@ import { type Digest } from './analytics.js';
|
|
|
6
6
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
7
7
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
8
8
|
*/
|
|
9
|
-
export declare function StatsFigures({ digest, first,
|
|
9
|
+
export declare function StatsFigures({ digest, first, apart, controls }: {
|
|
10
10
|
digest: Digest;
|
|
11
11
|
first: number;
|
|
12
|
-
|
|
12
|
+
apart: boolean;
|
|
13
|
+
controls: ReactNode;
|
|
13
14
|
}): import("react").JSX.Element;
|
|
14
15
|
export interface PasskeyButtonProps {
|
|
15
16
|
/** The route that serves `gate.route`. */
|
package/dist/stats-figures.js
CHANGED
|
@@ -26,9 +26,21 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
|
26
26
|
const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
|
|
27
27
|
return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/** Visitors are one figure counting agents too, or `apart` two: people, and agents. */
|
|
30
|
+
const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
31
|
+
...(apart
|
|
32
|
+
? [
|
|
33
|
+
{ label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: (r) => r.visitors },
|
|
34
|
+
{ label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: (r) => r.agents },
|
|
35
|
+
]
|
|
36
|
+
: [
|
|
37
|
+
{
|
|
38
|
+
label: 'Visitors',
|
|
39
|
+
value: count(d.visitors_incl_agents),
|
|
40
|
+
delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
|
|
41
|
+
day: (r) => r.visitors_incl_agents,
|
|
42
|
+
},
|
|
43
|
+
]),
|
|
32
44
|
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
33
45
|
{
|
|
34
46
|
label: 'Bounce rate',
|
|
@@ -65,8 +77,8 @@ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seco
|
|
|
65
77
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
66
78
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
67
79
|
*/
|
|
68
|
-
export function StatsFigures({ digest, first,
|
|
69
|
-
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
80
|
+
export function StatsFigures({ digest, first, apart, controls }) {
|
|
81
|
+
const figures = useMemo(() => figuresOf(digest, apart), [digest, apart]);
|
|
70
82
|
const [chosen, choose] = useState(0);
|
|
71
83
|
const figure = figures[chosen] ?? figures[0];
|
|
72
84
|
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
@@ -78,7 +90,7 @@ export function StatsFigures({ digest, first, windows }) {
|
|
|
78
90
|
return value == null ? null : { time, value };
|
|
79
91
|
}).filter(p => p !== null);
|
|
80
92
|
}, [byDay, first, figure]);
|
|
81
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls:
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls: controls, className: "stats-chart" })] }));
|
|
82
94
|
}
|
|
83
95
|
async function post(endpoint, body) {
|
|
84
96
|
const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
package/dist/stats.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface StatsProps {
|
|
|
8
8
|
days: number;
|
|
9
9
|
/** What the digest was narrowed to, as `digest` was given it. */
|
|
10
10
|
filter?: StatsFilter;
|
|
11
|
+
/** Visitors as people and agents, two figures, rather than one that counts both. */
|
|
12
|
+
apart?: boolean;
|
|
11
13
|
/** The windows offered as `?days=` links on the same page. */
|
|
12
14
|
ranges?: readonly number[];
|
|
13
15
|
}
|
|
@@ -19,15 +21,16 @@ export interface StatsProps {
|
|
|
19
21
|
export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
|
|
20
22
|
days: number;
|
|
21
23
|
filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
|
|
24
|
+
apart: boolean;
|
|
22
25
|
};
|
|
23
26
|
/**
|
|
24
27
|
* A site's own analytics on one page: the headline figures against the window
|
|
25
|
-
* before, whichever of them is chosen per day, and every ranked list the
|
|
28
|
+
* before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
|
|
26
29
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
27
30
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
28
31
|
* until the importing stylesheet gives them a colour.
|
|
29
32
|
*/
|
|
30
|
-
export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
|
|
33
|
+
export declare function Stats({ digest: d, days, filter, apart, ranges }: StatsProps): import("react").JSX.Element;
|
|
31
34
|
export interface StatsGateProps {
|
|
32
35
|
gate: PasskeyGate;
|
|
33
36
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -21,11 +21,13 @@ export function statsQuery(params, ranges = STATS_RANGES) {
|
|
|
21
21
|
if (typeof v === 'string' && v)
|
|
22
22
|
filter[k] = v.slice(0, 500);
|
|
23
23
|
}
|
|
24
|
-
return { days: statsDays(params.days, ranges), filter };
|
|
24
|
+
return { days: statsDays(params.days, ranges), filter, apart: params.agents === 'apart' };
|
|
25
25
|
}
|
|
26
|
-
/** The page at a window and a
|
|
27
|
-
const href = (days, filter) => {
|
|
26
|
+
/** The page at a window, a filter and a count of agents, as a query string. */
|
|
27
|
+
const href = (days, filter, apart) => {
|
|
28
28
|
const q = new URLSearchParams({ days: daysParam(days) });
|
|
29
|
+
if (apart)
|
|
30
|
+
q.set('agents', 'apart');
|
|
29
31
|
for (const k of STATS_FILTERS)
|
|
30
32
|
if (filter[k])
|
|
31
33
|
q.set(k, filter[k]);
|
|
@@ -50,12 +52,12 @@ function StatsList({ title, rows, label = v => v, to, }) {
|
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* A site's own analytics on one page: the headline figures against the window
|
|
53
|
-
* before, whichever of them is chosen per day, and every ranked list the
|
|
55
|
+
* before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
|
|
54
56
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
55
57
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
56
58
|
* until the importing stylesheet gives them a colour.
|
|
57
59
|
*/
|
|
58
|
-
export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
60
|
+
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES }) {
|
|
59
61
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
60
62
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
61
63
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -67,11 +69,12 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
|
67
69
|
? Math.max(start, counted)
|
|
68
70
|
: Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
|
|
69
71
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
70
|
-
const
|
|
72
|
+
const at = (f, n = days, a = apart) => href(n, f, a);
|
|
73
|
+
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
71
74
|
// Each window is another digest, so a link rather than a button, pinned
|
|
72
|
-
// with the chart's own controls.
|
|
73
|
-
const
|
|
74
|
-
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href:
|
|
75
|
+
// with the chart's own controls, and so is whether agents count apart.
|
|
76
|
+
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsxs("div", { role: "group", "aria-label": "Agents", children: [_jsx("a", { href: at(filter, days, false), "aria-current": apart ? undefined : 'page', title: "Visitors count agents", children: "With agents" }), _jsx("a", { href: at(filter, days, true), "aria-current": apart ? 'page' : undefined, title: "People and agents counted apart", children: "Apart" })] })] }));
|
|
77
|
+
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: at({ ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
80
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/dist/wiki-tools.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type McpTool } from './mcp.js';
|
|
2
2
|
import { type CorpusSection } from './corpus.js';
|
|
3
3
|
import { type WikiNode, type WikiTree } from './tree.js';
|
|
4
|
-
import type { WikiStore, WikiPageListing, PageMetadata } from './store.js';
|
|
4
|
+
import type { WikiStore, WikiPageListing, WikiPageRow, PageMetadata } from './store.js';
|
|
5
5
|
import type { DiffBlock } from './revisions.js';
|
|
6
|
-
type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus';
|
|
6
|
+
type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus' | 'get_page_history';
|
|
7
7
|
export interface WikiToolsOptions<B extends DiffBlock> {
|
|
8
8
|
store: WikiStore<B>;
|
|
9
9
|
tree: WikiTree<WikiNode>;
|
|
@@ -19,6 +19,10 @@ export interface WikiToolsOptions<B extends DiffBlock> {
|
|
|
19
19
|
pathPrefix?: string;
|
|
20
20
|
/** Back-office pages are private here, not merely unlisted: no tool reads one. */
|
|
21
21
|
privateHidden?: boolean;
|
|
22
|
+
/** What `get_page` with `blocks: true` adds for the app's write tool, such as fields it keeps inside metadata. */
|
|
23
|
+
forEdit?: (page: WikiPageRow<B>) => Record<string, unknown>;
|
|
24
|
+
/** Display names by user id, for `get_page_history`. Without it a revision names no author. */
|
|
25
|
+
authors?: (ids: string[]) => Promise<Map<string, string>>;
|
|
22
26
|
/** What a listing row adds from the page's metadata, or null for nothing. */
|
|
23
27
|
metadata?: (tagPath: string, metadata: PageMetadata) => Record<string, unknown> | null;
|
|
24
28
|
/** A category's name as the tree lists it. Defaults to the node's `name`. */
|
|
@@ -32,7 +36,7 @@ export interface WikiToolsOptions<B extends DiffBlock> {
|
|
|
32
36
|
}
|
|
33
37
|
/** A read tool: the package's always say whether they write. */
|
|
34
38
|
export type WikiTool = McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
|
|
35
|
-
type Rowable<B> = Pick<WikiPageListing, 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
|
|
39
|
+
type Rowable<B> = Pick<WikiPageListing, 'id' | 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
|
|
36
40
|
content?: B[];
|
|
37
41
|
};
|
|
38
42
|
/** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
|
|
@@ -42,6 +46,7 @@ declare const rowFor: <B extends DiffBlock>(o: Pick<WikiToolsOptions<B>, "url" |
|
|
|
42
46
|
snippet: string;
|
|
43
47
|
updatedAt: string;
|
|
44
48
|
chars?: number | undefined;
|
|
49
|
+
id: string;
|
|
45
50
|
title: string;
|
|
46
51
|
path: string;
|
|
47
52
|
url: string;
|
package/dist/wiki-tools.js
CHANGED
|
@@ -21,12 +21,19 @@ import { openingText } from './text.js';
|
|
|
21
21
|
const MAX_BATCH_PATHS = 20;
|
|
22
22
|
const READ = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
|
|
23
23
|
const pathOf = (tagPath, slug) => wikiPath(tagPath, slug).slice(1);
|
|
24
|
+
/** How a single-page tool takes its page. */
|
|
25
|
+
const ADDRESS = {
|
|
26
|
+
path: { type: 'string', description: 'The page\'s whole path, e.g. "developers/scrypto/blueprints". Preferred over tagPath + slug.' },
|
|
27
|
+
tagPath: { type: 'string', description: 'Tag path only ("" for a top-level page). Ignored when `path` is set.' },
|
|
28
|
+
slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
|
|
29
|
+
};
|
|
24
30
|
/** Whether a tag path is closed to these tools: a back office the wiki keeps private. */
|
|
25
31
|
const offFor = (o) => (tagPath) => !!tagPath && !!o.privateHidden && o.store.isHidden(tagPath);
|
|
26
32
|
/** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
|
|
27
33
|
const rowFor = (o) => (p) => {
|
|
28
34
|
const meta = o.metadata?.(p.tagPath, (p.metadata ?? {}));
|
|
29
35
|
return {
|
|
36
|
+
id: p.id,
|
|
30
37
|
title: p.title,
|
|
31
38
|
path: pathOf(p.tagPath, p.slug),
|
|
32
39
|
url: o.url(p.tagPath, p.slug),
|
|
@@ -68,6 +75,21 @@ export function wikiReadTools(o) {
|
|
|
68
75
|
const page = (await store.get(t.tagPath, t.slug)) ?? (await store.get(pathOf(t.tagPath, t.slug), ''));
|
|
69
76
|
return page && !off(page.tagPath) ? page : null;
|
|
70
77
|
};
|
|
78
|
+
/** The page a call names by `path` or `tagPath` + `slug`; a miss names the nearest pages, so a wrong guess is fixed in one retry. */
|
|
79
|
+
const named = async (r) => {
|
|
80
|
+
const raw = r.str('path');
|
|
81
|
+
const t = raw ? target(raw) : { tagPath: r.str('tagPath'), slug: r.str('slug') };
|
|
82
|
+
if (!t?.slug)
|
|
83
|
+
throw new McpToolError('Pass either `path` ("tag/path/slug") or `slug` (with optional `tagPath`).');
|
|
84
|
+
const page = await read(t);
|
|
85
|
+
if (page)
|
|
86
|
+
return page;
|
|
87
|
+
const near = await store.search(t.slug.replace(/-/g, ' '), { take: 5 });
|
|
88
|
+
throw new McpToolError(`No page at path "${pathOf(t.tagPath, t.slug)}".`, {
|
|
89
|
+
didYouMean: near.items.filter(p => !off(p.tagPath)).map(p => ({ path: pathOf(p.tagPath, p.slug), title: p.title })),
|
|
90
|
+
hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
|
|
91
|
+
});
|
|
92
|
+
};
|
|
71
93
|
const full = (page) => ({
|
|
72
94
|
...summary({ ...page, snippet: describe(openingText(page.content)) }),
|
|
73
95
|
version: page.version,
|
|
@@ -100,8 +122,8 @@ export function wikiReadTools(o) {
|
|
|
100
122
|
properties: {
|
|
101
123
|
query: { type: 'string', description: 'Search term, matched against page titles and body text' },
|
|
102
124
|
tagPath: { type: 'string', description: 'Only pages at this tag path, e.g. a path from get_categories' },
|
|
103
|
-
page: { type: 'number', description: '1-based page number (default 1)' },
|
|
104
|
-
pageSize: { type: 'number', description: 'Results per page (default 20, max 50)' },
|
|
125
|
+
page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
|
|
126
|
+
pageSize: { type: 'number', description: 'Results per page (default 20, max 50)', minimum: 1, maximum: 50 },
|
|
105
127
|
},
|
|
106
128
|
required: ['query'],
|
|
107
129
|
},
|
|
@@ -129,31 +151,22 @@ export function wikiReadTools(o) {
|
|
|
129
151
|
description: `One page of ${noun} in full: its text, version, update date and metadata. ` +
|
|
130
152
|
'Address it by `path` ("tag/path/slug", exactly the `path` or `url` a listing returns), or by `tagPath` + `slug`; a category\'s own path reads its hub article. Not a title. ' +
|
|
131
153
|
'For 2 to 20 pages call get_pages, one round trip rather than N. A miss names the nearest pages, so a wrong guess is fixed in one retry. ' +
|
|
132
|
-
'A page\'s URL with `.md` appended is its markdown.'
|
|
154
|
+
'A page\'s URL with `.md` appended is its markdown. ' +
|
|
155
|
+
'With `blocks: true`, `content` is the stored block array, ids and all, beside the page `id` and its whole `metadata`: read a page this way before editing it, and send its `version` back as the edit\'s baseVersion.',
|
|
133
156
|
inputSchema: {
|
|
134
157
|
type: 'object',
|
|
135
158
|
properties: {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
|
|
159
|
+
...ADDRESS,
|
|
160
|
+
blocks: { type: 'boolean', description: 'Return the stored blocks rather than prose, for an edit' },
|
|
139
161
|
},
|
|
140
162
|
requireOneOf: ['path', 'slug'],
|
|
141
163
|
},
|
|
142
164
|
handler: async (args) => {
|
|
143
165
|
const r = readArgs(args);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const page = await read(t);
|
|
149
|
-
if (!page) {
|
|
150
|
-
const near = await store.search(t.slug.replace(/-/g, ' '), { take: 5 });
|
|
151
|
-
throw new McpToolError(`No page at path "${pathOf(t.tagPath, t.slug)}".`, {
|
|
152
|
-
didYouMean: near.items.filter(p => !off(p.tagPath)).map(p => ({ path: pathOf(p.tagPath, p.slug), title: p.title })),
|
|
153
|
-
hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return withAdjustments(r, full(page));
|
|
166
|
+
const page = await named(r);
|
|
167
|
+
if (!r.bool('blocks'))
|
|
168
|
+
return withAdjustments(r, full(page));
|
|
169
|
+
return withAdjustments(r, { ...full(page), id: page.id, content: page.content, metadata: page.metadata, ...o.forEdit?.(page) });
|
|
157
170
|
},
|
|
158
171
|
},
|
|
159
172
|
{
|
|
@@ -167,7 +180,7 @@ export function wikiReadTools(o) {
|
|
|
167
180
|
type: 'object',
|
|
168
181
|
properties: {
|
|
169
182
|
paths: { type: 'array', items: { type: 'string' }, description: `Page paths, 1 to ${MAX_BATCH_PATHS} per call` },
|
|
170
|
-
maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)' },
|
|
183
|
+
maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)', minimum: 1_000, maximum: 1_000_000 },
|
|
171
184
|
},
|
|
172
185
|
required: ['paths'],
|
|
173
186
|
},
|
|
@@ -228,8 +241,8 @@ export function wikiReadTools(o) {
|
|
|
228
241
|
properties: {
|
|
229
242
|
tagPath: { type: 'string', description: 'The branch to list, e.g. a path from get_categories. Includes its descendants.' },
|
|
230
243
|
sort: { type: 'string', enum: ['title', 'updatedAt'], description: 'Sort order (default "updatedAt", newest first)' },
|
|
231
|
-
page: { type: 'number', description: '1-based page number (default 1)' },
|
|
232
|
-
pageSize: { type: 'number', description: 'Results per page (default 20, max 100)' },
|
|
244
|
+
page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
|
|
245
|
+
pageSize: { type: 'number', description: 'Results per page (default 20, max 100)', minimum: 1, maximum: 100 },
|
|
233
246
|
},
|
|
234
247
|
},
|
|
235
248
|
handler: async (args) => {
|
|
@@ -285,8 +298,8 @@ export function wikiReadTools(o) {
|
|
|
285
298
|
inputSchema: {
|
|
286
299
|
type: 'object',
|
|
287
300
|
properties: {
|
|
288
|
-
days: { type: 'number', description: 'Look back this many days (default 7, max 30)' },
|
|
289
|
-
limit: { type: 'number', description: 'Most pages returned (default 20, max 50)' },
|
|
301
|
+
days: { type: 'number', description: 'Look back this many days (default 7, max 30)', minimum: 1, maximum: 30 },
|
|
302
|
+
limit: { type: 'number', description: 'Most pages returned (default 20, max 50)', minimum: 1, maximum: 50 },
|
|
290
303
|
},
|
|
291
304
|
},
|
|
292
305
|
handler: async (args) => {
|
|
@@ -320,8 +333,8 @@ export function wikiReadTools(o) {
|
|
|
320
333
|
properties: {
|
|
321
334
|
sizeOnly: { type: 'boolean', description: 'Sizes and breakdowns only, no document. Do this before the first real pull.' },
|
|
322
335
|
tagPath: { type: 'string', description: 'One branch and its descendants. Omit for everything.' },
|
|
323
|
-
maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})
|
|
324
|
-
skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)' },
|
|
336
|
+
maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})`, minimum: CORPUS_BUDGET.min, maximum: CORPUS_BUDGET.max },
|
|
337
|
+
skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)', minimum: 0 },
|
|
325
338
|
},
|
|
326
339
|
},
|
|
327
340
|
handler: async (args) => {
|
|
@@ -337,6 +350,33 @@ export function wikiReadTools(o) {
|
|
|
337
350
|
return withAdjustments(r, { scope: o.corpusTitle, ...(tagPath ? { tagPath } : {}), ...slice });
|
|
338
351
|
},
|
|
339
352
|
},
|
|
353
|
+
{
|
|
354
|
+
name: 'get_page_history',
|
|
355
|
+
title: 'Page history',
|
|
356
|
+
annotations: READ,
|
|
357
|
+
description: `A page's revisions in ${noun}, newest first: each one's \`id\`, version, change type, message, date and the blocks it changed. ` +
|
|
358
|
+
'Address the page as get_page does. A revision `id` is what a restore takes, where this server has one. Defaults: limit 20 (max 100).',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
...ADDRESS,
|
|
363
|
+
limit: { type: 'number', description: 'Most revisions returned (default 20, max 100)', minimum: 1, maximum: 100 },
|
|
364
|
+
},
|
|
365
|
+
requireOneOf: ['path', 'slug'],
|
|
366
|
+
},
|
|
367
|
+
handler: async (args) => {
|
|
368
|
+
const r = readArgs(args);
|
|
369
|
+
const page = await named(r);
|
|
370
|
+
const revisions = await store.history(page.id, { limit: r.num('limit', 20, 1, 100) });
|
|
371
|
+
const names = o.authors && await o.authors([...new Set(revisions.flatMap(v => v.authorId ? [v.authorId] : []))]);
|
|
372
|
+
return withAdjustments(r, {
|
|
373
|
+
path: pathOf(page.tagPath, page.slug),
|
|
374
|
+
url: o.url(page.tagPath, page.slug),
|
|
375
|
+
currentVersion: page.version,
|
|
376
|
+
revisions: revisions.map(({ pageId: _, authorId, ...v }) => ({ ...v, ...(names ? { author: (authorId && names.get(authorId)) || null } : {}) })),
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
},
|
|
340
380
|
];
|
|
341
381
|
return tools.map(t => (o.skills?.[t.name] ? { ...t, skill: o.skills[t.name] } : t));
|
|
342
382
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -243,6 +243,11 @@
|
|
|
243
243
|
"import": "./dist/seeded.js",
|
|
244
244
|
"default": "./dist/seeded.js"
|
|
245
245
|
},
|
|
246
|
+
"./og": {
|
|
247
|
+
"types": "./dist/og.d.ts",
|
|
248
|
+
"import": "./dist/og.js",
|
|
249
|
+
"default": "./dist/og.js"
|
|
250
|
+
},
|
|
246
251
|
"./sanitize": {
|
|
247
252
|
"types": "./dist/sanitize.d.ts",
|
|
248
253
|
"import": "./dist/sanitize.js",
|