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/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
- if (!root)
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
- return tree ? activePath === href || activePath.startsWith(`${href}/`) : activePath === href;
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
@@ -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.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": false,
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": {