typstgen 0.2.0

Compile existing .typ files to PDF with an embedded Typst engine — no external typst binary, config-driven template resolution. Usable as CLI, Rust lib, and Wasm.
Documentation
use std::fs;
use std::process::Command;

use tempfile::tempdir;
use typstgen::{compile, compile_with_warnings, Config, Error};

#[test]
fn compiles_a_document_with_an_import_from_the_template_root() {
    let temp = tempdir().expect("create temporary project");
    let template_root = temp.path().join("templates");
    fs::create_dir_all(template_root.join("shared")).expect("create template directory");
    fs::write(
        template_root.join("shared/title.typ"),
        "#let title = [Configured template import]",
    )
    .expect("write imported template");

    let input = temp.path().join("document.typ");
    fs::write(
        &input,
        "#import \"shared/title.typ\": title\n#set text(font: \"Libertinus Serif\")\n= #title",
    )
    .expect("write input");

    let config = Config {
        template_paths: vec![template_root],
        font_paths: vec![],
        use_system_fonts: false,
    };
    let pdf = compile(&input, &config).expect("compile Typst document");

    assert!(pdf.starts_with(b"%PDF-"));
}

#[test]
fn cli_writes_a_valid_pdf() {
    let temp = tempdir().expect("create temporary project");
    let template_root = temp.path().join("templates");
    fs::create_dir_all(&template_root).expect("create template directory");
    fs::write(
        template_root.join("shared.typ"),
        "#let title = [CLI output]",
    )
    .expect("write imported template");

    let input = temp.path().join("document.typ");
    fs::write(&input, "#import \"shared.typ\": title\n= #title").expect("write input");
    let config = temp.path().join("typstgen.toml");
    fs::write(
        &config,
        format!("template_paths = [\"{}\"]", template_root.display()),
    )
    .expect("write config");

    let output = temp.path().join("document.pdf");
    let status = Command::new(env!("CARGO_BIN_EXE_typstgen"))
        .args(["compile", input.to_str().unwrap(), "--config"])
        .arg(&config)
        .args(["--output", output.to_str().unwrap()])
        .status()
        .expect("run typstgen CLI");

    assert!(status.success());
    assert!(fs::read(output)
        .expect("read generated PDF")
        .starts_with(b"%PDF-"));
}

fn config_with(template_paths: Vec<std::path::PathBuf>) -> Config {
    Config {
        template_paths,
        font_paths: vec![],
        use_system_fonts: false,
    }
}

#[test]
fn compiles_without_any_template_directory() {
    let temp = tempdir().expect("create temporary project");
    let input = temp.path().join("document.typ");
    fs::write(&input, "= No templates needed").expect("write input");

    let config = config_with(vec![temp.path().join("does-not-exist")]);
    let pdf = compile(&input, &config).expect("compile without template directory");

    assert!(pdf.starts_with(b"%PDF-"));
}

#[test]
fn searches_every_template_directory_per_import() {
    let temp = tempdir().expect("create temporary project");
    let empty = temp.path().join("empty");
    let first = temp.path().join("first");
    let second = temp.path().join("second");
    for dir in [&empty, &first, &second] {
        fs::create_dir_all(dir).expect("create template directory");
    }
    fs::write(first.join("a.typ"), "#let a = [A]").expect("write first template");
    fs::write(second.join("b.typ"), "#let b = [B]").expect("write second template");

    let docs = temp.path().join("docs");
    fs::create_dir_all(&docs).expect("create document directory");
    let input = docs.join("document.typ");
    fs::write(
        &input,
        "#import \"a.typ\": a\n#import \"b.typ\": b\n= #a #b",
    )
    .expect("write input");

    let config = config_with(vec![empty, first, second]);
    let pdf = compile(&input, &config).expect("compile with imports from two roots");

    assert!(pdf.starts_with(b"%PDF-"));
}

#[test]
fn skips_template_paths_that_are_files() {
    let temp = tempdir().expect("create temporary project");
    let not_a_dir = temp.path().join("templates.typ");
    fs::write(&not_a_dir, "").expect("write file in place of a directory");
    let templates = temp.path().join("templates");
    fs::create_dir_all(&templates).expect("create template directory");
    fs::write(templates.join("t.typ"), "#let t = [T]").expect("write template");

    let config = config_with(vec![not_a_dir, templates.clone()]);
    assert_eq!(
        config.template_roots(),
        vec![templates
            .canonicalize()
            .expect("canonicalize template directory")]
    );

    let input = temp.path().join("document.typ");
    fs::write(&input, "#import \"t.typ\": t\n= #t").expect("write input");
    assert!(compile(&input, &config).is_ok());
}

#[test]
fn loads_binary_assets_from_a_template_directory() {
    let temp = tempdir().expect("create temporary project");
    let templates = temp.path().join("templates");
    fs::create_dir_all(&templates).expect("create template directory");
    fs::write(
        templates.join("logo.svg"),
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#,
    )
    .expect("write image");

    let input = temp.path().join("document.typ");
    fs::write(&input, "#image(\"/logo.svg\", width: 1cm)").expect("write input");

    let pdf = compile(&input, &config_with(vec![templates])).expect("compile with image");
    assert!(pdf.starts_with(b"%PDF-"));
}

#[test]
fn today_returns_a_date_and_honours_the_offset() {
    let temp = tempdir().expect("create temporary project");
    let utc = chrono::Utc::now().date_naive();
    let input = temp.path().join("document.typ");
    fs::write(
        &input,
        format!(
            "#assert.eq(datetime.today().hour(), none)\n\
             #assert.eq(datetime.today(offset: 0), datetime(year: {}, month: {}, day: {}))\n\
             = Today",
            chrono::Datelike::year(&utc),
            chrono::Datelike::month(&utc),
            chrono::Datelike::day(&utc),
        ),
    )
    .expect("write input");

    compile(&input, &config_with(vec![])).expect("today() assertions hold");
}

#[cfg(feature = "wasm")]
#[test]
fn wasm_compiles_from_a_virtual_filesystem() {
    let request = serde_json::json!({
        "source": "#import \"templates/shared.typ\": title\n= #title",
        "files": { "templates/shared.typ": "#let title = [Virtual]" },
    });
    let pdf = typstgen::wasm::compile(&request.to_string()).expect("compile virtual files");

    assert!(pdf.starts_with(b"%PDF-"));
}

#[test]
fn errors_name_file_line_and_column() {
    let temp = tempdir().expect("create temporary project");
    let input = temp.path().join("document.typ");
    fs::write(&input, "= Title\n#missing").expect("write input");

    let Err(Error::Compile(message)) = compile(&input, &config_with(vec![])) else {
        panic!("expected a compile error");
    };
    assert!(
        message.contains("document.typ:2:2: error: unknown variable: missing"),
        "{message}"
    );
}

#[test]
fn warnings_are_returned() {
    let temp = tempdir().expect("create temporary project");
    let input = temp.path().join("document.typ");
    fs::write(&input, "#set text(font: \"No Such Font\")\nHello").expect("write input");

    let output = compile_with_warnings(&input, &config_with(vec![])).expect("compile");
    assert!(output.pdf.starts_with(b"%PDF-"));
    assert!(
        output
            .warnings
            .iter()
            .any(|warning| warning.contains("document.typ:1:") && warning.contains("warning:")),
        "{:?}",
        output.warnings
    );
}