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
6456 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 { architectureToolsPath } from "../../../core/resources.ts";
11
import { execProcess } from "../../../core/process.ts";
12
import { dirname, join, relative } from "../../../deno_ral/path.ts";
13
import { existsSync } from "../../../deno_ral/fs.ts";
14
import { isWindows } from "../../../deno_ral/platform.ts";
15
import { expandGlobSync } from "../../../core/deno/expand-glob.ts";
16
import { readYaml } from "../../../core/yaml.ts";
17
18
// Convert path to use forward slashes for TOML compatibility
19
// TOML treats backslash as escape character, so Windows paths must use forward slashes
20
function toTomlPath(p: string): string {
21
return p.replace(/\\/g, "/");
22
}
23
24
interface ExtensionYml {
25
contributes?: {
26
formats?: {
27
typst?: {
28
template?: string;
29
"template-partials"?: string[];
30
};
31
};
32
};
33
}
34
35
interface TypstGatherConfig {
36
configFile?: string; // Path to config file if one was found
37
rootdir?: string;
38
destination: string;
39
discover: string[];
40
}
41
42
async function findExtensionDir(): Promise<string | null> {
43
const cwd = Deno.cwd();
44
45
// Check if we're in an extension directory (has _extension.yml)
46
if (existsSync(join(cwd, "_extension.yml"))) {
47
return cwd;
48
}
49
50
// Check if there's an _extensions directory with a single extension
51
const extensionsDir = join(cwd, "_extensions");
52
if (existsSync(extensionsDir)) {
53
const extensionDirs: string[] = [];
54
for (const entry of expandGlobSync("_extensions/**/_extension.yml")) {
55
extensionDirs.push(dirname(entry.path));
56
}
57
58
if (extensionDirs.length === 1) {
59
return extensionDirs[0];
60
} else if (extensionDirs.length > 1) {
61
console.error("Multiple extension directories found.\n");
62
console.error(
63
"Run this command from within a specific extension directory,",
64
);
65
console.error(
66
"or create a typst-gather.toml to specify the configuration.",
67
);
68
return null;
69
}
70
}
71
72
return null;
73
}
74
75
function extractTypstFiles(extensionDir: string): string[] {
76
const extensionYmlPath = join(extensionDir, "_extension.yml");
77
78
if (!existsSync(extensionYmlPath)) {
79
return [];
80
}
81
82
try {
83
const yml = readYaml(extensionYmlPath) as ExtensionYml;
84
const typstConfig = yml?.contributes?.formats?.typst;
85
86
if (!typstConfig) {
87
return [];
88
}
89
90
const files: string[] = [];
91
92
// Add template if specified
93
if (typstConfig.template) {
94
files.push(join(extensionDir, typstConfig.template));
95
}
96
97
// Add template-partials if specified
98
if (typstConfig["template-partials"]) {
99
for (const partial of typstConfig["template-partials"]) {
100
files.push(join(extensionDir, partial));
101
}
102
}
103
104
return files;
105
} catch {
106
return [];
107
}
108
}
109
110
async function resolveConfig(
111
extensionDir: string | null,
112
): Promise<TypstGatherConfig | null> {
113
const cwd = Deno.cwd();
114
115
// First, check for typst-gather.toml in current directory
116
const configPath = join(cwd, "typst-gather.toml");
117
if (existsSync(configPath)) {
118
info(`Using config: ${configPath}`);
119
// Return the config file path - rust will parse it directly
120
// We still parse minimally to validate and show info
121
const content = Deno.readTextFileSync(configPath);
122
const config = parseSimpleToml(content);
123
config.configFile = configPath;
124
return config;
125
}
126
127
// No config file - try to auto-detect from _extension.yml
128
if (!extensionDir) {
129
console.error(
130
"No typst-gather.toml found and no extension directory detected.\n",
131
);
132
console.error("Either:");
133
console.error(" 1. Create a typst-gather.toml file, or");
134
console.error(
135
" 2. Run from within an extension directory with _extension.yml",
136
);
137
return null;
138
}
139
140
const typstFiles = extractTypstFiles(extensionDir);
141
142
if (typstFiles.length === 0) {
143
console.error("No Typst files found in _extension.yml.\n");
144
console.error(
145
"The extension must define 'template' or 'template-partials' under contributes.formats.typst",
146
);
147
return null;
148
}
149
150
// Default destination is 'typst/packages' directory in extension folder
151
const destination = join(extensionDir, "typst/packages");
152
153
// Show paths relative to cwd for cleaner output
154
const relDest = relative(cwd, destination);
155
const relFiles = typstFiles.map((f) => relative(cwd, f));
156
157
info(`Auto-detected from _extension.yml:`);
158
info(` Destination: ${relDest}`);
159
info(` Files to scan: ${relFiles.join(", ")}`);
160
161
return {
162
destination,
163
discover: typstFiles,
164
};
165
}
166
167
function parseSimpleToml(content: string): TypstGatherConfig {
168
const lines = content.split("\n");
169
let rootdir: string | undefined;
170
let destination = "";
171
const discover: string[] = [];
172
173
for (const line of lines) {
174
const trimmed = line.trim();
175
176
// Parse rootdir
177
const rootdirMatch = trimmed.match(/^rootdir\s*=\s*"([^"]+)"/);
178
if (rootdirMatch) {
179
rootdir = rootdirMatch[1];
180
continue;
181
}
182
183
// Parse destination
184
const destMatch = trimmed.match(/^destination\s*=\s*"([^"]+)"/);
185
if (destMatch) {
186
destination = destMatch[1];
187
continue;
188
}
189
190
// Parse discover as string
191
const discoverStrMatch = trimmed.match(/^discover\s*=\s*"([^"]+)"/);
192
if (discoverStrMatch) {
193
discover.push(discoverStrMatch[1]);
194
continue;
195
}
196
197
// Parse discover as array (simple single-line parsing)
198
const discoverArrMatch = trimmed.match(/^discover\s*=\s*\[([^\]]+)\]/);
199
if (discoverArrMatch) {
200
const items = discoverArrMatch[1].split(",");
201
for (const item of items) {
202
const match = item.trim().match(/"([^"]+)"/);
203
if (match) {
204
discover.push(match[1]);
205
}
206
}
207
}
208
}
209
210
return { rootdir, destination, discover };
211
}
212
213
interface DiscoveredImport {
214
name: string;
215
version: string;
216
sourceFile: string;
217
}
218
219
interface DiscoveryResult {
220
preview: DiscoveredImport[];
221
local: DiscoveredImport[];
222
scannedFiles: string[];
223
}
224
225
function discoverImportsFromFiles(files: string[]): DiscoveryResult {
226
const result: DiscoveryResult = {
227
preview: [],
228
local: [],
229
scannedFiles: [],
230
};
231
232
// Regex to match @namespace/name:version imports
233
// Note: #include is for files, not packages, so we only match #import
234
const importRegex = /#import\s+"@(\w+)\/([^:]+):([^"]+)"/g;
235
236
for (const file of files) {
237
if (!existsSync(file)) continue;
238
if (!file.endsWith(".typ")) continue;
239
240
const filename = file.split("/").pop() || file;
241
result.scannedFiles.push(filename);
242
243
try {
244
const content = Deno.readTextFileSync(file);
245
let match;
246
while ((match = importRegex.exec(content)) !== null) {
247
const [, namespace, name, version] = match;
248
const entry = { name, version, sourceFile: filename };
249
250
if (namespace === "preview") {
251
result.preview.push(entry);
252
} else if (namespace === "local") {
253
result.local.push(entry);
254
}
255
}
256
} catch {
257
// Skip files that can't be read
258
}
259
}
260
261
return result;
262
}
263
264
function generateConfigContent(
265
discovery: DiscoveryResult,
266
rootdir?: string,
267
): string {
268
const lines: string[] = [];
269
270
lines.push("# typst-gather configuration");
271
lines.push("# Run: quarto call typst-gather");
272
lines.push("");
273
274
if (rootdir) {
275
lines.push(`rootdir = "${toTomlPath(rootdir)}"`);
276
}
277
lines.push('destination = "typst/packages"');
278
lines.push("");
279
280
// Discover section
281
if (discovery.scannedFiles.length > 0) {
282
if (discovery.scannedFiles.length === 1) {
283
lines.push(`discover = "${toTomlPath(discovery.scannedFiles[0])}"`);
284
} else {
285
const files = discovery.scannedFiles.map((f) => `"${toTomlPath(f)}"`)
286
.join(", ");
287
lines.push(`discover = [${files}]`);
288
}
289
} else {
290
lines.push('# discover = "template.typ" # Add your .typ files here');
291
}
292
293
lines.push("");
294
295
// Preview section (commented out - packages will be auto-discovered)
296
lines.push("# Preview packages are auto-discovered from imports.");
297
lines.push("# Uncomment to pin specific versions:");
298
lines.push("# [preview]");
299
if (discovery.preview.length > 0) {
300
// Deduplicate
301
const seen = new Set<string>();
302
for (const { name, version } of discovery.preview) {
303
if (!seen.has(name)) {
304
seen.add(name);
305
lines.push(`# ${name} = "${version}"`);
306
}
307
}
308
} else {
309
lines.push('# cetz = "0.4.1"');
310
}
311
312
lines.push("");
313
314
// Local section
315
lines.push(
316
"# Local packages (@local namespace) must be configured manually.",
317
);
318
if (discovery.local.length > 0) {
319
lines.push("# Found @local imports:");
320
const seen = new Set<string>();
321
for (const { name, version, sourceFile } of discovery.local) {
322
if (!seen.has(name)) {
323
seen.add(name);
324
lines.push(`# @local/${name}:${version} (in ${sourceFile})`);
325
}
326
}
327
lines.push("[local]");
328
seen.clear();
329
for (const { name } of discovery.local) {
330
if (!seen.has(name)) {
331
seen.add(name);
332
lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`);
333
}
334
}
335
} else {
336
lines.push("# [local]");
337
lines.push('# my-pkg = "/path/to/my-pkg"');
338
}
339
340
lines.push("");
341
return lines.join("\n");
342
}
343
344
async function initConfig(): Promise<void> {
345
const configFile = join(Deno.cwd(), "typst-gather.toml");
346
347
// Check if config already exists
348
if (existsSync(configFile)) {
349
console.error("typst-gather.toml already exists");
350
console.error("Remove it first or edit it manually.");
351
Deno.exit(1);
352
}
353
354
// Find typst files via extension directory structure
355
const extensionDir = await findExtensionDir();
356
357
if (!extensionDir) {
358
console.error("No extension directory found.");
359
console.error(
360
"Run this command from a directory containing _extension.yml or _extensions/",
361
);
362
Deno.exit(1);
363
}
364
365
const typFiles = extractTypstFiles(extensionDir);
366
367
if (typFiles.length === 0) {
368
info("Warning: No .typ files found in _extension.yml.");
369
info(
370
"Edit the generated typst-gather.toml to configure local or pinned dependencies.",
371
);
372
} else {
373
info(`Found extension: ${extensionDir}`);
374
}
375
376
// Discover imports from the files
377
const discovery = discoverImportsFromFiles(typFiles);
378
379
// Calculate relative path from cwd to extension dir for rootdir
380
const rootdir = relative(Deno.cwd(), extensionDir);
381
382
// Generate config content
383
const configContent = generateConfigContent(discovery, rootdir);
384
385
// Write config file
386
try {
387
Deno.writeTextFileSync(configFile, configContent);
388
} catch (e) {
389
console.error(`Error writing typst-gather.toml: ${e}`);
390
Deno.exit(1);
391
}
392
393
info("Created typst-gather.toml");
394
if (discovery.scannedFiles.length > 0) {
395
info(` Scanned: ${discovery.scannedFiles.join(", ")}`);
396
}
397
if (discovery.preview.length > 0) {
398
info(` Found ${discovery.preview.length} @preview import(s)`);
399
}
400
if (discovery.local.length > 0) {
401
info(
402
` Found ${discovery.local.length} @local import(s) - configure paths in [local] section`,
403
);
404
}
405
406
info("");
407
info("Next steps:");
408
info(" 1. Review and edit typst-gather.toml");
409
if (discovery.local.length > 0) {
410
info(" 2. Add paths for @local packages in [local] section");
411
}
412
info(" 3. Run: quarto call typst-gather");
413
}
414
415
export const typstGatherCommand = new Command()
416
.name("typst-gather")
417
.description(
418
"Gather Typst packages for a format extension.\n\n" +
419
"This command scans Typst files for @preview imports and downloads " +
420
"the packages to a local directory for offline use.\n\n" +
421
"Configuration is determined by:\n" +
422
" 1. typst-gather.toml in current directory (if present)\n" +
423
" 2. Auto-detection from _extension.yml (template and template-partials)",
424
)
425
.option(
426
"--init-config",
427
"Generate a starter typst-gather.toml in current directory",
428
)
429
.action(async (options: { initConfig?: boolean }) => {
430
// Handle --init-config
431
if (options.initConfig) {
432
await initConfig();
433
return;
434
}
435
try {
436
// Find extension directory
437
const extensionDir = await findExtensionDir();
438
439
// Resolve configuration
440
const config = await resolveConfig(extensionDir);
441
if (!config) {
442
Deno.exit(1);
443
}
444
445
if (!config.destination) {
446
console.error("No destination specified in configuration.");
447
Deno.exit(1);
448
}
449
450
if (config.discover.length === 0) {
451
console.error("No files to discover imports from.");
452
Deno.exit(1);
453
}
454
455
// Find typst-gather binary in standard tools location
456
const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";
457
const typstGatherBinary = architectureToolsPath(binaryName);
458
if (!existsSync(typstGatherBinary)) {
459
console.error(
460
`typst-gather binary not found.\n` +
461
`Run ./configure.sh to build and install it.`,
462
);
463
Deno.exit(1);
464
}
465
466
// Determine config file to use
467
let configFileToUse: string;
468
let tempConfig: string | null = null;
469
470
if (config.configFile) {
471
// Use existing config file directly - rust will parse [local], [preview], etc.
472
configFileToUse = config.configFile;
473
} else {
474
// Create a temporary TOML config file for auto-detected config
475
tempConfig = Deno.makeTempFileSync({ suffix: ".toml" });
476
const discoverArray = config.discover.map((p) => `"${toTomlPath(p)}"`)
477
.join(", ");
478
let tomlContent = "";
479
if (config.rootdir) {
480
tomlContent += `rootdir = "${toTomlPath(config.rootdir)}"\n`;
481
}
482
tomlContent += `destination = "${toTomlPath(config.destination)}"\n`;
483
tomlContent += `discover = [${discoverArray}]\n`;
484
Deno.writeTextFileSync(tempConfig, tomlContent);
485
configFileToUse = tempConfig;
486
}
487
488
info(`Running typst-gather...`);
489
490
// Run typst-gather
491
const result = await execProcess({
492
cmd: typstGatherBinary,
493
args: [configFileToUse],
494
cwd: Deno.cwd(),
495
});
496
497
// Clean up temp file if we created one
498
if (tempConfig) {
499
try {
500
Deno.removeSync(tempConfig);
501
} catch {
502
// Ignore cleanup errors
503
}
504
}
505
506
if (!result.success) {
507
// Print any output from the tool
508
if (result.stdout) {
509
console.log(result.stdout);
510
}
511
if (result.stderr) {
512
console.error(result.stderr);
513
}
514
515
// Check for @local imports not configured error and suggest --init-config
516
// Only suggest if no config file was found
517
const output = (result.stdout || "") + (result.stderr || "");
518
if (
519
output.includes("@local imports not configured") && !config.configFile
520
) {
521
console.error("");
522
console.error(
523
"Tip: Run 'quarto call typst-gather --init-config' to generate a config file",
524
);
525
console.error(
526
" with placeholders for your @local package paths.",
527
);
528
}
529
530
Deno.exit(1);
531
}
532
533
info("Done!");
534
} catch (e) {
535
if (e instanceof Error) {
536
console.error(e.message);
537
} else {
538
console.error(String(e));
539
}
540
Deno.exit(1);
541
}
542
});
543
544