duckfn-docs-kit 0.1.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 +689 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/dom.d.ts +69 -0
- package/dist/home/DfkFeatures.d.ts +20 -0
- package/dist/home/DfkHero.d.ts +25 -0
- package/dist/home/DfkNextSteps.d.ts +16 -0
- package/dist/home/styles.d.ts +8 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +2 -0
- package/dist/register-DKLiYs-F.js +2324 -0
- package/dist/register.d.ts +10 -0
- package/dist/remark.d.ts +21 -0
- package/dist/remark.js +15 -0
- package/dist/runtimeConfig-Bokbb8VH.js +106 -0
- package/dist/sql/DfkSql.d.ts +7 -0
- package/dist/sql/PreviewTabs.d.ts +37 -0
- package/dist/sql/client.d.ts +1 -0
- package/dist/sql/client.js +4 -0
- package/dist/sql/editor.d.ts +16 -0
- package/dist/sql/extensions.d.ts +108 -0
- package/dist/sql/extensions.js +198 -0
- package/dist/sql/remark.d.ts +88 -0
- package/dist/sql/remark.js +69 -0
- package/dist/sql/renderers.d.ts +44 -0
- package/dist/sql/runtime.d.ts +105 -0
- package/dist/sql/runtimeConfig.d.ts +80 -0
- package/dist/sql/styles.d.ts +6 -0
- package/dist/toc-toggle/TocToggle.d.ts +46 -0
- package/dist/toc-toggle/TocToggle.js +69 -0
- package/dist/toc-toggle/client.d.ts +1 -0
- package/dist/toc-toggle/client.js +9 -0
- package/dist/toc-toggle/plugin.d.ts +36 -0
- package/dist/toc-toggle/plugin.js +13 -0
- package/dist/types.d.ts +42 -0
- package/package.json +73 -0
- package/src/dom.ts +109 -0
- package/src/home/DfkFeatures.ts +78 -0
- package/src/home/DfkHero.ts +128 -0
- package/src/home/DfkNextSteps.ts +73 -0
- package/src/home/home.css +520 -0
- package/src/home/styles.ts +28 -0
- package/src/index.ts +59 -0
- package/src/kit.css +19 -0
- package/src/register.ts +39 -0
- package/src/remark.ts +60 -0
- package/src/sql/DfkSql.css +226 -0
- package/src/sql/DfkSql.ts +620 -0
- package/src/sql/PreviewTabs.ts +169 -0
- package/src/sql/client.ts +16 -0
- package/src/sql/editor.ts +75 -0
- package/src/sql/extensions.ts +470 -0
- package/src/sql/remark.ts +213 -0
- package/src/sql/renderers.ts +916 -0
- package/src/sql/runtime.ts +348 -0
- package/src/sql/runtimeConfig.ts +249 -0
- package/src/sql/sql.css +397 -0
- package/src/sql/styles.ts +24 -0
- package/src/theme/tokens.css +75 -0
- package/src/toc-toggle/TocToggle.css +69 -0
- package/src/toc-toggle/TocToggle.ts +172 -0
- package/src/toc-toggle/client.ts +20 -0
- package/src/toc-toggle/plugin.ts +54 -0
- package/src/types.ts +47 -0
- package/src/vite-env.d.ts +8 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import type {IconifyIconHTMLElement} from 'iconify-icon';
|
|
2
|
+
import type {RunnableSqlConfig} from './remark';
|
|
3
|
+
import {DuckDBRuntime, type QueryResult} from './runtime';
|
|
4
|
+
import {rendererFor, type RenderContext} from './renderers';
|
|
5
|
+
import {mountSqlEditor, type SqlEditor} from './editor';
|
|
6
|
+
import {sqlStyles} from './styles';
|
|
7
|
+
import {el, HTMLElementBase} from '../dom';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `<dfk-sql>` — a runnable SQL example produced by `remarkRunnableSql`.
|
|
11
|
+
*
|
|
12
|
+
* A code block, a row of icon buttons floating in its top-right corner on
|
|
13
|
+
* hover (run / format / reset / wrap / copy — the same idiom as Docusaurus' own
|
|
14
|
+
* code blocks), and a result area that appears only once something has run. The
|
|
15
|
+
* CodeMirror editor *is* the code view: there is no read-only preview and no
|
|
16
|
+
* edit mode to enter.
|
|
17
|
+
*
|
|
18
|
+
* Everything but the result is in the shadow root. The result is a slotted
|
|
19
|
+
* light-DOM sibling because VTable injects a *document-level* stylesheet that a
|
|
20
|
+
* shadow boundary could not host — and it is only created when a query runs,
|
|
21
|
+
* long after hydration, so the light DOM still starts empty (AGENTS.md rule 11).
|
|
22
|
+
* The editor has no such problem: it sits in this shadow root, so CodeMirror's
|
|
23
|
+
* style-mod resolves the root to the same tree its styles are used in.
|
|
24
|
+
*
|
|
25
|
+
* Retained-mode: every node is built once in the constructor and held in a
|
|
26
|
+
* field; the `#set*` / `#show*` helpers mutate the nodes they own. Each block
|
|
27
|
+
* keeps its own `#currentSql`, so editing one never touches another.
|
|
28
|
+
*
|
|
29
|
+
* The fullscreen toggle is the exception: it belongs in the result's tab strip,
|
|
30
|
+
* which a renderer builds, so the component keeps the node (and its state) and
|
|
31
|
+
* hands it over through `RenderContext.fullscreenButton`.
|
|
32
|
+
*
|
|
33
|
+
* Content entry is an **attribute seed** (see AGENTS.md rule 5 exception): the
|
|
34
|
+
* `config` / `sql` attributes are read once in `connectedCallback` because the
|
|
35
|
+
* remark-generated JSX cannot hand content through a ref setter. Reading once
|
|
36
|
+
* to initialise is not an attribute→render loop, so the retained-mode contract
|
|
37
|
+
* still holds.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// A `type` alias (not an interface) so it satisfies `RenderContext`'s
|
|
41
|
+
// `Record<string, string>` index signature. It carries the renderers' strings
|
|
42
|
+
// too, because a renderer resolves everything through the labels it is handed.
|
|
43
|
+
type SqlLabels = {
|
|
44
|
+
run: string;
|
|
45
|
+
format: string;
|
|
46
|
+
reset: string;
|
|
47
|
+
/** Tooltip for the wrap toggle while wrapping is *off* (i.e. "turn it on"). */
|
|
48
|
+
wrapOn: string;
|
|
49
|
+
/** Tooltip for the wrap toggle while wrapping is *on* (i.e. "turn it off"). */
|
|
50
|
+
wrapOff: string;
|
|
51
|
+
copy: string;
|
|
52
|
+
copied: string;
|
|
53
|
+
/** Context-menu labels for the result table (see `renderers.ts`). */
|
|
54
|
+
copyAll: string;
|
|
55
|
+
wrapColumn: string;
|
|
56
|
+
unwrapColumn: string;
|
|
57
|
+
freezeColumn: string;
|
|
58
|
+
unfreezeColumns: string;
|
|
59
|
+
resetView: string;
|
|
60
|
+
noData: string;
|
|
61
|
+
/** Width-mode submenu of the result table (see `renderers.ts`). */
|
|
62
|
+
widthMode: string;
|
|
63
|
+
widthAdaptive: string;
|
|
64
|
+
widthStandard: string;
|
|
65
|
+
widthFill: string;
|
|
66
|
+
running: string;
|
|
67
|
+
initializing: string;
|
|
68
|
+
loadingExtensions: string;
|
|
69
|
+
initFailed: string;
|
|
70
|
+
error: string;
|
|
71
|
+
formatFailed: string;
|
|
72
|
+
fullscreen: string;
|
|
73
|
+
exitFullscreen: string;
|
|
74
|
+
table: string;
|
|
75
|
+
text: string;
|
|
76
|
+
row: string;
|
|
77
|
+
noField: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const LABELS: Record<string, SqlLabels> = {
|
|
81
|
+
en: {
|
|
82
|
+
run: 'Run',
|
|
83
|
+
format: 'Format SQL',
|
|
84
|
+
reset: 'Reset',
|
|
85
|
+
wrapOn: 'Wrap long lines',
|
|
86
|
+
wrapOff: 'Stop wrapping lines',
|
|
87
|
+
copy: 'Copy',
|
|
88
|
+
copied: 'Copied',
|
|
89
|
+
copyAll: 'Copy table',
|
|
90
|
+
wrapColumn: 'Wrap column',
|
|
91
|
+
unwrapColumn: 'Stop wrapping column',
|
|
92
|
+
freezeColumn: 'Freeze up to here',
|
|
93
|
+
unfreezeColumns: 'Unfreeze columns',
|
|
94
|
+
resetView: 'Reset view',
|
|
95
|
+
noData: 'No rows',
|
|
96
|
+
widthMode: 'Column width',
|
|
97
|
+
widthAdaptive: 'Fill the width',
|
|
98
|
+
widthStandard: 'Content widths, scroll sideways',
|
|
99
|
+
widthFill: 'Content first, fill when it fits',
|
|
100
|
+
running: 'Running…',
|
|
101
|
+
initializing: 'Initializing DuckDB…',
|
|
102
|
+
loadingExtensions: 'Loading extensions…',
|
|
103
|
+
initFailed: 'DuckDB failed to start',
|
|
104
|
+
error: 'Error',
|
|
105
|
+
formatFailed: 'Could not format the SQL',
|
|
106
|
+
fullscreen: 'Fullscreen',
|
|
107
|
+
exitFullscreen: 'Exit fullscreen',
|
|
108
|
+
table: 'Table',
|
|
109
|
+
text: 'Text',
|
|
110
|
+
row: 'Row',
|
|
111
|
+
noField: 'this result has no markup column; set `field`',
|
|
112
|
+
},
|
|
113
|
+
'zh-hans': {
|
|
114
|
+
run: '执行',
|
|
115
|
+
format: '格式化',
|
|
116
|
+
reset: '重置',
|
|
117
|
+
wrapOn: '折行显示',
|
|
118
|
+
wrapOff: '取消折行',
|
|
119
|
+
copy: '复制',
|
|
120
|
+
copied: '已复制',
|
|
121
|
+
copyAll: '复制整表',
|
|
122
|
+
wrapColumn: '此列折行',
|
|
123
|
+
unwrapColumn: '取消此列折行',
|
|
124
|
+
freezeColumn: '冻结到此列',
|
|
125
|
+
unfreezeColumns: '取消冻结',
|
|
126
|
+
resetView: '重置视图',
|
|
127
|
+
noData: '无数据',
|
|
128
|
+
widthMode: '列宽模式',
|
|
129
|
+
widthAdaptive: '铺满宽度',
|
|
130
|
+
widthStandard: '按内容列宽(可横向滚动)',
|
|
131
|
+
widthFill: '内容优先,装得下就铺满',
|
|
132
|
+
running: '执行中…',
|
|
133
|
+
initializing: '正在初始化 DuckDB…',
|
|
134
|
+
loadingExtensions: '正在加载扩展…',
|
|
135
|
+
initFailed: 'DuckDB 初始化失败',
|
|
136
|
+
error: '错误',
|
|
137
|
+
formatFailed: '格式化失败',
|
|
138
|
+
fullscreen: '全屏',
|
|
139
|
+
exitFullscreen: '退出全屏',
|
|
140
|
+
table: '表格',
|
|
141
|
+
text: '文本',
|
|
142
|
+
row: '行',
|
|
143
|
+
noField: '该结果没有可展示的标记列,请设置 `field`',
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** How long the copy button shows its "copied" confirmation, in ms. */
|
|
148
|
+
const COPIED_HOLD = 1600;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `sql-formatter` is loaded on first use, like the CodeMirror modules: a reader
|
|
152
|
+
* who never presses Format pays nothing for its parser. `duckdb` is a dialect
|
|
153
|
+
* of its own there, so DuckDB-only syntax (`EXCLUDE`, `PIVOT`) survives.
|
|
154
|
+
*/
|
|
155
|
+
let formatterModule: Promise<typeof import('sql-formatter')> | null = null;
|
|
156
|
+
function loadFormatter(): Promise<typeof import('sql-formatter')> {
|
|
157
|
+
formatterModule ??= import('sql-formatter');
|
|
158
|
+
return formatterModule;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class DfkSql extends HTMLElementBase {
|
|
162
|
+
/** The CodeMirror host; it lives in the shadow tree, so `DfkSql.css` styles it. */
|
|
163
|
+
readonly #editorHost = el('div', {class: 'dfk-sql-editor'});
|
|
164
|
+
/**
|
|
165
|
+
* The editor ghost: one bar per line of SQL, shown until the CodeMirror
|
|
166
|
+
* modules have landed (they are a lazy `import()`, and on a slow link that
|
|
167
|
+
* window is long enough to see). The box keeps its border and background
|
|
168
|
+
* either way, so the editor fills it rather than replacing it.
|
|
169
|
+
*/
|
|
170
|
+
readonly #editorSkeleton = el('div', {class: 'dfk-sql-editor-skeleton'});
|
|
171
|
+
/** The bars currently in `#editorSkeleton`, kept in step with the SQL's lines. */
|
|
172
|
+
readonly #skeletonLines: HTMLElement[] = [];
|
|
173
|
+
/** Wraps the editor and anchors the floating action cluster. */
|
|
174
|
+
readonly #code = el('div', {class: 'dfk-sql-code'});
|
|
175
|
+
readonly #actions = el('div', {class: 'dfk-sql-actions'});
|
|
176
|
+
readonly #status = el('span', {class: 'dfk-sql-status', attrs: {'aria-live': 'polite'}});
|
|
177
|
+
readonly #runBtn: IconButton;
|
|
178
|
+
readonly #formatBtn: IconButton;
|
|
179
|
+
readonly #resetBtn: IconButton;
|
|
180
|
+
readonly #wrapBtn: IconButton;
|
|
181
|
+
readonly #copyBtn: IconButton;
|
|
182
|
+
readonly #fullscreenBtn: IconButton;
|
|
183
|
+
|
|
184
|
+
#labels: SqlLabels = LABELS.en;
|
|
185
|
+
#config: RunnableSqlConfig = {type: 'duckfn'};
|
|
186
|
+
#originalSql = '';
|
|
187
|
+
#currentSql = '';
|
|
188
|
+
/** Long SQL lines are the norm in a docs example, so wrapping starts on. */
|
|
189
|
+
#wrapped = true;
|
|
190
|
+
#running = false;
|
|
191
|
+
/** True while `sql-formatter` is loading or laying the SQL out. */
|
|
192
|
+
#formatting = false;
|
|
193
|
+
#seeded = false;
|
|
194
|
+
#mounting = false;
|
|
195
|
+
#expanded = false;
|
|
196
|
+
/** Whether the document-level Esc handler is currently attached. */
|
|
197
|
+
#escBound = false;
|
|
198
|
+
/** Pending "copied" reset timer; 0 when none is scheduled. */
|
|
199
|
+
#copyTimer = 0;
|
|
200
|
+
readonly #onEsc = (event: KeyboardEvent): void => {
|
|
201
|
+
if (event.key === 'Escape') {
|
|
202
|
+
this.#setExpanded(false);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
#resultHost: HTMLElement | null = null;
|
|
207
|
+
#editor: SqlEditor | null = null;
|
|
208
|
+
#disposeResult: (() => void) | null = null;
|
|
209
|
+
|
|
210
|
+
constructor() {
|
|
211
|
+
super();
|
|
212
|
+
this.#runBtn = new IconButton('lucide:play', () => void this.#onRun());
|
|
213
|
+
this.#formatBtn = new IconButton('lucide:wand-sparkles', () => void this.#onFormat());
|
|
214
|
+
this.#resetBtn = new IconButton('lucide:rotate-ccw', () => this.#onReset());
|
|
215
|
+
this.#wrapBtn = new IconButton('lucide:wrap-text', () => this.#onToggleWrap());
|
|
216
|
+
this.#copyBtn = new IconButton('lucide:copy', () => void this.#onCopy());
|
|
217
|
+
// Not in this subtree: a renderer parks it at the right end of the result's
|
|
218
|
+
// tab strip, so it sits exactly where the result's chrome is.
|
|
219
|
+
this.#fullscreenBtn = new IconButton('lucide:maximize', () =>
|
|
220
|
+
this.#setExpanded(!this.#expanded),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
this.#actions.append(
|
|
224
|
+
this.#status,
|
|
225
|
+
this.#runBtn.root,
|
|
226
|
+
this.#formatBtn.root,
|
|
227
|
+
this.#resetBtn.root,
|
|
228
|
+
this.#wrapBtn.root,
|
|
229
|
+
this.#copyBtn.root,
|
|
230
|
+
);
|
|
231
|
+
this.#setEditorPending(true);
|
|
232
|
+
this.#code.append(this.#editorHost, this.#actions);
|
|
233
|
+
|
|
234
|
+
const shadow = this.attachShadow({mode: 'open'});
|
|
235
|
+
shadow.adoptedStyleSheets = [sqlStyles()];
|
|
236
|
+
// No default slot: the original code node the remark plugin keeps as a
|
|
237
|
+
// child is the prerendered fallback only, and `sql.css` hides it — the
|
|
238
|
+
// editor is the one code view.
|
|
239
|
+
shadow.append(this.#code, el('slot', {attrs: {name: 'dfk-result'}}));
|
|
240
|
+
this.#applyLabels();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
connectedCallback(): void {
|
|
244
|
+
if (!this.#seeded) {
|
|
245
|
+
this.#seed();
|
|
246
|
+
this.#seeded = true;
|
|
247
|
+
}
|
|
248
|
+
this.#warmRuntime();
|
|
249
|
+
void this.#mountEditor();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
disconnectedCallback(): void {
|
|
253
|
+
this.#setExpanded(false);
|
|
254
|
+
this.#disposeResult?.();
|
|
255
|
+
this.#disposeResult = null;
|
|
256
|
+
this.#editor?.destroy();
|
|
257
|
+
this.#editor = null;
|
|
258
|
+
if (this.#copyTimer !== 0) {
|
|
259
|
+
window.clearTimeout(this.#copyTimer);
|
|
260
|
+
this.#copyTimer = 0;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Reads the `config` / `sql` attributes once. */
|
|
265
|
+
#seed(): void {
|
|
266
|
+
this.#labels =
|
|
267
|
+
LABELS[(document.documentElement.getAttribute('lang') ?? 'en').toLowerCase()] ??
|
|
268
|
+
LABELS.en;
|
|
269
|
+
const raw = this.getAttribute('config');
|
|
270
|
+
if (raw) {
|
|
271
|
+
try {
|
|
272
|
+
this.#config = JSON.parse(raw) as RunnableSqlConfig;
|
|
273
|
+
} catch {
|
|
274
|
+
// Malformed config: fall back to the default `table` view.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.#originalSql = this.getAttribute('sql') ?? '';
|
|
278
|
+
this.#currentSql = this.#originalSql;
|
|
279
|
+
this.#applyLabels();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#applyLabels(): void {
|
|
283
|
+
this.#runBtn.setLabel(this.#labels.run);
|
|
284
|
+
this.#formatBtn.setLabel(this.#labels.format);
|
|
285
|
+
this.#resetBtn.setLabel(this.#labels.reset);
|
|
286
|
+
this.#wrapBtn.setLabel(this.#wrapped ? this.#labels.wrapOff : this.#labels.wrapOn);
|
|
287
|
+
this.#wrapBtn.setOn(this.#wrapped);
|
|
288
|
+
this.#copyBtn.setLabel(this.#labels.copy);
|
|
289
|
+
this.#fullscreenBtn.setLabel(
|
|
290
|
+
this.#expanded ? this.#labels.exitFullscreen : this.#labels.fullscreen,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Editor ----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Shows or drops the editor ghost, sized to the SQL it stands in for. A ghost
|
|
298
|
+
* is one `line-height: 1.6` box per line, exactly like the editor, so the block
|
|
299
|
+
* is the same height before and after CodeMirror takes over.
|
|
300
|
+
*
|
|
301
|
+
* It goes away once the editor is in place, and also when the `import()` itself
|
|
302
|
+
* failed — the status line carries that message, so bars that keep pulsing would
|
|
303
|
+
* be a lie. A later remount (the element was disconnected and reconnected) puts
|
|
304
|
+
* it back.
|
|
305
|
+
*/
|
|
306
|
+
#setEditorPending(pending: boolean): void {
|
|
307
|
+
if (!pending) {
|
|
308
|
+
this.#editorSkeleton.remove();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.#sizeEditorSkeleton();
|
|
312
|
+
this.#editorHost.appendChild(this.#editorSkeleton);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Grows or shrinks the ghost to the SQL's line count. `sql/remark.ts`
|
|
317
|
+
* prerenders the very same bars for the pre-upgrade window; it is Node build
|
|
318
|
+
* code that must stay out of the browser bundle, so both sides write this
|
|
319
|
+
* count out instead of sharing it.
|
|
320
|
+
*/
|
|
321
|
+
#sizeEditorSkeleton(): void {
|
|
322
|
+
const lines = Math.max(1, this.#currentSql.split('\n').length);
|
|
323
|
+
while (this.#skeletonLines.length > lines) {
|
|
324
|
+
this.#skeletonLines.pop()?.remove();
|
|
325
|
+
}
|
|
326
|
+
while (this.#skeletonLines.length < lines) {
|
|
327
|
+
const bar = el('span', {class: 'dfk-sql-editor-skeleton-line'});
|
|
328
|
+
this.#skeletonLines.push(bar);
|
|
329
|
+
this.#editorSkeleton.appendChild(bar);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async #mountEditor(): Promise<void> {
|
|
334
|
+
if (this.#editor || this.#mounting) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.#mounting = true;
|
|
338
|
+
this.#setEditorPending(true);
|
|
339
|
+
try {
|
|
340
|
+
const editor = await mountSqlEditor(this.#editorHost, this.#currentSql, (value) => {
|
|
341
|
+
this.#currentSql = value;
|
|
342
|
+
});
|
|
343
|
+
if (!this.isConnected) {
|
|
344
|
+
// Disconnected while the CodeMirror modules were loading: nothing will
|
|
345
|
+
// ever dispose this editor, so dispose it here.
|
|
346
|
+
editor.destroy();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
editor.setWrap(this.#wrapped);
|
|
350
|
+
this.#editor = editor;
|
|
351
|
+
this.#setEditorPending(false);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
this.#setEditorPending(false);
|
|
354
|
+
this.#setStatus(messageOf(error));
|
|
355
|
+
} finally {
|
|
356
|
+
this.#mounting = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// --- Code-block actions ----------------------------------------------------
|
|
361
|
+
|
|
362
|
+
#onReset(): void {
|
|
363
|
+
this.#currentSql = this.#originalSql;
|
|
364
|
+
this.#editor?.setValue(this.#currentSql);
|
|
365
|
+
this.#clearResult();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Re-lays-out the SQL through `sql-formatter`. It only changes whitespace —
|
|
370
|
+
* the author's keyword casing is preserved — so the last result stays valid
|
|
371
|
+
* and is deliberately left on screen. Formatting goes through `setValue()`,
|
|
372
|
+
* which is a normal edit: CodeMirror's undo history covers it.
|
|
373
|
+
*/
|
|
374
|
+
async #onFormat(): Promise<void> {
|
|
375
|
+
if (this.#formatting) {
|
|
376
|
+
return; // The formatter module is still on its way in.
|
|
377
|
+
}
|
|
378
|
+
this.#formatting = true;
|
|
379
|
+
this.#formatBtn.setDisabled(true);
|
|
380
|
+
try {
|
|
381
|
+
const {format} = await loadFormatter();
|
|
382
|
+
const formatted = format(this.#currentSql, {language: 'duckdb'});
|
|
383
|
+
this.#currentSql = formatted;
|
|
384
|
+
// A block whose editor has not finished mounting keeps the formatted text
|
|
385
|
+
// in `#currentSql`; `#mountEditor` seeds the editor from it.
|
|
386
|
+
this.#editor?.setValue(formatted);
|
|
387
|
+
} catch {
|
|
388
|
+
this.#setStatus(this.#labels.formatFailed);
|
|
389
|
+
} finally {
|
|
390
|
+
this.#formatting = false;
|
|
391
|
+
this.#formatBtn.setDisabled(false);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#onToggleWrap(): void {
|
|
396
|
+
this.#wrapped = !this.#wrapped;
|
|
397
|
+
this.#editor?.setWrap(this.#wrapped);
|
|
398
|
+
this.#applyLabels();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async #onCopy(): Promise<void> {
|
|
402
|
+
try {
|
|
403
|
+
await navigator.clipboard.writeText(this.#currentSql);
|
|
404
|
+
} catch {
|
|
405
|
+
// Clipboard access can be denied (insecure context, permissions); the
|
|
406
|
+
// button simply keeps its idle look.
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.#copyBtn.setIcon('lucide:check');
|
|
410
|
+
this.#copyBtn.setLabel(this.#labels.copied);
|
|
411
|
+
if (this.#copyTimer !== 0) {
|
|
412
|
+
window.clearTimeout(this.#copyTimer);
|
|
413
|
+
}
|
|
414
|
+
this.#copyTimer = window.setTimeout(() => {
|
|
415
|
+
this.#copyTimer = 0;
|
|
416
|
+
this.#copyBtn.setIcon('lucide:copy');
|
|
417
|
+
this.#copyBtn.setLabel(this.#labels.copy);
|
|
418
|
+
}, COPIED_HOLD);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- Fullscreen ------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The whole fullscreen state: the result panel becomes a fixed overlay and
|
|
425
|
+
* its own tab strip carries the toggle back out (now "Exit fullscreen"). Esc
|
|
426
|
+
* exits too, from anywhere on the page.
|
|
427
|
+
*/
|
|
428
|
+
#setExpanded(value: boolean): void {
|
|
429
|
+
this.#expanded = value;
|
|
430
|
+
this.#resultHost?.classList.toggle('dfk-sql-result-expanded', value);
|
|
431
|
+
this.#fullscreenBtn.setIcon(value ? 'lucide:minimize' : 'lucide:maximize');
|
|
432
|
+
this.#applyLabels();
|
|
433
|
+
if (value !== this.#escBound) {
|
|
434
|
+
if (value) {
|
|
435
|
+
document.addEventListener('keydown', this.#onEsc);
|
|
436
|
+
} else {
|
|
437
|
+
document.removeEventListener('keydown', this.#onEsc);
|
|
438
|
+
}
|
|
439
|
+
this.#escBound = value;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- Run -------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Starts the shared runtime in the background as soon as a block exists on
|
|
447
|
+
* the page, so the first Run click does not pay for the DuckDB download and
|
|
448
|
+
* the preloaded extensions. Failures are swallowed on purpose: `init()`
|
|
449
|
+
* stays retryable, and the block that eventually runs a query surfaces the
|
|
450
|
+
* error in its result area.
|
|
451
|
+
*/
|
|
452
|
+
#warmRuntime(): void {
|
|
453
|
+
void DuckDBRuntime.getInstance()
|
|
454
|
+
.init({allowUnsignedExtensions: this.#config.allowUnsignedExtensions === true})
|
|
455
|
+
.catch(() => undefined);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async #onRun(): Promise<void> {
|
|
459
|
+
if (this.#running) {
|
|
460
|
+
return; // Guard against double-clicks running the same example twice.
|
|
461
|
+
}
|
|
462
|
+
this.#running = true;
|
|
463
|
+
this.#setBusy(true);
|
|
464
|
+
const runtime = DuckDBRuntime.getInstance();
|
|
465
|
+
try {
|
|
466
|
+
if (runtime.state !== 'ready') {
|
|
467
|
+
this.#setStatus(this.#labels.initializing);
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
// The instance-wide settings come from whichever block initialises the
|
|
471
|
+
// shared runtime first; see `RuntimeOptions`.
|
|
472
|
+
await runtime.init({
|
|
473
|
+
allowUnsignedExtensions: this.#config.allowUnsignedExtensions === true,
|
|
474
|
+
});
|
|
475
|
+
} catch {
|
|
476
|
+
this.#showInitError();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const extensions = this.#config.extensions ?? [];
|
|
481
|
+
if (extensions.length > 0) {
|
|
482
|
+
this.#setStatus(this.#labels.loadingExtensions);
|
|
483
|
+
try {
|
|
484
|
+
for (const name of extensions) {
|
|
485
|
+
await runtime.loadExtension(name, {repository: this.#config.repository});
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.#renderInto(errorText(this.#labels, messageOf(error)));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this.#setStatus(this.#labels.running);
|
|
494
|
+
const result = await runtime.execute(this.#currentSql);
|
|
495
|
+
this.#showResult(result);
|
|
496
|
+
} finally {
|
|
497
|
+
this.#running = false;
|
|
498
|
+
this.#setBusy(false);
|
|
499
|
+
this.#setStatus('');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#setBusy(busy: boolean): void {
|
|
504
|
+
this.#runBtn.setDisabled(busy);
|
|
505
|
+
this.#formatBtn.setDisabled(busy);
|
|
506
|
+
this.#resetBtn.setDisabled(busy);
|
|
507
|
+
// A run started from a click keeps the cluster on screen, so the progress
|
|
508
|
+
// status in it is actually readable.
|
|
509
|
+
this.#actions.classList.toggle('dfk-sql-actions-busy', busy);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#setStatus(text: string): void {
|
|
513
|
+
this.#status.textContent = text;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#showInitError(): void {
|
|
517
|
+
const message = DuckDBRuntime.getInstance().message;
|
|
518
|
+
this.#renderInto(this.#labels.initFailed + (message ? `: ${message}` : ''));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- Result rendering ------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
#ensureResultHost(): HTMLElement {
|
|
524
|
+
if (!this.#resultHost) {
|
|
525
|
+
this.#resultHost = el('div', {class: 'dfk-sql-result'});
|
|
526
|
+
this.#resultHost.slot = 'dfk-result';
|
|
527
|
+
this.appendChild(this.#resultHost);
|
|
528
|
+
}
|
|
529
|
+
this.#resultHost.hidden = false;
|
|
530
|
+
return this.#resultHost;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#showResult(result: QueryResult): void {
|
|
534
|
+
this.#disposeResult?.();
|
|
535
|
+
this.#disposeResult = null;
|
|
536
|
+
// An error view carries no chrome, so a fullscreen overlay built from a
|
|
537
|
+
// previous result would leave no visible way out (only Esc).
|
|
538
|
+
if (result.error) {
|
|
539
|
+
this.#setExpanded(false);
|
|
540
|
+
}
|
|
541
|
+
const host = this.#ensureResultHost();
|
|
542
|
+
const context: RenderContext = {
|
|
543
|
+
host,
|
|
544
|
+
config: this.#config,
|
|
545
|
+
labels: this.#labels,
|
|
546
|
+
fullscreenButton: this.#fullscreenBtn.root,
|
|
547
|
+
};
|
|
548
|
+
const renderer = rendererFor(this.#config, result);
|
|
549
|
+
void renderer(context, result).then((dispose) => {
|
|
550
|
+
this.#disposeResult = dispose ?? null;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#renderInto(text: string): void {
|
|
555
|
+
this.#setExpanded(false);
|
|
556
|
+
const host = this.#ensureResultHost();
|
|
557
|
+
const pre = host.ownerDocument.createElement('pre');
|
|
558
|
+
pre.className = 'dfk-sql-error';
|
|
559
|
+
pre.textContent = text;
|
|
560
|
+
host.replaceChildren(pre);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
#clearResult(): void {
|
|
564
|
+
this.#setExpanded(false);
|
|
565
|
+
this.#disposeResult?.();
|
|
566
|
+
this.#disposeResult = null;
|
|
567
|
+
if (this.#resultHost) {
|
|
568
|
+
this.#resultHost.replaceChildren();
|
|
569
|
+
this.#resultHost.hidden = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
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
|
+
function messageOf(error: unknown): string {
|
|
615
|
+
return error instanceof Error ? error.message : String(error);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function errorText(labels: SqlLabels, detail: string): string {
|
|
619
|
+
return `${labels.error}: ${detail}`;
|
|
620
|
+
}
|