wiki-formant 0.83.1 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- 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/freshness.d.ts +11 -1
- package/dist/freshness.js +7 -7
- package/dist/http.js +12 -2
- package/dist/link-check.d.ts +6 -0
- package/dist/link-check.js +20 -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.d.ts +45 -0
- package/dist/queues.js +91 -0
- package/dist/react-server.d.ts +32 -0
- package/dist/react-server.js +28 -1
- package/dist/react.d.ts +9 -0
- package/dist/react.js +62 -8
- package/dist/revisions.d.ts +5 -15
- package/dist/revisions.js +7 -19
- 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.js +8 -4
- 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 +6 -1
- package/prisma/wiki.sql +0 -2
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.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface QueuePage {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
tagPath: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
content: unknown;
|
|
7
|
+
metadata: unknown;
|
|
8
|
+
updatedAt: Date | string;
|
|
9
|
+
lastVerifiedAt?: Date | string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface QueueItem {
|
|
12
|
+
title: string;
|
|
13
|
+
href: string;
|
|
14
|
+
tagPath: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
}
|
|
17
|
+
export type QueueKey = 'outdated' | 'orphaned' | 'unsourced' | 'incomplete';
|
|
18
|
+
export interface MaintenanceQueue {
|
|
19
|
+
key: QueueKey;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
items: QueueItem[];
|
|
23
|
+
}
|
|
24
|
+
export interface QueueOptions<P extends QueuePage> {
|
|
25
|
+
/** A page's address as its links name it: `/guides/install`, no trailing slash. */
|
|
26
|
+
href: (page: P) => string;
|
|
27
|
+
/** The facts a page's category requires, by key and label. */
|
|
28
|
+
required?: (tagPath: string) => readonly {
|
|
29
|
+
key: string;
|
|
30
|
+
label: string;
|
|
31
|
+
}[];
|
|
32
|
+
nowMs: number;
|
|
33
|
+
maxAgeDays?: number;
|
|
34
|
+
/** How a page is stamped as checked here, closing the outdated queue's description. */
|
|
35
|
+
verify?: string;
|
|
36
|
+
/** The ids a block names its pages by, for a list that picks pages by id rather than by link. */
|
|
37
|
+
listedIds?: (content: unknown) => Iterable<string>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The four queues over a wiki's pages, empty ones dropped. Pass every page
|
|
41
|
+
* outside the home page and the back office: an orphan is a page nothing else
|
|
42
|
+
* links to, so the link graph needs them all. One pass reads each page's
|
|
43
|
+
* content for both the links in and the sources out.
|
|
44
|
+
*/
|
|
45
|
+
export declare function maintenanceQueues<P extends QueuePage>(pages: readonly P[], o: QueueOptions<P>): MaintenanceQueue[];
|
package/dist/queues.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// queues.ts — tracking categories, Wikipedia's hidden maintenance categories,
|
|
2
|
+
// as work queues.
|
|
3
|
+
//
|
|
4
|
+
// A cleanup template files a page into `Articles needing update` or `Orphaned
|
|
5
|
+
// articles`, and contributors pick work off the list. Every wiki here already
|
|
6
|
+
// computed the same signals: `isStale` drives the outdated banner, the content
|
|
7
|
+
// standard asks for linked sources, a category declares required facts. Each
|
|
8
|
+
// surfaced only on the affected page, where nobody looking for work sees it.
|
|
9
|
+
// These are those predicates gathered into lists, derived on every read, so
|
|
10
|
+
// fixing a page takes it off the list with no ticket to close.
|
|
11
|
+
//
|
|
12
|
+
// radix-wiki wrote it over its own Prisma rows; caper, acuiq2 and miow's wikis
|
|
13
|
+
// had none. What differs is how a page is addressed and which facts its
|
|
14
|
+
// category requires, so those are the arguments.
|
|
15
|
+
import { daysSince, isStale, DEFAULT_MAX_AGE_DAYS } from './freshness.js';
|
|
16
|
+
import { metaValue } from './taxonomy.js';
|
|
17
|
+
// An href inside stored JSON, its quotes escaped or not.
|
|
18
|
+
const HREF = /href=\\?"([^"\\?#]+)/g;
|
|
19
|
+
/** A `references` block keeps each citation in a `url` field, not an href. */
|
|
20
|
+
const REF_URL = /"url":"https?:/g;
|
|
21
|
+
/**
|
|
22
|
+
* The four queues over a wiki's pages, empty ones dropped. Pass every page
|
|
23
|
+
* outside the home page and the back office: an orphan is a page nothing else
|
|
24
|
+
* links to, so the link graph needs them all. One pass reads each page's
|
|
25
|
+
* content for both the links in and the sources out.
|
|
26
|
+
*/
|
|
27
|
+
export function maintenanceQueues(pages, o) {
|
|
28
|
+
const maxAgeDays = o.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
|
|
29
|
+
const ids = new Set(pages.map(p => p.id));
|
|
30
|
+
const linkedPaths = new Set();
|
|
31
|
+
const linkedIds = new Set();
|
|
32
|
+
const sources = new Map();
|
|
33
|
+
for (const page of pages) {
|
|
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);
|
|
37
|
+
let outbound = 0;
|
|
38
|
+
for (const [, target] of text.matchAll(HREF)) {
|
|
39
|
+
if (!target)
|
|
40
|
+
continue;
|
|
41
|
+
if (target.startsWith('/')) {
|
|
42
|
+
const path = target.replace(/\/+$/, '') || '/';
|
|
43
|
+
if (path !== self)
|
|
44
|
+
linkedPaths.add(path);
|
|
45
|
+
}
|
|
46
|
+
else if (/^https?:/i.test(target))
|
|
47
|
+
outbound++;
|
|
48
|
+
}
|
|
49
|
+
sources.set(page.id, outbound + (text.match(REF_URL)?.length ?? 0));
|
|
50
|
+
for (const id of o.listedIds?.(page.content) ?? [])
|
|
51
|
+
if (id !== page.id && ids.has(id))
|
|
52
|
+
linkedIds.add(id);
|
|
53
|
+
}
|
|
54
|
+
const item = (p, detail) => ({ title: p.title, href: o.href(p), tagPath: p.tagPath, detail });
|
|
55
|
+
// Missing is what the facts panel would not show, so a number counts as given.
|
|
56
|
+
const incomplete = pages.flatMap(p => {
|
|
57
|
+
const missing = (o.required?.(p.tagPath) ?? [])
|
|
58
|
+
.filter(k => !metaValue(p, k.key))
|
|
59
|
+
.map(k => k.label.replace(/:$/, ''));
|
|
60
|
+
return missing.length ? [item(p, `missing ${missing.join(', ')}`)] : [];
|
|
61
|
+
});
|
|
62
|
+
const queues = [
|
|
63
|
+
{
|
|
64
|
+
key: 'outdated',
|
|
65
|
+
title: 'Outdated',
|
|
66
|
+
description: `Not checked or changed in ${maxAgeDays} days. Re-check the facts against current sources${o.verify ? `, then ${o.verify}` : ''}.`,
|
|
67
|
+
items: pages
|
|
68
|
+
.filter(p => isStale(p, o.nowMs, maxAgeDays))
|
|
69
|
+
.map(p => item(p, `${daysSince(p.lastVerifiedAt ?? p.updatedAt, o.nowMs)} days`)),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: 'orphaned',
|
|
73
|
+
title: 'Orphaned',
|
|
74
|
+
description: 'No other page links here, so readers arrive only by search or the category list. Link it from a related page.',
|
|
75
|
+
items: pages.filter(p => !linkedPaths.has(o.href(p)) && !linkedIds.has(p.id)).map(p => item(p, 'no links in')),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'unsourced',
|
|
79
|
+
title: 'Unsourced',
|
|
80
|
+
description: 'No link out anywhere on the page. Every claim of fact should link to the source it came from.',
|
|
81
|
+
items: pages.filter(p => !sources.get(p.id)).map(p => item(p, 'no links out')),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'incomplete',
|
|
85
|
+
title: 'Missing facts',
|
|
86
|
+
description: 'A fact the page’s category requires is empty, so its facts panel and the category’s filters leave it out.',
|
|
87
|
+
items: incomplete,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
return queues.filter(q => q.items.length > 0);
|
|
91
|
+
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
|
|
3
3
|
import type { HistoryChange } from './history.js';
|
|
4
|
+
import type { MaintenanceQueue } from './queues.js';
|
|
4
5
|
/**
|
|
5
6
|
* The shape a router's link component has to satisfy here.
|
|
6
7
|
*
|
|
@@ -298,3 +299,34 @@ export interface RevisionChangesProps {
|
|
|
298
299
|
* are the importing stylesheet's.
|
|
299
300
|
*/
|
|
300
301
|
export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
|
|
302
|
+
export interface MaintenanceQueuesProps {
|
|
303
|
+
queues: readonly MaintenanceQueue[];
|
|
304
|
+
link?: WikiLinkComponent;
|
|
305
|
+
/** Shown when every queue is empty. */
|
|
306
|
+
empty?: ReactNode;
|
|
307
|
+
/** The level each queue's heading takes. */
|
|
308
|
+
headingLevel?: 'h2' | 'h3';
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* `maintenanceQueues`' lists, a heading and a count each. The page around them
|
|
312
|
+
* (its title, a sentence on what the queues are) is the wiki's.
|
|
313
|
+
*/
|
|
314
|
+
export declare function MaintenanceQueues({ queues, link: Link, empty, headingLevel: H }: MaintenanceQueuesProps): import("react").JSX.Element;
|
|
315
|
+
export interface CiteProps {
|
|
316
|
+
/** Who publishes the page: the site's name. */
|
|
317
|
+
publisher: string;
|
|
318
|
+
title: string;
|
|
319
|
+
/** The day the page last changed, or was published where a wiki keeps that. */
|
|
320
|
+
date: Date | string;
|
|
321
|
+
/** The page's one canonical address. */
|
|
322
|
+
url: string;
|
|
323
|
+
className?: string;
|
|
324
|
+
}
|
|
325
|
+
/** The citation for a page, one line: `Publisher. "Title." October 22, 2026. url`, as acuiq2 first wrote it. */
|
|
326
|
+
export declare const citation: ({ publisher, title, date, url }: Omit<CiteProps, "className">) => string;
|
|
327
|
+
/**
|
|
328
|
+
* "Cite" in an article's meta line, opening on the citation to copy. Shown,
|
|
329
|
+
* not copied by a button, so it works with no script and a reader can take
|
|
330
|
+
* part of it.
|
|
331
|
+
*/
|
|
332
|
+
export declare function Cite({ className, ...page }: CiteProps): import("react").JSX.Element;
|
package/dist/react-server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { Fragment } from 'react';
|
|
|
22
22
|
import { safeLinkHref } from './validation.js';
|
|
23
23
|
import { isoDate } from './html.js';
|
|
24
24
|
import { blockLocation } from './revisions.js';
|
|
25
|
+
import { formatDay } from './freshness.js';
|
|
25
26
|
/**
|
|
26
27
|
* The default for anything that does not need client-side navigation.
|
|
27
28
|
*
|
|
@@ -199,7 +200,14 @@ export function SnapDots({ count, at, onGo, noun = 'item', className = '' }) {
|
|
|
199
200
|
export function InfoboxAside({ label, series, link: Link = Anchor, className, children }) {
|
|
200
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] }));
|
|
201
202
|
}
|
|
202
|
-
|
|
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
|
+
};
|
|
203
211
|
function externalLink(v) {
|
|
204
212
|
const href = safeLinkHref(/^[a-z][a-z0-9+.-]*:/i.test(v) ? v : `https://${v}`);
|
|
205
213
|
if (!href)
|
|
@@ -250,3 +258,22 @@ export function RevisionChanges({ changes, label = type => type }) {
|
|
|
250
258
|
return null;
|
|
251
259
|
return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
|
|
252
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* `maintenanceQueues`' lists, a heading and a count each. The page around them
|
|
263
|
+
* (its title, a sentence on what the queues are) is the wiki's.
|
|
264
|
+
*/
|
|
265
|
+
export function MaintenanceQueues({ queues, link: Link = Anchor, empty = _jsx("p", { children: "Nothing needs attention." }), headingLevel: H = 'h2' }) {
|
|
266
|
+
if (!queues.length)
|
|
267
|
+
return _jsx(_Fragment, { children: empty });
|
|
268
|
+
return (_jsx(_Fragment, { children: queues.map(queue => (_jsxs("section", { className: "maintenance-queue", "aria-labelledby": `queue-${queue.key}`, children: [_jsxs(H, { id: `queue-${queue.key}`, children: [queue.title, " ", _jsx("span", { className: "maintenance-count", children: queue.items.length })] }), _jsx("p", { className: "maintenance-description", children: queue.description }), _jsx("ul", { className: "maintenance-list", children: queue.items.map(item => (_jsxs("li", { className: "maintenance-item", children: [_jsx(Link, { href: item.href, children: item.title }), _jsxs("span", { className: "maintenance-detail", children: [item.tagPath ? `${item.tagPath} · ` : '', item.detail] })] }, item.href))) })] }, queue.key))) }));
|
|
269
|
+
}
|
|
270
|
+
/** The citation for a page, one line: `Publisher. "Title." October 22, 2026. url`, as acuiq2 first wrote it. */
|
|
271
|
+
export const citation = ({ publisher, title, date, url }) => `${publisher}. "${title}." ${formatDay(date, { month: 'long' })}. ${url}`;
|
|
272
|
+
/**
|
|
273
|
+
* "Cite" in an article's meta line, opening on the citation to copy. Shown,
|
|
274
|
+
* not copied by a button, so it works with no script and a reader can take
|
|
275
|
+
* part of it.
|
|
276
|
+
*/
|
|
277
|
+
export function Cite({ className = 'article-cite', ...page }) {
|
|
278
|
+
return (_jsxs("details", { className: className, children: [_jsx("summary", { children: "Cite" }), _jsx("code", { children: citation(page) })] }));
|
|
279
|
+
}
|
package/dist/react.d.ts
CHANGED
|
@@ -330,6 +330,15 @@ export declare function useUnsavedChanges(dirty: boolean, message?: string): ()
|
|
|
330
330
|
* file. `save` is read when the key is pressed, so it may close over state.
|
|
331
331
|
*/
|
|
332
332
|
export declare function useSaveShortcut(save: () => void): void;
|
|
333
|
+
/**
|
|
334
|
+
* "/" opens search from anywhere, Wikipedia's key, unless the reader is
|
|
335
|
+
* already typing. `open` focuses the field, or opens whatever holds it, and
|
|
336
|
+
* returns false when there is nothing to open (a field hidden at this width),
|
|
337
|
+
* which leaves the key to the page.
|
|
338
|
+
*/
|
|
339
|
+
export declare function useSearchShortcut(open: () => boolean | void): void;
|
|
340
|
+
/** Focus a search field that is shown: `useSearchShortcut(() => focusShown(ref.current))`. */
|
|
341
|
+
export declare const focusShown: (input: HTMLInputElement | null) => boolean;
|
|
333
342
|
/** Resolves to an error message to show, or null on success. */
|
|
334
343
|
export type RestoreRevision<Id> = (id: Id) => Promise<string | null>;
|
|
335
344
|
/**
|
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();
|
|
@@ -654,6 +675,39 @@ export function useSaveShortcut(save) {
|
|
|
654
675
|
return () => window.removeEventListener('keydown', onKey);
|
|
655
676
|
}, []);
|
|
656
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* "/" opens search from anywhere, Wikipedia's key, unless the reader is
|
|
680
|
+
* already typing. `open` focuses the field, or opens whatever holds it, and
|
|
681
|
+
* returns false when there is nothing to open (a field hidden at this width),
|
|
682
|
+
* which leaves the key to the page.
|
|
683
|
+
*/
|
|
684
|
+
export function useSearchShortcut(open) {
|
|
685
|
+
const latest = useRef(open);
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
latest.current = open;
|
|
688
|
+
});
|
|
689
|
+
useEffect(() => {
|
|
690
|
+
const onKey = (e) => {
|
|
691
|
+
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey)
|
|
692
|
+
return;
|
|
693
|
+
const t = e.target;
|
|
694
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable))
|
|
695
|
+
return;
|
|
696
|
+
if (latest.current() !== false)
|
|
697
|
+
e.preventDefault();
|
|
698
|
+
};
|
|
699
|
+
window.addEventListener('keydown', onKey);
|
|
700
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
701
|
+
}, []);
|
|
702
|
+
}
|
|
703
|
+
/** Focus a search field that is shown: `useSearchShortcut(() => focusShown(ref.current))`. */
|
|
704
|
+
export const focusShown = (input) => {
|
|
705
|
+
// A field hidden at this width has no offsetParent.
|
|
706
|
+
if (!input?.offsetParent)
|
|
707
|
+
return false;
|
|
708
|
+
input.focus();
|
|
709
|
+
return true;
|
|
710
|
+
};
|
|
657
711
|
/**
|
|
658
712
|
* POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
|
|
659
713
|
* third passes its server action instead.
|
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';
|
|
@@ -128,29 +127,23 @@ export function diffBlocks(oldBlocks, newBlocks, opts) {
|
|
|
128
127
|
return changes;
|
|
129
128
|
}
|
|
130
129
|
/**
|
|
131
|
-
* `major` for structure, `minor` for prose
|
|
130
|
+
* `major` for structure, `minor` for prose and the title.
|
|
132
131
|
*
|
|
133
132
|
* A `modified` change is only ever recorded when `diffAttributes` returned
|
|
134
133
|
* something, so a non-empty change set is always at least a minor bump.
|
|
135
134
|
*
|
|
136
135
|
* @internal
|
|
137
136
|
*/
|
|
138
|
-
export function classifyChanges(changes, titleChanged
|
|
139
|
-
if (changes.length === 0 && !titleChanged && !metaChanged)
|
|
140
|
-
return 'none';
|
|
137
|
+
export function classifyChanges(changes, titleChanged) {
|
|
141
138
|
if (changes.some(c => c.action === 'added' || c.action === 'removed' || c.action === 'moved')) {
|
|
142
139
|
return 'major';
|
|
143
140
|
}
|
|
144
|
-
|
|
145
|
-
return 'minor';
|
|
146
|
-
return 'patch';
|
|
141
|
+
return changes.length > 0 || titleChanged ? 'minor' : 'none';
|
|
147
142
|
}
|
|
148
143
|
export function changeSummary(diff) {
|
|
149
144
|
const parts = [];
|
|
150
145
|
if (diff.titleChanged)
|
|
151
146
|
parts.push('title updated');
|
|
152
|
-
if (diff.metaChanged)
|
|
153
|
-
parts.push(`${diff.metaLabel ?? 'banner'} updated`);
|
|
154
147
|
const count = (action) => diff.changes.filter(c => c.action === action).length;
|
|
155
148
|
const plural = (n, word) => `${n} ${word}${n > 1 ? 's' : ''}`;
|
|
156
149
|
for (const [action, verb] of [
|
|
@@ -166,19 +159,14 @@ export function changeSummary(diff) {
|
|
|
166
159
|
return parts.length > 0 ? parts.join(', ') : 'no changes';
|
|
167
160
|
}
|
|
168
161
|
export function computeRevisionDiff(opts) {
|
|
169
|
-
const changes = diffBlocks(opts.oldContent, opts.newContent, {
|
|
170
|
-
containers: opts.containers,
|
|
171
|
-
leafDiff: opts.leafDiff,
|
|
172
|
-
});
|
|
162
|
+
const changes = diffBlocks(opts.oldContent, opts.newContent, { containers: opts.containers });
|
|
173
163
|
const titleChanged = opts.oldTitle !== opts.newTitle;
|
|
174
|
-
const
|
|
175
|
-
const changeType = classifyChanges(changes, titleChanged, metaChanged);
|
|
164
|
+
const changeType = classifyChanges(changes, titleChanged);
|
|
176
165
|
const partial = {
|
|
177
166
|
version: incrementVersion(parseVersion(opts.currentVersion), changeType),
|
|
178
167
|
changeType,
|
|
179
168
|
changes,
|
|
180
169
|
titleChanged,
|
|
181
|
-
metaChanged,
|
|
182
170
|
};
|
|
183
|
-
return { ...partial, summary: changeSummary(
|
|
171
|
+
return { ...partial, summary: changeSummary(partial) };
|
|
184
172
|
}
|
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
|
};
|