duckfn-docs-kit 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +294 -224
- package/README.md +10 -6
- package/bin/sql-verify.mjs +12 -12
- package/dist/IconButton.d.ts +22 -0
- package/dist/codemirror.d.ts +29 -0
- package/dist/index.d.ts +28 -7
- package/dist/index.js +2 -2
- package/dist/mermaid/DfkMermaid.d.ts +7 -0
- package/dist/mermaid/config.d.ts +48 -0
- package/dist/mermaid/remark.d.ts +40 -0
- package/dist/mermaid/remark.js +32 -0
- package/dist/mermaid/render.d.ts +94 -0
- package/dist/mermaid/styles.d.ts +6 -0
- package/dist/mermaid/title.d.ts +23 -0
- package/dist/{register-DKLiYs-F.js → register-Dev_kc3Z.js} +921 -579
- package/dist/remark.d.ts +1 -1
- package/dist/sql/browserRunner.d.ts +41 -0
- package/dist/sql/browserRunner.js +186 -0
- package/dist/sql/client.js +1 -1
- package/dist/sql/harness.d.ts +59 -0
- package/dist/sql/harness.js +8328 -0
- package/dist/sql/remark.d.ts +6 -1
- package/dist/sql/renderers.d.ts +2 -0
- package/dist/sql/runtime.d.ts +20 -0
- package/dist/sql/verify.d.ts +4 -5
- package/dist/sql/verify.js +36 -49
- package/package.json +6 -2
- package/src/IconButton.ts +50 -0
- package/src/codemirror.ts +88 -0
- package/src/index.ts +31 -7
- package/src/mermaid/DfkMermaid.css +289 -0
- package/src/mermaid/DfkMermaid.ts +557 -0
- package/src/mermaid/config.ts +74 -0
- package/src/mermaid/remark.ts +98 -0
- package/src/mermaid/render.ts +178 -0
- package/src/mermaid/styles.ts +24 -0
- package/src/mermaid/title.ts +127 -0
- package/src/register.ts +3 -0
- package/src/remark.ts +1 -1
- package/src/sql/DfkSql.css +13 -10
- package/src/sql/DfkSql.ts +11 -46
- package/src/sql/browserRunner.ts +402 -0
- package/src/sql/harness.ts +134 -0
- package/src/sql/remark.ts +6 -1
- package/src/sql/renderers.ts +24 -3
- package/src/sql/runtime.ts +44 -0
- package/src/sql/sql.css +13 -11
- package/src/sql/verify.ts +23 -41
- package/dist/sql/editor.d.ts +0 -16
- package/dist/sql/nodeRunner.d.ts +0 -51
- package/dist/sql/nodeRunner.js +0 -115
- package/src/sql/editor.ts +0 -75
- package/src/sql/nodeRunner.ts +0 -298
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs runnable SQL blocks in a **real browser**, driven from Node with
|
|
3
|
+
* Playwright — the offline successor to the old Node-worker runner
|
|
4
|
+
* (`nodeRunner.ts`, now removed).
|
|
5
|
+
*
|
|
6
|
+
* Why a browser instead of the Node worker
|
|
7
|
+
* ----------------------------------------
|
|
8
|
+
* The Node worker target is not the environment a reader gets: it cannot read
|
|
9
|
+
* remote `http(s)` data (every remote-data example failed with `IO Error: No
|
|
10
|
+
* files found`), and loading the extension forced a loopback server on a
|
|
11
|
+
* Windows-hostile port (the staging path embeds the port, colons are illegal).
|
|
12
|
+
* A browser is the actual product surface: DuckDB-Wasm reads remote files
|
|
13
|
+
* there, and the extension loads same-origin over plain http with none of the
|
|
14
|
+
* staging/port constraints. That is exactly what the docs site ships.
|
|
15
|
+
*
|
|
16
|
+
* Why Playwright, and why `playwright-core`
|
|
17
|
+
* -----------------------------------------
|
|
18
|
+
* Playwright is the mature, maintained way to drive a browser: it owns the
|
|
19
|
+
* protocol, the connect/navigate/evaluate plumbing, auto-waiting, timeouts and
|
|
20
|
+
* crash handling that a hand-rolled driver would have to reinvent. The kit uses
|
|
21
|
+
* the `playwright-core` package (not the full `playwright`) precisely because
|
|
22
|
+
* `playwright-core` never downloads a browser — it launches one you point it
|
|
23
|
+
* at. The runner starts your **system Chrome/Edge** via `executablePath`, so
|
|
24
|
+
* verification stays fully offline. `--browser <path>` / `DFK_BROWSER` override
|
|
25
|
+
* which executable is used; otherwise a small set of well-known paths is probed.
|
|
26
|
+
*
|
|
27
|
+
* How a page maps to a DuckDB instance
|
|
28
|
+
* -------------------------------------
|
|
29
|
+
* `newPage()` navigates to the harness page (`harness.ts`), which builds a
|
|
30
|
+
* fresh `AsyncDuckDB` from the same-origin engine and preloads the extension.
|
|
31
|
+
* Reloading per content file is the isolation boundary (pages never share an
|
|
32
|
+
* instance), while the blocks of one page share its connection — the same "one
|
|
33
|
+
* instance per page, blocks share a connection" model as the site. Files
|
|
34
|
+
* (`COPY … TO`, `dfn_file_write_*`) land in the instance's in-memory file
|
|
35
|
+
* system, readable within the page and gone on reload — the faithful browser
|
|
36
|
+
* behaviour, not a host-directory emulation.
|
|
37
|
+
*/
|
|
38
|
+
import {createServer, type Server} from 'node:http';
|
|
39
|
+
import {createRequire} from 'node:module';
|
|
40
|
+
import {existsSync, readFileSync} from 'node:fs';
|
|
41
|
+
import {basename, dirname, join} from 'node:path';
|
|
42
|
+
import {chromium, type Browser, type Page} from 'playwright-core';
|
|
43
|
+
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
|
|
46
|
+
/** The extension to `LOAD`: a local `.duckdb_extension.wasm` path or an http(s) URL. */
|
|
47
|
+
export interface RunnerOptions {
|
|
48
|
+
extension: string;
|
|
49
|
+
/** DuckDB-Wasm platform bundle; must match how the extension was built. */
|
|
50
|
+
platform?: WasmPlatform;
|
|
51
|
+
/** Engine wasm override; defaults to the platform bundle inside duckdb-wasm. */
|
|
52
|
+
engine?: string;
|
|
53
|
+
/** Browser executable; defaults to a detected Chrome/Edge (`DFK_BROWSER` wins). */
|
|
54
|
+
browser?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WasmPlatform = 'eh' | 'mvp';
|
|
58
|
+
|
|
59
|
+
export interface RunResult {
|
|
60
|
+
rows: number;
|
|
61
|
+
columns: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-platform file names inside `@duckdb/duckdb-wasm/dist`. */
|
|
65
|
+
const PLATFORM_BUNDLES: Record<WasmPlatform, {wasm: string; worker: string}> = {
|
|
66
|
+
eh: {wasm: 'duckdb-eh.wasm', worker: 'duckdb-browser-eh.worker.js'},
|
|
67
|
+
mvp: {wasm: 'duckdb-mvp.wasm', worker: 'duckdb-browser-mvp.worker.js'},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The harness's `__dfkRun` return shape (see `harness.ts`). */
|
|
71
|
+
interface HarnessResult {
|
|
72
|
+
rows: number;
|
|
73
|
+
columns: number;
|
|
74
|
+
error?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The harness's `__dfkQuery` return shape (see `harness.ts`). */
|
|
78
|
+
export interface QueryResult {
|
|
79
|
+
columns: string[];
|
|
80
|
+
rows: Record<string, unknown>[];
|
|
81
|
+
error?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One DuckDB-Wasm instance at a time, driven through a headless browser.
|
|
86
|
+
* Mirrors the surface `verify.ts` used from `WasmSqlRunner`.
|
|
87
|
+
*/
|
|
88
|
+
export class BrowserSqlRunner {
|
|
89
|
+
#server: Server | null = null;
|
|
90
|
+
#browser: Browser | null = null;
|
|
91
|
+
#page: Page | null = null;
|
|
92
|
+
#harnessUrl = '';
|
|
93
|
+
/** Set when the current page failed to initialise; every run then reports it. */
|
|
94
|
+
#pageError: string | null = null;
|
|
95
|
+
|
|
96
|
+
readonly #extensionUrl: string;
|
|
97
|
+
readonly #enginePath: string;
|
|
98
|
+
readonly #workerPath: string;
|
|
99
|
+
readonly #browserExecutable: string;
|
|
100
|
+
readonly #allowUnsigned: boolean;
|
|
101
|
+
|
|
102
|
+
private constructor(options: {
|
|
103
|
+
extensionUrl: string;
|
|
104
|
+
enginePath: string;
|
|
105
|
+
workerPath: string;
|
|
106
|
+
browserExecutable: string;
|
|
107
|
+
allowUnsigned: boolean;
|
|
108
|
+
}) {
|
|
109
|
+
this.#extensionUrl = options.extensionUrl;
|
|
110
|
+
this.#enginePath = options.enginePath;
|
|
111
|
+
this.#workerPath = options.workerPath;
|
|
112
|
+
this.#browserExecutable = options.browserExecutable;
|
|
113
|
+
this.#allowUnsigned = options.allowUnsigned;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static async create(options: RunnerOptions): Promise<BrowserSqlRunner> {
|
|
117
|
+
const platform = options.platform ?? 'eh';
|
|
118
|
+
const bundle = PLATFORM_BUNDLES[platform];
|
|
119
|
+
const distDir = dirname(require.resolve('@duckdb/duckdb-wasm/dist/duckdb-browser.mjs'));
|
|
120
|
+
const enginePath = options.engine ? resolveLocal(options.engine) : join(distDir, bundle.wasm);
|
|
121
|
+
const workerPath = join(distDir, bundle.worker);
|
|
122
|
+
|
|
123
|
+
const remote = /^https?:\/\//i.test(options.extension);
|
|
124
|
+
const extensionUrl = remote ? options.extension : resolveLocal(options.extension);
|
|
125
|
+
|
|
126
|
+
return new BrowserSqlRunner({
|
|
127
|
+
extensionUrl,
|
|
128
|
+
enginePath,
|
|
129
|
+
workerPath,
|
|
130
|
+
browserExecutable: findBrowser(options.browser),
|
|
131
|
+
// The runner is a trusted local loopback: an unsigned dev extension must
|
|
132
|
+
// load, exactly as the site's own config opts in.
|
|
133
|
+
allowUnsigned: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Starts the static server and the browser; idempotent. */
|
|
138
|
+
async #ensureStarted(): Promise<void> {
|
|
139
|
+
if (this.#page) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.#server = await startStaticServer(this.#routes());
|
|
143
|
+
const address = this.#server.address();
|
|
144
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
145
|
+
this.#harnessUrl = `http://127.0.0.1:${port}/harness.html`;
|
|
146
|
+
|
|
147
|
+
// `playwright-core` launches the executable we hand it and manages the
|
|
148
|
+
// browser process and a throwaway profile for us.
|
|
149
|
+
this.#browser = await chromium.launch({
|
|
150
|
+
executablePath: this.#browserExecutable,
|
|
151
|
+
headless: true,
|
|
152
|
+
});
|
|
153
|
+
this.#page = await this.#browser.newPage();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Maps the URL space the harness page needs to everything on disk. */
|
|
157
|
+
#routes(): Routes {
|
|
158
|
+
// A served extension keeps a `/ext/<name>.duckdb_extension.wasm` shape so
|
|
159
|
+
// its base name still names the entry symbol (`<name>_init_c_api`).
|
|
160
|
+
const ext = /^https?:\/\//i.test(this.#extensionUrl)
|
|
161
|
+
? {remoteUrl: this.#extensionUrl}
|
|
162
|
+
: {localFile: this.#extensionUrl, baseName: basename(this.#extensionUrl)};
|
|
163
|
+
return {
|
|
164
|
+
// Resolved through the package's own `exports` wildcard
|
|
165
|
+
// (`./sql/harness` -> `dist/sql/harness.js`), so it finds the built harness
|
|
166
|
+
// whether the runner runs from the workspace symlink or an installed copy.
|
|
167
|
+
harnessScript: require.resolve('duckfn-docs-kit/sql/harness'),
|
|
168
|
+
engine: {file: this.#enginePath, name: basename(this.#enginePath)},
|
|
169
|
+
worker: {file: this.#workerPath, name: basename(this.#workerPath)},
|
|
170
|
+
extension: ext,
|
|
171
|
+
allowUnsigned: this.#allowUnsigned,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Drops the current page state and opens a fresh one: new instance, new connection. */
|
|
176
|
+
async newPage(): Promise<void> {
|
|
177
|
+
await this.#ensureStarted();
|
|
178
|
+
const page = this.#page;
|
|
179
|
+
if (!page) {
|
|
180
|
+
throw new Error('sql/browserRunner: browser is not running');
|
|
181
|
+
}
|
|
182
|
+
this.#pageError = null;
|
|
183
|
+
await page.goto(this.#harnessUrl, {waitUntil: 'load'});
|
|
184
|
+
// `__dfkReady` resolves once the engine is up and the preloads have loaded;
|
|
185
|
+
// Playwright awaits the promise and throws if it rejects.
|
|
186
|
+
try {
|
|
187
|
+
await page.evaluate(() => (window as unknown as {__dfkReady: Promise<void>}).__dfkReady);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.#pageError = messageOf(error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Runs one block; a SQL failure is thrown so the caller can weigh it. */
|
|
194
|
+
async run(sql: string): Promise<RunResult> {
|
|
195
|
+
if (this.#pageError) {
|
|
196
|
+
throw new Error(this.#pageError);
|
|
197
|
+
}
|
|
198
|
+
const page = this.#page;
|
|
199
|
+
if (!page) {
|
|
200
|
+
throw new Error('sql/browserRunner: no page is open — call newPage() first');
|
|
201
|
+
}
|
|
202
|
+
// `sql` crosses as an evaluate argument, never spliced into page code.
|
|
203
|
+
const result = await page.evaluate(
|
|
204
|
+
(statement) => (window as unknown as {__dfkRun: (s: string) => Promise<HarnessResult>}).__dfkRun(statement),
|
|
205
|
+
sql,
|
|
206
|
+
);
|
|
207
|
+
if (result?.error) {
|
|
208
|
+
throw new Error(result.error);
|
|
209
|
+
}
|
|
210
|
+
return {rows: result.rows, columns: result.columns};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Runs one statement and returns its actual rows (not just a count). Used by
|
|
215
|
+
* the vfs browser probe to read each value; `run()` is the verifier's path.
|
|
216
|
+
* Unlike `run()`, a failure is returned as `{error}` rather than thrown.
|
|
217
|
+
*/
|
|
218
|
+
async query(sql: string): Promise<QueryResult> {
|
|
219
|
+
if (this.#pageError) {
|
|
220
|
+
return {columns: [], rows: [], error: this.#pageError};
|
|
221
|
+
}
|
|
222
|
+
const page = this.#page;
|
|
223
|
+
if (!page) {
|
|
224
|
+
throw new Error('sql/browserRunner: no page is open — call newPage() first');
|
|
225
|
+
}
|
|
226
|
+
const result = await page.evaluate(
|
|
227
|
+
(statement) =>
|
|
228
|
+
(window as unknown as {__dfkQuery: (s: string) => Promise<QueryResult>}).__dfkQuery(statement),
|
|
229
|
+
sql,
|
|
230
|
+
);
|
|
231
|
+
return result ?? {columns: [], rows: [], error: 'no result'};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async close(): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
await this.#page?.close();
|
|
237
|
+
} catch {
|
|
238
|
+
// The page may already be gone when the browser is shutting down.
|
|
239
|
+
}
|
|
240
|
+
this.#page = null;
|
|
241
|
+
try {
|
|
242
|
+
await this.#browser?.close();
|
|
243
|
+
} catch {
|
|
244
|
+
// Likewise the browser: closing twice or after a crash is not an error.
|
|
245
|
+
}
|
|
246
|
+
this.#browser = null;
|
|
247
|
+
if (this.#server) {
|
|
248
|
+
await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
|
|
249
|
+
this.#server = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A path or `file:` URL to an absolute local path; leaves a remote URL alone. */
|
|
255
|
+
function resolveLocal(value: string): string {
|
|
256
|
+
return /^file:\/\//i.test(value) ? new URL(value).pathname.replace(/^\/(\w:)/i, '$1') : value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Static server
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
interface Routes {
|
|
264
|
+
harnessScript: string;
|
|
265
|
+
engine: {file: string; name: string};
|
|
266
|
+
worker: {file: string; name: string};
|
|
267
|
+
extension: {remoteUrl: string} | {localFile: string; baseName: string};
|
|
268
|
+
allowUnsigned: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A loopback static server for the harness: `harness.html` (generated, wiring
|
|
273
|
+
* the engine/extension URLs into the two contracts `harness.ts` reads),
|
|
274
|
+
* `harness.js` (the bundled harness), `/vendor/*` (engine + worker), and
|
|
275
|
+
* `/ext/*` (the served extension file). Nothing else is reachable; this is a
|
|
276
|
+
* short-lived test fixture, not a web server.
|
|
277
|
+
*/
|
|
278
|
+
async function startStaticServer(routes: Routes): Promise<Server> {
|
|
279
|
+
const harness = readFileSync(routes.harnessScript);
|
|
280
|
+
const engine = readFileSync(routes.engine.file);
|
|
281
|
+
const worker = readFileSync(routes.worker.file);
|
|
282
|
+
const extension =
|
|
283
|
+
'localFile' in routes.extension ? readFileSync(routes.extension.localFile) : null;
|
|
284
|
+
const extBaseName = 'baseName' in routes.extension ? routes.extension.baseName : '';
|
|
285
|
+
|
|
286
|
+
const extensionUrl =
|
|
287
|
+
'remoteUrl' in routes.extension
|
|
288
|
+
? routes.extension.remoteUrl
|
|
289
|
+
: `ext/${routes.extension.baseName}`;
|
|
290
|
+
|
|
291
|
+
const html = `<!doctype html>
|
|
292
|
+
<html lang="en">
|
|
293
|
+
<head>
|
|
294
|
+
<meta charset="utf-8" />
|
|
295
|
+
<title>duckfn sql harness</title>
|
|
296
|
+
<script>
|
|
297
|
+
window.DFK_HARNESS_BUNDLE = {
|
|
298
|
+
mainModule: '/vendor/${routes.engine.name}',
|
|
299
|
+
mainWorker: '/vendor/${routes.worker.name}',
|
|
300
|
+
};
|
|
301
|
+
</script>
|
|
302
|
+
<script id="dfk-sql-runtime" type="application/json">
|
|
303
|
+
{"allowUnsignedExtensions":${routes.allowUnsigned},"preload":[{"url":"${extensionUrl}"}]}
|
|
304
|
+
</script>
|
|
305
|
+
</head>
|
|
306
|
+
<body>
|
|
307
|
+
<script type="module" src="/harness.js"></script>
|
|
308
|
+
</body>
|
|
309
|
+
</html>`;
|
|
310
|
+
|
|
311
|
+
const server = createServer((request, response) => {
|
|
312
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
313
|
+
if (path === '/harness.html') {
|
|
314
|
+
send(response, 'text/html; charset=utf-8', Buffer.from(html, 'utf8'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (path === '/harness.js') {
|
|
318
|
+
send(response, 'text/javascript; charset=utf-8', harness);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (path === `/vendor/${routes.engine.name}`) {
|
|
322
|
+
send(response, 'application/wasm', engine);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (path === `/vendor/${routes.worker.name}`) {
|
|
326
|
+
send(response, 'text/javascript; charset=utf-8', worker);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (extension && path === `/ext/${extBaseName}`) {
|
|
330
|
+
send(response, 'application/octet-stream', extension);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
response.writeHead(404, {'content-type': 'text/plain'});
|
|
334
|
+
response.end('not found');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await new Promise<void>((resolve, reject) => {
|
|
338
|
+
server.once('error', reject);
|
|
339
|
+
// Port 0 lets the OS pick a free one; a browser has no reason for a fixed
|
|
340
|
+
// port (unlike the Node worker's staging path, which embedded it).
|
|
341
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
342
|
+
});
|
|
343
|
+
return server;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function send(response: import('node:http').ServerResponse, type: string, body: Buffer): void {
|
|
347
|
+
response.writeHead(200, {'content-type': type, 'content-length': body.length});
|
|
348
|
+
response.end(body);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Browser detection
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** A short list of well-known Chrome/Edge locations, overridable by DFK_BROWSER. */
|
|
356
|
+
function findBrowser(explicit?: string): string {
|
|
357
|
+
const candidate = explicit ?? process.env.DFK_BROWSER;
|
|
358
|
+
if (candidate) {
|
|
359
|
+
return candidate;
|
|
360
|
+
}
|
|
361
|
+
const paths: string[] = [];
|
|
362
|
+
if (process.platform === 'win32') {
|
|
363
|
+
const pf = process.env['PROGRAMFILES'] ?? 'C:\\Program Files';
|
|
364
|
+
const pf86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)';
|
|
365
|
+
const local = process.env['LOCALAPPDATA'] ?? '';
|
|
366
|
+
paths.push(
|
|
367
|
+
join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
368
|
+
join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
369
|
+
join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
370
|
+
join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
371
|
+
local ? join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') : '',
|
|
372
|
+
);
|
|
373
|
+
} else if (process.platform === 'darwin') {
|
|
374
|
+
paths.push(
|
|
375
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
376
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
paths.push(
|
|
380
|
+
'/usr/bin/google-chrome',
|
|
381
|
+
'/usr/bin/google-chrome-stable',
|
|
382
|
+
'/usr/bin/microsoft-edge',
|
|
383
|
+
'/usr/bin/chromium',
|
|
384
|
+
'/usr/bin/chromium-browser',
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
for (const path of paths) {
|
|
388
|
+
if (path && existsSync(path)) {
|
|
389
|
+
return path;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
throw new Error(
|
|
393
|
+
'sql/browserRunner: no Chrome/Edge found — pass --browser <path> or set DFK_BROWSER',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function messageOf(error: unknown): string {
|
|
398
|
+
if (error instanceof Error) {
|
|
399
|
+
return error.message;
|
|
400
|
+
}
|
|
401
|
+
return String(error);
|
|
402
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `duckfn-docs-kit/sql/harness` — the browser page the offline SQL verifier
|
|
3
|
+
* (`sql/browserRunner`) drives with Playwright.
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately thin: it reuses the site's own runtime (`sql/runtime`) so
|
|
6
|
+
* a verified block runs through exactly the code path a reader's **Run** click
|
|
7
|
+
* does — same `AsyncDuckDB` wiring, same ordered extension preload, same
|
|
8
|
+
* "multi-statement returns the last result" `execute()`. The only difference is
|
|
9
|
+
* where the engine comes from: instead of the jsDelivr CDN, the runner serves
|
|
10
|
+
* `duckdb-*.wasm` and its worker script same-origin and hands those URLs in
|
|
11
|
+
* through {@link window.DFK_HARNESS_BUNDLE}, which `runtime.init` accepts as a
|
|
12
|
+
* {@link LocalBundle}.
|
|
13
|
+
*
|
|
14
|
+
* The two contracts the page reads are injected by the served `harness.html`:
|
|
15
|
+
*
|
|
16
|
+
* - `window.DFK_HARNESS_BUNDLE` — `{mainModule, mainWorker, pthreadWorker?}`,
|
|
17
|
+
* the locally-served engine bundle (an absolute same-origin URL each).
|
|
18
|
+
* - the `<script id="dfk-sql-runtime">` JSON tag — the preload list and
|
|
19
|
+
* `allowUnsignedExtensions`, in the very shape the site plugin injects
|
|
20
|
+
* (see `sql/runtimeConfig`), so nothing here re-implements extension loading.
|
|
21
|
+
*
|
|
22
|
+
* The runner communicates through `window.__dfkReady`, `window.__dfkRun` and
|
|
23
|
+
* `window.__dfkQuery`, each reached with a Playwright `page.evaluate` (which
|
|
24
|
+
* awaits the returned promise and marshals the value back to Node). Page reload
|
|
25
|
+
* per content file is what gives "one instance per page": a fresh document
|
|
26
|
+
* resets the module-level singleton, so pages never share a DuckDB instance
|
|
27
|
+
* while the blocks of one page keep sharing its connection (via
|
|
28
|
+
* `DuckDBRuntime.execute`).
|
|
29
|
+
*/
|
|
30
|
+
import {DuckDBRuntime, type LocalBundle} from './runtime';
|
|
31
|
+
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window {
|
|
34
|
+
/** The locally-served engine bundle, injected by the runner's harness.html. */
|
|
35
|
+
DFK_HARNESS_BUNDLE?: LocalBundle;
|
|
36
|
+
/** Resolves once the instance is up and the site preloads have loaded. */
|
|
37
|
+
__dfkReady?: Promise<void>;
|
|
38
|
+
/** Runs one block and reports rows/columns, or a captured error message. */
|
|
39
|
+
__dfkRun?: (sql: string) => Promise<HarnessRunResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Runs one statement and returns its actual result — the rows, not just a
|
|
42
|
+
* count. Used by the vfs browser probe (`browserRunner.query`) to read the
|
|
43
|
+
* value each statement produced; the docs verifier never needs it.
|
|
44
|
+
*/
|
|
45
|
+
__dfkQuery?: (sql: string) => Promise<HarnessQueryResult>;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What {@link window.__dfkRun} resolves with; mirrors the runner's `RunResult`. */
|
|
50
|
+
export interface HarnessRunResult {
|
|
51
|
+
rows: number;
|
|
52
|
+
columns: number;
|
|
53
|
+
/** Set when the block failed; `rows`/`columns` are then 0. */
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What {@link window.__dfkQuery} resolves with: the marshalled result rows. */
|
|
58
|
+
export interface HarnessQueryResult {
|
|
59
|
+
columns: string[];
|
|
60
|
+
rows: Record<string, unknown>[];
|
|
61
|
+
error?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function ready(): Promise<void> {
|
|
65
|
+
const bundle = window.DFK_HARNESS_BUNDLE;
|
|
66
|
+
if (!bundle) {
|
|
67
|
+
throw new Error('sql/harness: window.DFK_HARNESS_BUNDLE is not set');
|
|
68
|
+
}
|
|
69
|
+
const runtime = DuckDBRuntime.getInstance();
|
|
70
|
+
const allowUnsigned = readAllowUnsigned();
|
|
71
|
+
await runtime.init({bundle, allowUnsignedExtensions: allowUnsigned});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `allowUnsignedExtensions` is fixed at `open()` time, so the harness has to
|
|
76
|
+
* hand it to `init()` directly rather than let the runtime read it lazily from
|
|
77
|
+
* the config tag. Reading the same tag here (idempotently) keeps the injected
|
|
78
|
+
* config the single source of truth — the runtime still reads the preload list
|
|
79
|
+
* from it inside `#create()`.
|
|
80
|
+
*/
|
|
81
|
+
function readAllowUnsigned(): boolean {
|
|
82
|
+
const text = document
|
|
83
|
+
.getElementById('dfk-sql-runtime')
|
|
84
|
+
?.textContent?.trim();
|
|
85
|
+
if (!text) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(text) as {allowUnsignedExtensions?: boolean};
|
|
90
|
+
return parsed.allowUnsignedExtensions === true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function run(sql: string): Promise<HarnessRunResult> {
|
|
97
|
+
const result = await DuckDBRuntime.getInstance().execute(sql);
|
|
98
|
+
if (result.error) {
|
|
99
|
+
return {rows: 0, columns: 0, error: result.error};
|
|
100
|
+
}
|
|
101
|
+
return {rows: result.rows.length, columns: result.columns.length};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function query(sql: string): Promise<HarnessQueryResult> {
|
|
105
|
+
const result = await DuckDBRuntime.getInstance().execute(sql);
|
|
106
|
+
if (result.error) {
|
|
107
|
+
return {columns: [], rows: [], error: result.error};
|
|
108
|
+
}
|
|
109
|
+
// Rebuild each row as a plain, own-enumerable object. `execute()` hands back
|
|
110
|
+
// Apache-Arrow row objects whose values sit behind getters, so Playwright's
|
|
111
|
+
// `page.evaluate` serializer would marshal them to `{}`; reading the getters
|
|
112
|
+
// here, in-page, and copying to a literal survives the hop. BigInt values
|
|
113
|
+
// (DuckDB integers) become strings for the same reason.
|
|
114
|
+
const rows = result.rows.map((row) => {
|
|
115
|
+
const plain: Record<string, unknown> = {};
|
|
116
|
+
for (const column of result.columns) {
|
|
117
|
+
const value = row[column];
|
|
118
|
+
plain[column] =
|
|
119
|
+
typeof value === 'bigint'
|
|
120
|
+
? value.toString()
|
|
121
|
+
: value === null || value === undefined
|
|
122
|
+
? null
|
|
123
|
+
: typeof value === 'object'
|
|
124
|
+
? JSON.stringify(value)
|
|
125
|
+
: value;
|
|
126
|
+
}
|
|
127
|
+
return plain;
|
|
128
|
+
});
|
|
129
|
+
return {columns: result.columns, rows};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
window.__dfkReady = ready();
|
|
133
|
+
window.__dfkRun = run;
|
|
134
|
+
window.__dfkQuery = query;
|
package/src/sql/remark.ts
CHANGED
|
@@ -36,8 +36,13 @@ export interface RunnableSqlConfig {
|
|
|
36
36
|
*
|
|
37
37
|
* `html` and `iframe` are the same renderer: both sandbox the markup in an
|
|
38
38
|
* iframe, so scripts run with an opaque origin.
|
|
39
|
+
*
|
|
40
|
+
* `mermaid` renders the column's mermaid source as a diagram through
|
|
41
|
+
* `<dfk-mermaid>` (the same element a ```mermaid fence produces), so the result
|
|
42
|
+
* gets the element's zoom, fullscreen, source editing and SVG download for
|
|
43
|
+
* free.
|
|
39
44
|
*/
|
|
40
|
-
show?: 'table' | 'html' | 'iframe' | 'svg' | 'text';
|
|
45
|
+
show?: 'table' | 'html' | 'iframe' | 'svg' | 'text' | 'mermaid';
|
|
41
46
|
/**
|
|
42
47
|
* What this block is expected to do when the docs' own SQL test suite runs it
|
|
43
48
|
* (`duckfn-docs-kit/sql/verify`). Defaults to `'ok'`; `'error'` marks a block
|
package/src/sql/renderers.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {el} from '../dom';
|
|
|
9
9
|
*
|
|
10
10
|
* The registry is the seam later phases plug into. It ships `table` (VisActor
|
|
11
11
|
* VTable), a `text` fallback, the markup previews `iframe` / `html` / `svg`,
|
|
12
|
+
* `mermaid` (which hands the cell to the kit's own `<dfk-mermaid>` element, so a
|
|
13
|
+
* query can produce a diagram the reader can zoom, expand, edit and download),
|
|
12
14
|
* plus the `error` view every renderer shares.
|
|
13
15
|
*
|
|
14
16
|
* Heavy dependencies (`@visactor/vtable`) load through dynamic `import()`
|
|
@@ -808,8 +810,11 @@ function applyPreviewSize(node: HTMLElement, config: RunnableSqlConfig): void {
|
|
|
808
810
|
}
|
|
809
811
|
}
|
|
810
812
|
|
|
813
|
+
/** How a preview panel is filled for one row of the result. */
|
|
814
|
+
type PreviewKind = 'iframe' | 'svg' | 'mermaid';
|
|
815
|
+
|
|
811
816
|
function mountPreviewPanel(
|
|
812
|
-
kind:
|
|
817
|
+
kind: PreviewKind,
|
|
813
818
|
panel: HTMLElement,
|
|
814
819
|
value: unknown,
|
|
815
820
|
label: string,
|
|
@@ -834,6 +839,20 @@ function mountPreviewPanel(
|
|
|
834
839
|
return;
|
|
835
840
|
}
|
|
836
841
|
|
|
842
|
+
if (kind === 'mermaid') {
|
|
843
|
+
// The cell is handed to the kit's own diagram element rather than rendered
|
|
844
|
+
// here: `<dfk-mermaid>` loads mermaid through the page-wide render queue and
|
|
845
|
+
// brings the zoom / fullscreen / edit / download chrome with it. A `mermaid`
|
|
846
|
+
// result and a ```mermaid fence therefore behave identically, and there is
|
|
847
|
+
// one place that knows about the dark-mode-first-load fix.
|
|
848
|
+
//
|
|
849
|
+
// The source travels as an attribute (the element's attribute seed) rather
|
|
850
|
+
// than through a setter, so this works whether or not the element has been
|
|
851
|
+
// upgraded yet: an attribute set before insertion is read at upgrade time.
|
|
852
|
+
panel.appendChild(el('dfk-mermaid', {attrs: {source: markup}}));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
837
856
|
const svg = parseSvgMarkup(panel.ownerDocument, markup);
|
|
838
857
|
if (!svg) {
|
|
839
858
|
// Not SVG: show the markup as text rather than an empty panel.
|
|
@@ -848,9 +867,10 @@ function mountPreviewPanel(
|
|
|
848
867
|
|
|
849
868
|
/**
|
|
850
869
|
* Builds a preview renderer: one tab per row, then the raw rows in the trailing
|
|
851
|
-
* `Table` tab. `iframe` and `
|
|
870
|
+
* `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a panel
|
|
871
|
+
* is filled.
|
|
852
872
|
*/
|
|
853
|
-
function previewRenderer(kind:
|
|
873
|
+
function previewRenderer(kind: PreviewKind): Renderer {
|
|
854
874
|
return async ({host, config, labels, fullscreenButton}, result) => {
|
|
855
875
|
const field = resolveField(config, result);
|
|
856
876
|
if (!field) {
|
|
@@ -900,6 +920,7 @@ const registry: Record<string, Renderer> = {
|
|
|
900
920
|
// `html` is the historical spelling of the same renderer; both stay valid.
|
|
901
921
|
html: previewRenderer('iframe'),
|
|
902
922
|
svg: previewRenderer('svg'),
|
|
923
|
+
mermaid: previewRenderer('mermaid'),
|
|
903
924
|
};
|
|
904
925
|
|
|
905
926
|
/**
|
package/src/sql/runtime.ts
CHANGED
|
@@ -65,6 +65,27 @@ export type RuntimeState = 'idle' | 'loading' | 'ready' | 'error';
|
|
|
65
65
|
export interface RuntimeOptions {
|
|
66
66
|
/** Let `LOAD` accept an extension whose signature does not verify. */
|
|
67
67
|
allowUnsignedExtensions?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Serve the DuckDB-Wasm engine from explicit same-origin URLs instead of the
|
|
70
|
+
* jsDelivr CDN. Used by the offline SQL verifier (`sql/browserRunner`): the
|
|
71
|
+
* harness passes the locally-served `duckdb-*.wasm` / worker script so a CI
|
|
72
|
+
* run never reaches the network. When set, `#create()` skips
|
|
73
|
+
* `getJsDelivrBundles()`/`selectBundle()` and the cross-origin blob-worker
|
|
74
|
+
* wrapper (the worker is same-origin here, so it is constructed directly).
|
|
75
|
+
*/
|
|
76
|
+
bundle?: LocalBundle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A locally-served DuckDB-Wasm bundle: absolute same-origin URLs for the
|
|
81
|
+
* engine wasm and its worker script. `pthreadWorker` is only needed for the
|
|
82
|
+
* cross-origin-isolated (COI) bundle; the default non-COI `eh`/`mvp` bundles run
|
|
83
|
+
* single-threaded and leave it unset.
|
|
84
|
+
*/
|
|
85
|
+
export interface LocalBundle {
|
|
86
|
+
mainModule: string;
|
|
87
|
+
mainWorker: string;
|
|
88
|
+
pthreadWorker?: string;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
/** Options for {@link DuckDBRuntime.loadExtension}. */
|
|
@@ -96,6 +117,8 @@ export class DuckDBRuntime {
|
|
|
96
117
|
#db: DuckdbWasm.AsyncDuckDB | null = null;
|
|
97
118
|
#conn: DuckdbWasm.AsyncDuckDBConnection | null = null;
|
|
98
119
|
#allowUnsigned = false;
|
|
120
|
+
/** A locally-served engine bundle (offline verifier); `null` means CDN. */
|
|
121
|
+
#bundle: LocalBundle | null = null;
|
|
99
122
|
/** The injected site config; read (and validated) once on first init. */
|
|
100
123
|
#site: SiteRuntimeConfig | null = null;
|
|
101
124
|
/** Loaded / in-flight extensions, keyed by repository + name, or by URL. */
|
|
@@ -125,6 +148,9 @@ export class DuckDBRuntime {
|
|
|
125
148
|
if (options.allowUnsignedExtensions) {
|
|
126
149
|
this.#allowUnsigned = true;
|
|
127
150
|
}
|
|
151
|
+
if (options.bundle) {
|
|
152
|
+
this.#bundle = options.bundle;
|
|
153
|
+
}
|
|
128
154
|
if (this.#state === 'ready') {
|
|
129
155
|
return Promise.resolve();
|
|
130
156
|
}
|
|
@@ -154,6 +180,24 @@ export class DuckDBRuntime {
|
|
|
154
180
|
|
|
155
181
|
async #create(preload: readonly PreloadEntry[]): Promise<void> {
|
|
156
182
|
const duckdb = await import('@duckdb/duckdb-wasm');
|
|
183
|
+
|
|
184
|
+
// Offline verifier path: the engine and its worker are served same-origin
|
|
185
|
+
// by the runner, so the worker is constructed directly (no cross-origin
|
|
186
|
+
// blob wrapper) and no CDN bundle is selected.
|
|
187
|
+
if (this.#bundle) {
|
|
188
|
+
const worker = new Worker(this.#bundle.mainWorker);
|
|
189
|
+
this.#db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
|
|
190
|
+
await this.#db.instantiate(this.#bundle.mainModule, this.#bundle.pthreadWorker ?? null);
|
|
191
|
+
await this.#db.open({allowUnsignedExtensions: this.#allowUnsigned});
|
|
192
|
+
this.#conn = await this.#db.connect();
|
|
193
|
+
for (const entry of preload) {
|
|
194
|
+
await this.#loadEntry(entry);
|
|
195
|
+
}
|
|
196
|
+
this.#state = 'ready';
|
|
197
|
+
this.#message = '';
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
157
201
|
const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
|
|
158
202
|
if (!bundle.mainWorker) {
|
|
159
203
|
throw new Error('The selected DuckDB-Wasm bundle has no worker script');
|