Skip to main content

askama_minify/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Span, TokenStream as TokenStream2};
3use quote::quote;
4use std::fs;
5use std::path::{Path, PathBuf};
6use syn::punctuated::Punctuated;
7use syn::{
8    Attribute, Expr, ExprLit, Item, Lit, LitStr, Meta, MetaNameValue, Token, parse_macro_input,
9    parse_quote,
10};
11
12mod minifier;
13
14/// Reads an Askama template at compile time, minifies it, and injects it as
15/// `#[template(source = "...", ext = "...")]`.
16///
17/// Use it with Askama's derive macro:
18///
19/// ```ignore
20/// use askama::Template;
21/// use askama_minify::template_minify;
22///
23/// #[template_minify(path = "index.html")]
24/// #[derive(Template)]
25/// struct IndexTemplate<'a> {
26///     title: &'a str,
27/// }
28/// ```
29///
30/// Template paths are resolved relative to `CARGO_MANIFEST_DIR`; if that file
31/// does not exist, `templates/<path>` is tried to match Askama's default layout.
32#[proc_macro_attribute]
33pub fn template_minify(attr: TokenStream, item: TokenStream) -> TokenStream {
34    let args = parse_macro_input!(attr with MacroArgs::parse);
35    let item = parse_macro_input!(item as Item);
36
37    match expand_template_minify(args, item) {
38        Ok(tokens) => tokens.into(),
39        Err(error) => error.to_compile_error().into(),
40    }
41}
42
43struct MacroArgs {
44    input: TemplateInput,
45    ext: Option<LitStr>,
46    passthrough: Vec<Meta>,
47}
48
49enum TemplateInput {
50    Path(LitStr),
51    Source(LitStr),
52}
53
54struct LoadedTemplate {
55    source: String,
56    ext: String,
57    include_path: Option<PathBuf>,
58}
59
60impl MacroArgs {
61    fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
62        let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
63        let mut path = None;
64        let mut source = None;
65        let mut ext = None;
66        let mut passthrough = Vec::new();
67
68        for meta in metas {
69            if let Some(value) = string_name_value(&meta, "path")? {
70                set_once(&mut path, value, "duplicate `path` argument")?;
71                continue;
72            }
73
74            if let Some(value) = string_name_value(&meta, "source")? {
75                set_once(&mut source, value, "duplicate `source` argument")?;
76                continue;
77            }
78
79            if let Some(value) = string_name_value(&meta, "ext")? {
80                set_once(&mut ext, value, "duplicate `ext` argument")?;
81                continue;
82            }
83
84            passthrough.push(meta);
85        }
86
87        let input = match (path, source) {
88            (Some(path), None) => TemplateInput::Path(path),
89            (None, Some(source)) => TemplateInput::Source(source),
90            (Some(path), Some(_)) => {
91                return Err(syn::Error::new_spanned(
92                    path,
93                    "`path` and `source` cannot be used together",
94                ));
95            }
96            (None, None) => {
97                return Err(syn::Error::new(
98                    Span::call_site(),
99                    "expected `path = \"...\"` or `source = \"...\"`",
100                ));
101            }
102        };
103
104        Ok(Self {
105            input,
106            ext,
107            passthrough,
108        })
109    }
110}
111
112fn expand_template_minify(args: MacroArgs, mut item: Item) -> syn::Result<TokenStream2> {
113    reject_existing_template_attr(&item)?;
114    item_attrs_mut(&mut item)?;
115
116    let template = load_template(&args)?;
117    let source = LitStr::new(
118        &minify_template_source(&template.source, &template.ext),
119        Span::call_site(),
120    );
121    let ext = LitStr::new(&template.ext, Span::call_site());
122    let passthrough = args.passthrough;
123
124    let template_attr: Attribute = parse_quote! {
125        #[template(source = #source, ext = #ext #(, #passthrough)*)]
126    };
127    item_attrs_mut(&mut item)?.push(template_attr);
128
129    let tracking = template.include_path.map(|path| {
130        let path = LitStr::new(&path.to_string_lossy(), Span::call_site());
131        quote! {
132            const _: &str = include_str!(#path);
133        }
134    });
135
136    Ok(quote! {
137        #item
138        #tracking
139    })
140}
141
142fn load_template(args: &MacroArgs) -> syn::Result<LoadedTemplate> {
143    match &args.input {
144        TemplateInput::Source(source) => {
145            let Some(ext) = &args.ext else {
146                return Err(syn::Error::new_spanned(
147                    source,
148                    "`source` templates require `ext = \"...\"`",
149                ));
150            };
151
152            Ok(LoadedTemplate {
153                source: source.value(),
154                ext: ext.value(),
155                include_path: None,
156            })
157        }
158        TemplateInput::Path(path) => {
159            let resolved = resolve_template_path(&path.value())
160                .map_err(|message| syn::Error::new_spanned(path, message))?;
161            let source = fs::read_to_string(&resolved).map_err(|error| {
162                syn::Error::new_spanned(
163                    path,
164                    format!("failed to read template `{}`: {error}", resolved.display()),
165                )
166            })?;
167            let ext = args
168                .ext
169                .as_ref()
170                .map(LitStr::value)
171                .or_else(|| extension_from_path(&resolved))
172                .ok_or_else(|| {
173                    syn::Error::new_spanned(
174                        path,
175                        "could not infer template extension; add `ext = \"...\"`",
176                    )
177                })?;
178
179            Ok(LoadedTemplate {
180                source,
181                ext,
182                include_path: Some(resolved),
183            })
184        }
185    }
186}
187
188fn resolve_template_path(path: &str) -> Result<PathBuf, String> {
189    let raw = Path::new(path);
190    if raw.is_absolute() && raw.is_file() {
191        return Ok(raw.to_path_buf());
192    }
193
194    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR")
195        .map(PathBuf::from)
196        .ok_or_else(|| "CARGO_MANIFEST_DIR is not set".to_string())?;
197    let candidates = [
198        manifest_dir.join(raw),
199        manifest_dir.join("templates").join(raw),
200    ];
201
202    candidates
203        .iter()
204        .find(|candidate| candidate.is_file())
205        .cloned()
206        .ok_or_else(|| {
207            let tried = candidates
208                .iter()
209                .map(|candidate| format!("`{}`", candidate.display()))
210                .collect::<Vec<_>>()
211                .join(", ");
212            format!("template `{path}` was not found; tried {tried}")
213        })
214}
215
216fn minify_template_source(source: &str, ext: &str) -> String {
217    if matches!(ext.to_ascii_lowercase().as_str(), "html" | "htm") {
218        minifier::minify_html(source)
219    } else {
220        source.to_owned()
221    }
222}
223
224fn extension_from_path(path: &Path) -> Option<String> {
225    path.extension()
226        .and_then(|extension| extension.to_str())
227        .map(ToOwned::to_owned)
228}
229
230fn item_attrs_mut(item: &mut Item) -> syn::Result<&mut Vec<Attribute>> {
231    match item {
232        Item::Const(item) => Ok(&mut item.attrs),
233        Item::Enum(item) => Ok(&mut item.attrs),
234        Item::Struct(item) => Ok(&mut item.attrs),
235        Item::Union(item) => Ok(&mut item.attrs),
236        _ => Err(syn::Error::new_spanned(
237            item,
238            "`template_minify` can only be used on an Askama template item",
239        )),
240    }
241}
242
243fn reject_existing_template_attr(item: &Item) -> syn::Result<()> {
244    let attrs = match item {
245        Item::Const(item) => &item.attrs,
246        Item::Enum(item) => &item.attrs,
247        Item::Struct(item) => &item.attrs,
248        Item::Union(item) => &item.attrs,
249        _ => return Ok(()),
250    };
251
252    for attr in attrs {
253        if attr.path().is_ident("template") {
254            return Err(syn::Error::new_spanned(
255                attr,
256                "`template_minify` generates `#[template(...)]`; remove the existing template attribute",
257            ));
258        }
259    }
260
261    Ok(())
262}
263
264fn string_name_value(meta: &Meta, name: &str) -> syn::Result<Option<LitStr>> {
265    let Meta::NameValue(MetaNameValue { path, value, .. }) = meta else {
266        return Ok(None);
267    };
268
269    if !path.is_ident(name) {
270        return Ok(None);
271    }
272
273    match value {
274        Expr::Lit(ExprLit {
275            lit: Lit::Str(value),
276            ..
277        }) => Ok(Some(value.clone())),
278        _ => Err(syn::Error::new_spanned(
279            value,
280            format!("`{name}` must be a string literal"),
281        )),
282    }
283}
284
285fn set_once<T>(target: &mut Option<T>, value: T, message: &str) -> syn::Result<()> {
286    if target.is_some() {
287        return Err(syn::Error::new(Span::call_site(), message));
288    }
289
290    *target = Some(value);
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use quote::quote;
298    use syn::parse::Parser;
299
300    #[test]
301    fn parses_path_argument() {
302        let args = MacroArgs::parse
303            .parse2(quote!(path = "index.html"))
304            .unwrap();
305        match args.input {
306            TemplateInput::Path(path) => assert_eq!(path.value(), "index.html"),
307            TemplateInput::Source(_) => panic!("expected path input"),
308        }
309    }
310
311    #[test]
312    fn requires_ext_for_source_argument() {
313        let args = MacroArgs::parse
314            .parse2(quote!(source = "<p>{{ value }}</p>"))
315            .unwrap();
316
317        assert!(load_template(&args).is_err());
318    }
319
320    #[test]
321    fn minifies_html_templates() {
322        let result = minify_template_source("<div>   value   </div>", "HTML");
323
324        assert_eq!(result, "<div> value </div>");
325    }
326
327    #[test]
328    fn leaves_non_html_templates_unchanged() {
329        let source = "line 1\n  {{ value }}\nline 3";
330        let result = minify_template_source(source, "txt");
331
332        assert_eq!(result, source);
333    }
334}