wiki-formant 0.77.0 → 0.78.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 +11 -4
- package/dist/base.css +2 -4
- package/dist/conformance.d.ts +16 -0
- package/dist/conformance.js +29 -0
- package/dist/crawlers.js +0 -2
- package/dist/http.d.ts +14 -0
- package/dist/http.js +19 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -260,6 +260,7 @@ The drift checks hold an MCP server to what its own site does, so a tool cannot
|
|
|
260
260
|
- `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
|
|
261
261
|
- `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
|
|
262
262
|
- `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
|
|
263
|
+
- `freshAfterWriteChecks(t, urls, write, { shows?, gone? })`: each URL is read, `write` runs, and each then carries `shows` and not `gone`. It catches a twin or `llms.txt` the CDN keeps past the save that changed it, which every check that reads a surface once passes. Run it against a deployment: with no CDN in front it passes trivially.
|
|
263
264
|
|
|
264
265
|
`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.
|
|
265
266
|
|
|
@@ -267,7 +268,7 @@ The drift checks hold an MCP server to what its own site does, so a tool cannot
|
|
|
267
268
|
await versionCoherence(t, server.version, standardVersionSources(t, ['/openapi.json', '/api/openapi.json']), init.result?.serverInfo?.version);
|
|
268
269
|
```
|
|
269
270
|
|
|
270
|
-
`descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves, and `distinctEtagChecks(t, ['llms.txt', 'llms-
|
|
271
|
+
`descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves, and `distinctEtagChecks(t, ['llms.txt', 'llms-full.txt'])` asserts that depths projecting one corpus carry different ETags — every one present, which a bare Set-size check misses.
|
|
271
272
|
|
|
272
273
|
## Markdown twins
|
|
273
274
|
|
|
@@ -290,13 +291,19 @@ return new Response(
|
|
|
290
291
|
|
|
291
292
|
`noindex` sends `X-Robots-Tag: noindex`, for a twin of an HTML page: a search engine that reads it has the page already. A document with no HTML page, an AGENTS.md, leaves it off.
|
|
292
293
|
|
|
294
|
+
Put `cacheTagHeaders(tags)` in `extra`, with the tags your save path already revalidates. A route handler is held at Vercel's CDN by its `s-maxage` alone, so without `Vercel-Cache-Tag` the twin outlives every save by its edge window while the ISR page beside it is fresh. The same goes for `llms.txt`, a search index and a sitemap: any text an agent reads back after writing.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
headers: markdownHeaders(lastModified, { etag, noindex: true, extra: { ...VARY_ACCEPT, ...cacheTagHeaders(pageTag(path)) } })
|
|
298
|
+
```
|
|
299
|
+
|
|
293
300
|
Block trees stay in your app — every project owns its own type set. Give this module HTML and it gives you markdown.
|
|
294
301
|
|
|
295
302
|
> A trap worth naming: if you serve twins via a rewrite, Next drops the destination query string. The rewrite must carry the `.md` extension through to the destination path, or the twin silently serves JSON.
|
|
296
303
|
|
|
297
304
|
## Conditional GET
|
|
298
305
|
|
|
299
|
-
|
|
306
|
+
`llms.txt` and the markdown twins are the most-recrawled URLs a wiki serves, and a corpus export the most expensive to render. Without a corpus-derived ETag, every AI crawler pays full price on every pass, forever.
|
|
300
307
|
|
|
301
308
|
A URL that answers JSON or markdown by `Accept` asks `wantsMarkdown(request)` and sends `VARY_ACCEPT` on both branches. Without the Vary, a shared cache hands one client the other's format.
|
|
302
309
|
|
|
@@ -497,7 +504,7 @@ and a crawler obeys only its most-specific matching group, so an agent with no
|
|
|
497
504
|
group of its own falls through to `*`. Three wikis were measuring five crawlers
|
|
498
505
|
they had never addressed.
|
|
499
506
|
|
|
500
|
-
Every agent group also allows the agent surface — `/api/mcp`,
|
|
507
|
+
Every agent group also allows the agent surface — `/api/mcp`, `/llms.txt`, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
|
|
501
508
|
|
|
502
509
|
`crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. 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. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
|
|
503
510
|
|
|
@@ -761,7 +768,7 @@ const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
|
|
|
761
768
|
|
|
762
769
|
The default list is derived from what the editor nodes in `wiki-formant/tiptap` store — the embed wrappers' data attributes, the tab markup `activateTabGroups` reads back, the table classes — plus the presentational SVG subset the infographics pipeline embeds. Extend it with `tags`, `attributes`, `classes` and `schemesByTag` derived from your stored HTML, never from memory: a list written from memory erases content on the first render.
|
|
763
770
|
|
|
764
|
-
A YouTube iframe comes out as the `YouTube` node's markup, `<div data-youtube-video><iframe …></iframe></div>`, whoever wrote it: an agent, an import, a pasted embed code. The editor then opens it as that node, and `base.css` draws it as the same full-width 16:9 player as one the editor wrote, as it does a map embed. Two wikis had drawn that player in their own stylesheets and a third drew any iframe its own way; corners, borders and the editor's selection ring stay the site's.
|
|
771
|
+
A YouTube iframe comes out as the `YouTube` node's markup, `<div data-youtube-video><iframe …></iframe></div>`, whoever wrote it: an agent, an import, a pasted embed code. The editor then opens it as that node, and `base.css` draws it as the same full-width 16:9 player as one the editor wrote, as it does a map embed. Two wikis had drawn that player in their own stylesheets and a third drew any iframe its own way; spacing, corners, borders and the editor's selection ring stay the site's.
|
|
765
772
|
|
|
766
773
|
Classes pass by name only. Limiting `style` to layout and paint is worth nothing while `class` is free, because every site's own stylesheet ships `fixed inset-0 z-50`, and those draw a fake prompt over the chrome as well as `position` does.
|
|
767
774
|
|
package/dist/base.css
CHANGED
|
@@ -434,13 +434,11 @@ pre:hover > .code-copy-btn,
|
|
|
434
434
|
/* ---- embeds ------------------------------------------------------------------
|
|
435
435
|
The players the `wiki-formant/tiptap` nodes store, filling their column at a
|
|
436
436
|
screen's proportions. The node writes a width and height onto the iframe,
|
|
437
|
-
which `aspect-ratio` with `height: auto` overrides.
|
|
438
|
-
editor's selection ring are the site's. */
|
|
439
|
-
:is(div[data-youtube-video], .map-embed) { margin: 1em 0; }
|
|
437
|
+
which `aspect-ratio` with `height: auto` overrides. Spacing, corners,
|
|
438
|
+
borders and the editor's selection ring are the site's. */
|
|
440
439
|
:is(div[data-youtube-video], .map-embed) iframe {
|
|
441
440
|
display: block;
|
|
442
441
|
width: 100%;
|
|
443
442
|
height: auto;
|
|
444
443
|
aspect-ratio: 16 / 9;
|
|
445
|
-
border: 0;
|
|
446
444
|
}
|
package/dist/conformance.d.ts
CHANGED
|
@@ -218,6 +218,22 @@ export declare function rowParity(t: Tester, label: string, mcp: unknown, rest:
|
|
|
218
218
|
* adds where the page's own blocks open with no h1.
|
|
219
219
|
*/
|
|
220
220
|
export declare function twinParity(t: Tester, label: string, twinUrl: string, content: string): Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* A write reaches what an agent reads back. Each URL is read first, so the CDN
|
|
223
|
+
* holds a copy, then `write` runs, and each must then carry `shows` and no
|
|
224
|
+
* longer carry `gone`.
|
|
225
|
+
*
|
|
226
|
+
* Every other check here reads a surface once, so a twin cached past the save
|
|
227
|
+
* that changed it passed all of them: the pages were ISR and fresh, while the
|
|
228
|
+
* route handlers beside them answered from their edge window for an hour. A
|
|
229
|
+
* purge takes a moment to reach every region, so each URL gets five seconds,
|
|
230
|
+
* nothing near an edge window. With no CDN in front, as on a dev server, it
|
|
231
|
+
* passes trivially; run it against a deployment.
|
|
232
|
+
*/
|
|
233
|
+
export declare function freshAfterWriteChecks(t: Tester, urls: readonly string[], write: () => Promise<unknown>, expect: {
|
|
234
|
+
shows?: string;
|
|
235
|
+
gone?: string;
|
|
236
|
+
}): Promise<void>;
|
|
221
237
|
/**
|
|
222
238
|
* What an edit starts from, on any server built on the wiki read tools: rows
|
|
223
239
|
* carry the id a pageList block takes, `get_page` with `blocks: true` returns
|
package/dist/conformance.js
CHANGED
|
@@ -538,6 +538,35 @@ export async function twinParity(t, label, twinUrl, content) {
|
|
|
538
538
|
body = body.replace(/^# [^\n]*\n+/, '');
|
|
539
539
|
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));
|
|
540
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* A write reaches what an agent reads back. Each URL is read first, so the CDN
|
|
543
|
+
* holds a copy, then `write` runs, and each must then carry `shows` and no
|
|
544
|
+
* longer carry `gone`.
|
|
545
|
+
*
|
|
546
|
+
* Every other check here reads a surface once, so a twin cached past the save
|
|
547
|
+
* that changed it passed all of them: the pages were ISR and fresh, while the
|
|
548
|
+
* route handlers beside them answered from their edge window for an hour. A
|
|
549
|
+
* purge takes a moment to reach every region, so each URL gets five seconds,
|
|
550
|
+
* nothing near an edge window. With no CDN in front, as on a dev server, it
|
|
551
|
+
* passes trivially; run it against a deployment.
|
|
552
|
+
*/
|
|
553
|
+
export async function freshAfterWriteChecks(t, urls, write, expect) {
|
|
554
|
+
const at = (u) => (u.startsWith('http') ? u : `${t.base}/${u.replace(/^\//, '')}`);
|
|
555
|
+
const fresh = (body) => (!expect.shows || body.includes(expect.shows)) && (!expect.gone || !body.includes(expect.gone));
|
|
556
|
+
await Promise.all(urls.map(u => fetch(at(u)).then(r => r.text())));
|
|
557
|
+
await write();
|
|
558
|
+
for (const u of urls) {
|
|
559
|
+
let res = null;
|
|
560
|
+
let body = '';
|
|
561
|
+
for (let i = 0; i < 5 && !(res?.ok && fresh(body)); i++) {
|
|
562
|
+
if (i)
|
|
563
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
564
|
+
res = await fetch(at(u));
|
|
565
|
+
body = await res.text();
|
|
566
|
+
}
|
|
567
|
+
t.check(`${u.replace(t.base, '')} after a write`, (!!expect.shows || !!expect.gone) && !!res?.ok && fresh(body), !expect.shows && !expect.gone ? 'nothing to compare: neither shows nor gone' : `${res?.status} ${res?.headers.get('x-vercel-cache') ?? ''} ${fresh(body) ? 'fresh' : 'the copy from before the write'}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
541
570
|
/**
|
|
542
571
|
* What an edit starts from, on any server built on the wiki read tools: rows
|
|
543
572
|
* carry the id a pageList block takes, `get_page` with `blocks: true` returns
|
package/dist/crawlers.js
CHANGED
package/dist/http.d.ts
CHANGED
|
@@ -62,6 +62,20 @@ export declare function markdownHeaders(lastModified?: string | null, opts?: {
|
|
|
62
62
|
noindex?: boolean;
|
|
63
63
|
extra?: Record<string, string>;
|
|
64
64
|
}): Record<string, string>;
|
|
65
|
+
/**
|
|
66
|
+
* The tag that ties a route handler's response to the writes that change it.
|
|
67
|
+
* Spread it into the response's headers, beside its `Cache-Control`.
|
|
68
|
+
*
|
|
69
|
+
* A route handler is held at Vercel's CDN by its `s-maxage` alone: the tags
|
|
70
|
+
* `revalidateTag` purges are a page's, and nothing names this response among
|
|
71
|
+
* them. So a twin, llms.txt or a search index outlived every save by its edge
|
|
72
|
+
* window, and an agent that read a page back after writing it saw the old
|
|
73
|
+
* words — four origins shipped that, each with ISR pages that were fresh. A
|
|
74
|
+
* response carrying `Vercel-Cache-Tag` is purged with the tag, so pass the
|
|
75
|
+
* tags the save path already revalidates. `freshAfterWriteChecks` holds a
|
|
76
|
+
* deployment to it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function cacheTagHeaders(tags: string | readonly string[]): Record<string, string>;
|
|
65
79
|
/**
|
|
66
80
|
* Whether a request to a URL that serves both JSON and markdown asked for the
|
|
67
81
|
* markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
|
package/dist/http.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// http.ts — conditional-GET plumbing for text corpus endpoints, and the error
|
|
2
2
|
// answers every route handler gives (at the foot).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// Without one, every AI crawler pays full price on every pass, forever.
|
|
4
|
+
// llms.txt and the markdown twins are the most-recrawled URLs a wiki serves.
|
|
5
|
+
// With a corpus-derived ETag a recrawl costs a 304 instead of a full corpus
|
|
6
|
+
// build. Without one, every AI crawler pays full price on every pass, forever.
|
|
8
7
|
import { isoDate } from './html.js';
|
|
9
8
|
/** A stable ETag from whatever the corpus revision is (count + newest stamp). */
|
|
10
9
|
export function corpusEtag(parts) {
|
|
@@ -125,6 +124,22 @@ export function markdownHeaders(lastModified, opts = {}) {
|
|
|
125
124
|
...opts.extra,
|
|
126
125
|
};
|
|
127
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The tag that ties a route handler's response to the writes that change it.
|
|
129
|
+
* Spread it into the response's headers, beside its `Cache-Control`.
|
|
130
|
+
*
|
|
131
|
+
* A route handler is held at Vercel's CDN by its `s-maxage` alone: the tags
|
|
132
|
+
* `revalidateTag` purges are a page's, and nothing names this response among
|
|
133
|
+
* them. So a twin, llms.txt or a search index outlived every save by its edge
|
|
134
|
+
* window, and an agent that read a page back after writing it saw the old
|
|
135
|
+
* words — four origins shipped that, each with ISR pages that were fresh. A
|
|
136
|
+
* response carrying `Vercel-Cache-Tag` is purged with the tag, so pass the
|
|
137
|
+
* tags the save path already revalidates. `freshAfterWriteChecks` holds a
|
|
138
|
+
* deployment to it.
|
|
139
|
+
*/
|
|
140
|
+
export function cacheTagHeaders(tags) {
|
|
141
|
+
return { 'Vercel-Cache-Tag': [tags].flat().join(',') };
|
|
142
|
+
}
|
|
128
143
|
/**
|
|
129
144
|
* Whether a request to a URL that serves both JSON and markdown asked for the
|
|
130
145
|
* markdown: `?format=text`, or an Accept naming text/markdown or text/plain.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.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",
|