Skip to content

Commit f909ffd

Browse files
Selective typst package staging using typst-gather analyze
Instead of copying ALL packages from built-in resources and extensions into the scratch directory, use typst-gather analyze to determine the exact set of packages needed (including transitive deps), then stage only those. - Create src/core/typst-gather.ts shared module (moved from cmd.ts) - Decompose stageTypstPackages into collectPackageSources, analyzeNeededPackages, stageSelectedPackages, stageAllPackages - Change from first-write-wins to last-write-wins so extensions can override built-in packages - Falls back to staging everything if typst-gather binary is missing or analyze fails - 14 unit tests for staging functions and TOML building - 1 smoke test verifying no packages staged for import-free documents Fixes #14157
1 parent 3e7c2b8 commit f909ffd

18 files changed

Lines changed: 951 additions & 97 deletions

File tree

src/command/call/typst-gather/cmd.ts

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@
77
import { Command } from "cliffy/command/mod.ts";
88
import { info } from "../../../deno_ral/log.ts";
99

10-
import { architectureToolsPath } from "../../../core/resources.ts";
1110
import { execProcess } from "../../../core/process.ts";
1211
import { dirname, join, relative } from "../../../deno_ral/path.ts";
1312
import { existsSync } from "../../../deno_ral/fs.ts";
14-
import { isWindows } from "../../../deno_ral/platform.ts";
1513
import { expandGlobSync } from "../../../core/deno/expand-glob.ts";
1614
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-
}
15+
import {
16+
type AnalyzeImport,
17+
type AnalyzeResult,
18+
runAnalyze,
19+
toTomlPath,
20+
typstGatherBinaryPath,
21+
} from "../../../core/typst-gather.ts";
2322

2423
interface ExtensionYml {
2524
contributes?: {
@@ -163,55 +162,7 @@ async function resolveConfig(
163162
};
164163
}
165164

166-
export interface AnalyzeImport {
167-
namespace: string;
168-
name: string;
169-
version: string;
170-
source: string;
171-
direct: boolean;
172-
}
173-
174-
export interface AnalyzeResult {
175-
imports: AnalyzeImport[];
176-
files: string[];
177-
}
178-
179-
function typstGatherBinaryPath(): string {
180-
const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";
181-
const binary = Deno.env.get("QUARTO_TYPST_GATHER") ||
182-
architectureToolsPath(binaryName);
183-
184-
if (!existsSync(binary)) {
185-
throw new Error(
186-
`typst-gather binary not found.\n` +
187-
`Run ./configure.sh to build and install it.`,
188-
);
189-
}
190-
191-
return binary;
192-
}
193-
194-
async function runAnalyze(tomlConfig: string): Promise<AnalyzeResult> {
195-
const binary = typstGatherBinaryPath();
196-
197-
const result = await execProcess(
198-
{
199-
cmd: binary,
200-
args: ["analyze", "-"],
201-
stdout: "piped",
202-
stderr: "piped",
203-
},
204-
tomlConfig,
205-
);
206-
207-
if (!result.success) {
208-
throw new Error(
209-
result.stderr || "typst-gather analyze failed",
210-
);
211-
}
212-
213-
return JSON.parse(result.stdout!) as AnalyzeResult;
214-
}
165+
export type { AnalyzeImport, AnalyzeResult };
215166

216167
export function generateConfigFromAnalysis(
217168
result: AnalyzeResult,

src/command/render/output-typst.ts

Lines changed: 120 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -49,70 +49,150 @@ import {
4949
TypstCompileOptions,
5050
validateRequiredTypstVersion,
5151
} from "../../core/typst.ts";
52+
import { runAnalyze, toTomlPath } from "../../core/typst-gather.ts";
5253
import { asArray } from "../../core/array.ts";
5354
import { ProjectContext } from "../../project/types.ts";
5455
import { validatePdfStandards } from "../../core/verapdf.ts";
5556

56-
// Stage typst packages to .quarto/typst-packages/
57-
// First stages built-in packages, then extension packages (which can override)
58-
async function stageTypstPackages(
59-
input: string,
60-
projectDir?: string,
61-
): Promise<string | undefined> {
62-
if (!projectDir) {
63-
return undefined;
64-
}
57+
export interface NeededPackage {
58+
namespace: string;
59+
name: string;
60+
version: string;
61+
}
6562

66-
const packageSources: string[] = [];
63+
// Collect all package source directories (built-in + extensions)
64+
async function collectPackageSources(
65+
input: string,
66+
projectDir: string,
67+
): Promise<string[]> {
68+
const sources: string[] = [];
6769

68-
// 1. Add built-in packages from quarto resources
70+
// 1. Built-in packages
6971
const builtinPackages = resourcePath("formats/typst/packages");
7072
if (existsSync(builtinPackages)) {
71-
packageSources.push(builtinPackages);
73+
sources.push(builtinPackages);
7274
}
7375

74-
// 2. Add packages from extensions (can override built-in)
76+
// 2. Extension packages
7577
const extensionDirs = inputExtensionDirs(input, projectDir);
7678
const subtreePath = builtinSubtreeExtensions();
7779
for (const extDir of extensionDirs) {
78-
// Use readSubtreeExtensions for subtree directory, readExtensions for others
7980
const extensions = extDir === subtreePath
8081
? await readSubtreeExtensions(extDir)
8182
: await readExtensions(extDir);
8283
for (const ext of extensions) {
8384
const packagesDir = join(ext.path, "typst/packages");
8485
if (existsSync(packagesDir)) {
85-
packageSources.push(packagesDir);
86+
sources.push(packagesDir);
8687
}
8788
}
8889
}
8990

90-
if (packageSources.length === 0) {
91-
return undefined;
91+
return sources;
92+
}
93+
94+
// Build the TOML config string for typst-gather analyze
95+
export function buildAnalyzeToml(
96+
typstInput: string,
97+
packageSources: string[],
98+
): string {
99+
const discoverPath = toTomlPath(typstInput);
100+
const cachePaths = packageSources.map((p) => `"${toTomlPath(p)}"`).join(", ");
101+
102+
return [
103+
`discover = ["${discoverPath}"]`,
104+
`package-cache = [${cachePaths}]`,
105+
].join("\n");
106+
}
107+
108+
// Run typst-gather analyze on the .typ file to determine needed packages
109+
async function analyzeNeededPackages(
110+
typstInput: string,
111+
packageSources: string[],
112+
): Promise<NeededPackage[] | null> {
113+
const tomlConfig = buildAnalyzeToml(typstInput, packageSources);
114+
115+
try {
116+
const result = await runAnalyze(tomlConfig);
117+
return result.imports.map(({ namespace, name, version }) => ({
118+
namespace,
119+
name,
120+
version,
121+
}));
122+
} catch {
123+
// Fallback: if analyze fails, stage everything (current behavior)
124+
warning("typst-gather analyze failed; staging all packages as fallback");
125+
return null;
92126
}
127+
}
93128

94-
// Stage to .quarto/typst/packages/
95-
const cacheDir = projectScratchPath(projectDir, "typst/packages");
129+
// Stage only the needed packages from source dirs into the cache dir.
130+
// Last write wins — extensions (listed after built-in) override built-in packages.
131+
export function stageSelectedPackages(
132+
sources: string[],
133+
cacheDir: string,
134+
needed: NeededPackage[] | null,
135+
): void {
136+
if (needed === null) {
137+
stageAllPackages(sources, cacheDir);
138+
return;
139+
}
96140

97-
// Copy contents of each source directory (merging namespaces like "preview", "local")
98-
for (const source of packageSources) {
99-
for (const entry of Deno.readDirSync(source)) {
100-
const srcPath = join(source, entry.name);
101-
const destPath = join(cacheDir, entry.name);
102-
if (!existsSync(destPath)) {
103-
copySync(srcPath, destPath);
104-
} else if (entry.isDirectory) {
105-
// Merge directory contents (e.g., merge packages within "preview" namespace)
106-
for (const subEntry of Deno.readDirSync(srcPath)) {
107-
const subSrcPath = join(srcPath, subEntry.name);
108-
const subDestPath = join(destPath, subEntry.name);
109-
if (!existsSync(subDestPath)) {
110-
copySync(subSrcPath, subDestPath);
111-
}
112-
}
141+
for (const pkg of needed) {
142+
const relPath = join(pkg.namespace, pkg.name, pkg.version);
143+
const destPath = join(cacheDir, relPath);
144+
145+
for (const source of sources) {
146+
const srcPath = join(source, relPath);
147+
if (existsSync(srcPath)) {
148+
ensureDirSync(dirname(destPath));
149+
copySync(srcPath, destPath, { overwrite: true });
113150
}
114151
}
115152
}
153+
}
154+
155+
// Fallback: copy all packages from all sources. Last write wins at the
156+
// package directory level. Built-in listed first, extensions after.
157+
export function stageAllPackages(sources: string[], cacheDir: string): void {
158+
for (const source of sources) {
159+
for (const nsEntry of Deno.readDirSync(source)) {
160+
if (!nsEntry.isDirectory) continue;
161+
const nsSrc = join(source, nsEntry.name);
162+
const nsDest = join(cacheDir, nsEntry.name);
163+
ensureDirSync(nsDest);
164+
for (const pkgEntry of Deno.readDirSync(nsSrc)) {
165+
const pkgSrc = join(nsSrc, pkgEntry.name);
166+
const pkgDest = join(nsDest, pkgEntry.name);
167+
copySync(pkgSrc, pkgDest, { overwrite: true });
168+
}
169+
}
170+
}
171+
}
172+
173+
// Stage typst packages to .quarto/typst-packages/
174+
// First stages built-in packages, then extension packages (which can override)
175+
async function stageTypstPackages(
176+
input: string,
177+
typstInput: string,
178+
projectDir?: string,
179+
): Promise<string | undefined> {
180+
if (!projectDir) {
181+
return undefined;
182+
}
183+
184+
const packageSources = await collectPackageSources(input, projectDir);
185+
if (packageSources.length === 0) {
186+
return undefined;
187+
}
188+
189+
const neededPackages = await analyzeNeededPackages(
190+
typstInput,
191+
packageSources,
192+
);
193+
194+
const cacheDir = projectScratchPath(projectDir, "typst/packages");
195+
stageSelectedPackages(packageSources, cacheDir, neededPackages);
116196

117197
return cacheDir;
118198
}
@@ -168,7 +248,11 @@ export function typstPdfOutputRecipe(
168248
typstOptions.rootDir = project.dir;
169249

170250
// Stage extension typst packages
171-
const packagePath = await stageTypstPackages(input, project.dir);
251+
const packagePath = await stageTypstPackages(
252+
input,
253+
typstInput,
254+
project.dir,
255+
);
172256
if (packagePath) {
173257
typstOptions.packagePath = packagePath;
174258
}

src/core/typst-gather.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* typst-gather.ts
3+
*
4+
* Shared infrastructure for typst-gather binary integration.
5+
*
6+
* Copyright (C) 2025 Posit Software, PBC
7+
*/
8+
9+
import { existsSync } from "../deno_ral/fs.ts";
10+
import { isWindows } from "../deno_ral/platform.ts";
11+
import { architectureToolsPath } from "./resources.ts";
12+
import { execProcess } from "./process.ts";
13+
14+
// Convert path to use forward slashes for TOML compatibility
15+
// TOML treats backslash as escape character, so Windows paths must use forward slashes
16+
export function toTomlPath(p: string): string {
17+
return p.replace(/\\/g, "/");
18+
}
19+
20+
export interface AnalyzeImport {
21+
namespace: string;
22+
name: string;
23+
version: string;
24+
source: string;
25+
direct: boolean;
26+
}
27+
28+
export interface AnalyzeResult {
29+
imports: AnalyzeImport[];
30+
files: string[];
31+
}
32+
33+
export function typstGatherBinaryPath(): string {
34+
const binaryName = isWindows ? "typst-gather.exe" : "typst-gather";
35+
const binary = Deno.env.get("QUARTO_TYPST_GATHER") ||
36+
architectureToolsPath(binaryName);
37+
38+
if (!existsSync(binary)) {
39+
throw new Error(
40+
`typst-gather binary not found.\n` +
41+
`Run ./configure.sh to build and install it.`,
42+
);
43+
}
44+
45+
return binary;
46+
}
47+
48+
export async function runAnalyze(tomlConfig: string): Promise<AnalyzeResult> {
49+
const binary = typstGatherBinaryPath();
50+
51+
const result = await execProcess(
52+
{
53+
cmd: binary,
54+
args: ["analyze", "-"],
55+
stdout: "piped",
56+
stderr: "piped",
57+
},
58+
tomlConfig,
59+
);
60+
61+
if (!result.success) {
62+
throw new Error(
63+
result.stderr || "typst-gather analyze failed",
64+
);
65+
}
66+
67+
return JSON.parse(result.stdout!) as AnalyzeResult;
68+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/.quarto/
2+
**/*.quarto_ipynb
3+
*.html
4+
*_files/
5+
*.pdf
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
project:
2+
type: default
3+
4+
format:
5+
typst:
6+
keep-typ: true
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
title: "Marginalia Only Test"
3+
papersize: us-letter
4+
---
5+
6+
Main content here.
7+
8+
[This note goes in the margin.]{.column-margin}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
project:
2+
type: default
3+
4+
format:
5+
typst:
6+
keep-typ: true
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: "No Packages Test"
3+
---
4+
5+
Hello world. This document uses no typst packages.

0 commit comments

Comments
 (0)