Skip to main content

typst_bake_macros/
lib.rs

1//! Procedural macros for typst-bake
2//!
3//! This crate provides the [`document!`] macro that embeds templates, fonts,
4//! and packages at compile time. All resources are compressed with zstd for
5//! optimized binary size.
6
7mod compression_cache;
8mod config;
9mod derive_intoval;
10mod dir_embed;
11mod downloader;
12mod scanner;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use proc_macro::TokenStream;
18use quote::quote;
19use syn::{parse_macro_input, LitStr};
20
21use compression_cache::CompressionCache;
22use dir_embed::DirEmbedResult;
23
24use scanner::ResolvedPackage;
25
26/// Per-package metadata collected during embedding.
27#[derive(Debug)]
28struct MacroPackageInfo {
29    name: String,
30    original_size: usize,
31    compressed_size: usize,
32    file_count: usize,
33}
34
35/// Resolved packages: each entry pairs a package spec with its on-disk path.
36type ResolvedPackages = Vec<ResolvedPackage>;
37
38/// Collected results from embedding all packages.
39struct EmbeddedPackages {
40    infos: Vec<MacroPackageInfo>,
41    total_original: usize,
42    total_compressed: usize,
43    namespace_entries: Vec<proc_macro2::TokenStream>,
44}
45
46/// Resolve template_dir, fonts_dir and validate the entry file exists.
47fn resolve_config(
48    entry: &LitStr,
49    entry_value: &str,
50) -> Result<(PathBuf, PathBuf), proc_macro2::TokenStream> {
51    let template_dir = config::get_template_dir()
52        .map_err(|e| syn::Error::new_spanned(entry, e).to_compile_error())?;
53
54    let entry_path = template_dir.join(entry_value);
55    if !entry_path.exists() {
56        return Err(syn::Error::new_spanned(
57            entry,
58            format!("Entry file not found: {}", entry_path.display()),
59        )
60        .to_compile_error());
61    }
62
63    let fonts_dir = config::get_fonts_dir()
64        .map_err(|e| syn::Error::new_spanned(entry, e).to_compile_error())?;
65
66    Ok((template_dir, fonts_dir))
67}
68
69/// Scan template directory for package imports and resolve them.
70fn resolve_and_download_packages(
71    entry: &LitStr,
72    template_dir: &Path,
73) -> Result<ResolvedPackages, proc_macro2::TokenStream> {
74    eprintln!("typst-bake: Scanning for package imports...");
75    let packages = scanner::extract_packages(template_dir);
76
77    let data_dir = downloader::get_data_dir();
78    let cache_dir = downloader::get_cache_dir()
79        .map_err(|e| syn::Error::new_spanned(entry, e).to_compile_error())?;
80
81    let resolved_packages = if !packages.is_empty() {
82        eprintln!("typst-bake: Found {} package(s) to bundle", packages.len());
83
84        let refresh = config::should_refresh_cache();
85        downloader::resolve_packages(&packages, data_dir.as_deref(), &cache_dir, refresh)
86            .map_err(|e| syn::Error::new_spanned(entry, e).to_compile_error())?
87    } else {
88        eprintln!("typst-bake: No packages found");
89        Vec::new()
90    };
91
92    Ok(resolved_packages)
93}
94
95/// Generate a `DirEntry::Dir` token wrapping children under a given name.
96fn dir_entry_token(name: &str, children: &[proc_macro2::TokenStream]) -> proc_macro2::TokenStream {
97    quote! {
98        ::typst_bake::__internal::include_dir::DirEntry::Dir(
99            ::typst_bake::__internal::include_dir::Dir::new(#name, &[#(#children),*])
100        )
101    }
102}
103
104/// Embed all resolved packages, collecting stats and directory entry tokens.
105fn embed_packages(
106    resolved_packages: &[ResolvedPackage],
107    cache: &mut CompressionCache,
108) -> EmbeddedPackages {
109    let mut package_infos = Vec::new();
110    let mut pkg_total_original = 0;
111    let mut pkg_total_compressed = 0;
112    let mut namespace_entries = Vec::new();
113
114    // Group resolved packages into a sorted tree: namespace -> name -> (version -> path)
115    let mut pkg_tree: BTreeMap<&str, BTreeMap<&str, BTreeMap<&str, &Path>>> = BTreeMap::new();
116    for rp in resolved_packages {
117        pkg_tree
118            .entry(rp.spec.namespace.as_str())
119            .or_default()
120            .entry(rp.spec.name.as_str())
121            .or_default()
122            .insert(rp.spec.version.as_str(), &rp.path);
123    }
124
125    for (namespace, names) in &pkg_tree {
126        let mut name_entries = Vec::new();
127
128        for (name, versions) in names {
129            let mut version_entries = Vec::new();
130
131            for (version, ver_path) in versions {
132                let pkg_result = dir_embed::embed_dir(ver_path, cache);
133                let pkg_name = format!("@{namespace}/{name}:{version}");
134
135                package_infos.push(MacroPackageInfo {
136                    name: pkg_name,
137                    original_size: pkg_result.original_size,
138                    compressed_size: pkg_result.compressed_size,
139                    file_count: pkg_result.file_count,
140                });
141                pkg_total_original += pkg_result.original_size;
142                pkg_total_compressed += pkg_result.compressed_size;
143
144                version_entries.push(dir_entry_token(version, &pkg_result.entries));
145            }
146
147            name_entries.push(dir_entry_token(name, &version_entries));
148        }
149
150        namespace_entries.push(dir_entry_token(namespace, &name_entries));
151    }
152
153    EmbeddedPackages {
154        infos: package_infos,
155        total_original: pkg_total_original,
156        total_compressed: pkg_total_compressed,
157        namespace_entries,
158    }
159}
160
161/// Generate the final output `TokenStream` from embedded results and stats.
162fn generate_output(
163    entry_value: &str,
164    templates_result: &DirEmbedResult,
165    fonts_result: &DirEmbedResult,
166    packages: &EmbeddedPackages,
167    cache: &mut CompressionCache,
168    compression_level: i32,
169) -> proc_macro2::TokenStream {
170    cache.log_summary();
171    cache.cleanup();
172
173    let dedup = cache.dedup_summary();
174    let dedup_total_files = dedup.total_files;
175    let dedup_unique_blobs = dedup.unique_blobs;
176    let dedup_duplicate_count = dedup.duplicate_count;
177    let dedup_saved_bytes = dedup.saved_bytes;
178    let dedup_statics = cache.dedup_statics();
179
180    let templates_code = templates_result.to_dir_code("");
181    let fonts_code = fonts_result.to_dir_code("");
182    let namespace_entries = &packages.namespace_entries;
183    let packages_code = quote! {
184        ::typst_bake::__internal::include_dir::Dir::new("", &[#(#namespace_entries),*])
185    };
186
187    let template_original = templates_result.original_size;
188    let template_compressed = templates_result.compressed_size;
189    let template_count = templates_result.file_count;
190
191    let font_original = fonts_result.original_size;
192    let font_compressed = fonts_result.compressed_size;
193    let font_count = fonts_result.file_count;
194
195    let pkg_total_original = packages.total_original;
196    let pkg_total_compressed = packages.total_compressed;
197
198    let pkg_info_tokens: Vec<_> = packages
199        .infos
200        .iter()
201        .map(|info| {
202            let name = &info.name;
203            let orig = info.original_size;
204            let comp = info.compressed_size;
205            let count = info.file_count;
206            quote! {
207                ::typst_bake::PackageInfo {
208                    name: #name.to_string(),
209                    original_size: #orig,
210                    compressed_size: #comp,
211                    file_count: #count,
212                }
213            }
214        })
215        .collect();
216
217    quote! {
218        {
219            use ::typst_bake::__internal::{Dir, Document};
220
221            #(#dedup_statics)*
222
223            static TEMPLATES: Dir<'static> = #templates_code;
224            static PACKAGES: Dir<'static> = #packages_code;
225            static FONTS: Dir<'static> = #fonts_code;
226
227            let stats = ::typst_bake::EmbedStats {
228                templates: ::typst_bake::CategoryStats {
229                    original_size: #template_original,
230                    compressed_size: #template_compressed,
231                    file_count: #template_count,
232                },
233                packages: ::typst_bake::PackageStats {
234                    packages: vec![#(#pkg_info_tokens),*],
235                    original_size: #pkg_total_original,
236                    compressed_size: #pkg_total_compressed,
237                },
238                fonts: ::typst_bake::CategoryStats {
239                    original_size: #font_original,
240                    compressed_size: #font_compressed,
241                    file_count: #font_count,
242                },
243                dedup: ::typst_bake::DedupStats {
244                    total_files: #dedup_total_files,
245                    unique_blobs: #dedup_unique_blobs,
246                    duplicate_count: #dedup_duplicate_count,
247                    saved_bytes: #dedup_saved_bytes,
248                },
249                compression_level: #compression_level,
250            };
251
252            Document::__new(&TEMPLATES, &PACKAGES, &FONTS, #entry_value, stats)
253        }
254    }
255}
256
257#[proc_macro]
258pub fn document(input: TokenStream) -> TokenStream {
259    let entry = parse_macro_input!(input as LitStr);
260    let entry_value = entry.value();
261
262    let (template_dir, fonts_dir) = match resolve_config(&entry, &entry_value) {
263        Ok(v) => v,
264        Err(e) => return e.into(),
265    };
266
267    let resolved_packages = match resolve_and_download_packages(&entry, &template_dir) {
268        Ok(v) => v,
269        Err(e) => return e.into(),
270    };
271
272    let compression_level = config::get_compression_level();
273    let compression_cache_dir = config::get_compression_cache_dir()
274        .map_err(|e| eprintln!("typst-bake: Compression cache disabled: {e}"))
275        .ok();
276    let mut cache = CompressionCache::new(compression_cache_dir, compression_level);
277
278    let templates_result = dir_embed::embed_dir(&template_dir, &mut cache);
279    let fonts_result = dir_embed::embed_fonts_dir(&fonts_dir, &mut cache);
280
281    let embedded_packages = embed_packages(&resolved_packages, &mut cache);
282
283    generate_output(
284        &entry_value,
285        &templates_result,
286        &fonts_result,
287        &embedded_packages,
288        &mut cache,
289        compression_level,
290    )
291    .into()
292}
293
294#[proc_macro_derive(IntoValue)]
295pub fn derive_into_value(item: TokenStream) -> TokenStream {
296    let item = parse_macro_input!(item as syn::DeriveInput);
297    derive_intoval::derive_into_value(item)
298        .unwrap_or_else(|err| err.to_compile_error())
299        .into()
300}
301
302#[proc_macro_derive(IntoDict)]
303pub fn derive_into_dict(item: TokenStream) -> TokenStream {
304    let item = parse_macro_input!(item as syn::DeriveInput);
305    derive_intoval::derive_into_dict(item)
306        .unwrap_or_else(|err| err.to_compile_error())
307        .into()
308}