Path: blob/main/src/command/call/typst-gather/cmd.ts
12926 views
/*1* cmd.ts2*3* Copyright (C) 2025 Posit Software, PBC4*/56import { Command } from "cliffy/command/mod.ts";7import { info } from "../../../deno_ral/log.ts";89import { execProcess } from "../../../core/process.ts";10import { dirname, join, relative } from "../../../deno_ral/path.ts";11import { existsSync } from "../../../deno_ral/fs.ts";12import { expandGlobSync } from "../../../core/deno/expand-glob.ts";13import { readYaml } from "../../../core/yaml.ts";14import {15type AnalyzeImport,16type AnalyzeResult,17runAnalyze,18toTomlPath,19typstGatherBinaryPath,20} from "../../../core/typst-gather.ts";2122interface ExtensionYml {23contributes?: {24formats?: {25typst?: {26template?: string;27"template-partials"?: string[];28};29};30};31}3233interface TypstGatherConfig {34configFile?: string; // Path to config file if one was found35rootdir?: string;36destination: string;37discover: string[];38}3940async function findExtensionDir(): Promise<string | null> {41const cwd = Deno.cwd();4243// Check if we're in an extension directory (has _extension.yml)44if (existsSync(join(cwd, "_extension.yml"))) {45return cwd;46}4748// Check if there's an _extensions directory with a single extension49const extensionsDir = join(cwd, "_extensions");50if (existsSync(extensionsDir)) {51const extensionDirs: string[] = [];52for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {53extensionDirs.push(dirname(entry.path));54}5556if (extensionDirs.length === 1) {57return extensionDirs[0];58} else if (extensionDirs.length > 1) {59console.error("Multiple extension directories found.\n");60console.error(61"Run this command from within a specific extension directory,",62);63console.error(64"or create a typst-gather.toml to specify the configuration.",65);66return null;67}68}6970return null;71}7273function extractTypstFiles(extensionDir: string): string[] {74const extensionYmlPath = join(extensionDir, "_extension.yml");7576if (!existsSync(extensionYmlPath)) {77return [];78}7980try {81const yml = readYaml(extensionYmlPath) as ExtensionYml;82const typstConfig = yml?.contributes?.formats?.typst;8384if (!typstConfig) {85return [];86}8788const files: string[] = [];8990// Add template if specified91if (typstConfig.template) {92files.push(join(extensionDir, typstConfig.template));93}9495// Add template-partials if specified96if (typstConfig["template-partials"]) {97for (const partial of typstConfig["template-partials"]) {98files.push(join(extensionDir, partial));99}100}101102return files;103} catch {104return [];105}106}107108async function resolveConfig(109extensionDir: string | null,110): Promise<TypstGatherConfig | null> {111const cwd = Deno.cwd();112113// First, check for typst-gather.toml in current directory114const configPath = join(cwd, "typst-gather.toml");115if (existsSync(configPath)) {116info(`Using config: ${configPath}`);117return {118configFile: configPath,119destination: "",120discover: [],121};122}123124// No config file - try to auto-detect from _extension.yml125if (!extensionDir) {126console.error(127"No typst-gather.toml found and no extension directory detected.\n",128);129console.error("Either:");130console.error(" 1. Create a typst-gather.toml file, or");131console.error(132" 2. Run from within an extension directory with _extension.yml",133);134return null;135}136137const typstFiles = extractTypstFiles(extensionDir);138139if (typstFiles.length === 0) {140console.error("No Typst files found in _extension.yml.\n");141console.error(142"The extension must define 'template' or 'template-partials' under contributes.formats.typst",143);144return null;145}146147// Default destination is 'typst/packages' directory in extension folder148const destination = join(extensionDir, "typst/packages");149150// Show paths relative to cwd for cleaner output151const relDest = relative(cwd, destination);152const relFiles = typstFiles.map((f) => relative(cwd, f));153154info(`Auto-detected from _extension.yml:`);155info(` Destination: ${relDest}`);156info(` Files to scan: ${relFiles.join(", ")}`);157158return {159destination,160discover: typstFiles,161};162}163164export type { AnalyzeImport, AnalyzeResult };165166export function generateConfigFromAnalysis(167result: AnalyzeResult,168rootdir?: string,169): string {170const lines: string[] = [];171172lines.push("# typst-gather configuration");173lines.push("# Run: quarto call typst-gather");174lines.push("");175176if (rootdir) {177lines.push(`rootdir = "${toTomlPath(rootdir)}"`);178}179lines.push('destination = "typst/packages"');180lines.push("");181182// Discover section183if (result.files.length === 1) {184lines.push(`discover = "${toTomlPath(result.files[0])}"`);185} else if (result.files.length > 1) {186const files = result.files.map((f) => `"${toTomlPath(f)}"`).join(", ");187lines.push(`discover = [${files}]`);188} else {189lines.push('# discover = "template.typ" # Add your .typ files here');190}191192lines.push("");193194// Preview section (commented out - packages will be auto-discovered)195const previewImports = result.imports.filter((i) =>196i.namespace === "preview"197);198lines.push("# Preview packages are auto-discovered from imports.");199lines.push("# Uncomment to pin specific versions:");200lines.push("# [preview]");201if (previewImports.length > 0) {202const seen = new Set<string>();203for (const { name, version, direct, source } of previewImports) {204if (!seen.has(name)) {205seen.add(name);206const suffix = direct ? "" : ` # via ${source}`;207lines.push(`# ${name} = "${version}"${suffix}`);208}209}210} else {211lines.push('# cetz = "0.4.1"');212}213214lines.push("");215216// Local section217const localImports = result.imports.filter(218(i) => i.namespace === "local" && i.direct,219);220lines.push(221"# Local packages (@local namespace) must be configured manually.",222);223if (localImports.length > 0) {224lines.push("# Found @local imports:");225const seen = new Set<string>();226for (const { name, version, source } of localImports) {227if (!seen.has(name)) {228seen.add(name);229lines.push(`# @local/${name}:${version} (in ${source})`);230}231}232lines.push("[local]");233seen.clear();234for (const { name } of localImports) {235if (!seen.has(name)) {236seen.add(name);237lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`);238}239}240} else {241lines.push("# [local]");242lines.push('# my-pkg = "/path/to/my-pkg"');243}244245lines.push("");246return lines.join("\n");247}248249async function initConfig(): Promise<void> {250const configFile = join(Deno.cwd(), "typst-gather.toml");251252// Check if config already exists253if (existsSync(configFile)) {254console.error("typst-gather.toml already exists");255console.error("Remove it first or edit it manually.");256Deno.exit(1);257}258259// Find typst files via extension directory structure260const extensionDir = await findExtensionDir();261262if (!extensionDir) {263console.error("No extension directory found.");264console.error(265"Run this command from a directory containing _extension.yml or _extensions/",266);267Deno.exit(1);268}269270const typFiles = extractTypstFiles(extensionDir);271272if (typFiles.length === 0) {273info("Warning: No .typ files found in _extension.yml.");274info(275"Edit the generated typst-gather.toml to configure local or pinned dependencies.",276);277} else {278info(`Found extension: ${extensionDir}`);279}280281// Build analyze config with discover paths282const discoverArray = typFiles.map((f) => `"${toTomlPath(f)}"`).join(", ");283const analyzeConfig = `discover = [${discoverArray}]\n`;284285// Run typst-gather analyze to discover imports286const analysis = await runAnalyze(analyzeConfig);287288// Calculate relative path from cwd to extension dir for rootdir289const rootdir = relative(Deno.cwd(), extensionDir);290291// Generate config content from analysis292const configContent = generateConfigFromAnalysis(analysis, rootdir);293294// Write config file295try {296Deno.writeTextFileSync(configFile, configContent);297} catch (e) {298console.error(`Error writing typst-gather.toml: ${e}`);299Deno.exit(1);300}301302const previewImports = analysis.imports.filter(303(i) => i.namespace === "preview",304);305const localImports = analysis.imports.filter(306(i) => i.namespace === "local" && i.direct,307);308309info("Created typst-gather.toml");310if (analysis.files.length > 0) {311info(` Scanned: ${analysis.files.join(", ")}`);312}313if (previewImports.length > 0) {314info(` Found ${previewImports.length} @preview import(s)`);315}316if (localImports.length > 0) {317info(318` Found ${localImports.length} @local import(s) - configure paths in [local] section`,319);320}321322info("");323info("Next steps:");324info(" 1. Review and edit typst-gather.toml");325if (localImports.length > 0) {326info(" 2. Add paths for @local packages in [local] section");327}328info(" 3. Run: quarto call typst-gather");329}330331export const typstGatherCommand = new Command()332.name("typst-gather")333.description(334"Gather Typst packages for a format extension.\n\n" +335"This command scans Typst files for @preview imports and downloads " +336"the packages to a local directory for offline use.\n\n" +337"Configuration is determined by:\n" +338" 1. typst-gather.toml in current directory (if present)\n" +339" 2. Auto-detection from _extension.yml (template and template-partials)",340)341.option(342"--init-config",343"Generate a starter typst-gather.toml in current directory",344)345.action(async (options: { initConfig?: boolean }) => {346// Handle --init-config347if (options.initConfig) {348await initConfig();349return;350}351try {352// Find extension directory353const extensionDir = await findExtensionDir();354355// Resolve configuration356const config = await resolveConfig(extensionDir);357if (!config) {358Deno.exit(1);359}360361const typstGatherBinary = typstGatherBinaryPath();362363info(`Running typst-gather...`);364365// Run typst-gather gather366let result;367if (config.configFile) {368// Existing config file — pass directly369result = await execProcess({370cmd: typstGatherBinary,371args: ["gather", config.configFile],372cwd: Deno.cwd(),373});374} else {375// Auto-detected — pipe config on stdin376const discoverArray = config.discover.map((p) => `"${toTomlPath(p)}"`)377.join(", ");378let tomlContent = "";379if (config.rootdir) {380tomlContent += `rootdir = "${toTomlPath(config.rootdir)}"\n`;381}382tomlContent += `destination = "${toTomlPath(config.destination)}"\n`;383tomlContent += `discover = [${discoverArray}]\n`;384385result = await execProcess(386{387cmd: typstGatherBinary,388args: ["gather", "-"],389cwd: Deno.cwd(),390},391tomlContent,392);393}394395if (!result.success) {396// Print any output from the tool397if (result.stdout) {398console.log(result.stdout);399}400if (result.stderr) {401console.error(result.stderr);402}403404// Check for @local imports not configured error and suggest --init-config405// Only suggest if no config file was found406const output = (result.stdout || "") + (result.stderr || "");407if (408output.includes("@local imports not configured") && !config.configFile409) {410console.error("");411console.error(412"Tip: Run 'quarto call typst-gather --init-config' to generate a config file",413);414console.error(415" with placeholders for your @local package paths.",416);417}418419Deno.exit(1);420}421422info("Done!");423} catch (e) {424if (e instanceof Error) {425console.error(e.message);426} else {427console.error(String(e));428}429Deno.exit(1);430}431});432433434