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 +7 -7
- package/bin/check-pages.mjs +7 -2
- package/bin/maintenance-log.mjs +2 -2
- package/dist/block-views.js +3 -1
- package/dist/dom.js +3 -1
- package/dist/editor.d.ts +14 -5
- package/dist/editor.js +26 -21
- package/dist/freshness.d.ts +6 -3
- package/dist/freshness.js +9 -4
- package/dist/link-check.d.ts +30 -3
- package/dist/link-check.js +20 -2
- package/dist/maintenance.d.ts +1 -1
- package/dist/maintenance.js +8 -2
- package/dist/queues.js +5 -2
- package/dist/react.d.ts +26 -2
- package/dist/react.js +33 -19
- package/dist/scripts.d.ts +1 -1
- package/dist/scripts.js +2 -2
- package/dist/store.d.ts +8 -0
- package/dist/store.js +7 -7
- package/package.json +1 -1
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
|
|
package/bin/check-pages.mjs
CHANGED
|
@@ -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 } }))
|
|
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
|
-
|
|
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);
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -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 ?? '') && (
|
|
134
|
+
if (PINNED.test(item ?? '') && isOpen(b) && !kept.has(item)) {
|
|
135
135
|
console.log(` flag dropped over the cap: ${item}`);
|
|
136
136
|
}
|
|
137
137
|
}
|
package/dist/block-views.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
171
|
-
uploadImage: (file: File) => Promise<string
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 [
|
|
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,
|
|
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({
|
package/dist/freshness.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 =
|
|
36
|
+
const age = freshnessAge(page, now);
|
|
32
37
|
return age === null || age > maxAgeDays;
|
|
33
38
|
}
|
|
34
39
|
/**
|
package/dist/link-check.d.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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[];
|
package/dist/link-check.js
CHANGED
|
@@ -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:
|
|
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
|
|
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(', ')}`);
|
package/dist/maintenance.d.ts
CHANGED
package/dist/maintenance.js
CHANGED
|
@@ -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
|
-
.
|
|
40
|
-
.
|
|
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 {
|
|
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 =>
|
|
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
|
-
/**
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
357
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|