wiki-formant 0.80.0 → 0.82.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 CHANGED
@@ -698,7 +698,7 @@ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
698
698
 
699
699
  `addCopyButton` was **byte-identical** in two BlockRenderers, down to the SVG path data. Its idempotence guard now lives inside the function rather than in a `pre:not(:has(…))` at the call site, where it can be — and was — retyped.
700
700
 
701
- In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetEmbeds(ref, [html])` the embed pair, from `wiki-formant/react`. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
701
+ In React, `useArticlePasses(ref, [blocks])` (`wiki-formant/react`) runs them all, the embed pair included. Three renderers had the effect written out and disagreed on its dependencies: two ran once on mount, so a page swapped in without a remount kept its old tables unsortable.
702
702
 
703
703
  `activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
704
704
 
@@ -810,19 +810,26 @@ const CodeBlock = createCodeBlock({
810
810
 
811
811
  The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant/blocks`, which two wikis had typed identically; a wiki with its own passes `langs` and `defaultLang`. The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
812
812
 
813
- `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
813
+ Both node views draw in default classes (`code-block…`, `tabs-editor`, `tab-button`…) that `base.css` styles plainly enough for any site, the editor's rule showing the tab the writer picked included; pass `classNames` and `icons` to draw them your own way. `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
814
814
 
815
815
  `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
816
816
 
817
- `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". `WikiToolbar` lays them out the way three wikis had each assembled them by hand: the format buttons, Remove link while the cursor is in a link, upload and embed, the insert buttons, and the table controls while the cursor is in a table. Every button is a `ToolbarButton` (named, and `aria-pressed`, since these are toggles); the link button opens the address field and reads as on while it is open. Which buttons, icons and classes are yours; a toolbar shaped differently builds from `ToolbarButton` and `runTableAction` directly.
817
+ `WikiToolbar` shows what the editor can do and nothing else: each command whose node, mark or command the editor's extensions include, so an app's buttons follow from its nodes and no app keeps a second list. They come in groups – undo and redo; bold, italic, inline code and link; the three headings; lists, quote and code block; then upload (given `onUpload`), embed (where there are embed nodes), table, tabs and divider – with Remove link after Link while the cursor is in a link, and the table controls while it is in a table. Four apps had each listed buttons by hand: one had no numbered list, one had a code-block node and no button for it, and only one had undo. Every button is a `ToolbarButton`, named and `aria-pressed`; the icons, the classes and anything after the buttons (`children`) are yours.
818
818
 
819
819
  ```tsx
820
- <WikiToolbar editor={editor} isActive={isActive} prompt={url} actions={FORMAT} trailing={INSERT} onUpload={triggerUpload} embed
820
+ <WikiToolbar editor={editor} prompt={url} onUpload={triggerUpload}
821
821
  icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
822
822
  classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
823
823
  <UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
824
824
  ```
825
825
 
826
+ `WikiLine` is a one-line field – a title, a heading, a caption, a button – edited in the element that shows it. Its schema is the line and the `marks` it may keep (`bold`, `italic`, `link`), so a typed "- " makes no list and a pasted heading runs into the line; its value is that HTML, or plain text with no marks. It has its own undo, and `onFocus` hands over its editor, so one `WikiToolbar` can drive whichever field was last touched. `base.css` gives it a placeholder from `data-placeholder`.
827
+
828
+ ```tsx
829
+ <WikiLine as="h1" value={title} onChange={setTitle} placeholder="The page's title" />
830
+ <WikiLine as="p" marks={['bold', 'italic', 'link']} value={note} onChange={setNote} placeholder="A note" />
831
+ ```
832
+
826
833
  `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
827
834
 
828
835
  `UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
@@ -27,6 +27,11 @@
27
27
  // URLs come from /sitemap.xml, so a route the sitemap omits is not checked —
28
28
  // which is the right default, since an unlisted route is one nobody asked a
29
29
  // crawler to read.
30
+ //
31
+ // Once per site it also checks the icon. miow.me changed its icon in October
32
+ // 2026 and every address of it 404'd at /favicon.ico, the one place a client
33
+ // that never reads the page's <link rel="icon"> looks; so /favicon.ico must
34
+ // answer an image, and the same image the home page declares.
30
35
  import { outlineIssues } from '../dist/headings.js';
31
36
  import { readSitemap } from '../dist/indexnow.js';
32
37
  import { decodeEntities } from '../dist/markdown.js';
@@ -125,6 +130,36 @@ for (const url of checked) {
125
130
  if (descriptionOf(page.body) === null) faults.push({ url, fault: 'noDescription', detail: '' });
126
131
  }
127
132
 
133
+ // The icon, once per site: what the home page declares (a page that declares
134
+ // none is shown /favicon.ico) against what /favicon.ico answers, redirects
135
+ // followed.
136
+ const iconHref = html => {
137
+ for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
138
+ if (/\brel=["'](?:shortcut )?icon["']/i.test(tag)) return decodeEntities(tag.match(/\bhref=["']([^"']+)["']/i)?.[1] ?? '');
139
+ }
140
+ return '';
141
+ };
142
+ const image = async url => {
143
+ try {
144
+ const res = await fetch(url, { headers: { 'user-agent': UA } });
145
+ const type = res.headers.get('content-type') ?? '';
146
+ return res.ok && /^image\//i.test(type) ? Buffer.from(await res.arrayBuffer()) : `${res.status} ${type}`.trim();
147
+ } catch (e) {
148
+ return e.message;
149
+ }
150
+ };
151
+ {
152
+ const home = await get(`${site}/`).catch(() => ({ body: '' }));
153
+ const declared = new URL(iconHref(home.body) || '/favicon.ico', `${site}/`).href;
154
+ const favicon = await image(`${site}/favicon.ico`);
155
+ const shown = declared === `${site}/favicon.ico` ? favicon : await image(declared);
156
+ if (!Buffer.isBuffer(favicon)) faults.push({ url: `${site}/favicon.ico`, fault: 'noFavicon', detail: favicon });
157
+ if (!Buffer.isBuffer(shown)) faults.push({ url: declared, fault: 'iconDead', detail: shown });
158
+ else if (Buffer.isBuffer(favicon) && !favicon.equals(shown)) {
159
+ faults.push({ url: `${site}/favicon.ico`, fault: 'faviconStale', detail: `not the icon the home page declares, ${declared.replace(site, '')}` });
160
+ }
161
+ }
162
+
128
163
  // Reported once per title rather than once per page, or a shared title on forty
129
164
  // routes drowns everything else.
130
165
  for (const [title, sharing] of titles) {
@@ -149,7 +184,7 @@ if (JSON_OUT) {
149
184
  const byFault = new Map();
150
185
  for (const f of faults) byFault.set(f.fault, [...(byFault.get(f.fault) ?? []), f]);
151
186
  console.log(`check-pages: ${checked.length} of ${urls.length} sitemap URLs on ${site}\n`);
152
- if (!faults.length) console.log(' clean — one h1 per page, no skipped levels, every title inside the budget.');
187
+ if (!faults.length) console.log(' clean — one h1 per page, no skipped levels, every title inside the budget, and /favicon.ico the icon the page declares.');
153
188
  else if (!failing.length) console.log(' no failures; everything below is advisory.\n');
154
189
  for (const [fault, list] of [...byFault].sort((a, b) => b[1].length - a[1].length)) {
155
190
  console.log(` ${fault} (${list.length})${ADVISORY.has(fault) ? ' — advisory, does not fail' : ''}`);
package/dist/base.css CHANGED
@@ -180,6 +180,41 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
180
180
  stands in for the group rather than every tab at once. */
181
181
  .tab-panel:not(.active) { display: none; }
182
182
  [data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
183
+ .tabs-list { display: flex; flex-wrap: wrap; gap: 0.25rem; }
184
+ .tab-button { font: inherit; color: inherit; background: none; border: 0; border-bottom: 2px solid transparent; padding: 0.4em 0.8em; cursor: pointer; }
185
+ .tab-button.active { border-bottom-color: currentColor; }
186
+
187
+ /* ---- editor node views (createCodeBlock, createTabs) ---------------------------
188
+ Their default classes, drawn plainly enough to sit on any site; an app that
189
+ passes its own classes styles them itself. The tab editor shows the tab the
190
+ writer picked, which the stored-group rule above would otherwise hide. */
191
+ .code-block { position: relative; }
192
+ .code-block-control { position: absolute; top: 0.4rem; right: 0.4rem; z-index: 1; font-size: 0.8rem; }
193
+ :is(.code-block-button, .code-block-option, .tab-title, .tab-remove, .tab-add) { font: inherit; color: inherit; background: none; border: 0; }
194
+ :is(.code-block-button, .code-block-option, .tab-remove, .tab-add) { padding: 0.15em 0.5em; cursor: pointer; }
195
+ .code-block-menu { position: absolute; right: 0; display: grid; max-height: 16rem; overflow: auto; background: Canvas; color: CanvasText; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); border-radius: 0.4rem; }
196
+ .code-block-option { text-align: left; }
197
+ .code-block-option.active { font-weight: 600; }
198
+ .tabs-editor { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); border-radius: 0.5rem; }
199
+ .tabs-editor .tabs-list { align-items: center; padding: 0.4rem; border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
200
+ .tabs-editor .tab-button { display: inline-flex; align-items: center; padding: 0.15em 0.3em; border-bottom: 0; border-radius: 0.3rem; }
201
+ .tabs-editor .tab-button.active { background: color-mix(in srgb, currentColor 10%, transparent); }
202
+ .tab-title { width: 7em; outline: none; }
203
+ .tab-remove, .tab-add { opacity: 0.7; }
204
+ .tabs-content { padding: 1rem; }
205
+ .tabs-editor [data-tab-item] { display: none; }
206
+ [data-tabs][data-active-tab='0'] .tabs-editor [data-tab-item]:nth-of-type(1),
207
+ [data-tabs][data-active-tab='1'] .tabs-editor [data-tab-item]:nth-of-type(2),
208
+ [data-tabs][data-active-tab='2'] .tabs-editor [data-tab-item]:nth-of-type(3),
209
+ [data-tabs][data-active-tab='3'] .tabs-editor [data-tab-item]:nth-of-type(4),
210
+ [data-tabs][data-active-tab='4'] .tabs-editor [data-tab-item]:nth-of-type(5),
211
+ [data-tabs][data-active-tab='5'] .tabs-editor [data-tab-item]:nth-of-type(6),
212
+ [data-tabs][data-active-tab='6'] .tabs-editor [data-tab-item]:nth-of-type(7),
213
+ [data-tabs][data-active-tab='7'] .tabs-editor [data-tab-item]:nth-of-type(8) { display: block; }
214
+
215
+ /* ---- one-line fields (WikiLine) --------------------------------------------- */
216
+ [data-wiki-line] { outline: none; cursor: text; white-space: pre-wrap; }
217
+ [data-wiki-line]:is(:empty, :has(> .ProseMirror-trailingBreak))::before { content: attr(data-placeholder); opacity: 0.6; }
183
218
 
184
219
  /* ---- table scroll boxes (scrollTables, React tables) -----------------------
185
220
  A table wider than its column scrolls inside its own box, at every width,
package/dist/editor.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnyExtension, Editor } from '@tiptap/core';
1
+ import { type AnyExtension, type Editor } from '@tiptap/core';
2
2
  import { type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject } from 'react';
3
3
  /**
4
4
  * Re-exported so the COMMAND AUGMENTATIONS reach consumers.
@@ -37,18 +37,18 @@ export declare const TABLE_ACTIONS: ReadonlyArray<[command: string, label: strin
37
37
  export type ActiveDescriptor = string | [string, Record<string, unknown>];
38
38
  /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
39
39
  export declare function runTableAction(editor: Editor, command: string): void;
40
- export type ToolbarKey = 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
40
+ export type ToolbarKey = 'undo' | 'redo' | 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
41
41
  export interface ToolbarAction {
42
42
  key: ToolbarKey;
43
43
  /** What a screen reader announces and a tooltip shows. */
44
44
  label: string;
45
+ /** The node, mark or command the editor needs for the button to show. */
46
+ needs: string;
45
47
  active?: ActiveDescriptor;
46
48
  /** Absent on `link`, which needs a URL: call `withUrl` once you have one. */
47
49
  run?: (editor: Editor) => void;
48
50
  withUrl?: (editor: Editor, url: string) => void;
49
51
  }
50
- /** The actions a toolbar shows, in the order it shows them. */
51
- export declare function toolbarActions(keys: readonly ToolbarKey[]): ToolbarAction[];
52
52
  /**
53
53
  * A toolbar button that says what it is and whether it is on. `aria-pressed`
54
54
  * is right here, unlike on a facet link: this is a toggle.
@@ -61,50 +61,43 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
61
61
  children: ReactNode;
62
62
  }): import("react").JSX.Element;
63
63
  export interface WikiToolbarProps {
64
- editor: Editor;
65
- /** `useWikiEditor`'s `isActive`. */
66
- isActive: (a?: ActiveDescriptor) => boolean;
64
+ /** The editor it drives; with none it shows only `children`. */
65
+ editor: Editor | null;
67
66
  /** `useUrlPrompt`'s state: the link button opens it, and shows pressed while it is open. */
68
67
  prompt: UrlPromptState;
69
- /** The formatting buttons, before Remove link, upload and embed. */
70
- actions: readonly ToolbarAction[];
71
- /** The insert buttons after them, such as table and tabs. */
72
- trailing?: readonly ToolbarAction[];
73
- /** Shows the upload button, which calls this: `useWikiEditor`'s `triggerUpload`. */
68
+ /** Shows the upload button where the editor takes images: `useWikiEditor`'s `triggerUpload`. */
74
69
  onUpload?: () => void;
75
- /** Shows the embed button, which opens the prompt on an embed address. */
76
- embed?: boolean;
77
- icons: Partial<Record<ToolbarKey, ReactNode>> & {
78
- unlink: ReactNode;
79
- upload?: ReactNode;
80
- embed?: ReactNode;
81
- };
70
+ icons: Partial<Record<ToolbarKey | 'unlink' | 'upload' | 'embed', ReactNode>>;
82
71
  classNames?: {
83
72
  toolbar?: string;
84
73
  button?: string;
85
74
  /** Added to a button that is on. */
86
75
  pressed?: string;
76
+ /** Drawn between groups, and before the table controls. */
87
77
  divider?: string;
88
78
  /** A table control, which is a word rather than an icon. */
89
79
  tableButton?: string;
90
80
  /** Added to the table controls that delete. */
91
81
  danger?: string;
92
82
  };
83
+ /** After the buttons: a hint, or controls of the app's own. */
84
+ children?: ReactNode;
93
85
  }
94
86
  /**
95
- * The formatting bar three wikis had each assembled from `toolbarActions`,
96
- * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
97
- * a link button that needs an address and so opens the prompt, a Remove link
98
- * button while the cursor is in a link, upload and embed between the format
99
- * and insert groups, and the table controls while the cursor is in a table.
100
- * Which buttons, which icons and which classes are the caller's.
87
+ * The formatting bar. It shows what the editor can do and nothing else: every
88
+ * command whose node, mark or command the editor has (`supports`), grouped,
89
+ * then upload where it takes images and embed where it has embed nodes, then
90
+ * the table controls while the cursor is in a table. Remove link follows Link
91
+ * while the cursor is in a link. The same bar drives a whole article, a one-line
92
+ * field and an editor shared between blocks; which icons and classes are the
93
+ * caller's. Every button is a `ToolbarButton`, named and `aria-pressed`.
101
94
  */
102
- export declare function WikiToolbar({ editor, isActive, prompt, actions, trailing, onUpload, embed, icons, classNames: c }: WikiToolbarProps): import("react").JSX.Element;
95
+ export declare function WikiToolbar({ editor, prompt, onUpload, icons, classNames: c, children }: WikiToolbarProps): import("react").JSX.Element;
103
96
  export interface UrlPromptFieldProps {
104
97
  prompt: UrlPromptState;
98
+ /** The link field's placeholder, where the wiki's own paths differ from a full address. */
105
99
  placeholders?: {
106
100
  link?: string;
107
- embed?: string;
108
101
  };
109
102
  classNames?: {
110
103
  row?: string;
@@ -170,14 +163,31 @@ export interface WikiEditorState {
170
163
  handleFileChange: (e: ChangeEvent<HTMLInputElement>) => Promise<void>;
171
164
  /** Open the file picker — what an upload toolbar button calls. */
172
165
  triggerUpload: () => void;
173
- isActive: (a?: ActiveDescriptor) => boolean;
174
166
  }
175
167
  /**
176
- * The editor, its upload plumbing and the state a toolbar reads.
177
- *
178
- * The toolbar itself stays with the consumer: the two wikis differ on which
179
- * buttons exist, which icon set draws them, and whether a link is entered in a
180
- * `window.prompt` or an inline field. Those are real differences. What they had
181
- * no business differing on is everything below.
168
+ * The editor and its upload plumbing. `WikiToolbar` given the editor draws
169
+ * the buttons its extensions allow; the icons and classes are the app's.
182
170
  */
183
171
  export declare function useWikiEditor({ value, onChange, placeholder, nodes, proseClass, uploadImage, debounceMs, }: WikiEditorOptions): WikiEditorState;
172
+ /** What a one-line field may keep. With none, its value is plain text. */
173
+ export type LineMark = 'bold' | 'italic' | 'link';
174
+ export interface WikiLineProps {
175
+ as: 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'div' | 'small' | 'figcaption';
176
+ value: string;
177
+ onChange: (value: string) => void;
178
+ /** Shown while it is empty, and its accessible name. */
179
+ placeholder: string;
180
+ marks?: readonly LineMark[];
181
+ className?: string;
182
+ /** The editor, as it takes focus: what a toolbar shared between fields claims. */
183
+ onFocus?: (editor: Editor) => void;
184
+ }
185
+ /**
186
+ * One line of words – a title, a heading, a caption, a button – edited in the
187
+ * element that shows it, so it reads as the page draws it. Its schema is the
188
+ * line and the `marks` it may keep, so nothing else can get in: no list from a
189
+ * typed "- ", no heading from a paste. Its value is that HTML, or plain text
190
+ * when it keeps no marks. It has its own undo, and `WikiToolbar` given its
191
+ * editor shows undo and its marks. `base.css` gives it its placeholder.
192
+ */
193
+ export declare function WikiLine({ as: Tag, value, onChange, placeholder, marks, className, onFocus }: WikiLineProps): import("react").JSX.Element;
package/dist/editor.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // editor.tsx — the wiki rich-text editor's engine, minus its chrome.
4
4
  //
5
5
  // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
@@ -30,7 +30,8 @@ import TiptapTableCell from '@tiptap/extension-table-cell';
30
30
  import TiptapTableHeader from '@tiptap/extension-table-header';
31
31
  import Placeholder from '@tiptap/extension-placeholder';
32
32
  import { useEditor } from '@tiptap/react';
33
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
+ import { Editor as TiptapEditor, Node as TiptapNode } from '@tiptap/core';
34
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
34
35
  import { toMapEmbedUrl } from './maps.js';
35
36
  import { cx } from './html.js';
36
37
  /** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
@@ -157,47 +158,57 @@ function insertEmbed(editor, url, { resolveMapUrl }) {
157
158
  }
158
159
  /** The table controls, shown only while the selection is inside a table. */
159
160
  export const TABLE_ACTIONS = [
160
- ['addColumnAfter', '+Col'],
161
- ['addRowAfter', '+Row'],
162
- ['deleteColumn', '-Col', true],
163
- ['deleteRow', '-Row', true],
164
- ['deleteTable', '-Tbl', true],
161
+ ['addColumnAfter', 'Add column'],
162
+ ['addRowAfter', 'Add row'],
163
+ ['deleteColumn', 'Remove column', true],
164
+ ['deleteRow', 'Remove row', true],
165
+ ['deleteTable', 'Remove table', true],
165
166
  ];
166
167
  /** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
167
168
  export function runTableAction(editor, command) {
168
169
  const chain = editor.chain().focus();
169
170
  chain[command]?.().run();
170
171
  }
172
+ const heading = (level, label) => ({
173
+ key: `h${level}`,
174
+ label,
175
+ needs: 'heading',
176
+ active: ['heading', { level }],
177
+ run: e => e.chain().focus().toggleHeading({ level }).run(),
178
+ });
171
179
  /**
172
- * The formatting commands, labelled. Three toolbars ran these identically and
173
- * two titled their buttons with the internal key — a screen reader said
174
- * "codeBlock", and inline code and code block shared one icon and one name.
175
- * Upload and embed stay with the caller: they are the parts that differ.
180
+ * The formatting commands, labelled, in toolbar order. Three toolbars ran these
181
+ * identically and two titled their buttons with the internal key – a screen
182
+ * reader said "codeBlock", and inline code and code block shared one icon and
183
+ * one name.
176
184
  *
177
185
  * @internal
178
186
  */
179
187
  export const TOOLBAR_ACTIONS = [
180
- { key: 'bold', label: 'Bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
181
- { key: 'italic', label: 'Italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
182
- { key: 'code', label: 'Inline code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
183
- { key: 'link', label: 'Link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
184
- { key: 'h2', label: 'Heading 2', active: ['heading', { level: 2 }], run: e => e.chain().focus().toggleHeading({ level: 2 }).run() },
185
- { key: 'h3', label: 'Heading 3', active: ['heading', { level: 3 }], run: e => e.chain().focus().toggleHeading({ level: 3 }).run() },
186
- { key: 'h4', label: 'Heading 4', active: ['heading', { level: 4 }], run: e => e.chain().focus().toggleHeading({ level: 4 }).run() },
187
- { key: 'bulletList', label: 'Bulleted list', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
188
- { key: 'orderedList', label: 'Numbered list', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
189
- { key: 'blockquote', label: 'Quote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
190
- { key: 'codeBlock', label: 'Code block', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
191
- { key: 'divider', label: 'Divider', run: e => e.chain().focus().setHorizontalRule().run() },
188
+ { key: 'undo', label: 'Undo', needs: 'undo', run: e => e.chain().focus().undo().run() },
189
+ { key: 'redo', label: 'Redo', needs: 'redo', run: e => e.chain().focus().redo().run() },
190
+ { key: 'bold', label: 'Bold', needs: 'bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
191
+ { key: 'italic', label: 'Italic', needs: 'italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
192
+ { key: 'code', label: 'Inline code', needs: 'code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
193
+ { key: 'link', label: 'Link', needs: 'link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
194
+ heading(2, 'Heading'),
195
+ heading(3, 'Subheading'),
196
+ heading(4, 'Small heading'),
197
+ { key: 'bulletList', label: 'Bulleted list', needs: 'bulletList', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
198
+ { key: 'orderedList', label: 'Numbered list', needs: 'orderedList', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
199
+ { key: 'blockquote', label: 'Quote', needs: 'blockquote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
200
+ { key: 'codeBlock', label: 'Code block', needs: 'codeBlock', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
192
201
  {
193
202
  key: 'table',
194
203
  label: 'Table',
204
+ needs: 'table',
195
205
  active: 'table',
196
206
  run: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
197
207
  },
198
208
  {
199
209
  key: 'tabs',
200
210
  label: 'Tabs',
211
+ needs: 'tabGroup',
201
212
  active: 'tabGroup',
202
213
  // Two, not one: a group of one is a heading with extra steps, and the node
203
214
  // refuses to delete its last tab anyway.
@@ -210,10 +221,43 @@ export const TOOLBAR_ACTIONS = [
210
221
  })
211
222
  .run(),
212
223
  },
224
+ { key: 'divider', label: 'Divider', needs: 'horizontalRule', run: e => e.chain().focus().setHorizontalRule().run() },
213
225
  ];
214
- /** The actions a toolbar shows, in the order it shows them. */
215
- export function toolbarActions(keys) {
216
- return keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key));
226
+ /** The groups the toolbar draws, a divider between each. Upload and embed lead the last. */
227
+ const GROUPS = [
228
+ ['undo', 'redo'],
229
+ ['bold', 'italic', 'code', 'link'],
230
+ ['h2', 'h3', 'h4'],
231
+ ['bulletList', 'orderedList', 'blockquote', 'codeBlock'],
232
+ ['table', 'tabs', 'divider'],
233
+ ];
234
+ /**
235
+ * Whether the editor can do what an action does: its schema has the node or
236
+ * mark, or it has the command, and a heading button's level is one the editor
237
+ * allows. What a toolbar shows follows from the extensions an editor was
238
+ * built with, so no app keeps a list of buttons beside its list of nodes.
239
+ *
240
+ * @internal
241
+ */
242
+ export function supports(editor, { key, needs }) {
243
+ const { nodes, marks } = editor.schema;
244
+ if (needs === 'heading') {
245
+ const levels = editor.extensionManager.extensions.find(e => e.name === 'heading')?.options.levels;
246
+ return !!nodes.heading && !!levels?.includes(Number(key.slice(1)));
247
+ }
248
+ return !!(nodes[needs] || marks[needs] || needs in editor.commands);
249
+ }
250
+ /** The editor redrawn on every change, for a toolbar whose editor is not the component's own. */
251
+ function useTransactions(editor) {
252
+ const [, redraw] = useReducer((n) => n + 1, 0);
253
+ useEffect(() => {
254
+ if (!editor)
255
+ return;
256
+ editor.on('transaction', redraw);
257
+ return () => {
258
+ editor.off('transaction', redraw);
259
+ };
260
+ }, [editor]);
217
261
  }
218
262
  /**
219
263
  * A toolbar button that says what it is and whether it is on. `aria-pressed`
@@ -223,19 +267,35 @@ export function ToolbarButton({ label, pressed, onPress, className, children, })
223
267
  return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
224
268
  }
225
269
  /**
226
- * The formatting bar three wikis had each assembled from `toolbarActions`,
227
- * `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
228
- * a link button that needs an address and so opens the prompt, a Remove link
229
- * button while the cursor is in a link, upload and embed between the format
230
- * and insert groups, and the table controls while the cursor is in a table.
231
- * Which buttons, which icons and which classes are the caller's.
270
+ * The formatting bar. It shows what the editor can do and nothing else: every
271
+ * command whose node, mark or command the editor has (`supports`), grouped,
272
+ * then upload where it takes images and embed where it has embed nodes, then
273
+ * the table controls while the cursor is in a table. Remove link follows Link
274
+ * while the cursor is in a link. The same bar drives a whole article, a one-line
275
+ * field and an editor shared between blocks; which icons and classes are the
276
+ * caller's. Every button is a `ToolbarButton`, named and `aria-pressed`.
232
277
  */
233
- export function WikiToolbar({ editor, isActive, prompt, actions, trailing = [], onUpload, embed, icons, classNames: c = {} }) {
234
- const button = ({ key, label, active, run }) => {
235
- const pressed = isActive(active) || (key === 'link' && prompt.kind === 'link');
236
- return (_jsx(ToolbarButton, { label: label, pressed: active ? pressed : undefined, onPress: () => (run ? run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[key] }, key));
278
+ export function WikiToolbar({ editor, prompt, onUpload, icons, classNames: c = {}, children }) {
279
+ useTransactions(editor);
280
+ if (!editor || editor.isDestroyed) {
281
+ return (_jsx("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: children }));
282
+ }
283
+ const button = (a) => {
284
+ const pressed = (!!a.active && (typeof a.active === 'string' ? editor.isActive(a.active) : editor.isActive(...a.active))) || (a.key === 'link' && prompt.kind === 'link');
285
+ return (_jsx(ToolbarButton, { label: a.label, pressed: a.active ? pressed : undefined, onPress: () => (a.run ? a.run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[a.key] }, a.key));
237
286
  };
238
- return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [actions.map(button), editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink })), onUpload && (_jsx(ToolbarButton, { label: "Upload image", onPress: onUpload, className: c.button, children: icons.upload })), embed && (_jsx(ToolbarButton, { label: "Embed", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed })), trailing.map(button), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] }))] }));
287
+ const extra = {
288
+ link: editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink }, "unlink")),
289
+ };
290
+ const insert = [
291
+ onUpload && editor.schema.nodes.image && (_jsx(ToolbarButton, { label: "Picture", onPress: onUpload, className: c.button, children: icons.upload }, "upload")),
292
+ (editor.schema.nodes.youtube || editor.schema.nodes.iframe) && (_jsx(ToolbarButton, { label: "Video, post or map", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed }, "embed")),
293
+ ].filter(Boolean);
294
+ const groups = GROUPS.map((keys, i) => [
295
+ ...(i === GROUPS.length - 1 ? insert : []),
296
+ ...keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key && supports(editor, a)).flatMap(a => [button(a), extra[a.key]])),
297
+ ].filter(Boolean)).filter(g => g.length);
298
+ return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [groups.map((g, i) => (_jsxs(Fragment, { children: [i > 0 && c.divider && _jsx("span", { className: c.divider, "aria-hidden": true }), g] }, i))), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] })), children] }));
239
299
  }
240
300
  /**
241
301
  * The address field under a toolbar, open while `prompt.kind` is set: Enter
@@ -245,7 +305,7 @@ export function UrlPromptField({ prompt, placeholders = {}, classNames: c = {} }
245
305
  if (!prompt.kind)
246
306
  return null;
247
307
  const link = prompt.kind === 'link';
248
- return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : (placeholders.embed ?? 'YouTube, X, Google or Apple Maps, or any iframe URL'), value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
308
+ return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : 'A YouTube, X, Google Maps or Apple Maps link', value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
249
309
  }
250
310
  /**
251
311
  * What a link or embed field does with its address. A link sets or replaces
@@ -335,12 +395,8 @@ export function uploadImageTo(endpoint) {
335
395
  };
336
396
  }
337
397
  /**
338
- * The editor, its upload plumbing and the state a toolbar reads.
339
- *
340
- * The toolbar itself stays with the consumer: the two wikis differ on which
341
- * buttons exist, which icon set draws them, and whether a link is entered in a
342
- * `window.prompt` or an inline field. Those are real differences. What they had
343
- * no business differing on is everything below.
398
+ * The editor and its upload plumbing. `WikiToolbar` given the editor draws
399
+ * the buttons its extensions allow; the icons and classes are the app's.
344
400
  */
345
401
  export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], proseClass, uploadImage, debounceMs = 150, }) {
346
402
  const fileInputRef = useRef(null);
@@ -413,6 +469,77 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
413
469
  fileInputRef.current.value = '';
414
470
  }, [editor, uploadImage]);
415
471
  const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
416
- const isActive = useCallback((a) => a ? Boolean(Array.isArray(a) ? editor?.isActive(a[0], a[1]) : editor?.isActive(a)) : false, [editor]);
417
- return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload, isActive };
472
+ return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload };
473
+ }
474
+ // A line's whole document is the line, so Enter has nowhere to go.
475
+ const LineDoc = TiptapNode.create({
476
+ name: 'doc',
477
+ topNode: true,
478
+ content: 'text*',
479
+ addKeyboardShortcuts: () => ({ Enter: () => true, 'Shift-Enter': () => true }),
480
+ });
481
+ /** A paste as one line: its blocks and breaks run on with a space, before the schema keeps only the marks the line takes. */
482
+ const pastedLine = (html) => cleanPastedHtml(html).replace(/<\/(p|h[1-6]|li|div|blockquote|tr|td|th)>|<br\s*\/?>/gi, ' ');
483
+ /**
484
+ * One line of words – a title, a heading, a caption, a button – edited in the
485
+ * element that shows it, so it reads as the page draws it. Its schema is the
486
+ * line and the `marks` it may keep, so nothing else can get in: no list from a
487
+ * typed "- ", no heading from a paste. Its value is that HTML, or plain text
488
+ * when it keeps no marks. It has its own undo, and `WikiToolbar` given its
489
+ * editor shows undo and its marks. `base.css` gives it its placeholder.
490
+ */
491
+ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], className, onFocus }) {
492
+ const ref = useRef(null);
493
+ const [editor, setEditor] = useState(null);
494
+ const latest = useRef({ onChange, onFocus });
495
+ useEffect(() => {
496
+ latest.current = { onChange, onFocus };
497
+ });
498
+ const key = marks.join();
499
+ const out = (e) => (key ? e.getHTML() : e.getText()).replace(/\s+/g, ' ');
500
+ const content = (v) => (key ? v : { type: 'doc', content: v ? [{ type: 'text', text: v }] : [] });
501
+ // Mounted on the element itself: the element is the editable root.
502
+ useLayoutEffect(() => {
503
+ const has = (m) => key.split(',').includes(m);
504
+ const e = new TiptapEditor({
505
+ element: { mount: ref.current },
506
+ extensions: [
507
+ LineDoc,
508
+ StarterKit.configure({
509
+ document: false,
510
+ paragraph: false,
511
+ heading: false,
512
+ blockquote: false,
513
+ bulletList: false,
514
+ orderedList: false,
515
+ listItem: false,
516
+ codeBlock: false,
517
+ code: false,
518
+ horizontalRule: false,
519
+ hardBreak: false,
520
+ strike: false,
521
+ dropcursor: false,
522
+ gapcursor: false,
523
+ ...(has('bold') ? {} : { bold: false }),
524
+ ...(has('italic') ? {} : { italic: false }),
525
+ }),
526
+ ...(has('link') ? [wikiLink] : []),
527
+ ],
528
+ content: content(value),
529
+ editorProps: { transformPastedHTML: pastedLine, transformPastedText: t => t.replace(/\s+/g, ' ') },
530
+ onUpdate: ({ editor }) => latest.current.onChange(out(editor)),
531
+ onFocus: ({ editor }) => latest.current.onFocus?.(editor),
532
+ });
533
+ setEditor(e);
534
+ return () => e.destroy();
535
+ // The value is read once here; the effect below takes later ones.
536
+ // eslint-disable-next-line react-hooks/exhaustive-deps
537
+ }, [key]);
538
+ // Take a change from outside, never one the field itself just reported.
539
+ useEffect(() => {
540
+ if (editor && !editor.isFocused && out(editor) !== value)
541
+ editor.commands.setContent(content(value));
542
+ // eslint-disable-next-line react-hooks/exhaustive-deps
543
+ }, [editor, value]);
544
+ return _jsx(Tag, { ref: ref, className: className, "data-wiki-line": "", "data-placeholder": placeholder, "aria-label": placeholder });
418
545
  }
package/dist/react.d.ts CHANGED
@@ -479,14 +479,13 @@ export declare function isRailLinkActive(activePath: string, href: string, tree?
479
479
  export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
480
480
  /**
481
481
  * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
482
- * buttons, sortable tables. All three renderers ran the same three in an
483
- * effect, and they disagreed on when: two ran it once on mount, so a page
484
- * swapped in without a remount kept its old tables unsortable. Each pass is
485
- * idempotent, so running on every `deps` change is safe.
482
+ * buttons, sortable tables, and each post on X hydrated and kept at the height
483
+ * it posts. All three renderers ran the same passes in an effect, and they
484
+ * disagreed on when: two ran it once on mount, so a page swapped in without a
485
+ * remount kept its old tables unsortable. Each pass is idempotent, so running
486
+ * on every `deps` change is safe.
486
487
  */
487
488
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
488
- /** Tweet placeholders hydrated, and each iframe kept at the height it posts. */
489
- export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
490
489
  /**
491
490
  * Counts the page views of the app it is mounted in: place it once, in the root
492
491
  * layout. `endpoint` is the route that hands the beacon to `collect`.
package/dist/react.js CHANGED
@@ -883,10 +883,11 @@ const decodePath = (path) => {
883
883
  // ---- rendered-article passes --------------------------------------------------
884
884
  /**
885
885
  * The `wiki-formant/dom` passes over a rendered article: tab groups, copy
886
- * buttons, sortable tables. All three renderers ran the same three in an
887
- * effect, and they disagreed on when: two ran it once on mount, so a page
888
- * swapped in without a remount kept its old tables unsortable. Each pass is
889
- * idempotent, so running on every `deps` change is safe.
886
+ * buttons, sortable tables, and each post on X hydrated and kept at the height
887
+ * it posts. All three renderers ran the same passes in an effect, and they
888
+ * disagreed on when: two ran it once on mount, so a page swapped in without a
889
+ * remount kept its old tables unsortable. Each pass is idempotent, so running
890
+ * on every `deps` change is safe.
890
891
  */
891
892
  export function useArticlePasses(ref, deps) {
892
893
  useEffect(() => {
@@ -896,15 +897,6 @@ export function useArticlePasses(ref, deps) {
896
897
  activateTabGroups(root);
897
898
  addCopyButtons(root);
898
899
  sortTables(root);
899
- // eslint-disable-next-line react-hooks/exhaustive-deps
900
- }, deps);
901
- }
902
- /** Tweet placeholders hydrated, and each iframe kept at the height it posts. */
903
- export function useTweetEmbeds(ref, deps) {
904
- useEffect(() => {
905
- const root = ref.current;
906
- if (!root)
907
- return;
908
900
  hydrateTweetEmbeds(root);
909
901
  return onTweetResize((height, source) => sizeTweetEmbed(root, height, source));
910
902
  // eslint-disable-next-line react-hooks/exhaustive-deps
package/dist/tiptap.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface CodeBlockOptions {
61
61
  };
62
62
  }
63
63
  /** A code block whose language is pickable from a dropdown. */
64
- export declare function createCodeBlock({ langs, defaultLang, classNames, icons, }?: CodeBlockOptions): TiptapNode<import("@tiptap/extension-code-block").CodeBlockOptions, any>;
64
+ export declare function createCodeBlock({ langs, defaultLang, classNames: own, icons, }?: CodeBlockOptions): TiptapNode<import("@tiptap/extension-code-block").CodeBlockOptions, any>;
65
65
  export interface TabsClassNames {
66
66
  editor?: string;
67
67
  list?: string;
@@ -82,12 +82,7 @@ export interface TabsOptions {
82
82
  /** Names a newly added tab. Receives the 1-based position. */
83
83
  tabLabel?: (index: number) => string;
84
84
  }
85
- /**
86
- * A tab group and its items, which must be registered together — `tabGroup`'s
87
- * content expression is `tabItem+`, so registering one without the other leaves
88
- * a node type the schema cannot satisfy.
89
- */
90
- export declare function createTabs({ classNames, icons, tabLabel, }?: TabsOptions): {
85
+ export declare function createTabs({ classNames: own, icons: ownIcons, tabLabel, }?: TabsOptions): {
91
86
  TabGroup: TiptapNode<any, any>;
92
87
  TabItem: TiptapNode<any, any>;
93
88
  };
package/dist/tiptap.js CHANGED
@@ -245,14 +245,24 @@ export const embedNodes = ({ resolveMapUrl = resolveMapRoute } = {}) => [
245
245
  TwitterEmbed,
246
246
  createMapEmbed({ resolveMapUrl }),
247
247
  ];
248
+ /** The classes `base.css` draws a code block's language picker with, each replaceable. */
249
+ const CODE_BLOCK_CLASSES = {
250
+ wrapper: 'code-block',
251
+ control: 'code-block-control',
252
+ button: 'code-block-button',
253
+ dropdown: 'code-block-menu',
254
+ option: 'code-block-option',
255
+ optionActive: 'active',
256
+ };
248
257
  /** A code block whose language is pickable from a dropdown. */
249
- export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG, classNames = {}, icons = {}, } = {}) {
258
+ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG, classNames: own = {}, icons = {}, } = {}) {
259
+ const classNames = { ...CODE_BLOCK_CLASSES, ...own };
250
260
  function CodeBlockView({ node, updateAttributes, }) {
251
261
  const [open, setOpen] = useState(false);
252
262
  const close = useCallback(() => setOpen(false), []);
253
263
  const dropdownRef = useClickOutside(close);
254
264
  const lang = node.attrs.language || defaultLang;
255
- return (_jsxs(NodeViewWrapper, { className: classNames.wrapper, children: [_jsxs("div", { ref: dropdownRef, className: classNames.control, children: [_jsxs("button", { type: "button", onClick: () => setOpen(!open), className: classNames.button, children: [icons.lang, _jsx("span", { children: lang }), icons.chevron?.(open)] }), open && (_jsx("div", { className: classNames.dropdown, children: langs.map(l => (_jsxs("button", { type: "button", onClick: () => {
265
+ return (_jsxs(NodeViewWrapper, { className: classNames.wrapper, children: [_jsxs("div", { ref: dropdownRef, className: classNames.control, children: [_jsxs("button", { type: "button", onClick: () => setOpen(!open), className: classNames.button, "aria-label": `Language: ${lang}`, "aria-expanded": open, children: [icons.lang, _jsx("span", { children: lang }), icons.chevron?.(open)] }), open && (_jsx("div", { className: classNames.dropdown, children: langs.map(l => (_jsxs("button", { type: "button", onClick: () => {
256
266
  updateAttributes({ language: l });
257
267
  setOpen(false);
258
268
  }, className: cx(classNames.option, l === lang && classNames.optionActive), children: [l, l === lang && icons.selected] }, l))) }))] }), _jsx("pre", { children: _jsx(NodeViewContent, { as: "code", className: `language-${lang}` }) })] }));
@@ -277,7 +287,20 @@ export function createCodeBlock({ langs = CODE_LANGS, defaultLang = DEFAULT_LANG
277
287
  * content expression is `tabItem+`, so registering one without the other leaves
278
288
  * a node type the schema cannot satisfy.
279
289
  */
280
- export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab ${i}`, } = {}) {
290
+ /** The classes `base.css` draws the tab editor with, each replaceable. */
291
+ const TABS_CLASSES = {
292
+ editor: 'tabs-editor',
293
+ list: 'tabs-list',
294
+ tab: 'tab-button',
295
+ tabActive: 'active',
296
+ title: 'tab-title',
297
+ remove: 'tab-remove',
298
+ add: 'tab-add',
299
+ content: 'tabs-content',
300
+ };
301
+ export function createTabs({ classNames: own = {}, icons: ownIcons = {}, tabLabel = i => `Tab ${i}`, } = {}) {
302
+ const classNames = { ...TABS_CLASSES, ...own };
303
+ const icons = { add: '+', remove: '×', ...ownIcons };
281
304
  function TabGroupView({ node, getPos, editor, updateAttributes, }) {
282
305
  const activeTab = node.attrs.activeTab ?? 0;
283
306
  const tabs = node.content.content || [];
@@ -313,7 +336,7 @@ export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab $
313
336
  tr.setNodeMarkup(posOf(index), undefined, { title });
314
337
  editor.view.dispatch(tr);
315
338
  };
316
- return (_jsx(NodeViewWrapper, { "data-tabs": "", "data-active-tab": activeTab, children: _jsxs("div", { className: classNames.editor, children: [_jsxs("div", { className: classNames.list, children: [tabs.map((tab, i) => (_jsxs("div", { className: cx(classNames.tab, activeTab === i && classNames.tabActive), children: [_jsx("input", { type: "text", value: tab.attrs.title, onChange: e => renameTab(i, e.target.value), onClick: () => setActiveTab(i), className: classNames.title }), tabs.length > 1 && (_jsx("button", { type: "button", onClick: () => removeTab(i), className: classNames.remove, children: icons.remove }))] }, i))), _jsx("button", { type: "button", onClick: addTab, className: classNames.add, children: icons.add })] }), _jsx("div", { className: classNames.content, children: _jsx(NodeViewContent, {}) })] }) }));
339
+ return (_jsx(NodeViewWrapper, { "data-tabs": "", "data-active-tab": activeTab, children: _jsxs("div", { className: classNames.editor, children: [_jsxs("div", { className: classNames.list, children: [tabs.map((tab, i) => (_jsxs("div", { className: cx(classNames.tab, activeTab === i && classNames.tabActive), children: [_jsx("input", { type: "text", value: tab.attrs.title, onChange: e => renameTab(i, e.target.value), onClick: () => setActiveTab(i), className: classNames.title, "aria-label": "Tab name" }), tabs.length > 1 && (_jsx("button", { type: "button", onClick: () => removeTab(i), className: classNames.remove, "aria-label": "Remove this tab", title: "Remove this tab", children: icons.remove }))] }, i))), _jsx("button", { type: "button", onClick: addTab, className: classNames.add, "aria-label": "Add a tab", title: "Add a tab", children: icons.add })] }), _jsx("div", { className: classNames.content, children: _jsx(NodeViewContent, {}) })] }) }));
317
340
  }
318
341
  const TabGroup = TiptapNode.create({
319
342
  name: 'tabGroup',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.80.0",
3
+ "version": "0.82.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",