duckfn-docs-kit 0.1.0 → 0.2.1
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 +224 -689
- package/README.md +7 -5
- package/bin/sql-verify.mjs +12 -0
- package/dist/remark.d.ts +1 -1
- package/dist/sql/collect.d.ts +29 -0
- package/dist/sql/collect.js +65 -0
- package/dist/sql/nodeRunner.d.ts +51 -0
- package/dist/sql/nodeRunner.js +115 -0
- package/dist/sql/remark.d.ts +20 -0
- package/dist/sql/remark.js +1 -1
- package/dist/sql/verify.d.ts +61 -0
- package/dist/sql/verify.js +148 -0
- package/package.json +5 -1
- package/src/remark.ts +1 -1
- package/src/sql/DfkSql.ts +2 -2
- package/src/sql/collect.ts +136 -0
- package/src/sql/nodeRunner.ts +298 -0
- package/src/sql/remark.ts +21 -3
- package/src/sql/sql.css +1 -1
- package/src/sql/verify.ts +357 -0
- package/src/toc-toggle/TocToggle.css +28 -0
|
@@ -0,0 +1,357 @@
|
|
|
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 worker,
|
|
7
|
+
* the site's extension preloaded, one instance per page and the page's blocks
|
|
8
|
+
* sharing a connection (see `sql/nodeRunner.ts` for why it is this target and
|
|
9
|
+
* not the blocking one).
|
|
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 {mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
|
19
|
+
import {tmpdir} from 'node:os';
|
|
20
|
+
import {join, resolve} from 'node:path';
|
|
21
|
+
|
|
22
|
+
import {collectRunnableSql, expectsError, type RunnableSqlBlock} from './collect';
|
|
23
|
+
import {WasmSqlRunner, type WasmPlatform} from './nodeRunner';
|
|
24
|
+
|
|
25
|
+
export interface VerifyOptions {
|
|
26
|
+
/** Docs site root; defaults to the working directory. */
|
|
27
|
+
siteDir?: string;
|
|
28
|
+
/** Content directories relative to the site root; defaults to the site layout. */
|
|
29
|
+
contentDirs?: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* The extension to `LOAD`: a path, or an absolute `http(s)` URL. Defaults to
|
|
32
|
+
* the single file under `<siteDir>/static/duckdb-extensions/`.
|
|
33
|
+
*/
|
|
34
|
+
extension?: string;
|
|
35
|
+
/** DuckDB-Wasm platform, which must match the extension build. */
|
|
36
|
+
platform?: WasmPlatform;
|
|
37
|
+
/** Engine wasm override, for pinning a specific DuckDB-Wasm build. */
|
|
38
|
+
engine?: string;
|
|
39
|
+
/** Per-block timeout in milliseconds; a hang is reported instead of blocking CI. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Directory the blocks run in. Defaults to a fresh temporary directory that
|
|
43
|
+
* is removed afterwards: a block may `COPY … TO 'a.csv'`, and on Node
|
|
44
|
+
* DuckDB's file system is the real one, relative to the working directory.
|
|
45
|
+
*/
|
|
46
|
+
workingDir?: string;
|
|
47
|
+
/** Write the full result list here as JSON. */
|
|
48
|
+
reportFile?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What happened to a block, judged against what it declared. */
|
|
52
|
+
export type BlockOutcome =
|
|
53
|
+
/** Ran, and was expected to run. */
|
|
54
|
+
| 'ok'
|
|
55
|
+
/** Failed, and declared `"expect": "error"`. */
|
|
56
|
+
| 'error-as-expected'
|
|
57
|
+
/** Failed, but was expected to run. */
|
|
58
|
+
| 'unexpected-error'
|
|
59
|
+
/** Ran, but declared `"expect": "error"`. */
|
|
60
|
+
| 'unexpected-success';
|
|
61
|
+
|
|
62
|
+
export interface BlockResult {
|
|
63
|
+
file: string;
|
|
64
|
+
line: number;
|
|
65
|
+
outcome: BlockOutcome;
|
|
66
|
+
detail: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VerifyReport {
|
|
70
|
+
blocks: BlockResult[];
|
|
71
|
+
/** Blocks that behaved as declared: they ran, or they failed as declared. */
|
|
72
|
+
asDeclared: BlockResult[];
|
|
73
|
+
/** Blocks that did not behave as declared — the ones that should fail CI. */
|
|
74
|
+
unexpected: BlockResult[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DEFAULT_CONTENT = ['docs', 'i18n'] as const;
|
|
78
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
79
|
+
|
|
80
|
+
/** Runs every runnable block of the site and returns the outcome of each. */
|
|
81
|
+
export async function verifySqlDocs(options: VerifyOptions = {}): Promise<VerifyReport> {
|
|
82
|
+
// Everything is resolved to absolute paths first: the run changes the working
|
|
83
|
+
// directory (see below).
|
|
84
|
+
const siteDir = resolve(options.siteDir ?? process.cwd());
|
|
85
|
+
const blocks = collectRunnableSql({
|
|
86
|
+
siteDir,
|
|
87
|
+
contentDirs: options.contentDirs ?? defaultContentDirs(siteDir),
|
|
88
|
+
});
|
|
89
|
+
const requested = options.extension;
|
|
90
|
+
const extension = requested
|
|
91
|
+
? /^https?:\/\//i.test(requested)
|
|
92
|
+
? requested
|
|
93
|
+
: resolve(requested)
|
|
94
|
+
: defaultExtension(siteDir);
|
|
95
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
96
|
+
|
|
97
|
+
// A block may write files — `COPY (SELECT 1) TO 'a.csv'` — and on Node the
|
|
98
|
+
// file system behind DuckDB is the real one, resolved against the working
|
|
99
|
+
// directory. Giving the run a scratch directory of its own keeps the docs tree
|
|
100
|
+
// clean instead of dropping test residue into it.
|
|
101
|
+
const scratch = options.workingDir
|
|
102
|
+
? resolve(options.workingDir)
|
|
103
|
+
: mkdtempSync(join(tmpdir(), 'duckfn-sql-verify-'));
|
|
104
|
+
mkdirSync(scratch, {recursive: true});
|
|
105
|
+
const previousCwd = process.cwd();
|
|
106
|
+
process.chdir(scratch);
|
|
107
|
+
|
|
108
|
+
let results: BlockResult[];
|
|
109
|
+
try {
|
|
110
|
+
results = await runPages(blocks, extension, options, timeoutMs);
|
|
111
|
+
} finally {
|
|
112
|
+
process.chdir(previousCwd);
|
|
113
|
+
if (!options.workingDir) {
|
|
114
|
+
rmSync(scratch, {recursive: true, force: true});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const report: VerifyReport = {
|
|
119
|
+
blocks: results,
|
|
120
|
+
asDeclared: results.filter((result) => result.outcome === 'ok' || result.outcome === 'error-as-expected'),
|
|
121
|
+
unexpected: results.filter(
|
|
122
|
+
(result) => result.outcome === 'unexpected-error' || result.outcome === 'unexpected-success',
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
if (options.reportFile) {
|
|
126
|
+
writeFileSync(options.reportFile, `${JSON.stringify(report, null, 2)}\n`);
|
|
127
|
+
}
|
|
128
|
+
return report;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Opens one page per content file and runs that file's blocks against it. */
|
|
132
|
+
async function runPages(
|
|
133
|
+
blocks: readonly RunnableSqlBlock[],
|
|
134
|
+
extension: string,
|
|
135
|
+
options: VerifyOptions,
|
|
136
|
+
timeoutMs: number,
|
|
137
|
+
): Promise<BlockResult[]> {
|
|
138
|
+
const runner = await WasmSqlRunner.create({
|
|
139
|
+
extension,
|
|
140
|
+
platform: options.platform,
|
|
141
|
+
engine: options.engine,
|
|
142
|
+
});
|
|
143
|
+
const results: BlockResult[] = [];
|
|
144
|
+
try {
|
|
145
|
+
let currentFile: string | null = null;
|
|
146
|
+
for (const block of blocks) {
|
|
147
|
+
if (block.file !== currentFile) {
|
|
148
|
+
currentFile = block.file;
|
|
149
|
+
await runner.newPage();
|
|
150
|
+
}
|
|
151
|
+
results.push(await runBlock(runner, block, timeoutMs));
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
await runner.close();
|
|
155
|
+
}
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Runs one block and judges the result against the block's own declaration. */
|
|
160
|
+
async function runBlock(
|
|
161
|
+
runner: WasmSqlRunner,
|
|
162
|
+
block: RunnableSqlBlock,
|
|
163
|
+
timeoutMs: number,
|
|
164
|
+
): Promise<BlockResult> {
|
|
165
|
+
const declaredError = expectsError(block.config);
|
|
166
|
+
try {
|
|
167
|
+
const result = await withTimeout(runner.run(block.sql), timeoutMs);
|
|
168
|
+
return {
|
|
169
|
+
file: block.file,
|
|
170
|
+
line: block.line,
|
|
171
|
+
outcome: declaredError ? 'unexpected-success' : 'ok',
|
|
172
|
+
detail: declaredError
|
|
173
|
+
? `declared "expect": "error" but succeeded (${result.rows}×${result.columns})`
|
|
174
|
+
: `ok (${result.rows}×${result.columns})`,
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const message = String((error as Error)?.message ?? error).split('\n')[0] ?? '';
|
|
178
|
+
return {
|
|
179
|
+
file: block.file,
|
|
180
|
+
line: block.line,
|
|
181
|
+
outcome: declaredError ? 'error-as-expected' : 'unexpected-error',
|
|
182
|
+
detail: message,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
188
|
+
let timer: NodeJS.Timeout | undefined;
|
|
189
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
190
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
191
|
+
});
|
|
192
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Where a Docusaurus site keeps its pages: the English sources in `docs/`, and
|
|
197
|
+
* each translation under `i18n/<locale>/docusaurus-plugin-content-docs/current/`.
|
|
198
|
+
*/
|
|
199
|
+
function defaultContentDirs(siteDir: string): string[] {
|
|
200
|
+
const dirs: string[] = [];
|
|
201
|
+
if (isDirectory(join(siteDir, DEFAULT_CONTENT[0]))) {
|
|
202
|
+
dirs.push(DEFAULT_CONTENT[0]);
|
|
203
|
+
}
|
|
204
|
+
const i18n = join(siteDir, DEFAULT_CONTENT[1]);
|
|
205
|
+
if (isDirectory(i18n)) {
|
|
206
|
+
for (const locale of readdirSync(i18n)) {
|
|
207
|
+
const translated = join(i18n, locale, 'docusaurus-plugin-content-docs', 'current');
|
|
208
|
+
if (isDirectory(translated)) {
|
|
209
|
+
dirs.push(join('i18n', locale, 'docusaurus-plugin-content-docs', 'current'));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return dirs.length > 0 ? dirs : ['.'];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The site's preloaded extension, as `dfkExtensions` places it under `static/`. */
|
|
217
|
+
function defaultExtension(siteDir: string): string {
|
|
218
|
+
const dir = join(siteDir, 'static', 'duckdb-extensions');
|
|
219
|
+
const candidates = isDirectory(dir)
|
|
220
|
+
? readdirSync(dir).filter((name) => name.endsWith('.duckdb_extension.wasm'))
|
|
221
|
+
: [];
|
|
222
|
+
if (candidates.length === 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`sql/verify: no extension found in ${dir} — pass --extension <file|url>, or let the site's ` +
|
|
225
|
+
`extension preload plugin fetch it first`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (candidates.length > 1) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`sql/verify: several extensions found in ${dir} (${candidates.join(', ')}) — ` +
|
|
231
|
+
`pass --extension to pick one`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return join(dir, candidates[0] as string);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isDirectory(path: string): boolean {
|
|
238
|
+
return statSync(path, {throwIfNoEntry: false})?.isDirectory() ?? false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface CliOptions extends VerifyOptions {
|
|
242
|
+
quiet?: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `duckfn-sql-verify` — the command line around {@link verifySqlDocs}.
|
|
247
|
+
*
|
|
248
|
+
* Exit code 1 when a block did not behave as it declared, so a docs site can
|
|
249
|
+
* wire it straight into `npm test`.
|
|
250
|
+
*/
|
|
251
|
+
export async function cliMain(argv: readonly string[]): Promise<void> {
|
|
252
|
+
const options = parseArgs(argv);
|
|
253
|
+
if (options.help) {
|
|
254
|
+
process.stdout.write(USAGE);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const started = Date.now();
|
|
258
|
+
const report = await verifySqlDocs(options);
|
|
259
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
260
|
+
const declaredFailures = report.blocks.filter(
|
|
261
|
+
(result) => result.outcome === 'error-as-expected',
|
|
262
|
+
).length;
|
|
263
|
+
if (!options.quiet) {
|
|
264
|
+
process.stdout.write(`\n${report.blocks.length} block(s) in ${seconds}s\n`);
|
|
265
|
+
process.stdout.write(
|
|
266
|
+
` ${report.asDeclared.length} as declared (${declaredFailures} erroring on purpose), ` +
|
|
267
|
+
`${report.unexpected.length} unexpected\n`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (report.unexpected.length > 0) {
|
|
271
|
+
process.stdout.write('unexpected behaviour:\n');
|
|
272
|
+
for (const block of report.unexpected) {
|
|
273
|
+
process.stdout.write(`- ${block.file}:${block.line} [${block.outcome}] :: ${block.detail}\n`);
|
|
274
|
+
}
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const USAGE = `Usage: duckfn-sql-verify [options]
|
|
280
|
+
|
|
281
|
+
Runs every runnable SQL block of a duckfn docs site in DuckDB-Wasm, and checks
|
|
282
|
+
that each one behaves as its own metadata declares ("expect": "error" for a
|
|
283
|
+
block that demonstrates a failure).
|
|
284
|
+
|
|
285
|
+
--site <dir> Docs site root (default: the working directory)
|
|
286
|
+
--content <dir> Content directory, relative to the site root (repeatable;
|
|
287
|
+
default: docs/ plus every i18n/<locale>/… translation)
|
|
288
|
+
--extension <path> The extension to preload: a .duckdb_extension.wasm path,
|
|
289
|
+
or an absolute http(s) URL (default: the single file under
|
|
290
|
+
static/duckdb-extensions/)
|
|
291
|
+
--platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build
|
|
292
|
+
(default: eh)
|
|
293
|
+
--engine <path> Engine wasm override
|
|
294
|
+
--timeout <ms> Per-block timeout (default: 30000)
|
|
295
|
+
--working-dir <dir> Directory the blocks run in (default: a temporary one)
|
|
296
|
+
--report <file> Write the full result list as JSON
|
|
297
|
+
--quiet Only report unexpected behaviour
|
|
298
|
+
--help Show this help
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
interface ParsedArgs extends CliOptions {
|
|
302
|
+
help?: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
306
|
+
const options: ParsedArgs = {};
|
|
307
|
+
const content: string[] = [];
|
|
308
|
+
for (let index = 0; index < argv.length; index++) {
|
|
309
|
+
const arg = argv[index];
|
|
310
|
+
const next = (): string => {
|
|
311
|
+
const value = argv[++index];
|
|
312
|
+
if (value === undefined) {
|
|
313
|
+
throw new Error(`sql/verify: ${arg} needs a value`);
|
|
314
|
+
}
|
|
315
|
+
return value;
|
|
316
|
+
};
|
|
317
|
+
switch (arg) {
|
|
318
|
+
case '--site':
|
|
319
|
+
options.siteDir = next();
|
|
320
|
+
break;
|
|
321
|
+
case '--content':
|
|
322
|
+
content.push(next());
|
|
323
|
+
break;
|
|
324
|
+
case '--extension':
|
|
325
|
+
options.extension = next();
|
|
326
|
+
break;
|
|
327
|
+
case '--platform':
|
|
328
|
+
options.platform = next() as WasmPlatform;
|
|
329
|
+
break;
|
|
330
|
+
case '--engine':
|
|
331
|
+
options.engine = next();
|
|
332
|
+
break;
|
|
333
|
+
case '--timeout':
|
|
334
|
+
options.timeoutMs = Number(next());
|
|
335
|
+
break;
|
|
336
|
+
case '--working-dir':
|
|
337
|
+
options.workingDir = next();
|
|
338
|
+
break;
|
|
339
|
+
case '--report':
|
|
340
|
+
options.reportFile = next();
|
|
341
|
+
break;
|
|
342
|
+
case '--quiet':
|
|
343
|
+
options.quiet = true;
|
|
344
|
+
break;
|
|
345
|
+
case '--help':
|
|
346
|
+
case '-h':
|
|
347
|
+
options.help = true;
|
|
348
|
+
break;
|
|
349
|
+
default:
|
|
350
|
+
throw new Error(`sql/verify: unknown option ${arg}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (content.length > 0) {
|
|
354
|
+
options.contentDirs = content;
|
|
355
|
+
}
|
|
356
|
+
return options;
|
|
357
|
+
}
|
|
@@ -54,6 +54,28 @@ body.toc-collapsed .toc-toggle::after {
|
|
|
54
54
|
order wins over specificity — an unlayered `!important` loses to a layered
|
|
55
55
|
one, so the `max-width` below would silently do nothing outside this layer. */
|
|
56
56
|
@layer docusaurus.theme-classic {
|
|
57
|
+
/* The button sits in the column above the TOC, while only the TOC is sticky
|
|
58
|
+
(`DocItem/TOC` styles make it so). On any page taller than the viewport the
|
|
59
|
+
button therefore scrolls away and the TOC cannot be collapsed back — so it
|
|
60
|
+
is pinned here, and the TOC's own `top`/`max-height` are pushed down by the
|
|
61
|
+
button's height plus its margin, which is what keeps the two from
|
|
62
|
+
overlapping. The `top` values follow the theme's
|
|
63
|
+
`calc(var(--ifm-navbar-height) + 1rem)`.
|
|
64
|
+
|
|
65
|
+
Written with the `.toc-column` prefix for specificity: the TOC's rule comes
|
|
66
|
+
from a CSS module's hashed class at the same layer, so an equal-specificity
|
|
67
|
+
rule here would depend on stylesheet order. */
|
|
68
|
+
.toc-column .toc-toggle {
|
|
69
|
+
position: sticky;
|
|
70
|
+
top: calc(var(--ifm-navbar-height) + 0.5rem);
|
|
71
|
+
z-index: 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.toc-column .theme-doc-toc-desktop {
|
|
75
|
+
top: calc(var(--ifm-navbar-height) + 3rem);
|
|
76
|
+
max-height: calc(100vh - (var(--ifm-navbar-height) + 4rem));
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
body.toc-collapsed .toc-column {
|
|
58
80
|
--ifm-col-width: 2.5rem;
|
|
59
81
|
padding: 0;
|
|
@@ -63,6 +85,12 @@ body.toc-collapsed .toc-toggle::after {
|
|
|
63
85
|
display: none;
|
|
64
86
|
}
|
|
65
87
|
|
|
88
|
+
/* Collapsed, the strip is 2.5rem wide and only the button is left in it: it
|
|
89
|
+
centres instead of hugging the right edge of a column it no longer fills. */
|
|
90
|
+
body.toc-collapsed .toc-toggle {
|
|
91
|
+
margin-inline: auto;
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
body.toc-collapsed [class*='docItemCol'] {
|
|
67
95
|
max-width: 100% !important;
|
|
68
96
|
}
|