Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/call/build-ts-extension/cmd.ts
6456 views
1
/*
2
* cmd.ts
3
*
4
* Copyright (C) 2025 Posit Software, PBC
5
*/
6
7
import { Command } from "cliffy/command/mod.ts";
8
import { error, info } from "../../../deno_ral/log.ts";
9
import {
10
architectureToolsPath,
11
resourcePath,
12
} from "../../../core/resources.ts";
13
import { execProcess } from "../../../core/process.ts";
14
import { basename, dirname, extname, join } from "../../../deno_ral/path.ts";
15
import { existsSync } from "../../../deno_ral/fs.ts";
16
import { expandGlobSync } from "../../../core/deno/expand-glob.ts";
17
import { readYaml } from "../../../core/yaml.ts";
18
import { warning } from "../../../deno_ral/log.ts";
19
20
interface DenoConfig {
21
compilerOptions?: Record<string, unknown>;
22
importMap?: string;
23
imports?: Record<string, string>;
24
bundle?: {
25
entryPoint?: string;
26
outputFile?: string;
27
minify?: boolean;
28
sourcemap?: boolean | string;
29
};
30
}
31
32
interface BuildOptions {
33
check?: boolean;
34
initConfig?: boolean;
35
}
36
37
async function resolveConfig(): Promise<
38
{ config: DenoConfig; configPath: string }
39
> {
40
// Look for deno.json in current directory
41
const cwd = Deno.cwd();
42
const userConfigPath = join(cwd, "deno.json");
43
44
if (existsSync(userConfigPath)) {
45
info(`Using config: ${userConfigPath}`);
46
const content = Deno.readTextFileSync(userConfigPath);
47
const config = JSON.parse(content) as DenoConfig;
48
49
// Validate that both importMap and imports are not present
50
if (config.importMap && config.imports) {
51
error('deno.json contains both "importMap" and "imports"\n');
52
error(
53
'deno.json can use either "importMap" (path to file) OR "imports" (inline mappings), but not both.\n',
54
);
55
error("Please remove one of these fields from your deno.json.");
56
Deno.exit(1);
57
}
58
59
return { config, configPath: userConfigPath };
60
}
61
62
// Fall back to Quarto's default config
63
const defaultConfigPath = resourcePath("extension-build/deno.json");
64
65
if (!existsSync(defaultConfigPath)) {
66
error("Could not find default extension-build configuration.\n");
67
error("This may indicate that Quarto was not built correctly.");
68
error("Expected config at: " + defaultConfigPath);
69
Deno.exit(1);
70
}
71
72
info(`Using default config: ${defaultConfigPath}`);
73
const content = Deno.readTextFileSync(defaultConfigPath);
74
const config = JSON.parse(content) as DenoConfig;
75
76
return { config, configPath: defaultConfigPath };
77
}
78
79
async function autoDetectEntryPoint(
80
configEntryPoint?: string,
81
): Promise<string> {
82
// If config specifies entry point, use it (check this first, before src/ validation)
83
if (configEntryPoint) {
84
if (!existsSync(configEntryPoint)) {
85
error(
86
`Entry point specified in deno.json does not exist: ${configEntryPoint}`,
87
);
88
Deno.exit(1);
89
}
90
return configEntryPoint;
91
}
92
93
const srcDir = "src";
94
95
// Check if src/ exists (only needed for auto-detection)
96
if (!existsSync(srcDir)) {
97
error("No src/ directory found.\n");
98
error("Create a TypeScript file in src/:");
99
error(" mkdir -p src");
100
error(" touch src/my-engine.ts\n");
101
error("Or specify entry point as argument:");
102
error(" quarto call build-ts-extension src/my-engine.ts\n");
103
104
// Only show deno.json config if it already exists
105
if (existsSync("deno.json")) {
106
error("Or configure in deno.json:");
107
error(" {");
108
error(' "bundle": {');
109
error(' "entryPoint": "path/to/file.ts"');
110
error(" }");
111
error(" }");
112
}
113
Deno.exit(1);
114
}
115
116
// Find .ts files in src/
117
const tsFiles: string[] = [];
118
for await (const entry of Deno.readDir(srcDir)) {
119
if (entry.isFile && entry.name.endsWith(".ts")) {
120
tsFiles.push(entry.name);
121
}
122
}
123
124
// Resolution logic
125
if (tsFiles.length === 0) {
126
error("No .ts files found in src/\n");
127
error("Create a TypeScript file:");
128
error(" touch src/my-engine.ts");
129
Deno.exit(1);
130
}
131
132
if (tsFiles.length === 1) {
133
return join(srcDir, tsFiles[0]);
134
}
135
136
// Multiple files - require mod.ts
137
if (tsFiles.includes("mod.ts")) {
138
return join(srcDir, "mod.ts");
139
}
140
141
error(`Multiple .ts files found in src/: ${tsFiles.join(", ")}\n`);
142
error("Specify entry point as argument:");
143
error(" quarto call build-ts-extension src/my-engine.ts\n");
144
error("Or rename one file to mod.ts:");
145
error(` mv src/${tsFiles[0]} src/mod.ts\n`);
146
147
// Only show deno.json config if it already exists
148
if (existsSync("deno.json")) {
149
error("Or configure in deno.json:");
150
error(" {");
151
error(' "bundle": {');
152
error(' "entryPoint": "src/my-engine.ts"');
153
error(" }");
154
error(" }");
155
}
156
157
Deno.exit(1);
158
}
159
160
function inferFilename(entryPoint: string): string {
161
// Get the base name without extension, add .js
162
const fileName = basename(entryPoint, extname(entryPoint));
163
return `${fileName}.js`;
164
}
165
166
function inferOutputPath(
167
outputFilename: string,
168
userSpecifiedFilename?: string,
169
): string {
170
// Derive extension name from filename for error messages
171
const extensionName = basename(outputFilename, extname(outputFilename));
172
173
// Find the extension directory by looking for _extension.yml
174
const extensionsDir = "_extensions";
175
if (!existsSync(extensionsDir)) {
176
error("No _extensions/ directory found.\n");
177
178
if (userSpecifiedFilename) {
179
// User specified a filename in deno.json - offer path prefix option
180
error(
181
`You specified outputFile: "${userSpecifiedFilename}" in deno.json.`,
182
);
183
error("To write to the current directory, use a path prefix:");
184
error(` "outputFile": "./${userSpecifiedFilename}"\n`);
185
error("Or create an extension structure:");
186
} else {
187
// Auto-detection mode - standard error
188
error(
189
"Extension projects must have an _extensions/ directory with _extension.yml.",
190
);
191
error("Create the extension structure:");
192
}
193
194
error(` mkdir -p _extensions/${extensionName}`);
195
error(` touch _extensions/${extensionName}/_extension.yml`);
196
Deno.exit(1);
197
}
198
199
// Find all _extension.yml files using glob pattern
200
const extensionYmlFiles: string[] = [];
201
for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {
202
extensionYmlFiles.push(dirname(entry.path));
203
}
204
205
if (extensionYmlFiles.length === 0) {
206
error("No _extension.yml found in _extensions/ subdirectories.\n");
207
error(
208
"Extension projects must have _extension.yml in a subdirectory of _extensions/.",
209
);
210
error("Create the extension metadata:");
211
error(` touch _extensions/${extensionName}/_extension.yml`);
212
Deno.exit(1);
213
}
214
215
if (extensionYmlFiles.length > 1) {
216
const extensionNames = extensionYmlFiles.map((path) =>
217
path.replace("_extensions/", "")
218
);
219
error(
220
`Multiple extension directories found: ${extensionNames.join(", ")}\n`,
221
);
222
223
if (existsSync("deno.json")) {
224
// User already has deno.json - show them how to configure it
225
// Use relative path in example (strip absolute path prefix)
226
const relativeExtPath = extensionYmlFiles[0].replace(
227
/^.*\/_extensions\//,
228
"_extensions/",
229
);
230
error("Specify the output path in deno.json:");
231
error(" {");
232
error(' "bundle": {');
233
error(` "outputFile": "${relativeExtPath}/${outputFilename}"`);
234
error(" }");
235
error(" }");
236
} else {
237
// No deno.json - guide them to create one if this is intentional
238
error("This tool doesn't currently support multi-extension projects.");
239
error(
240
"Use `quarto call build-ts-extension --init-config` to create a deno.json if this is intentional.",
241
);
242
}
243
Deno.exit(1);
244
}
245
246
// Use the single extension directory found
247
return join(extensionYmlFiles[0], outputFilename);
248
}
249
250
async function bundle(
251
entryPoint: string,
252
config: DenoConfig,
253
configPath: string,
254
): Promise<void> {
255
info("Bundling...");
256
257
const denoBinary = Deno.env.get("QUARTO_DENO") ||
258
architectureToolsPath("deno");
259
260
// Determine output path
261
let outputPath: string;
262
if (config.bundle?.outputFile) {
263
const specifiedOutput = config.bundle.outputFile;
264
// Check if it's just a filename (no path separators)
265
if (!specifiedOutput.includes("/") && !specifiedOutput.includes("\\")) {
266
// Just filename - infer directory from _extension.yml
267
// Pass the user-specified filename for better error messages
268
outputPath = inferOutputPath(specifiedOutput, specifiedOutput);
269
} else {
270
// Full path specified - use as-is
271
outputPath = specifiedOutput;
272
}
273
} else {
274
// Nothing specified - infer both directory and filename
275
const filename = inferFilename(entryPoint);
276
outputPath = inferOutputPath(filename);
277
}
278
279
// Ensure output directory exists
280
const outputDir = dirname(outputPath);
281
if (!existsSync(outputDir)) {
282
Deno.mkdirSync(outputDir, { recursive: true });
283
}
284
285
// Build deno bundle arguments
286
const args = [
287
"bundle",
288
`--config=${configPath}`,
289
`--output=${outputPath}`,
290
entryPoint,
291
];
292
293
// Add optional flags
294
if (config.bundle?.minify) {
295
args.push("--minify");
296
}
297
298
if (config.bundle?.sourcemap) {
299
const sourcemapValue = config.bundle.sourcemap;
300
if (typeof sourcemapValue === "string") {
301
args.push(`--sourcemap=${sourcemapValue}`);
302
} else {
303
args.push("--sourcemap");
304
}
305
}
306
307
const result = await execProcess({
308
cmd: denoBinary,
309
args,
310
cwd: Deno.cwd(),
311
});
312
313
if (!result.success) {
314
error("deno bundle failed");
315
if (result.stderr) {
316
error(result.stderr);
317
}
318
Deno.exit(1);
319
}
320
321
// Validate that _extension.yml path matches output filename
322
validateExtensionYml(outputPath);
323
324
info(`✓ Built ${entryPoint} → ${outputPath}`);
325
}
326
327
function validateExtensionYml(outputPath: string): void {
328
// Find _extension.yml in the same directory as output
329
const extensionDir = dirname(outputPath);
330
const extensionYmlPath = join(extensionDir, "_extension.yml");
331
332
if (!existsSync(extensionYmlPath)) {
333
return; // No _extension.yml, can't validate
334
}
335
336
try {
337
const yml = readYaml(extensionYmlPath);
338
const engines = yml?.contributes?.engines;
339
340
if (Array.isArray(engines)) {
341
const outputFilename = basename(outputPath);
342
343
for (const engine of engines) {
344
const enginePath = typeof engine === "string" ? engine : engine?.path;
345
if (enginePath && enginePath !== outputFilename) {
346
warning(
347
`_extension.yml specifies engine path "${enginePath}" but built file is "${outputFilename}"`,
348
);
349
warning(` Update _extension.yml to: path: ${outputFilename}`);
350
}
351
}
352
}
353
} catch {
354
// Ignore YAML parsing errors
355
}
356
}
357
358
async function initializeConfig(): Promise<void> {
359
const configPath = "deno.json";
360
361
// Check if deno.json already exists
362
if (existsSync(configPath)) {
363
const importMapPath = resourcePath("extension-build/import-map.json");
364
error("deno.json already exists\n");
365
error("To use Quarto's default config, remove the existing deno.json.");
366
error("Or manually add the importMap to your existing config:");
367
info(` "importMap": "${importMapPath}"`);
368
Deno.exit(1);
369
}
370
371
// Get absolute path to Quarto's import map
372
const importMapPath = resourcePath("extension-build/import-map.json");
373
374
// Create minimal config
375
const config = {
376
compilerOptions: {
377
strict: true,
378
lib: ["deno.ns", "DOM", "ES2021"],
379
},
380
importMap: importMapPath,
381
};
382
383
// Write deno.json
384
Deno.writeTextFileSync(
385
configPath,
386
JSON.stringify(config, null, 2) + "\n",
387
);
388
389
// Inform user
390
info("✓ Created deno.json");
391
info(` Import map: ${importMapPath}`);
392
info("");
393
info("Customize as needed:");
394
info(' - Add "bundle" section for build options:');
395
info(' "entryPoint": "src/my-engine.ts"');
396
info(' "outputFile": "_extensions/my-engine/my-engine.js"');
397
info(' "minify": true');
398
info(' "sourcemap": true');
399
info(' - Modify "compilerOptions" for type-checking behavior');
400
}
401
402
export const buildTsExtensionCommand = new Command()
403
.name("build-ts-extension")
404
.arguments("[entry-point:string]")
405
.description(
406
"Build TypeScript execution engine extensions.\n\n" +
407
"This command type-checks and bundles TypeScript extensions " +
408
"into single JavaScript files using Quarto's bundled deno bundle.\n\n" +
409
"The entry point is determined by:\n" +
410
" 1. [entry-point] command-line argument (if specified)\n" +
411
" 2. bundle.entryPoint in deno.json (if specified)\n" +
412
" 3. Single .ts file in src/ directory\n" +
413
" 4. src/mod.ts (if multiple .ts files exist)",
414
)
415
.option("--check", "Type-check only (skip bundling)")
416
.option(
417
"--init-config",
418
"Generate deno.json with absolute importMap path",
419
)
420
.action(async (options: BuildOptions, entryPointArg?: string) => {
421
try {
422
// Handle --init-config flag first (don't build)
423
if (options.initConfig) {
424
await initializeConfig();
425
return;
426
}
427
428
// 1. Resolve configuration
429
const { config, configPath } = await resolveConfig();
430
431
// 2. Resolve entry point (CLI arg takes precedence)
432
const entryPoint = entryPointArg ||
433
await autoDetectEntryPoint(
434
config.bundle?.entryPoint,
435
);
436
info(`Entry point: ${entryPoint}`);
437
438
// 3. Type-check or bundle
439
if (options.check) {
440
// Just type-check
441
info("Type-checking...");
442
const denoBinary = Deno.env.get("QUARTO_DENO") ||
443
architectureToolsPath("deno");
444
const result = await execProcess({
445
cmd: denoBinary,
446
args: ["check", `--config=${configPath}`, entryPoint],
447
cwd: Deno.cwd(),
448
});
449
if (!result.success) {
450
error("Type check failed\n");
451
error(
452
"See errors above. Fix type errors in your code or adjust compilerOptions in deno.json.",
453
);
454
Deno.exit(1);
455
}
456
info("✓ Type check passed");
457
} else {
458
// Type-check and bundle (deno bundle does both)
459
await bundle(entryPoint, config, configPath);
460
}
461
} catch (e) {
462
if (e instanceof Error) {
463
error(e.message);
464
} else {
465
error(String(e));
466
}
467
Deno.exit(1);
468
}
469
});
470
471