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.
Files changed (53) hide show
  1. package/AGENTS.md +294 -224
  2. package/README.md +10 -6
  3. package/bin/sql-verify.mjs +12 -12
  4. package/dist/IconButton.d.ts +22 -0
  5. package/dist/codemirror.d.ts +29 -0
  6. package/dist/index.d.ts +28 -7
  7. package/dist/index.js +2 -2
  8. package/dist/mermaid/DfkMermaid.d.ts +7 -0
  9. package/dist/mermaid/config.d.ts +48 -0
  10. package/dist/mermaid/remark.d.ts +40 -0
  11. package/dist/mermaid/remark.js +32 -0
  12. package/dist/mermaid/render.d.ts +94 -0
  13. package/dist/mermaid/styles.d.ts +6 -0
  14. package/dist/mermaid/title.d.ts +23 -0
  15. package/dist/{register-DKLiYs-F.js → register-Dev_kc3Z.js} +921 -579
  16. package/dist/remark.d.ts +1 -1
  17. package/dist/sql/browserRunner.d.ts +41 -0
  18. package/dist/sql/browserRunner.js +186 -0
  19. package/dist/sql/client.js +1 -1
  20. package/dist/sql/harness.d.ts +59 -0
  21. package/dist/sql/harness.js +8328 -0
  22. package/dist/sql/remark.d.ts +6 -1
  23. package/dist/sql/renderers.d.ts +2 -0
  24. package/dist/sql/runtime.d.ts +20 -0
  25. package/dist/sql/verify.d.ts +4 -5
  26. package/dist/sql/verify.js +36 -49
  27. package/package.json +6 -2
  28. package/src/IconButton.ts +50 -0
  29. package/src/codemirror.ts +88 -0
  30. package/src/index.ts +31 -7
  31. package/src/mermaid/DfkMermaid.css +289 -0
  32. package/src/mermaid/DfkMermaid.ts +557 -0
  33. package/src/mermaid/config.ts +74 -0
  34. package/src/mermaid/remark.ts +98 -0
  35. package/src/mermaid/render.ts +178 -0
  36. package/src/mermaid/styles.ts +24 -0
  37. package/src/mermaid/title.ts +127 -0
  38. package/src/register.ts +3 -0
  39. package/src/remark.ts +1 -1
  40. package/src/sql/DfkSql.css +13 -10
  41. package/src/sql/DfkSql.ts +11 -46
  42. package/src/sql/browserRunner.ts +402 -0
  43. package/src/sql/harness.ts +134 -0
  44. package/src/sql/remark.ts +6 -1
  45. package/src/sql/renderers.ts +24 -3
  46. package/src/sql/runtime.ts +44 -0
  47. package/src/sql/sql.css +13 -11
  48. package/src/sql/verify.ts +23 -41
  49. package/dist/sql/editor.d.ts +0 -16
  50. package/dist/sql/nodeRunner.d.ts +0 -51
  51. package/dist/sql/nodeRunner.js +0 -115
  52. package/src/sql/editor.ts +0 -75
  53. 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
@@ -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: 'iframe' | 'svg',
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 `svg` share everything except how a panel is filled.
870
+ * `Table` tab. `iframe`, `svg` and `mermaid` share everything except how a panel
871
+ * is filled.
852
872
  */
853
- function previewRenderer(kind: 'iframe' | 'svg'): Renderer {
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
  /**
@@ -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');