duckfn-docs-kit 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +43 -11
- package/dist/download.d.ts +51 -0
- package/dist/index.js +1 -1
- package/dist/mermaid/DfkMermaid.d.ts +18 -0
- package/dist/mermaid/render.d.ts +0 -2
- package/dist/panzoom-view.d.ts +17 -0
- package/dist/{register-Dev_kc3Z.js → register-wdwf0LC4.js} +1141 -847
- package/dist/source-dialog.d.ts +35 -0
- package/dist/sql/PreviewTabs.d.ts +39 -23
- package/dist/sql/SvgViewer.d.ts +53 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.js +1 -1
- package/dist/sql/renderers.d.ts +7 -0
- package/package.json +3 -2
- package/src/codemirror.ts +14 -5
- package/src/{mermaid/title.ts → download.ts} +66 -24
- package/src/mermaid/DfkMermaid.css +52 -15
- package/src/mermaid/DfkMermaid.ts +144 -290
- package/src/mermaid/render.ts +0 -6
- package/src/panzoom-view.ts +235 -0
- package/src/source-dialog.ts +126 -0
- package/src/sql/DfkSql.ts +49 -5
- package/src/sql/PreviewTabs.ts +98 -52
- package/src/sql/SvgViewer.ts +155 -0
- package/src/sql/renderers.ts +423 -170
- package/src/sql/sql.css +176 -8
- package/dist/mermaid/title.d.ts +0 -23
package/src/sql/PreviewTabs.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
* a field. Activating a tab mutates the nodes it owns
|
|
12
|
-
* `aria-selected`, `tabIndex`) — there is no rebuild, and
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
readonly #items: (PreviewTabItem | null)[] = [];
|
|
49
|
+
readonly #items: PreviewTabItem[] = [];
|
|
40
50
|
readonly #mounted: boolean[] = [];
|
|
41
|
-
|
|
51
|
+
/** Disposers returned by mounts, run on {@link dispose}. */
|
|
52
|
+
readonly #disposers: (() => void)[] = [];
|
|
53
|
+
readonly #downloadBtn: IconButton;
|
|
42
54
|
|
|
43
|
-
#
|
|
55
|
+
#active = 0;
|
|
56
|
+
#fullscreen = false;
|
|
44
57
|
#disposed = false;
|
|
45
58
|
|
|
46
59
|
/**
|
|
47
|
-
* @param corner Node parked at the
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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
|
|
120
|
+
add(item);
|
|
105
121
|
}
|
|
106
|
-
add(tableLabel, null);
|
|
107
122
|
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
137
|
+
/** Releases every mounted item and empties the panels. */
|
|
118
138
|
dispose(): void {
|
|
119
139
|
this.#disposed = true;
|
|
120
|
-
this.#
|
|
121
|
-
|
|
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
|
-
|
|
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 #
|
|
150
|
-
const
|
|
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
|
-
|
|
192
|
+
dispose();
|
|
154
193
|
return;
|
|
155
194
|
}
|
|
156
|
-
this.#
|
|
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
|
+
}
|