duckfn-docs-kit 0.3.0 → 0.4.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.
@@ -0,0 +1,557 @@
1
+ import type {PanzoomObject} from '@panzoom/panzoom';
2
+ import type {CodeEditor} from '../codemirror';
3
+ import {mountCodeEditor} from '../codemirror';
4
+ import {IconButton} from '../IconButton';
5
+ import {el, HTMLElementBase} from '../dom';
6
+ import {parseMermaidConfig, type DfkMermaidConfig, type MermaidColorMode} from './config';
7
+ import {
8
+ documentColorMode,
9
+ loadPanzoom,
10
+ parseMermaidSvg,
11
+ renderMermaid,
12
+ serializeMermaidSvg,
13
+ watchColorMode,
14
+ } from './render';
15
+ import {mermaidStyles} from './styles';
16
+ import {diagramFileName} from './title';
17
+
18
+ /**
19
+ * `<dfk-mermaid>` — a ```mermaid fence rendered as a diagram, produced by
20
+ * `remarkMermaid`.
21
+ *
22
+ * This element *is* the kit's mermaid integration: it replaces
23
+ * `@docusaurus/theme-mermaid`, whose React component cannot avoid the two
24
+ * upstream defects `./render.ts` documents (the dark-mode first-load flash and
25
+ * the empty diagram, both from rendering twice and concurrently). Because the
26
+ * rendering happens here rather than inside a React tree, the same code serves
27
+ * the runnable-SQL `mermaid` output — see the renderer in `sql/renderers.ts`.
28
+ *
29
+ * What a reader gets on top of the diagram:
30
+ *
31
+ * - **Zoom and pan** — wheel to zoom, drag to pan (`@panzoom/panzoom` on the
32
+ * content box, so the SVG node itself is never mutated; the download
33
+ * re-serialises that same untouched node, see `serializeMermaidSvg`). Panning
34
+ * only engages once the diagram is zoomed, which is what lets the page keep
35
+ * scrolling normally over a diagram that fits.
36
+ * - **Reset zoom**, **fullscreen**, **source editing** (a CodeMirror dialog),
37
+ * and **download SVG** as floating icon buttons in the top-right corner, the
38
+ * same idiom as the kit's code blocks.
39
+ *
40
+ * Everything lives in the shadow root, diagram included: mermaid ships an inline
41
+ * `<style>` inside every SVG it renders, and one shadow root per diagram is what
42
+ * keeps those styles from leaking into the page (and into each other).
43
+ *
44
+ * Retained-mode: the structure is built once in the constructor and held in
45
+ * fields; the `#render*` / `#set*` helpers mutate the nodes they own. The only
46
+ * wholesale replacement is the rendered SVG itself — a new render *is* a new
47
+ * document, the same way a new query result is.
48
+ *
49
+ * Content entry is an **attribute seed** (see CONVENTIONS.md rule 5 exception):
50
+ * `source` / `config` are read once in `connectedCallback`, because neither
51
+ * producer — the remark plugin, or the runnable-SQL `mermaid` renderer — has a
52
+ * React mount point to call a setter from. Reading once to initialise is not an
53
+ * attribute→render loop, so the retained-mode contract still holds.
54
+ */
55
+
56
+ type MermaidLabels = {
57
+ reset: string;
58
+ fullscreen: string;
59
+ exitFullscreen: string;
60
+ edit: string;
61
+ download: string;
62
+ apply: string;
63
+ cancel: string;
64
+ editTitle: string;
65
+ rendering: string;
66
+ renderFailed: string;
67
+ };
68
+
69
+ const LABELS: Record<string, MermaidLabels> = {
70
+ en: {
71
+ reset: 'Reset zoom',
72
+ fullscreen: 'Fullscreen',
73
+ exitFullscreen: 'Exit fullscreen',
74
+ edit: 'Edit diagram source',
75
+ download: 'Download SVG',
76
+ apply: 'Apply',
77
+ cancel: 'Cancel',
78
+ editTitle: 'Mermaid source',
79
+ rendering: 'Rendering diagram…',
80
+ renderFailed: 'Could not render the diagram',
81
+ },
82
+ 'zh-hans': {
83
+ reset: '还原缩放',
84
+ fullscreen: '全屏',
85
+ exitFullscreen: '退出全屏',
86
+ edit: '编辑图表源码',
87
+ download: '下载 SVG',
88
+ apply: '应用',
89
+ cancel: '取消',
90
+ editTitle: 'Mermaid 源码',
91
+ rendering: '正在渲染图表…',
92
+ renderFailed: '图表渲染失败',
93
+ },
94
+ };
95
+
96
+ /**
97
+ * The zoom range. `MIN_SCALE` is the fit-to-box scale: the diagram opens there
98
+ * and cannot go below it, which is what makes "zoomed" a clean yes/no — it is
99
+ * exactly the state in which a drag pans, the wheel has something to undo, and
100
+ * the cursor stops promising plain text.
101
+ */
102
+ const MIN_SCALE = 1;
103
+ const MAX_SCALE = 8;
104
+ const ZOOM_STEP = 0.25;
105
+
106
+ export class DfkMermaid extends HTMLElementBase {
107
+ readonly #canvas = el('div', {class: 'dfk-mermaid-canvas', hidden: true});
108
+ /**
109
+ * The panzoom viewport, which also clips: the transform runs on the content
110
+ * box *inside* it, so a zoomed diagram cannot spill over the page.
111
+ */
112
+ readonly #viewport = el('div', {class: 'dfk-mermaid-viewport'});
113
+ /** The transform target; holds the rendered `<svg>` and nothing else. */
114
+ readonly #content = el('div', {class: 'dfk-mermaid-content'});
115
+ readonly #actions = el('div', {class: 'dfk-mermaid-actions'});
116
+ readonly #message = el('p', {
117
+ class: 'dfk-mermaid-message',
118
+ attrs: {'aria-live': 'polite'},
119
+ hidden: true,
120
+ });
121
+ readonly #resetBtn: IconButton;
122
+ readonly #editBtn: IconButton;
123
+ readonly #downloadBtn: IconButton;
124
+ readonly #fullscreenBtn: IconButton;
125
+ readonly #dialog = el('dialog', {class: 'dfk-mermaid-dialog'});
126
+ readonly #dialogTitle = el('h2', {class: 'dfk-mermaid-dialog-title'});
127
+ /** Where the CodeMirror editor mounts, inside the dialog. */
128
+ readonly #editorHost = el('div', {class: 'dfk-mermaid-dialog-editor'});
129
+ readonly #applyBtn = el('button', {
130
+ class: 'dfk-mermaid-dialog-button dfk-mermaid-dialog-apply',
131
+ type: 'button',
132
+ });
133
+ readonly #cancelBtn = el('button', {class: 'dfk-mermaid-dialog-button', type: 'button'});
134
+
135
+ #labels: MermaidLabels = LABELS.en;
136
+ #config: DfkMermaidConfig = parseMermaidConfig(null);
137
+ #source = '';
138
+ /**
139
+ * The diagram currently on screen, held as a *node* rather than as mermaid's
140
+ * returned string: the download re-serialises it (`serializeMermaidSvg`), which
141
+ * is the only way to get well-formed SVG out of mermaid's HTML-serialised
142
+ * output. `null` until a diagram renders.
143
+ */
144
+ #svg: SVGElement | null = null;
145
+ #colorMode: MermaidColorMode | null = null;
146
+ #seeded = false;
147
+ #expanded = false;
148
+ /** Whether the document-level Esc handler is currently attached. */
149
+ #escBound = false;
150
+ /** Invalidates an in-flight render when a newer one starts or the element leaves. */
151
+ #renderToken = 0;
152
+ #panzoom: PanzoomObject | null = null;
153
+ #panzoomLoading = false;
154
+ #editor: CodeEditor | null = null;
155
+ #editorLoading = false;
156
+ #unwatchColorMode: (() => void) | null = null;
157
+
158
+ readonly #onEsc = (event: KeyboardEvent): void => {
159
+ if (event.key === 'Escape' && this.#expanded && !this.#dialog.open) {
160
+ this.#setExpanded(false);
161
+ }
162
+ };
163
+ readonly #onWheel = (event: WheelEvent): void => {
164
+ this.#panzoom?.zoomWithWheel(event);
165
+ };
166
+ /** The cursor follows the zoom state, so it never promises a pan that cannot happen. */
167
+ readonly #onPanzoomChange = (): void => {
168
+ this.#canvas.classList.toggle('dfk-mermaid-zoomed', this.#isZoomed());
169
+ };
170
+ readonly #onPanzoomEnd = (): void => {
171
+ this.#canvas.classList.remove('dfk-mermaid-grabbing');
172
+ };
173
+ readonly #onPanzoomStart = (): void => {
174
+ // `panzoomstart` fires at fit too, where the gesture was left to the browser;
175
+ // only a drag that will really pan gets the closed hand.
176
+ this.#canvas.classList.toggle('dfk-mermaid-grabbing', this.#isZoomed());
177
+ };
178
+ readonly #onColorModeChange = (): void => {
179
+ if (documentColorMode() !== this.#colorMode) {
180
+ void this.#render();
181
+ }
182
+ };
183
+
184
+ constructor() {
185
+ super();
186
+ this.#resetBtn = new IconButton('lucide:rotate-ccw', () => this.#resetView());
187
+ this.#editBtn = new IconButton('lucide:pencil', () => this.#openEditor());
188
+ this.#downloadBtn = new IconButton('lucide:download', () => this.#download());
189
+ this.#fullscreenBtn = new IconButton('lucide:maximize', () =>
190
+ this.#setExpanded(!this.#expanded),
191
+ );
192
+
193
+ this.#actions.append(
194
+ this.#resetBtn.root,
195
+ this.#editBtn.root,
196
+ this.#downloadBtn.root,
197
+ this.#fullscreenBtn.root,
198
+ );
199
+ this.#viewport.appendChild(this.#content);
200
+ this.#canvas.append(this.#viewport, this.#actions);
201
+ // panzoom reports state as DOM `CustomEvent`s dispatched on the element it
202
+ // transforms (`@panzoom/panzoom` v4 has no `on()` API), so they are listened
203
+ // for here, on our own node — which also means they need no teardown, and
204
+ // that re-creating the panzoom instance after a reconnect cannot double them.
205
+ this.#content.addEventListener('panzoomchange', this.#onPanzoomChange);
206
+ this.#content.addEventListener('panzoomstart', this.#onPanzoomStart);
207
+ this.#content.addEventListener('panzoomend', this.#onPanzoomEnd);
208
+
209
+ this.#applyBtn.addEventListener('click', () => this.#applyEdit());
210
+ this.#cancelBtn.addEventListener('click', () => this.#dialog.close());
211
+ this.#dialog.append(
212
+ el('div', {class: 'dfk-mermaid-dialog-body'}, (body) =>
213
+ body.append(
214
+ this.#dialogTitle,
215
+ this.#editorHost,
216
+ el('div', {class: 'dfk-mermaid-dialog-footer'}, (footer) =>
217
+ footer.append(this.#cancelBtn, this.#applyBtn),
218
+ ),
219
+ ),
220
+ ),
221
+ );
222
+
223
+ const shadow = this.attachShadow({mode: 'open'});
224
+ shadow.adoptedStyleSheets = [mermaidStyles()];
225
+ // No slot: nothing is ever handed in as a child (the source travels as an
226
+ // attribute), so the light DOM stays empty and there is nothing to hide.
227
+ shadow.append(this.#canvas, this.#message, this.#dialog);
228
+ this.#applyLabels();
229
+ }
230
+
231
+ connectedCallback(): void {
232
+ if (!this.#seeded) {
233
+ this.#seed();
234
+ this.#seeded = true;
235
+ }
236
+ // Registered here rather than in the constructor: a listener on an *external*
237
+ // object has to be paired with a removal, and connect/disconnect is where
238
+ // that pairing is observable (React may remount the element).
239
+ this.#unwatchColorMode ??= watchColorMode(this.#onColorModeChange);
240
+ void this.#ensurePanzoom();
241
+ // Only when there is nothing on screen: a reconnect after a move already
242
+ // carries its diagram, and re-rendering it would flash for no reason.
243
+ if (this.#source && !this.#content.hasChildNodes()) {
244
+ void this.#render();
245
+ }
246
+ }
247
+
248
+ disconnectedCallback(): void {
249
+ this.#setExpanded(false);
250
+ this.#unwatchColorMode?.();
251
+ this.#unwatchColorMode = null;
252
+ // Panzoom binds move/up on `document`, so it outlives the element unless it
253
+ // is torn down here.
254
+ this.#viewport.removeEventListener('wheel', this.#onWheel);
255
+ this.#panzoom?.destroy();
256
+ this.#panzoom = null;
257
+ // The zoom-state classes describe the instance that is now gone.
258
+ this.#canvas.classList.remove('dfk-mermaid-zoomed', 'dfk-mermaid-grabbing');
259
+ this.#renderToken += 1;
260
+ this.#editor?.destroy();
261
+ this.#editor = null;
262
+ if (this.#dialog.open) {
263
+ this.#dialog.close();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Reads the `source` / `config` attributes once.
269
+ *
270
+ * This is the element's *only* content entry, for both producers: the remark
271
+ * plugin emits the attributes at build time, and the runnable-SQL `mermaid`
272
+ * renderer sets them on the element it creates. Neither has a React mount point
273
+ * to call a setter from, which is the exception CONVENTIONS.md rule 5 makes for
274
+ * plugin-generated elements — and reading them once to initialise is not an
275
+ * attribute→render loop, so the retained-mode contract still holds.
276
+ *
277
+ * `source` is only applied when the attribute is actually present, so a caller
278
+ * that sets the attribute before inserting the element keeps it: the attribute
279
+ * is read at *upgrade* time, which for an element created by
280
+ * `document.createElement` after `customElements.define` is the insertion.
281
+ */
282
+ #seed(): void {
283
+ this.#labels =
284
+ LABELS[(document.documentElement.getAttribute('lang') ?? 'en').toLowerCase()] ??
285
+ LABELS.en;
286
+ const source = this.getAttribute('source');
287
+ if (source !== null) {
288
+ this.#source = source;
289
+ }
290
+ this.#config = parseMermaidConfig(this.getAttribute('config'));
291
+ this.#applyLabels();
292
+ }
293
+
294
+ #applyLabels(): void {
295
+ this.#resetBtn.setLabel(this.#labels.reset);
296
+ this.#editBtn.setLabel(this.#labels.edit);
297
+ this.#downloadBtn.setLabel(this.#labels.download);
298
+ this.#fullscreenBtn.setLabel(
299
+ this.#expanded ? this.#labels.exitFullscreen : this.#labels.fullscreen,
300
+ );
301
+ this.#dialogTitle.textContent = this.#labels.editTitle;
302
+ this.#cancelBtn.textContent = this.#labels.cancel;
303
+ this.#applyBtn.textContent = this.#labels.apply;
304
+ }
305
+
306
+ // --- Rendering -------------------------------------------------------------
307
+
308
+ /**
309
+ * Renders the source for the page's current colour mode and puts the result on
310
+ * screen. Re-entrancy is handled by {@link #renderToken}: a render that was
311
+ * superseded (a newer one started, or the element was disconnected) drops its
312
+ * result instead of racing the newer one into the DOM.
313
+ *
314
+ * The queue inside `render.ts` is what makes this safe at all — mermaid is one
315
+ * mutable singleton, so two diagrams rendering at once corrupt each other.
316
+ */
317
+ async #render(): Promise<void> {
318
+ if (!this.#source) {
319
+ return;
320
+ }
321
+ const token = (this.#renderToken += 1);
322
+ const colorMode = documentColorMode();
323
+ this.#colorMode = colorMode;
324
+ // A re-render keeps the old diagram up while the new one is in flight, so the
325
+ // status line is only for the first paint (or for an error that left nothing).
326
+ if (this.#svg === null) {
327
+ this.#setMessage(this.#labels.rendering, false);
328
+ }
329
+ try {
330
+ const output = await renderMermaid({
331
+ source: this.#source,
332
+ config: this.#config,
333
+ colorMode,
334
+ });
335
+ if (token !== this.#renderToken || !this.isConnected) {
336
+ return;
337
+ }
338
+ const svg = parseMermaidSvg(this.ownerDocument, output.svg);
339
+ if (!svg) {
340
+ throw new Error(this.#labels.renderFailed);
341
+ }
342
+ this.#svg = svg;
343
+ this.#content.replaceChildren(svg);
344
+ // Mermaid's own hook for click handlers on nodes; it takes the container
345
+ // that holds the SVG.
346
+ output.bind?.(this.#content);
347
+ this.#canvas.hidden = false;
348
+ this.#setMessage('', false);
349
+ this.#setActionsAvailable(true);
350
+ this.#resetView();
351
+ } catch (error) {
352
+ if (token !== this.#renderToken || !this.isConnected) {
353
+ return;
354
+ }
355
+ this.#svg = null;
356
+ this.#content.replaceChildren();
357
+ this.#canvas.hidden = true;
358
+ this.#setActionsAvailable(false);
359
+ this.#setMessage(`${this.#labels.renderFailed}: ${messageOf(error)}`, true);
360
+ }
361
+ }
362
+
363
+ #setMessage(text: string, isError: boolean): void {
364
+ this.#message.textContent = text;
365
+ this.#message.hidden = text === '';
366
+ this.#message.classList.toggle('dfk-mermaid-message-error', isError);
367
+ }
368
+
369
+ /** Reset zoom and download only mean something once a diagram is on screen. */
370
+ #setActionsAvailable(available: boolean): void {
371
+ this.#resetBtn.root.hidden = !available;
372
+ this.#downloadBtn.root.hidden = !available;
373
+ }
374
+
375
+ // --- Zoom and pan ----------------------------------------------------------
376
+
377
+ /**
378
+ * Binds panzoom to the content box. `@panzoom/panzoom` is an enhancement, not a
379
+ * requirement: if its chunk never arrives, the diagram still renders, only
380
+ * wheel zoom and dragging stay inert.
381
+ *
382
+ * Three settings carry the interaction contract:
383
+ *
384
+ * - `panOnlyWhenZoomed` — a diagram that already fits must not swallow drags,
385
+ * so panning only engages once it is enlarged. That is also what keeps
386
+ * `touchAction: 'pan-y'` meaningful: vertical page scrolling stays the
387
+ * browser's, pinch and horizontal drags go to the diagram.
388
+ * - `handleStartEvent` — panzoom's default takes the gesture on *every*
389
+ * pointerdown (`preventDefault` + `stopPropagation`), which costs the reader
390
+ * text selection at every zoom level. Handing the gesture over only when a
391
+ * drag will really pan is what makes the labels selectable while the diagram
392
+ * is at fit. Nothing else blocks it: panzoom's move listener is `passive` and
393
+ * never calls `preventDefault`.
394
+ * - no `cursor` option — panzoom would then put `grab` on the element for good,
395
+ * over text that is perfectly selectable. The cursor is driven by the zoom
396
+ * state instead, from `DfkMermaid.css`.
397
+ */
398
+ async #ensurePanzoom(): Promise<void> {
399
+ if (this.#panzoom !== null || this.#panzoomLoading) {
400
+ return;
401
+ }
402
+ this.#panzoomLoading = true;
403
+ try {
404
+ const Panzoom = await loadPanzoom();
405
+ if (!this.isConnected) {
406
+ return;
407
+ }
408
+ this.#viewport.addEventListener('wheel', this.#onWheel, {passive: false});
409
+ this.#panzoom = Panzoom(this.#content, {
410
+ maxScale: MAX_SCALE,
411
+ minScale: MIN_SCALE,
412
+ step: ZOOM_STEP,
413
+ panOnlyWhenZoomed: true,
414
+ touchAction: 'pan-y',
415
+ // panzoom's own default is `move`, written inline on the element — the
416
+ // cursor has to stay a CSS decision (see `DfkMermaid.css`), so it is
417
+ // switched off here.
418
+ cursor: '',
419
+ handleStartEvent: (event) => {
420
+ if (!this.#isZoomed()) {
421
+ return;
422
+ }
423
+ event.preventDefault();
424
+ event.stopPropagation();
425
+ },
426
+ });
427
+ // panzoom also forces `user-select: none` inline on the element *and its
428
+ // parent*, with no option to prevent it — that alone made every label in
429
+ // every diagram unselectable. It exists to stop a drag from selecting while
430
+ // panning, which `handleStartEvent` already covers: whenever a pan will
431
+ // happen, the gesture is taken with `preventDefault()` before the browser
432
+ // can start a selection. panzoom writes these styles only here and in
433
+ // `setOptions`, which this component never calls, so clearing them once is
434
+ // enough.
435
+ this.#content.style.userSelect = '';
436
+ this.#viewport.style.userSelect = '';
437
+ } catch {
438
+ // Nothing to report: the diagram is already usable without pan/zoom.
439
+ } finally {
440
+ this.#panzoomLoading = false;
441
+ }
442
+ }
443
+
444
+ /** Whether the diagram is enlarged past its fit-to-box size. */
445
+ #isZoomed(): boolean {
446
+ return (this.#panzoom?.getScale() ?? MIN_SCALE) > MIN_SCALE;
447
+ }
448
+
449
+ #resetView(): void {
450
+ this.#panzoom?.reset({
451
+ // Zooming back to fit is a transition the reader did not ask to skip, but
452
+ // one they may have asked not to have.
453
+ animate: !prefersReducedMotion(),
454
+ });
455
+ }
456
+
457
+ // --- Fullscreen ------------------------------------------------------------
458
+
459
+ #setExpanded(value: boolean): void {
460
+ this.#expanded = value;
461
+ this.#canvas.classList.toggle('dfk-mermaid-expanded', value);
462
+ this.#fullscreenBtn.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
463
+ this.#applyLabels();
464
+ if (value !== this.#escBound) {
465
+ if (value) {
466
+ document.addEventListener('keydown', this.#onEsc);
467
+ } else {
468
+ document.removeEventListener('keydown', this.#onEsc);
469
+ }
470
+ this.#escBound = value;
471
+ }
472
+ }
473
+
474
+ // --- Download --------------------------------------------------------------
475
+
476
+ /**
477
+ * Saves the diagram as a standalone `.svg` file.
478
+ *
479
+ * The markup is re-serialised from the rendered node, not taken from mermaid's
480
+ * return value — that one is HTML, and its void elements come out unclosed,
481
+ * which a browser opening the file as XML rejects. See `serializeMermaidSvg`.
482
+ */
483
+ #download(): void {
484
+ if (this.#svg === null) {
485
+ return;
486
+ }
487
+ const markup = serializeMermaidSvg(this.#svg);
488
+ const blob = new Blob([markup], {type: 'image/svg+xml;charset=utf-8'});
489
+ const url = URL.createObjectURL(blob);
490
+ // Named after the section the diagram sits in (see `title.ts`), so the reader
491
+ // gets `2. Registration.svg` rather than a second `mermaid-diagram.svg`.
492
+ const link = el('a', {href: url, download: diagramFileName(this.#source, this)});
493
+ // Anchored in the shadow tree for the click; a detached anchor is ignored by
494
+ // some browsers, and by then the download has already been handed to it.
495
+ this.shadowRoot?.appendChild(link);
496
+ link.click();
497
+ link.remove();
498
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
499
+ }
500
+
501
+ // --- Source editing --------------------------------------------------------
502
+
503
+ /**
504
+ * Opens the source dialog, mounting the editor on first use. The dialog is
505
+ * shown *before* the editor mounts: CodeMirror measures its container as it is
506
+ * constructed, and a `display: none` dialog measures to zero.
507
+ */
508
+ #openEditor(): void {
509
+ this.#dialog.showModal();
510
+ if (this.#editor) {
511
+ this.#editor.setValue(this.#source);
512
+ return;
513
+ }
514
+ void this.#mountEditor();
515
+ }
516
+
517
+ async #mountEditor(): Promise<void> {
518
+ if (this.#editorLoading) {
519
+ return;
520
+ }
521
+ this.#editorLoading = true;
522
+ try {
523
+ // No language: there is no first-party CodeMirror grammar for mermaid, and
524
+ // the dialog is for touching up a diagram, not writing SQL.
525
+ const editor = await mountCodeEditor(this.#editorHost, this.#source, () => undefined);
526
+ if (!this.isConnected || !this.#dialog.open) {
527
+ // Closed while the modules were loading: nothing will ever dispose this
528
+ // editor, so dispose it here.
529
+ editor.destroy();
530
+ return;
531
+ }
532
+ editor.setWrap(true);
533
+ this.#editor = editor;
534
+ } catch {
535
+ // The dialog stays open with an empty editor box; a second click retries.
536
+ } finally {
537
+ this.#editorLoading = false;
538
+ }
539
+ }
540
+
541
+ #applyEdit(): void {
542
+ const edited = this.#editor?.getValue();
543
+ this.#dialog.close();
544
+ if (edited !== undefined && edited !== this.#source) {
545
+ this.#source = edited;
546
+ void this.#render();
547
+ }
548
+ }
549
+ }
550
+
551
+ function messageOf(error: unknown): string {
552
+ return error instanceof Error ? error.message : String(error);
553
+ }
554
+
555
+ function prefersReducedMotion(): boolean {
556
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
557
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The mermaid look and palette, shared by the Node-side remark plugin (which
3
+ * stamps it onto every `<dfk-mermaid>` it emits) and the browser element (whose
4
+ * fallback when no config travels with the element).
5
+ *
6
+ * Pure data with no imports, like `sql/runtimeConfig.ts`: both sides have to
7
+ * agree on the shape, and neither may drag the other into its bundle — the
8
+ * remark plugin runs in Docusaurus' Node build, the element in the browser.
9
+ *
10
+ * Why the config travels per element instead of living in the component: the
11
+ * palette is the one part of a diagram that is *site-specific*. Passing it
12
+ * through the site's `docusaurus.config.ts` (as an option of `remarkMermaid`)
13
+ * keeps that choice where the rest of the site's theme is decided, and keeps a
14
+ * downstream docs site from having to fork the kit to change two colour names.
15
+ */
16
+
17
+ /** The two colour modes a diagram is rendered for. */
18
+ export type MermaidColorMode = 'light' | 'dark';
19
+
20
+ /** A partial config, as a site writes it. */
21
+ export interface DfkMermaidConfigInput {
22
+ /** Mermaid theme name per colour mode; missing sides fall back to the default. */
23
+ theme?: Partial<Record<MermaidColorMode, string>>;
24
+ /** Extra mermaid options, spread into `initialize()` after `theme`. */
25
+ options?: Record<string, unknown>;
26
+ }
27
+
28
+ /** A resolved config: both themes present, options ready to spread. */
29
+ export interface DfkMermaidConfig {
30
+ theme: Record<MermaidColorMode, string>;
31
+ options: Record<string, unknown>;
32
+ }
33
+
34
+ /**
35
+ * The kit's default: the `neo` look (mermaid's flatter, rounder chrome) with the
36
+ * redux palette — `redux-color` in light mode, `redux-dark-color` in dark.
37
+ *
38
+ * `look` has no per-mode counterpart in mermaid, so it belongs in `options`;
39
+ * `theme` is the per-mode one. Both are easy to get wrong *silently*: mermaid
40
+ * ignores an unrecognised value and falls back, so a change is verified in a
41
+ * browser, not by a build.
42
+ */
43
+ export const DEFAULT_MERMAID_CONFIG: DfkMermaidConfig = {
44
+ theme: {light: 'redux-color', dark: 'redux-dark-color'},
45
+ options: {look: 'neo'},
46
+ };
47
+
48
+ /** Merges a site's overrides over {@link DEFAULT_MERMAID_CONFIG}. */
49
+ export function resolveMermaidConfig(input?: DfkMermaidConfigInput | null): DfkMermaidConfig {
50
+ return {
51
+ theme: {
52
+ light: input?.theme?.light ?? DEFAULT_MERMAID_CONFIG.theme.light,
53
+ dark: input?.theme?.dark ?? DEFAULT_MERMAID_CONFIG.theme.dark,
54
+ },
55
+ options: {...DEFAULT_MERMAID_CONFIG.options, ...input?.options},
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Parses the `config` attribute the remark plugin writes. Anything unreadable
61
+ * falls back to the default rather than failing a diagram, and only the two
62
+ * known keys are read — the attribute is content, not a channel for arbitrary
63
+ * configuration.
64
+ */
65
+ export function parseMermaidConfig(raw: string | null | undefined): DfkMermaidConfig {
66
+ if (!raw) {
67
+ return resolveMermaidConfig();
68
+ }
69
+ try {
70
+ return resolveMermaidConfig(JSON.parse(raw) as DfkMermaidConfigInput);
71
+ } catch {
72
+ return resolveMermaidConfig();
73
+ }
74
+ }