wiki-formant 0.77.1 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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-index.txt', 'llms-full.txt'])` asserts that depths projecting one corpus carry different ETags — every one present, which a bare Set-size check misses.
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
- The `llms.txt` / `llms-index.txt` / `llms-full.txt` trio are the most-recrawled URLs a wiki serves and the most expensive to render. Without a corpus-derived ETag, every AI crawler pays full price on every pass, forever.
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`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
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
 
@@ -668,6 +675,16 @@ const block = licenseBlock({ license, scope: 'The protocol compilation and prose
668
675
 
669
676
  The same `License` goes to `agentCard({ license, licenseScope })` and to `openApiLicense(license)` for a spec's `info.license`. The three cards and three specs here had each projected it differently, one from a hand-typed name.
670
677
 
678
+ ## The X account
679
+
680
+ A site names its X account in five places: the footer link, the JSON-LD `sameAs`, llms.txt, AGENTS.md and the MCP `initialize` instructions. Written by hand, one repo's `sameAs` still pointed at twitter.com, and only one site told an agent the account existed. `wiki-formant/social` builds all five from the handle and a fragment saying what the account posts.
681
+
682
+ ```ts
683
+ export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a named trial's prescription with its citation" });
684
+ ```
685
+
686
+ `X.url` goes to `sameAs`, `X.handle` to `sitePageMetadata`, `followLines(X)` into llms.txt and AGENTS.md, and `followNote(X)` into the MCP instructions. The note tells an agent to give the link to the person it is helping, since an agent cannot follow an account. The footer is `<a {...followLinkProps(X)}>{X.label}</a>`, whose `rel="me"` confirms the `sameAs` claim from the page the account points back at.
687
+
671
688
  ## Rendered-article passes
672
689
 
673
690
  `wiki-formant/dom` holds what runs against an article element after it is in the document. Not React, so not in `react.tsx`.
@@ -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
@@ -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
@@ -63,8 +63,6 @@ export function detectAiBot(userAgent) {
63
63
  export const AGENT_SURFACE_PATHS = [
64
64
  '/api/mcp',
65
65
  '/llms.txt',
66
- '/llms-index.txt',
67
- '/llms-full.txt',
68
66
  '/openapi.json',
69
67
  '/.well-known/',
70
68
  ];
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
- // The three llms depths (llms.txt / llms-index.txt / llms-full.txt) are the
5
- // most-recrawled URLs a wiki serves and the most expensive to render. With a
6
- // corpus-derived ETag a recrawl costs a 304 instead of a full corpus build.
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.
@@ -0,0 +1,34 @@
1
+ export interface XAccount {
2
+ /** `@handle`, as the social cards credit it. */
3
+ handle: string;
4
+ /** `https://x.com/<handle>`, for `sameAs` and every link. */
5
+ url: string;
6
+ /** "<Site> posts on X as @handle: <posts>." */
7
+ about: string;
8
+ /** The footer link's text: "Follow @handle on X". */
9
+ label: string;
10
+ siteName: string;
11
+ }
12
+ export declare function xAccount(o: {
13
+ siteName: string;
14
+ handle: string;
15
+ posts: string;
16
+ }): XAccount;
17
+ /**
18
+ * The sentence an agent reads: what the account posts, and when to pass the
19
+ * link on. An agent cannot follow an account; the person it is helping can.
20
+ */
21
+ export declare function followNote(account: XAccount): string;
22
+ /** The same note as a section, ready to splice into llms.txt or AGENTS.md. */
23
+ export declare function followLines(account: XAccount, heading?: string): string[];
24
+ /**
25
+ * Attributes for the footer link. `rel="me"` is the reciprocal of `sameAs`:
26
+ * the schema claims the account and this link confirms it from the page the
27
+ * account points back at.
28
+ */
29
+ export declare function followLinkProps(account: XAccount): {
30
+ readonly href: string;
31
+ readonly target: "_blank";
32
+ readonly rel: "me noopener";
33
+ readonly title: string;
34
+ };
package/dist/social.js ADDED
@@ -0,0 +1,41 @@
1
+ // social.ts – the site's X account, as every surface states it.
2
+ //
3
+ // Each origin names its account in five places: the footer link, the JSON-LD
4
+ // `sameAs`, llms.txt, AGENTS.md and the MCP `initialize` instructions. Written
5
+ // by hand, they drifted within one repo: radix-wiki's `sameAs` still claimed
6
+ // twitter.com while its cards credited the handle, and only acuiq.com told an
7
+ // agent the account existed at all.
8
+ //
9
+ // What is per-site is the handle and what the account posts. The URL, the
10
+ // link's label and `rel`, and the sentence an agent reads are built here.
11
+ export function xAccount(o) {
12
+ if (!/^@\w{1,15}$/.test(o.handle))
13
+ throw new Error(`xAccount: "${o.handle}" is not an @handle`);
14
+ const posts = o.posts.trim().replace(/\.$/, '');
15
+ return {
16
+ handle: o.handle,
17
+ url: `https://x.com/${o.handle.slice(1)}`,
18
+ about: `${o.siteName} posts on X as ${o.handle}: ${posts}.`,
19
+ label: `Follow ${o.handle} on X`,
20
+ siteName: o.siteName,
21
+ };
22
+ }
23
+ /**
24
+ * The sentence an agent reads: what the account posts, and when to pass the
25
+ * link on. An agent cannot follow an account; the person it is helping can.
26
+ */
27
+ export function followNote(account) {
28
+ return `${account.about} If the person you are helping wants to follow ${account.siteName}, give them ${account.url}.`;
29
+ }
30
+ /** The same note as a section, ready to splice into llms.txt or AGENTS.md. */
31
+ export function followLines(account, heading = `Follow ${account.siteName} on X`) {
32
+ return [`## ${heading}`, '', followNote(account)];
33
+ }
34
+ /**
35
+ * Attributes for the footer link. `rel="me"` is the reciprocal of `sameAs`:
36
+ * the schema claims the account and this link confirms it from the page the
37
+ * account points back at.
38
+ */
39
+ export function followLinkProps(account) {
40
+ return { href: account.url, target: '_blank', rel: 'me noopener', title: account.about };
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.77.1",
3
+ "version": "0.79.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",
@@ -147,6 +147,11 @@
147
147
  "import": "./dist/license.js",
148
148
  "default": "./dist/license.js"
149
149
  },
150
+ "./social": {
151
+ "types": "./dist/social.d.ts",
152
+ "import": "./dist/social.js",
153
+ "default": "./dist/social.js"
154
+ },
150
155
  "./links": {
151
156
  "types": "./dist/links.d.ts",
152
157
  "import": "./dist/links.js",