Skip to content

Commit 09f2f95

Browse files
claude: Add build-ts-extension command for TypeScript engine extensions
Implements MVP of `quarto dev-call build-ts-extension` command that: - Type-checks TypeScript extensions against Quarto API types - Bundles extensions into single JavaScript files using esbuild - Provides default configuration for extensions Key features: - Auto-detects entry point (config, single file, or mod.ts) - Config resolution (user deno.json or default from share/extension-build/) - Type checking with Deno's bundled binary - Bundling with esbuild's bundled binary - --check flag for type-check only Distribution setup: - Static config templates in src/resources/extension-build/ - Copied to share/extension-build/ during packaging - Works in both dev mode and distribution mode Test extension: - Minimal test in tests/smoke/build-ts-extension/ - Implements ExecutionEngineDiscovery interface - Demonstrates type-checking and bundling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 73697e1 commit 09f2f95

File tree

10 files changed

+531
-2
lines changed

10 files changed

+531
-2
lines changed

package/src/common/prepare-dist.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,28 @@ export async function prepareDist(
163163
);
164164
}
165165

166-
// Copy quarto-types for engine extension template
166+
// Copy quarto-types for engine extension template (backward compatibility)
167167
info("Copying quarto-types.d.ts for engine extension template");
168168
copySync(
169169
join(config.directoryInfo.root, "packages/quarto-types/dist/index.d.ts"),
170170
join(config.directoryInfo.pkgWorking.share, "quarto-types.d.ts"),
171171
{ overwrite: true },
172172
);
173173

174+
// Copy quarto-types to extension-build directory
175+
// Note: src/resources/extension-build/ already has deno.json and import-map.json
176+
// which are copied automatically by supportingFiles()
177+
info("Copying quarto-types.d.ts to extension-build directory");
178+
const extensionBuildDir = join(
179+
config.directoryInfo.pkgWorking.share,
180+
"extension-build",
181+
);
182+
copySync(
183+
join(config.directoryInfo.root, "packages/quarto-types/dist/index.d.ts"),
184+
join(extensionBuildDir, "quarto-types.d.ts"),
185+
{ overwrite: true },
186+
);
187+
174188
// Remove the config directory, if present
175189
info(`Cleaning config`);
176190
const configDir = join(config.directoryInfo.dist, "config");
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
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+
17+
interface DenoConfig {
18+
compilerOptions?: Record<string, unknown>;
19+
importMap?: string;
20+
imports?: Record<string, string>;
21+
quartoExtension?: {
22+
entryPoint?: string;
23+
outputFile?: string;
24+
minify?: boolean;
25+
sourcemap?: boolean;
26+
target?: string;
27+
};
28+
}
29+
30+
interface BuildOptions {
31+
check?: boolean;
32+
}
33+
34+
async function resolveConfig(): Promise<
35+
{ config: DenoConfig; configPath: string }
36+
> {
37+
// Look for deno.json in current directory
38+
const cwd = Deno.cwd();
39+
const userConfigPath = join(cwd, "deno.json");
40+
41+
if (existsSync(userConfigPath)) {
42+
info(`Using config: ${userConfigPath}`);
43+
const content = Deno.readTextFileSync(userConfigPath);
44+
const config = JSON.parse(content) as DenoConfig;
45+
46+
// Validate that both importMap and imports are not present
47+
if (config.importMap && config.imports) {
48+
error('Error: deno.json contains both "importMap" and "imports"');
49+
error("");
50+
error(
51+
'deno.json can use either "importMap" (path to file) OR "imports" (inline mappings), but not both.',
52+
);
53+
error("");
54+
error("Please remove one of these fields from your deno.json.");
55+
Deno.exit(1);
56+
}
57+
58+
return { config, configPath: userConfigPath };
59+
}
60+
61+
// Fall back to Quarto's default config
62+
const defaultConfigPath = resourcePath("extension-build/deno.json");
63+
64+
if (!existsSync(defaultConfigPath)) {
65+
error("Error: Could not find default extension-build configuration.");
66+
error("");
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+
const srcDir = "src";
83+
84+
// Check if src/ exists
85+
if (!existsSync(srcDir)) {
86+
error("Error: No src/ directory found.");
87+
error("");
88+
error("Create a TypeScript file in src/:");
89+
error(" mkdir -p src");
90+
error(" touch src/my-engine.ts");
91+
error("");
92+
error("Or specify entry point in deno.json:");
93+
error(" {");
94+
error(' "quartoExtension": {');
95+
error(' "entryPoint": "path/to/file.ts"');
96+
error(" }");
97+
error(" }");
98+
Deno.exit(1);
99+
}
100+
101+
// If config specifies entry point, use it
102+
if (configEntryPoint) {
103+
if (!existsSync(configEntryPoint)) {
104+
error(
105+
`Error: Entry point specified in deno.json does not exist: ${configEntryPoint}`,
106+
);
107+
Deno.exit(1);
108+
}
109+
return configEntryPoint;
110+
}
111+
112+
// Find .ts files in src/
113+
const tsFiles: string[] = [];
114+
for await (const entry of Deno.readDir(srcDir)) {
115+
if (entry.isFile && entry.name.endsWith(".ts")) {
116+
tsFiles.push(entry.name);
117+
}
118+
}
119+
120+
// Resolution logic
121+
if (tsFiles.length === 0) {
122+
error("Error: No .ts files found in src/");
123+
error("");
124+
error("Create a TypeScript file:");
125+
error(" touch src/my-engine.ts");
126+
Deno.exit(1);
127+
}
128+
129+
if (tsFiles.length === 1) {
130+
return join(srcDir, tsFiles[0]);
131+
}
132+
133+
// Multiple files - require mod.ts
134+
if (tsFiles.includes("mod.ts")) {
135+
return join(srcDir, "mod.ts");
136+
}
137+
138+
error(`Error: Multiple .ts files found in src/: ${tsFiles.join(", ")}`);
139+
error("");
140+
error("Specify entry point in deno.json:");
141+
error(" {");
142+
error(' "quartoExtension": {');
143+
error(' "entryPoint": "src/my-engine.ts"');
144+
error(" }");
145+
error(" }");
146+
error("");
147+
error("Or rename one file to mod.ts:");
148+
error(` mv src/${tsFiles[0]} src/mod.ts`);
149+
Deno.exit(1);
150+
}
151+
152+
async function typeCheck(
153+
entryPoint: string,
154+
configPath: string,
155+
): Promise<void> {
156+
info("Type-checking...");
157+
158+
const denoBinary = Deno.env.get("QUARTO_DENO") ||
159+
architectureToolsPath("deno");
160+
161+
const result = await execProcess({
162+
cmd: denoBinary,
163+
args: ["check", `--config=${configPath}`, entryPoint],
164+
cwd: Deno.cwd(),
165+
});
166+
167+
if (!result.success) {
168+
error("Error: Type check failed");
169+
error("");
170+
error(
171+
"See errors above. Fix type errors in your code or adjust compilerOptions in deno.json.",
172+
);
173+
error("");
174+
error("To see just type errors without building:");
175+
error(" quarto dev-call build-ts-extension --check");
176+
Deno.exit(1);
177+
}
178+
179+
info("✓ Type check passed");
180+
}
181+
182+
function inferOutputPath(entryPoint: string): string {
183+
// Get the base name without extension
184+
const fileName = basename(entryPoint, extname(entryPoint));
185+
186+
// Try to determine extension name from directory structure or use filename
187+
let extensionName = fileName;
188+
189+
// Check if _extension.yml exists to get extension name
190+
const extensionYml = "_extension.yml";
191+
if (existsSync(extensionYml)) {
192+
try {
193+
// Simple extraction - look for extension name in path or use filename
194+
// For MVP, we'll just use the filename
195+
} catch {
196+
// Ignore errors, use filename
197+
}
198+
}
199+
200+
// Output to _extensions/<name>/<name>.js
201+
const outputDir = join("_extensions", extensionName);
202+
return join(outputDir, `${fileName}.js`);
203+
}
204+
205+
async function bundle(
206+
entryPoint: string,
207+
config: DenoConfig,
208+
): Promise<void> {
209+
info("Bundling...");
210+
211+
const esbuildBinary = Deno.env.get("QUARTO_ESBUILD") ||
212+
architectureToolsPath("esbuild");
213+
214+
// Determine output path
215+
const outputPath = config.quartoExtension?.outputFile ||
216+
inferOutputPath(entryPoint);
217+
218+
// Ensure output directory exists
219+
const outputDir = dirname(outputPath);
220+
if (!existsSync(outputDir)) {
221+
Deno.mkdirSync(outputDir, { recursive: true });
222+
}
223+
224+
// Build esbuild arguments
225+
const args = [
226+
entryPoint,
227+
"--bundle",
228+
"--format=esm",
229+
`--outfile=${outputPath}`,
230+
];
231+
232+
// Add target
233+
const target = config.quartoExtension?.target || "es2022";
234+
args.push(`--target=${target}`);
235+
236+
// Add optional flags
237+
if (config.quartoExtension?.minify) {
238+
args.push("--minify");
239+
}
240+
241+
if (config.quartoExtension?.sourcemap) {
242+
args.push("--sourcemap");
243+
}
244+
245+
const result = await execProcess({
246+
cmd: esbuildBinary,
247+
args,
248+
cwd: Deno.cwd(),
249+
});
250+
251+
if (!result.success) {
252+
error("Error: esbuild bundling failed");
253+
if (result.stderr) {
254+
error(result.stderr);
255+
}
256+
Deno.exit(1);
257+
}
258+
259+
info(`✓ Built ${entryPoint}${outputPath}`);
260+
}
261+
262+
export const buildTsExtensionCommand = new Command()
263+
.name("build-ts-extension")
264+
.hidden()
265+
.description(
266+
"Build TypeScript execution engine extensions.\n\n" +
267+
"This command type-checks and bundles TypeScript extensions " +
268+
"into single JavaScript files using Quarto's bundled esbuild.\n\n" +
269+
"The entry point is determined by:\n" +
270+
" 1. quartoExtension.entryPoint in deno.json (if specified)\n" +
271+
" 2. Single .ts file in src/ directory\n" +
272+
" 3. src/mod.ts (if multiple .ts files exist)",
273+
)
274+
.option("--check", "Type-check only (skip bundling)")
275+
.action(async (options: BuildOptions) => {
276+
try {
277+
// 1. Resolve configuration
278+
const { config, configPath } = await resolveConfig();
279+
280+
// 2. Resolve entry point
281+
const entryPoint = await autoDetectEntryPoint(
282+
config.quartoExtension?.entryPoint,
283+
);
284+
info(`Entry point: ${entryPoint}`);
285+
286+
// 3. Type-check with Deno
287+
await typeCheck(entryPoint, configPath);
288+
289+
// 4. Bundle with esbuild (unless --check)
290+
if (!options.check) {
291+
await bundle(entryPoint, config);
292+
} else {
293+
info("Skipping bundle (--check flag specified)");
294+
}
295+
} catch (e) {
296+
if (e instanceof Error) {
297+
error(`Error: ${e.message}`);
298+
} else {
299+
error(`Error: ${String(e)}`);
300+
}
301+
Deno.exit(1);
302+
}
303+
});

src/command/dev-call/cmd.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { validateYamlCommand } from "./validate-yaml/cmd.ts";
66
import { showAstTraceCommand } from "./show-ast-trace/cmd.ts";
77
import { makeAstDiagramCommand } from "./make-ast-diagram/cmd.ts";
88
import { pullGitSubtreeCommand } from "./pull-git-subtree/cmd.ts";
9+
import { buildTsExtensionCommand } from "./build-ts-extension/cmd.ts";
910

1011
type CommandOptionInfo = {
1112
name: string;
@@ -77,4 +78,5 @@ export const devCallCommand = new Command()
7778
.command("build-artifacts", buildJsCommand)
7879
.command("show-ast-trace", showAstTraceCommand)
7980
.command("make-ast-diagram", makeAstDiagramCommand)
80-
.command("pull-git-subtree", pullGitSubtreeCommand);
81+
.command("pull-git-subtree", pullGitSubtreeCommand)
82+
.command("build-ts-extension", buildTsExtensionCommand);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Extension Build Resources
2+
3+
This directory contains default configuration files for TypeScript execution engine extensions.
4+
5+
## Files
6+
7+
- **deno.json** - Default TypeScript compiler configuration
8+
- **import-map.json** - Import mappings for @quarto/types and Deno standard library
9+
- **quarto-types.d.ts** - Type definitions for Quarto API (copied from packages/quarto-types/dist/ during build)
10+
11+
## Usage
12+
13+
These files are used by `quarto dev-call build-ts-extension` when a user project doesn't have its own `deno.json`.
14+
15+
In dev mode: accessed via `resourcePath("extension-build/")`
16+
In distribution: copied to `share/extension-build/` during packaging
17+
18+
## Updating Versions
19+
20+
When updating Deno standard library versions:
21+
1. Update `src/import_map.json` (main Quarto CLI import map)
22+
2. Update `import-map.json` in this directory to match
23+
24+
The versions should stay in sync with Quarto CLI's dependencies.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"lib": ["DOM", "ES2021"]
5+
},
6+
"importMap": "./import-map.json"
7+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"imports": {
3+
"@quarto/types": "./quarto-types.d.ts",
4+
"path": "jsr:@std/path@1.0.8",
5+
"path/posix": "jsr:@std/path@1.0.8/posix",
6+
"log": "jsr:/@std/log@0.224.0",
7+
"log/": "jsr:/@std/log@0.224.0/",
8+
"fs/": "jsr:/@std/fs@1.0.16/",
9+
"encoding/": "jsr:/@std/encoding@1.0.9/"
10+
}
11+
}

0 commit comments

Comments
 (0)