Path: blob/main/src/command/call/build-ts-extension/cmd.ts
6456 views
/*1* cmd.ts2*3* Copyright (C) 2025 Posit Software, PBC4*/56import { Command } from "cliffy/command/mod.ts";7import { error, info } from "../../../deno_ral/log.ts";8import {9architectureToolsPath,10resourcePath,11} from "../../../core/resources.ts";12import { execProcess } from "../../../core/process.ts";13import { basename, dirname, extname, join } from "../../../deno_ral/path.ts";14import { existsSync } from "../../../deno_ral/fs.ts";15import { expandGlobSync } from "../../../core/deno/expand-glob.ts";16import { readYaml } from "../../../core/yaml.ts";17import { warning } from "../../../deno_ral/log.ts";1819interface DenoConfig {20compilerOptions?: Record<string, unknown>;21importMap?: string;22imports?: Record<string, string>;23bundle?: {24entryPoint?: string;25outputFile?: string;26minify?: boolean;27sourcemap?: boolean | string;28};29}3031interface BuildOptions {32check?: boolean;33initConfig?: boolean;34}3536async function resolveConfig(): Promise<37{ config: DenoConfig; configPath: string }38> {39// Look for deno.json in current directory40const cwd = Deno.cwd();41const userConfigPath = join(cwd, "deno.json");4243if (existsSync(userConfigPath)) {44info(`Using config: ${userConfigPath}`);45const content = Deno.readTextFileSync(userConfigPath);46const config = JSON.parse(content) as DenoConfig;4748// Validate that both importMap and imports are not present49if (config.importMap && config.imports) {50error('deno.json contains both "importMap" and "imports"\n');51error(52'deno.json can use either "importMap" (path to file) OR "imports" (inline mappings), but not both.\n',53);54error("Please remove one of these fields from your deno.json.");55Deno.exit(1);56}5758return { config, configPath: userConfigPath };59}6061// Fall back to Quarto's default config62const defaultConfigPath = resourcePath("extension-build/deno.json");6364if (!existsSync(defaultConfigPath)) {65error("Could not find default extension-build configuration.\n");66error("This may indicate that Quarto was not built correctly.");67error("Expected config at: " + defaultConfigPath);68Deno.exit(1);69}7071info(`Using default config: ${defaultConfigPath}`);72const content = Deno.readTextFileSync(defaultConfigPath);73const config = JSON.parse(content) as DenoConfig;7475return { config, configPath: defaultConfigPath };76}7778async function autoDetectEntryPoint(79configEntryPoint?: string,80): Promise<string> {81// If config specifies entry point, use it (check this first, before src/ validation)82if (configEntryPoint) {83if (!existsSync(configEntryPoint)) {84error(85`Entry point specified in deno.json does not exist: ${configEntryPoint}`,86);87Deno.exit(1);88}89return configEntryPoint;90}9192const srcDir = "src";9394// Check if src/ exists (only needed for auto-detection)95if (!existsSync(srcDir)) {96error("No src/ directory found.\n");97error("Create a TypeScript file in src/:");98error(" mkdir -p src");99error(" touch src/my-engine.ts\n");100error("Or specify entry point as argument:");101error(" quarto call build-ts-extension src/my-engine.ts\n");102103// Only show deno.json config if it already exists104if (existsSync("deno.json")) {105error("Or configure in deno.json:");106error(" {");107error(' "bundle": {');108error(' "entryPoint": "path/to/file.ts"');109error(" }");110error(" }");111}112Deno.exit(1);113}114115// Find .ts files in src/116const tsFiles: string[] = [];117for await (const entry of Deno.readDir(srcDir)) {118if (entry.isFile && entry.name.endsWith(".ts")) {119tsFiles.push(entry.name);120}121}122123// Resolution logic124if (tsFiles.length === 0) {125error("No .ts files found in src/\n");126error("Create a TypeScript file:");127error(" touch src/my-engine.ts");128Deno.exit(1);129}130131if (tsFiles.length === 1) {132return join(srcDir, tsFiles[0]);133}134135// Multiple files - require mod.ts136if (tsFiles.includes("mod.ts")) {137return join(srcDir, "mod.ts");138}139140error(`Multiple .ts files found in src/: ${tsFiles.join(", ")}\n`);141error("Specify entry point as argument:");142error(" quarto call build-ts-extension src/my-engine.ts\n");143error("Or rename one file to mod.ts:");144error(` mv src/${tsFiles[0]} src/mod.ts\n`);145146// Only show deno.json config if it already exists147if (existsSync("deno.json")) {148error("Or configure in deno.json:");149error(" {");150error(' "bundle": {');151error(' "entryPoint": "src/my-engine.ts"');152error(" }");153error(" }");154}155156Deno.exit(1);157}158159function inferFilename(entryPoint: string): string {160// Get the base name without extension, add .js161const fileName = basename(entryPoint, extname(entryPoint));162return `${fileName}.js`;163}164165function inferOutputPath(166outputFilename: string,167userSpecifiedFilename?: string,168): string {169// Derive extension name from filename for error messages170const extensionName = basename(outputFilename, extname(outputFilename));171172// Find the extension directory by looking for _extension.yml173const extensionsDir = "_extensions";174if (!existsSync(extensionsDir)) {175error("No _extensions/ directory found.\n");176177if (userSpecifiedFilename) {178// User specified a filename in deno.json - offer path prefix option179error(180`You specified outputFile: "${userSpecifiedFilename}" in deno.json.`,181);182error("To write to the current directory, use a path prefix:");183error(` "outputFile": "./${userSpecifiedFilename}"\n`);184error("Or create an extension structure:");185} else {186// Auto-detection mode - standard error187error(188"Extension projects must have an _extensions/ directory with _extension.yml.",189);190error("Create the extension structure:");191}192193error(` mkdir -p _extensions/${extensionName}`);194error(` touch _extensions/${extensionName}/_extension.yml`);195Deno.exit(1);196}197198// Find all _extension.yml files using glob pattern199const extensionYmlFiles: string[] = [];200for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {201extensionYmlFiles.push(dirname(entry.path));202}203204if (extensionYmlFiles.length === 0) {205error("No _extension.yml found in _extensions/ subdirectories.\n");206error(207"Extension projects must have _extension.yml in a subdirectory of _extensions/.",208);209error("Create the extension metadata:");210error(` touch _extensions/${extensionName}/_extension.yml`);211Deno.exit(1);212}213214if (extensionYmlFiles.length > 1) {215const extensionNames = extensionYmlFiles.map((path) =>216path.replace("_extensions/", "")217);218error(219`Multiple extension directories found: ${extensionNames.join(", ")}\n`,220);221222if (existsSync("deno.json")) {223// User already has deno.json - show them how to configure it224// Use relative path in example (strip absolute path prefix)225const relativeExtPath = extensionYmlFiles[0].replace(226/^.*\/_extensions\//,227"_extensions/",228);229error("Specify the output path in deno.json:");230error(" {");231error(' "bundle": {');232error(` "outputFile": "${relativeExtPath}/${outputFilename}"`);233error(" }");234error(" }");235} else {236// No deno.json - guide them to create one if this is intentional237error("This tool doesn't currently support multi-extension projects.");238error(239"Use `quarto call build-ts-extension --init-config` to create a deno.json if this is intentional.",240);241}242Deno.exit(1);243}244245// Use the single extension directory found246return join(extensionYmlFiles[0], outputFilename);247}248249async function bundle(250entryPoint: string,251config: DenoConfig,252configPath: string,253): Promise<void> {254info("Bundling...");255256const denoBinary = Deno.env.get("QUARTO_DENO") ||257architectureToolsPath("deno");258259// Determine output path260let outputPath: string;261if (config.bundle?.outputFile) {262const specifiedOutput = config.bundle.outputFile;263// Check if it's just a filename (no path separators)264if (!specifiedOutput.includes("/") && !specifiedOutput.includes("\\")) {265// Just filename - infer directory from _extension.yml266// Pass the user-specified filename for better error messages267outputPath = inferOutputPath(specifiedOutput, specifiedOutput);268} else {269// Full path specified - use as-is270outputPath = specifiedOutput;271}272} else {273// Nothing specified - infer both directory and filename274const filename = inferFilename(entryPoint);275outputPath = inferOutputPath(filename);276}277278// Ensure output directory exists279const outputDir = dirname(outputPath);280if (!existsSync(outputDir)) {281Deno.mkdirSync(outputDir, { recursive: true });282}283284// Build deno bundle arguments285const args = [286"bundle",287`--config=${configPath}`,288`--output=${outputPath}`,289entryPoint,290];291292// Add optional flags293if (config.bundle?.minify) {294args.push("--minify");295}296297if (config.bundle?.sourcemap) {298const sourcemapValue = config.bundle.sourcemap;299if (typeof sourcemapValue === "string") {300args.push(`--sourcemap=${sourcemapValue}`);301} else {302args.push("--sourcemap");303}304}305306const result = await execProcess({307cmd: denoBinary,308args,309cwd: Deno.cwd(),310});311312if (!result.success) {313error("deno bundle failed");314if (result.stderr) {315error(result.stderr);316}317Deno.exit(1);318}319320// Validate that _extension.yml path matches output filename321validateExtensionYml(outputPath);322323info(`✓ Built ${entryPoint} → ${outputPath}`);324}325326function validateExtensionYml(outputPath: string): void {327// Find _extension.yml in the same directory as output328const extensionDir = dirname(outputPath);329const extensionYmlPath = join(extensionDir, "_extension.yml");330331if (!existsSync(extensionYmlPath)) {332return; // No _extension.yml, can't validate333}334335try {336const yml = readYaml(extensionYmlPath);337const engines = yml?.contributes?.engines;338339if (Array.isArray(engines)) {340const outputFilename = basename(outputPath);341342for (const engine of engines) {343const enginePath = typeof engine === "string" ? engine : engine?.path;344if (enginePath && enginePath !== outputFilename) {345warning(346`_extension.yml specifies engine path "${enginePath}" but built file is "${outputFilename}"`,347);348warning(` Update _extension.yml to: path: ${outputFilename}`);349}350}351}352} catch {353// Ignore YAML parsing errors354}355}356357async function initializeConfig(): Promise<void> {358const configPath = "deno.json";359360// Check if deno.json already exists361if (existsSync(configPath)) {362const importMapPath = resourcePath("extension-build/import-map.json");363error("deno.json already exists\n");364error("To use Quarto's default config, remove the existing deno.json.");365error("Or manually add the importMap to your existing config:");366info(` "importMap": "${importMapPath}"`);367Deno.exit(1);368}369370// Get absolute path to Quarto's import map371const importMapPath = resourcePath("extension-build/import-map.json");372373// Create minimal config374const config = {375compilerOptions: {376strict: true,377lib: ["deno.ns", "DOM", "ES2021"],378},379importMap: importMapPath,380};381382// Write deno.json383Deno.writeTextFileSync(384configPath,385JSON.stringify(config, null, 2) + "\n",386);387388// Inform user389info("✓ Created deno.json");390info(` Import map: ${importMapPath}`);391info("");392info("Customize as needed:");393info(' - Add "bundle" section for build options:');394info(' "entryPoint": "src/my-engine.ts"');395info(' "outputFile": "_extensions/my-engine/my-engine.js"');396info(' "minify": true');397info(' "sourcemap": true');398info(' - Modify "compilerOptions" for type-checking behavior');399}400401export const buildTsExtensionCommand = new Command()402.name("build-ts-extension")403.arguments("[entry-point:string]")404.description(405"Build TypeScript execution engine extensions.\n\n" +406"This command type-checks and bundles TypeScript extensions " +407"into single JavaScript files using Quarto's bundled deno bundle.\n\n" +408"The entry point is determined by:\n" +409" 1. [entry-point] command-line argument (if specified)\n" +410" 2. bundle.entryPoint in deno.json (if specified)\n" +411" 3. Single .ts file in src/ directory\n" +412" 4. src/mod.ts (if multiple .ts files exist)",413)414.option("--check", "Type-check only (skip bundling)")415.option(416"--init-config",417"Generate deno.json with absolute importMap path",418)419.action(async (options: BuildOptions, entryPointArg?: string) => {420try {421// Handle --init-config flag first (don't build)422if (options.initConfig) {423await initializeConfig();424return;425}426427// 1. Resolve configuration428const { config, configPath } = await resolveConfig();429430// 2. Resolve entry point (CLI arg takes precedence)431const entryPoint = entryPointArg ||432await autoDetectEntryPoint(433config.bundle?.entryPoint,434);435info(`Entry point: ${entryPoint}`);436437// 3. Type-check or bundle438if (options.check) {439// Just type-check440info("Type-checking...");441const denoBinary = Deno.env.get("QUARTO_DENO") ||442architectureToolsPath("deno");443const result = await execProcess({444cmd: denoBinary,445args: ["check", `--config=${configPath}`, entryPoint],446cwd: Deno.cwd(),447});448if (!result.success) {449error("Type check failed\n");450error(451"See errors above. Fix type errors in your code or adjust compilerOptions in deno.json.",452);453Deno.exit(1);454}455info("✓ Type check passed");456} else {457// Type-check and bundle (deno bundle does both)458await bundle(entryPoint, config, configPath);459}460} catch (e) {461if (e instanceof Error) {462error(e.message);463} else {464error(String(e));465}466Deno.exit(1);467}468});469470471