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/dist/remark.d.ts CHANGED
@@ -13,7 +13,7 @@ import type { Plugin } from 'unified';
13
13
  */
14
14
  export declare const DEFAULT_VERSION_PLACEHOLDER = "{{DUCKFN_VERSION}}";
15
15
  export interface VersionPlaceholderOptions {
16
- /** The real version string to substitute in, e.g. `0.0.15`. */
16
+ /** The real version string to substitute in, e.g. `0.0.17`. */
17
17
  version: string;
18
18
  /** Override the token if a site uses a different one. */
19
19
  placeholder?: string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The "edit the source" dialog shared by `<dfk-mermaid>` and the SQL result `svg`
3
+ * viewer: a modal holding a plain-text CodeMirror editor and an Apply / Cancel
4
+ * pair.
5
+ *
6
+ * The editor writes back through the callback {@link open} is given rather than
7
+ * through an event, so the owner keeps its own source as the single truth — the
8
+ * dialog never renders anything and never decides what a change means.
9
+ *
10
+ * The `<dialog>` is built once and reused; the editor mounts on first open and is
11
+ * kept, because a reader who edits one diagram is likely to edit the next. The
12
+ * dialog is shown *before* the editor mounts: CodeMirror measures its container as
13
+ * it is constructed, and a `display: none` dialog measures to zero.
14
+ *
15
+ * This is browser-only code.
16
+ */
17
+ export interface SourceDialogLabels {
18
+ title: string;
19
+ apply: string;
20
+ cancel: string;
21
+ }
22
+ export declare class SourceDialog {
23
+ #private;
24
+ readonly root: HTMLDialogElement;
25
+ constructor(labels: SourceDialogLabels);
26
+ setLabels(labels: SourceDialogLabels): void;
27
+ /**
28
+ * Shows the dialog with `source` in the editor. `onApply` receives the edited
29
+ * text — only when it differs from what was opened, so an unedited Apply is a
30
+ * plain close.
31
+ */
32
+ open(source: string, onApply: (value: string) => void): void;
33
+ /** Closes the dialog and releases the editor. The dialog node stays in place. */
34
+ destroy(): void;
35
+ }
@@ -1,37 +1,53 @@
1
+ import { type DownloadPayload } from '../download';
1
2
  /**
2
3
  * Every renderer's result shell: a tab strip plus the panels behind it.
3
4
  *
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.
5
+ * The strip holds one tab per item — a preview row, a `Text` view, or the
6
+ * trailing `Table` view — and, at its right end, the chrome that acts on the
7
+ * result as a whole: the active tab's own {@link PreviewTabItem.actions}, a
8
+ * **download** button, and whatever the caller parks last (the fullscreen toggle
9
+ * `<dfk-sql>` owns). Every result therefore has the same chrome, and the panel
10
+ * behind a tab is built the first time that tab is shown.
9
11
  *
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.
12
+ * Retained mode: every button, action container and panel is built in the
13
+ * constructor and held in a field. Activating a tab mutates the nodes it owns
14
+ * (`hidden`, `classList`, `aria-selected`, `tabIndex`) — there is no rebuild, and
15
+ * a panel is filled the first time it is shown rather than up front.
17
16
  */
18
- /** One preview row: its tab label and how to fill its panel. */
17
+ /** One tab: its label, how to fill its panel, and what it offers the strip. */
19
18
  export interface PreviewTabItem {
20
19
  label: string;
21
- mount(panel: HTMLElement): void;
22
- }
23
- /** What `PreviewTabs` needs from the caller to own the trailing table tab. */
24
- export interface PreviewTableHandle {
25
- dispose(): void;
20
+ /**
21
+ * Fills the panel. Called once, the first time the tab is shown; an async mount
22
+ * may resolve with a disposer, which the strip runs when it is disposed (that is
23
+ * how the table's handle gets released, including when the countdown lands after
24
+ * {@link PreviewTabs.dispose}).
25
+ */
26
+ mount(panel: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
27
+ /**
28
+ * Controls for the strip's right end, shown only while this tab is active — the
29
+ * place for actions that belong to *this* result (a table's search and view
30
+ * switches, a figure's zoom reset) rather than to every result.
31
+ */
32
+ actions?: HTMLElement;
33
+ /** This tab's file, or `null` while there is nothing to save yet. */
34
+ download?: () => DownloadPayload | null;
35
+ /** Told the result area's fullscreen state, for figures that zoom inside it. */
36
+ setFullscreen?: (value: boolean) => void;
26
37
  }
27
38
  export declare class PreviewTabs {
28
39
  #private;
29
40
  /**
30
- * @param corner Node parked at the right end of the strip, outside the
31
- * scrolling tab list. `<dfk-sql>` passes its fullscreen toggle: it owns that
32
- * button's state, so it owns the node and only lends it here.
41
+ * @param corner Node parked at the very end of the strip, after the download
42
+ * button. `<dfk-sql>` passes its fullscreen toggle: it owns that button's state,
43
+ * so it owns the node and only lends it here.
33
44
  */
34
- constructor(host: HTMLElement, items: readonly PreviewTabItem[], tableLabel: string, mountTable: (panel: HTMLElement) => Promise<PreviewTableHandle>, corner?: HTMLElement);
35
- /** Releases the table (if it was ever shown) and empties every panel. */
45
+ constructor(host: HTMLElement, items: readonly PreviewTabItem[], downloadLabel: string, corner?: HTMLElement);
46
+ /** Releases every mounted item and empties the panels. */
36
47
  dispose(): void;
48
+ /**
49
+ * Records the result area's fullscreen state and passes it on to the tab on
50
+ * screen: a figure zooms only where the result is expanded (see `PanZoomView`).
51
+ */
52
+ setFullscreen(value: boolean): void;
37
53
  }
@@ -0,0 +1,53 @@
1
+ import { type DownloadPayload } from '../download';
2
+ /**
3
+ * The `svg` result view: an inline SVG a reader can zoom (in the result area's
4
+ * fullscreen), edit, and download — the same interactions a diagram gets from
5
+ * `<dfk-mermaid>`, which is why both are built on `PanZoomView` and
6
+ * `SourceDialog`.
7
+ *
8
+ * A plain class rather than a custom element: nothing hands it content through
9
+ * markup, and the SQL renderer already has the parsed node, so there is no
10
+ * upgrade to wait for.
11
+ *
12
+ * Like an embedded `<dfk-mermaid>`, the element contributes no frame — the result
13
+ * area's panel is the frame — and no floating cluster: its two controls travel out
14
+ * through {@link actions} for the result's tab strip, and its file travels out
15
+ * through {@link downloadPayload} for the strip's download button. Zoom is off
16
+ * until {@link setFullscreen} says the result area is expanded.
17
+ *
18
+ * This is browser-only code.
19
+ */
20
+ /** The strings a figure's controls need. */
21
+ export interface FigureLabels {
22
+ reset: string;
23
+ edit: string;
24
+ editTitle: string;
25
+ apply: string;
26
+ cancel: string;
27
+ }
28
+ export declare class SvgViewer {
29
+ #private;
30
+ /** The box in the tab panel: the panzoom viewport, and the frame's clipping box. */
31
+ readonly root: HTMLDivElement;
32
+ /** The controls the result area's tab strip shows while this tab is active. */
33
+ readonly actions: HTMLDivElement;
34
+ constructor(svg: SVGElement | null, markup: string, labels: FigureLabels, fallback: string);
35
+ /** Turns zoom and pan on or off; the result area calls this with its fullscreen. */
36
+ setFullscreen(value: boolean): void;
37
+ /**
38
+ * The file this view would be saved as: the SVG itself, or — when the markup
39
+ * does not parse and the view is showing it as text — that text.
40
+ */
41
+ downloadPayload(): DownloadPayload;
42
+ dispose(): void;
43
+ }
44
+ /**
45
+ * Parses SVG markup from a result cell into a node this document can host.
46
+ *
47
+ * `image/svg+xml` is strict XML: malformed markup (unclosed tags, a bare `&`,
48
+ * an HTML `<br>`) comes back as a `<parsererror>` element rather than throwing,
49
+ * so the caller can degrade to text. Script-bearing and event-handler content
50
+ * is stripped — inline SVG is *not* isolated (use the `iframe` renderer for
51
+ * untrusted markup).
52
+ */
53
+ export declare function parseSvgMarkup(document: Document, markup: string): SVGElement | null;
@@ -1,4 +1,4 @@
1
- import { t as e } from "../register-CALCwFBv.js";
1
+ import { t as e } from "../register-wdwf0LC4.js";
2
2
  //#region src/sql/client.ts
3
3
  typeof window < "u" && e();
4
4
  //#endregion
@@ -8000,7 +8000,7 @@ var Eu, Du, Ou, ku, Au, ju, Mu, Nu, Pu, Fu, Iu, Lu, Ru, zu, Bu, Vu, Hu, Uu, Wu,
8000
8000
  11
8001
8001
  ])), od = {
8002
8002
  name: "@duckdb/duckdb-wasm",
8003
- version: "1.33.1-dev64.0",
8003
+ version: "1.33.1-dev65.0",
8004
8004
  description: "DuckDB powered by WebAssembly",
8005
8005
  license: "MIT",
8006
8006
  repository: {
@@ -34,8 +34,13 @@ export interface RunnableSqlConfig {
34
34
  *
35
35
  * `html` and `iframe` are the same renderer: both sandbox the markup in an
36
36
  * iframe, so scripts run with an opaque origin.
37
+ *
38
+ * `mermaid` renders the column's mermaid source as a diagram through
39
+ * `<dfk-mermaid>` (the same element a ```mermaid fence produces), so the result
40
+ * gets the element's zoom, fullscreen, source editing and SVG download for
41
+ * free.
37
42
  */
38
- show?: 'table' | 'html' | 'iframe' | 'svg' | 'text';
43
+ show?: 'table' | 'html' | 'iframe' | 'svg' | 'text' | 'mermaid';
39
44
  /**
40
45
  * What this block is expected to do when the docs' own SQL test suite runs it
41
46
  * (`duckfn-docs-kit/sql/verify`). Defaults to `'ok'`; `'error'` marks a block
@@ -5,6 +5,8 @@ import type { RunnableSqlConfig } from './remark';
5
5
  *
6
6
  * The registry is the seam later phases plug into. It ships `table` (VisActor
7
7
  * VTable), a `text` fallback, the markup previews `iframe` / `html` / `svg`,
8
+ * `mermaid` (which hands the cell to the kit's own `<dfk-mermaid>` element, so a
9
+ * query can produce a diagram the reader can zoom, expand, edit and download),
8
10
  * plus the `error` view every renderer shares.
9
11
  *
10
12
  * Heavy dependencies (`@visactor/vtable`) load through dynamic `import()`
@@ -23,6 +25,13 @@ export interface RenderContext {
23
25
  * renderer only borrows it so every result has the same chrome.
24
26
  */
25
27
  fullscreenButton: HTMLElement;
28
+ /**
29
+ * Subscribes to the result area's fullscreen state, calling back with the
30
+ * current value straight away and returning the unsubscribe. A renderer whose
31
+ * figure zooms only in fullscreen (`svg`, `mermaid`) forwards the value to its
32
+ * viewer; the subscription must be released by the renderer's disposer.
33
+ */
34
+ onFullscreenChange(listener: (value: boolean) => void): () => void;
26
35
  }
27
36
  /**
28
37
  * Renders `result` into `context.host`. Resolves with a disposer releasing any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duckfn-docs-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared Docusaurus building blocks (TOC toggle, home-page web components, brand tokens, remark version placeholder) for duckfn-family extension docs sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -58,10 +58,14 @@
58
58
  "@codemirror/lang-sql": "^6.10.0",
59
59
  "@codemirror/state": "^6.7.6",
60
60
  "@codemirror/view": "^6.43.13",
61
- "@duckdb/duckdb-wasm": "1.33.1-dev64.0",
61
+ "@duckdb/duckdb-wasm": "1.33.1-dev65.0",
62
+ "@panzoom/panzoom": "^4.6.2",
62
63
  "@visactor/vtable": "^1.26.8",
64
+ "@visactor/vtable-search": "^1.26.8",
63
65
  "codemirror": "^6.0.2",
66
+ "filenamify": "^7.0.3",
64
67
  "iconify-icon": "^3.0.3",
68
+ "mermaid": "^12.0.0",
65
69
  "playwright-core": "^1.63.0",
66
70
  "sql-formatter": "^15.9.0"
67
71
  },
@@ -0,0 +1,50 @@
1
+ import type {IconifyIconHTMLElement} from 'iconify-icon';
2
+ import {el} from './dom';
3
+
4
+ /**
5
+ * A compact icon-only button with a hover/focus tooltip, built once and then
6
+ * mutated. Shared by `<dfk-sql>`'s code-block cluster and `<dfk-mermaid>`'s
7
+ * diagram cluster: both want the same "floating icon row over content" idiom,
8
+ * and neither wants a second implementation of it.
9
+ *
10
+ * The tooltip is also the accessible name — an icon-only control has no text to
11
+ * fall back on. The two class names are written by this module and styled in
12
+ * *each* consumer's shadow-root CSS; that duplication is unavoidable (a shadow
13
+ * boundary stops one sheet from reaching the other tree), so the rules carry the
14
+ * same names and are kept in sync by hand.
15
+ */
16
+ export class IconButton {
17
+ readonly root = el('button', {class: 'dfk-icon-button', type: 'button'});
18
+ readonly #icon: IconifyIconHTMLElement = el('iconify-icon', {
19
+ class: 'dfk-icon',
20
+ attrs: {'aria-hidden': 'true'},
21
+ });
22
+
23
+ constructor(icon: string, onClick: () => void) {
24
+ this.root.appendChild(this.#icon);
25
+ this.root.addEventListener('click', onClick);
26
+ this.setIcon(icon);
27
+ }
28
+
29
+ setIcon(icon: string): void {
30
+ this.#icon.setAttribute('icon', icon);
31
+ }
32
+
33
+ setLabel(text: string): void {
34
+ this.root.setAttribute('data-tip', text);
35
+ this.root.setAttribute('aria-label', text);
36
+ }
37
+
38
+ /** Marks a toggle as currently on (e.g. the SQL block's wrap toggle). */
39
+ setOn(on: boolean): void {
40
+ this.root.classList.toggle('dfk-icon-on', on);
41
+ }
42
+
43
+ setDisabled(disabled: boolean): void {
44
+ if (disabled) {
45
+ this.root.setAttribute('disabled', '');
46
+ } else {
47
+ this.root.removeAttribute('disabled');
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The CodeMirror 6 editor behind the kit's editable content: the code view of a
3
+ * runnable SQL block (`<dfk-sql>`) and the diagram-source dialog of a
4
+ * `<dfk-mermaid>`. One implementation, two languages — the SQL block asks for
5
+ * highlighting, the diagram editor takes the plain-text default.
6
+ *
7
+ * Every CodeMirror module arrives through a dynamic `import()` inside
8
+ * {@link mountCodeEditor}: a page full of blocks pays nothing for the editor on
9
+ * its critical path, and Docusaurus' Node prerender never evaluates any of it.
10
+ *
11
+ * This is browser-only code: `document` is touched only through the container
12
+ * the caller hands over, and everything else is behind the `import()`s.
13
+ */
14
+
15
+ export interface CodeEditor {
16
+ getValue(): string;
17
+ setValue(value: string): void;
18
+ /** Soft-wraps long lines, or stops wrapping them (the block's wrap toggle). */
19
+ setWrap(wrapped: boolean): void;
20
+ destroy(): void;
21
+ }
22
+
23
+ export interface CodeEditorOptions {
24
+ /**
25
+ * Highlight the document as SQL. Omitted, the editor is plain text — which is
26
+ * what a mermaid diagram gets: there is no first-party CodeMirror language for
27
+ * it, and a docs reader is editing prose-shaped source, not writing SQL.
28
+ */
29
+ language?: 'sql';
30
+ }
31
+
32
+ export async function mountCodeEditor(
33
+ container: HTMLElement,
34
+ value: string,
35
+ onChange: (value: string) => void,
36
+ options: CodeEditorOptions = {},
37
+ ): Promise<CodeEditor> {
38
+ const [{basicSetup}, {EditorView, keymap}, {defaultKeymap, historyKeymap}, {Compartment}] =
39
+ await Promise.all([
40
+ import('codemirror'),
41
+ import('@codemirror/view'),
42
+ import('@codemirror/commands'),
43
+ import('@codemirror/state'),
44
+ ]);
45
+ // Loaded only for the blocks that ask for it, so a diagram editor does not pull
46
+ // the SQL grammar (and its lezer parsers) into the page.
47
+ const language =
48
+ options.language === 'sql' ? [(await import('@codemirror/lang-sql')).sql()] : [];
49
+
50
+ // Wrapping is toggled from the outside, and reconfiguring it must not disturb
51
+ // the document or the undo history — that is exactly what a compartment is
52
+ // for, so the extension is swapped in place rather than rebuilt.
53
+ const wrap = new Compartment();
54
+
55
+ const view = new EditorView({
56
+ doc: value,
57
+ extensions: [
58
+ basicSetup,
59
+ ...language,
60
+ keymap.of([...defaultKeymap, ...historyKeymap]),
61
+ wrap.of([]),
62
+ EditorView.updateListener.of((update) => {
63
+ if (update.docChanged) {
64
+ onChange(update.state.doc.toString());
65
+ }
66
+ }),
67
+ ],
68
+ parent: container,
69
+ // The tree the container really belongs to — `getRootNode()`, not CodeMirror's
70
+ // own `getRoot()`. `getRoot()` follows `assignedSlot` before `parentNode`, so a
71
+ // container in a *slotted* subtree is reported as living in the shadow root it
72
+ // is slotted **into**. The SQL result area is exactly that (light DOM, slotted
73
+ // into `<dfk-sql>` through `slot="dfk-result"`), so style-mod mounted the base
74
+ // theme in a shadow root, where it cannot reach slotted nodes at all: the
75
+ // editor laid out as plain block boxes — `.cm-scroller` stacked instead of
76
+ // flex, gutter above the code.
77
+ //
78
+ // `getRootNode()` stops at the tree that owns the container: `document` for
79
+ // that slotted light DOM, the shadow root for a genuine shadow child (the
80
+ // code view, a diagram's dialog) — which is where the `.cm-*` rules are
81
+ // needed. Pinning it to `document` would break the shadow cases instead.
82
+ root: container.getRootNode() as Document | ShadowRoot,
83
+ });
84
+
85
+ return {
86
+ getValue: () => view.state.doc.toString(),
87
+ setValue: (next: string) =>
88
+ view.dispatch({
89
+ changes: {from: 0, to: view.state.doc.length, insert: next},
90
+ }),
91
+ setWrap: (wrapped: boolean) =>
92
+ view.dispatch({
93
+ effects: wrap.reconfigure(wrapped ? EditorView.lineWrapping : []),
94
+ }),
95
+ destroy: () => view.destroy(),
96
+ };
97
+ }
@@ -0,0 +1,169 @@
1
+ import filenamify from 'filenamify';
2
+ import {el} from './dom';
3
+
4
+ /**
5
+ * Saving something the kit rendered to a file: the payload shape, the click that
6
+ * performs the save, and how the file is named.
7
+ *
8
+ * Shared by `<dfk-mermaid>` (a downloaded diagram) and the SQL result chrome (a
9
+ * downloaded result of any kind), so a docs page has one definition of "what a
10
+ * download is" rather than one per component.
11
+ *
12
+ * This is browser-only code.
13
+ */
14
+
15
+ /** One file's worth of text, ready to save. */
16
+ export interface DownloadPayload {
17
+ /** The file name, extension included. */
18
+ name: string;
19
+ /** The blob's MIME type. */
20
+ mime: string;
21
+ /** The file's contents — text for every format the kit produces. */
22
+ text: string;
23
+ }
24
+
25
+ /**
26
+ * Saves a payload through a temporary object URL.
27
+ *
28
+ * The anchor is in the document for the click — some browsers ignore a detached
29
+ * one — and the URL is revoked on the next tick, once the download has been
30
+ * handed to the browser.
31
+ */
32
+ export function saveDownload(payload: DownloadPayload): void {
33
+ const blob = new Blob([payload.text], {type: payload.mime});
34
+ const url = URL.createObjectURL(blob);
35
+ const link = el('a', {href: url, download: payload.name});
36
+ document.body.appendChild(link);
37
+ link.click();
38
+ link.remove();
39
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
40
+ }
41
+
42
+ /** Used when nothing on the page says anything about what this is. */
43
+ const DEFAULT_BASE = 'diagram';
44
+
45
+ /**
46
+ * The length cap. `filenamify` truncates by grapheme, so a CJK heading is cut at
47
+ * 80 *characters*, not bytes, and an emoji survives whole.
48
+ */
49
+ const MAX_LENGTH = 80;
50
+
51
+ /** How a reserved character is spelled in the file name (`2. Registration: …`). */
52
+ const REPLACEMENT = '-';
53
+
54
+ /**
55
+ * The *format* characters (zero-width space, word joiner, byte-order mark…) and
56
+ * whitespace that a title can wear at its edges.
57
+ */
58
+ const EDGE_NOISE = /^[\s\p{Cf}]+|[\s\p{Cf}]+$/gu;
59
+
60
+ /**
61
+ * Trims a candidate down to its text, dropping format characters at the edges.
62
+ *
63
+ * Plain whitespace trimming is not enough: Docusaurus gives every heading an
64
+ * anchor link whose label is a zero-width space, so `textContent` of a heading is
65
+ * `"2. Registration\u200B"`. `filenamify` turns a format character into the
66
+ * replacement rather than dropping it, which would produce
67
+ * `2. Registration-.svg`. Only the *edges*: an interior zero-width joiner is what
68
+ * holds an emoji together.
69
+ */
70
+ function cleanTitle(value: string | undefined): string {
71
+ return value === undefined ? '' : value.replace(EDGE_NOISE, '');
72
+ }
73
+
74
+ /**
75
+ * What a downloaded figure is called.
76
+ *
77
+ * The name is derived from the page, not from the figure: a diagram or an SVG
78
+ * result has no name of its own, and the reader downloading one is after "the
79
+ * figure from that section", not `diagram.svg`.
80
+ *
81
+ * Three sources, most specific first, each falling back to the next:
82
+ *
83
+ * 1. **The figure's own title** — mermaid's frontmatter (`---\ntitle: …\n---`,
84
+ * which mermaid itself draws above the diagram); only read when `options.source`
85
+ * is given.
86
+ * 2. **The nearest heading above it** — the section the figure belongs to, which
87
+ * on a docs page is what a reader would call it.
88
+ * 3. **The document title** — the browser tab's title, for a figure that sits
89
+ * above every heading on its page.
90
+ *
91
+ * Nothing found → `options.fallback` (or {@link DEFAULT_BASE}), with `extension`
92
+ * appended either way.
93
+ */
94
+ export function sectionFileName(
95
+ element: Element,
96
+ extension: string,
97
+ options: {source?: string; fallback?: string} = {},
98
+ ): string {
99
+ const candidates = [
100
+ options.source === undefined ? undefined : frontmatterTitle(options.source),
101
+ precedingHeading(element),
102
+ element.ownerDocument.title,
103
+ ].map(cleanTitle);
104
+ const title = candidates.find((candidate) => candidate !== '');
105
+ if (title === undefined) {
106
+ return `${options.fallback ?? DEFAULT_BASE}.${extension}`;
107
+ }
108
+ // Filenames are a filesystem concern, so they go through a library rather than a
109
+ // hand-rolled character class. Three things it does that matter here and that a
110
+ // browser does *not*: it strips what Windows and macOS reject (`:`, `?`, `*`,
111
+ // `"`, `<`, `>`, `|`, the path separators and control characters), it trims the
112
+ // trailing dots and spaces Windows silently drops, and it avoids the reserved
113
+ // device names (`con`, `nul`, …). The `download` attribute's own sanitisation
114
+ // covers only `/` and `\`.
115
+ //
116
+ // It also normalises Unicode whitespace and drops format characters, which
117
+ // quietly cleans up Docusaurus' heading anchors: the `<a>` it appends to every
118
+ // heading contributes a zero-width space to `textContent`.
119
+ return `${filenamify(title, {replacement: REPLACEMENT, maxLength: MAX_LENGTH})}.${extension}`;
120
+ }
121
+
122
+ /**
123
+ * mermaid's frontmatter block, if the source opens with one.
124
+ *
125
+ * Only the top-level `title:` key is read. Mermaid's frontmatter is YAML, and the
126
+ * rest of it (a nested `config:`, `displayMode:`, …) is none of this module's
127
+ * business: a key that is always a plain scalar on one line does not justify a
128
+ * YAML parser, and anything indented — a nested key — is skipped by anchoring the
129
+ * match at the line start.
130
+ */
131
+ function frontmatterTitle(source: string): string | undefined {
132
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source.trimStart())?.[1];
133
+ if (block === undefined) {
134
+ return undefined;
135
+ }
136
+ const value = /^title\s*:\s*(.+)$/m.exec(block)?.[1];
137
+ return value === undefined ? undefined : unquote(value.trim());
138
+ }
139
+
140
+ /** Strips one pair of matching quotes, so `title: "A: B"` keeps its colon. */
141
+ function unquote(value: string): string {
142
+ const first = value[0];
143
+ return (first === '"' || first === "'") && value.endsWith(first) ? value.slice(1, -1) : value;
144
+ }
145
+
146
+ /**
147
+ * The text of the last heading that precedes the figure, or `undefined`.
148
+ *
149
+ * The search is scoped to the enclosing `<article>` where there is one: a docs
150
+ * page's navbar, sidebar and footer are full of headings that have nothing to do
151
+ * with this figure, and the article is the one container that holds the page's
152
+ * own content.
153
+ */
154
+ function precedingHeading(element: Element): string | undefined {
155
+ const scope: ParentNode = element.closest('article') ?? element.ownerDocument;
156
+ let found: string | undefined;
157
+ for (const heading of scope.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
158
+ // `FOLLOWING` means the heading comes before the element in document order.
159
+ if (!(heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
160
+ // Headings are in document order, so nothing later can precede it either.
161
+ break;
162
+ }
163
+ const text = heading.textContent?.trim();
164
+ if (text) {
165
+ found = text;
166
+ }
167
+ }
168
+ return found;
169
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import type {HTMLAttributes} from 'react';
2
+ // Imported as well as re-exported: the `HTMLElementTagNameMap` augmentation below
3
+ // names the classes, and a module augmentation can only refer to local bindings.
4
+ import {DfkFeatures} from './home/DfkFeatures';
5
+ import {DfkHero} from './home/DfkHero';
6
+ import {DfkMermaid} from './mermaid/DfkMermaid';
7
+ import {DfkNextSteps} from './home/DfkNextSteps';
8
+ import {DfkSql} from './sql/DfkSql';
2
9
 
3
10
  /**
4
- * Browser entry for duckfn-docs-kit: the home-page custom elements and the
11
+ * Browser entry for duckfn-docs-kit: the custom elements it registers and the
5
12
  * value types their `set*` methods accept.
6
13
  *
7
14
  * The components are retained-mode (build once, then mutate held nodes) and
@@ -13,17 +20,16 @@ import type {HTMLAttributes} from 'react';
13
20
  * web component, which `registerDfkElements()` registers as a side effect, so
14
21
  * this package ships no icon data.
15
22
  *
16
- * `TocToggle` and the remark plugin keep their own subpaths
17
- * (`duckfn-docs-kit/toc-toggle/TocToggle`, `duckfn-docs-kit/remark`) instead of
23
+ * `TocToggle` and the remark plugins keep their own subpaths
24
+ * (`duckfn-docs-kit/toc-toggle/TocToggle`, `duckfn-docs-kit/remark`,
25
+ * `duckfn-docs-kit/sql/remark`, `duckfn-docs-kit/mermaid/remark`) instead of
18
26
  * being merged here: a Docusaurus config file must never pull browser code into
19
27
  * Node, and a site that only wants the TOC collapse button should not pay for
20
28
  * the bundled `iconify-icon`.
21
29
  */
22
- export {DfkFeatures} from './home/DfkFeatures';
23
- export {DfkHero} from './home/DfkHero';
24
- export {DfkNextSteps} from './home/DfkNextSteps';
25
- export {DfkSql} from './sql/DfkSql';
30
+ export {DfkFeatures, DfkHero, DfkMermaid, DfkNextSteps, DfkSql};
26
31
  export {registerDfkElements} from './register';
32
+ export type {DfkMermaidConfig, DfkMermaidConfigInput} from './mermaid/config';
27
33
  export type {RunnableSqlConfig} from './sql/remark';
28
34
  export type {
29
35
  FeatureItem,
@@ -47,6 +53,23 @@ export type {
47
53
  */
48
54
  export type DfkElementProps = HTMLAttributes<HTMLElement>;
49
55
 
56
+ /**
57
+ * The kit's tags in the DOM's own tag map, so `document.createElement('dfk-sql')`
58
+ * (and the kit's `el()` helper) is typed as the class it upgrades to. Without
59
+ * this, every place that builds a `dfk-*` element from scratch — `sql/renderers.ts`
60
+ * building a `<dfk-mermaid>` for a `mermaid` result, `docs/src/pages/index.tsx`
61
+ * mounting the home elements — would have to cast the result.
62
+ */
63
+ declare global {
64
+ interface HTMLElementTagNameMap {
65
+ 'dfk-hero': DfkHero;
66
+ 'dfk-features': DfkFeatures;
67
+ 'dfk-next-steps': DfkNextSteps;
68
+ 'dfk-sql': DfkSql;
69
+ 'dfk-mermaid': DfkMermaid;
70
+ }
71
+ }
72
+
50
73
  declare module 'react' {
51
74
  namespace JSX {
52
75
  interface IntrinsicElements {
@@ -54,6 +77,7 @@ declare module 'react' {
54
77
  'dfk-features': DfkElementProps;
55
78
  'dfk-next-steps': DfkElementProps;
56
79
  'dfk-sql': DfkElementProps;
80
+ 'dfk-mermaid': DfkElementProps;
57
81
  }
58
82
  }
59
83
  }