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 +6 -6
- package/bin/maintenance-log.mjs +27 -17
- package/bin/mark-verified.mjs +3 -2
- package/dist/a2a.js +4 -4
- package/dist/analytics.js +7 -4
- package/dist/block-views.d.ts +7 -1
- package/dist/block-views.js +3 -2
- package/dist/blocks.js +4 -1
- package/dist/conformance.d.ts +0 -10
- package/dist/conformance.js +13 -17
- package/dist/corpus.js +1 -1
- package/dist/http.js +12 -2
- package/dist/maintenance.d.ts +11 -0
- package/dist/maintenance.js +16 -2
- package/dist/markdown.js +6 -4
- package/dist/mcp.js +20 -11
- package/dist/queues.js +10 -4
- package/dist/react-server.js +8 -1
- package/dist/react.js +29 -8
- package/dist/revisions.d.ts +5 -15
- package/dist/revisions.js +15 -21
- package/dist/stats-figures.js +1 -1
- package/dist/store.js +23 -8
- package/dist/taxonomy.js +2 -1
- package/dist/text.js +1 -1
- package/dist/tiptap.d.ts +5 -0
- package/dist/tiptap.js +12 -8
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +10 -6
- package/dist/well-known.d.ts +2 -0
- package/dist/well-known.js +2 -2
- package/dist/wiki-tools.d.ts +1 -1
- package/dist/wiki-tools.js +3 -3
- package/package.json +1 -1
- package/prisma/wiki.sql +0 -2
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// page's
|
|
33
|
-
|
|
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 = $
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
134
|
-
|
|
143
|
+
if (dry) await q('ROLLBACK');
|
|
144
|
+
else {
|
|
135
145
|
await q(
|
|
136
|
-
`UPDATE ${pages} SET content = $1, metadata = $
|
|
137
|
-
[JSON.stringify(content), JSON.stringify(
|
|
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(
|
package/bin/mark-verified.mjs
CHANGED
|
@@ -29,8 +29,9 @@ try {
|
|
|
29
29
|
client = await connect();
|
|
30
30
|
const { rowCount } = all
|
|
31
31
|
? await client.query(
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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:
|
|
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
|
|
138
|
-
|
|
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:
|
|
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). */
|
package/dist/block-views.d.ts
CHANGED
|
@@ -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
|
})[];
|
package/dist/block-views.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/conformance.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/conformance.js
CHANGED
|
@@ -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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/maintenance.d.ts
CHANGED
|
@@ -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. */
|
package/dist/maintenance.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
381
|
-
return toolText(id, `"arguments" must be an object; received ${typeOf(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
});
|
package/dist/react-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
470
|
-
|
|
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();
|
package/dist/revisions.d.ts
CHANGED
|
@@ -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
|
|
32
|
+
export interface RevisionDiff {
|
|
33
33
|
version: SemVer;
|
|
34
34
|
changeType: ChangeType;
|
|
35
|
-
changes: BlockChange
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
177
|
+
return { ...partial, summary: changeSummary(partial) };
|
|
184
178
|
}
|
package/dist/stats-figures.js
CHANGED
|
@@ -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
|
|
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
|
|
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 $
|
|
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 = $
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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(/<
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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;
|
package/dist/validation.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/validation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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;
|
package/dist/well-known.d.ts
CHANGED
|
@@ -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>;
|
package/dist/well-known.js
CHANGED
|
@@ -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,
|
package/dist/wiki-tools.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/wiki-tools.js
CHANGED
|
@@ -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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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.
|
|
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
|
|