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,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
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The one place the kit talks to mermaid: the load, the render queue, the config
3
+ * the renderer is initialised with, and the two document-level facts a diagram
4
+ * needs (which colour mode the page is in, and how a rendered SVG gets into a
5
+ * shadow tree).
6
+ *
7
+ * Two upstream problems are solved here, and neither can be fixed from a site's
8
+ * `docusaurus.config.ts` or from the diagram source:
9
+ *
10
+ * 1. **The flash on a dark-mode first load, and the blank diagram.** Docusaurus'
11
+ * `useColorMode()` deliberately lags behind on the first client render (its
12
+ * state is initialised in an effect, to avoid hydration mismatches), so a
13
+ * theme-aware renderer paints once with the light palette and then again with
14
+ * the dark one: the light one lands on screen briefly (the flash), and the two
15
+ * overlap inside mermaid's mutable singleton, which can resolve with an empty
16
+ * SVG (an empty container, no error — facebook/docusaurus#8357). This module
17
+ * reads `<html data-theme>` instead: the attribute the inline script in
18
+ * `<head>` writes *before* the first paint, and the one the page CSS keys off.
19
+ * Nothing is rendered until it is known, so each diagram is rendered exactly
20
+ * once per mode, in the mode the page is really in.
21
+ * 2. **Concurrent renders.** Mermaid is a mutable singleton that cannot render
22
+ * two diagrams at once (`mermaid.initialize()` sets one global config, and the
23
+ * renderer mutates it as it goes): docusaurus#8357 asks to "render them
24
+ * sequentially one after the other", pointing at mermaid-js/mermaid#3577.
25
+ * Every render therefore goes through a single queue.
26
+ *
27
+ * This is browser-only code: `mermaid` and every DOM API are reached lazily, so
28
+ * Docusaurus' Node prerender can import the module graph without evaluating any
29
+ * of it.
30
+ */
31
+
32
+ import type {DfkMermaidConfig, MermaidColorMode} from './config';
33
+
34
+ /** The mermaid singleton, exactly as the package's default export types it. */
35
+ type Mermaid = (typeof import('mermaid'))['default'];
36
+ type RenderResult = Awaited<ReturnType<Mermaid['render']>>;
37
+ type MermaidConfig = Parameters<Mermaid['initialize']>[0];
38
+
39
+ /**
40
+ * One queue for the whole page: mermaid's `render()` calls are serialised,
41
+ * never concurrent. A rejected task must not poison the queue, hence the
42
+ * `catch` on the tail that the next task chains from.
43
+ */
44
+ let queue: Promise<unknown> = Promise.resolve();
45
+
46
+ function enqueue<T>(task: () => Promise<T>): Promise<T> {
47
+ const result = queue.then(task, task);
48
+ queue = result.catch(() => undefined);
49
+ return result;
50
+ }
51
+
52
+ let mermaidModule: Promise<Mermaid> | null = null;
53
+
54
+ /**
55
+ * Loads (once) and returns the mermaid singleton. A dynamic `import()` so a page
56
+ * with no diagram never downloads it, and the Node prerender never evaluates it.
57
+ */
58
+ function loadMermaid(): Promise<Mermaid> {
59
+ mermaidModule ??= import('mermaid').then((module) => module.default);
60
+ return mermaidModule;
61
+ }
62
+
63
+ /** Keeps the ids mermaid is handed unique across every diagram on a page. */
64
+ let sequence = 0;
65
+
66
+ /** How a diagram is rendered: its source, its config, and its colour mode. */
67
+ export interface MermaidRenderRequest {
68
+ source: string;
69
+ config: DfkMermaidConfig;
70
+ colorMode: MermaidColorMode;
71
+ }
72
+
73
+ /** A rendered diagram: the SVG markup, plus mermaid's bind hook for click handlers. */
74
+ export interface MermaidRenderOutput {
75
+ svg: string;
76
+ bind?(container: Element): void;
77
+ }
78
+
79
+ /**
80
+ * Renders one diagram, through the page-wide queue. Each render re-initialises
81
+ * mermaid with this diagram's config first: mermaid has two config levels and the
82
+ * site-wide one can only be set through `initialize()`.
83
+ */
84
+ export function renderMermaid({
85
+ source,
86
+ config,
87
+ colorMode,
88
+ }: MermaidRenderRequest): Promise<MermaidRenderOutput> {
89
+ const id = `dfk-mermaid-svg-${(sequence += 1)}`;
90
+ return enqueue(async () => {
91
+ const mermaid = await loadMermaid();
92
+ const options: MermaidConfig = {
93
+ startOnLoad: false,
94
+ ...config.options,
95
+ // `DfkMermaidConfig.theme` is a plain string on purpose — the kit does not
96
+ // pin mermaid's theme list (the shared config must stay dependency-free,
97
+ // and a site may name a theme a newer mermaid adds). This is the one place
98
+ // the value meets mermaid's own union, so the narrowing happens here.
99
+ theme: config.theme[colorMode] as MermaidConfig['theme'],
100
+ };
101
+ mermaid.initialize(options);
102
+ try {
103
+ const result: RenderResult = await mermaid.render(id, source);
104
+ return {svg: result.svg, bind: result.bindFunctions};
105
+ } catch (error) {
106
+ // Mermaid leaves a stray SVG/message in the DOM on error
107
+ // (https://github.com/mermaid-js/mermaid/issues/3205).
108
+ document.querySelector(`#d${id}`)?.remove();
109
+ throw error;
110
+ }
111
+ });
112
+ }
113
+
114
+ /** The colour mode the page is actually in, read from the pre-paint attribute. */
115
+ export function documentColorMode(): MermaidColorMode {
116
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
117
+ }
118
+
119
+ /**
120
+ * Calls `listener` whenever the page's colour mode changes, and returns the
121
+ * unsubscribe. The `<head>` script only *writes* the attribute on load, so a
122
+ * mode switch made later has to be observed — that is how a diagram follows the
123
+ * theme toggle.
124
+ */
125
+ export function watchColorMode(listener: () => void): () => void {
126
+ const observer = new MutationObserver(listener);
127
+ observer.observe(document.documentElement, {attributeFilter: ['data-theme']});
128
+ return () => observer.disconnect();
129
+ }
130
+
131
+ /**
132
+ * Turns mermaid's SVG markup into a node a shadow tree can host.
133
+ *
134
+ * Mermaid serialises through `innerHTML`, so its output is *HTML*, not strict
135
+ * XML: an HTML label can carry a bare `<br>`, which `image/svg+xml` would reject.
136
+ * Parsing as `text/html` and taking the `<svg>` element gives the right
137
+ * namespace for free (the HTML parser enters foreign content for `<svg>`) and
138
+ * keeps markup out of `innerHTML`, which the kit's conventions rule out.
139
+ *
140
+ * Returns `null` when there is no `<svg>` to be found, so the caller can show an
141
+ * error instead of an empty box.
142
+ */
143
+ export function parseMermaidSvg(owner: Document, svg: string): SVGElement | null {
144
+ const parsed = new DOMParser().parseFromString(svg, 'text/html');
145
+ const root = parsed.body.querySelector('svg');
146
+ return root ? (owner.importNode(root, true) as unknown as SVGElement) : null;
147
+ }
148
+
149
+ /** The pan/zoom library, loaded once on first use (see `DfkMermaid`). */
150
+ export async function loadPanzoom(): Promise<typeof import('@panzoom/panzoom')['default']> {
151
+ const module = await import('@panzoom/panzoom');
152
+ return module.default;
153
+ }
154
+
155
+ /**
156
+ * Serialises a rendered diagram into standalone SVG markup.
157
+ *
158
+ * Mermaid hands its SVG back as an **HTML** string (it stringifies a detached
159
+ * element through `innerHTML`), and HTML serialisation writes a void element
160
+ * without a closing slash: a `<br/>` the author put inside a label comes back as
161
+ * `<br>`. That is fine where it lands — the page's HTML parser reads it, and
162
+ * `parseMermaidSvg()` parses it the same way — but it is fatal for a *file*: a
163
+ * browser opening an `.svg` parses XML, and `<br>` inside a `<p>` there is
164
+ * `Opening and ending tag mismatch: br … and p`, with the drawing cut off at the
165
+ * first error.
166
+ *
167
+ * Re-serialising the live node with `XMLSerializer` fixes both halves at once:
168
+ * XML serialisation closes every element, and it emits the namespace declarations
169
+ * a standalone document needs (`xmlns` on the `<svg>`, and one on any
170
+ * `foreignObject` subtree, whose XHTML content inherits its namespace from the
171
+ * page rather than carrying it as an attribute).
172
+ *
173
+ * This is why the element keeps the rendered `<svg>` node for the download
174
+ * instead of mermaid's own string: only the node can be re-serialised.
175
+ */
176
+ export function serializeMermaidSvg(svg: SVGElement): string {
177
+ return new XMLSerializer().serializeToString(svg);
178
+ }
@@ -0,0 +1,24 @@
1
+ // The `<dfk-mermaid>` shadow-root CSS as an inline string (Vite `?inline`
2
+ // import), mirroring `home/styles.ts` and `sql/styles.ts`. Everything the
3
+ // component draws lives in the shadow tree — the diagram included, which is what
4
+ // keeps mermaid's inline `<style>` (it ships one inside every SVG) scoped to this
5
+ // one component instead of leaking into the page or into a sibling diagram.
6
+ //
7
+ // The type declaration lives in `vite-env.d.ts`; `tsc` never resolves the
8
+ // `?inline` suffix, so the module graph stays buildable without Vite running.
9
+ import mermaidCss from './DfkMermaid.css?inline';
10
+
11
+ let sheet: CSSStyleSheet | null = null;
12
+
13
+ /**
14
+ * The one parsed stylesheet shared by every `<dfk-mermaid>` shadow root.
15
+ * Created lazily: `CSSStyleSheet` does not exist during Docusaurus' Node
16
+ * prerender, and only the browser ever calls this.
17
+ */
18
+ export function mermaidStyles(): CSSStyleSheet {
19
+ if (!sheet) {
20
+ sheet = new CSSStyleSheet();
21
+ sheet.replaceSync(mermaidCss);
22
+ }
23
+ return sheet;
24
+ }
@@ -0,0 +1,127 @@
1
+ import filenamify from 'filenamify';
2
+
3
+ /**
4
+ * What a downloaded diagram is called.
5
+ *
6
+ * The name is derived from the page, not from the diagram: a diagram has no name
7
+ * of its own, and the reader downloading one is after "the diagram from that
8
+ * section", not `mermaid-diagram.svg`.
9
+ *
10
+ * Three sources, most specific first, each falling back to the next:
11
+ *
12
+ * 1. **The diagram's own title** — mermaid's frontmatter
13
+ * (`---\ntitle: …\n---`, which mermaid itself draws above the diagram).
14
+ * 2. **The nearest heading above it** — the section the diagram belongs to, which
15
+ * on a docs page is what a reader would call it.
16
+ * 3. **The document title** — the browser tab's title, for a diagram that sits
17
+ * above every heading on its page.
18
+ *
19
+ * Nothing found → {@link DEFAULT_DIAGRAM_FILE}.
20
+ *
21
+ * This is browser-only code.
22
+ */
23
+
24
+ /** Used when nothing on the page says anything about this diagram. */
25
+ export const DEFAULT_DIAGRAM_FILE = 'mermaid-diagram.svg';
26
+
27
+ /**
28
+ * The length cap. `filenamify` truncates by grapheme, so a CJK heading is cut at
29
+ * 80 *characters*, not bytes, and an emoji survives whole.
30
+ */
31
+ const MAX_LENGTH = 80;
32
+
33
+ /** How a reserved character is spelled in the file name (`2. Registration: …`). */
34
+ const REPLACEMENT = '-';
35
+
36
+ /**
37
+ * The *format* characters (zero-width space, word joiner, byte-order mark…) and
38
+ * whitespace that a title can wear at its edges.
39
+ */
40
+ const EDGE_NOISE = /^[\s\p{Cf}]+|[\s\p{Cf}]+$/gu;
41
+
42
+ /**
43
+ * Trims a candidate down to its text, dropping format characters at the edges.
44
+ *
45
+ * Plain whitespace trimming is not enough: Docusaurus gives every heading an
46
+ * anchor link whose label is a zero-width space, so `textContent` of a heading is
47
+ * `"2. Registration\u200B"`. `filenamify` turns a format character into the
48
+ * replacement rather than dropping it, which would produce
49
+ * `2. Registration-.svg`. Only the *edges*: an interior zero-width joiner is what
50
+ * holds an emoji together.
51
+ */
52
+ function cleanTitle(value: string | undefined): string {
53
+ return value === undefined ? '' : value.replace(EDGE_NOISE, '');
54
+ }
55
+
56
+ export function diagramFileName(source: string, element: Element): string {
57
+ const candidates = [
58
+ frontmatterTitle(source),
59
+ precedingHeading(element),
60
+ element.ownerDocument.title,
61
+ ].map(cleanTitle);
62
+ const title = candidates.find((candidate) => candidate !== '');
63
+ if (title === undefined) {
64
+ return DEFAULT_DIAGRAM_FILE;
65
+ }
66
+ // Filenames are a filesystem concern, so they go through a library rather than a
67
+ // hand-rolled character class. Three things it does that matter here and that a
68
+ // browser does *not*: it strips what Windows and macOS reject (`:`, `?`, `*`,
69
+ // `"`, `<`, `>`, `|`, the path separators and control characters), it trims the
70
+ // trailing dots and spaces Windows silently drops, and it avoids the reserved
71
+ // device names (`con`, `nul`, …). The `download` attribute's own sanitisation
72
+ // covers only `/` and `\`.
73
+ //
74
+ // It also normalises Unicode whitespace and drops format characters, which
75
+ // quietly cleans up Docusaurus' heading anchors: the `<a>` it appends to every
76
+ // heading contributes a zero-width space to `textContent`.
77
+ return `${filenamify(title, {replacement: REPLACEMENT, maxLength: MAX_LENGTH})}.svg`;
78
+ }
79
+
80
+ /**
81
+ * mermaid's frontmatter block, if the source opens with one.
82
+ *
83
+ * Only the top-level `title:` key is read. Mermaid's frontmatter is YAML, and the
84
+ * rest of it (a nested `config:`, `displayMode:`, …) is none of this module's
85
+ * business: a key that is always a plain scalar on one line does not justify a
86
+ * YAML parser, and anything indented — a nested key — is skipped by anchoring the
87
+ * match at the line start.
88
+ */
89
+ function frontmatterTitle(source: string): string | undefined {
90
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source.trimStart())?.[1];
91
+ if (block === undefined) {
92
+ return undefined;
93
+ }
94
+ const value = /^title\s*:\s*(.+)$/m.exec(block)?.[1];
95
+ return value === undefined ? undefined : unquote(value.trim());
96
+ }
97
+
98
+ /** Strips one pair of matching quotes, so `title: "A: B"` keeps its colon. */
99
+ function unquote(value: string): string {
100
+ const first = value[0];
101
+ return (first === '"' || first === "'") && value.endsWith(first) ? value.slice(1, -1) : value;
102
+ }
103
+
104
+ /**
105
+ * The text of the last heading that precedes the diagram, or `undefined`.
106
+ *
107
+ * The search is scoped to the enclosing `<article>` where there is one: a docs
108
+ * page's navbar, sidebar and footer are full of headings that have nothing to do
109
+ * with this diagram, and the article is the one container that holds the page's
110
+ * own content.
111
+ */
112
+ function precedingHeading(element: Element): string | undefined {
113
+ const scope: ParentNode = element.closest('article') ?? element.ownerDocument;
114
+ let found: string | undefined;
115
+ for (const heading of scope.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
116
+ // `FOLLOWING` means the heading comes before the element in document order.
117
+ if (!(heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
118
+ // Headings are in document order, so nothing later can precede it either.
119
+ break;
120
+ }
121
+ const text = heading.textContent?.trim();
122
+ if (text) {
123
+ found = text;
124
+ }
125
+ }
126
+ return found;
127
+ }
package/src/register.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import 'iconify-icon';
5
5
  import {DfkFeatures} from './home/DfkFeatures';
6
6
  import {DfkHero} from './home/DfkHero';
7
+ import {DfkMermaid} from './mermaid/DfkMermaid';
7
8
  import {DfkNextSteps} from './home/DfkNextSteps';
8
9
  import {DfkSql} from './sql/DfkSql';
9
10
 
@@ -12,6 +13,7 @@ const TAGS = {
12
13
  features: 'dfk-features',
13
14
  nextSteps: 'dfk-next-steps',
14
15
  sql: 'dfk-sql',
16
+ mermaid: 'dfk-mermaid',
15
17
  } as const;
16
18
 
17
19
  /**
@@ -31,6 +33,7 @@ export function registerDfkElements(): void {
31
33
  [TAGS.features]: DfkFeatures,
32
34
  [TAGS.nextSteps]: DfkNextSteps,
33
35
  [TAGS.sql]: DfkSql,
36
+ [TAGS.mermaid]: DfkMermaid,
34
37
  })) {
35
38
  if (!customElements.get(name)) {
36
39
  customElements.define(name, ctor);
package/src/remark.ts CHANGED
@@ -15,7 +15,7 @@ import type {Plugin} from 'unified';
15
15
  export const DEFAULT_VERSION_PLACEHOLDER = '{{DUCKFN_VERSION}}';
16
16
 
17
17
  export interface VersionPlaceholderOptions {
18
- /** The real version string to substitute in, e.g. `0.0.15`. */
18
+ /** The real version string to substitute in, e.g. `0.0.17`. */
19
19
  version: string;
20
20
  /** Override the token if a site uses a different one. */
21
21
  placeholder?: string;
@@ -159,10 +159,13 @@
159
159
 
160
160
  /* --- Icon buttons and their tooltips ------------------------------------- */
161
161
 
162
- /* Duplicated in `sql.css`: these buttons live in this shadow tree, while the
163
- fullscreen toggle a renderer parks in the result's tab strip lives in the
164
- light DOM, where a shadow rule could not reach it. Keep both copies in sync. */
165
- .dfk-sql-icon-button {
162
+ /* The class names are written by the shared `IconButton` widget (`src/IconButton.ts`).
163
+ The rules are duplicated in `sql.css` and `DfkMermaid.css`: these buttons live
164
+ in this shadow tree, the fullscreen toggle a renderer parks in the result's tab
165
+ strip lives in the light DOM, and a diagram's cluster lives in another shadow
166
+ tree entirely — a shadow rule can only ever reach one of the three. Keep the
167
+ copies in sync. */
168
+ .dfk-icon-button {
166
169
  position: relative;
167
170
  display: inline-flex;
168
171
  align-items: center;
@@ -178,27 +181,27 @@
178
181
  transition: background 0.12s ease, color 0.12s ease;
179
182
  }
180
183
 
181
- .dfk-sql-icon-button:hover:not(:disabled),
182
- .dfk-sql-icon-button:focus-visible {
184
+ .dfk-icon-button:hover:not(:disabled),
185
+ .dfk-icon-button:focus-visible {
183
186
  background: var(--ifm-color-emphasis-200, #e6e6e6);
184
187
  color: var(--ifm-color-primary, #14459b);
185
188
  }
186
189
 
187
- .dfk-sql-icon-button:disabled {
190
+ .dfk-icon-button:disabled {
188
191
  opacity: 0.45;
189
192
  cursor: progress;
190
193
  }
191
194
 
192
- .dfk-sql-icon-button[hidden] {
195
+ .dfk-icon-button[hidden] {
193
196
  display: none;
194
197
  }
195
198
 
196
199
  /* A sticky state, e.g. the wrap toggle while wrapping is on. */
197
- .dfk-sql-icon-on {
200
+ .dfk-icon-on {
198
201
  color: var(--ifm-color-primary, #14459b);
199
202
  }
200
203
 
201
- .dfk-sql-icon {
204
+ .dfk-icon {
202
205
  font-size: 1rem;
203
206
  }
204
207
 
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
 
@@ -204,7 +204,7 @@ export class DfkSql extends HTMLElementBase {
204
204
  };
205
205
 
206
206
  #resultHost: HTMLElement | null = null;
207
- #editor: SqlEditor | null = null;
207
+ #editor: CodeEditor | null = null;
208
208
  #disposeResult: (() => void) | null = null;
209
209
 
210
210
  constructor() {
@@ -337,9 +337,14 @@ export class DfkSql extends HTMLElementBase {
337
337
  this.#mounting = true;
338
338
  this.#setEditorPending(true);
339
339
  try {
340
- const editor = await mountSqlEditor(this.#editorHost, this.#currentSql, (value) => {
341
- this.#currentSql = value;
342
- });
340
+ const editor = await mountCodeEditor(
341
+ this.#editorHost,
342
+ this.#currentSql,
343
+ (value) => {
344
+ this.#currentSql = value;
345
+ },
346
+ {language: 'sql'},
347
+ );
343
348
  if (!this.isConnected) {
344
349
  // Disconnected while the CodeMirror modules were loading: nothing will
345
350
  // ever dispose this editor, so dispose it here.
@@ -571,46 +576,6 @@ export class DfkSql extends HTMLElementBase {
571
576
  }
572
577
  }
573
578
 
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
579
  function messageOf(error: unknown): string {
615
580
  return error instanceof Error ? error.message : String(error);
616
581
  }
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
@@ -9,6 +9,8 @@ import {el} from '../dom';
9
9
  *
10
10
  * The registry is the seam later phases plug into. It ships `table` (VisActor
11
11
  * VTable), a `text` fallback, the markup previews `iframe` / `html` / `svg`,
12
+ * `mermaid` (which hands the cell to the kit's own `<dfk-mermaid>` element, so a
13
+ * query can produce a diagram the reader can zoom, expand, edit and download),
12
14
  * plus the `error` view every renderer shares.
13
15
  *
14
16
  * Heavy dependencies (`@visactor/vtable`) load through dynamic `import()`
@@ -808,8 +810,11 @@ function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
808
810
  }
809
811
  }
810
812
 
813
+ /** How a preview panel is filled for one row of the result. */
814
+ type PreviewKind = 'iframe' | 'svg' | 'mermaid';
815
+
811
816
  function mountPreviewPanel(
812
- kind: 'iframe' | 'svg',
817
+ kind: PreviewKind,
813
818
  panel: HTMLElement,
814
819
  value: unknown,
815
820
  label: string,
@@ -834,6 +839,20 @@ function mountPreviewPanel(
834
839
  return;
835
840
  }
836
841
 
842
+ if (kind === 'mermaid') {
843
+ // The cell is handed to the kit's own diagram element rather than rendered
844
+ // here: `<dfk-mermaid>` loads mermaid through the page-wide render queue and
845
+ // brings the zoom / fullscreen / edit / download chrome with it. A `mermaid`
846
+ // result and a ```mermaid fence therefore behave identically, and there is
847
+ // one place that knows about the dark-mode-first-load fix.
848
+ //
849
+ // The source travels as an attribute (the element's attribute seed) rather
850
+ // than through a setter, so this works whether or not the element has been
851
+ // upgraded yet: an attribute set before insertion is read at upgrade time.
852
+ panel.appendChild(el('dfk-mermaid', {attrs: {source: markup}}));
853
+ return;
854
+ }
855
+
837
856
  const svg = parseSvgMarkup(panel.ownerDocument, markup);
838
857
  if (!svg) {
839
858
  // Not SVG: show the markup as text rather than an empty panel.
@@ -848,9 +867,10 @@ function mountPreviewPanel(
848
867
 
849
868
  /**
850
869
  * Builds a preview renderer: one tab per row, then the raw rows in the trailing
851
- * `Table` tab. `iframe` and `svg` share everything except how a panel is filled.
870
+ * `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a panel
871
+ * is filled.
852
872
  */
853
- function previewRenderer(kind: 'iframe' | 'svg'): Renderer {
873
+ function previewRenderer(kind: PreviewKind): Renderer {
854
874
  return async ({host, config, labels, fullscreenButton}, result) => {
855
875
  const field = resolveField(config, result);
856
876
  if (!field) {
@@ -900,6 +920,7 @@ const registry: Record<string, Renderer> = {
900
920
  // `html` is the historical spelling of the same renderer; both stay valid.
901
921
  html: previewRenderer('iframe'),
902
922
  svg: previewRenderer('svg'),
923
+ mermaid: previewRenderer('mermaid'),
903
924
  };
904
925
 
905
926
  /**