Path: blob/main/src/command/call/typst-gather/cmd.ts
6456 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 { architectureToolsPath } from "../../../core/resources.ts";10import { execProcess } from "../../../core/process.ts";11import { dirname, join, relative } from "../../../deno_ral/path.ts";12import { existsSync } from "../../../deno_ral/fs.ts";13import { isWindows } from "../../../deno_ral/platform.ts";14import { expandGlobSync } from "../../../core/deno/expand-glob.ts";15import { readYaml } from "../../../core/yaml.ts";1617// Convert path to use forward slashes for TOML compatibility18// TOML treats backslash as escape character, so Windows paths must use forward slashes19function toTomlPath(p: string): string {20return p.replace(/\\/g, "/");21}2223interface ExtensionYml {24contributes?: {25formats?: {26typst?: {27template?: string;28"template-partials"?: string[];29};30};31};32}3334interface TypstGatherConfig {35configFile?: string; // Path to config file if one was found36rootdir?: string;37destination: string;38discover: string[];39}4041async function findExtensionDir(): Promise<string | null> {42const cwd = Deno.cwd();4344// Check if we're in an extension directory (has _extension.yml)45if (existsSync(join(cwd, "_extension.yml"))) {46return cwd;47}4849// Check if there's an _extensions directory with a single extension50const extensionsDir = join(cwd, "_extensions");51if (existsSync(extensionsDir)) {52const extensionDirs: string[] = [];53for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {54extensionDirs.push(dirname(entry.path));55}5657if (extensionDirs.length === 1) {58return extensionDirs[0];59} else if (extensionDirs.length > 1) {60console.error("Multiple extension directories found.\n");61console.error(62"Run this command from within a specific extension directory,",63);64console.error(65"or create a typst-gather.toml to specify the configuration.",66);67return null;68}69}7071return null;72}7374function extractTypstFiles(extensionDir: string): string[] {75const extensionYmlPath = join(extensionDir, "_extension.yml");7677if (!existsSync(extensionYmlPath)) {78return [];79}8081try {82const yml = readYaml(extensionYmlPath) as ExtensionYml;83const typstConfig = yml?.contributes?.formats?.typst;8485if (!typstConfig) {86return [];87}8889const files: string[] = [];9091// Add template if specified92if (typstConfig.template) {93files.push(join(extensionDir, typstConfig.template));94}9596// Add template-partials if specified97if (typstConfig["template-partials"]) {98for (const partial of typstConfig["template-partials"]) {99files.push(join(extensionDir, partial));100}101}102103return files;104} catch {105return [];106}107}108109async function resolveConfig(110extensionDir: string | null,111): Promise<TypstGatherConfig | null> {112const cwd = Deno.cwd();113114// First, check for typst-gather.toml in current directory115const configPath = join(cwd, "typst-gather.toml");116if (existsSync(configPath)) {117info(`Using config: ${configPath}`);118// Return the config file path - rust will parse it directly119// We still parse minimally to validate and show info120const content = Deno.readTextFileSync(configPath);121const config = parseSimpleToml(content);122config.configFile = configPath;123return config;124}125126// No config file - try to auto-detect from _extension.yml127if (!extensionDir) {128console.error(129"No typst-gather.toml found and no extension directory detected.\n",130);131console.error("Either:");132console.error(" 1. Create a typst-gather.toml file, or");133console.error(134" 2. Run from within an extension directory with _extension.yml",135);136return null;137}138139const typstFiles = extractTypstFiles(extensionDir);140141if (typstFiles.length === 0) {142console.error("No Typst files found in _extension.yml.\n");143console.error(144"The extension must define 'template' or 'template-partials' under contributes.formats.typst",145);146return null;147}148149// Default destination is 'typst/packages' directory in extension folder150const destination = join(extensionDir, "typst/packages");151152// Show paths relative to cwd for cleaner output153const relDest = relative(cwd, destination);154const relFiles = typstFiles.map((f) => relative(cwd, f));155156info(`Auto-detected from _extension.yml:`);157info(` Destination: ${relDest}`);158info(` Files to scan: ${relFiles.join(", ")}`);159160return {161destination,162discover: typstFiles,163};164}165166function parseSimpleToml(content: string): TypstGatherConfig {167const lines = content.split("\n");168let rootdir: string | undefined;169let destination = "";170const discover: string[] = [];171172for (const line of lines) {173const trimmed = line.trim();174175// Parse rootdir176const rootdirMatch = trimmed.match(/^rootdir\s*=\s*"([^"]+)"/);177if (rootdirMatch) {178rootdir = rootdirMatch[1];179continue;180}181182// Parse destination183const destMatch = trimmed.match(/^destination\s*=\s*"([^"]+)"/);184if (destMatch) {185destination = destMatch[1];186continue;187}188189// Parse discover as string190const discoverStrMatch = trimmed.match(/^discover\s*=\s*"([^"]+)"/);191if (discoverStrMatch) {192discover.push(discoverStrMatch[1]);193continue;194}195196// Parse discover as array (simple single-line parsing)197const discoverArrMatch = trimmed.match(/^discover\s*=\s*\[([^\]]+)\]/);198if (discoverArrMatch) {199const items = discoverArrMatch[1].split(",");200for (const item of items) {201const match = item.trim().match(/"([^"]+)"/);202if (match) {203discover.push(match[1]);204}205}206}207}208209return { rootdir, destination, discover };210}211212interface DiscoveredImport {213name: string;214version: string;215sourceFile: string;216}217218interface DiscoveryResult {219preview: DiscoveredImport[];220local: DiscoveredImport[];221scannedFiles: string[];222}223224function discoverImportsFromFiles(files: string[]): DiscoveryResult {225const result: DiscoveryResult = {226preview: [],227local: [],228scannedFiles: [],229};230231// Regex to match @namespace/name:version imports232// Note: #include is for files, not packages, so we only match #import233const importRegex = /#import\s+"@(\w+)\/([^:]+):([^"]+)"/g;234235for (const file of files) {236if (!existsSync(file)) continue;237if (!file.endsWith(".typ")) continue;238239const filename = file.split("/").pop() || file;240result.scannedFiles.push(filename);241242try {243const content = Deno.readTextFileSync(file);244let match;245while ((match = importRegex.exec(content)) !== null) {246const [, namespace, name, version] = match;247const entry = { name, version, sourceFile: filename };248249if (namespace === "preview") {250result.preview.push(entry);251} else if (namespace === "local") {252result.local.push(entry);253}254}255} catch {256// Skip files that can't be read257}258}259260return result;261}262263function generateConfigContent(264discovery: DiscoveryResult,265rootdir?: string,266): string {267const lines: string[] = [];268269lines.push("# typst-gather configuration");270lines.push("# Run: quarto call typst-gather");271lines.push("");272273if (rootdir) {274lines.push(`rootdir = "${toTomlPath(rootdir)}"`);275}276lines.push('destination = "typst/packages"');277lines.push("");278279// Discover section280if (discovery.scannedFiles.length > 0) {281if (discovery.scannedFiles.length === 1) {282lines.push(`discover = "${toTomlPath(discovery.scannedFiles[0])}"`);283} else {284const files = discovery.scannedFiles.map((f) => `"${toTomlPath(f)}"`)285.join(", ");286lines.push(`discover = [${files}]`);287}288} else {289lines.push('# discover = "template.typ" # Add your .typ files here');290}291292lines.push("");293294// Preview section (commented out - packages will be auto-discovered)295lines.push("# Preview packages are auto-discovered from imports.");296lines.push("# Uncomment to pin specific versions:");297lines.push("# [preview]");298if (discovery.preview.length > 0) {299// Deduplicate300const seen = new Set<string>();301for (const { name, version } of discovery.preview) {302if (!seen.has(name)) {303seen.add(name);304lines.push(`# ${name} = "${version}"`);305}306}307} else {308lines.push('# cetz = "0.4.1"');309}310311lines.push("");312313// Local section314lines.push(315"# Local packages (@local namespace) must be configured manually.",316);317if (discovery.local.length > 0) {318lines.push("# Found @local imports:");319const seen = new Set<string>();320for (const { name, version, sourceFile } of discovery.local) {321if (!seen.has(name)) {322seen.add(name);323lines.push(`# @local/${name}:${version} (in ${sourceFile})`);324}325}326lines.push("[local]");327seen.clear();328for (const { name } of discovery.local) {329if (!seen.has(name)) {330seen.add(name);331lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`);332}333}334} else {335lines.push("# [local]");336lines.push('# my-pkg = "/path/to/my-pkg"');337}338339lines.push("");340return lines.join("\n");341}342343async function initConfig(): Promise<void> {344const configFile = join(Deno.cwd(), "typst-gather.toml");345346// Check if config already exists347if (existsSync(configFile)) {348console.error("typst-gather.toml already exists");349console.error("Remove it first or edit it manually.");350Deno.exit(1);351}352353// Find typst files via extension directory structure354const extensionDir = await findExtensionDir();355356if (!extensionDir) {357console.error("No extension directory found.");358console.error(359"Run this command from a directory containing _extension.yml or _extensions/",360);361Deno.exit(1);362}363364const typFiles = extractTypstFiles(extensionDir);365366if (typFiles.length === 0) {367info("Warning: No .typ files found in _extension.yml.");368info(369"Edit the generated typst-gather.toml to configure local or pinned dependencies.",370);371} else {372info(`Found extension: ${extensionDir}`);373}374375// Discover imports from the files376const discovery = discoverImportsFromFiles(typFiles);377378// Calculate relative path from cwd to extension dir for rootdir379const rootdir = relative(Deno.cwd(), extensionDir);380381// Generate config content382const configContent = generateConfigContent(discovery, rootdir);383384// Write config file385try {386Deno.writeTextFileSync(configFile, configContent);387} catch (e) {388console.error(`Error writing typst-gather.toml: ${e}`);389Deno.exit(1);390}391392info("Created typst-gather.toml");393if (discovery.scannedFiles.length > 0) {394info(` Scanned: ${discovery.scannedFiles.join(", ")}`);395}396if (discovery.preview.length > 0) {397info(` Found ${discovery.preview.length} @preview import(s)`);398}399if (discovery.local.length > 0) {400info(401` Found ${discovery.local.length} @local import(s) - configure paths in [local] section`,402);403}404405info("");406info("Next steps:");407info(" 1. Review and edit typst-gather.toml");408if (discovery.local.length > 0) {409info(" 2. Add paths for @local packages in [local] section");410}411info(" 3. Run: quarto call typst-gather");412}413414export const typstGatherCommand = new Command()415.name("typst-gather")416.description(417"Gather Typst packages for a format extension.\n\n" +418"This command scans Typst files for @preview imports and downloads " +419"the packages to a local directory for offline use.\n\n" +420"Configuration is determined by:\n" +421" 1. typst-gather.toml in current directory (if present)\n" +422" 2. Auto-detection from _extension.yml (template and template-partials)",423)424.option(425"--init-config",426"Generate a starter typst-gather.toml in current directory",427)428.action(async (options: { initConfig?: boolean }) => {429// Handle --init-config430if (options.initConfig) {431await initConfig();432return;433}434try {435// Find extension directory436const extensionDir = await findExtensionDir();437438// Resolve configuration439const config = await resolveConfig(extensionDir);440if (!config) {441Deno.exit(1);442}443444if (!config.destination) {445console.error("No destination specified in configuration.");446Deno.exit(1);447}448449if (config.discover.length === 0) {450console.error("No files to discover imports from.");451Deno.exit(1);452}453454// Find typst-gather binary in standard tools location455const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";456const typstGatherBinary = architectureToolsPath(binaryName);457if (!existsSync(typstGatherBinary)) {458console.error(459`typst-gather binary not found.\n` +460`Run ./configure.sh to build and install it.`,461);462Deno.exit(1);463}464465// Determine config file to use466let configFileToUse: string;467let tempConfig: string | null = null;468469if (config.configFile) {470// Use existing config file directly - rust will parse [local], [preview], etc.471configFileToUse = config.configFile;472} else {473// Create a temporary TOML config file for auto-detected config474tempConfig = Deno.makeTempFileSync({ suffix: ".toml" });475const discoverArray = config.discover.map((p) => `"${toTomlPath(p)}"`)476.join(", ");477let tomlContent = "";478if (config.rootdir) {479tomlContent += `rootdir = "${toTomlPath(config.rootdir)}"\n`;480}481tomlContent += `destination = "${toTomlPath(config.destination)}"\n`;482tomlContent += `discover = [${discoverArray}]\n`;483Deno.writeTextFileSync(tempConfig, tomlContent);484configFileToUse = tempConfig;485}486487info(`Running typst-gather...`);488489// Run typst-gather490const result = await execProcess({491cmd: typstGatherBinary,492args: [configFileToUse],493cwd: Deno.cwd(),494});495496// Clean up temp file if we created one497if (tempConfig) {498try {499Deno.removeSync(tempConfig);500} catch {501// Ignore cleanup errors502}503}504505if (!result.success) {506// Print any output from the tool507if (result.stdout) {508console.log(result.stdout);509}510if (result.stderr) {511console.error(result.stderr);512}513514// Check for @local imports not configured error and suggest --init-config515// Only suggest if no config file was found516const output = (result.stdout || "") + (result.stderr || "");517if (518output.includes("@local imports not configured") && !config.configFile519) {520console.error("");521console.error(522"Tip: Run 'quarto call typst-gather --init-config' to generate a config file",523);524console.error(525" with placeholders for your @local package paths.",526);527}528529Deno.exit(1);530}531532info("Done!");533} catch (e) {534if (e instanceof Error) {535console.error(e.message);536} else {537console.error(String(e));538}539Deno.exit(1);540}541});542543544