wiki-formant 0.105.1 → 0.107.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 +8 -7
- package/bin/check-pages.mjs +7 -2
- package/dist/editor.d.ts +14 -5
- package/dist/editor.js +25 -24
- 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/metadata.d.ts +24 -1
- package/dist/metadata.js +24 -2
- package/dist/queues.js +5 -2
- package/dist/react.d.ts +26 -2
- package/dist/react.js +20 -20
- 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)`.
|
|
@@ -445,6 +445,7 @@ export const generateMetadata = () => ({
|
|
|
445
445
|
```
|
|
446
446
|
|
|
447
447
|
- Next replaces `openGraph` and `twitter` per segment rather than merging, so `pageMetadata` sets both; `sitePageMetadata({ siteName, handle })` binds the fields every page's card needs.
|
|
448
|
+
- `sitePageMetadata({ ..., card: `${SITE_URL}/og` })` binds the site's card route, and a page then passes `card: { title, eyebrow, description, banner }` in place of `image`. `cardUrl(base, fields)` is the same address for JSON-LD: empty fields left out, the order fixed, so one card has one URL.
|
|
448
449
|
- `articleLd` requires `image`. `collectionLd` is an index over an `ItemList`. `siteGraphLd` is the Organization, WebSite and `WebAPI` `@graph` for the root layout, and `siteRefs(url)` hands out the same `@id`s for a page's `publisher` and `isPartOf`.
|
|
449
450
|
- `questionPairs(html)` reads a FAQ from headings ending in `?`; `faqPageLd(pairs, url)` is null under two pairs.
|
|
450
451
|
- `describe(text)` is a meta description from any prose within `DESCRIPTION_LIMIT` (160): whole sentences where they fill half, else cut at a word with an ellipsis.
|
|
@@ -585,7 +586,7 @@ const banner = freshnessBanner(page, Date.now(), { against: 'current sources and
|
|
|
585
586
|
const queues = maintenanceQueues(pages, { href, required, nowMs, verify, listedIds });
|
|
586
587
|
```
|
|
587
588
|
|
|
588
|
-
- `freshnessBanner` is the `outdated` banner once a page goes unchecked for `maxAgeDays` (180), or null.
|
|
589
|
+
- `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
590
|
- `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
591
|
- `citation({ publisher, title, date, url })` (`wiki-formant/react-server`) is the line `Cite` shows.
|
|
591
592
|
|
|
@@ -669,7 +670,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
|
|
|
669
670
|
- `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
671
|
- `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
672
|
- `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.
|
|
673
|
+
- 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
674
|
|
|
674
675
|
## Block editor
|
|
675
676
|
|
|
@@ -805,7 +806,7 @@ import { prismaConfig } from 'wiki-formant/db';
|
|
|
805
806
|
export default defineConfig(prismaConfig(import.meta.dirname));
|
|
806
807
|
```
|
|
807
808
|
|
|
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`.
|
|
809
|
+
`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
810
|
|
|
810
811
|
```ts
|
|
811
812
|
import { withClient, readPage, writeRevision } from 'wiki-formant/scripts';
|
|
@@ -815,7 +816,7 @@ await withClient(async client => {
|
|
|
815
816
|
}, { tag: 'wiki' });
|
|
816
817
|
```
|
|
817
818
|
|
|
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`.
|
|
819
|
+
`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
820
|
|
|
820
821
|
`wiki-formant/knip`: the shared knip configuration, spread into each app's `knip.ts`.
|
|
821
822
|
|
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/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,21 +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
|
-
|
|
480
|
+
// Cleared so choosing the same file twice in a row still fires a change.
|
|
481
|
+
if (fileInputRef.current)
|
|
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' });
|
|
485
487
|
try {
|
|
486
488
|
const url = await uploadImage(file);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
editor.chain().focus().setImage({ src: url }).run();
|
|
490
|
+
if (attempt === uploadAttempt.current)
|
|
491
|
+
setUpload(UPLOAD_IDLE);
|
|
489
492
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (fileInputRef.current)
|
|
494
|
-
fileInputRef.current.value = '';
|
|
493
|
+
catch (err) {
|
|
494
|
+
if (attempt === uploadAttempt.current)
|
|
495
|
+
setUpload({ status: 'failed', error: err instanceof Error ? err.message : 'The upload failed. Try again.' });
|
|
495
496
|
}
|
|
496
497
|
}, [editor, uploadImage]);
|
|
497
498
|
const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
|
|
498
|
-
return { editor, fileInputRef,
|
|
499
|
+
return { editor, fileInputRef, upload, handleFileChange, triggerUpload };
|
|
499
500
|
}
|
|
500
501
|
// A line's whole document is the line, so Enter has nowhere to go.
|
|
501
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/metadata.d.ts
CHANGED
|
@@ -209,7 +209,10 @@ export declare function sitePageMetadata(site: {
|
|
|
209
209
|
siteName: string;
|
|
210
210
|
locale?: string;
|
|
211
211
|
handle?: string;
|
|
212
|
-
|
|
212
|
+
card?: string;
|
|
213
|
+
}): ({ card, ...o }: Omit<PageMetadataOptions, "siteName" | "locale" | "handle"> & {
|
|
214
|
+
card?: CardFields;
|
|
215
|
+
}) => {
|
|
213
216
|
openGraph: {
|
|
214
217
|
publishedTime?: string;
|
|
215
218
|
modifiedTime?: string;
|
|
@@ -243,6 +246,26 @@ export declare function sitePageMetadata(site: {
|
|
|
243
246
|
canonical: string;
|
|
244
247
|
} | undefined;
|
|
245
248
|
};
|
|
249
|
+
/**
|
|
250
|
+
* What a generated card draws: the text, and a banner that replaces its art.
|
|
251
|
+
* Any other key is a parameter only one site's route reads, such as the palette
|
|
252
|
+
* a radix-wiki section picks.
|
|
253
|
+
*/
|
|
254
|
+
export interface CardFields {
|
|
255
|
+
title?: string;
|
|
256
|
+
eyebrow?: string;
|
|
257
|
+
description?: string;
|
|
258
|
+
banner?: string | null;
|
|
259
|
+
[param: string]: string | null | undefined;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The address of a card route drawing `fields`, e.g. `cardUrl(`${SITE_URL}/og`, { title })`.
|
|
263
|
+
* Empty fields are left out, and the order is fixed (title, eyebrow,
|
|
264
|
+
* description, any site key, banner) whatever order the caller wrote, so one
|
|
265
|
+
* card has one URL and the CDN and X cache it once. Three wikis each built this
|
|
266
|
+
* query string by hand; the route that reads it is each site's own.
|
|
267
|
+
*/
|
|
268
|
+
export declare function cardUrl(base: string, fields: CardFields): string;
|
|
246
269
|
/** The `@id` references a page's own node points at for `publisher` and `isPartOf`. */
|
|
247
270
|
export declare function siteRefs(url: string): {
|
|
248
271
|
organization: LdNode;
|
package/dist/metadata.js
CHANGED
|
@@ -257,8 +257,30 @@ export function describe(text, limit = DESCRIPTION_LIMIT) {
|
|
|
257
257
|
* locale at all of them.
|
|
258
258
|
*/
|
|
259
259
|
export function sitePageMetadata(site) {
|
|
260
|
-
const
|
|
261
|
-
|
|
260
|
+
const { card: base, ...rest } = site;
|
|
261
|
+
const bound = { locale: 'en_US', ...rest };
|
|
262
|
+
return ({ card, ...o }) => {
|
|
263
|
+
if (card && !base)
|
|
264
|
+
throw new Error('sitePageMetadata: a page passed `card` but the site bound no card route');
|
|
265
|
+
return pageMetadata({ ...o, ...(card ? { image: cardUrl(base, card) } : {}), ...bound });
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const CARD_HEAD = ['title', 'eyebrow', 'description'];
|
|
269
|
+
/**
|
|
270
|
+
* The address of a card route drawing `fields`, e.g. `cardUrl(`${SITE_URL}/og`, { title })`.
|
|
271
|
+
* Empty fields are left out, and the order is fixed (title, eyebrow,
|
|
272
|
+
* description, any site key, banner) whatever order the caller wrote, so one
|
|
273
|
+
* card has one URL and the CDN and X cache it once. Three wikis each built this
|
|
274
|
+
* query string by hand; the route that reads it is each site's own.
|
|
275
|
+
*/
|
|
276
|
+
export function cardUrl(base, fields) {
|
|
277
|
+
const keys = [...CARD_HEAD, ...Object.keys(fields).filter(k => !CARD_HEAD.includes(k) && k !== 'banner'), 'banner'];
|
|
278
|
+
const q = new URLSearchParams();
|
|
279
|
+
for (const k of keys)
|
|
280
|
+
if (fields[k])
|
|
281
|
+
q.set(k, fields[k]);
|
|
282
|
+
const query = q.toString();
|
|
283
|
+
return query ? `${base}?${query}` : base;
|
|
262
284
|
}
|
|
263
285
|
/** The `@id` references a page's own node points at for `publisher` and `isPartOf`. */
|
|
264
286
|
export function siteRefs(url) {
|
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') {
|
|
@@ -396,7 +393,7 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
|
|
|
396
393
|
else if (e.key === 'Enter') {
|
|
397
394
|
// The list answers the last settled query. Typed past it, Enter falls
|
|
398
395
|
// through to the form, so it submits what was typed, not a stale pick.
|
|
399
|
-
if (query !== debounced ||
|
|
396
|
+
if (query !== debounced || results.status !== 'found')
|
|
400
397
|
return;
|
|
401
398
|
e.preventDefault();
|
|
402
399
|
const target = items[highlight];
|
|
@@ -412,10 +409,10 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
|
|
|
412
409
|
query,
|
|
413
410
|
setQuery,
|
|
414
411
|
debouncedQuery: debounced,
|
|
412
|
+
results,
|
|
415
413
|
items,
|
|
416
414
|
highlight,
|
|
417
415
|
setHighlight,
|
|
418
|
-
isSearching,
|
|
419
416
|
onKeyDown,
|
|
420
417
|
clearItems,
|
|
421
418
|
reset,
|
|
@@ -475,11 +472,14 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
|
|
|
475
472
|
showTimer.current = setTimeout(async () => {
|
|
476
473
|
let data = cache.current.get(href);
|
|
477
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.
|
|
478
478
|
try {
|
|
479
479
|
data = await opts.current.fetch(href);
|
|
480
480
|
}
|
|
481
481
|
catch {
|
|
482
|
-
|
|
482
|
+
return;
|
|
483
483
|
}
|
|
484
484
|
cache.current.set(href, data);
|
|
485
485
|
}
|
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.107.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",
|