wiki-formant 0.83.1 → 0.85.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
@@ -37,9 +37,9 @@ const { items, total } = await store.search('validators', { take: 20 });
37
37
  await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.version });
38
38
  ```
39
39
 
40
- - **A save is one statement.** The page update and its revision are a data-modifying CTE guarded on the version the save read. A save that lost a race writes nothing and throws a 409 `HttpError`; pass `baseVersion` and a save opened on an old version is refused the same way, instead of erasing the edit in between.
40
+ - **A save is one statement.** The page update and its revision are a data-modifying CTE guarded on the version the save read. A save that lost a race writes nothing and throws a 409 `HttpError`; pass `baseVersion` and a save opened on an old version, or on a page deleted since, is refused the same way, instead of erasing the edit in between or re-creating the page. A rename onto a slug another save took meanwhile is a 409 too.
41
41
  - **The bump is the diff's.** Added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it for a script that knows better. A metadata-only save moves no version and writes no revision.
42
- - **History is diffed on read.** A revision stores the page as it was saved, never a diff, so a script's revision and the editor's carry the same history. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown is diffed against its real predecessor.
42
+ - **History is diffed on read.** A revision stores the page as it was saved, never a diff, so a script's revision and the editor's carry the same history. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown is diffed against its real predecessor. It and `revision` answer only for a page of the store's own site.
43
43
  - **Listings carry a snippet, not the article.** `list`, `recent` and `byIds` drop `content` for one line of it; a category of 200 cards otherwise ships 200 block arrays.
44
44
  - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' own headline as the snippet. Hidden paths are left out unless the search names one; `content: true` keeps each row's blocks.
45
45
  - **A path under a hidden one is hidden too**, in every listing, whether or not the tree names it.
@@ -93,7 +93,7 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
93
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`.
94
94
  - `text` should be the serializer the page's markdown twin uses, and `resolve` the resolver its page uses to fill what is read live (a page list's pages): `get_page`, `get_pages` and the default corpus read text through both, so a page reads the same over MCP as at its twin, which `twinParity` checks.
95
95
  - `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
96
- - `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.
96
+ - `metadata`, `label`, `corpus`, `noMatch` and `skills` are the app's: what a row adds, how a category is named, the sections `get_full_corpus` slices, what an empty search says, and the A2A skills.
97
97
  - Writes stay with each app, because who may write is the one thing no two of them agree on.
98
98
 
99
99
  ## Taxonomy
@@ -268,7 +268,7 @@ The drift checks hold an MCP server to what its own site does, so a tool cannot
268
268
  await versionCoherence(t, server.version, standardVersionSources(t, ['/openapi.json', '/api/openapi.json']), init.result?.serverInfo?.version);
269
269
  ```
270
270
 
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
+ `descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves.
272
272
 
273
273
  ## Markdown twins
274
274
 
@@ -377,7 +377,7 @@ The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the same list the sanitiser's
377
377
 
378
378
  `parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
379
379
 
380
- `parseVersion` / `bump` / `compareVersions` handle revision semver tolerantly — a page always has a version, even when the column holds `null` or junk.
380
+ `parseVersion` / `bump` handle revision semver tolerantly — a page always has a version, even when the column holds `null` or junk.
381
381
 
382
382
  ## React
383
383
 
@@ -422,7 +422,7 @@ const { ref, onScroll, at, step, go } = useSnapRow();
422
422
 
423
423
  `useClickOutside` is fifteen lines and was in all three. Only one had the `offsetParent` check, and without it a container hidden at the current breakpoint still answers outside-clicks — so on a phone, a tap anywhere dismisses the popover the reader is looking at, because the hidden desktop copy got there first.
424
424
 
425
- `useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed.
425
+ `useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed. `useSearchShortcut(open)` is Wikipedia's "/" to search, ignored while the reader types in a field: `open` focuses the field or opens what holds it, and returns false to leave the key alone, which `focusShown(input)` does for a field hidden at this width.
426
426
 
427
427
  ### The listbox contract
428
428
 
@@ -765,6 +765,14 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
765
765
 
766
766
  `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
767
767
 
768
+ ## Freshness and the maintenance queues
769
+
770
+ `freshnessBanner(page, nowMs, { maxAgeDays, against })` is the synthetic `outdated` banner a page carries once it has gone unchecked for `maxAgeDays` (180), or null. Its notice asks the reader to re-check the facts against `current sources`; the two Radix wikis pass `against: 'current sources and the live ledger'`, which a shop's wiki has no use for.
771
+
772
+ `wiki-formant/queues` gathers the signals each page shows into Wikipedia's tracking categories: `maintenanceQueues(pages, { href, required, nowMs, verify, listedIds })` returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped, from one pass over the pages' content. Pass every page but the home page and the back office, since an orphan is a page no other page links to. `href` is a page's address as its links name it, `required` the facts its category requires, `verify` how a page is stamped as checked here (it closes the outdated queue's sentence), and `listedIds` the ids a list block names its pages by. `MaintenanceQueues` (`wiki-formant/react-server`) draws them, a heading and a count each, under classes `maintenance-*`; the page around them is the wiki's, `noindex`. All four apps serve one at `/maintenance` (on miow, the owner page).
773
+
774
+ `Cite` (`wiki-formant/react-server`) is "Cite" in an article's meta line, a `<details>` opening on `citation({ publisher, title, date, url })`: `AcuiQ. "Point codes." October 2, 2026. https://…`.
775
+
768
776
  ## Sanitising stored HTML
769
777
 
770
778
  `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
@@ -1177,7 +1185,7 @@ npx fts-ddl pages
1177
1185
  npx fts-ddl '"WikiPage"'
1178
1186
  ```
1179
1187
 
1180
- `mark-verified` stamps `lastVerifiedAt` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Both work on the package's own `WikiPage` and `WikiRevision`. Run them from the app's root, like `passkey-invite`:
1188
+ `mark-verified` stamps `lastVerifiedAt` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which is how another routine files an item. Every mode holds the row lock from read to write, and `backlog-add` records what it filed in `state.filed`, so a `write` of a state read before the filing merges the item back in rather than dropping it (`lateBacklog`). Only `metadata.state` is written; the page's other metadata is kept. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Both work on the package's own `WikiPage` and `WikiRevision`. Run them from the app's root, like `passkey-invite`:
1181
1189
 
1182
1190
  ```sh
1183
1191
  npx mark-verified contents/tech/radix-engine scrypto # one page
@@ -26,11 +26,12 @@
26
26
  // page in place, because a full copy per run is what made radix-wiki's log
27
27
  // over half its revisions table. `write` refuses a state whose totalRuns is
28
28
  // below the stored one: that state was read before another run wrote.
29
- // `backlog-add` is how another routine files an
30
- // item: `write` replaces the whole state, so an append through it could lose a
31
- // concurrent run's, and the row lock serialises the two. A revision bumps the
32
- // page's version a minor.
33
- import { PINNED, addBacklog, compact, render, staleWrite } from '../dist/maintenance.js';
29
+ // `backlog-add` is how another routine files an item. It records what it filed
30
+ // in `state.filed`, and a `write` whose state was read before the filing merges
31
+ // the item back in, so a run's whole-state write cannot drop it. Every mode
32
+ // reads and writes under the row lock. Only `state` in the page's metadata is
33
+ // written; other keys are kept. A revision bumps the page's version a minor.
34
+ import { PINNED, addBacklog, compact, lateBacklog, render, staleWrite } from '../dist/maintenance.js';
34
35
  import { bump } from '../dist/versioning.js';
35
36
  import { arg, connect, invalidate } from './_cli.mjs';
36
37
 
@@ -48,6 +49,8 @@ if (!['read', 'write', 'compact', 'backlog-add'].includes(mode) || slugAt < 1 ||
48
49
  const pages = '"WikiPage"';
49
50
  const revisions = '"WikiRevision"';
50
51
  const where = `"tagPath" = $1 AND slug = $2 AND "siteId" = $3`;
52
+ /** The new state into the page's metadata, beside whatever else it holds. `lastVerified` was a stamp nothing read. */
53
+ const withState = `(metadata - 'lastVerified') || jsonb_build_object('state', $2::jsonb)`;
51
54
  const address = [at.slice(0, slugAt), at.slice(slugAt + 1), arg('--site') ?? ''];
52
55
 
53
56
  const readStdin = async () => {
@@ -79,14 +82,14 @@ try {
79
82
  await q('ROLLBACK');
80
83
  console.log(' nothing to add (all duplicates)');
81
84
  } else {
82
- const next = compact({ ...state, backlog: [...(state.backlog ?? []), ...added] });
85
+ const next = compact({ ...state, backlog: [...(state.backlog ?? []), ...added], filed: [...(state.filed ?? []), ...added.map(a => a.item)] });
83
86
  console.log(` ${dry ? '[dry] ' : ''}+${added.length} backlog item(s), ${next.backlog.length} open`);
84
87
  for (const a of added) console.log(` ${a.item}`);
85
88
  if (dry) await q('ROLLBACK');
86
89
  else {
87
- await q(`UPDATE ${pages} SET content = $1, metadata = $2, "updatedAt" = now() WHERE id = $3`, [
90
+ await q(`UPDATE ${pages} SET content = $1, metadata = ${withState}, "updatedAt" = now() WHERE id = $3`, [
88
91
  JSON.stringify(render(next, routine)),
89
- JSON.stringify({ state: next, lastVerified: today }),
92
+ JSON.stringify(next),
90
93
  page.id,
91
94
  ]);
92
95
  await q('COMMIT');
@@ -94,15 +97,22 @@ try {
94
97
  }
95
98
  }
96
99
  } else {
97
- const [page] = await q(`SELECT * FROM ${pages} WHERE ${where}`, address);
100
+ const text = mode === 'write' ? (payload === undefined || payload === '-' ? await readStdin() : payload) : '';
101
+ if (mode === 'write' && !text.trim()) throw new Error('write needs the state as JSON, as an argument or on stdin with `write -`');
102
+ // Locked from the read to the write, so a `backlog-add` lands before or after, never in between.
103
+ await q('BEGIN');
104
+ const [page] = await q(`SELECT * FROM ${pages} WHERE ${where} FOR UPDATE`, address);
98
105
  if (!page) throw new Error(`no log page at ${at}`);
99
- let input = page.metadata?.state ?? {};
106
+ const stored = page.metadata?.state ?? {};
107
+ let input = stored;
100
108
  if (mode === 'write') {
101
- const text = payload === undefined || payload === '-' ? await readStdin() : payload;
102
- if (!text.trim()) throw new Error('write needs the state as JSON, as an argument or on stdin with `write -`');
103
109
  input = JSON.parse(text);
104
- const stale = staleWrite(page.metadata?.state ?? {}, input);
110
+ const stale = staleWrite(stored, input);
105
111
  if (stale) throw new Error(stale);
112
+ const late = lateBacklog(stored, input, today);
113
+ for (const l of late) console.log(` kept, filed since this state was read: ${l.item}`);
114
+ const { filed: _, ...rest } = input;
115
+ input = { ...rest, backlog: [...(input.backlog ?? []), ...late] };
106
116
  }
107
117
  const state = compact(input);
108
118
  const content = render(state, routine);
@@ -130,11 +140,11 @@ try {
130
140
  `content ${JSON.stringify(content).length} B${revise ? `, revision v${version}` : ', no revision today'}`,
131
141
  );
132
142
 
133
- if (!dry) {
134
- await q('BEGIN');
143
+ if (dry) await q('ROLLBACK');
144
+ else {
135
145
  await q(
136
- `UPDATE ${pages} SET content = $1, metadata = $2, "updatedAt" = now(), version = $4 WHERE id = $3`,
137
- [JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id, version],
146
+ `UPDATE ${pages} SET content = $1, metadata = ${withState}, "updatedAt" = now(), version = $4 WHERE id = $3`,
147
+ [JSON.stringify(content), JSON.stringify(state), page.id, version],
138
148
  );
139
149
  if (revise) {
140
150
  await q(
@@ -29,8 +29,9 @@ try {
29
29
  client = await connect();
30
30
  const { rowCount } = all
31
31
  ? await client.query(
32
- `UPDATE "WikiPage" SET "lastVerifiedAt" = now() WHERE "siteId" = $3 AND ("tagPath" = $1 OR "tagPath" LIKE $2)`,
33
- [tagPath, `${tagPath}/%`, site],
32
+ // starts_with, not LIKE: a `_` or `%` in the path would match other branches.
33
+ `UPDATE "WikiPage" SET "lastVerifiedAt" = now() WHERE "siteId" = $3 AND ("tagPath" = $1 OR starts_with("tagPath", $2))`,
34
+ [tagPath, `${tagPath}/`, site],
34
35
  )
35
36
  : await client.query(
36
37
  `UPDATE "WikiPage" SET "lastVerifiedAt" = now() WHERE "siteId" = $3 AND "tagPath" = $1 AND slug = $2`,
package/dist/a2a.js CHANGED
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // Both revisions in the field are spoken: v0.3 (`message/send`, a Message with
12
12
  // `kind`) and v1.0 (`SendMessage`, a `{ message }` with proto-JSON enums).
13
- import { McpToolError } from './mcp.js';
13
+ import { MCP_LEGACY_PROTOCOL_VERSION, McpToolError } from './mcp.js';
14
14
  const SEND = { 'message/send': '0.3', SendMessage: '1.0' };
15
15
  /** Task methods: none can succeed, because no task is ever created. */
16
16
  const TASK = new Set(['tasks/get', 'tasks/cancel', 'tasks/resubscribe', 'GetTask', 'CancelTask', 'SubscribeToTask']);
@@ -31,7 +31,7 @@ const UNSUPPORTED = new Set([
31
31
  'GetExtendedAgentCard',
32
32
  ]);
33
33
  /** @internal */
34
- export const isA2aMethod = (method) => method in SEND || TASK.has(method) || UNSUPPORTED.has(method);
34
+ export const isA2aMethod = (method) => Object.hasOwn(SEND, method) || TASK.has(method) || UNSUPPORTED.has(method);
35
35
  const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
36
36
  /** The text of a message's text parts, whichever revision shaped them. */
37
37
  function textOf(message) {
@@ -53,14 +53,14 @@ async function reply(text, config) {
53
53
  const out = await tool.handler({ [config.answer.argument]: text }, {
54
54
  meta: {},
55
55
  setMeta: () => { },
56
- protocolVersion: '2025-11-25',
56
+ protocolVersion: MCP_LEGACY_PROTOCOL_VERSION,
57
57
  });
58
58
  const lead = `${config.serverInfo.name} ${tool.name} for ${JSON.stringify(text)}. For more, call this endpoint over MCP: "initialize", then "tools/list".`;
59
59
  if (typeof out === 'string')
60
60
  return { text: `${lead}\n\n${out}` };
61
61
  // The text twin stays beside the data, as it does on an MCP tool result:
62
62
  // a client that reads only text parts still has to be able to read the answer.
63
- return { text: `${lead}\n\n${JSON.stringify(out, null, 2)}`, ...(out && typeof out === 'object' && !Array.isArray(out) ? { data: out } : {}) };
63
+ return { text: `${lead}\n\n${JSON.stringify(out ?? null, null, 2)}`, ...(out && typeof out === 'object' && !Array.isArray(out) ? { data: out } : {}) };
64
64
  }
65
65
  catch (err) {
66
66
  if (err instanceof McpToolError)
package/dist/analytics.js CHANGED
@@ -134,15 +134,18 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
134
134
  if (BOT.test(headers.get('user-agent') ?? '') || !rateLimit(clientKey('beacon', headers), limit).ok)
135
135
  return done;
136
136
  const body = (await request.json().catch(() => null));
137
- const path = typeof body?.path === 'string' ? normalisePath(body.path) : null;
138
- if (!body || !path)
137
+ const raw = typeof body?.path === 'string' ? body.path : null;
138
+ const path = raw === null ? null : normalisePath(raw);
139
+ if (!body || raw === null || !path)
139
140
  return done;
140
141
  const key = site ? await site(request, path) : '';
141
142
  if (key === null)
142
143
  return done;
144
+ // The recorders normalise what they are given, so they get the path as sent:
145
+ // decoded twice, `/100%25-off` was dropped and `/what%3Fnow` cut to `/what`.
143
146
  defer(() => typeof body.name === 'string' && body.name
144
- ? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: path, headers, site: key })
145
- : recordView(sql, { headers, path, referrer: body.referrer, utm: body.utm, site: key }));
147
+ ? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: raw, headers, site: key })
148
+ : recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
146
149
  return done;
147
150
  }
148
151
  /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
@@ -82,7 +82,13 @@ export declare function PageRefsView({ refs, empty, link: Link, renderItem, }: {
82
82
  * through its HTML pipeline, and running it in the other would rewrite anchors
83
83
  * that were minted under different rules.
84
84
  */
85
- export declare function ReferencesView({ items, title, processHtml, }: {
85
+ export declare function ReferencesView({ id, items, title, processHtml, }: {
86
+ /**
87
+ * The block's id, which keys the heading's: `references-<id>`. Without it the
88
+ * heading is `references-heading`, which a second references block on the
89
+ * page would repeat, labelling both sections with the first one's heading.
90
+ */
91
+ id?: string;
86
92
  items?: readonly (ReferenceItem & {
87
93
  id?: string;
88
94
  })[];
@@ -89,11 +89,12 @@ export function PageRefsView({ refs, empty, link: Link = Anchor, renderItem, })
89
89
  * through its HTML pipeline, and running it in the other would rewrite anchors
90
90
  * that were minted under different rules.
91
91
  */
92
- export function ReferencesView({ items, title = 'References', processHtml = (html) => html, }) {
92
+ export function ReferencesView({ id, items, title = 'References', processHtml = (html) => html, }) {
93
93
  const rows = items ?? [];
94
94
  if (!rows.length)
95
95
  return null;
96
- return (_jsxs("section", { className: "references-block", "aria-labelledby": "references-heading", children: [_jsx("h2", { id: "references-heading", children: title }), _jsx("ol", { className: "references-list", children: rows.map((item, i) => {
96
+ const headingId = id ? `references-${id}` : 'references-heading';
97
+ return (_jsxs("section", { className: "references-block", "aria-labelledby": headingId, children: [_jsx("h2", { id: headingId, children: title }), _jsx("ol", { className: "references-list", children: rows.map((item, i) => {
97
98
  const url = safeLinkHref(item.url);
98
99
  return (_jsxs("li", { id: `ref-${i + 1}`, className: "reference-item", children: [_jsx("a", { href: `#cite-${i + 1}`, className: "ref-backlink", "aria-label": "Back to citation", children: "\u2191" }), ' ', _jsx("span", { dangerouslySetInnerHTML: { __html: processHtml(item.text) } }), url && (_jsxs(_Fragment, { children: [' ', _jsx("a", { href: url, target: "_blank", rel: "noopener", className: "reference-link", "aria-label": "Open source", children: "\u2197" })] }))] }, item.id ?? i));
99
100
  }) })] }));
package/dist/blocks.js CHANGED
@@ -164,7 +164,10 @@ export function codeTabsToMarkdown(tabs) {
164
164
  .map(t => {
165
165
  const longest = Math.max(0, ...(t.code.match(/`+/g) ?? []).map(run => run.length));
166
166
  const fence = '`'.repeat(Math.max(3, longest + 1));
167
- return `**${t.label}**\n\n${fence}${t.language || ''}\n${t.code.trim()}\n${fence}`;
167
+ // Blank lines go from either end, but not the first line's indentation:
168
+ // in YAML or Python it is part of the code.
169
+ const code = t.code.replace(/^\s*\n/, '').trimEnd();
170
+ return `**${t.label}**\n\n${fence}${t.language || ''}\n${code}\n${fence}`;
168
171
  })
169
172
  .join('\n\n');
170
173
  }
@@ -152,16 +152,6 @@ export declare const S10_DESCRIPTORS: readonly string[];
152
152
  * validator, and it is enough.
153
153
  */
154
154
  export declare function descriptorChecks(t: Tester, paths?: readonly string[]): Promise<void>;
155
- /**
156
- * Documents that project one corpus at different depths carry different ETags.
157
- *
158
- * Depths sharing a validator pass every conditional-GET check and still never
159
- * move when only one of them changes. Every one must also HAVE an ETag: a Set
160
- * of three nulls is size one and fails, but one null among two tags would pass
161
- * a size check alone — the copy of this check one repo carried had exactly
162
- * that hole.
163
- */
164
- export declare function distinctEtagChecks(t: Tester, paths: readonly string[], label?: string): Promise<void>;
165
155
  /**
166
156
  * Every tool carries behavioural hints, and the ones that write say so.
167
157
  *
@@ -367,19 +367,6 @@ export async function descriptorChecks(t, paths = S10_DESCRIPTORS) {
367
367
  t.check(`${label} states a freshness`, /max-age=\d+/.test(fresh.headers.get('cache-control') ?? ''), `cache-control: ${fresh.headers.get('cache-control')}`);
368
368
  }
369
369
  }
370
- /**
371
- * Documents that project one corpus at different depths carry different ETags.
372
- *
373
- * Depths sharing a validator pass every conditional-GET check and still never
374
- * move when only one of them changes. Every one must also HAVE an ETag: a Set
375
- * of three nulls is size one and fails, but one null among two tags would pass
376
- * a size check alone — the copy of this check one repo carried had exactly
377
- * that hole.
378
- */
379
- export async function distinctEtagChecks(t, paths, label = 'llms depths have distinct ETags') {
380
- const tags = await Promise.all(paths.map(p => fetch(`${t.base}/${p.replace(/^\//, '')}`).then(r => r.headers.get('etag'))));
381
- t.check(label, tags.every(Boolean) && new Set(tags).size === paths.length, tags.join(' '));
382
- }
383
370
  /**
384
371
  * Every tool carries behavioural hints, and the ones that write say so.
385
372
  *
@@ -452,7 +439,11 @@ export async function payloadBudget(t, calls, maxBytes = 120_000) {
452
439
  export async function robotsChecks(t, paths) {
453
440
  const body = await (await fetch(`${t.base}/robots.txt`)).text();
454
441
  const rules = [];
442
+ // A group is a run of User-agent lines and the rules after it, so `*` anywhere
443
+ // in the run takes the group's rules; the next User-agent after a rule opens
444
+ // a new group.
455
445
  let inStar = false;
446
+ let inRules = false;
456
447
  for (const raw of body.split('\n')) {
457
448
  const line = raw.replace(/#.*$/, '').trim();
458
449
  const [field, ...rest] = line.split(':');
@@ -460,10 +451,15 @@ export async function robotsChecks(t, paths) {
460
451
  continue;
461
452
  const key = field.trim().toLowerCase();
462
453
  const value = rest.join(':').trim();
463
- if (key === 'user-agent')
464
- inStar = value === '*';
465
- else if (inStar && (key === 'allow' || key === 'disallow') && value) {
466
- rules.push({ allow: key === 'allow', path: value });
454
+ if (key === 'user-agent') {
455
+ if (inRules)
456
+ [inStar, inRules] = [false, false];
457
+ inStar ||= value === '*';
458
+ }
459
+ else if (key !== 'sitemap') {
460
+ inRules = true;
461
+ if (inStar && (key === 'allow' || key === 'disallow') && value)
462
+ rules.push({ allow: key === 'allow', path: value });
467
463
  }
468
464
  }
469
465
  for (const path of paths) {
package/dist/corpus.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // corpus.ts — a wiki's whole corpus, sliced to something a tool result can hold.
2
2
  //
3
- // /llms-full.txt can afford to be 3 MB: a fetch streams to disk under an ETag.
3
+ // A file export can afford to be 3 MB: a fetch streams to disk under an ETag.
4
4
  // A tool result lands in a context window, so the MCP twin of that export needs
5
5
  // a preflight, a character budget and a page-aligned resume. Two wikis wrote
6
6
  // that loop and called the second a copy of the first "field for field". It was
@@ -12,6 +12,16 @@ export declare function daysSince(date: Date | string | null | undefined, now: n
12
12
  * edit is a weaker signal than a verification, but it is a signal.
13
13
  */
14
14
  export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?: number): boolean;
15
+ export interface FreshnessOptions {
16
+ /** Older than this, unverified, and the page is stale. */
17
+ maxAgeDays?: number;
18
+ /**
19
+ * What the notice asks a reader to re-check the facts against: `current
20
+ * sources` unless the wiki has more to name. The two Radix wikis add the live
21
+ * ledger, which a shop's wiki or a medical one has no use for.
22
+ */
23
+ against?: string;
24
+ }
15
25
  /**
16
26
  * A synthetic `outdated` banner block for a stale page, or null when it is
17
27
  * fresh — the shape every wiki's `banner` type already stores, so it drops
@@ -20,7 +30,7 @@ export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?:
20
30
  * during a client render would let a page near the boundary be stale on the
21
31
  * server and fresh in the browser.
22
32
  */
23
- export declare function freshnessBanner(page: FreshnessInput, nowMs: number, maxAgeDays?: number): {
33
+ export declare function freshnessBanner(page: FreshnessInput, nowMs: number, { maxAgeDays, against }?: FreshnessOptions): {
24
34
  id: string;
25
35
  type: 'banner';
26
36
  variant: 'outdated';
package/dist/freshness.js CHANGED
@@ -32,17 +32,17 @@ export function isStale(page, now, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
32
32
  return age === null || age > maxAgeDays;
33
33
  }
34
34
  /**
35
- * The sentence the notice carries. Both wikis shipped this string identically,
36
- * down to the ISO date and the closing request, so it lives here rather than
37
- * being re-typed either side of the extraction.
35
+ * The sentence the notice carries. Both Radix wikis shipped this string
36
+ * identically, down to the ISO date and the closing request; what to re-check
37
+ * against is the one part that differs between wikis.
38
38
  *
39
39
  * @internal
40
40
  */
41
- export function freshnessNotice(page) {
41
+ export function freshnessNotice(page, against = 'current sources') {
42
42
  const when = page.lastVerifiedAt
43
43
  ? `last verified ${isoDate(new Date(page.lastVerifiedAt))}`
44
44
  : 'not yet verified against sources';
45
- return `This page was ${when} and may be out of date. Please help re-check its facts against current sources and the live ledger.`;
45
+ return `This page was ${when} and may be out of date. Please help re-check its facts against ${against}.`;
46
46
  }
47
47
  /**
48
48
  * A synthetic `outdated` banner block for a stale page, or null when it is
@@ -52,10 +52,10 @@ export function freshnessNotice(page) {
52
52
  * during a client render would let a page near the boundary be stale on the
53
53
  * server and fresh in the browser.
54
54
  */
55
- export function freshnessBanner(page, nowMs, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
55
+ export function freshnessBanner(page, nowMs, { maxAgeDays = DEFAULT_MAX_AGE_DAYS, against } = {}) {
56
56
  if (!isStale(page, nowMs, maxAgeDays))
57
57
  return null;
58
- return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page) };
58
+ return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page, against) };
59
59
  }
60
60
  const toMs = (d) => (typeof d === 'number' ? d : d instanceof Date ? d.getTime() : Date.parse(d));
61
61
  /**
package/dist/http.js CHANGED
@@ -151,7 +151,15 @@ export function cacheTagHeaders(tags) {
151
151
  */
152
152
  export function wantsMarkdown(request) {
153
153
  return (new URL(request.url).searchParams.get('format') === 'text' ||
154
- /text\/(markdown|plain)/.test(request.headers.get('accept') ?? ''));
154
+ (request.headers.get('accept') ?? '').split(',').some(acceptsText));
155
+ }
156
+ /** One Accept media range naming markdown or plain text at a weight above zero: `q=0` means "not this". */
157
+ function acceptsText(range) {
158
+ const [type, ...params] = range.toLowerCase().split(';').map(s => s.replace(/\s/g, ''));
159
+ if (type !== 'text/markdown' && type !== 'text/plain')
160
+ return false;
161
+ const q = params.find(p => p.startsWith('q='));
162
+ return !q || Number(q.slice(2)) > 0;
155
163
  }
156
164
  /** The header every response from a content-negotiated URL carries. */
157
165
  export const VARY_ACCEPT = { Vary: 'Accept' };
@@ -215,7 +223,9 @@ export function cleanSnippet(text, max = 160) {
215
223
  export function pageLine(opts) {
216
224
  const excerpt = opts.excerpt ? `: ${cleanSnippet(opts.excerpt)}` : '';
217
225
  const stamp = opts.updated ? isoDate(opts.updated) : '';
218
- return `- [${opts.title}](${opts.url})${excerpt}${stamp ? ` _(updated ${stamp})_` : ''}`;
226
+ // A bracket in a title ("[RFC] Fees") would close the link text early.
227
+ const title = opts.title.replace(/[[\]\\]/g, '\\$&');
228
+ return `- [${title}](${opts.url})${excerpt}${stamp ? ` _(updated ${stamp})_` : ''}`;
219
229
  }
220
230
  /**
221
231
  * The validators for an aggregate: the ETag seeded from `seed` (the depth or
@@ -8,6 +8,12 @@ export interface Probe {
8
8
  note?: string;
9
9
  contentType?: string;
10
10
  bytes?: number;
11
+ /**
12
+ * The host refused a script rather than the page being gone: a bot challenge,
13
+ * a 429, or a 403/503 whose body is a challenge page. Report it as
14
+ * unverifiable, never as dead.
15
+ */
16
+ walled?: boolean;
11
17
  }
12
18
  export interface ProbeOptions {
13
19
  /** Ordinary per-attempt budget. */
@@ -106,23 +106,41 @@ export function perHost(url, fn) {
106
106
  hostQueues.set(host, next.catch(() => undefined));
107
107
  return next;
108
108
  }
109
+ /**
110
+ * A browser's request headers. A scholarly publisher or a standards body 403s
111
+ * Node's own user agent whatever the page's health; acuiq2's sweep, which
112
+ * checks PubMed and journal citations, sent these and saw a fraction of the
113
+ * false deaths.
114
+ */
115
+ const BROWSER = {
116
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36',
117
+ Accept: 'text/html,application/xhtml+xml,*/*;q=0.8',
118
+ 'Accept-Language': 'en',
119
+ };
120
+ const CHALLENGE = /recaptcha|cf-chl|challenge-platform|just a moment|captcha/i;
109
121
  async function attempt(url, budgetMs) {
110
122
  // One signal for both requests, so the budget covers the HEAD *and* the GET
111
123
  // rather than resetting between them. `engines.node` is >= 20, so this needs
112
124
  // no controller and no `clearTimeout` in a `finally` that could be forgotten.
113
125
  const signal = AbortSignal.timeout(budgetMs);
114
126
  try {
115
- let res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal });
127
+ let res = await fetch(url, { method: 'HEAD', redirect: 'follow', headers: BROWSER, signal });
116
128
  // Some hosts reject HEAD outright; retry with GET before believing a 4xx.
129
+ let body = '';
117
130
  if (res.status >= 400) {
118
- res = await fetch(url, { method: 'GET', redirect: 'follow', signal });
131
+ res = await fetch(url, { method: 'GET', redirect: 'follow', headers: BROWSER, signal });
132
+ if (res.status >= 400)
133
+ body = await res.text().catch(() => '');
119
134
  }
135
+ // A 429 is the host rationing us, which says nothing about the page.
136
+ const walled = res.headers.get('cf-mitigated') === 'challenge' || res.status === 429 || ([403, 503].includes(res.status) && CHALLENGE.test(body));
120
137
  return {
121
138
  url,
122
139
  status: res.status,
123
140
  ok: res.status < 400,
124
141
  contentType: (res.headers.get('content-type') || '').split(';')[0]?.trim(),
125
142
  bytes: Number(res.headers.get('content-length') || 0),
143
+ ...(walled && { walled }),
126
144
  };
127
145
  }
128
146
  catch (err) {
@@ -36,6 +36,8 @@ export interface LogState {
36
36
  backlog?: BacklogEntry[];
37
37
  feedback?: FeedbackEntry[];
38
38
  runHistory?: RunEntry[];
39
+ /** What `backlog-add` filed since the last `write`, by item: how a write tells a late item from one it closed. */
40
+ filed?: string[];
39
41
  [key: string]: unknown;
40
42
  }
41
43
  /** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
@@ -47,6 +49,15 @@ export declare function compact(state: LogState): LogState;
47
49
  * with it silently erases what the later run recorded.
48
50
  */
49
51
  export declare function staleWrite(stored: LogState, next: LogState): string | null;
52
+ /**
53
+ * The items another routine filed with `backlog-add` after `next` was read,
54
+ * which `next` therefore cannot carry: in the stored `filed` list and not in
55
+ * the one `next` was read with. A run's `write` replaces the whole state, so
56
+ * without this an item filed mid-run was lost. Merged rather than refused,
57
+ * because a run that has done its work should not be sent back over another
58
+ * routine's note.
59
+ */
60
+ export declare function lateBacklog(stored: LogState, next: LogState, today: string): BacklogEntry[];
50
61
  /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
51
62
  export declare function addBacklog(state: LogState, items: readonly BacklogEntry[], today: string): BacklogEntry[];
52
63
  /** The page's content blocks, re-rendered from state on every write. `routine` names who keeps it. */
@@ -67,6 +67,19 @@ export function staleWrite(stored, next) {
67
67
  ? `refusing a state with totalRuns ${has} over the stored ${had}: it predates a run that has already written. Read the log again and reapply this run's changes.`
68
68
  : null;
69
69
  }
70
+ /**
71
+ * The items another routine filed with `backlog-add` after `next` was read,
72
+ * which `next` therefore cannot carry: in the stored `filed` list and not in
73
+ * the one `next` was read with. A run's `write` replaces the whole state, so
74
+ * without this an item filed mid-run was lost. Merged rather than refused,
75
+ * because a run that has done its work should not be sent back over another
76
+ * routine's note.
77
+ */
78
+ export function lateBacklog(stored, next, today) {
79
+ const filed = new Set(stored.filed ?? []);
80
+ const seen = new Set(next.filed ?? []);
81
+ return addBacklog(next, (stored.backlog ?? []).filter(b => filed.has(itemOf(b)) && !seen.has(itemOf(b))), today);
82
+ }
70
83
  /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
71
84
  export function addBacklog(state, items, today) {
72
85
  const seen = new Set((state.backlog ?? []).map(itemOf));
@@ -94,9 +107,10 @@ export function render(state, routine, newId = () => crypto.randomUUID()) {
94
107
  const block = (text) => ({ id: newId(), type: 'content', text });
95
108
  const label = (x) => (typeof x === 'string' ? x : (x.handle ?? x.name ?? x.url ?? ''));
96
109
  const sourceRows = sources.slice(0, SHOWN.sources).map(([key, v]) => {
97
- const watch = (v.list ?? []).map(label).filter(Boolean);
110
+ // `v?.`: the state is a routine's own JSON, and `compact` keeps a null entry.
111
+ const watch = (v?.list ?? []).map(label).filter(Boolean);
98
112
  const watching = watch.length ? ` — watching: ${esc(watch.slice(0, 8).join(', '))}${watch.length > 8 ? '…' : ''}` : '';
99
- return `<tr><td>${esc(key)}</td><td>${esc(v.lastChecked || '–')}</td><td>${esc(v.note)}${watching}</td></tr>`;
113
+ return `<tr><td>${esc(key)}</td><td>${esc(v?.lastChecked || '–')}</td><td>${esc(v?.note)}${watching}</td></tr>`;
100
114
  });
101
115
  const runRows = (state.runHistory ?? []).slice(-SHOWN.runs).reverse().map(r => {
102
116
  const tweet = r.tweet && /^https?:\/\//.test(r.tweet) ? `<a href="${esc(r.tweet)}">link</a>` : esc(r.tweet);
package/dist/markdown.js CHANGED
@@ -45,7 +45,7 @@ const NAMED_ENTITIES = {
45
45
  export function decodeEntities(s) {
46
46
  return s.replace(/&(?:#x([0-9a-fA-F]{1,6})|#(\d{1,7})|([a-zA-Z][a-zA-Z0-9]{1,9}));/g, (m, hex, dec, name) => {
47
47
  if (name)
48
- return NAMED_ENTITIES[name] ?? m;
48
+ return Object.hasOwn(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : m;
49
49
  const code = hex ? parseInt(hex, 16) : Number(dec);
50
50
  return code <= 0x10ffff ? String.fromCodePoint(code) : m;
51
51
  });
@@ -146,15 +146,17 @@ export function htmlToMarkdown(html) {
146
146
  }
147
147
  /** YAML frontmatter block, `---` fences included. */
148
148
  export function frontmatter(fields) {
149
- const esc = (s) => s.replace(/"/g, '\\"');
149
+ // A YAML double-quoted scalar: a backslash starts an escape (`\b` is a
150
+ // backspace) and a raw newline ends the line, so both are escaped with the quote.
151
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n');
150
152
  return [
151
153
  '---',
152
154
  `title: "${esc(fields.title)}"`,
153
- `url: "${fields.url}"`,
155
+ `url: "${esc(fields.url)}"`,
154
156
  ...(fields.updated ? [`updated: ${isoDate(fields.updated)}`] : []),
155
157
  ...(fields.lastVerified ? [`last_verified: ${isoDate(fields.lastVerified)}`] : []),
156
158
  ...(fields.license
157
- ? [`license: ${fields.license.spdx}`, `license_url: "${fields.license.url}"`]
159
+ ? [`license: ${fields.license.spdx}`, `license_url: "${esc(fields.license.url)}"`]
158
160
  : []),
159
161
  ...Object.entries(fields.extra ?? {})
160
162
  .filter(([, v]) => v !== undefined && v !== '')