duckfn-docs-kit 0.4.1 → 0.4.2

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.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duckfn-docs-kit",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared Docusaurus building blocks (TOC toggle, home-page web components, brand tokens, remark version placeholder) for duckfn-family extension docs sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,136 +1,136 @@
1
- /**
2
- * Node-side collector for runnable SQL blocks: walks a docs site's content and
3
- * returns every fenced block whose info string is a runnable config.
4
- *
5
- * The metastring contract belongs to `sql/remark.ts` (one parser, exported as
6
- * `parseRunnableSqlMeta`), so the blocks collected here are exactly the ones
7
- * the build turns into `<dfk-sql>` — a CI check over this list therefore covers
8
- * what the site actually publishes.
9
- *
10
- * Fences follow CommonMark closely enough for a docs tree: a closing fence has
11
- * to use the same character, be at least as long, and carry no info string.
12
- * That is what keeps a ```sql example *inside* a ````md wrapper (as
13
- * `docs-kit/runnable-sql.md` shows the metastring) from being collected as a
14
- * block of its own.
15
- */
16
- import {readFileSync, readdirSync, statSync} from 'node:fs';
17
- import {join, relative, sep} from 'node:path';
18
-
19
- import {parseRunnableSqlMeta, type RunnableSqlConfig} from './remark';
20
-
21
- /** One runnable block, positioned so a failure can name it. */
22
- export interface RunnableSqlBlock {
23
- /** Path relative to the site root, always with forward slashes. */
24
- file: string;
25
- /** 1-based line of the opening fence. */
26
- line: number;
27
- config: RunnableSqlConfig;
28
- sql: string;
29
- }
30
-
31
- export interface CollectRunnableSqlOptions {
32
- /** Site root the reported paths are relative to, and the base of a relative dir. */
33
- siteDir: string;
34
- /**
35
- * Directories to scan (absolute, or relative to `siteDir`). Missing ones are
36
- * skipped rather than reported: a site may have no translations.
37
- */
38
- contentDirs: readonly string[];
39
- /** File extensions to scan; both `.md` and `.mdx` are markdown to us. */
40
- extensions?: readonly string[];
41
- }
42
-
43
- const DEFAULT_EXTENSIONS = ['.md', '.mdx'] as const;
44
-
45
- /** Every runnable block under `contentDirs`, in file order, then line order. */
46
- export function collectRunnableSql(options: CollectRunnableSqlOptions): RunnableSqlBlock[] {
47
- const {siteDir, contentDirs, extensions = DEFAULT_EXTENSIONS} = options;
48
- const files: string[] = [];
49
- for (const dir of contentDirs) {
50
- collectFiles(join(siteDir, dir), extensions, files);
51
- }
52
- const blocks: RunnableSqlBlock[] = [];
53
- for (const file of files.sort()) {
54
- for (const block of blocksOf(readFileSync(file, 'utf8'))) {
55
- blocks.push({
56
- ...block,
57
- file: relative(siteDir, file).split(sep).join('/'),
58
- });
59
- }
60
- }
61
- return blocks;
62
- }
63
-
64
- function collectFiles(dir: string, extensions: readonly string[], out: string[]): void {
65
- let entries: string[];
66
- try {
67
- entries = readdirSync(dir);
68
- } catch {
69
- // Nothing to scan here (typically a translation that does not exist yet).
70
- return;
71
- }
72
- for (const name of entries) {
73
- const path = join(dir, name);
74
- if (statSync(path).isDirectory()) {
75
- collectFiles(path, extensions, out);
76
- } else if (extensions.some((extension) => name.endsWith(extension))) {
77
- out.push(path);
78
- }
79
- }
80
- }
81
-
82
- interface RawBlock {
83
- line: number;
84
- config: RunnableSqlConfig;
85
- sql: string;
86
- }
87
-
88
- /** The runnable blocks of one markdown document. */
89
- function blocksOf(text: string): RawBlock[] {
90
- const lines = text.split('\n');
91
- const out: RawBlock[] = [];
92
- let open: {marker: string; info: string; line: number} | null = null;
93
- let body: string[] = [];
94
-
95
- for (let index = 0; index < lines.length; index++) {
96
- const line = lines[index] ?? '';
97
- const fence = /^(`{3,}|~{3,})(.*)$/.exec(line);
98
- if (!fence) {
99
- if (open) {
100
- body.push(line);
101
- }
102
- continue;
103
- }
104
- const [marker, info] = [fence[1] ?? '', (fence[2] ?? '').trim()];
105
- if (!open) {
106
- open = {marker, info, line: index + 1};
107
- body = [];
108
- continue;
109
- }
110
- const closes =
111
- marker.charAt(0) === open.marker.charAt(0) && marker.length >= open.marker.length && info === '';
112
- if (!closes) {
113
- // A shorter or differently marked fence is content of the open block.
114
- body.push(line);
115
- continue;
116
- }
117
- if (open.info.startsWith('sql')) {
118
- const config = parseRunnableSqlMeta(open.info.slice('sql'.length).trim());
119
- if (config) {
120
- out.push({line: open.line, config, sql: body.join('\n')});
121
- }
122
- }
123
- open = null;
124
- body = [];
125
- }
126
- return out;
127
- }
128
-
129
- /**
130
- * Whether a block is expected to fail — the block's own `"expect": "error"`
131
- * metadata, never its prose or its comments: an expectation that the SQL test
132
- * suite acts on has to be data, not a string match on a comment.
133
- */
134
- export function expectsError(config: RunnableSqlConfig): boolean {
135
- return config.expect === 'error';
136
- }
1
+ /**
2
+ * Node-side collector for runnable SQL blocks: walks a docs site's content and
3
+ * returns every fenced block whose info string is a runnable config.
4
+ *
5
+ * The metastring contract belongs to `sql/remark.ts` (one parser, exported as
6
+ * `parseRunnableSqlMeta`), so the blocks collected here are exactly the ones
7
+ * the build turns into `<dfk-sql>` — a CI check over this list therefore covers
8
+ * what the site actually publishes.
9
+ *
10
+ * Fences follow CommonMark closely enough for a docs tree: a closing fence has
11
+ * to use the same character, be at least as long, and carry no info string.
12
+ * That is what keeps a ```sql example *inside* a ````md wrapper (as
13
+ * `docs-kit/runnable-sql.md` shows the metastring) from being collected as a
14
+ * block of its own.
15
+ */
16
+ import {readFileSync, readdirSync, statSync} from 'node:fs';
17
+ import {join, relative, sep} from 'node:path';
18
+
19
+ import {parseRunnableSqlMeta, type RunnableSqlConfig} from './remark';
20
+
21
+ /** One runnable block, positioned so a failure can name it. */
22
+ export interface RunnableSqlBlock {
23
+ /** Path relative to the site root, always with forward slashes. */
24
+ file: string;
25
+ /** 1-based line of the opening fence. */
26
+ line: number;
27
+ config: RunnableSqlConfig;
28
+ sql: string;
29
+ }
30
+
31
+ export interface CollectRunnableSqlOptions {
32
+ /** Site root the reported paths are relative to, and the base of a relative dir. */
33
+ siteDir: string;
34
+ /**
35
+ * Directories to scan (absolute, or relative to `siteDir`). Missing ones are
36
+ * skipped rather than reported: a site may have no translations.
37
+ */
38
+ contentDirs: readonly string[];
39
+ /** File extensions to scan; both `.md` and `.mdx` are markdown to us. */
40
+ extensions?: readonly string[];
41
+ }
42
+
43
+ const DEFAULT_EXTENSIONS = ['.md', '.mdx'] as const;
44
+
45
+ /** Every runnable block under `contentDirs`, in file order, then line order. */
46
+ export function collectRunnableSql(options: CollectRunnableSqlOptions): RunnableSqlBlock[] {
47
+ const {siteDir, contentDirs, extensions = DEFAULT_EXTENSIONS} = options;
48
+ const files: string[] = [];
49
+ for (const dir of contentDirs) {
50
+ collectFiles(join(siteDir, dir), extensions, files);
51
+ }
52
+ const blocks: RunnableSqlBlock[] = [];
53
+ for (const file of files.sort()) {
54
+ for (const block of blocksOf(readFileSync(file, 'utf8'))) {
55
+ blocks.push({
56
+ ...block,
57
+ file: relative(siteDir, file).split(sep).join('/'),
58
+ });
59
+ }
60
+ }
61
+ return blocks;
62
+ }
63
+
64
+ function collectFiles(dir: string, extensions: readonly string[], out: string[]): void {
65
+ let entries: string[];
66
+ try {
67
+ entries = readdirSync(dir);
68
+ } catch {
69
+ // Nothing to scan here (typically a translation that does not exist yet).
70
+ return;
71
+ }
72
+ for (const name of entries) {
73
+ const path = join(dir, name);
74
+ if (statSync(path).isDirectory()) {
75
+ collectFiles(path, extensions, out);
76
+ } else if (extensions.some((extension) => name.endsWith(extension))) {
77
+ out.push(path);
78
+ }
79
+ }
80
+ }
81
+
82
+ interface RawBlock {
83
+ line: number;
84
+ config: RunnableSqlConfig;
85
+ sql: string;
86
+ }
87
+
88
+ /** The runnable blocks of one markdown document. */
89
+ function blocksOf(text: string): RawBlock[] {
90
+ const lines = text.split('\n');
91
+ const out: RawBlock[] = [];
92
+ let open: {marker: string; info: string; line: number} | null = null;
93
+ let body: string[] = [];
94
+
95
+ for (let index = 0; index < lines.length; index++) {
96
+ const line = lines[index] ?? '';
97
+ const fence = /^(`{3,}|~{3,})(.*)$/.exec(line);
98
+ if (!fence) {
99
+ if (open) {
100
+ body.push(line);
101
+ }
102
+ continue;
103
+ }
104
+ const [marker, info] = [fence[1] ?? '', (fence[2] ?? '').trim()];
105
+ if (!open) {
106
+ open = {marker, info, line: index + 1};
107
+ body = [];
108
+ continue;
109
+ }
110
+ const closes =
111
+ marker.charAt(0) === open.marker.charAt(0) && marker.length >= open.marker.length && info === '';
112
+ if (!closes) {
113
+ // A shorter or differently marked fence is content of the open block.
114
+ body.push(line);
115
+ continue;
116
+ }
117
+ if (open.info.startsWith('sql')) {
118
+ const config = parseRunnableSqlMeta(open.info.slice('sql'.length).trim());
119
+ if (config) {
120
+ out.push({line: open.line, config, sql: body.join('\n')});
121
+ }
122
+ }
123
+ open = null;
124
+ body = [];
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Whether a block is expected to fail — the block's own `"expect": "error"`
131
+ * metadata, never its prose or its comments: an expectation that the SQL test
132
+ * suite acts on has to be data, not a string match on a comment.
133
+ */
134
+ export function expectsError(config: RunnableSqlConfig): boolean {
135
+ return config.expect === 'error';
136
+ }
package/src/sql/verify.ts CHANGED
@@ -1,339 +1,339 @@
1
- /**
2
- * `duckfn-docs-kit/sql/verify` — runs every runnable SQL block a docs site
3
- * publishes, so a broken example is caught by CI instead of by a reader
4
- * clicking **Run**.
5
- *
6
- * It is the same environment the site gives a block: DuckDB-Wasm in a browser,
7
- * the site's extension preloaded, one instance per page and the page's blocks
8
- * sharing a connection (see `sql/browserRunner.ts` for why this is a real
9
- * browser and not the Node worker the kit used to run).
10
- *
11
- * A block may fail *on purpose* — half the guide ends on a statement that
12
- * demonstrates an error. Such a block says so in its own metadata
13
- * (`{"type":"duckfn","expect":"error"}`) and is then **required** to fail: one
14
- * that starts succeeding is reported too, because an expectation the suite acts
15
- * on has to be data rather than a string match on a comment. Only blocks that
16
- * did not behave as declared make the command exit non-zero.
17
- */
18
- import {readdirSync, statSync, writeFileSync} from 'node:fs';
19
- import {join, resolve} from 'node:path';
20
-
21
- import {collectRunnableSql, expectsError, type RunnableSqlBlock} from './collect';
22
- import {BrowserSqlRunner, type WasmPlatform} from './browserRunner';
23
-
24
- export interface VerifyOptions {
25
- /** Docs site root; defaults to the working directory. */
26
- siteDir?: string;
27
- /** Content directories relative to the site root; defaults to the site layout. */
28
- contentDirs?: readonly string[];
29
- /**
30
- * The extension to `LOAD`: a path, or an absolute `http(s)` URL. Defaults to
31
- * the single file under `<siteDir>/static/duckdb-extensions/`.
32
- */
33
- extension?: string;
34
- /** DuckDB-Wasm platform, which must match the extension build. */
35
- platform?: WasmPlatform;
36
- /** Engine wasm override, for pinning a specific DuckDB-Wasm build. */
37
- engine?: string;
38
- /** Per-block timeout in milliseconds; a hang is reported instead of blocking CI. */
39
- timeoutMs?: number;
40
- /**
41
- * The browser executable that runs the blocks. Defaults to a detected
42
- * Chrome/Edge; the `DFK_BROWSER` environment variable is the same override.
43
- */
44
- browser?: string;
45
- /** Write the full result list here as JSON. */
46
- reportFile?: string;
47
- }
48
-
49
- /** What happened to a block, judged against what it declared. */
50
- export type BlockOutcome =
51
- /** Ran, and was expected to run. */
52
- | 'ok'
53
- /** Failed, and declared `"expect": "error"`. */
54
- | 'error-as-expected'
55
- /** Failed, but was expected to run. */
56
- | 'unexpected-error'
57
- /** Ran, but declared `"expect": "error"`. */
58
- | 'unexpected-success';
59
-
60
- export interface BlockResult {
61
- file: string;
62
- line: number;
63
- outcome: BlockOutcome;
64
- detail: string;
65
- }
66
-
67
- export interface VerifyReport {
68
- blocks: BlockResult[];
69
- /** Blocks that behaved as declared: they ran, or they failed as declared. */
70
- asDeclared: BlockResult[];
71
- /** Blocks that did not behave as declared — the ones that should fail CI. */
72
- unexpected: BlockResult[];
73
- }
74
-
75
- const DEFAULT_CONTENT = ['docs', 'i18n'] as const;
76
- const DEFAULT_TIMEOUT_MS = 30_000;
77
-
78
- /** Runs every runnable block of the site and returns the outcome of each. */
79
- export async function verifySqlDocs(options: VerifyOptions = {}): Promise<VerifyReport> {
80
- const siteDir = resolve(options.siteDir ?? process.cwd());
81
- const blocks = collectRunnableSql({
82
- siteDir,
83
- contentDirs: options.contentDirs ?? defaultContentDirs(siteDir),
84
- });
85
- const requested = options.extension;
86
- const extension = requested
87
- ? /^https?:\/\//i.test(requested)
88
- ? requested
89
- : resolve(requested)
90
- : defaultExtension(siteDir);
91
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
-
93
- // No working directory: in a browser DuckDB's file system is the instance's
94
- // own memory, so `COPY … TO` / `dfn_file_write_*` never touch the docs tree —
95
- // they land in the page and vanish on the next `newPage()`.
96
- const results = await runPages(blocks, extension, options, timeoutMs);
97
-
98
- const report: VerifyReport = {
99
- blocks: results,
100
- asDeclared: results.filter((result) => result.outcome === 'ok' || result.outcome === 'error-as-expected'),
101
- unexpected: results.filter(
102
- (result) => result.outcome === 'unexpected-error' || result.outcome === 'unexpected-success',
103
- ),
104
- };
105
- if (options.reportFile) {
106
- writeFileSync(options.reportFile, `${JSON.stringify(report, null, 2)}\n`);
107
- }
108
- return report;
109
- }
110
-
111
- /** Opens one page per content file and runs that file's blocks against it. */
112
- async function runPages(
113
- blocks: readonly RunnableSqlBlock[],
114
- extension: string,
115
- options: VerifyOptions,
116
- timeoutMs: number,
117
- ): Promise<BlockResult[]> {
118
- const runner = await BrowserSqlRunner.create({
119
- extension,
120
- platform: options.platform,
121
- engine: options.engine,
122
- browser: options.browser,
123
- });
124
- const results: BlockResult[] = [];
125
- try {
126
- let currentFile: string | null = null;
127
- for (const block of blocks) {
128
- if (block.file !== currentFile) {
129
- currentFile = block.file;
130
- await runner.newPage();
131
- }
132
- results.push(await runBlock(runner, block, timeoutMs));
133
- }
134
- } finally {
135
- await runner.close();
136
- }
137
- return results;
138
- }
139
-
140
- /** Runs one block and judges the result against the block's own declaration. */
141
- async function runBlock(
142
- runner: BrowserSqlRunner,
143
- block: RunnableSqlBlock,
144
- timeoutMs: number,
145
- ): Promise<BlockResult> {
146
- const declaredError = expectsError(block.config);
147
- try {
148
- const result = await withTimeout(runner.run(block.sql), timeoutMs);
149
- return {
150
- file: block.file,
151
- line: block.line,
152
- outcome: declaredError ? 'unexpected-success' : 'ok',
153
- detail: declaredError
154
- ? `declared "expect": "error" but succeeded (${result.rows}×${result.columns})`
155
- : `ok (${result.rows}×${result.columns})`,
156
- };
157
- } catch (error) {
158
- const message = String((error as Error)?.message ?? error).split('\n')[0] ?? '';
159
- return {
160
- file: block.file,
161
- line: block.line,
162
- outcome: declaredError ? 'error-as-expected' : 'unexpected-error',
163
- detail: message,
164
- };
165
- }
166
- }
167
-
168
- function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
169
- let timer: NodeJS.Timeout | undefined;
170
- const timeout = new Promise<never>((_resolve, reject) => {
171
- timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
172
- });
173
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
174
- }
175
-
176
- /**
177
- * Where a Docusaurus site keeps its pages: the English sources in `docs/`, and
178
- * each translation under `i18n/<locale>/docusaurus-plugin-content-docs/current/`.
179
- */
180
- function defaultContentDirs(siteDir: string): string[] {
181
- const dirs: string[] = [];
182
- if (isDirectory(join(siteDir, DEFAULT_CONTENT[0]))) {
183
- dirs.push(DEFAULT_CONTENT[0]);
184
- }
185
- const i18n = join(siteDir, DEFAULT_CONTENT[1]);
186
- if (isDirectory(i18n)) {
187
- for (const locale of readdirSync(i18n)) {
188
- const translated = join(i18n, locale, 'docusaurus-plugin-content-docs', 'current');
189
- if (isDirectory(translated)) {
190
- dirs.push(join('i18n', locale, 'docusaurus-plugin-content-docs', 'current'));
191
- }
192
- }
193
- }
194
- return dirs.length > 0 ? dirs : ['.'];
195
- }
196
-
197
- /** The site's preloaded extension, as `dfkExtensions` places it under `static/`. */
198
- function defaultExtension(siteDir: string): string {
199
- const dir = join(siteDir, 'static', 'duckdb-extensions');
200
- const candidates = isDirectory(dir)
201
- ? readdirSync(dir).filter((name) => name.endsWith('.duckdb_extension.wasm'))
202
- : [];
203
- if (candidates.length === 0) {
204
- throw new Error(
205
- `sql/verify: no extension found in ${dir} — pass --extension <file|url>, or let the site's ` +
206
- `extension preload plugin fetch it first`,
207
- );
208
- }
209
- if (candidates.length > 1) {
210
- throw new Error(
211
- `sql/verify: several extensions found in ${dir} (${candidates.join(', ')}) — ` +
212
- `pass --extension to pick one`,
213
- );
214
- }
215
- return join(dir, candidates[0] as string);
216
- }
217
-
218
- function isDirectory(path: string): boolean {
219
- return statSync(path, {throwIfNoEntry: false})?.isDirectory() ?? false;
220
- }
221
-
222
- export interface CliOptions extends VerifyOptions {
223
- quiet?: boolean;
224
- }
225
-
226
- /**
227
- * `duckfn-sql-verify` — the command line around {@link verifySqlDocs}.
228
- *
229
- * Exit code 1 when a block did not behave as it declared, so a docs site can
230
- * wire it straight into `npm test`.
231
- */
232
- export async function cliMain(argv: readonly string[]): Promise<void> {
233
- const options = parseArgs(argv);
234
- if (options.help) {
235
- process.stdout.write(USAGE);
236
- return;
237
- }
238
- const started = Date.now();
239
- const report = await verifySqlDocs(options);
240
- const seconds = ((Date.now() - started) / 1000).toFixed(1);
241
- const declaredFailures = report.blocks.filter(
242
- (result) => result.outcome === 'error-as-expected',
243
- ).length;
244
- if (!options.quiet) {
245
- process.stdout.write(`\n${report.blocks.length} block(s) in ${seconds}s\n`);
246
- process.stdout.write(
247
- ` ${report.asDeclared.length} as declared (${declaredFailures} erroring on purpose), ` +
248
- `${report.unexpected.length} unexpected\n`,
249
- );
250
- }
251
- if (report.unexpected.length > 0) {
252
- process.stdout.write('unexpected behaviour:\n');
253
- for (const block of report.unexpected) {
254
- process.stdout.write(`- ${block.file}:${block.line} [${block.outcome}] :: ${block.detail}\n`);
255
- }
256
- process.exitCode = 1;
257
- }
258
- }
259
-
260
- const USAGE = `Usage: duckfn-sql-verify [options]
261
-
262
- Runs every runnable SQL block of a duckfn docs site in a headless browser
263
- (DuckDB-Wasm), and checks that each one behaves as its own metadata declares
264
- ("expect": "error" for a block that demonstrates a failure).
265
-
266
- --site <dir> Docs site root (default: the working directory)
267
- --content <dir> Content directory, relative to the site root (repeatable;
268
- default: docs/ plus every i18n/<locale>/… translation)
269
- --extension <path> The extension to preload: a .duckdb_extension.wasm path,
270
- or an absolute http(s) URL (default: the single file under
271
- static/duckdb-extensions/)
272
- --platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build
273
- (default: eh)
274
- --engine <path> Engine wasm override
275
- --browser <path> Browser executable (default: a detected Chrome/Edge,
276
- or the DFK_BROWSER environment variable)
277
- --timeout <ms> Per-block timeout (default: 30000)
278
- --report <file> Write the full result list as JSON
279
- --quiet Only report unexpected behaviour
280
- --help Show this help
281
- `;
282
-
283
- interface ParsedArgs extends CliOptions {
284
- help?: boolean;
285
- }
286
-
287
- function parseArgs(argv: readonly string[]): ParsedArgs {
288
- const options: ParsedArgs = {};
289
- const content: string[] = [];
290
- for (let index = 0; index < argv.length; index++) {
291
- const arg = argv[index];
292
- const next = (): string => {
293
- const value = argv[++index];
294
- if (value === undefined) {
295
- throw new Error(`sql/verify: ${arg} needs a value`);
296
- }
297
- return value;
298
- };
299
- switch (arg) {
300
- case '--site':
301
- options.siteDir = next();
302
- break;
303
- case '--content':
304
- content.push(next());
305
- break;
306
- case '--extension':
307
- options.extension = next();
308
- break;
309
- case '--platform':
310
- options.platform = next() as WasmPlatform;
311
- break;
312
- case '--engine':
313
- options.engine = next();
314
- break;
315
- case '--browser':
316
- options.browser = next();
317
- break;
318
- case '--timeout':
319
- options.timeoutMs = Number(next());
320
- break;
321
- case '--report':
322
- options.reportFile = next();
323
- break;
324
- case '--quiet':
325
- options.quiet = true;
326
- break;
327
- case '--help':
328
- case '-h':
329
- options.help = true;
330
- break;
331
- default:
332
- throw new Error(`sql/verify: unknown option ${arg}`);
333
- }
334
- }
335
- if (content.length > 0) {
336
- options.contentDirs = content;
337
- }
338
- return options;
339
- }
1
+ /**
2
+ * `duckfn-docs-kit/sql/verify` — runs every runnable SQL block a docs site
3
+ * publishes, so a broken example is caught by CI instead of by a reader
4
+ * clicking **Run**.
5
+ *
6
+ * It is the same environment the site gives a block: DuckDB-Wasm in a browser,
7
+ * the site's extension preloaded, one instance per page and the page's blocks
8
+ * sharing a connection (see `sql/browserRunner.ts` for why this is a real
9
+ * browser and not the Node worker the kit used to run).
10
+ *
11
+ * A block may fail *on purpose* — half the guide ends on a statement that
12
+ * demonstrates an error. Such a block says so in its own metadata
13
+ * (`{"type":"duckfn","expect":"error"}`) and is then **required** to fail: one
14
+ * that starts succeeding is reported too, because an expectation the suite acts
15
+ * on has to be data rather than a string match on a comment. Only blocks that
16
+ * did not behave as declared make the command exit non-zero.
17
+ */
18
+ import {readdirSync, statSync, writeFileSync} from 'node:fs';
19
+ import {join, resolve} from 'node:path';
20
+
21
+ import {collectRunnableSql, expectsError, type RunnableSqlBlock} from './collect';
22
+ import {BrowserSqlRunner, type WasmPlatform} from './browserRunner';
23
+
24
+ export interface VerifyOptions {
25
+ /** Docs site root; defaults to the working directory. */
26
+ siteDir?: string;
27
+ /** Content directories relative to the site root; defaults to the site layout. */
28
+ contentDirs?: readonly string[];
29
+ /**
30
+ * The extension to `LOAD`: a path, or an absolute `http(s)` URL. Defaults to
31
+ * the single file under `<siteDir>/static/duckdb-extensions/`.
32
+ */
33
+ extension?: string;
34
+ /** DuckDB-Wasm platform, which must match the extension build. */
35
+ platform?: WasmPlatform;
36
+ /** Engine wasm override, for pinning a specific DuckDB-Wasm build. */
37
+ engine?: string;
38
+ /** Per-block timeout in milliseconds; a hang is reported instead of blocking CI. */
39
+ timeoutMs?: number;
40
+ /**
41
+ * The browser executable that runs the blocks. Defaults to a detected
42
+ * Chrome/Edge; the `DFK_BROWSER` environment variable is the same override.
43
+ */
44
+ browser?: string;
45
+ /** Write the full result list here as JSON. */
46
+ reportFile?: string;
47
+ }
48
+
49
+ /** What happened to a block, judged against what it declared. */
50
+ export type BlockOutcome =
51
+ /** Ran, and was expected to run. */
52
+ | 'ok'
53
+ /** Failed, and declared `"expect": "error"`. */
54
+ | 'error-as-expected'
55
+ /** Failed, but was expected to run. */
56
+ | 'unexpected-error'
57
+ /** Ran, but declared `"expect": "error"`. */
58
+ | 'unexpected-success';
59
+
60
+ export interface BlockResult {
61
+ file: string;
62
+ line: number;
63
+ outcome: BlockOutcome;
64
+ detail: string;
65
+ }
66
+
67
+ export interface VerifyReport {
68
+ blocks: BlockResult[];
69
+ /** Blocks that behaved as declared: they ran, or they failed as declared. */
70
+ asDeclared: BlockResult[];
71
+ /** Blocks that did not behave as declared — the ones that should fail CI. */
72
+ unexpected: BlockResult[];
73
+ }
74
+
75
+ const DEFAULT_CONTENT = ['docs', 'i18n'] as const;
76
+ const DEFAULT_TIMEOUT_MS = 30_000;
77
+
78
+ /** Runs every runnable block of the site and returns the outcome of each. */
79
+ export async function verifySqlDocs(options: VerifyOptions = {}): Promise<VerifyReport> {
80
+ const siteDir = resolve(options.siteDir ?? process.cwd());
81
+ const blocks = collectRunnableSql({
82
+ siteDir,
83
+ contentDirs: options.contentDirs ?? defaultContentDirs(siteDir),
84
+ });
85
+ const requested = options.extension;
86
+ const extension = requested
87
+ ? /^https?:\/\//i.test(requested)
88
+ ? requested
89
+ : resolve(requested)
90
+ : defaultExtension(siteDir);
91
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
+
93
+ // No working directory: in a browser DuckDB's file system is the instance's
94
+ // own memory, so `COPY … TO` / `dfn_file_write_*` never touch the docs tree —
95
+ // they land in the page and vanish on the next `newPage()`.
96
+ const results = await runPages(blocks, extension, options, timeoutMs);
97
+
98
+ const report: VerifyReport = {
99
+ blocks: results,
100
+ asDeclared: results.filter((result) => result.outcome === 'ok' || result.outcome === 'error-as-expected'),
101
+ unexpected: results.filter(
102
+ (result) => result.outcome === 'unexpected-error' || result.outcome === 'unexpected-success',
103
+ ),
104
+ };
105
+ if (options.reportFile) {
106
+ writeFileSync(options.reportFile, `${JSON.stringify(report, null, 2)}\n`);
107
+ }
108
+ return report;
109
+ }
110
+
111
+ /** Opens one page per content file and runs that file's blocks against it. */
112
+ async function runPages(
113
+ blocks: readonly RunnableSqlBlock[],
114
+ extension: string,
115
+ options: VerifyOptions,
116
+ timeoutMs: number,
117
+ ): Promise<BlockResult[]> {
118
+ const runner = await BrowserSqlRunner.create({
119
+ extension,
120
+ platform: options.platform,
121
+ engine: options.engine,
122
+ browser: options.browser,
123
+ });
124
+ const results: BlockResult[] = [];
125
+ try {
126
+ let currentFile: string | null = null;
127
+ for (const block of blocks) {
128
+ if (block.file !== currentFile) {
129
+ currentFile = block.file;
130
+ await runner.newPage();
131
+ }
132
+ results.push(await runBlock(runner, block, timeoutMs));
133
+ }
134
+ } finally {
135
+ await runner.close();
136
+ }
137
+ return results;
138
+ }
139
+
140
+ /** Runs one block and judges the result against the block's own declaration. */
141
+ async function runBlock(
142
+ runner: BrowserSqlRunner,
143
+ block: RunnableSqlBlock,
144
+ timeoutMs: number,
145
+ ): Promise<BlockResult> {
146
+ const declaredError = expectsError(block.config);
147
+ try {
148
+ const result = await withTimeout(runner.run(block.sql), timeoutMs);
149
+ return {
150
+ file: block.file,
151
+ line: block.line,
152
+ outcome: declaredError ? 'unexpected-success' : 'ok',
153
+ detail: declaredError
154
+ ? `declared "expect": "error" but succeeded (${result.rows}×${result.columns})`
155
+ : `ok (${result.rows}×${result.columns})`,
156
+ };
157
+ } catch (error) {
158
+ const message = String((error as Error)?.message ?? error).split('\n')[0] ?? '';
159
+ return {
160
+ file: block.file,
161
+ line: block.line,
162
+ outcome: declaredError ? 'error-as-expected' : 'unexpected-error',
163
+ detail: message,
164
+ };
165
+ }
166
+ }
167
+
168
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
169
+ let timer: NodeJS.Timeout | undefined;
170
+ const timeout = new Promise<never>((_resolve, reject) => {
171
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
172
+ });
173
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
174
+ }
175
+
176
+ /**
177
+ * Where a Docusaurus site keeps its pages: the English sources in `docs/`, and
178
+ * each translation under `i18n/<locale>/docusaurus-plugin-content-docs/current/`.
179
+ */
180
+ function defaultContentDirs(siteDir: string): string[] {
181
+ const dirs: string[] = [];
182
+ if (isDirectory(join(siteDir, DEFAULT_CONTENT[0]))) {
183
+ dirs.push(DEFAULT_CONTENT[0]);
184
+ }
185
+ const i18n = join(siteDir, DEFAULT_CONTENT[1]);
186
+ if (isDirectory(i18n)) {
187
+ for (const locale of readdirSync(i18n)) {
188
+ const translated = join(i18n, locale, 'docusaurus-plugin-content-docs', 'current');
189
+ if (isDirectory(translated)) {
190
+ dirs.push(join('i18n', locale, 'docusaurus-plugin-content-docs', 'current'));
191
+ }
192
+ }
193
+ }
194
+ return dirs.length > 0 ? dirs : ['.'];
195
+ }
196
+
197
+ /** The site's preloaded extension, as `dfkExtensions` places it under `static/`. */
198
+ function defaultExtension(siteDir: string): string {
199
+ const dir = join(siteDir, 'static', 'duckdb-extensions');
200
+ const candidates = isDirectory(dir)
201
+ ? readdirSync(dir).filter((name) => name.endsWith('.duckdb_extension.wasm'))
202
+ : [];
203
+ if (candidates.length === 0) {
204
+ throw new Error(
205
+ `sql/verify: no extension found in ${dir} — pass --extension <file|url>, or let the site's ` +
206
+ `extension preload plugin fetch it first`,
207
+ );
208
+ }
209
+ if (candidates.length > 1) {
210
+ throw new Error(
211
+ `sql/verify: several extensions found in ${dir} (${candidates.join(', ')}) — ` +
212
+ `pass --extension to pick one`,
213
+ );
214
+ }
215
+ return join(dir, candidates[0] as string);
216
+ }
217
+
218
+ function isDirectory(path: string): boolean {
219
+ return statSync(path, {throwIfNoEntry: false})?.isDirectory() ?? false;
220
+ }
221
+
222
+ export interface CliOptions extends VerifyOptions {
223
+ quiet?: boolean;
224
+ }
225
+
226
+ /**
227
+ * `duckfn-sql-verify` — the command line around {@link verifySqlDocs}.
228
+ *
229
+ * Exit code 1 when a block did not behave as it declared, so a docs site can
230
+ * wire it straight into `npm test`.
231
+ */
232
+ export async function cliMain(argv: readonly string[]): Promise<void> {
233
+ const options = parseArgs(argv);
234
+ if (options.help) {
235
+ process.stdout.write(USAGE);
236
+ return;
237
+ }
238
+ const started = Date.now();
239
+ const report = await verifySqlDocs(options);
240
+ const seconds = ((Date.now() - started) / 1000).toFixed(1);
241
+ const declaredFailures = report.blocks.filter(
242
+ (result) => result.outcome === 'error-as-expected',
243
+ ).length;
244
+ if (!options.quiet) {
245
+ process.stdout.write(`\n${report.blocks.length} block(s) in ${seconds}s\n`);
246
+ process.stdout.write(
247
+ ` ${report.asDeclared.length} as declared (${declaredFailures} erroring on purpose), ` +
248
+ `${report.unexpected.length} unexpected\n`,
249
+ );
250
+ }
251
+ if (report.unexpected.length > 0) {
252
+ process.stdout.write('unexpected behaviour:\n');
253
+ for (const block of report.unexpected) {
254
+ process.stdout.write(`- ${block.file}:${block.line} [${block.outcome}] :: ${block.detail}\n`);
255
+ }
256
+ process.exitCode = 1;
257
+ }
258
+ }
259
+
260
+ const USAGE = `Usage: duckfn-sql-verify [options]
261
+
262
+ Runs every runnable SQL block of a duckfn docs site in a headless browser
263
+ (DuckDB-Wasm), and checks that each one behaves as its own metadata declares
264
+ ("expect": "error" for a block that demonstrates a failure).
265
+
266
+ --site <dir> Docs site root (default: the working directory)
267
+ --content <dir> Content directory, relative to the site root (repeatable;
268
+ default: docs/ plus every i18n/<locale>/… translation)
269
+ --extension <path> The extension to preload: a .duckdb_extension.wasm path,
270
+ or an absolute http(s) URL (default: the single file under
271
+ static/duckdb-extensions/)
272
+ --platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build
273
+ (default: eh)
274
+ --engine <path> Engine wasm override
275
+ --browser <path> Browser executable (default: a detected Chrome/Edge,
276
+ or the DFK_BROWSER environment variable)
277
+ --timeout <ms> Per-block timeout (default: 30000)
278
+ --report <file> Write the full result list as JSON
279
+ --quiet Only report unexpected behaviour
280
+ --help Show this help
281
+ `;
282
+
283
+ interface ParsedArgs extends CliOptions {
284
+ help?: boolean;
285
+ }
286
+
287
+ function parseArgs(argv: readonly string[]): ParsedArgs {
288
+ const options: ParsedArgs = {};
289
+ const content: string[] = [];
290
+ for (let index = 0; index < argv.length; index++) {
291
+ const arg = argv[index];
292
+ const next = (): string => {
293
+ const value = argv[++index];
294
+ if (value === undefined) {
295
+ throw new Error(`sql/verify: ${arg} needs a value`);
296
+ }
297
+ return value;
298
+ };
299
+ switch (arg) {
300
+ case '--site':
301
+ options.siteDir = next();
302
+ break;
303
+ case '--content':
304
+ content.push(next());
305
+ break;
306
+ case '--extension':
307
+ options.extension = next();
308
+ break;
309
+ case '--platform':
310
+ options.platform = next() as WasmPlatform;
311
+ break;
312
+ case '--engine':
313
+ options.engine = next();
314
+ break;
315
+ case '--browser':
316
+ options.browser = next();
317
+ break;
318
+ case '--timeout':
319
+ options.timeoutMs = Number(next());
320
+ break;
321
+ case '--report':
322
+ options.reportFile = next();
323
+ break;
324
+ case '--quiet':
325
+ options.quiet = true;
326
+ break;
327
+ case '--help':
328
+ case '-h':
329
+ options.help = true;
330
+ break;
331
+ default:
332
+ throw new Error(`sql/verify: unknown option ${arg}`);
333
+ }
334
+ }
335
+ if (content.length > 0) {
336
+ options.contentDirs = content;
337
+ }
338
+ return options;
339
+ }