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
package/src/sql/DfkSql.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type {IconifyIconHTMLElement} from 'iconify-icon';
2
1
  import type {RunnableSqlConfig} from './remark';
3
2
  import {DuckDBRuntime, type QueryResult} from './runtime';
4
3
  import {rendererFor, type RenderContext} from './renderers';
5
- import {mountSqlEditor, type SqlEditor} from './editor';
4
+ import {mountCodeEditor, type CodeEditor} from '../codemirror';
5
+ import {IconButton} from '../IconButton';
6
6
  import {sqlStyles} from './styles';
7
7
  import {el, HTMLElementBase} from '../dom';
8
8
 
@@ -50,7 +50,7 @@ type SqlLabels = {
50
50
  wrapOff: string;
51
51
  copy: string;
52
52
  copied: string;
53
- /** Context-menu labels for the result table (see `renderers.ts`). */
53
+ /** Result-table chrome: the tab strip's controls and the context menu. */
54
54
  copyAll: string;
55
55
  wrapColumn: string;
56
56
  unwrapColumn: string;
@@ -58,11 +58,19 @@ type SqlLabels = {
58
58
  unfreezeColumns: string;
59
59
  resetView: string;
60
60
  noData: string;
61
- /** Width-mode submenu of the result table (see `renderers.ts`). */
62
- widthMode: string;
61
+ search: string;
62
+ /** Width-mode button at the right end of the result tab strip. */
63
63
  widthAdaptive: string;
64
64
  widthStandard: string;
65
65
  widthFill: string;
66
+ /** The tab strip's download button (every result format). */
67
+ download: string;
68
+ /** Figure controls: the svg viewer, and embedded `<dfk-mermaid>`. */
69
+ resetZoom: string;
70
+ editSource: string;
71
+ svgSource: string;
72
+ apply: string;
73
+ cancel: string;
66
74
  running: string;
67
75
  initializing: string;
68
76
  loadingExtensions: string;
@@ -93,10 +101,16 @@ const LABELS: Record<string, SqlLabels> = {
93
101
  unfreezeColumns: 'Unfreeze columns',
94
102
  resetView: 'Reset view',
95
103
  noData: 'No rows',
96
- widthMode: 'Column width',
104
+ search: 'Search',
97
105
  widthAdaptive: 'Fill the width',
98
106
  widthStandard: 'Content widths, scroll sideways',
99
107
  widthFill: 'Content first, fill when it fits',
108
+ download: 'Download',
109
+ resetZoom: 'Reset zoom',
110
+ editSource: 'Edit source',
111
+ svgSource: 'SVG source',
112
+ apply: 'Apply',
113
+ cancel: 'Cancel',
100
114
  running: 'Running…',
101
115
  initializing: 'Initializing DuckDB…',
102
116
  loadingExtensions: 'Loading extensions…',
@@ -125,10 +139,16 @@ const LABELS: Record<string, SqlLabels> = {
125
139
  unfreezeColumns: '取消冻结',
126
140
  resetView: '重置视图',
127
141
  noData: '无数据',
128
- widthMode: '列宽模式',
142
+ search: '搜索',
129
143
  widthAdaptive: '铺满宽度',
130
144
  widthStandard: '按内容列宽(可横向滚动)',
131
145
  widthFill: '内容优先,装得下就铺满',
146
+ download: '下载',
147
+ resetZoom: '还原缩放',
148
+ editSource: '编辑源码',
149
+ svgSource: 'SVG 源码',
150
+ apply: '应用',
151
+ cancel: '取消',
132
152
  running: '执行中…',
133
153
  initializing: '正在初始化 DuckDB…',
134
154
  loadingExtensions: '正在加载扩展…',
@@ -204,8 +224,15 @@ export class DfkSql extends HTMLElementBase {
204
224
  };
205
225
 
206
226
  #resultHost: HTMLElement | null = null;
207
- #editor: SqlEditor | null = null;
227
+ #editor: CodeEditor | null = null;
208
228
  #disposeResult: (() => void) | null = null;
229
+ /**
230
+ * Renderers watching the result area's fullscreen state. A figure zooms only
231
+ * where the result is expanded, so its renderer subscribes through
232
+ * `RenderContext.onFullscreenChange` and forwards every change — including the
233
+ * one at subscription time — to its viewer.
234
+ */
235
+ readonly #fullscreenListeners = new Set<(value: boolean) => void>();
209
236
 
210
237
  constructor() {
211
238
  super();
@@ -337,9 +364,14 @@ export class DfkSql extends HTMLElementBase {
337
364
  this.#mounting = true;
338
365
  this.#setEditorPending(true);
339
366
  try {
340
- const editor = await mountSqlEditor(this.#editorHost, this.#currentSql, (value) => {
341
- this.#currentSql = value;
342
- });
367
+ const editor = await mountCodeEditor(
368
+ this.#editorHost,
369
+ this.#currentSql,
370
+ (value) => {
371
+ this.#currentSql = value;
372
+ },
373
+ {language: 'sql'},
374
+ );
343
375
  if (!this.isConnected) {
344
376
  // Disconnected while the CodeMirror modules were loading: nothing will
345
377
  // ever dispose this editor, so dispose it here.
@@ -438,6 +470,22 @@ export class DfkSql extends HTMLElementBase {
438
470
  }
439
471
  this.#escBound = value;
440
472
  }
473
+ for (const listener of this.#fullscreenListeners) {
474
+ listener(value);
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Subscribes a renderer to the fullscreen state above, calling back with the
480
+ * current value straight away (a renderer can mount while the result is
481
+ * already expanded) and returning the unsubscribe.
482
+ */
483
+ #onFullscreenChange(listener: (value: boolean) => void): () => void {
484
+ this.#fullscreenListeners.add(listener);
485
+ listener(this.#expanded);
486
+ return () => {
487
+ this.#fullscreenListeners.delete(listener);
488
+ };
441
489
  }
442
490
 
443
491
  // --- Run -------------------------------------------------------------------
@@ -544,6 +592,7 @@ export class DfkSql extends HTMLElementBase {
544
592
  config: this.#config,
545
593
  labels: this.#labels,
546
594
  fullscreenButton: this.#fullscreenBtn.root,
595
+ onFullscreenChange: (listener) => this.#onFullscreenChange(listener),
547
596
  };
548
597
  const renderer = rendererFor(this.#config, result);
549
598
  void renderer(context, result).then((dispose) => {
@@ -571,46 +620,6 @@ export class DfkSql extends HTMLElementBase {
571
620
  }
572
621
  }
573
622
 
574
- /**
575
- * A compact icon-only button with a hover tooltip, built once. The tooltip is
576
- * also the accessible name — an icon-only control has no text to fall back on.
577
- */
578
- class IconButton {
579
- readonly root = el('button', {class: 'dfk-sql-icon-button', type: 'button'});
580
- readonly #icon: IconifyIconHTMLElement = el('iconify-icon', {
581
- class: 'dfk-sql-icon',
582
- attrs: {'aria-hidden': 'true'},
583
- });
584
-
585
- constructor(icon: string, onClick: () => void) {
586
- this.root.appendChild(this.#icon);
587
- this.root.addEventListener('click', onClick);
588
- this.setIcon(icon);
589
- }
590
-
591
- setIcon(icon: string): void {
592
- this.#icon.setAttribute('icon', icon);
593
- }
594
-
595
- setLabel(text: string): void {
596
- this.root.setAttribute('data-tip', text);
597
- this.root.setAttribute('aria-label', text);
598
- }
599
-
600
- /** Marks a toggle as currently on (the wrap button). */
601
- setOn(on: boolean): void {
602
- this.root.classList.toggle('dfk-sql-icon-on', on);
603
- }
604
-
605
- setDisabled(disabled: boolean): void {
606
- if (disabled) {
607
- this.root.setAttribute('disabled', '');
608
- } else {
609
- this.root.removeAttribute('disabled');
610
- }
611
- }
612
- }
613
-
614
623
  function messageOf(error: unknown): string {
615
624
  return error instanceof Error ? error.message : String(error);
616
625
  }
@@ -1,32 +1,43 @@
1
+ import {el} from '../dom';
2
+ import {saveDownload, type DownloadPayload} from '../download';
3
+ import {IconButton} from '../IconButton';
4
+
1
5
  /**
2
6
  * Every renderer's result shell: a tab strip plus the panels behind it.
3
7
  *
4
- * The strip is one tab per preview row plus a `Table` tab that always comes
5
- * **last**; a renderer with a single view passes no items at all, so a plain
6
- * table result is a strip holding nothing but that trailing `Table` tab. Every
7
- * result therefore has the same chrome — which is where the fullscreen toggle
8
- * lives.
8
+ * The strip holds one tab per item — a preview row, a `Text` view, or the
9
+ * trailing `Table` view — and, at its right end, the chrome that acts on the
10
+ * result as a whole: the active tab's own {@link PreviewTabItem.actions}, a
11
+ * **download** button, and whatever the caller parks last (the fullscreen toggle
12
+ * `<dfk-sql>` owns). Every result therefore has the same chrome, and the panel
13
+ * behind a tab is built the first time that tab is shown.
9
14
  *
10
- * Retained mode: every button and panel is built in the constructor and held in
11
- * a field. Activating a tab mutates the nodes it owns (`hidden`, `classList`,
12
- * `aria-selected`, `tabIndex`) — there is no rebuild, and a panel is filled the
13
- * first time it is shown rather than up front.
14
- *
15
- * The table panel is mounted on first activation on purpose: VTable measures
16
- * its container when it is constructed, and a `hidden` panel measures to zero.
15
+ * Retained mode: every button, action container and panel is built in the
16
+ * constructor and held in a field. Activating a tab mutates the nodes it owns
17
+ * (`hidden`, `classList`, `aria-selected`, `tabIndex`) — there is no rebuild, and
18
+ * a panel is filled the first time it is shown rather than up front.
17
19
  */
18
20
 
19
- import {el} from '../dom';
20
-
21
- /** One preview row: its tab label and how to fill its panel. */
21
+ /** One tab: its label, how to fill its panel, and what it offers the strip. */
22
22
  export interface PreviewTabItem {
23
23
  label: string;
24
- mount(panel: HTMLElement): void;
25
- }
26
-
27
- /** What `PreviewTabs` needs from the caller to own the trailing table tab. */
28
- export interface PreviewTableHandle {
29
- dispose(): void;
24
+ /**
25
+ * Fills the panel. Called once, the first time the tab is shown; an async mount
26
+ * may resolve with a disposer, which the strip runs when it is disposed (that is
27
+ * how the table's handle gets released, including when the countdown lands after
28
+ * {@link PreviewTabs.dispose}).
29
+ */
30
+ mount(panel: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
31
+ /**
32
+ * Controls for the strip's right end, shown only while this tab is active — the
33
+ * place for actions that belong to *this* result (a table's search and view
34
+ * switches, a figure's zoom reset) rather than to every result.
35
+ */
36
+ actions?: HTMLElement;
37
+ /** This tab's file, or `null` while there is nothing to save yet. */
38
+ download?: () => DownloadPayload | null;
39
+ /** Told the result area's fullscreen state, for figures that zoom inside it. */
40
+ setFullscreen?: (value: boolean) => void;
30
41
  }
31
42
 
32
43
  /** Keeps per-instance element ids unique across every `<dfk-sql>` on a page. */
@@ -35,38 +46,39 @@ let sequence = 0;
35
46
  export class PreviewTabs {
36
47
  readonly #buttons: HTMLButtonElement[] = [];
37
48
  readonly #panels: HTMLElement[] = [];
38
- /** `null` marks the trailing table tab. */
39
- readonly #items: (PreviewTabItem | null)[] = [];
49
+ readonly #items: PreviewTabItem[] = [];
40
50
  readonly #mounted: boolean[] = [];
41
- readonly #mountTable: (panel: HTMLElement) => Promise<PreviewTableHandle>;
51
+ /** Disposers returned by mounts, run on {@link dispose}. */
52
+ readonly #disposers: (() => void)[] = [];
53
+ readonly #downloadBtn: IconButton;
42
54
 
43
- #tableHandle: PreviewTableHandle | null = null;
55
+ #active = 0;
56
+ #fullscreen = false;
44
57
  #disposed = false;
45
58
 
46
59
  /**
47
- * @param corner Node parked at the right end of the strip, outside the
48
- * scrolling tab list. `<dfk-sql>` passes its fullscreen toggle: it owns that
49
- * button's state, so it owns the node and only lends it here.
60
+ * @param corner Node parked at the very end of the strip, after the download
61
+ * button. `<dfk-sql>` passes its fullscreen toggle: it owns that button's state,
62
+ * so it owns the node and only lends it here.
50
63
  */
51
64
  constructor(
52
65
  host: HTMLElement,
53
66
  items: readonly PreviewTabItem[],
54
- tableLabel: string,
55
- mountTable: (panel: HTMLElement) => Promise<PreviewTableHandle>,
67
+ downloadLabel: string,
56
68
  corner?: HTMLElement,
57
69
  ) {
58
- this.#mountTable = mountTable;
59
70
  const uid = `dfk-sql-tabs-${(sequence += 1)}`;
60
71
  const bar = el('div', {class: 'dfk-sql-tabs'});
61
- // Only the tab buttons belong to the tablist; the corner button must not be
72
+ // Only the tab buttons belong to the tablist; the corner must not be
62
73
  // scrollable with them, hence the nested list.
63
74
  const list = el('div', {
64
75
  class: 'dfk-sql-tab-list',
65
76
  attrs: {role: 'tablist'},
66
77
  });
78
+ const cornerBox = el('div', {class: 'dfk-sql-tab-corner'});
67
79
  const panels = el('div', {class: 'dfk-sql-panels'});
68
80
 
69
- const add = (label: string, item: PreviewTabItem | null): void => {
81
+ const add = (item: PreviewTabItem): void => {
70
82
  const index = this.#buttons.length;
71
83
  const panel = el('div', {
72
84
  class: 'dfk-sql-panel',
@@ -80,7 +92,7 @@ export class PreviewTabs {
80
92
  const button = el('button', {
81
93
  class: 'dfk-sql-tab',
82
94
  type: 'button',
83
- text: label,
95
+ text: item.label,
84
96
  tabIndex: index === 0 ? 0 : -1,
85
97
  attrs: {
86
98
  role: 'tab',
@@ -98,34 +110,54 @@ export class PreviewTabs {
98
110
  this.#mounted.push(false);
99
111
  list.appendChild(button);
100
112
  panels.appendChild(panel);
113
+ if (item.actions) {
114
+ item.actions.hidden = true;
115
+ cornerBox.appendChild(item.actions);
116
+ }
101
117
  };
102
118
 
103
119
  for (const item of items) {
104
- add(item.label, item);
120
+ add(item);
105
121
  }
106
- add(tableLabel, null);
107
122
 
108
- bar.appendChild(list);
123
+ this.#downloadBtn = new IconButton('lucide:download', () => this.#download());
124
+ this.#downloadBtn.setLabel(downloadLabel);
125
+ this.#downloadBtn.root.hidden = true;
126
+ cornerBox.appendChild(this.#downloadBtn.root);
109
127
  if (corner) {
110
- bar.appendChild(corner);
128
+ cornerBox.appendChild(corner);
111
129
  }
130
+
131
+ bar.append(list, cornerBox);
112
132
  // The one-time installation of this widget's own subtree.
113
133
  host.replaceChildren(bar, panels);
114
134
  this.#select(0);
115
135
  }
116
136
 
117
- /** Releases the table (if it was ever shown) and empties every panel. */
137
+ /** Releases every mounted item and empties the panels. */
118
138
  dispose(): void {
119
139
  this.#disposed = true;
120
- this.#tableHandle?.dispose();
121
- this.#tableHandle = null;
140
+ for (const dispose of this.#disposers) {
141
+ dispose();
142
+ }
143
+ this.#disposers.length = 0;
122
144
  for (const panel of this.#panels) {
123
145
  panel.replaceChildren();
124
146
  }
125
147
  }
126
148
 
149
+ /**
150
+ * Records the result area's fullscreen state and passes it on to the tab on
151
+ * screen: a figure zooms only where the result is expanded (see `PanZoomView`).
152
+ */
153
+ setFullscreen(value: boolean): void {
154
+ this.#fullscreen = value;
155
+ this.#items[this.#active]?.setFullscreen?.(value);
156
+ }
157
+
127
158
  /** Activation is pure mutation — no panel is rebuilt, none is discarded. */
128
159
  #select(index: number): void {
160
+ this.#active = index;
129
161
  for (let i = 0; i < this.#buttons.length; i += 1) {
130
162
  const active = i === index;
131
163
  const button = this.#buttons[i];
@@ -133,27 +165,41 @@ export class PreviewTabs {
133
165
  button.setAttribute('aria-selected', String(active));
134
166
  button.tabIndex = active ? 0 : -1;
135
167
  this.#panels[i].hidden = !active;
168
+ const actions = this.#items[i].actions;
169
+ if (actions) {
170
+ actions.hidden = !active;
171
+ }
136
172
  }
173
+ const item = this.#items[index];
174
+ // Only a tab that can save something gets a live download button; a figure
175
+ // that has not rendered yet answers `null` to the click instead.
176
+ this.#downloadBtn.root.hidden = item.download === undefined;
177
+ item.setFullscreen?.(this.#fullscreen);
137
178
  if (this.#mounted[index]) {
138
179
  return;
139
180
  }
140
181
  this.#mounted[index] = true;
141
- const item = this.#items[index];
142
- if (item) {
143
- item.mount(this.#panels[index]);
144
- } else {
145
- void this.#mountTableInto(index);
146
- }
182
+ void this.#mountInto(index);
147
183
  }
148
184
 
149
- async #mountTableInto(index: number): Promise<void> {
150
- const handle = await this.#mountTable(this.#panels[index]);
185
+ async #mountInto(index: number): Promise<void> {
186
+ const dispose = await this.#items[index].mount(this.#panels[index]);
187
+ if (typeof dispose !== 'function') {
188
+ return;
189
+ }
151
190
  if (this.#disposed) {
152
191
  // Disposed while the mount was in flight: release what just arrived.
153
- handle.dispose();
192
+ dispose();
154
193
  return;
155
194
  }
156
- this.#tableHandle = handle;
195
+ this.#disposers.push(dispose);
196
+ }
197
+
198
+ #download(): void {
199
+ const payload = this.#items[this.#active]?.download?.();
200
+ if (payload) {
201
+ saveDownload(payload);
202
+ }
157
203
  }
158
204
 
159
205
  #onKeydown(event: KeyboardEvent, index: number): void {
@@ -166,4 +212,4 @@ export class PreviewTabs {
166
212
  this.#select(next);
167
213
  this.#buttons[next].focus();
168
214
  }
169
- }
215
+ }
@@ -0,0 +1,155 @@
1
+ import {el} from '../dom';
2
+ import {sectionFileName, type DownloadPayload} from '../download';
3
+ import {IconButton} from '../IconButton';
4
+ import {PanZoomView} from '../panzoom-view';
5
+ import {SourceDialog} from '../source-dialog';
6
+
7
+ /**
8
+ * The `svg` result view: an inline SVG a reader can zoom (in the result area's
9
+ * fullscreen), edit, and download — the same interactions a diagram gets from
10
+ * `<dfk-mermaid>`, which is why both are built on `PanZoomView` and
11
+ * `SourceDialog`.
12
+ *
13
+ * A plain class rather than a custom element: nothing hands it content through
14
+ * markup, and the SQL renderer already has the parsed node, so there is no
15
+ * upgrade to wait for.
16
+ *
17
+ * Like an embedded `<dfk-mermaid>`, the element contributes no frame — the result
18
+ * area's panel is the frame — and no floating cluster: its two controls travel out
19
+ * through {@link actions} for the result's tab strip, and its file travels out
20
+ * through {@link downloadPayload} for the strip's download button. Zoom is off
21
+ * until {@link setFullscreen} says the result area is expanded.
22
+ *
23
+ * This is browser-only code.
24
+ */
25
+
26
+ /** The strings a figure's controls need. */
27
+ export interface FigureLabels {
28
+ reset: string;
29
+ edit: string;
30
+ editTitle: string;
31
+ apply: string;
32
+ cancel: string;
33
+ }
34
+
35
+ export class SvgViewer {
36
+ /** The box in the tab panel: the panzoom viewport, and the frame's clipping box. */
37
+ readonly root = el('div', {class: 'dfk-sql-svg'});
38
+ /** The controls the result area's tab strip shows while this tab is active. */
39
+ readonly actions = el('div', {class: 'dfk-sql-tab-actions'});
40
+ readonly #content = el('div', {class: 'dfk-sql-svg-content'});
41
+ readonly #view = new PanZoomView(this.root, this.#content);
42
+ readonly #resetBtn = new IconButton('lucide:rotate-ccw', () => this.#view.reset());
43
+ readonly #editBtn: IconButton;
44
+ readonly #dialog: SourceDialog;
45
+
46
+ /** The markup as edited, the source for the next re-render and the text export. */
47
+ #markup: string;
48
+ /** The rendered node, or `null` when the markup does not parse as SVG. */
49
+ #svg: SVGElement | null = null;
50
+ /** The file-name base when nothing on the page says anything better. */
51
+ readonly #fallback: string;
52
+
53
+ constructor(svg: SVGElement | null, markup: string, labels: FigureLabels, fallback: string) {
54
+ this.#markup = markup;
55
+ this.#fallback = fallback;
56
+ this.#svg = svg;
57
+ this.#editBtn = new IconButton('lucide:pencil', () => this.#openEditor());
58
+ this.#dialog = new SourceDialog({
59
+ title: labels.editTitle,
60
+ apply: labels.apply,
61
+ cancel: labels.cancel,
62
+ });
63
+ this.#resetBtn.setLabel(labels.reset);
64
+ this.#editBtn.setLabel(labels.edit);
65
+ this.actions.append(this.#resetBtn.root, this.#editBtn.root);
66
+ this.#view.setContent(svg ?? textBlock(markup));
67
+ this.root.append(this.#content, this.#dialog.root);
68
+ }
69
+
70
+ /** Turns zoom and pan on or off; the result area calls this with its fullscreen. */
71
+ setFullscreen(value: boolean): void {
72
+ this.#view.setActive(value);
73
+ }
74
+
75
+ /**
76
+ * The file this view would be saved as: the SVG itself, or — when the markup
77
+ * does not parse and the view is showing it as text — that text.
78
+ */
79
+ downloadPayload(): DownloadPayload {
80
+ const name = sectionFileName(this.root, this.#svg === null ? 'txt' : 'svg', {
81
+ fallback: this.#fallback,
82
+ });
83
+ if (this.#svg === null) {
84
+ return {name, mime: 'text/plain;charset=utf-8', text: this.#markup};
85
+ }
86
+ return {
87
+ name,
88
+ mime: 'image/svg+xml;charset=utf-8',
89
+ text: new XMLSerializer().serializeToString(this.#svg),
90
+ };
91
+ }
92
+
93
+ dispose(): void {
94
+ this.#view.destroy();
95
+ this.#dialog.destroy();
96
+ this.#dialog.root.remove();
97
+ }
98
+
99
+ #openEditor(): void {
100
+ this.#dialog.open(this.#markup, (value) => {
101
+ this.#markup = value;
102
+ this.#svg = parseSvgMarkup(this.root.ownerDocument, value);
103
+ this.#view.setContent(this.#svg ?? textBlock(value));
104
+ });
105
+ }
106
+ }
107
+
108
+ /** The fallback view for markup that is not SVG: the text, not an empty box. */
109
+ function textBlock(markup: string): HTMLElement {
110
+ return el('pre', {class: 'dfk-sql-text', text: markup});
111
+ }
112
+
113
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
114
+
115
+ /**
116
+ * Parses SVG markup from a result cell into a node this document can host.
117
+ *
118
+ * `image/svg+xml` is strict XML: malformed markup (unclosed tags, a bare `&`,
119
+ * an HTML `<br>`) comes back as a `<parsererror>` element rather than throwing,
120
+ * so the caller can degrade to text. Script-bearing and event-handler content
121
+ * is stripped — inline SVG is *not* isolated (use the `iframe` renderer for
122
+ * untrusted markup).
123
+ */
124
+ export function parseSvgMarkup(document: Document, markup: string): SVGElement | null {
125
+ if (!markup.trim()) {
126
+ return null;
127
+ }
128
+ const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
129
+ const root = parsed.documentElement;
130
+ if (!root || root.localName === 'parsererror' || root.namespaceURI !== SVG_NAMESPACE) {
131
+ return null;
132
+ }
133
+ stripActiveContent(root);
134
+ return document.importNode(root, true) as unknown as SVGElement;
135
+ }
136
+
137
+ /** Removes the parts of an SVG document that could execute or navigate. */
138
+ function stripActiveContent(root: Element): void {
139
+ for (const node of root.querySelectorAll('script, foreignObject')) {
140
+ node.remove();
141
+ }
142
+ const visit = (element: Element): void => {
143
+ for (const attribute of [...element.attributes]) {
144
+ const name = attribute.name.toLowerCase();
145
+ const value = attribute.value.replace(/\s/g, '').toLowerCase();
146
+ if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
147
+ element.removeAttribute(attribute.name);
148
+ }
149
+ }
150
+ for (const child of element.children) {
151
+ visit(child);
152
+ }
153
+ };
154
+ visit(root);
155
+ }
package/src/sql/remark.ts CHANGED
@@ -36,8 +36,13 @@ export interface RunnableSqlConfig {
36
36
  *
37
37
  * `html` and `iframe` are the same renderer: both sandbox the markup in an
38
38
  * iframe, so scripts run with an opaque origin.
39
+ *
40
+ * `mermaid` renders the column's mermaid source as a diagram through
41
+ * `<dfk-mermaid>` (the same element a ```mermaid fence produces), so the result
42
+ * gets the element's zoom, fullscreen, source editing and SVG download for
43
+ * free.
39
44
  */
40
- show?: 'table' | 'html' | 'iframe' | 'svg' | 'text';
45
+ show?: 'table' | 'html' | 'iframe' | 'svg' | 'text' | 'mermaid';
41
46
  /**
42
47
  * What this block is expected to do when the docs' own SQL test suite runs it
43
48
  * (`duckfn-docs-kit/sql/verify`). Defaults to `'ok'`; `'error'` marks a block