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,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
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs runnable SQL blocks in DuckDB-Wasm from Node, on the same architecture
|
|
3
|
+
* the site itself uses: the official Node **worker** target, one instance per
|
|
4
|
+
* page, one connection per page.
|
|
5
|
+
*
|
|
6
|
+
* Three platform facts shape this file, all of them measured rather than
|
|
7
|
+
* guessed (duckfn's AGENTS.md records the investigation):
|
|
8
|
+
*
|
|
9
|
+
* 1. The official Node *blocking* target (`duckdb-node-blocking.cjs`) deadlocks
|
|
10
|
+
* when it loads an extension that opens its own connection while registering
|
|
11
|
+
* — which is exactly what a docs site's extension may do. The worker target
|
|
12
|
+
* is the only Node target that works.
|
|
13
|
+
* 2. A bare file name in `LOAD` is not usable on wasm: it hangs, without an
|
|
14
|
+
* error, even with the file registered through `registerFileBuffer`. The
|
|
15
|
+
* extension has to be fetched over http(s), like the site does it.
|
|
16
|
+
* 3. DuckDB stages that URL under
|
|
17
|
+
* `~/.duckdb/extensions/<host>[:<port>]/<first path segment>/` — which is
|
|
18
|
+
* why the served URL keeps a path segment, and why the directory is
|
|
19
|
+
* pre-created: the loader's own `mkdir` is non-recursive. A colon is illegal
|
|
20
|
+
* in a Windows path, so the port plan is per platform: Windows takes the
|
|
21
|
+
* default port 80 (the URL then carries no port at all), POSIX takes any
|
|
22
|
+
* free port. The directory's previous content is removed so a stale copy can
|
|
23
|
+
* never be what runs.
|
|
24
|
+
*/
|
|
25
|
+
import {createServer, type Server} from 'node:http';
|
|
26
|
+
import {Worker as WorkerThread} from 'node:worker_threads';
|
|
27
|
+
import {createRequire} from 'node:module';
|
|
28
|
+
import {mkdirSync, readFileSync, rmSync} from 'node:fs';
|
|
29
|
+
import {homedir} from 'node:os';
|
|
30
|
+
import {basename, dirname, join} from 'node:path';
|
|
31
|
+
|
|
32
|
+
/** The slice of the Node target's surface this runner uses (it ships no types). */
|
|
33
|
+
interface DuckdbNodeTarget {
|
|
34
|
+
AsyncDuckDB: new (logger: unknown, worker: unknown) => DuckdbDatabase;
|
|
35
|
+
ConsoleLogger: new () => unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DuckdbQueryResult {
|
|
39
|
+
numRows: number;
|
|
40
|
+
schema: {fields: {name: string}[]};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DuckdbConnection {
|
|
44
|
+
query(sql: string): Promise<DuckdbQueryResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DuckdbDatabase {
|
|
48
|
+
instantiate(mainModule: string, pthreadWorker: string | null): Promise<void>;
|
|
49
|
+
open(config: {allowUnsignedExtensions?: boolean}): Promise<void>;
|
|
50
|
+
connect(): Promise<DuckdbConnection>;
|
|
51
|
+
terminate(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const require = createRequire(import.meta.url);
|
|
55
|
+
|
|
56
|
+
/** Loaded through a variable so a bundler leaves the dynamic import alone. */
|
|
57
|
+
const NODE_TARGET = '@duckdb/duckdb-wasm/dist/duckdb-node.cjs';
|
|
58
|
+
|
|
59
|
+
export type WasmPlatform = 'eh' | 'mvp';
|
|
60
|
+
|
|
61
|
+
export interface RunnerOptions {
|
|
62
|
+
/**
|
|
63
|
+
* The extension to `LOAD`: a local `.duckdb_extension.wasm` path (served to
|
|
64
|
+
* the worker over a loopback http server) or an absolute `http(s)` URL.
|
|
65
|
+
*/
|
|
66
|
+
extension: string;
|
|
67
|
+
/**
|
|
68
|
+
* DuckDB-Wasm platform. Must match how the extension was built — a site
|
|
69
|
+
* serving `duckfn-wasm_eh.duckdb_extension.wasm` runs the `eh` bundle.
|
|
70
|
+
*/
|
|
71
|
+
platform?: WasmPlatform;
|
|
72
|
+
/** The engine wasm; defaults to the one shipped beside the worker bundle. */
|
|
73
|
+
engine?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RunResult {
|
|
77
|
+
rows: number;
|
|
78
|
+
columns: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The WebWorker surface `AsyncDuckDB` expects, backed by a worker thread.
|
|
83
|
+
*
|
|
84
|
+
* The official `createWorker()` cannot be reused here: it fetches the worker
|
|
85
|
+
* script, wraps it in a blob URL and resolves it as a file path, which is the
|
|
86
|
+
* browser's module story. The contract it relies on internally is small —
|
|
87
|
+
* `worker_threads` entry with `workerData.mod` — and that is what this
|
|
88
|
+
* reproduces.
|
|
89
|
+
*/
|
|
90
|
+
class WorkerShim {
|
|
91
|
+
#thread: WorkerThread;
|
|
92
|
+
#listeners = new Map<string, ((event: unknown) => void)[]>();
|
|
93
|
+
onmessage: ((event: unknown) => void) | null = null;
|
|
94
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
95
|
+
onclose: ((event: unknown) => void) | null = null;
|
|
96
|
+
|
|
97
|
+
constructor(mod: string) {
|
|
98
|
+
this.#thread = new WorkerThread(require.resolve(NODE_TARGET), {
|
|
99
|
+
workerData: {mod, name: '', type: ''},
|
|
100
|
+
});
|
|
101
|
+
this.#thread.on('message', (data) => this.#emit('message', data));
|
|
102
|
+
this.#thread.on('error', (error) => this.#emit('error', error));
|
|
103
|
+
this.#thread.on('exit', () => this.#emit('close'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#emit(type: string, data?: unknown): void {
|
|
107
|
+
const event = {type, data, target: this, currentTarget: this};
|
|
108
|
+
const handler = (this as Record<string, unknown>)[`on${type}`];
|
|
109
|
+
if (typeof handler === 'function') {
|
|
110
|
+
(handler as (event: unknown) => void)(event);
|
|
111
|
+
}
|
|
112
|
+
for (const listener of this.#listeners.get(type) ?? []) {
|
|
113
|
+
listener(event);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
118
|
+
this.#listeners.set(type, [...(this.#listeners.get(type) ?? []), listener]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
122
|
+
this.#listeners.set(
|
|
123
|
+
type,
|
|
124
|
+
(this.#listeners.get(type) ?? []).filter((candidate) => candidate !== listener),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
postMessage(data: unknown, transfer?: unknown): void {
|
|
129
|
+
// The worker_threads signature always wants the second argument.
|
|
130
|
+
this.#thread.postMessage(data, (transfer ?? []) as never);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
terminate(): Promise<number> | void {
|
|
134
|
+
return this.#thread.terminate();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One DuckDB-Wasm instance at a time, driven from Node.
|
|
140
|
+
*
|
|
141
|
+
* `newPage()` is what a docs site does per page load: a fresh instance, a fresh
|
|
142
|
+
* connection, the extension loaded again. Blocks of one page then share state
|
|
143
|
+
* (a table created in one block is visible to the next), while pages stay
|
|
144
|
+
* isolated — which is why the runner is used one page at a time rather than
|
|
145
|
+
* over a single long-lived connection.
|
|
146
|
+
*/
|
|
147
|
+
export class WasmSqlRunner {
|
|
148
|
+
#db: DuckdbDatabase | null = null;
|
|
149
|
+
#conn: DuckdbConnection | null = null;
|
|
150
|
+
#server: Server | null = null;
|
|
151
|
+
#stagingDir: string | null = null;
|
|
152
|
+
readonly #extensionUrl: string;
|
|
153
|
+
readonly #engine: string;
|
|
154
|
+
readonly #workerBundle: string;
|
|
155
|
+
|
|
156
|
+
private constructor(extensionUrl: string, engine: string, workerBundle: string) {
|
|
157
|
+
this.#extensionUrl = extensionUrl;
|
|
158
|
+
this.#engine = engine;
|
|
159
|
+
this.#workerBundle = workerBundle;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static async create(options: RunnerOptions): Promise<WasmSqlRunner> {
|
|
163
|
+
const platform = options.platform ?? 'eh';
|
|
164
|
+
const workerBundle = require.resolve(
|
|
165
|
+
`@duckdb/duckdb-wasm/dist/duckdb-node-${platform}.worker.cjs`,
|
|
166
|
+
);
|
|
167
|
+
const engine = options.engine ?? join(dirname(workerBundle), `duckdb-${platform}.wasm`);
|
|
168
|
+
|
|
169
|
+
const remote = /^https?:\/\//i.test(options.extension);
|
|
170
|
+
const served = remote ? null : await serveExtension(options.extension);
|
|
171
|
+
const url = served ? served.url : options.extension;
|
|
172
|
+
|
|
173
|
+
const runner = new WasmSqlRunner(url, engine, workerBundle);
|
|
174
|
+
runner.#server = served ? served.server : null;
|
|
175
|
+
runner.#stagingDir = served
|
|
176
|
+
? prepareStagingDir(served.stagingHost, served.stagingSegment)
|
|
177
|
+
: null;
|
|
178
|
+
return runner;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Drops the current instance and starts a fresh page: new instance, new connection. */
|
|
182
|
+
async newPage(): Promise<void> {
|
|
183
|
+
if (this.#db) {
|
|
184
|
+
await this.#db.terminate();
|
|
185
|
+
this.#db = null;
|
|
186
|
+
this.#conn = null;
|
|
187
|
+
}
|
|
188
|
+
const duckdb = (await import(/* @vite-ignore */ NODE_TARGET)) as unknown as DuckdbNodeTarget;
|
|
189
|
+
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), new WorkerShim(this.#workerBundle));
|
|
190
|
+
await db.instantiate(this.#engine, null);
|
|
191
|
+
await db.open({allowUnsignedExtensions: true});
|
|
192
|
+
const conn = await db.connect();
|
|
193
|
+
await conn.query(`LOAD '${this.#extensionUrl}'`);
|
|
194
|
+
this.#db = db;
|
|
195
|
+
this.#conn = conn;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Runs one block; the caller decides whether a failure is expected. */
|
|
199
|
+
async run(sql: string): Promise<RunResult> {
|
|
200
|
+
const conn = this.#conn;
|
|
201
|
+
if (!conn) {
|
|
202
|
+
throw new Error('sql/verify: no page is open — call newPage() first');
|
|
203
|
+
}
|
|
204
|
+
const table = await conn.query(sql);
|
|
205
|
+
return {rows: table.numRows, columns: table.schema.fields.length};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Where the fetched extension was staged, for diagnostics. */
|
|
209
|
+
get stagingDir(): string | null {
|
|
210
|
+
return this.#stagingDir;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async close(): Promise<void> {
|
|
214
|
+
if (this.#db) {
|
|
215
|
+
await this.#db.terminate();
|
|
216
|
+
this.#db = null;
|
|
217
|
+
this.#conn = null;
|
|
218
|
+
}
|
|
219
|
+
if (this.#server) {
|
|
220
|
+
await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
|
|
221
|
+
this.#server = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Serves one extension file over loopback http, because wasm `LOAD` only works
|
|
228
|
+
* with a URL (see the file header). The server answers any path with that one
|
|
229
|
+
* file: it is a local, short-lived convenience, not a web server.
|
|
230
|
+
*
|
|
231
|
+
* The port choice is the platform split from the file header: the default port
|
|
232
|
+
* where the platform lets a non-root process take it (so the URL stays
|
|
233
|
+
* port-less, which is what Windows needs), any free port otherwise.
|
|
234
|
+
*/
|
|
235
|
+
async function serveExtension(file: string): Promise<{
|
|
236
|
+
server: Server;
|
|
237
|
+
url: string;
|
|
238
|
+
/** The directory DuckDB will stage the download under, and its parent. */
|
|
239
|
+
stagingHost: string;
|
|
240
|
+
stagingSegment: string;
|
|
241
|
+
}> {
|
|
242
|
+
const body = readFileSync(file);
|
|
243
|
+
const name = basename(file);
|
|
244
|
+
// DuckDB stages a fetched extension under
|
|
245
|
+
// `~/.duckdb/extensions/<host>/<first path segment>/`, so the URL needs one
|
|
246
|
+
// path segment — the site serves its extension from `duckdb-extensions/`, and
|
|
247
|
+
// this mirrors that shape with the extension's own name.
|
|
248
|
+
const segment = extensionName(file);
|
|
249
|
+
const server = createServer((_request, response) => {
|
|
250
|
+
response.writeHead(200, {
|
|
251
|
+
'content-type': 'application/octet-stream',
|
|
252
|
+
'content-length': body.length,
|
|
253
|
+
});
|
|
254
|
+
response.end(body);
|
|
255
|
+
});
|
|
256
|
+
const wanted = process.platform === 'win32' ? 80 : 0;
|
|
257
|
+
await new Promise<void>((resolve, reject) => {
|
|
258
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
259
|
+
reject(
|
|
260
|
+
new Error(
|
|
261
|
+
`sql/verify: cannot serve the extension on port ${wanted} (${error.code}): a port in ` +
|
|
262
|
+
`the URL would put a colon into DuckDB's staging path, which is not a legal Windows ` +
|
|
263
|
+
`path — free port 80, or load the extension from an http(s) URL with --extension`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
server.listen(wanted, 'localhost', resolve);
|
|
268
|
+
});
|
|
269
|
+
const address = server.address();
|
|
270
|
+
const bound = typeof address === 'object' && address ? address.port : wanted;
|
|
271
|
+
const host = bound === 80 ? 'localhost' : `localhost:${bound}`;
|
|
272
|
+
return {
|
|
273
|
+
server,
|
|
274
|
+
url: `http://${host}/${segment}/${name}`,
|
|
275
|
+
stagingHost: host,
|
|
276
|
+
stagingSegment: segment,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Pre-creates DuckDB's staging directory and empties it.
|
|
282
|
+
*
|
|
283
|
+
* The directory has to exist beforehand (the loader's own `mkdir` is
|
|
284
|
+
* non-recursive), and anything left in it from an earlier run would be reused
|
|
285
|
+
* instead of the extension being fetched again — a stale binary silently
|
|
286
|
+
* testing the wrong build.
|
|
287
|
+
*/
|
|
288
|
+
function prepareStagingDir(host: string, segment: string): string {
|
|
289
|
+
const dir = join(homedir(), '.duckdb', 'extensions', host, segment);
|
|
290
|
+
rmSync(dir, {recursive: true, force: true});
|
|
291
|
+
mkdirSync(dir, {recursive: true});
|
|
292
|
+
return dir;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The text before the first dot of the file name — the entry symbol DuckDB looks up. */
|
|
296
|
+
function extensionName(file: string): string {
|
|
297
|
+
return basename(file).split('.')[0] ?? '';
|
|
298
|
+
}
|
package/src/sql/remark.ts
CHANGED
|
@@ -38,6 +38,17 @@ export interface RunnableSqlConfig {
|
|
|
38
38
|
* iframe, so scripts run with an opaque origin.
|
|
39
39
|
*/
|
|
40
40
|
show?: 'table' | 'html' | 'iframe' | 'svg' | 'text';
|
|
41
|
+
/**
|
|
42
|
+
* What this block is expected to do when the docs' own SQL test suite runs it
|
|
43
|
+
* (`duckfn-docs-kit/sql/verify`). Defaults to `'ok'`; `'error'` marks a block
|
|
44
|
+
* that demonstrates a failure — the suite then *requires* it to fail, and
|
|
45
|
+
* reports it when it unexpectedly succeeds instead.
|
|
46
|
+
*
|
|
47
|
+
* This is the only source of truth for the expectation: the prose around a
|
|
48
|
+
* block, and a `-- error: …` comment inside it, are there for readers, and
|
|
49
|
+
* neither is machine-checked.
|
|
50
|
+
*/
|
|
51
|
+
expect?: 'ok' | 'error';
|
|
41
52
|
/**
|
|
42
53
|
* The column holding the markup, for the preview renderers. A single-column
|
|
43
54
|
* result is unambiguous and is used as-is.
|
|
@@ -102,8 +113,15 @@ interface ParentNode {
|
|
|
102
113
|
children?: unknown[];
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Parse the metastring; `null` means "not a runnable block, leave it alone".
|
|
118
|
+
*
|
|
119
|
+
* Exported because the block contract has two consumers: this plugin, which
|
|
120
|
+
* turns a block into `<dfk-sql>` at build time, and `sql/verify` (via
|
|
121
|
+
* `sql/collect`), which runs those same blocks in CI. Both have to agree on
|
|
122
|
+
* what counts as runnable, so there is one parser.
|
|
123
|
+
*/
|
|
124
|
+
export function parseRunnableSqlMeta(meta: string | null | undefined): RunnableSqlConfig | null {
|
|
107
125
|
if (!meta) {
|
|
108
126
|
return null;
|
|
109
127
|
}
|
|
@@ -199,7 +217,7 @@ export const remarkRunnableSql: Plugin<[RunnableSqlOptions?]> =
|
|
|
199
217
|
}
|
|
200
218
|
const candidate = child as CodeNode;
|
|
201
219
|
if (candidate.type === 'code' && candidate.lang === 'sql') {
|
|
202
|
-
const config =
|
|
220
|
+
const config = parseRunnableSqlMeta(candidate.meta);
|
|
203
221
|
if (config) {
|
|
204
222
|
return wrapRunnableSql(candidate, config);
|
|
205
223
|
}
|
package/src/sql/sql.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Light-DOM styles for `<dfk-sql>`: the result container. It lives in the
|
|
2
2
|
element's light DOM (not the shadow root) because VTable injects a
|
|
3
3
|
*document-level* stylesheet that a shadow boundary could not host — the same
|
|
4
|
-
light-DOM exception as the TOC toggle (see
|
|
4
|
+
light-DOM exception as the TOC toggle (see CONVENTIONS.md rule 9). It is created
|
|
5
5
|
only when a query runs, so the light DOM still starts empty (rule 11). Every
|
|
6
6
|
class carries the `dfk-sql-` prefix so nothing can collide with the host site.
|
|
7
7
|
*
|