wiki-formant 0.105.0 → 0.106.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 CHANGED
@@ -40,7 +40,7 @@ const { items, total } = await store.search('validators', { take: 20 });
40
40
  await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.version });
41
41
  ```
42
42
 
43
- - **A save is one statement**: the page update and its revision in a CTE guarded on the version read. A lost race, a stale `baseVersion`, a page deleted since or a rename onto a taken slug writes nothing and throws a 409 `HttpError`.
43
+ - **A save is one statement**: the page update and its revision in a CTE guarded on the version read. A lost race, a stale `baseVersion`, a page deleted since or a rename onto a taken slug writes nothing and throws a 409 `HttpError` whose body names the reason as `conflict`: `stale` and `deleted` mean reload, `taken` means another slug (`ConflictReason`).
44
44
  - **The bump is the diff's**: added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it. A metadata-only save moves no version and writes no revision.
45
45
  - **History is diffed on read.** A revision stores the page as saved. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown has its real predecessor.
46
46
  - **Listings carry a snippet, not the article**: `list`, `recent` and `byIds` drop `content`.
@@ -318,8 +318,8 @@ function LinkPreview() {
318
318
  - `RailShell` renders the rail's landmark, scroll wrapper and collapse states, and closes a mobile drawer on a followed link; mark the active link with `isRailLinkActive`. With `RailShell prefix="wiki-sidebar"` in a `.wiki-layout` beside `.wiki-layout__main`, `base.css` lays it out: a sticky column from 1024px, a drawer above the article below it. The site supplies `--rail-top`, `--rail-border` and the surface.
319
319
  - The breakpoint is known twice, in `matchMedia` and in CSS. `base.css` sets `--rail-floating: 1` inside its media query and the hook warns in development when the two disagree; a rail laid out by its own CSS sets the property the same way. `railBreakpointMismatch` and `readRailFloating` (`wiki-formant/sidebar`) are the mechanism.
320
320
  - `TableOfContents` is the "on this page" list with scroll-spy.
321
- - `useLinkPreview` is the hover card: one delegated listener, an intent delay, a grace period into the card, viewport clamping and a per-href cache. `LinkPreviewCard` is its markup (`link-preview-card`, `-banner`, `-body`, `-title`, `-excerpt`); a caller that must move the card passes its own `style`.
322
- - `useTypeahead` is the search field's state machine: a request-id guard, keyboard navigation, abort and a per-mount cache. `fetch` must be referentially stable. Its `combobox(listKey)` hands out `inputProps`, `listProps` and `optionProps(i)`, with `aria-activedescendant` omitted rather than emptied; `listKey` keeps two rendered lists' ids apart. The arithmetic is `wiki-formant/combobox`, which imports nothing.
321
+ - `useLinkPreview` is the hover card: one delegated listener, an intent delay, a grace period into the card, viewport clamping and a per-href cache of answers; a `fetch` that throws is asked again on the next hover, a null is cached as no card. `LinkPreviewCard` is its markup (`link-preview-card`, `-banner`, `-body`, `-title`, `-excerpt`); a caller that must move the card passes its own `style`.
322
+ - `useTypeahead` is the search field's state machine: a request-id guard, keyboard navigation, abort and a per-mount cache. `results` is `idle`, `searching`, `found` or `failed`, so "no results" is `found` with none and a failed search is never cached; `items` is the rows on screen. `fetch` must be referentially stable. Its `combobox(listKey)` hands out `inputProps`, `listProps` and `optionProps(i)`, with `aria-activedescendant` omitted rather than emptied; `listKey` keeps two rendered lists' ids apart. The arithmetic is `wiki-formant/combobox`, which imports nothing.
323
323
  - `useSnapRow` is a carousel as a scroll-snapped row: `step`, `go(i)` and `at`, no markup.
324
324
  - `useClickOutside` ignores a container hidden at the current breakpoint (`offsetParent`).
325
325
  - `useUnsavedChanges(dirty)` asks before leaving and returns `confirmLeave()` for a client-side navigation; `useSaveShortcut(save)` binds ⌘S / Ctrl+S; `useSearchShortcut(open)` is "/" to search, with `focusShown(input)`.
@@ -585,7 +585,7 @@ const banner = freshnessBanner(page, Date.now(), { against: 'current sources and
585
585
  const queues = maintenanceQueues(pages, { href, required, nowMs, verify, listedIds });
586
586
  ```
587
587
 
588
- - `freshnessBanner` is the `outdated` banner once a page goes unchecked for `maxAgeDays` (180), or null.
588
+ - `freshnessBanner` is the `outdated` banner once a page goes unchecked for `maxAgeDays` (180), or null. `freshnessAge` is the days since a page was verified, or edited if never, and null when it carries neither, which `isStale` counts as stale.
589
589
  - `maintenanceQueues` returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped. Pass every page but the home page and the back office. `MaintenanceQueues` draws them on a `noindex` page.
590
590
  - `citation({ publisher, title, date, url })` (`wiki-formant/react-server`) is the line `Cite` shows.
591
591
 
@@ -669,7 +669,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
669
669
  - `WikiToolbar` shows each command the editor's extensions support (`supports`) and nothing else, in fixed groups, with the table controls while the cursor is in a table.
670
670
  - `useUrlPrompt` is the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.
671
671
  - `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched. Its `style` mark is `wikiTextStyle`: a font, colour and size on a run of words, which an article editor takes through `nodes` and an app's own controls set with `setTextStyle`.
672
- - A link is stored as its `href` and the `link` class only; whether it is external is decided at render by `articleHtml`'s `links` pass. `uploadImageTo('/api/upload')` is the `uploadImage` an editor passes.
672
+ - A link is stored as its `href` and the `link` class only; whether it is external is decided at render by `articleHtml`'s `links` pass. `uploadImageTo('/api/upload')` is the `uploadImage` an editor passes: it resolves the URL or throws the route's reason, and `useWikiEditor`'s `upload` is `idle`, `uploading` or `failed` with that reason for the editor to show.
673
673
 
674
674
  ## Block editor
675
675
 
@@ -805,7 +805,7 @@ import { prismaConfig } from 'wiki-formant/db';
805
805
  export default defineConfig(prismaConfig(import.meta.dirname));
806
806
  ```
807
807
 
808
- `wiki-formant/scripts`: what a seed or sweep script shares: `withClient` (a `pg` client that rolls back on a throw and expires the cache tag after a write), `readPage`, `writeRevision` (refused if the page moved on), `upsertWikiPage`, the block builders `content`, `banner` and `infobox`, and `assertLinkShapes`.
808
+ `wiki-formant/scripts`: what a seed or sweep script shares: `withClient` (a `pg` client that rolls back on a throw and expires the cache tag after a write), `readPage`, `writeRevision` (refused if the page moved on), `upsertWikiPage` (`created`, `updated` or `unchanged`), the block builders `content`, `banner` and `infobox`, and `assertLinkShapes`.
809
809
 
810
810
  ```ts
811
811
  import { withClient, readPage, writeRevision } from 'wiki-formant/scripts';
@@ -815,7 +815,7 @@ await withClient(async client => {
815
815
  }, { tag: 'wiki' });
816
816
  ```
817
817
 
818
- `wiki-formant/link-check`: `auditLinks`, each link once with the pages that carry it, probed with `probeExternal` or the checker's own, and `printLinkAudit`.
818
+ `wiki-formant/link-check`: `auditLinks`, each link once with the pages that carry it, probed with `probeExternal` or the checker's own, and `printLinkAudit`. `verdictOf` reads a probe as `alive`, `dead`, `walled`, `tls` or `unreachable`; only `dead` lands in `brokenExternal`, the rest in `unverifiableExternal`, which a sweep confirms by hand and never strips.
819
819
 
820
820
  `wiki-formant/knip`: the shared knip configuration, spread into each app's `knip.ts`.
821
821
 
@@ -58,7 +58,7 @@ const get = async url => {
58
58
 
59
59
  // The sitemap is the URL list.
60
60
  async function sitemapUrls(origin) {
61
- const seen = (await readSitemap(`${origin}/sitemap.xml`, url => fetch(url, { headers: { 'user-agent': UA } })).catch(() => [])).map(e => e.url);
61
+ const seen = (await readSitemap(`${origin}/sitemap.xml`, url => fetch(url, { headers: { 'user-agent': UA } }))).map(e => e.url);
62
62
  // A sitemap states absolute production URLs by definition, so pointing
63
63
  // --site at a dev server would otherwise crawl production and report the
64
64
  // build you were trying to replace. The path is what the sitemap contributes;
@@ -82,7 +82,12 @@ const descriptionOf = html => {
82
82
  return m ? (m[0].match(/content=["']([\s\S]*?)["']/i)?.[1] ?? '').trim() : null;
83
83
  };
84
84
 
85
- const urls = await sitemapUrls(site);
85
+ // A sitemap that does not answer is a fault, not an empty site: read as no
86
+ // URLs, a down sitemap passed the gate it exists to hold.
87
+ const urls = await sitemapUrls(site).catch(e => {
88
+ console.error(`check-pages: ${site}/sitemap.xml could not be read: ${e.message}`);
89
+ process.exit(WARN_ONLY ? 0 : 1);
90
+ });
86
91
  if (!urls.length) {
87
92
  console.log(`check-pages: no URLs in ${site}/sitemap.xml — nothing to check. Skipping.`);
88
93
  process.exit(0);
@@ -31,7 +31,7 @@
31
31
  // the item back in, so a run's whole-state write cannot drop it. Every mode
32
32
  // reads and writes under the row lock. Only `state` in the page's metadata is
33
33
  // written; other keys are kept. A revision bumps the page's version a minor.
34
- import { PINNED, addBacklog, compact, lateBacklog, render, staleWrite } from '../dist/maintenance.js';
34
+ import { PINNED, addBacklog, compact, isOpen, lateBacklog, render, staleWrite } from '../dist/maintenance.js';
35
35
  import { bump } from '../dist/versioning.js';
36
36
  import { arg, connect, invalidate } from './_cli.mjs';
37
37
 
@@ -131,7 +131,7 @@ try {
131
131
  const kept = new Set(state.backlog.map(b => b.item));
132
132
  for (const b of input.backlog ?? []) {
133
133
  const item = typeof b === 'string' ? b : b.item;
134
- if (PINNED.test(item ?? '') && (typeof b === 'string' || (b.status ?? 'open') === 'open') && !kept.has(item)) {
134
+ if (PINNED.test(item ?? '') && isOpen(b) && !kept.has(item)) {
135
135
  console.log(` flag dropped over the cap: ${item}`);
136
136
  }
137
137
  }
@@ -32,9 +32,11 @@ const line = (html) => ({ dangerouslySetInnerHTML: { __html: html } });
32
32
  * whatever the consumer's highlighter attached to it on every tab press.
33
33
  */
34
34
  export function CodeTabsView({ tabs, highlighted = false }) {
35
- const [activeTab, setActiveTab] = useState(0);
35
+ const [picked, setActiveTab] = useState(0);
36
36
  if (!tabs.length)
37
37
  return null;
38
+ // Clamped, so tabs removed under the selection leave the last one showing.
39
+ const activeTab = Math.min(picked, tabs.length - 1);
38
40
  return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => highlighted ? (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, dangerouslySetInnerHTML: { __html: tab.code } }, i)) : (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, children: _jsx("pre", { children: _jsx("code", { className: tab.language ? `language-${tab.language}` : undefined, children: tab.code }) }) }, i)))] }));
39
41
  }
40
42
  /**
package/dist/dom.js CHANGED
@@ -35,13 +35,15 @@ export function addCopyButtons(root, options = {}) {
35
35
  btn.className = className;
36
36
  btn.setAttribute('aria-label', label);
37
37
  btn.innerHTML = COPY_SVG;
38
+ let revert;
38
39
  btn.onclick = () => {
39
40
  const code = pre.querySelector('code')?.textContent || pre.textContent || '';
40
41
  // A rejected clipboard write (permissions, insecure origin) must not
41
42
  // leave an unhandled rejection behind — the button simply does nothing.
42
43
  navigator.clipboard.writeText(code).then(() => {
43
44
  btn.innerHTML = CHECK_SVG;
44
- setTimeout(() => {
45
+ clearTimeout(revert);
46
+ revert = setTimeout(() => {
45
47
  btn.innerHTML = COPY_SVG;
46
48
  }, revertAfter);
47
49
  }, () => { });
package/dist/editor.d.ts CHANGED
@@ -157,9 +157,18 @@ export declare function useUrlPrompt(editor: Editor | null, options: {
157
157
  }): UrlPromptState;
158
158
  /**
159
159
  * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
160
- * back. Two editors carried it character for character, `alert` included.
160
+ * back, throwing the route's own reason when there is none.
161
161
  */
162
- export declare function uploadImageTo(endpoint: string): (file: File) => Promise<string | null>;
162
+ export declare function uploadImageTo(endpoint: string): (file: File) => Promise<string>;
163
+ /** An upload in the editor: the failure is the editor's to say, beside the field, not an `alert`. */
164
+ export type UploadState = {
165
+ status: 'idle';
166
+ } | {
167
+ status: 'uploading';
168
+ } | {
169
+ status: 'failed';
170
+ error: string;
171
+ };
163
172
  export interface WikiEditorOptions {
164
173
  value: string;
165
174
  onChange: (html: string) => void;
@@ -167,15 +176,15 @@ export interface WikiEditorOptions {
167
176
  nodes?: readonly AnyExtension[];
168
177
  /** The prose class the editor body renders under. */
169
178
  proseClass: string;
170
- /** Upload a chosen file, returning its URL or null. The route is the app's. */
171
- uploadImage: (file: File) => Promise<string | null>;
179
+ /** Upload a chosen file and resolve its URL; throw with the reason it failed. The route is the app's. */
180
+ uploadImage: (file: File) => Promise<string>;
172
181
  /** Debounce before `onChange` fires, in ms. */
173
182
  debounceMs?: number;
174
183
  }
175
184
  export interface WikiEditorState {
176
185
  editor: Editor | null;
177
186
  fileInputRef: RefObject<HTMLInputElement | null>;
178
- isUploading: boolean;
187
+ upload: UploadState;
179
188
  handleFileChange: (e: ChangeEvent<HTMLInputElement>) => Promise<void>;
180
189
  /** Open the file picker — what an upload toolbar button calls. */
181
190
  triggerUpload: () => void;
package/dist/editor.js CHANGED
@@ -395,34 +395,30 @@ export function useUrlPrompt(editor, options) {
395
395
  // ---- the editor -------------------------------------------------------------
396
396
  /**
397
397
  * An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
398
- * back. Two editors carried it character for character, `alert` included.
398
+ * back, throwing the route's own reason when there is none.
399
399
  */
400
400
  export function uploadImageTo(endpoint) {
401
401
  return async (file) => {
402
402
  const form = new FormData();
403
403
  form.append('file', file);
404
- try {
405
- const res = await fetch(endpoint, { method: 'POST', body: form });
406
- const body = (await res.json());
407
- if (!res.ok) {
408
- alert(body.error || 'Upload failed');
409
- return null;
410
- }
411
- return body.url ?? null;
412
- }
413
- catch {
414
- alert('Upload failed');
415
- return null;
416
- }
404
+ const res = await fetch(endpoint, { method: 'POST', body: form }).catch(() => {
405
+ throw new Error('The upload did not reach the server. Try again.');
406
+ });
407
+ const body = (await res.json().catch(() => ({})));
408
+ if (!res.ok || !body.url)
409
+ throw new Error(body.error || 'The upload failed. Try again.');
410
+ return body.url;
417
411
  };
418
412
  }
413
+ const UPLOAD_IDLE = { status: 'idle' };
419
414
  /**
420
415
  * The editor and its upload plumbing. `WikiToolbar` given the editor draws
421
416
  * the buttons its extensions allow; the icons and classes are the app's.
422
417
  */
423
418
  export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], proseClass, uploadImage, debounceMs = 150, }) {
424
419
  const fileInputRef = useRef(null);
425
- const [isUploading, setIsUploading] = useState(false);
420
+ const [upload, setUpload] = useState(UPLOAD_IDLE);
421
+ const uploadAttempt = useRef(0);
426
422
  const initialValueRef = useRef(value);
427
423
  const onChangeRef = useRef(onChange);
428
424
  const debounceRef = useRef(undefined);
@@ -481,17 +477,26 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
481
477
  const file = e.target.files?.[0];
482
478
  if (!file || !editor)
483
479
  return;
484
- setIsUploading(true);
485
- const url = await uploadImage(file);
486
- if (url)
487
- editor.chain().focus().setImage({ src: url }).run();
488
- setIsUploading(false);
489
480
  // Cleared so choosing the same file twice in a row still fires a change.
490
481
  if (fileInputRef.current)
491
482
  fileInputRef.current.value = '';
483
+ // The state belongs to the latest upload: an earlier one landing late
484
+ // still places its picture but does not speak over the one in flight.
485
+ const attempt = ++uploadAttempt.current;
486
+ setUpload({ status: 'uploading' });
487
+ try {
488
+ const url = await uploadImage(file);
489
+ editor.chain().focus().setImage({ src: url }).run();
490
+ if (attempt === uploadAttempt.current)
491
+ setUpload(UPLOAD_IDLE);
492
+ }
493
+ catch (err) {
494
+ if (attempt === uploadAttempt.current)
495
+ setUpload({ status: 'failed', error: err instanceof Error ? err.message : 'The upload failed. Try again.' });
496
+ }
492
497
  }, [editor, uploadImage]);
493
498
  const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
494
- return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload };
499
+ return { editor, fileInputRef, upload, handleFileChange, triggerUpload };
495
500
  }
496
501
  // A line's whole document is the line, so Enter has nowhere to go.
497
502
  const LineDoc = TiptapNode.create({
@@ -7,10 +7,13 @@ export interface FreshnessInput {
7
7
  /** Whole days between `date` and `now`, or null when there is no usable date. */
8
8
  export declare function daysSince(date: Date | string | null | undefined, now: number): number | null;
9
9
  /**
10
- * Stale when the page has never been verified, or was verified longer ago than
11
- * `maxAgeDays`. Falls back to `updatedAt` when `lastVerifiedAt` is unset: an
12
- * edit is a weaker signal than a verification, but it is a signal.
10
+ * Days since the page was last checked: since its verification, or, unverified,
11
+ * since its last edit, a weaker signal but a signal. Null when it carries
12
+ * neither, which is never fresh; a caller that read it as 0 called an undated
13
+ * page new.
13
14
  */
15
+ export declare function freshnessAge(page: FreshnessInput, now: number): number | null;
16
+ /** Stale when undated, or checked longer ago than `maxAgeDays`. */
14
17
  export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?: number): boolean;
15
18
  export interface FreshnessOptions {
16
19
  /** Older than this, unverified, and the page is stale. */
package/dist/freshness.js CHANGED
@@ -23,12 +23,17 @@ export function daysSince(date, now) {
23
23
  return Math.floor((now - t) / DAY_MS);
24
24
  }
25
25
  /**
26
- * Stale when the page has never been verified, or was verified longer ago than
27
- * `maxAgeDays`. Falls back to `updatedAt` when `lastVerifiedAt` is unset: an
28
- * edit is a weaker signal than a verification, but it is a signal.
26
+ * Days since the page was last checked: since its verification, or, unverified,
27
+ * since its last edit, a weaker signal but a signal. Null when it carries
28
+ * neither, which is never fresh; a caller that read it as 0 called an undated
29
+ * page new.
29
30
  */
31
+ export function freshnessAge(page, now) {
32
+ return daysSince(page.lastVerifiedAt ?? page.updatedAt, now);
33
+ }
34
+ /** Stale when undated, or checked longer ago than `maxAgeDays`. */
30
35
  export function isStale(page, now, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
31
- const age = daysSince(page.lastVerifiedAt ?? page.updatedAt, now);
36
+ const age = freshnessAge(page, now);
32
37
  return age === null || age > maxAgeDays;
33
38
  }
34
39
  /**
@@ -15,6 +15,28 @@ export interface Probe {
15
15
  */
16
16
  walled?: boolean;
17
17
  }
18
+ /**
19
+ * What a probe says about a link. Only `dead` is grounds to touch a citation:
20
+ * a wall, a lapsed certificate and a host that did not answer in time are
21
+ * unverifiable, and a sweep that read them off `!ok` stripped good citations.
22
+ */
23
+ export type LinkVerdict = {
24
+ kind: 'alive';
25
+ } | {
26
+ kind: 'dead';
27
+ status: number;
28
+ } | {
29
+ kind: 'walled';
30
+ status: number;
31
+ } | {
32
+ kind: 'tls';
33
+ code: string;
34
+ } | {
35
+ kind: 'unreachable';
36
+ error: string;
37
+ };
38
+ /** The one reading of a probe. A name that does not resolve is dead; any other failure to answer is not. */
39
+ export declare function verdictOf(probe: Probe): LinkVerdict;
18
40
  export interface ProbeOptions {
19
41
  /** Ordinary per-attempt budget. */
20
42
  timeoutMs?: number;
@@ -94,11 +116,16 @@ export interface AuditOptions<B extends {
94
116
  concurrency?: number;
95
117
  containers?: (block: B) => B[][] | null;
96
118
  }
119
+ export type AuditedLink = Probe & {
120
+ verdict: LinkVerdict;
121
+ pages: string[];
122
+ };
97
123
  export interface LinkAudit {
98
124
  externalChecked: number;
99
- brokenExternal: Array<Probe & {
100
- pages: string[];
101
- }>;
125
+ /** Links whose verdict is `dead`. */
126
+ brokenExternal: AuditedLink[];
127
+ /** Walled, TLS-failed or unanswered: confirm by hand, never strip. */
128
+ unverifiableExternal: AuditedLink[];
102
129
  brokenInternal: Array<{
103
130
  url: string;
104
131
  pages: string[];
@@ -13,6 +13,18 @@
13
13
  import { stripTags } from './html.js';
14
14
  import { decodeEntities } from './markdown.js';
15
15
  import { coreBlockShape, leafBlocks } from './blocks.js';
16
+ /** The one reading of a probe. A name that does not resolve is dead; any other failure to answer is not. */
17
+ export function verdictOf(probe) {
18
+ if (probe.ok)
19
+ return { kind: 'alive' };
20
+ if (probe.walled)
21
+ return { kind: 'walled', status: probe.status };
22
+ if (probe.tls)
23
+ return { kind: 'tls', code: probe.tls };
24
+ if (probe.status === 0 && probe.code !== 'ENOTFOUND')
25
+ return { kind: 'unreachable', error: probe.error ?? probe.code ?? 'no answer' };
26
+ return { kind: 'dead', status: probe.status };
27
+ }
16
28
  const DEFAULTS = { timeoutMs: 12_000, slowTimeoutMs: 40_000 };
17
29
  /**
18
30
  * TLS-verification failures are NOT death.
@@ -368,9 +380,11 @@ export async function auditLinks(pages, opts) {
368
380
  }
369
381
  const urls = [...external.keys()];
370
382
  const results = await mapLimit(urls, concurrency, url => probe(url));
383
+ const audited = results.map(r => ({ ...r, verdict: verdictOf(r), pages: external.get(r.url) }));
371
384
  return {
372
385
  externalChecked: urls.length,
373
- brokenExternal: results.filter(r => !r.ok).map(r => ({ ...r, pages: external.get(r.url) })),
386
+ brokenExternal: audited.filter(r => r.verdict.kind === 'dead'),
387
+ unverifiableExternal: audited.filter(r => r.verdict.kind !== 'dead' && r.verdict.kind !== 'alive'),
374
388
  brokenInternal: [...internalLinks.entries()]
375
389
  .filter(([u]) => {
376
390
  const p = u.split('?')[0];
@@ -381,13 +395,17 @@ export async function auditLinks(pages, opts) {
381
395
  skipped,
382
396
  };
383
397
  }
398
+ const verdictLabel = (v) => v.kind === 'walled' ? `walled ${v.status}` : v.kind === 'tls' ? `tls ${v.code}` : v.kind === 'unreachable' ? v.error : v.kind;
384
399
  /** The human report's first lines: what was checked, and what is broken. */
385
400
  export function printLinkAudit(audit, { scope = '(entire wiki)', pagesScanned }) {
386
401
  console.log(`\nLink check — scope: ${scope}`);
387
402
  console.log(`Pages scanned: ${pagesScanned} | external links checked: ${audit.externalChecked}`);
388
403
  console.log(`\nBroken external (${audit.brokenExternal.length}):`);
389
404
  for (const b of audit.brokenExternal)
390
- console.log(` [${b.status || b.code || b.error}] ${b.url}\n ← ${b.pages.join(', ')}`);
405
+ console.log(` [${b.status || b.code}] ${b.url}\n ← ${b.pages.join(', ')}`);
406
+ console.log(`\nUnverifiable external — confirm by hand, never strip (${audit.unverifiableExternal.length}):`);
407
+ for (const b of audit.unverifiableExternal)
408
+ console.log(` [${verdictLabel(b.verdict)}] ${b.url}\n ← ${b.pages.join(', ')}`);
391
409
  console.log(`\nBroken internal (${audit.brokenInternal.length}):`);
392
410
  for (const b of audit.brokenInternal)
393
411
  console.log(` ${b.url}\n ← ${b.pages.join(', ')}`);
@@ -11,7 +11,7 @@ interface SourceEntry {
11
11
  export type BacklogEntry = string | {
12
12
  date?: string;
13
13
  item: string;
14
- status?: string;
14
+ status?: string | null;
15
15
  };
16
16
  type FeedbackEntry = string | {
17
17
  date?: string;
@@ -31,13 +31,19 @@ const JOURNAL = /^run\s+\d+\b/i;
31
31
  export const PINNED = /FLAG FOR A HUMAN/i;
32
32
  const BANKED = /\bbanked\b|bot[- ]?(wall|block)|\b(403|429|999)\b|cloudflare/i;
33
33
  const itemOf = (b) => (typeof b === 'string' ? b : b.item);
34
+ /**
35
+ * @internal The one test of open, which the bin shares: a bare string, or no
36
+ * status, is open; `compact` deletes anything else for good.
37
+ */
38
+ export const isOpen = (b) => typeof b === 'string' || (b.status ?? 'open') === 'open';
34
39
  const recency = (a, b) => String(b[1]?.lastChecked ?? '').localeCompare(String(a[1]?.lastChecked ?? ''));
35
40
  /** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
36
41
  export function compact(state) {
37
42
  // Undated items fall back to append order: a later index is newer.
38
43
  const open = (state.backlog ?? [])
39
- .map((b, i) => ({ status: 'open', ...(typeof b === 'string' ? { date: '', item: b } : b), i }))
40
- .filter(b => b.status === 'open' && b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
44
+ .filter(isOpen)
45
+ .map((b, i) => ({ ...(typeof b === 'string' ? { date: '', item: b } : b), status: 'open', i }))
46
+ .filter(b => b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
41
47
  .sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')) || b.i - a.i);
42
48
  const backlog = [...open.filter(b => PINNED.test(b.item)).slice(0, MAX.flags), ...open.filter(b => !PINNED.test(b.item))]
43
49
  .slice(0, MAX.backlog)
package/dist/queues.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // radix-wiki wrote it over its own Prisma rows; caper, acuiq2 and miow's wikis
13
13
  // had none. What differs is how a page is addressed and which facts its
14
14
  // category requires, so those are the arguments.
15
- import { daysSince, isStale, DEFAULT_MAX_AGE_DAYS } from './freshness.js';
15
+ import { freshnessAge, isStale, DEFAULT_MAX_AGE_DAYS } from './freshness.js';
16
16
  import { metaValue } from './taxonomy.js';
17
17
  // An href inside stored JSON, its quotes escaped or not.
18
18
  const HREF = /href=\\?"([^"\\?#]+)/g;
@@ -66,7 +66,10 @@ export function maintenanceQueues(pages, o) {
66
66
  description: `Not checked or changed in ${maxAgeDays} days. Re-check the facts against current sources${o.verify ? `, then ${o.verify}` : ''}.`,
67
67
  items: pages
68
68
  .filter(p => isStale(p, o.nowMs, maxAgeDays))
69
- .map(p => item(p, `${daysSince(p.lastVerifiedAt ?? p.updatedAt, o.nowMs)} days`)),
69
+ .map(p => {
70
+ const age = freshnessAge(p, o.nowMs);
71
+ return item(p, age === null ? 'never dated' : `${age} days`);
72
+ }),
70
73
  },
71
74
  {
72
75
  key: 'orphaned',
package/dist/react.d.ts CHANGED
@@ -132,14 +132,34 @@ export interface TypeaheadOptions<T> {
132
132
  /** Remember results per query for this mount. */
133
133
  cache?: boolean;
134
134
  }
135
+ /**
136
+ * What the list is answering. A failed search is its own state, never an empty
137
+ * list: "no pages found" for a 429 tells the reader the page does not exist.
138
+ * `searching` keeps the last answer's items on screen until this one lands.
139
+ */
140
+ export type TypeaheadResults<T> = {
141
+ status: 'idle';
142
+ } | {
143
+ status: 'searching';
144
+ query: string;
145
+ items: T[];
146
+ } | {
147
+ status: 'found';
148
+ query: string;
149
+ items: T[];
150
+ } | {
151
+ status: 'failed';
152
+ query: string;
153
+ };
135
154
  export interface TypeaheadState<T> {
136
155
  query: string;
137
156
  setQuery: (query: string) => void;
138
157
  debouncedQuery: string;
158
+ results: TypeaheadResults<T>;
159
+ /** The rows on screen: `results.items` where it has any, else none. */
139
160
  items: T[];
140
161
  highlight: number;
141
162
  setHighlight: (index: number) => void;
142
- isSearching: boolean;
143
163
  onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
144
164
  /** Drop the results, keep the query. For an outside click. */
145
165
  clearItems: () => void;
@@ -181,7 +201,11 @@ export interface LinkPreviewOptions<T> {
181
201
  * is exactly the part that differs — so the whole predicate is yours.
182
202
  */
183
203
  eligible: (anchor: HTMLAnchorElement) => boolean;
184
- /** Resolve a preview for a href, or null for "no card". REST or server action. */
204
+ /**
205
+ * Resolve a preview for a href, or null for "no card". REST or server action.
206
+ * Throw when the answer could not be read (a 429, a 5xx): null is cached for
207
+ * the visit, a throw is asked again on the next hover.
208
+ */
185
209
  fetch: (href: string) => Promise<T | null>;
186
210
  /** Must match the class on the element you spread `cardProps` onto. */
187
211
  cardClassName?: string;
package/dist/react.js CHANGED
@@ -293,6 +293,9 @@ export function useClickOutside(onClose) {
293
293
  }, [onClose]);
294
294
  return ref;
295
295
  }
296
+ // ---- typeahead --------------------------------------------------------------
297
+ const TYPEAHEAD_IDLE = { status: 'idle' };
298
+ const NO_ITEMS = [];
296
299
  /**
297
300
  * The debounced-search state machine behind every typeahead on these sites.
298
301
  *
@@ -314,11 +317,11 @@ export function useClickOutside(onClose) {
314
317
  export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength = 1, cache = true, }) {
315
318
  const [query, setQuery] = useState('');
316
319
  const [debounced, setDebounced] = useState('');
317
- const [items, setItems] = useState([]);
320
+ const [results, setResults] = useState(TYPEAHEAD_IDLE);
318
321
  const [highlight, setHighlight] = useState(0);
319
- const [isSearching, setIsSearching] = useState(false);
320
322
  const requestId = useRef(0);
321
323
  const cached = useRef(new Map());
324
+ const items = results.status === 'searching' || results.status === 'found' ? results.items : NO_ITEMS;
322
325
  useEffect(() => {
323
326
  const timer = setTimeout(() => setDebounced(query), delay);
324
327
  return () => clearTimeout(timer);
@@ -329,36 +332,32 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
329
332
  // Bump the id so an in-flight request for a longer query cannot land
330
333
  // after the field has been cleared.
331
334
  requestId.current++;
332
- setItems([]);
333
- setIsSearching(false);
335
+ setResults(TYPEAHEAD_IDLE);
334
336
  return;
335
337
  }
336
338
  const hit = cache ? cached.current.get(q) : undefined;
337
339
  if (hit) {
338
340
  requestId.current++;
339
- setItems(hit);
341
+ setResults({ status: 'found', query: q, items: hit });
340
342
  setHighlight(0);
341
- setIsSearching(false);
342
343
  return;
343
344
  }
344
345
  const id = ++requestId.current;
345
346
  const controller = new AbortController();
346
- setIsSearching(true);
347
+ setResults(r => ({ status: 'searching', query: q, items: r.status === 'searching' || r.status === 'found' ? r.items : NO_ITEMS }));
347
348
  fetch(q, controller.signal).then(rows => {
348
349
  if (id !== requestId.current)
349
350
  return;
350
351
  if (cache)
351
352
  cached.current.set(q, rows);
352
- setItems(rows);
353
+ setResults({ status: 'found', query: q, items: rows });
353
354
  setHighlight(0);
354
- setIsSearching(false);
355
355
  }, () => {
356
- // An abort is the expected path for every superseded query, so it must
357
- // not surface as an error. A real failure reads as "no results".
356
+ // An abort is the expected path for every superseded query, and the id
357
+ // check drops it. A real failure is never cached, so asking again retries.
358
358
  if (id !== requestId.current)
359
359
  return;
360
- setItems([]);
361
- setIsSearching(false);
360
+ setResults({ status: 'failed', query: q });
362
361
  });
363
362
  return () => controller.abort();
364
363
  }, [debounced, fetch, minLength, cache]);
@@ -366,16 +365,14 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
366
365
  // bring back the list the reader just closed.
367
366
  const clearItems = useCallback(() => {
368
367
  requestId.current++;
369
- setItems([]);
370
- setIsSearching(false);
368
+ setResults(TYPEAHEAD_IDLE);
371
369
  }, []);
372
370
  const reset = useCallback(() => {
373
371
  requestId.current++;
374
372
  setQuery('');
375
373
  setDebounced('');
376
- setItems([]);
374
+ setResults(TYPEAHEAD_IDLE);
377
375
  setHighlight(0);
378
- setIsSearching(false);
379
376
  }, []);
380
377
  const onKeyDown = (e) => {
381
378
  if (e.key === 'Escape') {
@@ -394,6 +391,10 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
394
391
  setHighlight(h => (h - 1 + items.length) % items.length);
395
392
  }
396
393
  else if (e.key === 'Enter') {
394
+ // The list answers the last settled query. Typed past it, Enter falls
395
+ // through to the form, so it submits what was typed, not a stale pick.
396
+ if (query !== debounced || results.status !== 'found')
397
+ return;
397
398
  e.preventDefault();
398
399
  const target = items[highlight];
399
400
  if (target)
@@ -408,10 +409,10 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
408
409
  query,
409
410
  setQuery,
410
411
  debouncedQuery: debounced,
412
+ results,
411
413
  items,
412
414
  highlight,
413
415
  setHighlight,
414
- isSearching,
415
416
  onKeyDown,
416
417
  clearItems,
417
418
  reset,
@@ -463,16 +464,22 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
463
464
  clearTimeout(hideTimer.current);
464
465
  return;
465
466
  }
467
+ // A new link retires the last one's card at once: the grace is for
468
+ // crossing into that card, and the cursor has gone somewhere else.
466
469
  clearTimers();
470
+ setCard(null);
467
471
  activeHref.current = href;
468
472
  showTimer.current = setTimeout(async () => {
469
473
  let data = cache.current.get(href);
470
474
  if (data === undefined) {
475
+ // Only an answer is cached. A failed fetch shows no card this time and
476
+ // is asked again on the next hover, where caching it as "no preview"
477
+ // hid that link's card for the rest of the visit.
471
478
  try {
472
479
  data = await opts.current.fetch(href);
473
480
  }
474
481
  catch {
475
- data = null;
482
+ return;
476
483
  }
477
484
  cache.current.set(href, data);
478
485
  }
@@ -754,11 +761,17 @@ export function restoreViaPost(endpoint) {
754
761
  export function useRevisionRestore(restore, options) {
755
762
  const [busyId, setBusyId] = useState(null);
756
763
  const [error, setError] = useState(null);
764
+ // One restore at a time: a second, started while the first is in flight,
765
+ // would have its "Restoring…" cleared by the first one finishing.
766
+ const inFlight = useRef(false);
757
767
  const { onRestored, confirm = (label) => `Restore ${label}? The current state stays in the history.` } = options;
758
768
  const run = useCallback(async (id, label) => {
769
+ if (inFlight.current)
770
+ return;
759
771
  const question = confirm(label);
760
772
  if (question && !window.confirm(question))
761
773
  return;
774
+ inFlight.current = true;
762
775
  setBusyId(id);
763
776
  setError(null);
764
777
  try {
@@ -769,6 +782,7 @@ export function useRevisionRestore(restore, options) {
769
782
  onRestored();
770
783
  }
771
784
  finally {
785
+ inFlight.current = false;
772
786
  setBusyId(null);
773
787
  }
774
788
  }, [restore, onRestored, confirm]);
package/dist/scripts.d.ts CHANGED
@@ -92,5 +92,5 @@ export declare function writeRevision(client: ScriptClient, page: WikiPageRow<An
92
92
  export declare function upsertWikiPage(client: ScriptClient, input: SaveInput<AnyBlock> & {
93
93
  content: AnyBlock[];
94
94
  siteId?: string;
95
- }): Promise<'created' | 'updated'>;
95
+ }): Promise<'created' | 'updated' | 'unchanged'>;
96
96
  export {};
package/dist/scripts.js CHANGED
@@ -139,6 +139,6 @@ export async function writeRevision(client, page, blocks, { change = 'minor', me
139
139
  export async function upsertWikiPage(client, input) {
140
140
  assertLinkShapes(input.content, `${input.tagPath}/${input.slug}`);
141
141
  const { siteId, ...save } = input;
142
- const { created } = await storeFor(client, siteId).save(save);
143
- return created ? 'created' : 'updated';
142
+ const { created, changeType } = await storeFor(client, siteId).save(save);
143
+ return created ? 'created' : changeType === 'none' ? 'unchanged' : 'updated';
144
144
  }
package/dist/store.d.ts CHANGED
@@ -112,6 +112,14 @@ export declare function toListing<R extends {
112
112
  }>(row: R): Omit<R, 'content'> & {
113
113
  snippet: string;
114
114
  };
115
+ /**
116
+ * Why a save was refused, carried in the 409 body as `conflict`. `stale` and
117
+ * `deleted` mean reload: the page moved on, so saving again cannot succeed.
118
+ * `taken` means pick another slug, which reloading does not fix. A client that
119
+ * guessed this from its own state filed a rename of a stale page as a slug
120
+ * clash and kept offering Save for a 409 every retry would meet again.
121
+ */
122
+ export type ConflictReason = 'stale' | 'deleted' | 'taken';
115
123
  /** Re-order rows to match an id list, dropping ids that no longer resolve. */
116
124
  export declare function orderByIds<T extends {
117
125
  id: string;
package/dist/store.js CHANGED
@@ -34,7 +34,7 @@ export function toListing(row) {
34
34
  const { content, ...rest } = row;
35
35
  return { ...rest, snippet: describe(openingText(content)) };
36
36
  }
37
- const conflict = (message) => new HttpError(409, message);
37
+ const conflict = (reason, message) => new HttpError(409, message, { conflict: reason });
38
38
  const SLUG_TAKEN = 'A page with that slug already exists in this category';
39
39
  /** Re-order rows to match an id list, dropping ids that no longer resolve. */
40
40
  export function orderByIds(rows, ids) {
@@ -220,7 +220,7 @@ export function wikiStore(o) {
220
220
  )
221
221
  SELECT * FROM up`, site, input.tagPath, input.slug, input.title, JSON.stringify(input.content), JSON.stringify(input.metadata ?? {}), INITIAL_VERSION, input.authorId ?? null, input.verified === true, input.message ?? 'Initial version');
222
222
  if (!page)
223
- throw conflict('A page at that path was created while this one was being written');
223
+ throw conflict('taken', 'A page at that path was created while this one was being written');
224
224
  return { page, created: true, changeType: 'major', summary: 'page created' };
225
225
  };
226
226
  /**
@@ -232,17 +232,17 @@ export function wikiStore(o) {
232
232
  const save = async (input) => {
233
233
  const existing = await get(input.tagPath, input.slug);
234
234
  if (!existing && input.baseVersion !== undefined) {
235
- throw conflict(`This page was deleted since it was opened at v${input.baseVersion}. Saving now would create it again; reload to decide.`);
235
+ throw conflict('deleted', `This page was deleted since it was opened at v${input.baseVersion}. Saving now would create it again; reload to decide.`);
236
236
  }
237
237
  if (!existing)
238
238
  return create(input);
239
239
  if (input.baseVersion !== undefined && input.baseVersion !== existing.version) {
240
- 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.`);
240
+ throw conflict('stale', `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.`);
241
241
  }
242
242
  const slug = input.newSlug && input.newSlug !== existing.slug ? input.newSlug : existing.slug;
243
243
  const slugTaken = async () => slug !== existing.slug && (await get(existing.tagPath, slug)) !== null;
244
244
  if (await slugTaken())
245
- throw conflict(SLUG_TAKEN);
245
+ throw conflict('taken', SLUG_TAKEN);
246
246
  const title = input.title ?? existing.title;
247
247
  const content = input.content ?? existing.content;
248
248
  const diff = computeRevisionDiff({
@@ -269,11 +269,11 @@ export function wikiStore(o) {
269
269
  // A page that took the slug after the check above fails the unique index.
270
270
  // Asked again rather than read off the error, whose shape is the driver's.
271
271
  if (await slugTaken())
272
- throw conflict(SLUG_TAKEN);
272
+ throw conflict('taken', SLUG_TAKEN);
273
273
  throw err;
274
274
  });
275
275
  if (!page)
276
- throw conflict('This page was saved by someone else while this edit was being written. Reload it and apply the edit again.');
276
+ throw conflict('stale', 'This page was saved by someone else while this edit was being written. Reload it and apply the edit again.');
277
277
  return { page, created: false, changeType, summary: diff.summary };
278
278
  };
279
279
  /** Put a page back as a revision had it, as a major change of its own. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.105.0",
3
+ "version": "0.106.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",