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,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
|
+
}
|