Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/call/typst-gather/cmd.ts
12926 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 { info } from "../../../deno_ral/log.ts";
9
10
import { execProcess } from "../../../core/process.ts";
11
import { dirname, join, relative } from "../../../deno_ral/path.ts";
12
import { existsSync } from "../../../deno_ral/fs.ts";
13
import { expandGlobSync } from "../../../core/deno/expand-glob.ts";
14
import { readYaml } from "../../../core/yaml.ts";
15
import {
16
type AnalyzeImport,
17
type AnalyzeResult,
18
runAnalyze,
19
toTomlPath,
20
typstGatherBinaryPath,
21
} from "../../../core/typst-gather.ts";
22
23
interface ExtensionYml {
24
contributes?: {
25
formats?: {
26
typst?: {
27
template?: string;
28
"template-partials"?: string[];
29
};
30
};
31
};
32
}
33
34
interface TypstGatherConfig {
35
configFile?: string; // Path to config file if one was found
36
rootdir?: string;
37
destination: string;
38
discover: string[];
39
}
40
41
async function findExtensionDir(): Promise<string | null> {
42
const cwd = Deno.cwd();
43
44
// Check if we're in an extension directory (has _extension.yml)
45
if (existsSync(join(cwd, "_extension.yml"))) {
46
return cwd;
47
}
48
49
// Check if there's an _extensions directory with a single extension
50
const extensionsDir = join(cwd, "_extensions");
51
if (existsSync(extensionsDir)) {
52
const extensionDirs: string[] = [];
53
for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {
54
extensionDirs.push(dirname(entry.path));
55
}
56
57
if (extensionDirs.length === 1) {
58
return extensionDirs[0];
59
} else if (extensionDirs.length > 1) {
60
console.error("Multiple extension directories found.\n");
61
console.error(
62
"Run this command from within a specific extension directory,",
63
);
64
console.error(
65
"or create a typst-gather.toml to specify the configuration.",
66
);
67
return null;
68
}
69
}
70
71
return null;
72
}
73
74
function extractTypstFiles(extensionDir: string): string[] {
75
const extensionYmlPath = join(extensionDir, "_extension.yml");
76
77
if (!existsSync(extensionYmlPath)) {
78
return [];
79
}
80
81
try {
82
const yml = readYaml(extensionYmlPath) as ExtensionYml;
83
const typstConfig = yml?.contributes?.formats?.typst;
84
85
if (!typstConfig) {
86
return [];
87
}
88
89
const files: string[] = [];
90
91
// Add template if specified
92
if (typstConfig.template) {
93
files.push(join(extensionDir, typstConfig.template));
94
}
95
96
// Add template-partials if specified
97
if (typstConfig["template-partials"]) {
98
for (const partial of typstConfig["template-partials"]) {
99
files.push(join(extensionDir, partial));
100
}
101
}
102
103
return files;
104
} catch {
105
return [];
106
}
107
}
108
109
async function resolveConfig(
110
extensionDir: string | null,
111
): Promise<TypstGatherConfig | null> {
112
const cwd = Deno.cwd();
113
114
// First, check for typst-gather.toml in current directory
115
const configPath = join(cwd, "typst-gather.toml");
116
if (existsSync(configPath)) {
117
info(`Using config: ${configPath}`);
118
return {
119
configFile: configPath,
120
destination: "",
121
discover: [],
122
};
123
}
124
125
// No config file - try to auto-detect from _extension.yml
126
if (!extensionDir) {
127
console.error(
128
"No typst-gather.toml found and no extension directory detected.\n",
129
);
130
console.error("Either:");
131
console.error(" 1. Create a typst-gather.toml file, or");
132
console.error(
133
" 2. Run from within an extension directory with _extension.yml",
134
);
135
return null;
136
}
137
138
const typstFiles = extractTypstFiles(extensionDir);
139
140
if (typstFiles.length === 0) {
141
console.error("No Typst files found in _extension.yml.\n");
142
console.error(
143
"The extension must define 'template' or 'template-partials' under contributes.formats.typst",
144
);
145
return null;
146
}
147
148
// Default destination is 'typst/packages' directory in extension folder
149
const destination = join(extensionDir, "typst/packages");
150
151
// Show paths relative to cwd for cleaner output
152
const relDest = relative(cwd, destination);
153
const relFiles = typstFiles.map((f) => relative(cwd, f));
154
155
info(`Auto-detected from _extension.yml:`);
156
info(` Destination: ${relDest}`);
157
info(` Files to scan: ${relFiles.join(", ")}`);
158
159
return {
160
destination,
161
discover: typstFiles,
162
};
163
}
164
165
export type { AnalyzeImport, AnalyzeResult };
166
167
export function generateConfigFromAnalysis(
168
result: AnalyzeResult,
169
rootdir?: string,
170
): string {
171
const lines: string[] = [];
172
173
lines.push("# typst-gather configuration");
174
lines.push("# Run: quarto call typst-gather");
175
lines.push("");
176
177
if (rootdir) {
178
lines.push(`rootdir = "${toTomlPath(rootdir)}"`);
179
}
180
lines.push('destination = "typst/packages"');
181
lines.push("");
182
183
// Discover section
184
if (result.files.length === 1) {
185
lines.push(`discover = "${toTomlPath(result.files[0])}"`);
186
} else if (result.files.length > 1) {
187
const files = result.files.map((f) => `"${toTomlPath(f)}"`).join(", ");
188
lines.push(`discover = [${files}]`);
189
} else {
190
lines.push('# discover = "template.typ" # Add your .typ files here');
191
}
192
193
lines.push("");
194
195
// Preview section (commented out - packages will be auto-discovered)
196
const previewImports = result.imports.filter((i) =>
197
i.namespace === "preview"
198
);
199
lines.push("# Preview packages are auto-discovered from imports.");
200
lines.push("# Uncomment to pin specific versions:");
201
lines.push("# [preview]");
202
if (previewImports.length > 0) {
203
const seen = new Set<string>();
204
for (const { name, version, direct, source } of previewImports) {
205
if (!seen.has(name)) {
206
seen.add(name);
207
const suffix = direct ? "" : ` # via ${source}`;
208
lines.push(`# ${name} = "${version}"${suffix}`);
209
}
210
}
211
} else {
212
lines.push('# cetz = "0.4.1"');
213
}
214
215
lines.push("");
216
217
// Local section
218
const localImports = result.imports.filter(
219
(i) => i.namespace === "local" && i.direct,
220
);
221
lines.push(
222
"# Local packages (@local namespace) must be configured manually.",
223
);
224
if (localImports.length > 0) {
225
lines.push("# Found @local imports:");
226
const seen = new Set<string>();
227
for (const { name, version, source } of localImports) {
228
if (!seen.has(name)) {
229
seen.add(name);
230
lines.push(`# @local/${name}:${version} (in ${source})`);
231
}
232
}
233
lines.push("[local]");
234
seen.clear();
235
for (const { name } of localImports) {
236
if (!seen.has(name)) {
237
seen.add(name);
238
lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`);
239
}
240
}
241
} else {
242
lines.push("# [local]");
243
lines.push('# my-pkg = "/path/to/my-pkg"');
244
}
245
246
lines.push("");
247
return lines.join("\n");
248
}
249
250
async function initConfig(): Promise<void> {
251
const configFile = join(Deno.cwd(), "typst-gather.toml");
252
253
// Check if config already exists
254
if (existsSync(configFile)) {
255
console.error("typst-gather.toml already exists");
256
console.error("Remove it first or edit it manually.");
257
Deno.exit(1);
258
}
259
260
// Find typst files via extension directory structure
261
const extensionDir = await findExtensionDir();
262
263
if (!extensionDir) {
264
console.error("No extension directory found.");
265
console.error(
266
"Run this command from a directory containing _extension.yml or _extensions/",
267
);
268
Deno.exit(1);
269
}
270
271
const typFiles = extractTypstFiles(extensionDir);
272
273
if (typFiles.length === 0) {
274
info("Warning: No .typ files found in _extension.yml.");
275
info(
276
"Edit the generated typst-gather.toml to configure local or pinned dependencies.",
277
);
278
} else {
279
info(`Found extension: ${extensionDir}`);
280
}
281
282
// Build analyze config with discover paths
283
const discoverArray = typFiles.map((f) => `"${toTomlPath(f)}"`).join(", ");
284
const analyzeConfig = `discover = [${discoverArray}]\n`;
285
286
// Run typst-gather analyze to discover imports
287
const analysis = await runAnalyze(analyzeConfig);
288
289
// Calculate relative path from cwd to extension dir for rootdir
290
const rootdir = relative(Deno.cwd(), extensionDir);
291
292
// Generate config content from analysis
293
const configContent = generateConfigFromAnalysis(analysis, rootdir);
294
295
// Write config file
296
try {
297
Deno.writeTextFileSync(configFile, configContent);
298
} catch (e) {
299
console.error(`Error writing typst-gather.toml: ${e}`);
300
Deno.exit(1);
301
}
302
303
const previewImports = analysis.imports.filter(
304
(i) => i.namespace === "preview",
305
);
306
const localImports = analysis.imports.filter(
307
(i) => i.namespace === "local" && i.direct,
308
);
309
310
info("Created typst-gather.toml");
311
if (analysis.files.length > 0) {
312
info(` Scanned: ${analysis.files.join(", ")}`);
313
}
314
if (previewImports.length > 0) {
315
info(` Found ${previewImports.length} @preview import(s)`);
316
}
317
if (localImports.length > 0) {
318
info(
319
` Found ${localImports.length} @local import(s) - configure paths in [local] section`,
320
);
321
}
322
323
info("");
324
info("Next steps:");
325
info(" 1. Review and edit typst-gather.toml");
326
if (localImports.length > 0) {
327
info(" 2. Add paths for @local packages in [local] section");
328
}
329
info(" 3. Run: quarto call typst-gather");
330
}
331
332
export const typstGatherCommand = new Command()
333
.name("typst-gather")
334
.description(
335
"Gather Typst packages for a format extension.\n\n" +
336
"This command scans Typst files for @preview imports and downloads " +
337
"the packages to a local directory for offline use.\n\n" +
338
"Configuration is determined by:\n" +
339
" 1. typst-gather.toml in current directory (if present)\n" +
340
" 2. Auto-detection from _extension.yml (template and template-partials)",
341
)
342
.option(
343
"--init-config",
344
"Generate a starter typst-gather.toml in current directory",
345
)
346
.action(async (options: { initConfig?: boolean }) => {
347
// Handle --init-config
348
if (options.initConfig) {
349
await initConfig();
350
return;
351
}
352
try {
353
// Find extension directory
354
const extensionDir = await findExtensionDir();
355
356
// Resolve configuration
357
const config = await resolveConfig(extensionDir);
358
if (!config) {
359
Deno.exit(1);
360
}
361
362
const typstGatherBinary = typstGatherBinaryPath();
363
364
info(`Running typst-gather...`);
365
366
// Run typst-gather gather
367
let result;
368
if (config.configFile) {
369
// Existing config file — pass directly
370
result = await execProcess({
371
cmd: typstGatherBinary,
372
args: ["gather", config.configFile],
373
cwd: Deno.cwd(),
374
});
375
} else {
376
// Auto-detected — pipe config on stdin
377
const discoverArray = config.discover.map((p) => `"${toTomlPath(p)}"`)
378
.join(", ");
379
let tomlContent = "";
380
if (config.rootdir) {
381
tomlContent += `rootdir = "${toTomlPath(config.rootdir)}"\n`;
382
}
383
tomlContent += `destination = "${toTomlPath(config.destination)}"\n`;
384
tomlContent += `discover = [${discoverArray}]\n`;
385
386
result = await execProcess(
387
{
388
cmd: typstGatherBinary,
389
args: ["gather", "-"],
390
cwd: Deno.cwd(),
391
},
392
tomlContent,
393
);
394
}
395
396
if (!result.success) {
397
// Print any output from the tool
398
if (result.stdout) {
399
console.log(result.stdout);
400
}
401
if (result.stderr) {
402
console.error(result.stderr);
403
}
404
405
// Check for @local imports not configured error and suggest --init-config
406
// Only suggest if no config file was found
407
const output = (result.stdout || "") + (result.stderr || "");
408
if (
409
output.includes("@local imports not configured") && !config.configFile
410
) {
411
console.error("");
412
console.error(
413
"Tip: Run 'quarto call typst-gather --init-config' to generate a config file",
414
);
415
console.error(
416
" with placeholders for your @local package paths.",
417
);
418
}
419
420
Deno.exit(1);
421
}
422
423
info("Done!");
424
} catch (e) {
425
if (e instanceof Error) {
426
console.error(e.message);
427
} else {
428
console.error(String(e));
429
}
430
Deno.exit(1);
431
}
432
});
433
434