wiki-formant 0.83.1 → 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 +9 -1
- 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 +9 -0
- package/dist/react.js +33 -0
- 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
|
|
|
@@ -765,6 +765,14 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
|
|
|
765
765
|
|
|
766
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`.
|
|
767
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
|
+
|
|
768
776
|
## Sanitising stored HTML
|
|
769
777
|
|
|
770
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/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
|
/**
|
package/dist/react.js
CHANGED
|
@@ -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.
|
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",
|