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/dist/mcp.js CHANGED
@@ -203,7 +203,7 @@ function validateArgs(tool, args) {
203
203
  const allowed = Object.keys(properties);
204
204
  const problems = [];
205
205
  for (const key of Object.keys(args)) {
206
- if (key in properties)
206
+ if (Object.hasOwn(properties, key))
207
207
  continue;
208
208
  problems.push(allowed.length
209
209
  ? `Unknown parameter "${key}". Allowed parameters: ${quote(allowed)}.`
@@ -309,6 +309,10 @@ async function handleRpc(req, config, dispatch) {
309
309
  const foreign = ERA_METHODS[dispatch.era === 'modern' ? 'legacy' : 'modern'];
310
310
  if (foreign.includes(method))
311
311
  return methodNotFound(id, method, config, dispatch.era);
312
+ // A notification has no response at all, a method-not-found included:
313
+ // `notifications/progress` or `roots/list_changed` is not an error to answer.
314
+ if (method.startsWith('notifications/'))
315
+ return null;
312
316
  try {
313
317
  switch (method) {
314
318
  case 'initialize':
@@ -326,10 +330,6 @@ async function handleRpc(req, config, dispatch) {
326
330
  capabilities: capabilitiesOf(config),
327
331
  instructions: config.instructions,
328
332
  });
329
- // Notifications have no response at all.
330
- case 'notifications/initialized':
331
- case 'notifications/cancelled':
332
- return null;
333
333
  case 'ping':
334
334
  return ok(id, {});
335
335
  case 'tools/list':
@@ -368,7 +368,9 @@ async function handleRpc(req, config, dispatch) {
368
368
  });
369
369
  }
370
370
  case 'tools/call': {
371
- const { name, arguments: rawArgs = {} } = p;
371
+ // `null` is how some clients spell "no arguments"; it means the same as omitting them.
372
+ const { name, arguments: rawArgs } = p;
373
+ const given = rawArgs ?? {};
372
374
  const names = config.tools.map(t => t.name);
373
375
  const tool = name ? config.tools.find(t => t.name === name) : undefined;
374
376
  if (!tool) {
@@ -377,10 +379,10 @@ async function handleRpc(req, config, dispatch) {
377
379
  availableTools: names,
378
380
  });
379
381
  }
380
- if (typeOf(rawArgs) !== 'object') {
381
- return toolText(id, `"arguments" must be an object; received ${typeOf(rawArgs)}. Expected schema: ${JSON.stringify(tool.inputSchema)}`, true);
382
+ if (typeOf(given) !== 'object') {
383
+ return toolText(id, `"arguments" must be an object; received ${typeOf(given)}. Expected schema: ${JSON.stringify(tool.inputSchema)}`, true);
382
384
  }
383
- const args = rawArgs;
385
+ const args = given;
384
386
  const invalid = validateArgs(tool, args);
385
387
  if (invalid)
386
388
  return toolText(id, invalid, true);
@@ -395,7 +397,9 @@ async function handleRpc(req, config, dispatch) {
395
397
  };
396
398
  try {
397
399
  const data = await tool.handler(args, ctx);
398
- const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
400
+ // `?? null`: a handler that returns nothing still owes a string, and
401
+ // JSON.stringify(undefined) is undefined.
402
+ const text = typeof data === 'string' ? data : JSON.stringify(data ?? null, null, 2);
399
403
  // Every object answer, not only the schema-bearing ones. Gated on
400
404
  // `outputSchema`, a JSON-returning tool without one would carry no
401
405
  // `structuredContent`, and agents would parse prose to reach data the
@@ -526,7 +530,12 @@ export async function mcpResponse(request, config) {
526
530
  // Tracked before either era refuses anything: a probe the server turns away,
527
531
  // and a call an agent walked away from behind a gate, are exactly the ones
528
532
  // worth counting.
529
- config.onCall?.(request, body);
533
+ try {
534
+ config.onCall?.(request, body);
535
+ }
536
+ catch {
537
+ // Analytics never blocks the response, a throwing hook included.
538
+ }
530
539
  return isModern(request, body)
531
540
  ? modernResponse(request, body, config, base)
532
541
  : legacyResponse(request, body, config, base);
@@ -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
+ }
@@ -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;
@@ -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
- const looksLikeUrl = (v, type) => type === 'url' || /^https?:\/\//i.test(v) || /^[^\s/]+\.[a-z]{2,}(\/\S*)?$/i.test(v);
203
+ /** A file or library suffix no domain ends in: "Node.js", "Cargo.toml" and "setup.py" name things, not sites. */
204
+ const NOT_A_TLD = /^(c?js|mjs|jsx|tsx?|json|ya?ml|toml|md|py|sol|wasm|txt|html?|css|pdf|png|jpe?g|svg|gif)$/i;
205
+ const looksLikeUrl = (v, type) => {
206
+ if (type === 'url' || /^https?:\/\//i.test(v))
207
+ return true;
208
+ const bare = /^[^\s/]+\.([a-z]{2,})(\/\S*)?$/i.exec(v);
209
+ return !!bare && !NOT_A_TLD.test(bare[1]);
210
+ };
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
- const clearItems = useCallback(() => setItems([]), []);
359
+ // Dismissing the list also drops the search in flight, or its answer would
360
+ // bring back the list the reader just closed.
361
+ const clearItems = useCallback(() => {
362
+ requestId.current++;
363
+ setItems([]);
364
+ setIsSearching(false);
365
+ }, []);
348
366
  const reset = useCallback(() => {
349
367
  requestId.current++;
350
368
  setQuery('');
@@ -355,7 +373,7 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
355
373
  }, []);
356
374
  const onKeyDown = (e) => {
357
375
  if (e.key === 'Escape') {
358
- setItems([]);
376
+ clearItems();
359
377
  onEscape?.();
360
378
  return;
361
379
  }
@@ -466,8 +484,11 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
466
484
  }, showDelay);
467
485
  };
468
486
  const onOut = (e) => {
469
- // Moving into the card itself is not leaving.
470
- if (e.relatedTarget?.closest(`.${cardClassName}`))
487
+ // Moving within the link (onto a <code> or <em> in it), or into the card
488
+ // itself, is not leaving. Leaving cancels the pending show, and re-entering
489
+ // the same link does not start it again.
490
+ const to = e.relatedTarget;
491
+ if (to && (e.target?.closest('a')?.contains(to) || to.closest(`.${cardClassName}`)))
471
492
  return;
472
493
  clearTimers();
473
494
  hide();
@@ -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.
@@ -29,13 +29,11 @@ export interface BlockChange<L = unknown> {
29
29
  /** Whatever `leafDiff` returned. Absent when no `leafDiff` was supplied. */
30
30
  leafDiff?: L;
31
31
  }
32
- export interface RevisionDiff<L = unknown> {
32
+ export interface RevisionDiff {
33
33
  version: SemVer;
34
34
  changeType: ChangeType;
35
- changes: BlockChange<L>[];
35
+ changes: BlockChange[];
36
36
  titleChanged: boolean;
37
- /** A page-level flag outside the block tree — a banner, a notice, a status. */
38
- metaChanged: boolean;
39
37
  summary: string;
40
38
  }
41
39
  export interface DiffOptions<B, L> {
@@ -72,21 +70,13 @@ export declare function diffBlocks<B extends DiffBlock, L = unknown>(oldBlocks:
72
70
  export declare function changeSummary(diff: {
73
71
  changes: readonly BlockChange<unknown>[];
74
72
  titleChanged: boolean;
75
- metaChanged?: boolean;
76
- metaLabel?: string;
77
73
  }): string;
78
- export declare function computeRevisionDiff<B extends DiffBlock, L = unknown>(opts: {
74
+ export declare function computeRevisionDiff<B extends DiffBlock>(opts: {
79
75
  currentVersion: string | null | undefined;
80
76
  oldContent: readonly B[];
81
77
  newContent: readonly B[];
82
78
  oldTitle: string;
83
79
  newTitle: string;
84
- /** A page-level value outside the block tree; compared by strict equality. */
85
- oldMeta?: unknown;
86
- newMeta?: unknown;
87
- /** What to call that value in the summary. Defaults to `banner`. */
88
- metaLabel?: string;
89
- containers?: DiffOptions<B, L>['containers'];
90
- leafDiff?: DiffOptions<B, L>['leafDiff'];
91
- }): RevisionDiff<L>;
80
+ containers?: DiffOptions<B, unknown>['containers'];
81
+ }): RevisionDiff;
92
82
  export {};
package/dist/revisions.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // revisions.ts — what changed between two versions of a page.
2
2
  //
3
3
  // The semver lives next door in `versioning.ts`; this is the walk that decides
4
- // which bump to ask for. It carries a leaf-level diff, a changed-flag for the
5
- // page's banner, and a `patch` classification for when only that flag moved.
4
+ // which bump to ask for, and carries a leaf-level diff for a history view.
6
5
  // Matching is by id.
7
6
  import { incrementVersion, parseVersion } from './versioning.js';
8
7
  import { coreBlockGroups } from './blocks.js';
@@ -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, `patch` for a page-level flag.
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, metaChanged = false) {
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
- if (changes.length > 0 || titleChanged)
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 metaChanged = opts.oldMeta !== opts.newMeta;
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({ ...partial, metaLabel: opts.metaLabel }) };
171
+ return { ...partial, summary: changeSummary(partial) };
184
172
  }
@@ -11,7 +11,7 @@ import { count } from './analytics.js';
11
11
  import { TimeChart, stepDecimals } from './chart.js';
12
12
  // Whole seconds and percentages, unless an axis tick's step needs more to tell it from the next.
13
13
  const places = (step) => (step ? stepDecimals(step) : 0);
14
- const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
14
+ const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(Math.round(s) / 60)}m ${Math.round(s) % 60}s`);
15
15
  const percent = (n, step) => `${n.toFixed(places(step))}%`;
16
16
  /**
17
17
  * The change from the window before: a share of the figure before, or for a
package/dist/store.js CHANGED
@@ -35,6 +35,7 @@ export function toListing(row) {
35
35
  return { ...rest, snippet: describe(openingText(content)) };
36
36
  }
37
37
  const conflict = (message) => new HttpError(409, message);
38
+ const SLUG_TAKEN = 'A page with that slug already exists in this category';
38
39
  /** Re-order rows to match an id list, dropping ids that no longer resolve. */
39
40
  export function orderByIds(rows, ids) {
40
41
  const byId = new Map(rows.map(row => [row.id, row]));
@@ -153,15 +154,17 @@ export function wikiStore(o) {
153
154
  AND (${prose} ILIKE $3 OR (q.tsq IS NOT NULL AND p.search_tsv @@ q.tsq))))
154
155
  ),
155
156
  paged AS (
156
- SELECT *, count(*) OVER ()::int AS total FROM matched
157
+ SELECT * FROM matched
157
158
  ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC
158
159
  LIMIT $7::int OFFSET $8::int
159
160
  )
161
+ -- The count is joined to the page rather than windowed over it, so a
162
+ -- page past the end still carries the total, on one row of nulls.
160
163
  SELECT ${COLUMNS}, tier, total,
161
164
  CASE WHEN tier = 3 THEN ts_headline('english', ${proseSql()}, tsq, '${HEADLINE_OPTIONS}') END AS headline
162
- FROM paged
165
+ FROM (SELECT count(*)::int AS total FROM matched) t LEFT JOIN paged ON true
163
166
  ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query.trim(), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
164
- const items = found.map(({ tier, total: _, headline, content, ...row }) => ({
167
+ const items = found.filter(hit => hit.id !== null).map(({ tier, total: _, headline, content, ...row }) => ({
165
168
  ...row,
166
169
  ...(withContent ? { content: content } : {}),
167
170
  score: tier,
@@ -170,17 +173,19 @@ export function wikiStore(o) {
170
173
  }));
171
174
  return { items, total: found[0]?.total ?? 0 };
172
175
  };
176
+ /** A revision query's guard: `$1`, its page, is this site's. Another site's page id finds nothing. */
177
+ const OWN_PAGE = `"pageId" IN (SELECT id FROM ${PAGE} WHERE id = $1 AND "siteId" = $2)`;
173
178
  /**
174
179
  * A page's revisions, newest first, each with what changed against the one
175
180
  * before it. `limit` counts what is returned; one more is read so the oldest
176
181
  * shown is diffed against its real predecessor, not against nothing.
177
182
  */
178
183
  const history = async (pageId, { limit = 50, offset = 0 } = {}) => {
179
- const revisions = await rows(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 ORDER BY "createdAt" DESC, id DESC LIMIT $2::int OFFSET $3::int`, pageId, limit + 1, offset);
184
+ const revisions = await rows(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND ${OWN_PAGE} ORDER BY "createdAt" DESC, id DESC LIMIT $3::int OFFSET $4::int`, pageId, site, limit + 1, offset);
180
185
  const entries = withChanges(revisions, { containers: o.containers });
181
186
  return revisions.length > limit ? entries.slice(0, limit) : entries;
182
187
  };
183
- const revision = (pageId, revisionId) => one(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND id = $2`, pageId, revisionId);
188
+ const revision = (pageId, revisionId) => one(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND ${OWN_PAGE} AND id = $3`, pageId, site, revisionId);
184
189
  const create = async (input) => {
185
190
  if (input.title === undefined || input.content === undefined)
186
191
  throw new HttpError(400, 'A new page needs a title and content');
@@ -206,14 +211,18 @@ export function wikiStore(o) {
206
211
  */
207
212
  const save = async (input) => {
208
213
  const existing = await get(input.tagPath, input.slug);
214
+ if (!existing && input.baseVersion !== undefined) {
215
+ throw conflict(`This page was deleted since it was opened at v${input.baseVersion}. Saving now would create it again; reload to decide.`);
216
+ }
209
217
  if (!existing)
210
218
  return create(input);
211
219
  if (input.baseVersion !== undefined && input.baseVersion !== existing.version) {
212
220
  throw conflict(`This page changed since it was opened: it is at v${existing.version} now, not v${input.baseVersion}. Reload it and apply the edit again.`);
213
221
  }
214
222
  const slug = input.newSlug && input.newSlug !== existing.slug ? input.newSlug : existing.slug;
215
- if (slug !== existing.slug && (await get(existing.tagPath, slug)))
216
- throw conflict('A page with that slug already exists in this category');
223
+ const slugTaken = async () => slug !== existing.slug && (await get(existing.tagPath, slug)) !== null;
224
+ if (await slugTaken())
225
+ throw conflict(SLUG_TAKEN);
217
226
  const title = input.title ?? existing.title;
218
227
  const content = input.content ?? existing.content;
219
228
  const diff = computeRevisionDiff({
@@ -236,7 +245,13 @@ export function wikiStore(o) {
236
245
  INSERT INTO ${REVISION} ("pageId", title, content, version, "changeType", message, "authorId")
237
246
  SELECT id, title, content, version, $9::text, $10::text, $11::text FROM up WHERE $9::text <> 'none'
238
247
  )
239
- SELECT * FROM up`, existing.id, existing.version, title, slug, JSON.stringify(content), input.metadata === undefined ? null : JSON.stringify(input.metadata), version, input.verified === true, changeType, input.message ?? diff.summary, input.authorId ?? null);
248
+ SELECT * FROM up`, existing.id, existing.version, title, slug, JSON.stringify(content), input.metadata === undefined ? null : JSON.stringify(input.metadata), version, input.verified === true, changeType, input.message ?? diff.summary, input.authorId ?? null).catch(async (err) => {
249
+ // A page that took the slug after the check above fails the unique index.
250
+ // Asked again rather than read off the error, whose shape is the driver's.
251
+ if (await slugTaken())
252
+ throw conflict(SLUG_TAKEN);
253
+ throw err;
254
+ });
240
255
  if (!page)
241
256
  throw conflict('This page was saved by someone else while this edit was being written. Reload it and apply the edit again.');
242
257
  return { page, created: false, changeType, summary: diff.summary };
package/dist/taxonomy.js CHANGED
@@ -38,7 +38,8 @@ export function hrefBuilder(prefix = '') {
38
38
  export const defaultHref = hrefBuilder();
39
39
  const DEFAULT_ALPHA_INDEX_MIN_PAGES = 40;
40
40
  /** A key's value as text. Metadata is untyped JSON, and a number or a list stored under a key must not throw. */
41
- const metaValue = (page, key) => {
41
+ /** A metadata value as the facts panel shows it: trimmed text or a number, else ''. @internal */
42
+ export const metaValue = (page, key) => {
42
43
  const value = page.metadata?.[key];
43
44
  return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
44
45
  };