wiki-formant 0.83.0 → 0.84.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 +10 -3
- package/dist/dom.d.ts +0 -8
- package/dist/dom.js +0 -23
- package/dist/freshness.d.ts +11 -1
- package/dist/freshness.js +7 -7
- package/dist/link-check.d.ts +6 -0
- package/dist/link-check.js +20 -2
- package/dist/queues.d.ts +45 -0
- package/dist/queues.js +85 -0
- package/dist/react-server.d.ts +32 -0
- package/dist/react-server.js +20 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +60 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -422,7 +422,7 @@ const { ref, onScroll, at, step, go } = useSnapRow();
|
|
|
422
422
|
|
|
423
423
|
`useClickOutside` is fifteen lines and was in all three. Only one had the `offsetParent` check, and without it a container hidden at the current breakpoint still answers outside-clicks — so on a phone, a tap anywhere dismisses the popover the reader is looking at, because the hidden desktop copy got there first.
|
|
424
424
|
|
|
425
|
-
`useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed.
|
|
425
|
+
`useUnsavedChanges(dirty)` asks before an edit with unsaved changes is left: the browser's own prompt on a reload or a closed tab, and the `confirmLeave()` it returns for a Back link or Cancel, which a client-side navigation never sends through `beforeunload`. If a save leaves by a full page load, pass false once it has landed, or the save's own exit asks. `useSaveShortcut(save)` binds ⌘S and Ctrl+S, reading `save` when the key is pressed. `useSearchShortcut(open)` is Wikipedia's "/" to search, ignored while the reader types in a field: `open` focuses the field or opens what holds it, and returns false to leave the key alone, which `focusShown(input)` does for a field hidden at this width.
|
|
426
426
|
|
|
427
427
|
### The listbox contract
|
|
428
428
|
|
|
@@ -693,13 +693,12 @@ export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a name
|
|
|
693
693
|
addCopyButtons(el); // every <pre> gets one, once
|
|
694
694
|
sortTables(el); // every column-headed table sorts by its headers
|
|
695
695
|
hydrateTweetEmbeds(el); // placeholders get a live src
|
|
696
|
-
await renderMath(el); // $$…$$, \\[…\\] and \\(…\\) typeset by KaTeX, loaded only when there is maths
|
|
697
696
|
const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
|
|
698
697
|
```
|
|
699
698
|
|
|
700
699
|
`addCopyButton` was **byte-identical** in two BlockRenderers, down to the SVG path data. Its idempotence guard now lives inside the function rather than in a `pre:not(:has(…))` at the call site, where it can be — and was — retyped.
|
|
701
700
|
|
|
702
|
-
In React, `useArticlePasses(ref, [blocks])` (`wiki-formant/react`) runs them all, the embed pair and
|
|
701
|
+
In React, `useArticlePasses(ref, [blocks])` (`wiki-formant/react`) runs them all, the embed pair included, and typesets maths: `renderMath` (also `wiki-formant/react`, since its stylesheet import cannot sit in a module server code reaches) sets `$$…$$` and `\[…\]` as a display and `\(…\)` inline. KaTeX is an optional peer: an app installs `katex`, and a page with no delimiter never fetches it. There is no single-`$` delimiter, since prose writes $XRD. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
|
|
703
702
|
|
|
704
703
|
`activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
|
|
705
704
|
|
|
@@ -766,6 +765,14 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
|
|
|
766
765
|
|
|
767
766
|
`relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
|
|
768
767
|
|
|
768
|
+
## Freshness and the maintenance queues
|
|
769
|
+
|
|
770
|
+
`freshnessBanner(page, nowMs, { maxAgeDays, against })` is the synthetic `outdated` banner a page carries once it has gone unchecked for `maxAgeDays` (180), or null. Its notice asks the reader to re-check the facts against `current sources`; the two Radix wikis pass `against: 'current sources and the live ledger'`, which a shop's wiki has no use for.
|
|
771
|
+
|
|
772
|
+
`wiki-formant/queues` gathers the signals each page shows into Wikipedia's tracking categories: `maintenanceQueues(pages, { href, required, nowMs, verify, listedIds })` returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped, from one pass over the pages' content. Pass every page but the home page and the back office, since an orphan is a page no other page links to. `href` is a page's address as its links name it, `required` the facts its category requires, `verify` how a page is stamped as checked here (it closes the outdated queue's sentence), and `listedIds` the ids a list block names its pages by. `MaintenanceQueues` (`wiki-formant/react-server`) draws them, a heading and a count each, under classes `maintenance-*`; the page around them is the wiki's, `noindex`. All four apps serve one at `/maintenance` (on miow, the owner page).
|
|
773
|
+
|
|
774
|
+
`Cite` (`wiki-formant/react-server`) is "Cite" in an article's meta line, a `<details>` opening on `citation({ publisher, title, date, url })`: `AcuiQ. "Point codes." October 2, 2026. https://…`.
|
|
775
|
+
|
|
769
776
|
## Sanitising stored HTML
|
|
770
777
|
|
|
771
778
|
`wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
|
package/dist/dom.d.ts
CHANGED
|
@@ -40,14 +40,6 @@ export declare function onTweetResize(resize: (height: number, source: MessageEv
|
|
|
40
40
|
export declare function hydrateTweetEmbeds(root: ParentNode): void;
|
|
41
41
|
/** Apply a posted height to the embed iframe under `root` that posted it, and to no other. */
|
|
42
42
|
export declare function sizeTweetEmbed(root: ParentNode, height: number, source: MessageEventSource | null): void;
|
|
43
|
-
/**
|
|
44
|
-
* Typeset the maths under `root` with KaTeX: `$$…$$` and `\[…\]` as a display,
|
|
45
|
-
* `\(…\)` inline. KaTeX and its stylesheet load only when the text holds a
|
|
46
|
-
* delimiter, so a page with no maths fetches nothing. KaTeX is an optional peer
|
|
47
|
-
* the app installs. Typeset maths carries no delimiter, so a second pass finds
|
|
48
|
-
* nothing to do.
|
|
49
|
-
*/
|
|
50
|
-
export declare function renderMath(root: HTMLElement): Promise<void>;
|
|
51
43
|
export interface TabGroupClassNames {
|
|
52
44
|
list?: string;
|
|
53
45
|
panels?: string;
|
package/dist/dom.js
CHANGED
|
@@ -113,29 +113,6 @@ export function sizeTweetEmbed(root, height, source) {
|
|
|
113
113
|
iframe.style.height = `${height}px`;
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
// ---- maths ------------------------------------------------------------------
|
|
117
|
-
/** What marks maths in stored prose. No single `$`: prose writes $XRD, and two cashtags in one block would read as a formula. */
|
|
118
|
-
const MATH = /\$\$|\\\(|\\\[/;
|
|
119
|
-
/**
|
|
120
|
-
* Typeset the maths under `root` with KaTeX: `$$…$$` and `\[…\]` as a display,
|
|
121
|
-
* `\(…\)` inline. KaTeX and its stylesheet load only when the text holds a
|
|
122
|
-
* delimiter, so a page with no maths fetches nothing. KaTeX is an optional peer
|
|
123
|
-
* the app installs. Typeset maths carries no delimiter, so a second pass finds
|
|
124
|
-
* nothing to do.
|
|
125
|
-
*/
|
|
126
|
-
export async function renderMath(root) {
|
|
127
|
-
if (!MATH.test(root.textContent ?? ''))
|
|
128
|
-
return;
|
|
129
|
-
const [{ default: render }] = await Promise.all([import('katex/contrib/auto-render'), import('katex/dist/katex.min.css')]);
|
|
130
|
-
render(root, {
|
|
131
|
-
delimiters: [
|
|
132
|
-
{ left: '$$', right: '$$', display: true },
|
|
133
|
-
{ left: '\\(', right: '\\)', display: false },
|
|
134
|
-
{ left: '\\[', right: '\\]', display: true },
|
|
135
|
-
],
|
|
136
|
-
throwOnError: false,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
116
|
/**
|
|
140
117
|
* Turn stored `[data-tabs]` markup into a working tab group.
|
|
141
118
|
*
|
package/dist/freshness.d.ts
CHANGED
|
@@ -12,6 +12,16 @@ export declare function daysSince(date: Date | string | null | undefined, now: n
|
|
|
12
12
|
* edit is a weaker signal than a verification, but it is a signal.
|
|
13
13
|
*/
|
|
14
14
|
export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?: number): boolean;
|
|
15
|
+
export interface FreshnessOptions {
|
|
16
|
+
/** Older than this, unverified, and the page is stale. */
|
|
17
|
+
maxAgeDays?: number;
|
|
18
|
+
/**
|
|
19
|
+
* What the notice asks a reader to re-check the facts against: `current
|
|
20
|
+
* sources` unless the wiki has more to name. The two Radix wikis add the live
|
|
21
|
+
* ledger, which a shop's wiki or a medical one has no use for.
|
|
22
|
+
*/
|
|
23
|
+
against?: string;
|
|
24
|
+
}
|
|
15
25
|
/**
|
|
16
26
|
* A synthetic `outdated` banner block for a stale page, or null when it is
|
|
17
27
|
* fresh — the shape every wiki's `banner` type already stores, so it drops
|
|
@@ -20,7 +30,7 @@ export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?:
|
|
|
20
30
|
* during a client render would let a page near the boundary be stale on the
|
|
21
31
|
* server and fresh in the browser.
|
|
22
32
|
*/
|
|
23
|
-
export declare function freshnessBanner(page: FreshnessInput, nowMs: number, maxAgeDays?:
|
|
33
|
+
export declare function freshnessBanner(page: FreshnessInput, nowMs: number, { maxAgeDays, against }?: FreshnessOptions): {
|
|
24
34
|
id: string;
|
|
25
35
|
type: 'banner';
|
|
26
36
|
variant: 'outdated';
|
package/dist/freshness.js
CHANGED
|
@@ -32,17 +32,17 @@ export function isStale(page, now, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
|
|
|
32
32
|
return age === null || age > maxAgeDays;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* The sentence the notice carries. Both wikis shipped this string
|
|
36
|
-
* down to the ISO date and the closing request
|
|
37
|
-
*
|
|
35
|
+
* The sentence the notice carries. Both Radix wikis shipped this string
|
|
36
|
+
* identically, down to the ISO date and the closing request; what to re-check
|
|
37
|
+
* against is the one part that differs between wikis.
|
|
38
38
|
*
|
|
39
39
|
* @internal
|
|
40
40
|
*/
|
|
41
|
-
export function freshnessNotice(page) {
|
|
41
|
+
export function freshnessNotice(page, against = 'current sources') {
|
|
42
42
|
const when = page.lastVerifiedAt
|
|
43
43
|
? `last verified ${isoDate(new Date(page.lastVerifiedAt))}`
|
|
44
44
|
: 'not yet verified against sources';
|
|
45
|
-
return `This page was ${when} and may be out of date. Please help re-check its facts against
|
|
45
|
+
return `This page was ${when} and may be out of date. Please help re-check its facts against ${against}.`;
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* A synthetic `outdated` banner block for a stale page, or null when it is
|
|
@@ -52,10 +52,10 @@ export function freshnessNotice(page) {
|
|
|
52
52
|
* during a client render would let a page near the boundary be stale on the
|
|
53
53
|
* server and fresh in the browser.
|
|
54
54
|
*/
|
|
55
|
-
export function freshnessBanner(page, nowMs, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
|
|
55
|
+
export function freshnessBanner(page, nowMs, { maxAgeDays = DEFAULT_MAX_AGE_DAYS, against } = {}) {
|
|
56
56
|
if (!isStale(page, nowMs, maxAgeDays))
|
|
57
57
|
return null;
|
|
58
|
-
return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page) };
|
|
58
|
+
return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page, against) };
|
|
59
59
|
}
|
|
60
60
|
const toMs = (d) => (typeof d === 'number' ? d : d instanceof Date ? d.getTime() : Date.parse(d));
|
|
61
61
|
/**
|
package/dist/link-check.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface Probe {
|
|
|
8
8
|
note?: string;
|
|
9
9
|
contentType?: string;
|
|
10
10
|
bytes?: number;
|
|
11
|
+
/**
|
|
12
|
+
* The host refused a script rather than the page being gone: a bot challenge,
|
|
13
|
+
* a 429, or a 403/503 whose body is a challenge page. Report it as
|
|
14
|
+
* unverifiable, never as dead.
|
|
15
|
+
*/
|
|
16
|
+
walled?: boolean;
|
|
11
17
|
}
|
|
12
18
|
export interface ProbeOptions {
|
|
13
19
|
/** Ordinary per-attempt budget. */
|
package/dist/link-check.js
CHANGED
|
@@ -106,23 +106,41 @@ export function perHost(url, fn) {
|
|
|
106
106
|
hostQueues.set(host, next.catch(() => undefined));
|
|
107
107
|
return next;
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* A browser's request headers. A scholarly publisher or a standards body 403s
|
|
111
|
+
* Node's own user agent whatever the page's health; acuiq2's sweep, which
|
|
112
|
+
* checks PubMed and journal citations, sent these and saw a fraction of the
|
|
113
|
+
* false deaths.
|
|
114
|
+
*/
|
|
115
|
+
const BROWSER = {
|
|
116
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36',
|
|
117
|
+
Accept: 'text/html,application/xhtml+xml,*/*;q=0.8',
|
|
118
|
+
'Accept-Language': 'en',
|
|
119
|
+
};
|
|
120
|
+
const CHALLENGE = /recaptcha|cf-chl|challenge-platform|just a moment|captcha/i;
|
|
109
121
|
async function attempt(url, budgetMs) {
|
|
110
122
|
// One signal for both requests, so the budget covers the HEAD *and* the GET
|
|
111
123
|
// rather than resetting between them. `engines.node` is >= 20, so this needs
|
|
112
124
|
// no controller and no `clearTimeout` in a `finally` that could be forgotten.
|
|
113
125
|
const signal = AbortSignal.timeout(budgetMs);
|
|
114
126
|
try {
|
|
115
|
-
let res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal });
|
|
127
|
+
let res = await fetch(url, { method: 'HEAD', redirect: 'follow', headers: BROWSER, signal });
|
|
116
128
|
// Some hosts reject HEAD outright; retry with GET before believing a 4xx.
|
|
129
|
+
let body = '';
|
|
117
130
|
if (res.status >= 400) {
|
|
118
|
-
res = await fetch(url, { method: 'GET', redirect: 'follow', signal });
|
|
131
|
+
res = await fetch(url, { method: 'GET', redirect: 'follow', headers: BROWSER, signal });
|
|
132
|
+
if (res.status >= 400)
|
|
133
|
+
body = await res.text().catch(() => '');
|
|
119
134
|
}
|
|
135
|
+
// A 429 is the host rationing us, which says nothing about the page.
|
|
136
|
+
const walled = res.headers.get('cf-mitigated') === 'challenge' || res.status === 429 || ([403, 503].includes(res.status) && CHALLENGE.test(body));
|
|
120
137
|
return {
|
|
121
138
|
url,
|
|
122
139
|
status: res.status,
|
|
123
140
|
ok: res.status < 400,
|
|
124
141
|
contentType: (res.headers.get('content-type') || '').split(';')[0]?.trim(),
|
|
125
142
|
bytes: Number(res.headers.get('content-length') || 0),
|
|
143
|
+
...(walled && { walled }),
|
|
126
144
|
};
|
|
127
145
|
}
|
|
128
146
|
catch (err) {
|
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,85 @@
|
|
|
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
|
+
// An href inside stored JSON, its quotes escaped or not.
|
|
17
|
+
const HREF = /href=\\?"([^"\\?#]+)/g;
|
|
18
|
+
/** A `references` block keeps each citation in a `url` field, not an href. */
|
|
19
|
+
const REF_URL = /"url":"https?:/g;
|
|
20
|
+
/**
|
|
21
|
+
* The four queues over a wiki's pages, empty ones dropped. Pass every page
|
|
22
|
+
* outside the home page and the back office: an orphan is a page nothing else
|
|
23
|
+
* links to, so the link graph needs them all. One pass reads each page's
|
|
24
|
+
* content for both the links in and the sources out.
|
|
25
|
+
*/
|
|
26
|
+
export function maintenanceQueues(pages, o) {
|
|
27
|
+
const maxAgeDays = o.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
|
|
28
|
+
const ids = new Set(pages.map(p => p.id));
|
|
29
|
+
const linkedPaths = new Set();
|
|
30
|
+
const linkedIds = new Set();
|
|
31
|
+
const sources = new Map();
|
|
32
|
+
for (const page of pages) {
|
|
33
|
+
const text = JSON.stringify(page.content ?? '');
|
|
34
|
+
let outbound = 0;
|
|
35
|
+
for (const [, target] of text.matchAll(HREF)) {
|
|
36
|
+
if (!target)
|
|
37
|
+
continue;
|
|
38
|
+
if (target.startsWith('/'))
|
|
39
|
+
linkedPaths.add(target.replace(/\/+$/, '') || '/');
|
|
40
|
+
else if (/^https?:/i.test(target))
|
|
41
|
+
outbound++;
|
|
42
|
+
}
|
|
43
|
+
sources.set(page.id, outbound + (text.match(REF_URL)?.length ?? 0));
|
|
44
|
+
for (const id of o.listedIds?.(page.content) ?? [])
|
|
45
|
+
if (id !== page.id && ids.has(id))
|
|
46
|
+
linkedIds.add(id);
|
|
47
|
+
}
|
|
48
|
+
const item = (p, detail) => ({ title: p.title, href: o.href(p), tagPath: p.tagPath, detail });
|
|
49
|
+
const facts = (p) => (p.metadata && typeof p.metadata === 'object' ? p.metadata : {});
|
|
50
|
+
const incomplete = pages.flatMap(p => {
|
|
51
|
+
const missing = (o.required?.(p.tagPath) ?? [])
|
|
52
|
+
.filter(k => { const v = facts(p)[k.key]; return typeof v !== 'string' || !v.trim(); })
|
|
53
|
+
.map(k => k.label.replace(/:$/, ''));
|
|
54
|
+
return missing.length ? [item(p, `missing ${missing.join(', ')}`)] : [];
|
|
55
|
+
});
|
|
56
|
+
const queues = [
|
|
57
|
+
{
|
|
58
|
+
key: 'outdated',
|
|
59
|
+
title: 'Outdated',
|
|
60
|
+
description: `Not checked or changed in ${maxAgeDays} days. Re-check the facts against current sources${o.verify ? `, then ${o.verify}` : ''}.`,
|
|
61
|
+
items: pages
|
|
62
|
+
.filter(p => isStale(p, o.nowMs, maxAgeDays))
|
|
63
|
+
.map(p => item(p, `${daysSince(p.lastVerifiedAt ?? p.updatedAt, o.nowMs)} days`)),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'orphaned',
|
|
67
|
+
title: 'Orphaned',
|
|
68
|
+
description: 'No other page links here, so readers arrive only by search or the category list. Link it from a related page.',
|
|
69
|
+
items: pages.filter(p => !linkedPaths.has(o.href(p)) && !linkedIds.has(p.id)).map(p => item(p, 'no links in')),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: 'unsourced',
|
|
73
|
+
title: 'Unsourced',
|
|
74
|
+
description: 'No link out anywhere on the page. Every claim of fact should link to the source it came from.',
|
|
75
|
+
items: pages.filter(p => !sources.get(p.id)).map(p => item(p, 'no links out')),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'incomplete',
|
|
79
|
+
title: 'Missing facts',
|
|
80
|
+
description: 'A fact the page’s category requires is empty, so its facts panel and the category’s filters leave it out.',
|
|
81
|
+
items: incomplete,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
return queues.filter(q => q.items.length > 0);
|
|
85
|
+
}
|
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
|
*
|
|
@@ -250,3 +251,22 @@ export function RevisionChanges({ changes, label = type => type }) {
|
|
|
250
251
|
return null;
|
|
251
252
|
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
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* `maintenanceQueues`' lists, a heading and a count each. The page around them
|
|
256
|
+
* (its title, a sentence on what the queues are) is the wiki's.
|
|
257
|
+
*/
|
|
258
|
+
export function MaintenanceQueues({ queues, link: Link = Anchor, empty = _jsx("p", { children: "Nothing needs attention." }), headingLevel: H = 'h2' }) {
|
|
259
|
+
if (!queues.length)
|
|
260
|
+
return _jsx(_Fragment, { children: empty });
|
|
261
|
+
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))) }));
|
|
262
|
+
}
|
|
263
|
+
/** The citation for a page, one line: `Publisher. "Title." October 22, 2026. url`, as acuiq2 first wrote it. */
|
|
264
|
+
export const citation = ({ publisher, title, date, url }) => `${publisher}. "${title}." ${formatDay(date, { month: 'long' })}. ${url}`;
|
|
265
|
+
/**
|
|
266
|
+
* "Cite" in an article's meta line, opening on the citation to copy. Shown,
|
|
267
|
+
* not copied by a button, so it works with no script and a reader can take
|
|
268
|
+
* part of it.
|
|
269
|
+
*/
|
|
270
|
+
export function Cite({ className = 'article-cite', ...page }) {
|
|
271
|
+
return (_jsxs("details", { className: className, children: [_jsx("summary", { children: "Cite" }), _jsx("code", { children: citation(page) })] }));
|
|
272
|
+
}
|
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
|
/**
|
|
@@ -477,6 +486,14 @@ export declare function RailShell({ children, prefix, label, className }: RailSh
|
|
|
477
486
|
*/
|
|
478
487
|
export declare function isRailLinkActive(activePath: string, href: string, tree?: boolean): boolean;
|
|
479
488
|
export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
|
|
489
|
+
/**
|
|
490
|
+
* Typeset the maths under `root` with KaTeX: `$$…$$` and `\[…\]` as a display,
|
|
491
|
+
* `\(…\)` inline. KaTeX and its stylesheet load only when the text holds a
|
|
492
|
+
* delimiter, so a page with no maths fetches nothing. KaTeX is an optional peer
|
|
493
|
+
* the app installs. Typeset maths carries no delimiter, so a second pass finds
|
|
494
|
+
* nothing to do.
|
|
495
|
+
*/
|
|
496
|
+
export declare function renderMath(root: HTMLElement): Promise<void>;
|
|
480
497
|
/**
|
|
481
498
|
* The `wiki-formant/dom` passes over a rendered article: tab groups, copy
|
|
482
499
|
* buttons, sortable tables, maths typeset, and each post on X hydrated and kept
|
package/dist/react.js
CHANGED
|
@@ -26,7 +26,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
26
26
|
// state on <html> before first paint. The hook keeps that attribute in
|
|
27
27
|
// sync afterwards, so CSS has one source of truth either side of hydration.
|
|
28
28
|
import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
|
|
29
|
-
import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize,
|
|
29
|
+
import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbed, sortTables, startBeacon } from './dom.js';
|
|
30
30
|
import { comboboxAria } from './combobox.js';
|
|
31
31
|
import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
|
|
32
32
|
const readStored = (key) => {
|
|
@@ -654,6 +654,39 @@ export function useSaveShortcut(save) {
|
|
|
654
654
|
return () => window.removeEventListener('keydown', onKey);
|
|
655
655
|
}, []);
|
|
656
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* "/" opens search from anywhere, Wikipedia's key, unless the reader is
|
|
659
|
+
* already typing. `open` focuses the field, or opens whatever holds it, and
|
|
660
|
+
* returns false when there is nothing to open (a field hidden at this width),
|
|
661
|
+
* which leaves the key to the page.
|
|
662
|
+
*/
|
|
663
|
+
export function useSearchShortcut(open) {
|
|
664
|
+
const latest = useRef(open);
|
|
665
|
+
useEffect(() => {
|
|
666
|
+
latest.current = open;
|
|
667
|
+
});
|
|
668
|
+
useEffect(() => {
|
|
669
|
+
const onKey = (e) => {
|
|
670
|
+
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey)
|
|
671
|
+
return;
|
|
672
|
+
const t = e.target;
|
|
673
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable))
|
|
674
|
+
return;
|
|
675
|
+
if (latest.current() !== false)
|
|
676
|
+
e.preventDefault();
|
|
677
|
+
};
|
|
678
|
+
window.addEventListener('keydown', onKey);
|
|
679
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
680
|
+
}, []);
|
|
681
|
+
}
|
|
682
|
+
/** Focus a search field that is shown: `useSearchShortcut(() => focusShown(ref.current))`. */
|
|
683
|
+
export const focusShown = (input) => {
|
|
684
|
+
// A field hidden at this width has no offsetParent.
|
|
685
|
+
if (!input?.offsetParent)
|
|
686
|
+
return false;
|
|
687
|
+
input.focus();
|
|
688
|
+
return true;
|
|
689
|
+
};
|
|
657
690
|
/**
|
|
658
691
|
* POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
|
|
659
692
|
* third passes its server action instead.
|
|
@@ -880,6 +913,32 @@ const decodePath = (path) => {
|
|
|
880
913
|
return path;
|
|
881
914
|
}
|
|
882
915
|
};
|
|
916
|
+
// ---- maths ------------------------------------------------------------------
|
|
917
|
+
// Here rather than in `dom.ts` because of its stylesheet import: `dom.ts` is
|
|
918
|
+
// reached from server modules (`headings.ts`), and a bundler cannot chunk a
|
|
919
|
+
// CSS import from plain server code. This module is a client one.
|
|
920
|
+
/** What marks maths in stored prose. No single `$`: prose writes $XRD, and two cashtags in one block would read as a formula. */
|
|
921
|
+
const MATH = /\$\$|\\\(|\\\[/;
|
|
922
|
+
/**
|
|
923
|
+
* Typeset the maths under `root` with KaTeX: `$$…$$` and `\[…\]` as a display,
|
|
924
|
+
* `\(…\)` inline. KaTeX and its stylesheet load only when the text holds a
|
|
925
|
+
* delimiter, so a page with no maths fetches nothing. KaTeX is an optional peer
|
|
926
|
+
* the app installs. Typeset maths carries no delimiter, so a second pass finds
|
|
927
|
+
* nothing to do.
|
|
928
|
+
*/
|
|
929
|
+
export async function renderMath(root) {
|
|
930
|
+
if (!MATH.test(root.textContent ?? ''))
|
|
931
|
+
return;
|
|
932
|
+
const [{ default: render }] = await Promise.all([import('katex/contrib/auto-render'), import('katex/dist/katex.min.css')]);
|
|
933
|
+
render(root, {
|
|
934
|
+
delimiters: [
|
|
935
|
+
{ left: '$$', right: '$$', display: true },
|
|
936
|
+
{ left: '\\(', right: '\\)', display: false },
|
|
937
|
+
{ left: '\\[', right: '\\]', display: true },
|
|
938
|
+
],
|
|
939
|
+
throwOnError: false,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
883
942
|
// ---- rendered-article passes --------------------------------------------------
|
|
884
943
|
/**
|
|
885
944
|
* The `wiki-formant/dom` passes over a rendered article: tab groups, copy
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.0",
|
|
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",
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
"import": "./dist/sitemap.js",
|
|
58
58
|
"default": "./dist/sitemap.js"
|
|
59
59
|
},
|
|
60
|
+
"./queues": {
|
|
61
|
+
"types": "./dist/queues.d.ts",
|
|
62
|
+
"import": "./dist/queues.js",
|
|
63
|
+
"default": "./dist/queues.js"
|
|
64
|
+
},
|
|
60
65
|
"./versioning": {
|
|
61
66
|
"types": "./dist/versioning.d.ts",
|
|
62
67
|
"import": "./dist/versioning.js",
|