wiki-formant 0.22.0 → 0.23.1
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 +42 -3
- package/dist/base.css +100 -0
- package/dist/block-views.d.ts +35 -7
- package/dist/block-views.js +26 -14
- package/dist/blocks.d.ts +26 -3
- package/dist/blocks.js +19 -5
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.js +18 -12
- package/dist/editor.d.ts +33 -1
- package/dist/editor.js +58 -0
- package/dist/feed.d.ts +10 -0
- package/dist/feed.js +20 -2
- package/dist/freshness.d.ts +33 -0
- package/dist/freshness.js +53 -0
- package/dist/headings.d.ts +7 -0
- package/dist/headings.js +12 -4
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.js +64 -0
- package/dist/react-server.d.ts +81 -2
- package/dist/react-server.js +71 -2
- package/dist/react.d.ts +90 -1
- package/dist/react.js +156 -2
- package/dist/sanitize.d.ts +5 -7
- package/dist/sanitize.js +5 -9
- package/package.json +6 -3
package/dist/react.js
CHANGED
|
@@ -137,8 +137,15 @@ export function TableOfContents({ containerSelector, headings: providedHeadings,
|
|
|
137
137
|
// article per mutation is what makes a rail janky.
|
|
138
138
|
timer = setTimeout(() => {
|
|
139
139
|
const root = document.querySelector(containerSelector);
|
|
140
|
-
|
|
140
|
+
// No container is an answer, not a reason to skip: a rail that bailed
|
|
141
|
+
// here kept the LAST page's headings, so a section index reached from
|
|
142
|
+
// one of its own articles listed that article's sections under "on this
|
|
143
|
+
// page". It only looked right when a loading skeleton happened to render
|
|
144
|
+
// the container and scan to nothing.
|
|
145
|
+
if (!root) {
|
|
146
|
+
setScanned(prev => (prev.length ? [] : prev));
|
|
141
147
|
return;
|
|
148
|
+
}
|
|
142
149
|
const used = new Set();
|
|
143
150
|
const next = [];
|
|
144
151
|
for (const el of Array.from(root.querySelectorAll('h1, h2, h3'))) {
|
|
@@ -518,6 +525,134 @@ export function useTableSort(rows, config) {
|
|
|
518
525
|
}, [sortKey, direction, toggle]);
|
|
519
526
|
return { sorted, sortKey, direction, headerProps, toggle };
|
|
520
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* The header cell `useTableSort` drives: spread `headerProps(key)` onto it.
|
|
530
|
+
*
|
|
531
|
+
* The press target is a `<button>` inside the `<th>`, never the cell itself: a
|
|
532
|
+
* cell with an `onClick` cannot be reached or pressed from the keyboard, which
|
|
533
|
+
* is the state one of the two copies this replaced was in. The markup is the
|
|
534
|
+
* one `sortTables` in `wiki-formant/dom` writes for tables stored in articles,
|
|
535
|
+
* so one stylesheet rule keyed on `aria-sort` draws both kinds of arrow.
|
|
536
|
+
*/
|
|
537
|
+
export function SortHeader({ sortKey, onSort, active: _active, direction: _direction, 'aria-sort': ariaSort, label, icon, children, ...th }) {
|
|
538
|
+
return (_jsx("th", { scope: "col", ...th, "aria-sort": ariaSort, children: _jsxs("button", { type: "button", className: "sort-header", onClick: () => onSort(sortKey), title: label, "aria-label": label, children: [children, icon] }) }));
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The five edits every block editor makes to its list, with stable callbacks.
|
|
542
|
+
*
|
|
543
|
+
* The list and its setter are read through refs written after render, so a row
|
|
544
|
+
* memoised on these callbacks does not re-render on every keystroke elsewhere
|
|
545
|
+
* — and the setter may be a `useState` setter or a parent's `onChange`, which
|
|
546
|
+
* is what the three copies this replaced were split between. Creating and
|
|
547
|
+
* duplicating a block stay with the caller, whose union it is.
|
|
548
|
+
*/
|
|
549
|
+
export function useBlockOperations(blocks, setBlocks, options) {
|
|
550
|
+
const [selectedIndex, setSelectedIndex] = useState(null);
|
|
551
|
+
const latest = useRef({ blocks, setBlocks, clone: options.duplicate });
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
latest.current = { blocks, setBlocks, clone: options.duplicate };
|
|
554
|
+
});
|
|
555
|
+
const update = useCallback((i, block) => {
|
|
556
|
+
const { blocks, setBlocks } = latest.current;
|
|
557
|
+
setBlocks(blocks.map((b, j) => (j === i ? block : b)));
|
|
558
|
+
}, []);
|
|
559
|
+
const remove = useCallback((i) => {
|
|
560
|
+
const { blocks, setBlocks } = latest.current;
|
|
561
|
+
setBlocks(blocks.filter((_, j) => j !== i));
|
|
562
|
+
setSelectedIndex(null);
|
|
563
|
+
}, []);
|
|
564
|
+
const duplicate = useCallback((i) => {
|
|
565
|
+
const { blocks, setBlocks, clone } = latest.current;
|
|
566
|
+
const source = blocks[i];
|
|
567
|
+
if (source === undefined)
|
|
568
|
+
return;
|
|
569
|
+
setBlocks([...blocks.slice(0, i + 1), clone(source), ...blocks.slice(i + 1)]);
|
|
570
|
+
setSelectedIndex(i + 1);
|
|
571
|
+
}, []);
|
|
572
|
+
const move = useCallback((from, to) => {
|
|
573
|
+
const { blocks, setBlocks } = latest.current;
|
|
574
|
+
if (to < 0 || to >= blocks.length || from === to)
|
|
575
|
+
return;
|
|
576
|
+
const next = [...blocks];
|
|
577
|
+
const [moved] = next.splice(from, 1);
|
|
578
|
+
next.splice(to, 0, moved);
|
|
579
|
+
setBlocks(next);
|
|
580
|
+
setSelectedIndex(to);
|
|
581
|
+
}, []);
|
|
582
|
+
const insert = useCallback((block, at) => {
|
|
583
|
+
const { blocks, setBlocks } = latest.current;
|
|
584
|
+
const i = at ?? blocks.length;
|
|
585
|
+
setBlocks([...blocks.slice(0, i), block, ...blocks.slice(i)]);
|
|
586
|
+
setSelectedIndex(i);
|
|
587
|
+
}, []);
|
|
588
|
+
return { selectedIndex, setSelectedIndex, update, remove, duplicate, move, insert };
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Move up, move down, duplicate, delete. Every button has an accessible name
|
|
592
|
+
* and the moves are disabled at the ends — the three bars this replaced had
|
|
593
|
+
* each of those only sometimes. Clicks do not bubble, so a row that selects on
|
|
594
|
+
* click is not also selected by pressing delete in it. Omit `icons.duplicate`
|
|
595
|
+
* for a bar without it.
|
|
596
|
+
*/
|
|
597
|
+
export function BlockActions({ index, total, ops, icons, className, buttonClassName, blockLabel }) {
|
|
598
|
+
const name = (verb, rest = '') => (blockLabel ? `${verb} ${blockLabel}${rest}` : `${verb}${rest}`);
|
|
599
|
+
const button = (label, icon, run, disabled = false) => (_jsx("button", { type: "button", className: buttonClassName, title: label, "aria-label": label, disabled: disabled, onClick: e => {
|
|
600
|
+
e.stopPropagation();
|
|
601
|
+
run();
|
|
602
|
+
}, children: icon }));
|
|
603
|
+
return (_jsxs("span", { className: className, children: [button(name('Move', ' up'), icons.up, () => ops.move(index, index - 1), index === 0), button(name('Move', ' down'), icons.down, () => ops.move(index, index + 1), index === total - 1), icons.duplicate !== undefined && button(name('Duplicate'), icons.duplicate, () => ops.duplicate(index)), button(name('Delete'), icons.remove, () => ops.remove(index))] }));
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
|
|
607
|
+
* third passes its server action instead.
|
|
608
|
+
*/
|
|
609
|
+
export function restoreViaPost(endpoint) {
|
|
610
|
+
return async (id) => {
|
|
611
|
+
try {
|
|
612
|
+
const res = await fetch(endpoint, {
|
|
613
|
+
method: 'POST',
|
|
614
|
+
headers: { 'Content-Type': 'application/json' },
|
|
615
|
+
body: JSON.stringify({ revisionId: id }),
|
|
616
|
+
});
|
|
617
|
+
if (res.ok)
|
|
618
|
+
return null;
|
|
619
|
+
const body = (await res.json().catch(() => null));
|
|
620
|
+
return body?.error ?? 'Restore failed.';
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return 'Restore failed.';
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Confirm, restore, report. Of the three copies one asked no confirmation, one
|
|
629
|
+
* dropped a failure silently, and one reported it through `alert`. A restore
|
|
630
|
+
* writes the old state forward as a new revision in all three, which is what
|
|
631
|
+
* the default question says.
|
|
632
|
+
*/
|
|
633
|
+
export function useRevisionRestore(restore, options) {
|
|
634
|
+
const [busyId, setBusyId] = useState(null);
|
|
635
|
+
const [error, setError] = useState(null);
|
|
636
|
+
const { onRestored, confirm = (label) => `Restore ${label}? The current state stays in the history.` } = options;
|
|
637
|
+
const run = useCallback(async (id, label) => {
|
|
638
|
+
const question = confirm(label);
|
|
639
|
+
if (question && !window.confirm(question))
|
|
640
|
+
return;
|
|
641
|
+
setBusyId(id);
|
|
642
|
+
setError(null);
|
|
643
|
+
try {
|
|
644
|
+
const failure = await restore(id);
|
|
645
|
+
if (failure)
|
|
646
|
+
setError(failure);
|
|
647
|
+
else
|
|
648
|
+
onRestored();
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
setBusyId(null);
|
|
652
|
+
}
|
|
653
|
+
}, [restore, onRestored, confirm]);
|
|
654
|
+
return { restore: run, busyId, error };
|
|
655
|
+
}
|
|
521
656
|
// ---- copy to clipboard ------------------------------------------------------
|
|
522
657
|
/**
|
|
523
658
|
* "Copied", then not, after a beat. Six call sites across three repos wrote the
|
|
@@ -619,10 +754,29 @@ export function RailShell({ children, prefix, label, className }) {
|
|
|
619
754
|
* matched on the PATH ONLY, deliberately: `?edit=` and `?history=` are modes a
|
|
620
755
|
* page enters, not destinations a rail selects, and reading them would drag
|
|
621
756
|
* `useSearchParams` into a statically-rendered wiki.
|
|
757
|
+
*
|
|
758
|
+
* The path is decoded first. Next serves a catch-all route's prerender with the
|
|
759
|
+
* joined segments percent-encoded — `/wiki/tech%2Fcore-concepts` for
|
|
760
|
+
* `/wiki/tech/core-concepts` — so an encoded path matches neither its own href
|
|
761
|
+
* nor its section's prefix, and React does not patch a className on hydration.
|
|
762
|
+
* Both wikis were shipping every page below the top level with nothing in the
|
|
763
|
+
* rail marked, and it only came right if you arrived by client navigation.
|
|
622
764
|
*/
|
|
623
765
|
export function isRailLinkActive(activePath, href, tree = false) {
|
|
624
|
-
|
|
766
|
+
const path = decodePath(activePath);
|
|
767
|
+
return tree ? path === href || path.startsWith(`${href}/`) : path === href;
|
|
625
768
|
}
|
|
769
|
+
/** `decodeURIComponent` throws on a stray `%`; a path it cannot read is its own answer. */
|
|
770
|
+
const decodePath = (path) => {
|
|
771
|
+
if (!path.includes('%'))
|
|
772
|
+
return path;
|
|
773
|
+
try {
|
|
774
|
+
return decodeURIComponent(path);
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return path;
|
|
778
|
+
}
|
|
779
|
+
};
|
|
626
780
|
// ---- rendered-article passes --------------------------------------------------
|
|
627
781
|
/**
|
|
628
782
|
* The `wiki-formant/dom` passes over a rendered article: tab groups, copy
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -27,17 +27,15 @@ export interface HtmlSanitizerOptions {
|
|
|
27
27
|
*/
|
|
28
28
|
export declare function createHtmlSanitizer(options?: HtmlSanitizerOptions): (html: string) => string;
|
|
29
29
|
/**
|
|
30
|
-
* The core leaf types' HTML fields, cleaned: `content.text`, `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
|
|
31
|
+
* descriptions and `references` item text. Every other field those blocks
|
|
32
|
+
* carry renders as a React text node and is escaped already.
|
|
33
33
|
*
|
|
34
34
|
* Returns any other block untouched, so it drops straight into a
|
|
35
35
|
* `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
|
|
36
36
|
*
|
|
37
|
-
* `codeTabs` code is
|
|
38
|
-
*
|
|
39
|
-
* must not pass it through here — every `<T>` in a signature would go — and
|
|
40
|
-
* cleans its other three fields with its own switch.
|
|
37
|
+
* `codeTabs` passes through: its `code` is source text, which `CodeTabsView`
|
|
38
|
+
* escapes. Cleaning it as HTML was a bug — every `<T>` in a signature went.
|
|
41
39
|
*/
|
|
42
40
|
export declare function sanitizeCoreLeaf<B extends {
|
|
43
41
|
type: string;
|
package/dist/sanitize.js
CHANGED
|
@@ -159,25 +159,21 @@ export function createHtmlSanitizer(options = {}) {
|
|
|
159
159
|
const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item === 'object' ? map(item) : item)) : v;
|
|
160
160
|
const str = (v) => typeof v === 'string';
|
|
161
161
|
/**
|
|
162
|
-
* The core leaf types' HTML fields, cleaned: `content.text`, `
|
|
163
|
-
*
|
|
164
|
-
*
|
|
162
|
+
* The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
|
|
163
|
+
* descriptions and `references` item text. Every other field those blocks
|
|
164
|
+
* carry renders as a React text node and is escaped already.
|
|
165
165
|
*
|
|
166
166
|
* Returns any other block untouched, so it drops straight into a
|
|
167
167
|
* `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
|
|
168
168
|
*
|
|
169
|
-
* `codeTabs` code is
|
|
170
|
-
*
|
|
171
|
-
* must not pass it through here — every `<T>` in a signature would go — and
|
|
172
|
-
* cleans its other three fields with its own switch.
|
|
169
|
+
* `codeTabs` passes through: its `code` is source text, which `CodeTabsView`
|
|
170
|
+
* escapes. Cleaning it as HTML was a bug — every `<T>` in a signature went.
|
|
173
171
|
*/
|
|
174
172
|
export function sanitizeCoreLeaf(block, clean) {
|
|
175
173
|
const b = block;
|
|
176
174
|
switch (b.type) {
|
|
177
175
|
case 'content':
|
|
178
176
|
return str(b.text) ? { ...b, text: clean(b.text) } : block;
|
|
179
|
-
case 'codeTabs':
|
|
180
|
-
return { ...b, tabs: each(b.tabs, t => (str(t.code) ? { ...t, code: clean(t.code) } : t)) };
|
|
181
177
|
case 'linkGrid':
|
|
182
178
|
return {
|
|
183
179
|
...b,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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, and conditional-GET plumbing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"markdown",
|
|
19
19
|
"agent"
|
|
20
20
|
],
|
|
21
|
-
"sideEffects":
|
|
21
|
+
"sideEffects": [
|
|
22
|
+
"*.css"
|
|
23
|
+
],
|
|
22
24
|
"exports": {
|
|
23
25
|
".": {
|
|
24
26
|
"types": "./dist/index.d.ts",
|
|
@@ -144,6 +146,7 @@
|
|
|
144
146
|
"types": "./dist/x402.d.ts",
|
|
145
147
|
"import": "./dist/x402.js"
|
|
146
148
|
},
|
|
149
|
+
"./base.css": "./dist/base.css",
|
|
147
150
|
"./block-views": {
|
|
148
151
|
"types": "./dist/block-views.d.ts",
|
|
149
152
|
"import": "./dist/block-views.js"
|
|
@@ -184,7 +187,7 @@
|
|
|
184
187
|
"node": ">=20"
|
|
185
188
|
},
|
|
186
189
|
"scripts": {
|
|
187
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
190
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/base.css dist/base.css",
|
|
188
191
|
"test": "npm run build && node --test 'test/*.test.js'"
|
|
189
192
|
},
|
|
190
193
|
"devDependencies": {
|