wiki-formant 0.22.0 → 0.23.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/dist/react.js CHANGED
@@ -518,6 +518,134 @@ export function useTableSort(rows, config) {
518
518
  }, [sortKey, direction, toggle]);
519
519
  return { sorted, sortKey, direction, headerProps, toggle };
520
520
  }
521
+ /**
522
+ * The header cell `useTableSort` drives: spread `headerProps(key)` onto it.
523
+ *
524
+ * The press target is a `<button>` inside the `<th>`, never the cell itself: a
525
+ * cell with an `onClick` cannot be reached or pressed from the keyboard, which
526
+ * is the state one of the two copies this replaced was in. The markup is the
527
+ * one `sortTables` in `wiki-formant/dom` writes for tables stored in articles,
528
+ * so one stylesheet rule keyed on `aria-sort` draws both kinds of arrow.
529
+ */
530
+ export function SortHeader({ sortKey, onSort, active: _active, direction: _direction, 'aria-sort': ariaSort, label, icon, children, ...th }) {
531
+ 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] }) }));
532
+ }
533
+ /**
534
+ * The five edits every block editor makes to its list, with stable callbacks.
535
+ *
536
+ * The list and its setter are read through refs written after render, so a row
537
+ * memoised on these callbacks does not re-render on every keystroke elsewhere
538
+ * — and the setter may be a `useState` setter or a parent's `onChange`, which
539
+ * is what the three copies this replaced were split between. Creating and
540
+ * duplicating a block stay with the caller, whose union it is.
541
+ */
542
+ export function useBlockOperations(blocks, setBlocks, options) {
543
+ const [selectedIndex, setSelectedIndex] = useState(null);
544
+ const latest = useRef({ blocks, setBlocks, clone: options.duplicate });
545
+ useEffect(() => {
546
+ latest.current = { blocks, setBlocks, clone: options.duplicate };
547
+ });
548
+ const update = useCallback((i, block) => {
549
+ const { blocks, setBlocks } = latest.current;
550
+ setBlocks(blocks.map((b, j) => (j === i ? block : b)));
551
+ }, []);
552
+ const remove = useCallback((i) => {
553
+ const { blocks, setBlocks } = latest.current;
554
+ setBlocks(blocks.filter((_, j) => j !== i));
555
+ setSelectedIndex(null);
556
+ }, []);
557
+ const duplicate = useCallback((i) => {
558
+ const { blocks, setBlocks, clone } = latest.current;
559
+ const source = blocks[i];
560
+ if (source === undefined)
561
+ return;
562
+ setBlocks([...blocks.slice(0, i + 1), clone(source), ...blocks.slice(i + 1)]);
563
+ setSelectedIndex(i + 1);
564
+ }, []);
565
+ const move = useCallback((from, to) => {
566
+ const { blocks, setBlocks } = latest.current;
567
+ if (to < 0 || to >= blocks.length || from === to)
568
+ return;
569
+ const next = [...blocks];
570
+ const [moved] = next.splice(from, 1);
571
+ next.splice(to, 0, moved);
572
+ setBlocks(next);
573
+ setSelectedIndex(to);
574
+ }, []);
575
+ const insert = useCallback((block, at) => {
576
+ const { blocks, setBlocks } = latest.current;
577
+ const i = at ?? blocks.length;
578
+ setBlocks([...blocks.slice(0, i), block, ...blocks.slice(i)]);
579
+ setSelectedIndex(i);
580
+ }, []);
581
+ return { selectedIndex, setSelectedIndex, update, remove, duplicate, move, insert };
582
+ }
583
+ /**
584
+ * Move up, move down, duplicate, delete. Every button has an accessible name
585
+ * and the moves are disabled at the ends — the three bars this replaced had
586
+ * each of those only sometimes. Clicks do not bubble, so a row that selects on
587
+ * click is not also selected by pressing delete in it. Omit `icons.duplicate`
588
+ * for a bar without it.
589
+ */
590
+ export function BlockActions({ index, total, ops, icons, className, buttonClassName, blockLabel }) {
591
+ const name = (verb, rest = '') => (blockLabel ? `${verb} ${blockLabel}${rest}` : `${verb}${rest}`);
592
+ const button = (label, icon, run, disabled = false) => (_jsx("button", { type: "button", className: buttonClassName, title: label, "aria-label": label, disabled: disabled, onClick: e => {
593
+ e.stopPropagation();
594
+ run();
595
+ }, children: icon }));
596
+ 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))] }));
597
+ }
598
+ /**
599
+ * POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
600
+ * third passes its server action instead.
601
+ */
602
+ export function restoreViaPost(endpoint) {
603
+ return async (id) => {
604
+ try {
605
+ const res = await fetch(endpoint, {
606
+ method: 'POST',
607
+ headers: { 'Content-Type': 'application/json' },
608
+ body: JSON.stringify({ revisionId: id }),
609
+ });
610
+ if (res.ok)
611
+ return null;
612
+ const body = (await res.json().catch(() => null));
613
+ return body?.error ?? 'Restore failed.';
614
+ }
615
+ catch {
616
+ return 'Restore failed.';
617
+ }
618
+ };
619
+ }
620
+ /**
621
+ * Confirm, restore, report. Of the three copies one asked no confirmation, one
622
+ * dropped a failure silently, and one reported it through `alert`. A restore
623
+ * writes the old state forward as a new revision in all three, which is what
624
+ * the default question says.
625
+ */
626
+ export function useRevisionRestore(restore, options) {
627
+ const [busyId, setBusyId] = useState(null);
628
+ const [error, setError] = useState(null);
629
+ const { onRestored, confirm = (label) => `Restore ${label}? The current state stays in the history.` } = options;
630
+ const run = useCallback(async (id, label) => {
631
+ const question = confirm(label);
632
+ if (question && !window.confirm(question))
633
+ return;
634
+ setBusyId(id);
635
+ setError(null);
636
+ try {
637
+ const failure = await restore(id);
638
+ if (failure)
639
+ setError(failure);
640
+ else
641
+ onRestored();
642
+ }
643
+ finally {
644
+ setBusyId(null);
645
+ }
646
+ }, [restore, onRestored, confirm]);
647
+ return { restore: run, busyId, error };
648
+ }
521
649
  // ---- copy to clipboard ------------------------------------------------------
522
650
  /**
523
651
  * "Copied", then not, after a beat. Six call sites across three repos wrote the
@@ -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`, `codeTabs` code,
31
- * `linkGrid` group descriptions and `references` item text. Every other field
32
- * those blocks carry renders as a React text node and is escaped already.
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 treated as stored HTML, which is what the editor writes.
38
- * A wiki whose highlighter takes the code as SOURCE and escapes it on render
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`, `codeTabs` code,
163
- * `linkGrid` group descriptions and `references` item text. Every other field
164
- * those blocks carry renders as a React text node and is escaped already.
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 treated as stored HTML, which is what the editor writes.
170
- * A wiki whose highlighter takes the code as SOURCE and escapes it on render
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.22.0",
3
+ "version": "0.23.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, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "markdown",
19
19
  "agent"
20
20
  ],
21
- "sideEffects": false,
21
+ "sideEffects": ["*.css"],
22
22
  "exports": {
23
23
  ".": {
24
24
  "types": "./dist/index.d.ts",
@@ -144,6 +144,7 @@
144
144
  "types": "./dist/x402.d.ts",
145
145
  "import": "./dist/x402.js"
146
146
  },
147
+ "./base.css": "./dist/base.css",
147
148
  "./block-views": {
148
149
  "types": "./dist/block-views.d.ts",
149
150
  "import": "./dist/block-views.js"
@@ -184,7 +185,7 @@
184
185
  "node": ">=20"
185
186
  },
186
187
  "scripts": {
187
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
188
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/base.css dist/base.css",
188
189
  "test": "npm run build && node --test 'test/*.test.js'"
189
190
  },
190
191
  "devDependencies": {