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.
Files changed (65) hide show
  1. package/AGENTS.md +689 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/dom.d.ts +69 -0
  5. package/dist/home/DfkFeatures.d.ts +20 -0
  6. package/dist/home/DfkHero.d.ts +25 -0
  7. package/dist/home/DfkNextSteps.d.ts +16 -0
  8. package/dist/home/styles.d.ts +8 -0
  9. package/dist/index.d.ts +50 -0
  10. package/dist/index.js +2 -0
  11. package/dist/register-DKLiYs-F.js +2324 -0
  12. package/dist/register.d.ts +10 -0
  13. package/dist/remark.d.ts +21 -0
  14. package/dist/remark.js +15 -0
  15. package/dist/runtimeConfig-Bokbb8VH.js +106 -0
  16. package/dist/sql/DfkSql.d.ts +7 -0
  17. package/dist/sql/PreviewTabs.d.ts +37 -0
  18. package/dist/sql/client.d.ts +1 -0
  19. package/dist/sql/client.js +4 -0
  20. package/dist/sql/editor.d.ts +16 -0
  21. package/dist/sql/extensions.d.ts +108 -0
  22. package/dist/sql/extensions.js +198 -0
  23. package/dist/sql/remark.d.ts +88 -0
  24. package/dist/sql/remark.js +69 -0
  25. package/dist/sql/renderers.d.ts +44 -0
  26. package/dist/sql/runtime.d.ts +105 -0
  27. package/dist/sql/runtimeConfig.d.ts +80 -0
  28. package/dist/sql/styles.d.ts +6 -0
  29. package/dist/toc-toggle/TocToggle.d.ts +46 -0
  30. package/dist/toc-toggle/TocToggle.js +69 -0
  31. package/dist/toc-toggle/client.d.ts +1 -0
  32. package/dist/toc-toggle/client.js +9 -0
  33. package/dist/toc-toggle/plugin.d.ts +36 -0
  34. package/dist/toc-toggle/plugin.js +13 -0
  35. package/dist/types.d.ts +42 -0
  36. package/package.json +73 -0
  37. package/src/dom.ts +109 -0
  38. package/src/home/DfkFeatures.ts +78 -0
  39. package/src/home/DfkHero.ts +128 -0
  40. package/src/home/DfkNextSteps.ts +73 -0
  41. package/src/home/home.css +520 -0
  42. package/src/home/styles.ts +28 -0
  43. package/src/index.ts +59 -0
  44. package/src/kit.css +19 -0
  45. package/src/register.ts +39 -0
  46. package/src/remark.ts +60 -0
  47. package/src/sql/DfkSql.css +226 -0
  48. package/src/sql/DfkSql.ts +620 -0
  49. package/src/sql/PreviewTabs.ts +169 -0
  50. package/src/sql/client.ts +16 -0
  51. package/src/sql/editor.ts +75 -0
  52. package/src/sql/extensions.ts +470 -0
  53. package/src/sql/remark.ts +213 -0
  54. package/src/sql/renderers.ts +916 -0
  55. package/src/sql/runtime.ts +348 -0
  56. package/src/sql/runtimeConfig.ts +249 -0
  57. package/src/sql/sql.css +397 -0
  58. package/src/sql/styles.ts +24 -0
  59. package/src/theme/tokens.css +75 -0
  60. package/src/toc-toggle/TocToggle.css +69 -0
  61. package/src/toc-toggle/TocToggle.ts +172 -0
  62. package/src/toc-toggle/client.ts +20 -0
  63. package/src/toc-toggle/plugin.ts +54 -0
  64. package/src/types.ts +47 -0
  65. package/src/vite-env.d.ts +8 -0
@@ -0,0 +1,916 @@
1
+ import type {ListTable, ListTableConstructorOptions} from '@visactor/vtable';
2
+ import type {QueryResult} from './runtime';
3
+ import type {RunnableSqlConfig} from './remark';
4
+ import {PreviewTabs, type PreviewTabItem, type PreviewTableHandle} from './PreviewTabs';
5
+ import {el} from '../dom';
6
+
7
+ /**
8
+ * Result renderers, keyed by the config's `show` field.
9
+ *
10
+ * The registry is the seam later phases plug into. It ships `table` (VisActor
11
+ * VTable), a `text` fallback, the markup previews `iframe` / `html` / `svg`,
12
+ * plus the `error` view every renderer shares.
13
+ *
14
+ * Heavy dependencies (`@visactor/vtable`) load through dynamic `import()`
15
+ * inside the renderer, so a page that never runs a query never pays for them,
16
+ * and Docusaurus' Node prerender never touches them.
17
+ */
18
+
19
+ export interface RenderContext {
20
+ /** Container element in the light DOM, sized by `sql.css`. */
21
+ host: HTMLElement;
22
+ config: RunnableSqlConfig;
23
+ /** Localised strings resolved by the component (labels-by-html-lang). */
24
+ labels: Record<string, string>;
25
+ /**
26
+ * The component's fullscreen toggle, parked at the right end of the tab
27
+ * strip. `<dfk-sql>` owns the button's state and therefore the node; the
28
+ * renderer only borrows it so every result has the same chrome.
29
+ */
30
+ fullscreenButton: HTMLElement;
31
+ }
32
+
33
+ /**
34
+ * Renders `result` into `context.host`. Resolves with a disposer releasing any
35
+ * resources (table instances) the renderer created; it runs on the next render
36
+ * and when the element disconnects.
37
+ *
38
+ * Every renderer is async — the heavy ones await their dynamic `import()`, and
39
+ * the trivial ones just resolve immediately — so the caller has exactly one
40
+ * shape to handle.
41
+ */
42
+ export type Renderer = (
43
+ context: RenderContext,
44
+ result: QueryResult,
45
+ ) => Promise<void | (() => void)>;
46
+
47
+ /**
48
+ * The default `sandbox` for the `iframe` renderer: scripts run (HTML reports
49
+ * draw their charts with them), but `allow-same-origin` is deliberately absent,
50
+ * so the frame keeps an opaque origin and cannot reach this page.
51
+ */
52
+ const DEFAULT_SANDBOX = 'allow-scripts';
53
+
54
+ /**
55
+ * VTable's stock row height leaves a lot of air around a short cell; a docs
56
+ * example is an aside, so the rows are tightened up. The header is a touch
57
+ * taller than the body on purpose.
58
+ */
59
+ const ROW_HEIGHT = 30;
60
+ const HEADER_HEIGHT = 32;
61
+
62
+ /**
63
+ * Stable `menuKey`s for the context-menu items. VTable fires the click through
64
+ * the `dropdown_menu_click` event (see {@link ResultTable.#onMenu}) with
65
+ * `menuKey = menuItem.menuKey || menuItem.text`, so giving every item an
66
+ * explicit key keeps the dispatch independent of the (localised) label text.
67
+ */
68
+ const MENU = {
69
+ copyCell: 'dfk-copy-cell',
70
+ copyAll: 'dfk-copy-all',
71
+ wrap: 'dfk-wrap',
72
+ unwrap: 'dfk-unwrap',
73
+ freeze: 'dfk-freeze',
74
+ unfreeze: 'dfk-unfreeze',
75
+ reset: 'dfk-reset',
76
+ widthAdaptive: 'dfk-width-adaptive',
77
+ widthStandard: 'dfk-width-standard',
78
+ widthFill: 'dfk-width-fill',
79
+ } as const;
80
+
81
+ /**
82
+ * The column-width view modes the context menu switches between. Each is a
83
+ * plain pair of official VTable options:
84
+ *
85
+ * - `adaptive` (the default) hands the container width to the columns: every
86
+ * column keeps its measured content as its share, so the table always fills
87
+ * the box.
88
+ * - `standard` keeps each column at its measured content width and scrolls
89
+ * sideways when the total overflows.
90
+ * - `standard` + `autoFillWidth` keeps content widths but stretches them to
91
+ * fill when the content happens to be narrower than the box.
92
+ *
93
+ * `label`/`fallback` are the labels key and its English default, so a consumer
94
+ * that only provides a few strings still gets text for every item.
95
+ */
96
+ const WIDTH_MODES = [
97
+ {
98
+ menuKey: MENU.widthAdaptive,
99
+ label: 'widthAdaptive',
100
+ widthMode: 'adaptive',
101
+ autoFillWidth: false,
102
+ fallback: 'Fill the width',
103
+ },
104
+ {
105
+ menuKey: MENU.widthStandard,
106
+ label: 'widthStandard',
107
+ widthMode: 'standard',
108
+ autoFillWidth: false,
109
+ fallback: 'Content widths, scroll sideways',
110
+ },
111
+ {
112
+ menuKey: MENU.widthFill,
113
+ label: 'widthFill',
114
+ widthMode: 'standard',
115
+ autoFillWidth: true,
116
+ fallback: 'Content first, fill when it fits',
117
+ },
118
+ ] as const;
119
+
120
+ /** The width-mode table row for a `MENU.*` key (the default when unknown). */
121
+ function widthModeOption(menuKey: string): (typeof WIDTH_MODES)[number] {
122
+ return WIDTH_MODES.find((mode) => mode.menuKey === menuKey) ?? WIDTH_MODES[0];
123
+ }
124
+
125
+ /** Theme shape VTable accepts in the constructor / `updateTheme`. */
126
+ type TableTheme = NonNullable<ListTableConstructorOptions['theme']>;
127
+ /** The VTable module namespace from the dynamic `import()` (type-only here). */
128
+ type VTableModule = typeof import('@visactor/vtable');
129
+ /** The two official themes the table switches between. */
130
+ type VTableThemeName = 'DEFAULT' | 'DARK';
131
+ type TableColumns = NonNullable<ListTableConstructorOptions['columns']>;
132
+ /**
133
+ * The context-menu item shape. Mirrors VTable's `MenuListItem`, which is not
134
+ * re-exported from the package root, so it is spelled out locally.
135
+ */
136
+ type TableMenuItem =
137
+ | string
138
+ | {
139
+ text?: string;
140
+ type?: 'title' | 'item' | 'split';
141
+ menuKey?: string;
142
+ children?: TableMenuItem[];
143
+ };
144
+
145
+ /**
146
+ * A locale-aware, numeric-aware comparator shared by every sortable column.
147
+ * `Intl.Collator` is built lazily (it is comparatively cheap but not free, and
148
+ * most tables never sort).
149
+ */
150
+ let collator: Intl.Collator | undefined;
151
+ function compareText(a: string, b: string): number {
152
+ collator ??= new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
153
+ return collator.compare(a, b);
154
+ }
155
+
156
+ /**
157
+ * Coerces a value to a number when it is genuinely numeric (so `9` sorts before
158
+ * `10`), otherwise `null` so the caller falls back to a text comparison.
159
+ * DuckDB-Wasm hands back native `number`/`bigint`/`Date`/`boolean` values.
160
+ */
161
+ function numericValue(value: unknown): number | null {
162
+ if (typeof value === 'number') {
163
+ return Number.isNaN(value) ? null : value;
164
+ }
165
+ if (typeof value === 'bigint') {
166
+ return Number(value);
167
+ }
168
+ if (value instanceof Date) {
169
+ const time = value.getTime();
170
+ return Number.isNaN(time) ? null : time;
171
+ }
172
+ if (typeof value === 'boolean') {
173
+ return value ? 1 : 0;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * VTable's per-column `sort` callback. When a custom comparator is supplied,
180
+ * VTable calls it with the current `order` and uses the result verbatim (it
181
+ * does NOT flip for `desc` the way its built-in comparator does), so the
182
+ * direction has to be applied here. NULL is pinned last in both directions (a
183
+ * custom comparator must handle empty values itself), numbers compare
184
+ * numerically and everything else textually.
185
+ */
186
+ function compareValues(a: unknown, b: unknown, order: string): -1 | 0 | 1 {
187
+ const aEmpty = a === null || a === undefined;
188
+ const bEmpty = b === null || b === undefined;
189
+ if (aEmpty || bEmpty) {
190
+ if (aEmpty && bEmpty) {
191
+ return 0;
192
+ }
193
+ // NULL last, regardless of direction.
194
+ return aEmpty ? 1 : -1;
195
+ }
196
+ const numeric = numericValue(a);
197
+ const otherNumeric = numericValue(b);
198
+ let raw: number;
199
+ if (numeric !== null && otherNumeric !== null) {
200
+ raw = numeric === otherNumeric ? 0 : numeric < otherNumeric ? -1 : 1;
201
+ } else {
202
+ raw = compareText(stringify(a), stringify(b));
203
+ }
204
+ const sign: -1 | 0 | 1 = raw === 0 ? 0 : raw < 0 ? -1 : 1;
205
+ if (sign === 0) {
206
+ return 0;
207
+ }
208
+ return (String(order).toLowerCase() === 'desc' ? -sign : sign) as -1 | 0 | 1;
209
+ }
210
+
211
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
212
+
213
+ function messageOf(error: unknown): string {
214
+ return error instanceof Error ? error.message : String(error);
215
+ }
216
+
217
+ /** The shared error view: a styled block, never a thrown exception. */
218
+ function errorBlock(document: Document, text: string): HTMLPreElement {
219
+ const pre = document.createElement('pre');
220
+ pre.className = 'dfk-sql-error';
221
+ pre.textContent = text;
222
+ return pre;
223
+ }
224
+
225
+ function errorText(labels: Record<string, string>, detail: string): string {
226
+ return `${labels.error ?? 'Error'}: ${detail}`;
227
+ }
228
+
229
+ /**
230
+ * A VTable result grid with the interaction layer a docs example wants:
231
+ * sortable columns, clipboard copy, resizable rows/columns, draggable headers,
232
+ * cross-highlight on hover, per-column text wrapping and column freezing — all
233
+ * driven through VTable's own options and events so the canvas stays the single
234
+ * source of truth (nothing is re-laid-out in DOM).
235
+ *
236
+ * All styling comes from VTable's own themes (`themes.DEFAULT` / `themes.DARK`,
237
+ * picked below by the document's colour scheme). The table deliberately does
238
+ * not hand-pick colours property by property: vendor themes are complete, and
239
+ * repainting them by hand is how a theme change or a select/hover state ends up
240
+ * losing its text.
241
+ *
242
+ * The instance owns its box (`#record`); callers only reach it through
243
+ * {@link dispose}.
244
+ */
245
+ class ResultTable {
246
+ readonly #table: ListTable;
247
+ readonly #record: HTMLElement;
248
+ readonly #result: QueryResult;
249
+ readonly #labels: Record<string, string>;
250
+ /** VTable's theme namespace, taken from the dynamic `import()`. */
251
+ readonly #themes: VTableModule['themes'];
252
+ /** Fields whose column currently wraps (row height switches to `auto`). */
253
+ readonly #wrapped = new Set<string>();
254
+ #frozen = 0;
255
+ #frame = 0;
256
+ /** The official theme currently applied, so a repaint happens only on change. */
257
+ #appliedTheme?: VTableThemeName;
258
+ /** The active width mode, one of `WIDTH_MODES`' keys (default: adaptive). */
259
+ #widthMode: string = MENU.widthAdaptive;
260
+ #observer?: ResizeObserver;
261
+ #themeObserver?: MutationObserver;
262
+
263
+ constructor(
264
+ vtable: VTableModule,
265
+ record: HTMLElement,
266
+ result: QueryResult,
267
+ labels: Record<string, string>,
268
+ ) {
269
+ this.#record = record;
270
+ this.#result = result;
271
+ this.#labels = labels;
272
+ this.#themes = vtable.themes;
273
+ this.#table = new vtable.ListTable(this.#options());
274
+ this.#appliedTheme = this.#themeName();
275
+
276
+ // `resize()` re-measures and repaints inside `record`, so running it straight
277
+ // from the observer callback feeds the resulting box change back into the very
278
+ // delivery pass that is still going — the browser reports that as
279
+ // "ResizeObserver loop completed with undelivered notifications" (and
280
+ // webpack-dev-server turns it into a full-screen error overlay). Deferring to
281
+ // the next frame keeps the notification and the re-measure in separate passes.
282
+ this.#observer = new ResizeObserver(() => {
283
+ cancelAnimationFrame(this.#frame);
284
+ this.#frame = requestAnimationFrame(() => {
285
+ this.#frame = 0;
286
+ this.#table.resize();
287
+ });
288
+ });
289
+ this.#observer.observe(record);
290
+
291
+ // The canvas paints with concrete colours, so a light/dark flip has to be
292
+ // observed and the theme re-applied — a `data-theme` attribute change never
293
+ // reaches the canvas by itself.
294
+ this.#themeObserver = new MutationObserver(() => {
295
+ this.#applyTheme();
296
+ });
297
+ this.#themeObserver.observe(record.ownerDocument.documentElement, {
298
+ attributeFilter: ['data-theme', 'class'],
299
+ });
300
+
301
+ this.#table.on('dropdown_menu_click', (args) => this.#onMenu(args));
302
+ }
303
+
304
+ #columns(order: readonly string[]): TableColumns {
305
+ return order.map((field) => ({
306
+ field,
307
+ title: field,
308
+ sort: compareValues,
309
+ style: {autoWrapText: this.#wrapped.has(field)},
310
+ }));
311
+ }
312
+
313
+ /**
314
+ * The fields in their current display order. A header drag reorders the
315
+ * layout (and `options.columns` with it), so the order is read back from the
316
+ * table rather than assumed to still match the query result.
317
+ */
318
+ #displayOrder(): string[] {
319
+ const order: string[] = [];
320
+ for (let col = 0; col < this.#table.colCount; col += 1) {
321
+ const field: unknown = this.#table.getHeaderField(col, 0);
322
+ if (typeof field !== 'string' && typeof field !== 'number') {
323
+ // Unexpected shape (or a layout mid-rebuild): keep the query order.
324
+ return [...this.#result.columns];
325
+ }
326
+ order.push(String(field));
327
+ }
328
+ return order.length === this.#result.columns.length ? order : [...this.#result.columns];
329
+ }
330
+
331
+ /**
332
+ * The official theme for the current colour mode: `themes.DEFAULT` in light,
333
+ * `themes.DARK` in dark. Both are complete palettes (text, zebra rows, hover
334
+ * tints, a *translucent* selection fill, the frozen-column shadow, sort
335
+ * icons), so nothing has to be overridden by hand.
336
+ *
337
+ * `themes.of()` passes a `TableTheme` instance through unchanged, and
338
+ * `TableTheme.extends()` is the official way to layer a small delta on top,
339
+ * should one ever be needed.
340
+ */
341
+ #theme(): TableTheme {
342
+ return this.#themes[this.#themeName()];
343
+ }
344
+
345
+ /** Which official theme the document's `data-theme` asks for. */
346
+ #themeName(): VTableThemeName {
347
+ const mode = this.#record.ownerDocument.documentElement.getAttribute('data-theme');
348
+ return mode === 'dark' ? 'DARK' : 'DEFAULT';
349
+ }
350
+
351
+ /**
352
+ * Re-applies the theme for the current colour mode. `updateTheme` repaints
353
+ * the whole table (and rebuilds its components), so it is skipped unless the
354
+ * mode actually changed.
355
+ */
356
+ #applyTheme(): void {
357
+ const name = this.#themeName();
358
+ if (name !== this.#appliedTheme) {
359
+ this.#appliedTheme = name;
360
+ this.#table.updateTheme(this.#theme());
361
+ }
362
+ }
363
+
364
+ /** The official theme's own body text colour (the tip follows the theme). */
365
+ #themeText(): string {
366
+ const theme = this.#theme();
367
+ const value = theme.bodyStyle?.color ?? theme.defaultStyle?.color;
368
+ return typeof value === 'string' ? value : '#000';
369
+ }
370
+
371
+ /** The official theme's own body font size. */
372
+ #themeFontSize(): number {
373
+ const theme = this.#theme();
374
+ const value = theme.bodyStyle?.fontSize ?? theme.defaultStyle?.fontSize;
375
+ return typeof value === 'number' ? value : 12;
376
+ }
377
+
378
+ #options(): ListTableConstructorOptions {
379
+ const width = widthModeOption(this.#widthMode);
380
+ return {
381
+ container: this.#record,
382
+ records: this.#result.rows,
383
+ columns: this.#columns(this.#result.columns),
384
+ theme: this.#theme(),
385
+ // How the container width is shared out; see `WIDTH_MODES`. `adaptive`
386
+ // (the default) hands it to the columns — each keeps its measured content
387
+ // (the header measurement already includes the sort icon) as its share, so
388
+ // the initial view fills the box instead of starting from a default too
389
+ // narrow for its titles, and a very long unaliased header is capped by
390
+ // `limitMaxAutoWidth` (450) before the share is computed. It re-fills on
391
+ // container resizes (the fullscreen toggle included), and a column the
392
+ // user resized by hand is excluded while the rest re-fill around it.
393
+ widthMode: width.widthMode,
394
+ autoFillWidth: width.autoFillWidth,
395
+ // `auto` row height is what actually lets a wrapped column grow its rows;
396
+ // a fixed height would clip the extra lines even with `autoWrapText`.
397
+ defaultRowHeight: this.#wrapped.size > 0 ? 'auto' : ROW_HEIGHT,
398
+ defaultHeaderRowHeight: HEADER_HEIGHT,
399
+ // Resizing is on by default, but stating it keeps the intent readable and
400
+ // guards a future change of default.
401
+ columnResizeMode: 'all',
402
+ rowResizeMode: 'all',
403
+ // Header drag-to-reorder is off by default; enable columns only (there is
404
+ // no row header to drag). VTable requires the header cell to be selected
405
+ // before it can be dragged, and its `fixedFrozenCount` default keeps the
406
+ // frozen *count* stable while the frozen membership follows the new order.
407
+ dragHeaderMode: 'column',
408
+ // Cross highlight is what the "hover lights up the whole row + column"
409
+ // behaviour maps to; the default is per-cell only. The tints themselves
410
+ // come from the official theme's `hover` styles.
411
+ hover: {highlightMode: 'cross'},
412
+ // There is no default keyboard config, so the copy/select flags have to be
413
+ // listed or Ctrl+C / Ctrl+A stay inert.
414
+ keyboardOptions: {
415
+ copySelected: true,
416
+ selectAllOnCtrlA: true,
417
+ },
418
+ // Overflow tooltip: shows the full text of a clipped cell on hover.
419
+ tooltip: {isShowOverflowTextTooltip: true},
420
+ menu: {
421
+ renderMode: 'html',
422
+ contextMenuItems: (field) => this.#menuItems(String(field)),
423
+ },
424
+ frozenColCount: this.#frozen,
425
+ emptyTip: {
426
+ text: this.#labels.noData ?? 'No rows',
427
+ // The stock empty tip paints `#000` whatever the theme; take both
428
+ // values from the official theme instead of inventing colours.
429
+ textStyle: {fontSize: this.#themeFontSize(), color: this.#themeText()},
430
+ },
431
+ };
432
+ }
433
+
434
+ #menuItems(field: string): TableMenuItem[] {
435
+ const labels = this.#labels;
436
+ const wrapped = this.#wrapped.has(field);
437
+ const freezeOrUnfreeze: TableMenuItem =
438
+ this.#frozen > 0
439
+ ? {text: labels.unfreezeColumns ?? 'Unfreeze columns', menuKey: MENU.unfreeze}
440
+ : {text: labels.freezeColumn ?? 'Freeze up to here', menuKey: MENU.freeze};
441
+ return [
442
+ {text: field, type: 'title'},
443
+ {type: 'split'},
444
+ {text: labels.copy ?? 'Copy cell', menuKey: MENU.copyCell},
445
+ {text: labels.copyAll ?? 'Copy table', menuKey: MENU.copyAll},
446
+ {
447
+ text: wrapped
448
+ ? (labels.unwrapColumn ?? 'Stop wrapping column')
449
+ : (labels.wrapColumn ?? 'Wrap column'),
450
+ menuKey: wrapped ? MENU.unwrap : MENU.wrap,
451
+ },
452
+ freezeOrUnfreeze,
453
+ {type: 'split'},
454
+ // The width modes sit right above "reset": both are view switches.
455
+ {text: labels.widthMode ?? 'Column width', children: this.#widthModeItems()},
456
+ {text: labels.resetView ?? 'Reset view', menuKey: MENU.reset},
457
+ ];
458
+ }
459
+
460
+ /**
461
+ * The width-mode submenu. The vendor html menu has no check state of its own
462
+ * (its `--select` highlight is driven by `menu.dropDownMenuHighlight`, which
463
+ * only resolves against the cell being clicked), so the active mode carries a
464
+ * leading tick — item text goes through `innerHTML`, but a plain character is
465
+ * safe.
466
+ */
467
+ #widthModeItems(): TableMenuItem[] {
468
+ return WIDTH_MODES.map((mode) => ({
469
+ text: `${mode.menuKey === this.#widthMode ? '✓ ' : ''}${this.#labels[mode.label] ?? mode.fallback}`,
470
+ menuKey: mode.menuKey,
471
+ }));
472
+ }
473
+
474
+ /**
475
+ * Dispatch a context-menu click.
476
+ *
477
+ * VTable fires this through `dropdown_menu_click`, NOT `context_menu_click`:
478
+ * in 1.26.8 the html menu's own click handler emits `dropdown_menu_click`
479
+ * (with `menuKey = menuItem.menuKey || menuItem.text`), and the
480
+ * `context_menu_click` event type is defined but never fired — listening to
481
+ * it silently does nothing.
482
+ */
483
+ #onMenu(args: {col?: number; row?: number; menuKey?: string}): void {
484
+ const col = args.col ?? -1;
485
+ const row = args.row ?? -1;
486
+ const menuKey = args.menuKey;
487
+ if (!menuKey) {
488
+ return;
489
+ }
490
+ // The owning column's field; resolves for body cells and header cells alike.
491
+ const info = col >= 0 && row >= 0 ? this.#table.getCellInfo(col, row) : undefined;
492
+ const field = info?.field === undefined ? '' : String(info.field);
493
+ switch (menuKey) {
494
+ case MENU.copyCell:
495
+ void this.#copy(this.#cellText(col, row));
496
+ break;
497
+ case MENU.copyAll:
498
+ void this.#copy(this.#allText());
499
+ break;
500
+ case MENU.wrap:
501
+ if (field) {
502
+ this.#toggleWrap(field, true);
503
+ }
504
+ break;
505
+ case MENU.unwrap:
506
+ if (field) {
507
+ this.#toggleWrap(field, false);
508
+ }
509
+ break;
510
+ case MENU.freeze:
511
+ this.#freeze(col);
512
+ break;
513
+ case MENU.unfreeze:
514
+ this.#freeze(-1);
515
+ break;
516
+ case MENU.widthAdaptive:
517
+ case MENU.widthStandard:
518
+ case MENU.widthFill:
519
+ this.#setWidthMode(widthModeOption(menuKey));
520
+ break;
521
+ case MENU.reset:
522
+ this.#reset();
523
+ break;
524
+ }
525
+ }
526
+
527
+ #cellText(col: number, row: number): string {
528
+ if (col < 0 || row < 0) {
529
+ return '';
530
+ }
531
+ return stringify(this.#table.getCellRawValue(col, row));
532
+ }
533
+
534
+ #toggleWrap(field: string, on: boolean): void {
535
+ if (on) {
536
+ this.#wrapped.add(field);
537
+ } else {
538
+ this.#wrapped.delete(field);
539
+ }
540
+ this.#table.defaultRowHeight = this.#wrapped.size > 0 ? 'auto' : ROW_HEIGHT;
541
+ // Clear the row-height cache so rows regrow, but keep the column-width
542
+ // cache — widths the user dragged (and any row heights they resized) stay.
543
+ // Rebuild from the *display* order: `updateColumns` applies the array it is
544
+ // given verbatim, so the query order would undo any header drag.
545
+ this.#table.updateColumns(this.#columns(this.#displayOrder()), {
546
+ clearColWidthCache: false,
547
+ clearRowHeightCache: true,
548
+ });
549
+ }
550
+
551
+ #freeze(col: number): void {
552
+ // `setFrozenColCount` clamps to the table width and collapses to 0 once it
553
+ // would freeze every column, so read the effective count back instead of
554
+ // assuming `col + 1` stuck.
555
+ this.#table.setFrozenColCount(col < 0 ? 0 : col + 1);
556
+ this.#frozen = this.#table.frozenColCount;
557
+ }
558
+
559
+ /**
560
+ * Switches how the container width is shared out (see `WIDTH_MODES`).
561
+ *
562
+ * The two option setters only store the values, so the re-layout is driven
563
+ * through `updateColumns`: rebuilding the scene graph re-measures the columns
564
+ * under the new mode, and unlike `updateOption` it leaves the sort state
565
+ * alone. The columns go in as the *display* order (a header drag survives),
566
+ * and clearing only the column-width cache drops the manual widths — a mode
567
+ * change starts from a clean slate — while the user's row heights survive.
568
+ */
569
+ #setWidthMode(mode: (typeof WIDTH_MODES)[number]): void {
570
+ if (mode.menuKey === this.#widthMode) {
571
+ return;
572
+ }
573
+ this.#widthMode = mode.menuKey;
574
+ this.#table.widthMode = mode.widthMode;
575
+ this.#table.autoFillWidth = mode.autoFillWidth;
576
+ this.#table.updateColumns(this.#columns(this.#displayOrder()), {
577
+ clearColWidthCache: true,
578
+ clearRowHeightCache: false,
579
+ });
580
+ }
581
+
582
+ #reset(): void {
583
+ this.#wrapped.clear();
584
+ this.#frozen = 0;
585
+ // The width mode is a view switch too, so "reset" returns it to the default.
586
+ this.#widthMode = MENU.widthAdaptive;
587
+ // `updateOption` (unlike `updateColumns`) also resets the sort state, and
588
+ // with both caches cleared it drops the dragged widths/heights too — a true
589
+ // "back to the initial view". `#options()` carries the query's column order,
590
+ // so a header drag is undone as well.
591
+ void this.#table.updateOption(this.#options(), {
592
+ clearColWidthCache: true,
593
+ clearRowHeightCache: true,
594
+ });
595
+ }
596
+
597
+ /** The whole visible grid as tab-separated text, in current display order. */
598
+ #allText(): string {
599
+ const table = this.#table;
600
+ const lines = [this.#result.columns.join('\t')];
601
+ for (let row = table.columnHeaderLevelCount; row < table.rowCount; row += 1) {
602
+ const cells: string[] = [];
603
+ for (let col = 0; col < table.colCount; col += 1) {
604
+ cells.push(stringify(table.getCellRawValue(col, row)));
605
+ }
606
+ lines.push(cells.join('\t'));
607
+ }
608
+ return lines.join('\n');
609
+ }
610
+
611
+ async #copy(text: string): Promise<void> {
612
+ try {
613
+ await navigator.clipboard.writeText(text);
614
+ } catch {
615
+ // Clipboard access can be denied (permissions, insecure context); a
616
+ // silent no-op beats throwing from a click handler.
617
+ }
618
+ }
619
+
620
+ dispose(): void {
621
+ // The box may have shrunk a frame ago; never resize a released table.
622
+ cancelAnimationFrame(this.#frame);
623
+ this.#observer?.disconnect();
624
+ this.#themeObserver?.disconnect();
625
+ this.#table.release();
626
+ }
627
+ }
628
+
629
+ /** How many skeleton rows and columns the table placeholder draws. */
630
+ const SKELETON_ROWS = 2;
631
+ const SKELETON_COLUMNS = 4;
632
+
633
+ /**
634
+ * The two-row ghost of a table, shown inside `.dfk-sql-table` while VTable's
635
+ * bundle is in flight — it is the largest of the lazy `import()`s in a runnable
636
+ * block, and the box would otherwise be a blank rectangle. Two rows is the
637
+ * smallest shape that still reads as "a table is coming"; the columns line up
638
+ * between them because every row is built the same way.
639
+ *
640
+ * The row rhythm comes from `ROW_HEIGHT`, handed over as a custom property so the
641
+ * one number stays in this file. `sql.css` clips the ghost to the box: a result
642
+ * with a single row is shorter than two skeleton rows.
643
+ */
644
+ function tableSkeleton(): HTMLElement {
645
+ const box = el('div', {class: 'dfk-sql-table-skeleton'});
646
+ box.style.setProperty('--dfk-sql-skeleton-row-height', `${ROW_HEIGHT}px`);
647
+ for (let row = 0; row < SKELETON_ROWS; row += 1) {
648
+ box.appendChild(
649
+ el('div', {class: 'dfk-sql-table-skeleton-row'}, (line) => {
650
+ for (let column = 0; column < SKELETON_COLUMNS; column += 1) {
651
+ line.appendChild(el('span', {class: 'dfk-sql-table-skeleton-cell'}));
652
+ }
653
+ }),
654
+ );
655
+ }
656
+ return box;
657
+ }
658
+
659
+ /**
660
+ * Mounts a VTable list into `parent`, creating the `.dfk-sql-table` box itself.
661
+ *
662
+ * VTable is canvas-rendered and measures its container at construction time, so
663
+ * the box gets an explicit height through a custom property (which the
664
+ * fullscreen rule overrides by specificity rather than `!important`); the
665
+ * `ResizeObserver` inside {@link ResultTable} re-measures whenever that box
666
+ * changes shape — which is also how the table follows the fullscreen toggle
667
+ * without any resize plumbing through the component.
668
+ *
669
+ * A failed `import()` (offline, CDN blocked) degrades to the error view instead
670
+ * of rejecting the render.
671
+ */
672
+ async function mountTable(
673
+ parent: HTMLElement,
674
+ result: QueryResult,
675
+ labels: Record<string, string>,
676
+ ): Promise<PreviewTableHandle> {
677
+ const document = parent.ownerDocument;
678
+ const record = el('div', {class: 'dfk-sql-table'});
679
+ record.style.setProperty('--dfk-sql-table-height', `${Math.min(360, 36 + result.rows.length * ROW_HEIGHT)}px`);
680
+ const skeleton = tableSkeleton();
681
+ record.appendChild(skeleton);
682
+ parent.appendChild(record);
683
+
684
+ try {
685
+ const vtable = await import('@visactor/vtable');
686
+ const table = new ResultTable(vtable, record, result, labels);
687
+ // VTable draws into the same box; the ghost is one sibling too many. It is
688
+ // dropped after construction (not by VTable) so a failed import can still
689
+ // hand the box over to the error view.
690
+ skeleton.remove();
691
+ return {dispose: () => table.dispose()};
692
+ } catch (error) {
693
+ skeleton.remove();
694
+ record.appendChild(errorBlock(document, errorText(labels, messageOf(error))));
695
+ return {dispose: () => {}};
696
+ }
697
+ }
698
+
699
+ const tableRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
700
+ const tabs = new PreviewTabs(
701
+ host,
702
+ [],
703
+ labels.table ?? 'Table',
704
+ (panel) => mountTable(panel, result, labels),
705
+ fullscreenButton,
706
+ );
707
+ return () => tabs.dispose();
708
+ };
709
+
710
+ /** Plain text: one line per row, columns tab-joined. */
711
+ function textBlock(document: Document, result: QueryResult): HTMLPreElement {
712
+ const pre = document.createElement('pre');
713
+ pre.className = 'dfk-sql-text';
714
+ const lines = [result.columns.join('\t')];
715
+ for (const row of result.rows) {
716
+ lines.push(result.columns.map((c) => stringify(row[c])).join('\t'));
717
+ }
718
+ pre.textContent = lines.join('\n');
719
+ return pre;
720
+ }
721
+
722
+ /**
723
+ * Text results get the same chrome as every other result: a `Text` tab plus the
724
+ * trailing `Table` tab, so a scalar can still be inspected as a table.
725
+ */
726
+ const textRenderer: Renderer = async ({host, labels, fullscreenButton}, result) => {
727
+ const tabs = new PreviewTabs(
728
+ host,
729
+ [
730
+ {
731
+ label: labels.text ?? 'Text',
732
+ mount: (panel) => panel.appendChild(textBlock(panel.ownerDocument, result)),
733
+ },
734
+ ],
735
+ labels.table ?? 'Table',
736
+ (panel) => mountTable(panel, result, labels),
737
+ fullscreenButton,
738
+ );
739
+ return () => tabs.dispose();
740
+ };
741
+
742
+ /** Shared error view: a styled block, never a thrown exception. */
743
+ export const errorRenderer: Renderer = async ({host, labels}, result) => {
744
+ host.replaceChildren(errorBlock(host.ownerDocument, errorText(labels, result.error ?? 'unknown')));
745
+ };
746
+
747
+ /**
748
+ * The column holding the markup. `field` wins; a single-column result is
749
+ * unambiguous, so it is used as-is.
750
+ */
751
+ function resolveField(config: RunnableSqlConfig, result: QueryResult): string | null {
752
+ if (config.field) {
753
+ return config.field;
754
+ }
755
+ return result.columns.length === 1 ? result.columns[0] : null;
756
+ }
757
+
758
+ /**
759
+ * Parses SVG markup from a result cell into a node this document can host.
760
+ *
761
+ * `image/svg+xml` is strict XML: malformed markup (unclosed tags, a bare `&`,
762
+ * an HTML `<br>`) comes back as a `<parsererror>` element rather than throwing,
763
+ * so the caller can degrade to text. Script-bearing and event-handler content
764
+ * is stripped — inline SVG is *not* isolated (use the `iframe` renderer for
765
+ * untrusted markup).
766
+ */
767
+ function parseSvgMarkup(document: Document, markup: string): SVGElement | null {
768
+ if (!markup.trim()) {
769
+ return null;
770
+ }
771
+ const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
772
+ const root = parsed.documentElement;
773
+ if (!root || root.localName === 'parsererror' || root.namespaceURI !== SVG_NAMESPACE) {
774
+ return null;
775
+ }
776
+ stripActiveContent(root);
777
+ return document.importNode(root, true) as unknown as SVGElement;
778
+ }
779
+
780
+ /** Removes the parts of an SVG document that could execute or navigate. */
781
+ function stripActiveContent(root: Element): void {
782
+ for (const node of root.querySelectorAll('script, foreignObject')) {
783
+ node.remove();
784
+ }
785
+ const visit = (element: Element): void => {
786
+ for (const attribute of [...element.attributes]) {
787
+ const name = attribute.name.toLowerCase();
788
+ const value = attribute.value.replace(/\s/g, '').toLowerCase();
789
+ if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
790
+ element.removeAttribute(attribute.name);
791
+ }
792
+ }
793
+ for (const child of element.children) {
794
+ visit(child);
795
+ }
796
+ };
797
+ visit(root);
798
+ }
799
+
800
+ /** Applies `option.width` / `option.height` as custom properties the CSS consumes. */
801
+ function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
802
+ const {width, height} = config.option ?? {};
803
+ if (width) {
804
+ node.style.setProperty('--dfk-sql-preview-width', width);
805
+ }
806
+ if (height) {
807
+ node.style.setProperty('--dfk-sql-preview-height', height);
808
+ }
809
+ }
810
+
811
+ function mountPreviewPanel(
812
+ kind: 'iframe' | 'svg',
813
+ panel: HTMLElement,
814
+ value: unknown,
815
+ label: string,
816
+ config: RunnableSqlConfig,
817
+ ): void {
818
+ const markup = value === null || value === undefined ? '' : String(value);
819
+ if (kind === 'iframe') {
820
+ const frame = el('iframe', {
821
+ class: 'dfk-sql-frame',
822
+ srcdoc: markup,
823
+ attrs: {
824
+ // `sandbox` is a DOMTokenList on the element, so it can only travel
825
+ // through `attrs`; the default is never an unsandboxed frame.
826
+ sandbox: config.option?.sandbox ?? DEFAULT_SANDBOX,
827
+ loading: 'lazy',
828
+ referrerpolicy: 'no-referrer',
829
+ title: label,
830
+ },
831
+ });
832
+ applyPreviewSize(frame, config);
833
+ panel.appendChild(frame);
834
+ return;
835
+ }
836
+
837
+ const svg = parseSvgMarkup(panel.ownerDocument, markup);
838
+ if (!svg) {
839
+ // Not SVG: show the markup as text rather than an empty panel.
840
+ panel.appendChild(el('pre', {class: 'dfk-sql-text', text: markup}));
841
+ return;
842
+ }
843
+ const holder = el('div', {class: 'dfk-sql-svg'});
844
+ holder.appendChild(svg);
845
+ applyPreviewSize(holder, config);
846
+ panel.appendChild(holder);
847
+ }
848
+
849
+ /**
850
+ * Builds a preview renderer: one tab per row, then the raw rows in the trailing
851
+ * `Table` tab. `iframe` and `svg` share everything except how a panel is filled.
852
+ */
853
+ function previewRenderer(kind: 'iframe' | 'svg'): Renderer {
854
+ return async ({host, config, labels, fullscreenButton}, result) => {
855
+ const field = resolveField(config, result);
856
+ if (!field) {
857
+ host.replaceChildren(
858
+ errorBlock(
859
+ host.ownerDocument,
860
+ errorText(labels, labels.noField ?? 'this result has no markup column; set `field`'),
861
+ ),
862
+ );
863
+ return;
864
+ }
865
+
866
+ const tabName = config.tab_name;
867
+ const items: PreviewTabItem[] = result.rows.map((row, index) => {
868
+ const value = tabName ? row[tabName] : undefined;
869
+ return {
870
+ label: value === null || value === undefined ? `${labels.row ?? 'Row'} ${index + 1}` : stringify(value),
871
+ mount: (panel) => mountPreviewPanel(kind, panel, row[field], '', config),
872
+ };
873
+ });
874
+
875
+ const tabs = new PreviewTabs(
876
+ host,
877
+ items,
878
+ labels.table ?? 'Table',
879
+ (panel) => mountTable(panel, result, labels),
880
+ fullscreenButton,
881
+ );
882
+ return () => tabs.dispose();
883
+ };
884
+ }
885
+
886
+ function stringify(value: unknown): string {
887
+ if (value === null || value === undefined) {
888
+ return 'NULL';
889
+ }
890
+ if (value instanceof Date) {
891
+ return value.toLocaleString();
892
+ }
893
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
894
+ }
895
+
896
+ const registry: Record<string, Renderer> = {
897
+ table: tableRenderer,
898
+ text: textRenderer,
899
+ iframe: previewRenderer('iframe'),
900
+ // `html` is the historical spelling of the same renderer; both stay valid.
901
+ html: previewRenderer('iframe'),
902
+ svg: previewRenderer('svg'),
903
+ };
904
+
905
+ /**
906
+ * Picks the renderer for a result: the config's `show` wins, otherwise a
907
+ * single-column single-row result degrades to `text` (a bare scalar like
908
+ * `SELECT 1;` reads better as a line than as a 1×1 table), otherwise `table`.
909
+ */
910
+ export function rendererFor(config: RunnableSqlConfig, result: QueryResult): Renderer {
911
+ if (result.error) {
912
+ return errorRenderer;
913
+ }
914
+ const show = config.show ?? (result.columns.length === 1 && result.rows.length === 1 ? 'text' : 'table');
915
+ return registry[show] ?? tableRenderer;
916
+ }