wiki-formant 0.84.0 → 0.85.1

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
 
@@ -1185,7 +1185,7 @@ npx fts-ddl pages
1185
1185
  npx fts-ddl '"WikiPage"'
1186
1186
  ```
1187
1187
 
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 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`:
1189
1189
 
1190
1190
  ```sh
1191
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
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
@@ -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 !== '')
package/dist/mcp.js CHANGED
@@ -203,7 +203,7 @@ function validateArgs(tool, args) {
203
203
  const allowed = Object.keys(properties);
204
204
  const problems = [];
205
205
  for (const key of Object.keys(args)) {
206
- if (key in properties)
206
+ if (Object.hasOwn(properties, key))
207
207
  continue;
208
208
  problems.push(allowed.length
209
209
  ? `Unknown parameter "${key}". Allowed parameters: ${quote(allowed)}.`
@@ -309,6 +309,10 @@ async function handleRpc(req, config, dispatch) {
309
309
  const foreign = ERA_METHODS[dispatch.era === 'modern' ? 'legacy' : 'modern'];
310
310
  if (foreign.includes(method))
311
311
  return methodNotFound(id, method, config, dispatch.era);
312
+ // A notification has no response at all, a method-not-found included:
313
+ // `notifications/progress` or `roots/list_changed` is not an error to answer.
314
+ if (method.startsWith('notifications/'))
315
+ return null;
312
316
  try {
313
317
  switch (method) {
314
318
  case 'initialize':
@@ -326,10 +330,6 @@ async function handleRpc(req, config, dispatch) {
326
330
  capabilities: capabilitiesOf(config),
327
331
  instructions: config.instructions,
328
332
  });
329
- // Notifications have no response at all.
330
- case 'notifications/initialized':
331
- case 'notifications/cancelled':
332
- return null;
333
333
  case 'ping':
334
334
  return ok(id, {});
335
335
  case 'tools/list':
@@ -368,7 +368,9 @@ async function handleRpc(req, config, dispatch) {
368
368
  });
369
369
  }
370
370
  case 'tools/call': {
371
- const { name, arguments: rawArgs = {} } = p;
371
+ // `null` is how some clients spell "no arguments"; it means the same as omitting them.
372
+ const { name, arguments: rawArgs } = p;
373
+ const given = rawArgs ?? {};
372
374
  const names = config.tools.map(t => t.name);
373
375
  const tool = name ? config.tools.find(t => t.name === name) : undefined;
374
376
  if (!tool) {
@@ -377,10 +379,10 @@ async function handleRpc(req, config, dispatch) {
377
379
  availableTools: names,
378
380
  });
379
381
  }
380
- if (typeOf(rawArgs) !== 'object') {
381
- return toolText(id, `"arguments" must be an object; received ${typeOf(rawArgs)}. Expected schema: ${JSON.stringify(tool.inputSchema)}`, true);
382
+ if (typeOf(given) !== 'object') {
383
+ return toolText(id, `"arguments" must be an object; received ${typeOf(given)}. Expected schema: ${JSON.stringify(tool.inputSchema)}`, true);
382
384
  }
383
- const args = rawArgs;
385
+ const args = given;
384
386
  const invalid = validateArgs(tool, args);
385
387
  if (invalid)
386
388
  return toolText(id, invalid, true);
@@ -395,7 +397,9 @@ async function handleRpc(req, config, dispatch) {
395
397
  };
396
398
  try {
397
399
  const data = await tool.handler(args, ctx);
398
- const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
400
+ // `?? null`: a handler that returns nothing still owes a string, and
401
+ // JSON.stringify(undefined) is undefined.
402
+ const text = typeof data === 'string' ? data : JSON.stringify(data ?? null, null, 2);
399
403
  // Every object answer, not only the schema-bearing ones. Gated on
400
404
  // `outputSchema`, a JSON-returning tool without one would carry no
401
405
  // `structuredContent`, and agents would parse prose to reach data the
@@ -526,7 +530,12 @@ export async function mcpResponse(request, config) {
526
530
  // Tracked before either era refuses anything: a probe the server turns away,
527
531
  // and a call an agent walked away from behind a gate, are exactly the ones
528
532
  // worth counting.
529
- config.onCall?.(request, body);
533
+ try {
534
+ config.onCall?.(request, body);
535
+ }
536
+ catch {
537
+ // Analytics never blocks the response, a throwing hook included.
538
+ }
530
539
  return isModern(request, body)
531
540
  ? modernResponse(request, body, config, base)
532
541
  : legacyResponse(request, body, config, base);
package/dist/queues.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // had none. What differs is how a page is addressed and which facts its
14
14
  // category requires, so those are the arguments.
15
15
  import { daysSince, isStale, DEFAULT_MAX_AGE_DAYS } from './freshness.js';
16
+ import { metaValue } from './taxonomy.js';
16
17
  // An href inside stored JSON, its quotes escaped or not.
17
18
  const HREF = /href=\\?"([^"\\?#]+)/g;
18
19
  /** A `references` block keeps each citation in a `url` field, not an href. */
@@ -31,12 +32,17 @@ export function maintenanceQueues(pages, o) {
31
32
  const sources = new Map();
32
33
  for (const page of pages) {
33
34
  const text = JSON.stringify(page.content ?? '');
35
+ // A page's link to itself is not a way in, as a list naming itself is not.
36
+ const self = o.href(page);
34
37
  let outbound = 0;
35
38
  for (const [, target] of text.matchAll(HREF)) {
36
39
  if (!target)
37
40
  continue;
38
- if (target.startsWith('/'))
39
- linkedPaths.add(target.replace(/\/+$/, '') || '/');
41
+ if (target.startsWith('/')) {
42
+ const path = target.replace(/\/+$/, '') || '/';
43
+ if (path !== self)
44
+ linkedPaths.add(path);
45
+ }
40
46
  else if (/^https?:/i.test(target))
41
47
  outbound++;
42
48
  }
@@ -46,10 +52,10 @@ export function maintenanceQueues(pages, o) {
46
52
  linkedIds.add(id);
47
53
  }
48
54
  const item = (p, detail) => ({ title: p.title, href: o.href(p), tagPath: p.tagPath, detail });
49
- const facts = (p) => (p.metadata && typeof p.metadata === 'object' ? p.metadata : {});
55
+ // Missing is what the facts panel would not show, so a number counts as given.
50
56
  const incomplete = pages.flatMap(p => {
51
57
  const missing = (o.required?.(p.tagPath) ?? [])
52
- .filter(k => { const v = facts(p)[k.key]; return typeof v !== 'string' || !v.trim(); })
58
+ .filter(k => !metaValue(p, k.key))
53
59
  .map(k => k.label.replace(/:$/, ''));
54
60
  return missing.length ? [item(p, `missing ${missing.join(', ')}`)] : [];
55
61
  });
@@ -200,7 +200,14 @@ export function SnapDots({ count, at, onGo, noun = 'item', className = '' }) {
200
200
  export function InfoboxAside({ label, series, link: Link = Anchor, className, children }) {
201
201
  return (_jsxs("aside", { className: className ? `infobox ${className}` : 'infobox', "aria-label": label, children: [series && (_jsxs("div", { className: "infobox-series", children: [_jsx("span", { children: "Part of a series on" }), _jsx(Link, { href: series.href, children: series.title })] })), children] }));
202
202
  }
203
- const looksLikeUrl = (v, type) => type === 'url' || /^https?:\/\//i.test(v) || /^[^\s/]+\.[a-z]{2,}(\/\S*)?$/i.test(v);
203
+ /** A file or library suffix no domain ends in: "Node.js", "Cargo.toml" and "setup.py" name things, not sites. */
204
+ const NOT_A_TLD = /^(c?js|mjs|jsx|tsx?|json|ya?ml|toml|md|py|sol|wasm|txt|html?|css|pdf|png|jpe?g|svg|gif)$/i;
205
+ const looksLikeUrl = (v, type) => {
206
+ if (type === 'url' || /^https?:\/\//i.test(v))
207
+ return true;
208
+ const bare = /^[^\s/]+\.([a-z]{2,})(\/\S*)?$/i.exec(v);
209
+ return !!bare && !NOT_A_TLD.test(bare[1]);
210
+ };
204
211
  function externalLink(v) {
205
212
  const href = safeLinkHref(/^[a-z][a-z0-9+.-]*:/i.test(v) ? v : `https://${v}`);
206
213
  if (!href)
package/dist/react.js CHANGED
@@ -183,11 +183,23 @@ export function TableOfContents({ containerSelector, headings: providedHeadings,
183
183
  setScanned(prev => (sameHeadings(prev, next) ? prev : next));
184
184
  }, 200);
185
185
  };
186
+ // A container missing at mount is waited for from the body, so an article
187
+ // that streams in later is still read; once it exists only it is watched.
188
+ let watched;
189
+ const observer = new MutationObserver(() => {
190
+ follow();
191
+ update();
192
+ });
193
+ const follow = () => {
194
+ const root = document.querySelector(containerSelector);
195
+ if (root === watched)
196
+ return;
197
+ watched = root;
198
+ observer.disconnect();
199
+ observer.observe(root ?? document.body, { childList: true, subtree: true });
200
+ };
201
+ follow();
186
202
  update();
187
- const root = document.querySelector(containerSelector);
188
- const observer = new MutationObserver(update);
189
- if (root)
190
- observer.observe(root, { childList: true, subtree: true });
191
203
  return () => {
192
204
  clearTimeout(timer);
193
205
  observer.disconnect();
@@ -344,7 +356,13 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
344
356
  });
345
357
  return () => controller.abort();
346
358
  }, [debounced, fetch, minLength, cache]);
347
- const clearItems = useCallback(() => setItems([]), []);
359
+ // Dismissing the list also drops the search in flight, or its answer would
360
+ // bring back the list the reader just closed.
361
+ const clearItems = useCallback(() => {
362
+ requestId.current++;
363
+ setItems([]);
364
+ setIsSearching(false);
365
+ }, []);
348
366
  const reset = useCallback(() => {
349
367
  requestId.current++;
350
368
  setQuery('');
@@ -355,7 +373,7 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
355
373
  }, []);
356
374
  const onKeyDown = (e) => {
357
375
  if (e.key === 'Escape') {
358
- setItems([]);
376
+ clearItems();
359
377
  onEscape?.();
360
378
  return;
361
379
  }
@@ -466,8 +484,11 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
466
484
  }, showDelay);
467
485
  };
468
486
  const onOut = (e) => {
469
- // Moving into the card itself is not leaving.
470
- if (e.relatedTarget?.closest(`.${cardClassName}`))
487
+ // Moving within the link (onto a <code> or <em> in it), or into the card
488
+ // itself, is not leaving. Leaving cancels the pending show, and re-entering
489
+ // the same link does not start it again.
490
+ const to = e.relatedTarget;
491
+ if (to && (e.target?.closest('a')?.contains(to) || to.closest(`.${cardClassName}`)))
471
492
  return;
472
493
  clearTimers();
473
494
  hide();
@@ -29,13 +29,11 @@ export interface BlockChange<L = unknown> {
29
29
  /** Whatever `leafDiff` returned. Absent when no `leafDiff` was supplied. */
30
30
  leafDiff?: L;
31
31
  }
32
- export interface RevisionDiff<L = unknown> {
32
+ export interface RevisionDiff {
33
33
  version: SemVer;
34
34
  changeType: ChangeType;
35
- changes: BlockChange<L>[];
35
+ changes: BlockChange[];
36
36
  titleChanged: boolean;
37
- /** A page-level flag outside the block tree — a banner, a notice, a status. */
38
- metaChanged: boolean;
39
37
  summary: string;
40
38
  }
41
39
  export interface DiffOptions<B, L> {
@@ -72,21 +70,13 @@ export declare function diffBlocks<B extends DiffBlock, L = unknown>(oldBlocks:
72
70
  export declare function changeSummary(diff: {
73
71
  changes: readonly BlockChange<unknown>[];
74
72
  titleChanged: boolean;
75
- metaChanged?: boolean;
76
- metaLabel?: string;
77
73
  }): string;
78
- export declare function computeRevisionDiff<B extends DiffBlock, L = unknown>(opts: {
74
+ export declare function computeRevisionDiff<B extends DiffBlock>(opts: {
79
75
  currentVersion: string | null | undefined;
80
76
  oldContent: readonly B[];
81
77
  newContent: readonly B[];
82
78
  oldTitle: string;
83
79
  newTitle: string;
84
- /** A page-level value outside the block tree; compared by strict equality. */
85
- oldMeta?: unknown;
86
- newMeta?: unknown;
87
- /** What to call that value in the summary. Defaults to `banner`. */
88
- metaLabel?: string;
89
- containers?: DiffOptions<B, L>['containers'];
90
- leafDiff?: DiffOptions<B, L>['leafDiff'];
91
- }): RevisionDiff<L>;
80
+ containers?: DiffOptions<B, unknown>['containers'];
81
+ }): RevisionDiff;
92
82
  export {};
package/dist/revisions.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // revisions.ts — what changed between two versions of a page.
2
2
  //
3
3
  // The semver lives next door in `versioning.ts`; this is the walk that decides
4
- // which bump to ask for. It carries a leaf-level diff, a changed-flag for the
5
- // page's banner, and a `patch` classification for when only that flag moved.
4
+ // which bump to ask for, and carries a leaf-level diff for a history view.
6
5
  // Matching is by id.
7
6
  import { incrementVersion, parseVersion } from './versioning.js';
8
7
  import { coreBlockGroups } from './blocks.js';
@@ -34,8 +33,14 @@ export function extractBlocks(blocks, containers, basePath = 'root') {
34
33
  });
35
34
  return out;
36
35
  }
36
+ /** A value with every object's keys in one order: jsonb hands a stored block's keys back in its own. */
37
+ const canonical = (v) => Array.isArray(v)
38
+ ? v.map(canonical)
39
+ : v && typeof v === 'object'
40
+ ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])]))
41
+ : v;
37
42
  /**
38
- * Which scalar fields differ. Nested block arrays are skipped: they are walked
43
+ * Which scalar fields differ, whatever order their keys are in. Nested block arrays are skipped: they are walked
39
44
  * as their own entries, and comparing them here would report a container as
40
45
  * modified every time anything inside it moved.
41
46
  */
@@ -58,7 +63,7 @@ function diffAttributes(oldBlock, newBlock, containers) {
58
63
  continue;
59
64
  const from = oldBlock[key];
60
65
  const to = newBlock[key];
61
- if (JSON.stringify(from) !== JSON.stringify(to))
66
+ if (JSON.stringify(canonical(from)) !== JSON.stringify(canonical(to)))
62
67
  diffs[key] = { from, to };
63
68
  }
64
69
  return Object.keys(diffs).length > 0 ? diffs : undefined;
@@ -128,29 +133,23 @@ export function diffBlocks(oldBlocks, newBlocks, opts) {
128
133
  return changes;
129
134
  }
130
135
  /**
131
- * `major` for structure, `minor` for prose, `patch` for a page-level flag.
136
+ * `major` for structure, `minor` for prose and the title.
132
137
  *
133
138
  * A `modified` change is only ever recorded when `diffAttributes` returned
134
139
  * something, so a non-empty change set is always at least a minor bump.
135
140
  *
136
141
  * @internal
137
142
  */
138
- export function classifyChanges(changes, titleChanged, metaChanged = false) {
139
- if (changes.length === 0 && !titleChanged && !metaChanged)
140
- return 'none';
143
+ export function classifyChanges(changes, titleChanged) {
141
144
  if (changes.some(c => c.action === 'added' || c.action === 'removed' || c.action === 'moved')) {
142
145
  return 'major';
143
146
  }
144
- if (changes.length > 0 || titleChanged)
145
- return 'minor';
146
- return 'patch';
147
+ return changes.length > 0 || titleChanged ? 'minor' : 'none';
147
148
  }
148
149
  export function changeSummary(diff) {
149
150
  const parts = [];
150
151
  if (diff.titleChanged)
151
152
  parts.push('title updated');
152
- if (diff.metaChanged)
153
- parts.push(`${diff.metaLabel ?? 'banner'} updated`);
154
153
  const count = (action) => diff.changes.filter(c => c.action === action).length;
155
154
  const plural = (n, word) => `${n} ${word}${n > 1 ? 's' : ''}`;
156
155
  for (const [action, verb] of [
@@ -166,19 +165,14 @@ export function changeSummary(diff) {
166
165
  return parts.length > 0 ? parts.join(', ') : 'no changes';
167
166
  }
168
167
  export function computeRevisionDiff(opts) {
169
- const changes = diffBlocks(opts.oldContent, opts.newContent, {
170
- containers: opts.containers,
171
- leafDiff: opts.leafDiff,
172
- });
168
+ const changes = diffBlocks(opts.oldContent, opts.newContent, { containers: opts.containers });
173
169
  const titleChanged = opts.oldTitle !== opts.newTitle;
174
- const metaChanged = opts.oldMeta !== opts.newMeta;
175
- const changeType = classifyChanges(changes, titleChanged, metaChanged);
170
+ const changeType = classifyChanges(changes, titleChanged);
176
171
  const partial = {
177
172
  version: incrementVersion(parseVersion(opts.currentVersion), changeType),
178
173
  changeType,
179
174
  changes,
180
175
  titleChanged,
181
- metaChanged,
182
176
  };
183
- return { ...partial, summary: changeSummary({ ...partial, metaLabel: opts.metaLabel }) };
177
+ return { ...partial, summary: changeSummary(partial) };
184
178
  }
@@ -11,7 +11,7 @@ import { count } from './analytics.js';
11
11
  import { TimeChart, stepDecimals } from './chart.js';
12
12
  // Whole seconds and percentages, unless an axis tick's step needs more to tell it from the next.
13
13
  const places = (step) => (step ? stepDecimals(step) : 0);
14
- const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
14
+ const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(Math.round(s) / 60)}m ${Math.round(s) % 60}s`);
15
15
  const percent = (n, step) => `${n.toFixed(places(step))}%`;
16
16
  /**
17
17
  * The change from the window before: a share of the figure before, or for a
package/dist/store.js CHANGED
@@ -35,6 +35,7 @@ export function toListing(row) {
35
35
  return { ...rest, snippet: describe(openingText(content)) };
36
36
  }
37
37
  const conflict = (message) => new HttpError(409, message);
38
+ const SLUG_TAKEN = 'A page with that slug already exists in this category';
38
39
  /** Re-order rows to match an id list, dropping ids that no longer resolve. */
39
40
  export function orderByIds(rows, ids) {
40
41
  const byId = new Map(rows.map(row => [row.id, row]));
@@ -153,15 +154,17 @@ export function wikiStore(o) {
153
154
  AND (${prose} ILIKE $3 OR (q.tsq IS NOT NULL AND p.search_tsv @@ q.tsq))))
154
155
  ),
155
156
  paged AS (
156
- SELECT *, count(*) OVER ()::int AS total FROM matched
157
+ SELECT * FROM matched
157
158
  ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC
158
159
  LIMIT $7::int OFFSET $8::int
159
160
  )
161
+ -- The count is joined to the page rather than windowed over it, so a
162
+ -- page past the end still carries the total, on one row of nulls.
160
163
  SELECT ${COLUMNS}, tier, total,
161
164
  CASE WHEN tier = 3 THEN ts_headline('english', ${proseSql()}, tsq, '${HEADLINE_OPTIONS}') END AS headline
162
- FROM paged
165
+ FROM (SELECT count(*)::int AS total FROM matched) t LEFT JOIN paged ON true
163
166
  ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query.trim(), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
164
- const items = found.map(({ tier, total: _, headline, content, ...row }) => ({
167
+ const items = found.filter(hit => hit.id !== null).map(({ tier, total: _, headline, content, ...row }) => ({
165
168
  ...row,
166
169
  ...(withContent ? { content: content } : {}),
167
170
  score: tier,
@@ -170,17 +173,19 @@ export function wikiStore(o) {
170
173
  }));
171
174
  return { items, total: found[0]?.total ?? 0 };
172
175
  };
176
+ /** A revision query's guard: `$1`, its page, is this site's. Another site's page id finds nothing. */
177
+ const OWN_PAGE = `"pageId" IN (SELECT id FROM ${PAGE} WHERE id = $1 AND "siteId" = $2)`;
173
178
  /**
174
179
  * A page's revisions, newest first, each with what changed against the one
175
180
  * before it. `limit` counts what is returned; one more is read so the oldest
176
181
  * shown is diffed against its real predecessor, not against nothing.
177
182
  */
178
183
  const history = async (pageId, { limit = 50, offset = 0 } = {}) => {
179
- const revisions = await rows(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 ORDER BY "createdAt" DESC, id DESC LIMIT $2::int OFFSET $3::int`, pageId, limit + 1, offset);
184
+ const revisions = await rows(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND ${OWN_PAGE} ORDER BY "createdAt" DESC, id DESC LIMIT $3::int OFFSET $4::int`, pageId, site, limit + 1, offset);
180
185
  const entries = withChanges(revisions, { containers: o.containers });
181
186
  return revisions.length > limit ? entries.slice(0, limit) : entries;
182
187
  };
183
- const revision = (pageId, revisionId) => one(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND id = $2`, pageId, revisionId);
188
+ const revision = (pageId, revisionId) => one(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND ${OWN_PAGE} AND id = $3`, pageId, site, revisionId);
184
189
  const create = async (input) => {
185
190
  if (input.title === undefined || input.content === undefined)
186
191
  throw new HttpError(400, 'A new page needs a title and content');
@@ -206,14 +211,18 @@ export function wikiStore(o) {
206
211
  */
207
212
  const save = async (input) => {
208
213
  const existing = await get(input.tagPath, input.slug);
214
+ if (!existing && input.baseVersion !== undefined) {
215
+ throw conflict(`This page was deleted since it was opened at v${input.baseVersion}. Saving now would create it again; reload to decide.`);
216
+ }
209
217
  if (!existing)
210
218
  return create(input);
211
219
  if (input.baseVersion !== undefined && input.baseVersion !== existing.version) {
212
220
  throw conflict(`This page changed since it was opened: it is at v${existing.version} now, not v${input.baseVersion}. Reload it and apply the edit again.`);
213
221
  }
214
222
  const slug = input.newSlug && input.newSlug !== existing.slug ? input.newSlug : existing.slug;
215
- if (slug !== existing.slug && (await get(existing.tagPath, slug)))
216
- throw conflict('A page with that slug already exists in this category');
223
+ const slugTaken = async () => slug !== existing.slug && (await get(existing.tagPath, slug)) !== null;
224
+ if (await slugTaken())
225
+ throw conflict(SLUG_TAKEN);
217
226
  const title = input.title ?? existing.title;
218
227
  const content = input.content ?? existing.content;
219
228
  const diff = computeRevisionDiff({
@@ -236,7 +245,13 @@ export function wikiStore(o) {
236
245
  INSERT INTO ${REVISION} ("pageId", title, content, version, "changeType", message, "authorId")
237
246
  SELECT id, title, content, version, $9::text, $10::text, $11::text FROM up WHERE $9::text <> 'none'
238
247
  )
239
- SELECT * FROM up`, existing.id, existing.version, title, slug, JSON.stringify(content), input.metadata === undefined ? null : JSON.stringify(input.metadata), version, input.verified === true, changeType, input.message ?? diff.summary, input.authorId ?? null);
248
+ SELECT * FROM up`, existing.id, existing.version, title, slug, JSON.stringify(content), input.metadata === undefined ? null : JSON.stringify(input.metadata), version, input.verified === true, changeType, input.message ?? diff.summary, input.authorId ?? null).catch(async (err) => {
249
+ // A page that took the slug after the check above fails the unique index.
250
+ // Asked again rather than read off the error, whose shape is the driver's.
251
+ if (await slugTaken())
252
+ throw conflict(SLUG_TAKEN);
253
+ throw err;
254
+ });
240
255
  if (!page)
241
256
  throw conflict('This page was saved by someone else while this edit was being written. Reload it and apply the edit again.');
242
257
  return { page, created: false, changeType, summary: diff.summary };
package/dist/taxonomy.js CHANGED
@@ -38,7 +38,8 @@ export function hrefBuilder(prefix = '') {
38
38
  export const defaultHref = hrefBuilder();
39
39
  const DEFAULT_ALPHA_INDEX_MIN_PAGES = 40;
40
40
  /** A key's value as text. Metadata is untyped JSON, and a number or a list stored under a key must not throw. */
41
- const metaValue = (page, key) => {
41
+ /** A metadata value as the facts panel shows it: trimmed text or a number, else ''. @internal */
42
+ export const metaValue = (page, key) => {
42
43
  const value = page.metadata?.[key];
43
44
  return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
44
45
  };
package/dist/text.js CHANGED
@@ -19,7 +19,7 @@ export function stripHtml(html) {
19
19
  .replace(/<a[^>]+href="([^"]*)"[^>]*>(.*?)<\/a>/gi, ' $2 ($1) ')
20
20
  .replace(/<br\s*\/?>/gi, '\n')
21
21
  .replace(/<\/(?:p|h[1-6]|li|tr|th|td|div)>/gi, '\n')
22
- .replace(/<(?:li)>/gi, '- ')
22
+ .replace(/<li\b[^>]*>/gi, '- ')
23
23
  .replace(/<[^>]+>/g, ''))
24
24
  .replace(/\n{3,}/g, '\n\n')
25
25
  .trim();
package/dist/tiptap.d.ts CHANGED
@@ -82,6 +82,11 @@ export interface TabsOptions {
82
82
  /** Names a newly added tab. Receives the 1-based position. */
83
83
  tabLabel?: (index: number) => string;
84
84
  }
85
+ /**
86
+ * A tab group and its items, which must be registered together — `tabGroup`'s
87
+ * content expression is `tabItem+`, so registering one without the other leaves
88
+ * a node type the schema cannot satisfy.
89
+ */
85
90
  export declare function createTabs({ classNames: own, icons: ownIcons, tabLabel, }?: TabsOptions): {
86
91
  TabGroup: TiptapNode<any, any>;
87
92
  TabItem: TiptapNode<any, any>;
package/dist/tiptap.js CHANGED
@@ -200,7 +200,8 @@ export function createMapEmbed({ resolveMapUrl }) {
200
200
  .insertContent({ type: 'mapEmbed', attrs: { src: 'about:blank', url } })
201
201
  .run();
202
202
  resolveMapUrl(url).then(resolved => {
203
- if (!resolved)
203
+ // The editor may have closed while the redirect was followed.
204
+ if (!resolved || ext.editor.isDestroyed)
204
205
  return;
205
206
  // Find the placeholder by its url rather than by a captured
206
207
  // position: the document has been editable throughout the hop.
@@ -282,11 +283,6 @@ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG
282
283
  },
283
284
  });
284
285
  }
285
- /**
286
- * A tab group and its items, which must be registered together — `tabGroup`'s
287
- * content expression is `tabItem+`, so registering one without the other leaves
288
- * a node type the schema cannot satisfy.
289
- */
290
286
  /** The classes `base.css` draws the tab editor with, each replaceable. */
291
287
  const TABS_CLASSES = {
292
288
  editor: 'tabs-editor',
@@ -298,6 +294,11 @@ const TABS_CLASSES = {
298
294
  add: 'tab-add',
299
295
  content: 'tabs-content',
300
296
  };
297
+ /**
298
+ * A tab group and its items, which must be registered together — `tabGroup`'s
299
+ * content expression is `tabItem+`, so registering one without the other leaves
300
+ * a node type the schema cannot satisfy.
301
+ */
301
302
  export function createTabs({ classNames: own = {}, icons: ownIcons = {}, tabLabel = i => `Tab ${i}`, } = {}) {
302
303
  const classNames = { ...TABS_CLASSES, ...own };
303
304
  const icons = { add: '+', remove: '×', ...ownIcons };
@@ -328,8 +329,11 @@ export function createTabs({ classNames: own = {}, icons: ownIcons = {}, tabLabe
328
329
  return;
329
330
  const pos = posOf(index);
330
331
  editor.chain().focus().deleteRange({ from: pos, to: pos + tabs[index].nodeSize }).run();
331
- if (activeTab >= tabs.length - 1)
332
- setActiveTab(Math.max(0, tabs.length - 2));
332
+ // The open tab stays open: removing one before it moves it down a place.
333
+ const at = Math.min(activeTab, tabs.length - 1);
334
+ const next = Math.max(0, index < at ? at - 1 : Math.min(at, tabs.length - 2));
335
+ if (next !== activeTab)
336
+ setActiveTab(next);
333
337
  };
334
338
  const renameTab = (index, title) => {
335
339
  const { tr } = editor.state;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * An href acceptable to persist: http, https, mailto, or a relative path or
2
+ * An href acceptable to persist: http, https, mailto, tel, or a relative path or
3
3
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
4
4
  * protocol-relative `//host` — comes back null.
5
5
  *
@@ -10,7 +10,7 @@
10
10
  // block type is a compile error there until it is handled.
11
11
  import { LIST_SOURCES } from './blocks.js';
12
12
  /**
13
- * An href acceptable to persist: http, https, mailto, or a relative path or
13
+ * An href acceptable to persist: http, https, mailto, tel, or a relative path or
14
14
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
15
15
  * protocol-relative `//host` — comes back null.
16
16
  *
@@ -23,16 +23,20 @@ export function safeLinkHref(raw) {
23
23
  if (!raw)
24
24
  return null;
25
25
  // Strip C0 controls (including tab, newline and CR) and DEL before reading
26
- // the scheme, so a newline cannot smuggle one past the match below.
27
- const href = raw.replace(/[\u0000-\u0020\u007f]/g, '').trim();
26
+ // the scheme, so a newline cannot smuggle one past the match below. A space
27
+ // is not one: inside a URL it is the author's (`/My Photo.jpg`), and at
28
+ // either end `trim` takes it.
29
+ const href = raw.replace(/[\u0000-\u001f\u007f]/g, '').trim();
28
30
  if (!href)
29
31
  return null;
30
- if (href.startsWith('//'))
31
- return null; // protocol-relative -> external host
32
+ // Protocol-relative -> external host. A browser reads `\` as `/` here, so
33
+ // `/\evil.com` is the same link.
34
+ if (/^[\\/]{2}/.test(href))
35
+ return null;
32
36
  const scheme = href.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
33
37
  if (scheme) {
34
38
  const s = scheme[1].toLowerCase();
35
- if (s !== 'http' && s !== 'https' && s !== 'mailto')
39
+ if (s !== 'http' && s !== 'https' && s !== 'mailto' && s !== 'tel')
36
40
  return null;
37
41
  }
38
42
  return href;
@@ -124,6 +124,8 @@ export interface McpManifestConfig {
124
124
  url: string;
125
125
  /** Who runs it. Defaults to `name`. */
126
126
  provider?: string;
127
+ /** The MCP endpoint's absolute URL. Defaults to `${url}/api/mcp`. */
128
+ mcpEndpoint?: string;
127
129
  tools: readonly ManifestTool[];
128
130
  /** Auth notes, per-tenant endpoints, payment terms — what this origin has that others do not. */
129
131
  extra?: Record<string, unknown>;
@@ -186,7 +186,7 @@ export function registryAuthHandler(publicKey = process.env.MCP_REGISTRY_PUBLIC_
186
186
  * so the manifest cannot advertise a version the server does not speak.
187
187
  */
188
188
  export function mcpManifest(config) {
189
- const { name, registryName, version, description, url, provider, tools, extra } = config;
189
+ const { name, registryName, version, description, url, provider, mcpEndpoint, tools, extra } = config;
190
190
  return {
191
191
  schema_version: '1.0',
192
192
  name,
@@ -197,7 +197,7 @@ export function mcpManifest(config) {
197
197
  provider: { name: provider ?? name, url },
198
198
  api: { type: 'openapi', url: `${url}/.well-known/openapi.json` },
199
199
  mcp: {
200
- endpoint: `${url}/api/mcp`,
200
+ endpoint: mcpEndpoint ?? `${url}/api/mcp`,
201
201
  transport: 'streamable-http',
202
202
  protocol: 'JSON-RPC 2.0',
203
203
  protocolVersion: MCP_PROTOCOL_VERSION,
@@ -34,7 +34,7 @@ export interface WikiToolsOptions<B extends DiffBlock> {
34
34
  metadata?: (tagPath: string, metadata: PageMetadata) => Record<string, unknown> | null;
35
35
  /** A category's name as the tree lists it. Defaults to the node's `name`. */
36
36
  label?: (node: WikiNode) => string;
37
- /** The corpus as sections, when the app's llms-full.txt sets their form. Defaults to one per page from the store. */
37
+ /** The corpus as sections, when the app sets their form. Defaults to one per page from the store. */
38
38
  corpus?: (tagPath?: string) => Promise<CorpusSection[]>;
39
39
  /** What an empty search says. */
40
40
  noMatch?: (query: string, tagPath?: string) => string;
@@ -8,9 +8,9 @@
8
8
  // lists a page here lists it the way its HTML route does, hidden paths included.
9
9
  //
10
10
  // What is the app's: where a page lives (`url`), how blocks read as text,
11
- // what a row adds (`metadata`), and how its corpus reads when it has an
12
- // llms-full.txt to match. Writes stay with each app, because who may write
13
- // is the one thing no two of them agree on.
11
+ // what a row adds (`metadata`), and how its corpus reads when it sets its own
12
+ // sections. Writes stay with each app, because who may write is the one thing
13
+ // no two of them agree on.
14
14
  import { McpToolError, readArgs, withAdjustments } from './mcp.js';
15
15
  import { listEnvelope } from './pagination.js';
16
16
  import { CORPUS_BUDGET, sliceCorpus } from './corpus.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.84.0",
3
+ "version": "0.85.1",
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",
package/prisma/wiki.sql CHANGED
@@ -44,8 +44,6 @@ CREATE INDEX "WikiPage_siteId_tagPath_updatedAt_idx" ON "WikiPage"("siteId", "ta
44
44
  -- CreateIndex
45
45
  CREATE INDEX "WikiPage_siteId_updatedAt_idx" ON "WikiPage"("siteId", "updatedAt");
46
46
 
47
- -- CreateIndex
48
-
49
47
  -- CreateIndex
50
48
  CREATE UNIQUE INDEX "WikiPage_siteId_tagPath_slug_key" ON "WikiPage"("siteId", "tagPath", "slug");
51
49