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.
- package/AGENTS.md +326 -234
- package/README.md +10 -6
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/download.d.ts +51 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +25 -0
- package/dist/mermaid/config.d.ts +48 -0
- package/dist/mermaid/remark.d.ts +40 -0
- package/dist/mermaid/remark.js +32 -0
- package/dist/mermaid/render.d.ts +92 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/panzoom-view.d.ts +17 -0
- package/dist/{register-CALCwFBv.js → register-wdwf0LC4.js} +1365 -738
- package/dist/remark.d.ts +1 -1
- package/dist/source-dialog.d.ts +35 -0
- package/dist/sql/PreviewTabs.d.ts +39 -23
- package/dist/sql/SvgViewer.d.ts +53 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.js +1 -1
- package/dist/sql/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +9 -0
- package/package.json +6 -2
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +97 -0
- package/src/download.ts +169 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +326 -0
- package/src/mermaid/DfkMermaid.ts +411 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +172 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/panzoom-view.ts +235 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/source-dialog.ts +126 -0
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +60 -51
- package/src/sql/PreviewTabs.ts +98 -52
- package/src/sql/SvgViewer.ts +155 -0
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +433 -159
- package/src/sql/sql.css +189 -19
- package/dist/sql/editor.d.ts +0 -16
- 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
|
+
}
|