duckfn-docs-kit 0.3.0 → 0.4.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.
Files changed (47) hide show
  1. package/AGENTS.md +326 -234
  2. package/README.md +10 -6
  3. package/dist/IconButton.d.ts +22 -0
  4. package/dist/codemirror.d.ts +29 -0
  5. package/dist/download.d.ts +51 -0
  6. package/dist/index.d.ts +28 -7
  7. package/dist/index.js +2 -2
  8. package/dist/mermaid/DfkMermaid.d.ts +25 -0
  9. package/dist/mermaid/config.d.ts +48 -0
  10. package/dist/mermaid/remark.d.ts +40 -0
  11. package/dist/mermaid/remark.js +32 -0
  12. package/dist/mermaid/render.d.ts +92 -0
  13. package/dist/mermaid/styles.d.ts +6 -0
  14. package/dist/panzoom-view.d.ts +17 -0
  15. package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
  16. package/dist/remark.d.ts +1 -1
  17. package/dist/source-dialog.d.ts +35 -0
  18. package/dist/sql/PreviewTabs.d.ts +39 -23
  19. package/dist/sql/SvgViewer.d.ts +53 -0
  20. package/dist/sql/client.js +1 -1
  21. package/dist/sql/harness.js +1 -1
  22. package/dist/sql/remark.d.ts +6 -1
  23. package/dist/sql/renderers.d.ts +9 -0
  24. package/package.json +6 -2
  25. package/src/IconButton.ts +50 -0
  26. package/src/codemirror.ts +97 -0
  27. package/src/download.ts +169 -0
  28. package/src/index.ts +31 -7
  29. package/src/mermaid/DfkMermaid.css +326 -0
  30. package/src/mermaid/DfkMermaid.ts +411 -0
  31. package/src/mermaid/config.ts +74 -0
  32. package/src/mermaid/remark.ts +98 -0
  33. package/src/mermaid/render.ts +172 -0
  34. package/src/mermaid/styles.ts +24 -0
  35. package/src/panzoom-view.ts +235 -0
  36. package/src/register.ts +3 -0
  37. package/src/remark.ts +1 -1
  38. package/src/source-dialog.ts +126 -0
  39. package/src/sql/DfkSql.css +13 -10
  40. package/src/sql/DfkSql.ts +60 -51
  41. package/src/sql/PreviewTabs.ts +98 -52
  42. package/src/sql/SvgViewer.ts +155 -0
  43. package/src/sql/remark.ts +6 -1
  44. package/src/sql/renderers.ts +433 -159
  45. package/src/sql/sql.css +189 -19
  46. package/dist/sql/editor.d.ts +0 -16
  47. package/src/sql/editor.ts +0 -75
@@ -0,0 +1,411 @@
1
+ import {IconButton} from '../IconButton';
2
+ import {el, HTMLElementBase} from '../dom';
3
+ import {saveDownload, sectionFileName, type DownloadPayload} from '../download';
4
+ import {PanZoomView} from '../panzoom-view';
5
+ import {SourceDialog} from '../source-dialog';
6
+ import {parseMermaidConfig, type DfkMermaidConfig, type MermaidColorMode} from './config';
7
+ import {documentColorMode, parseMermaidSvg, renderMermaid, serializeMermaidSvg, watchColorMode} from './render';
8
+ import {mermaidStyles} from './styles';
9
+
10
+ /**
11
+ * `<dfk-mermaid>` — a ```mermaid fence rendered as a diagram, produced by
12
+ * `remarkMermaid`.
13
+ *
14
+ * This element *is* the kit's mermaid integration: it replaces
15
+ * `@docusaurus/theme-mermaid`, whose React component cannot avoid the two
16
+ * upstream defects `./render.ts` documents (the dark-mode first-load flash and
17
+ * the empty diagram, both from rendering twice and concurrently). Because the
18
+ * rendering happens here rather than inside a React tree, the same code serves
19
+ * the runnable-SQL `mermaid` output — see the renderer in `sql/renderers.ts`.
20
+ *
21
+ * Two ways to use the element:
22
+ *
23
+ * - **Standalone** (a fence, the default) — the diagram floats the usual icon
24
+ * cluster in its top-right corner: reset zoom, source editing, download, and a
25
+ * fullscreen toggle of its own; zoom and pan turn on only in that fullscreen.
26
+ * - **Embedded** (`embedded`, set by the runnable-SQL renderer) — the element
27
+ * sheds its frame and its floating cluster, because the SQL result area already
28
+ * draws both. Its own zoom controls travel out as {@link actions} (reset zoom /
29
+ * source editing) for the result's tab strip, its download travels out as
30
+ * {@link downloadPayload} for the same strip's download button, and zoom is
31
+ * driven from outside by {@link setFullscreen} — the result area's fullscreen,
32
+ * which the element then fills.
33
+ *
34
+ * Everything lives in the shadow root, diagram included: mermaid ships an inline
35
+ * `<style>` inside every SVG it renders, and one shadow root per diagram is what
36
+ * keeps those styles from leaking into the page (and into each other).
37
+ *
38
+ * Retained-mode: the structure is built once in the constructor and held in
39
+ * fields; the `#render*` / `#set*` helpers mutate the nodes they own. The only
40
+ * wholesale replacement is the rendered SVG itself — a new render *is* a new
41
+ * document, the same way a new query result is.
42
+ *
43
+ * Content entry is an **attribute seed** (see CONVENTIONS.md rule 5 exception):
44
+ * `source` / `config` / `embedded` are read once, because none of the producers —
45
+ * the remark plugin, the runnable-SQL `mermaid` renderer — has a React mount point
46
+ * to call a setter from. Reading once to initialise is not an attribute→render
47
+ * loop, so the retained-mode contract still holds. Seeding is deferred until the
48
+ * first need ({@link #ensureSeeded}) rather than pinned to `connectedCallback`:
49
+ * the SQL renderer has to reach {@link actions} *before* inserting the element, to
50
+ * hand the container to the tab strip it is building.
51
+ */
52
+
53
+ type MermaidLabels = {
54
+ reset: string;
55
+ fullscreen: string;
56
+ exitFullscreen: string;
57
+ edit: string;
58
+ download: string;
59
+ apply: string;
60
+ cancel: string;
61
+ editTitle: string;
62
+ rendering: string;
63
+ renderFailed: string;
64
+ };
65
+
66
+ const LABELS: Record<string, MermaidLabels> = {
67
+ en: {
68
+ reset: 'Reset zoom',
69
+ fullscreen: 'Fullscreen',
70
+ exitFullscreen: 'Exit fullscreen',
71
+ edit: 'Edit diagram source',
72
+ download: 'Download SVG',
73
+ apply: 'Apply',
74
+ cancel: 'Cancel',
75
+ editTitle: 'Mermaid source',
76
+ rendering: 'Rendering diagram…',
77
+ renderFailed: 'Could not render the diagram',
78
+ },
79
+ 'zh-hans': {
80
+ reset: '还原缩放',
81
+ fullscreen: '全屏',
82
+ exitFullscreen: '退出全屏',
83
+ edit: '编辑图表源码',
84
+ download: '下载 SVG',
85
+ apply: '应用',
86
+ cancel: '取消',
87
+ editTitle: 'Mermaid 源码',
88
+ rendering: '正在渲染图表…',
89
+ renderFailed: '图表渲染失败',
90
+ },
91
+ };
92
+
93
+ /** What an embedded diagram is called when nothing on the page says otherwise. */
94
+ const FALLBACK_FILE = 'mermaid-diagram';
95
+
96
+ export class DfkMermaid extends HTMLElementBase {
97
+ readonly #canvas = el('div', {class: 'dfk-mermaid-canvas', hidden: true});
98
+ /**
99
+ * The panzoom viewport, which also clips: the transform runs on the content
100
+ * box *inside* it, so a zoomed diagram cannot spill over the page.
101
+ */
102
+ readonly #viewport = el('div', {class: 'dfk-mermaid-viewport'});
103
+ /** The transform target; holds the rendered `<svg>` and nothing else. */
104
+ readonly #content = el('div', {class: 'dfk-mermaid-content'});
105
+ readonly #view = new PanZoomView(this.#viewport, this.#content);
106
+ /**
107
+ * The zoom/source controls. Placed inside `#canvas` (floating) when standalone
108
+ * and handed out through {@link actions} when embedded; the class name is set at
109
+ * seed time, because which stylesheet has to reach it depends on that.
110
+ */
111
+ readonly #actions = el('div');
112
+ readonly #message = el('p', {
113
+ class: 'dfk-mermaid-message',
114
+ attrs: {'aria-live': 'polite'},
115
+ hidden: true,
116
+ });
117
+ readonly #resetBtn = new IconButton('lucide:rotate-ccw', () => this.#view.reset());
118
+ readonly #editBtn = new IconButton('lucide:pencil', () => this.#openEditor());
119
+ /** Standalone only; embedded diagrams download through the result chrome. */
120
+ #downloadBtn: IconButton | null = null;
121
+ /** Standalone only; embedded diagrams zoom in the result area's fullscreen. */
122
+ #fullscreenBtn: IconButton | null = null;
123
+ readonly #dialog = new SourceDialog({
124
+ title: LABELS.en.editTitle,
125
+ apply: LABELS.en.apply,
126
+ cancel: LABELS.en.cancel,
127
+ });
128
+
129
+ #labels: MermaidLabels = LABELS.en;
130
+ #config: DfkMermaidConfig = parseMermaidConfig(null);
131
+ #source = '';
132
+ #embedded = false;
133
+ /**
134
+ * The diagram currently on screen, held as a *node* rather than as mermaid's
135
+ * returned string: the download re-serialises it (`serializeMermaidSvg`), which
136
+ * is the only way to get well-formed SVG out of mermaid's HTML-serialised
137
+ * output. `null` until a diagram renders.
138
+ */
139
+ #svg: SVGElement | null = null;
140
+ #colorMode: MermaidColorMode | null = null;
141
+ #seeded = false;
142
+ /** Standalone fullscreen, which is also this element's zoom switch. */
143
+ #expanded = false;
144
+ /** Embedded fullscreen, driven from outside; also the zoom switch. */
145
+ #fullscreen = false;
146
+ /** Whether the document-level Esc handler is currently attached. */
147
+ #escBound = false;
148
+ /** Invalidates an in-flight render when a newer one starts or the element leaves. */
149
+ #renderToken = 0;
150
+ #unwatchColorMode: (() => void) | null = null;
151
+
152
+ readonly #onEsc = (event: KeyboardEvent): void => {
153
+ if (event.key === 'Escape' && this.#expanded && !this.#dialog.root.open) {
154
+ this.#setExpanded(false);
155
+ }
156
+ };
157
+ readonly #onColorModeChange = (): void => {
158
+ if (documentColorMode() !== this.#colorMode) {
159
+ void this.#render();
160
+ }
161
+ };
162
+
163
+ constructor() {
164
+ super();
165
+ this.#viewport.appendChild(this.#content);
166
+ this.#canvas.appendChild(this.#viewport);
167
+
168
+ const shadow = this.attachShadow({mode: 'open'});
169
+ shadow.adoptedStyleSheets = [mermaidStyles()];
170
+ // No slot: nothing is ever handed in as a child (the source travels as an
171
+ // attribute), so the light DOM stays empty and there is nothing to hide.
172
+ shadow.append(this.#canvas, this.#message, this.#dialog.root);
173
+ this.#applyLabels();
174
+ }
175
+
176
+ /**
177
+ * The zoom/source controls an embedded diagram offers, for the host to place in
178
+ * its own chrome. Empty (and unused) when standalone — the element keeps them.
179
+ */
180
+ get actions(): HTMLElement {
181
+ this.#ensureSeeded();
182
+ return this.#actions;
183
+ }
184
+
185
+ connectedCallback(): void {
186
+ this.#ensureSeeded();
187
+ // Registered here rather than in the constructor: a listener on an *external*
188
+ // object has to be paired with a removal, and connect/disconnect is where
189
+ // that pairing is observable (React may remount the element).
190
+ this.#unwatchColorMode ??= watchColorMode(this.#onColorModeChange);
191
+ // Only when there is nothing on screen: a reconnect after a move already
192
+ // carries its diagram, and re-rendering it would flash for no reason.
193
+ if (this.#source && !this.#content.hasChildNodes()) {
194
+ void this.#render();
195
+ }
196
+ }
197
+
198
+ disconnectedCallback(): void {
199
+ this.#setExpanded(false);
200
+ this.#unwatchColorMode?.();
201
+ this.#unwatchColorMode = null;
202
+ // Panzoom binds move/up on `document`, so it outlives the element unless it
203
+ // is torn down here.
204
+ this.#view.destroy();
205
+ this.#renderToken += 1;
206
+ this.#dialog.destroy();
207
+ }
208
+
209
+ /**
210
+ * Turns zoom/pan on or off for an embedded diagram. The host calls this with its
211
+ * own fullscreen state: an embedded diagram zooms exactly where its result area
212
+ * is expanded, and fills that area while it is.
213
+ */
214
+ setFullscreen(value: boolean): void {
215
+ this.#ensureSeeded();
216
+ this.#fullscreen = value;
217
+ this.classList.toggle('dfk-mermaid-fullscreen', value);
218
+ this.#view.setActive(value);
219
+ }
220
+
221
+ /**
222
+ * The file this diagram would be saved as, or `null` while nothing is rendered.
223
+ * An embedded diagram hands this to the result chrome's download button, which
224
+ * is why the element does not save it itself.
225
+ */
226
+ downloadPayload(): DownloadPayload | null {
227
+ if (this.#svg === null) {
228
+ return null;
229
+ }
230
+ return {
231
+ name: sectionFileName(this, 'svg', {source: this.#source, fallback: FALLBACK_FILE}),
232
+ mime: 'image/svg+xml;charset=utf-8',
233
+ text: serializeMermaidSvg(this.#svg),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Reads the `source` / `config` / `embedded` attributes once, and builds the
239
+ * part of the structure that depends on the mode. Idempotent, and callable
240
+ * before connection (see {@link actions}): the SQL renderer reads the attributes
241
+ * it set through `el()` before it inserts the element.
242
+ */
243
+ #ensureSeeded(): void {
244
+ if (this.#seeded) {
245
+ return;
246
+ }
247
+ this.#seeded = true;
248
+ this.#seed();
249
+ }
250
+
251
+ #seed(): void {
252
+ this.#labels =
253
+ LABELS[(document.documentElement.getAttribute('lang') ?? 'en').toLowerCase()] ??
254
+ LABELS.en;
255
+ const source = this.getAttribute('source');
256
+ if (source !== null) {
257
+ this.#source = source;
258
+ }
259
+ this.#config = parseMermaidConfig(this.getAttribute('config'));
260
+ this.#embedded = this.hasAttribute('embedded');
261
+ if (this.#embedded) {
262
+ // The result chrome places this container and styles it (see `sql.css`);
263
+ // the class is neutral because a shadow boundary is not involved here.
264
+ this.#actions.className = 'dfk-sql-tab-actions';
265
+ this.#actions.append(this.#resetBtn.root, this.#editBtn.root);
266
+ } else {
267
+ this.#downloadBtn = new IconButton('lucide:download', () => this.#download());
268
+ this.#fullscreenBtn = new IconButton('lucide:maximize', () =>
269
+ this.#setExpanded(!this.#expanded),
270
+ );
271
+ this.#actions.className = 'dfk-mermaid-actions';
272
+ this.#actions.append(
273
+ this.#resetBtn.root,
274
+ this.#editBtn.root,
275
+ this.#downloadBtn.root,
276
+ this.#fullscreenBtn.root,
277
+ );
278
+ this.#canvas.appendChild(this.#actions);
279
+ }
280
+ this.#applyLabels();
281
+ this.#setActionsAvailable(false);
282
+ }
283
+
284
+ #applyLabels(): void {
285
+ this.#resetBtn.setLabel(this.#labels.reset);
286
+ this.#editBtn.setLabel(this.#labels.edit);
287
+ this.#downloadBtn?.setLabel(this.#labels.download);
288
+ this.#fullscreenBtn?.setLabel(
289
+ this.#expanded ? this.#labels.exitFullscreen : this.#labels.fullscreen,
290
+ );
291
+ this.#dialog.setLabels({
292
+ title: this.#labels.editTitle,
293
+ apply: this.#labels.apply,
294
+ cancel: this.#labels.cancel,
295
+ });
296
+ }
297
+
298
+ // --- Rendering -------------------------------------------------------------
299
+
300
+ /**
301
+ * Renders the source for the page's current colour mode and puts the result on
302
+ * screen. Re-entrancy is handled by {@link #renderToken}: a render that was
303
+ * superseded (a newer one started, or the element was disconnected) drops its
304
+ * result instead of racing the newer one into the DOM.
305
+ *
306
+ * The queue inside `render.ts` is what makes this safe at all — mermaid is one
307
+ * mutable singleton, so two diagrams rendering at once corrupt each other.
308
+ */
309
+ async #render(): Promise<void> {
310
+ if (!this.#source) {
311
+ return;
312
+ }
313
+ const token = (this.#renderToken += 1);
314
+ const colorMode = documentColorMode();
315
+ this.#colorMode = colorMode;
316
+ // A re-render keeps the old diagram up while the new one is in flight, so the
317
+ // status line is only for the first paint (or for an error that left nothing).
318
+ if (this.#svg === null) {
319
+ this.#setMessage(this.#labels.rendering, false);
320
+ }
321
+ try {
322
+ const output = await renderMermaid({
323
+ source: this.#source,
324
+ config: this.#config,
325
+ colorMode,
326
+ });
327
+ if (token !== this.#renderToken || !this.isConnected) {
328
+ return;
329
+ }
330
+ const svg = parseMermaidSvg(this.ownerDocument, output.svg);
331
+ if (!svg) {
332
+ throw new Error(this.#labels.renderFailed);
333
+ }
334
+ this.#svg = svg;
335
+ this.#view.setContent(svg);
336
+ // Mermaid's own hook for click handlers on nodes; it takes the container
337
+ // that holds the SVG.
338
+ output.bind?.(this.#content);
339
+ this.#canvas.hidden = false;
340
+ this.#setMessage('', false);
341
+ this.#setActionsAvailable(true);
342
+ } catch (error) {
343
+ if (token !== this.#renderToken || !this.isConnected) {
344
+ return;
345
+ }
346
+ this.#svg = null;
347
+ this.#view.setContent(null);
348
+ this.#canvas.hidden = true;
349
+ this.#setActionsAvailable(false);
350
+ this.#setMessage(`${this.#labels.renderFailed}: ${messageOf(error)}`, true);
351
+ }
352
+ }
353
+
354
+ #setMessage(text: string, isError: boolean): void {
355
+ this.#message.textContent = text;
356
+ this.#message.hidden = text === '';
357
+ this.#message.classList.toggle('dfk-mermaid-message-error', isError);
358
+ }
359
+
360
+ /** Editing and resetting only mean something once a diagram is on screen. */
361
+ #setActionsAvailable(available: boolean): void {
362
+ this.#resetBtn.root.hidden = !available;
363
+ this.#editBtn.root.hidden = !available;
364
+ if (this.#downloadBtn) {
365
+ this.#downloadBtn.root.hidden = !available;
366
+ }
367
+ }
368
+
369
+ // --- Fullscreen ------------------------------------------------------------
370
+
371
+ /** The standalone fullscreen toggle; also switches zoom on and off. */
372
+ #setExpanded(value: boolean): void {
373
+ this.#expanded = value;
374
+ this.#canvas.classList.toggle('dfk-mermaid-expanded', value);
375
+ this.#view.setActive(value);
376
+ this.#fullscreenBtn?.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
377
+ this.#applyLabels();
378
+ if (value !== this.#escBound) {
379
+ if (value) {
380
+ document.addEventListener('keydown', this.#onEsc);
381
+ } else {
382
+ document.removeEventListener('keydown', this.#onEsc);
383
+ }
384
+ this.#escBound = value;
385
+ }
386
+ }
387
+
388
+ // --- Download --------------------------------------------------------------
389
+
390
+ /** Saves the diagram as a standalone `.svg` file (see {@link downloadPayload}). */
391
+ #download(): void {
392
+ const payload = this.downloadPayload();
393
+ if (payload) {
394
+ saveDownload(payload);
395
+ }
396
+ }
397
+
398
+ // --- Source editing --------------------------------------------------------
399
+
400
+ /** Opens the source dialog; the edited text is re-rendered on Apply. */
401
+ #openEditor(): void {
402
+ this.#dialog.open(this.#source, (value) => {
403
+ this.#source = value;
404
+ void this.#render();
405
+ });
406
+ }
407
+ }
408
+
409
+ function messageOf(error: unknown): string {
410
+ return error instanceof Error ? error.message : String(error);
411
+ }
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ import type {Plugin} from 'unified';
2
+ import type {DfkMermaidConfigInput} from './config';
3
+
4
+ /**
5
+ * Turns a ```mermaid fence into a `<dfk-mermaid>` custom element, so the docs
6
+ * site renders diagrams through this kit instead of through
7
+ * `@docusaurus/theme-mermaid`.
8
+ *
9
+ * That theme's React component cannot avoid the two upstream defects
10
+ * `./render.ts` documents — it colours by `useColorMode()`, which lags behind on
11
+ * the first client render, so a dark-mode first load paints a light diagram and
12
+ * then a dark one (the flash, and occasionally an empty SVG), and mermaid's
13
+ * mutable singleton renders two diagrams at once. Doing the render in a custom
14
+ * element instead puts both fixes in one place that every duckfn-family docs site
15
+ * shares, and lets the runnable-SQL `mermaid` output reuse the same renderer.
16
+ *
17
+ * The source travels as the `source` attribute and the palette as the `config`
18
+ * one (see `./config`): React 19 reconciles string props onto a custom element as
19
+ * attributes, so both survive prerendering and hydration. The element has no
20
+ * children — the diagram is built in its shadow root, so there is no prerendered
21
+ * markup to hide (contrast `sql/remark.ts`, whose code node is the fallback the
22
+ * editor replaces).
23
+ *
24
+ * This is Node-side build code: it must not touch `window` / `document`, and it
25
+ * must not import any browser module (type-only imports are fine).
26
+ */
27
+
28
+ /** The custom element the plugin emits; must match `register.ts`. */
29
+ export const DFK_MERMAID_TAG = 'dfk-mermaid';
30
+
31
+ export interface RemarkMermaidOptions {
32
+ /**
33
+ * Overrides for the kit's default look and palette, merged by the element (see
34
+ * `resolveMermaidConfig`). This is where a site picks its mermaid colours — the
35
+ * one part of a diagram that is site-specific — so no docs site has to fork the
36
+ * kit to change two theme names.
37
+ *
38
+ * Omitted, no `config` attribute is written at all and every element falls back
39
+ * to the kit's default (`DEFAULT_MERMAID_CONFIG` in `./config`).
40
+ */
41
+ config?: DfkMermaidConfigInput;
42
+ }
43
+
44
+ interface CodeNode {
45
+ type: string;
46
+ lang?: string | null;
47
+ value?: unknown;
48
+ }
49
+
50
+ interface ParentNode {
51
+ children?: unknown[];
52
+ }
53
+
54
+ export const remarkMermaid: Plugin<[RemarkMermaidOptions?]> =
55
+ (options = {}) =>
56
+ (tree) => {
57
+ // One JSON string for the whole page rather than one per fence: the attribute
58
+ // is identical everywhere, and building it once keeps the walk cheap.
59
+ const config = options.config === undefined ? null : JSON.stringify(options.config);
60
+
61
+ const walk = (node: unknown): void => {
62
+ if (typeof node !== 'object' || node === null) {
63
+ return;
64
+ }
65
+ const parent = node as ParentNode;
66
+ if (!Array.isArray(parent.children)) {
67
+ return;
68
+ }
69
+ parent.children = parent.children.map((child) => {
70
+ if (typeof child !== 'object' || child === null) {
71
+ return child;
72
+ }
73
+ const candidate = child as CodeNode;
74
+ if (candidate.type === 'code' && candidate.lang === 'mermaid') {
75
+ return wrapMermaid(candidate, config);
76
+ }
77
+ walk(candidate);
78
+ return candidate;
79
+ });
80
+ };
81
+
82
+ walk(tree);
83
+ };
84
+
85
+ function wrapMermaid(code: CodeNode, config: string | null): Record<string, unknown> {
86
+ const attributes = [
87
+ {type: 'mdxJsxAttribute', name: 'source', value: String(code.value ?? '')},
88
+ ];
89
+ if (config !== null) {
90
+ attributes.push({type: 'mdxJsxAttribute', name: 'config', value: config});
91
+ }
92
+ return {
93
+ type: 'mdxJsxFlowElement',
94
+ name: DFK_MERMAID_TAG,
95
+ attributes,
96
+ children: [],
97
+ };
98
+ }