duckfn-docs-kit 0.2.1 → 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.
- package/AGENTS.md +294 -224
- package/README.md +10 -6
- package/bin/sql-verify.mjs +12 -12
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +7 -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 +94 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/mermaid/title.d.ts +23 -0
- package/dist/{register-DKLiYs-F.js → register-Dev_kc3Z.js} +921 -579
- package/dist/remark.d.ts +1 -1
- package/dist/sql/browserRunner.d.ts +41 -0
- package/dist/sql/browserRunner.js +186 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.d.ts +59 -0
- package/dist/sql/harness.js +8328 -0
- package/dist/sql/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +2 -0
- package/dist/sql/runtime.d.ts +20 -0
- package/dist/sql/verify.d.ts +4 -5
- package/dist/sql/verify.js +36 -49
- package/package.json +6 -2
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +88 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +289 -0
- package/src/mermaid/DfkMermaid.ts +557 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +178 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/mermaid/title.ts +127 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +11 -46
- package/src/sql/browserRunner.ts +402 -0
- package/src/sql/harness.ts +134 -0
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +24 -3
- package/src/sql/runtime.ts +44 -0
- package/src/sql/sql.css +13 -11
- package/src/sql/verify.ts +23 -41
- package/dist/sql/editor.d.ts +0 -16
- package/dist/sql/nodeRunner.d.ts +0 -51
- package/dist/sql/nodeRunner.js +0 -115
- package/src/sql/editor.ts +0 -75
- package/src/sql/nodeRunner.ts +0 -298
|
@@ -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.
|
|
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;
|
package/src/sql/DfkSql.css
CHANGED
|
@@ -159,10 +159,13 @@
|
|
|
159
159
|
|
|
160
160
|
/* --- Icon buttons and their tooltips ------------------------------------- */
|
|
161
161
|
|
|
162
|
-
/*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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-
|
|
182
|
-
.dfk-
|
|
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-
|
|
190
|
+
.dfk-icon-button:disabled {
|
|
188
191
|
opacity: 0.45;
|
|
189
192
|
cursor: progress;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
.dfk-
|
|
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-
|
|
200
|
+
.dfk-icon-on {
|
|
198
201
|
color: var(--ifm-color-primary, #14459b);
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
.dfk-
|
|
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 {
|
|
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:
|
|
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
|
|
341
|
-
this.#
|
|
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
|
}
|