Skip to main content

typst_batch/process/
compile.rs

1//! High-level compilation API for Typst to HTML.
2//!
3//! # Example
4//!
5//! ```ignore
6//! use typst_batch::Compiler;
7//! use std::path::Path;
8//!
9//! // Simple compilation
10//! let result = Compiler::new(Path::new("."))
11//!     .with_path(Path::new("doc.typ"))
12//!     .compile()?;
13//!
14//! // With sys.inputs
15//! let result = Compiler::new(Path::new("."))
16//!     .with_inputs([("title", "Hello")])
17//!     .with_path(Path::new("doc.typ"))
18//!     .compile()?;
19//!
20//! // Custom World (advanced)
21//! let result = Compiler::new(Path::new("."))
22//!     .with_path(Path::new("doc.typ"))
23//!     .with_world(|main, root| {
24//!         TypstWorld::builder(main, root)
25//!             .with_local_cache()
26//!             .with_fonts()
27//!             .build()
28//!     })
29//!     .compile()?;
30//!
31//! // Batch compilation (requires `batch` feature)
32//! let batcher = Compiler::new(Path::new("."))
33//!     .into_batch()
34//!     .with_snapshot_from(&[path1, path2, path3])?;
35//!
36//! let results = batcher.batch_compile(&[path1, path2, path3])?;
37//! ```
38
39use std::path::{Path, PathBuf};
40
41use typst::foundations::Dict;
42
43use crate::diagnostic::{filter_html_warnings, has_errors, CompileError, Diagnostics};
44use crate::html::HtmlDocument;
45use crate::world::TypstWorld;
46
47use super::inputs::WithInputs;
48use super::session::{AccessedDeps, CompileSession};
49use crate::resource::file::PackageId;
50
51/// Type alias for custom World builder function.
52type WorldBuilderFn<'a> = Box<dyn FnOnce(MainPath<'_>, RootPath<'_>) -> TypstWorld + 'a>;
53
54
55
56/// Wrapper for the main file path, ensuring type safety.
57///
58/// Users cannot construct this directly; it's only created internally
59/// and passed to closures in `with_world()`.
60pub struct MainPath<'a>(&'a Path);
61
62impl<'a> MainPath<'a> {
63    /// Get the underlying path.
64    pub fn as_path(&self) -> &'a Path {
65        self.0
66    }
67}
68
69impl AsRef<Path> for MainPath<'_> {
70    fn as_ref(&self) -> &Path {
71        self.0
72    }
73}
74
75/// Wrapper for the root directory path, ensuring type safety.
76///
77/// Users cannot construct this directly; it's only created internally
78/// and passed to closures in `with_world()`.
79pub struct RootPath<'a>(&'a Path);
80
81impl<'a> RootPath<'a> {
82    /// Get the underlying path.
83    pub fn as_path(&self) -> &'a Path {
84        self.0
85    }
86}
87
88impl AsRef<Path> for RootPath<'_> {
89    fn as_ref(&self) -> &Path {
90        self.0
91    }
92}
93
94
95
96/// Builder for Typst compilation.
97///
98/// This is the entry point for all compilation operations. Use method chaining
99/// to configure and then either:
100/// - Call `with_path()` for single-file compilation → [`SingleCompiler`]
101/// - Call `into_batch()` for parallel batch compilation → [`BatchCompiler`]
102///
103/// # Example
104///
105/// ```ignore
106/// // Single file
107/// Compiler::new(root)
108///     .with_inputs([("key", "value")])
109///     .with_path(path)
110///     .compile()?;
111///
112/// // Batch
113/// Compiler::new(root)
114///     .into_batch()
115///     .with_snapshot_from(&files)?
116///     .batch_compile(&files)?;
117/// ```
118pub struct Compiler<'a> {
119    root: &'a Path,
120    inputs: Option<Dict>,
121    preludes: Vec<String>,
122    postludes: Vec<String>,
123}
124
125impl<'a> WithInputs for Compiler<'a> {
126    fn inputs_mut(&mut self) -> &mut Option<Dict> {
127        &mut self.inputs
128    }
129}
130
131impl<'a> Compiler<'a> {
132    /// Create a new compiler with the given root directory.
133    pub fn new(root: &'a Path) -> Self {
134        Self {
135            root,
136            inputs: None,
137            preludes: Vec::new(),
138            postludes: Vec::new(),
139        }
140    }
141
142    /// Add prelude code to inject at the beginning of the main file.
143    pub fn with_prelude(mut self, prelude: impl Into<String>) -> Self {
144        self.preludes.push(prelude.into());
145        self
146    }
147
148    /// Add postlude code to inject at the end of the main file.
149    pub fn with_postlude(mut self, postlude: impl Into<String>) -> Self {
150        self.postludes.push(postlude.into());
151        self
152    }
153
154    /// Set the file to compile, returning a [`SingleCompiler`].
155    pub fn with_path<P: AsRef<Path>>(self, path: P) -> SingleCompiler<'a> {
156        SingleCompiler {
157            root: self.root,
158            path: path.as_ref().to_path_buf(),
159            inputs: self.inputs,
160            preludes: self.preludes,
161            postludes: self.postludes,
162            world_builder: None,
163        }
164    }
165
166    /// Convert to batch compilation mode.
167    ///
168    /// Returns a [`Batcher`](super::batch::Batcher) for parallel compilation with snapshot optimization.
169    /// Any `with_inputs()` settings are inherited.
170    ///
171    /// **Note**: Batch mode uses lock-free snapshot caching internally.
172    /// Custom `with_world()` settings from single-file mode do not apply.
173    #[cfg(feature = "batch")]
174    pub fn into_batch(self) -> super::batch::Batcher<'a> {
175        let mut batcher = super::batch::Batcher::new(self.root);
176        if let Some(inputs) = self.inputs {
177            batcher = batcher.with_inputs_dict(inputs);
178        }
179        batcher.preludes = self.preludes;
180        batcher.postludes = self.postludes;
181        batcher
182    }
183}
184
185
186
187/// Builder for single-file compilation.
188///
189/// Created via `Compiler::new(root).with_path(path)`.
190///
191/// # Custom World
192///
193/// By default, uses shared cache mode (suitable for serve/hot-reload).
194/// For custom caching strategies, use `with_world()`:
195///
196/// ```ignore
197/// Compiler::new(root)
198///     .with_path(path)
199///     .with_world(|main, root| {
200///         TypstWorld::builder(main, root)
201///             .with_local_cache()  // No shared state
202///             .with_fonts()
203///             .build()
204///     })
205///     .compile()?;
206/// ```
207pub struct SingleCompiler<'a> {
208    root: &'a Path,
209    path: PathBuf,
210    inputs: Option<Dict>,
211    preludes: Vec<String>,
212    postludes: Vec<String>,
213    world_builder: Option<WorldBuilderFn<'a>>,
214}
215
216impl<'a> WithInputs for SingleCompiler<'a> {
217    fn inputs_mut(&mut self) -> &mut Option<Dict> {
218        &mut self.inputs
219    }
220}
221
222impl<'a> SingleCompiler<'a> {
223    /// Add prelude code to inject at the beginning of the main file.
224    pub fn with_prelude(mut self, prelude: impl Into<String>) -> Self {
225        self.preludes.push(prelude.into());
226        self
227    }
228
229    /// Add postlude code to inject at the end of the main file.
230    pub fn with_postlude(mut self, postlude: impl Into<String>) -> Self {
231        self.postludes.push(postlude.into());
232        self
233    }
234
235    /// Provide a custom World builder.
236    ///
237    /// The closure receives type-safe path wrappers that can only be obtained
238    /// through this API, ensuring the World's paths match the compiler's paths.
239    ///
240    /// # Example
241    ///
242    /// ```ignore
243    /// Compiler::new(root)
244    ///     .with_path(path)
245    ///     .with_world(|main, root| {
246    ///         TypstWorld::builder(main, root)
247    ///             .with_local_cache()
248    ///             .with_fonts()
249    ///             .build()
250    ///     })
251    ///     .compile()?;
252    /// ```
253    pub fn with_world<F>(mut self, f: F) -> Self
254    where
255        F: FnOnce(MainPath<'_>, RootPath<'_>) -> TypstWorld + 'a,
256    {
257        self.world_builder = Some(Box::new(f));
258        self
259    }
260
261    /// Compile the file.
262    pub fn compile(self) -> Result<CompileResult, CompileError> {
263        let world = match self.world_builder {
264            Some(builder) => builder(MainPath(&self.path), RootPath(self.root)),
265            None => self.default_world(),
266        };
267        compile_with_world(&world)
268    }
269
270    fn default_world(&self) -> TypstWorld {
271        let mut builder = TypstWorld::builder(&self.path, self.root)
272            .with_shared_cache()
273            .with_fonts();
274
275        if let Some(inputs) = &self.inputs {
276            builder = builder.with_inputs_dict(inputs.clone());
277        }
278
279        // Build combined prelude: styles + scripts + user preludes
280        let combined_prelude = self.build_prelude();
281        if !combined_prelude.is_empty() {
282            builder = builder.with_prelude(combined_prelude);
283        }
284
285        // Build combined postlude
286        let combined_postlude = self.postludes.join("\n");
287        if !combined_postlude.is_empty() {
288            builder = builder.with_postlude(combined_postlude);
289        }
290
291        builder.build()
292    }
293
294    fn build_prelude(&self) -> String {
295        self.preludes.join("\n")
296    }
297}
298
299
300
301/// Result of a successful compilation.
302#[derive(Debug)]
303pub struct CompileResult {
304    document: HtmlDocument,
305    accessed: AccessedDeps,
306    diagnostics: Diagnostics,
307}
308
309impl CompileResult {
310    /// Get the compiled HTML document.
311    pub fn document(&self) -> &HtmlDocument {
312        &self.document
313    }
314
315    /// Convert the document to HTML bytes.
316    pub fn html(&self) -> Result<Vec<u8>, CompileError> {
317        typst_html::html(self.document.as_inner())
318            .map(|s| s.into_bytes())
319            .map_err(|e| CompileError::html_export(format!("{e:?}")))
320    }
321
322    /// Get files and packages accessed during compilation.
323    pub fn accessed(&self) -> &AccessedDeps {
324        &self.accessed
325    }
326
327    /// Get files accessed during compilation.
328    pub fn accessed_files(&self) -> &[PathBuf] {
329        &self.accessed.files
330    }
331
332    /// Get packages accessed during compilation.
333    ///
334    /// Useful for detecting virtual package usage (e.g., `@myapp/data`).
335    pub fn accessed_packages(&self) -> &[PackageId] {
336        &self.accessed.packages
337    }
338
339    /// Get compilation diagnostics (warnings).
340    pub fn diagnostics(&self) -> &Diagnostics {
341        &self.diagnostics
342    }
343
344    /// Take ownership of the document.
345    pub fn into_document(self) -> HtmlDocument {
346        self.document
347    }
348
349    /// Destructure into components.
350    pub fn into_parts(self) -> (HtmlDocument, AccessedDeps, Diagnostics) {
351        (self.document, self.accessed, self.diagnostics)
352    }
353}
354
355
356
357pub(crate) fn compile_with_world(world: &TypstWorld) -> Result<CompileResult, CompileError> {
358    let session = CompileSession::start();
359    let line_offset = world.prelude_line_count();
360
361    let result = typst::compile(world);
362
363    if has_errors(&result.warnings) {
364        return Err(CompileError::compilation_with_offset(world, result.warnings.to_vec(), line_offset));
365    }
366
367    let document = result.output.map_err(|errors| {
368        let all_diags: Vec<_> = errors.iter().chain(&result.warnings).cloned().collect();
369        let filtered = filter_html_warnings(&all_diags);
370        CompileError::compilation_with_offset(world, filtered, line_offset)
371    })?;
372
373    let document = HtmlDocument::new(document);
374
375    let accessed = session.finish(world.root());
376    let filtered_warnings = filter_html_warnings(&result.warnings);
377    let diagnostics = Diagnostics::resolve_with_offset(world, &filtered_warnings, line_offset);
378
379    Ok(CompileResult {
380        document,
381        accessed,
382        diagnostics,
383    })
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use std::fs;
390    use tempfile::TempDir;
391
392    #[test]
393    fn test_simple_compile() {
394        let dir = TempDir::new().unwrap();
395        let file = dir.path().join("test.typ");
396        fs::write(&file, "= Hello World").unwrap();
397
398        let result = Compiler::new(dir.path()).with_path(&file).compile();
399        assert!(result.is_ok());
400
401        let html = result.unwrap().html().unwrap();
402        assert!(String::from_utf8_lossy(&html).contains("Hello World"));
403    }
404
405    #[test]
406    fn test_compile_with_inputs() {
407        let dir = TempDir::new().unwrap();
408        let file = dir.path().join("test.typ");
409        fs::write(
410            &file,
411            r#"#let title = sys.inputs.at("title", default: "Default")
412= #title"#,
413        )
414        .unwrap();
415
416        let result = Compiler::new(dir.path())
417            .with_inputs([("title", "Custom Title")])
418            .with_path(&file)
419            .compile();
420
421        assert!(result.is_ok());
422        let html = result.unwrap().html().unwrap();
423        assert!(String::from_utf8_lossy(&html).contains("Custom Title"));
424    }
425
426    #[test]
427    fn test_query_metadata() {
428        let dir = TempDir::new().unwrap();
429        let file = dir.path().join("test.typ");
430        fs::write(
431            &file,
432            r#"#metadata((title: "Test", draft: false)) <post-meta>
433= Content"#,
434        )
435        .unwrap();
436
437        let result = Compiler::new(dir.path())
438            .with_path(&file)
439            .compile()
440            .unwrap();
441        let meta = result.document().query_metadata("post-meta");
442
443        assert!(meta.is_some());
444        let meta = meta.unwrap();
445        assert_eq!(
446            meta.get("title")
447                .and_then(|v: &serde_json::Value| v.as_str()),
448            Some("Test")
449        );
450    }
451
452    #[test]
453    fn test_query_metadata_all() {
454        let dir = TempDir::new().unwrap();
455        let file = dir.path().join("test.typ");
456        fs::write(
457            &file,
458            r#"#metadata((id: 1, role: "first")) <item>
459#metadata((id: 2, role: "second")) <item>
460= Content"#,
461        )
462        .unwrap();
463
464        let result = Compiler::new(dir.path())
465            .with_path(&file)
466            .compile()
467            .unwrap();
468        let metas = result.document().query_metadata_all("item");
469
470        assert_eq!(metas.len(), 2);
471        assert_eq!(metas[0].get("id").and_then(|v| v.as_i64()), Some(1));
472        assert_eq!(metas[1].get("id").and_then(|v| v.as_i64()), Some(2));
473    }
474
475    #[test]
476    #[cfg(feature = "batch")]
477    fn test_batch_compile() {
478        let dir = TempDir::new().unwrap();
479
480        let file1 = dir.path().join("test1.typ");
481        let file2 = dir.path().join("test2.typ");
482        fs::write(&file1, "= File One").unwrap();
483        fs::write(&file2, "= File Two").unwrap();
484
485        let results = Compiler::new(dir.path())
486            .into_batch()
487            .batch_compile(&[&file1, &file2])
488            .unwrap();
489
490        assert_eq!(results.len(), 2);
491        assert!(results[0].is_ok());
492        assert!(results[1].is_ok());
493
494        let html1 = results[0].as_ref().unwrap().html().unwrap();
495        let html2 = results[1].as_ref().unwrap().html().unwrap();
496        assert!(String::from_utf8_lossy(&html1).contains("File One"));
497        assert!(String::from_utf8_lossy(&html2).contains("File Two"));
498    }
499
500    #[test]
501    #[cfg(feature = "batch")]
502    fn test_batch_with_snapshot_from() {
503        let dir = TempDir::new().unwrap();
504
505        let file1 = dir.path().join("test1.typ");
506        let file2 = dir.path().join("test2.typ");
507        fs::write(&file1, "= File One").unwrap();
508        fs::write(&file2, "= File Two").unwrap();
509
510        let batch = Compiler::new(dir.path())
511            .into_batch()
512            .with_snapshot_from(&[&file1, &file2])
513            .unwrap();
514
515        // First compile
516        let results1 = batch.batch_compile(&[&file1, &file2]).unwrap();
517        assert_eq!(results1.len(), 2);
518
519        // Second compile (reuses snapshot)
520        let results2 = batch.batch_compile(&[&file1]).unwrap();
521        assert_eq!(results2.len(), 1);
522    }
523}