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#[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}