Skip to main content

maud_extensions/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Span, TokenStream as TokenStream2, TokenTree};
3use quote::quote;
4use swc_common::{FileName, SourceMap};
5use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
6use syn::{
7    LitStr, Result, Token,
8    parse::{Parse, ParseStream},
9    parse_macro_input,
10    punctuated::Punctuated,
11};
12
13enum JsInput {
14    Literal(LitStr),
15    Tokens(TokenStream2),
16}
17
18impl Parse for JsInput {
19    fn parse(input: ParseStream) -> Result<Self> {
20        if input.peek(LitStr) {
21            let content: LitStr = input.parse()?;
22            Ok(JsInput::Literal(content))
23        } else {
24            let tokens: TokenStream2 = input.parse()?;
25            Ok(JsInput::Tokens(tokens))
26        }
27    }
28}
29
30enum CssInput {
31    Literal(LitStr),
32    Tokens(TokenStream2),
33}
34
35impl Parse for CssInput {
36    fn parse(input: ParseStream) -> Result<Self> {
37        if input.peek(LitStr) {
38            let content: LitStr = input.parse()?;
39            Ok(CssInput::Literal(content))
40        } else {
41            let tokens: TokenStream2 = input.parse()?;
42            Ok(CssInput::Tokens(tokens))
43        }
44    }
45}
46
47#[proc_macro]
48pub fn css(input: TokenStream) -> TokenStream {
49    let css_input = parse_macro_input!(input as CssInput);
50    let content_lit = match css_input {
51        CssInput::Literal(content) => content,
52        CssInput::Tokens(tokens) => {
53            let css = tokens_to_css(tokens);
54            if let Err(message) = validate_css(&css) {
55                return syn::Error::new(Span::call_site(), message)
56                    .to_compile_error()
57                    .into();
58            }
59            LitStr::new(&css, Span::call_site())
60        }
61    };
62
63    let output = quote! {
64
65        pub fn callsite_id(prefix: &str, file: &str, line: u32, col: u32) -> String {
66            // Stable, cheap hash. You can swap this for blake3 if you want.
67            let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset
68            for b in file.as_bytes() {
69                h ^= *b as u64;
70                h = h.wrapping_mul(0x100000001b3);
71            }
72            for b in line.to_le_bytes() {
73                h ^= b as u64;
74                h = h.wrapping_mul(0x100000001b3);
75            }
76            for b in col.to_le_bytes() {
77                h ^= b as u64;
78                h = h.wrapping_mul(0x100000001b3);
79            }
80
81            // HTML id safe, short, deterministic.
82            format!("{prefix}{h:016x}")
83        }
84
85        let __id = callsite_id(
86            "mx-css-",
87            file!(),
88            line!(),
89            column!(),
90        );
91
92        maud::html! {
93            style data-mx-css-id=(__id) {
94                (maud::PreEscaped(#content_lit))
95            }
96        }
97    };
98
99    TokenStream::from(output)
100}
101
102fn tokens_to_css(tokens: TokenStream2) -> String {
103    let mut out = String::new();
104    let mut prev_word = false;
105
106    for token in tokens {
107        match token {
108            TokenTree::Group(group) => {
109                let (open, close) = match group.delimiter() {
110                    proc_macro2::Delimiter::Parenthesis => ('(', ')'),
111                    proc_macro2::Delimiter::Bracket => ('[', ']'),
112                    proc_macro2::Delimiter::Brace => ('{', '}'),
113                    proc_macro2::Delimiter::None => (' ', ' '),
114                };
115                let needs_space = prev_word
116                    && matches!(
117                        group.delimiter(),
118                        proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
119                    );
120                if needs_space {
121                    out.push(' ');
122                }
123                if open != ' ' {
124                    out.push(open);
125                }
126                out.push_str(&tokens_to_css(group.stream()));
127                if close != ' ' {
128                    out.push(close);
129                }
130                prev_word = false;
131            }
132            TokenTree::Ident(ident) => {
133                if prev_word {
134                    out.push(' ');
135                }
136                out.push_str(&ident.to_string());
137                prev_word = true;
138            }
139            TokenTree::Literal(literal) => {
140                if prev_word {
141                    out.push(' ');
142                }
143                out.push_str(&literal.to_string());
144                prev_word = true;
145            }
146            TokenTree::Punct(punct) => {
147                out.push(punct.as_char());
148                prev_word = false;
149            }
150        }
151    }
152
153    out
154}
155
156fn validate_css(css: &str) -> core::result::Result<(), String> {
157    let mut input = cssparser::ParserInput::new(css);
158    let mut parser = cssparser::Parser::new(&mut input);
159    loop {
160        match parser.next_including_whitespace_and_comments() {
161            Ok(_) => {}
162            Err(err) => match err.kind {
163                cssparser::BasicParseErrorKind::EndOfInput => return Ok(()),
164                _ => return Err("css! could not parse CSS tokens".to_string()),
165            },
166        }
167    }
168}
169
170#[proc_macro]
171pub fn js(input: TokenStream) -> TokenStream {
172    let js_input = parse_macro_input!(input as JsInput);
173    let (content_lit, js_string) = match js_input {
174        JsInput::Literal(content) => {
175            let js_string = content.value();
176            (content, js_string)
177        }
178        JsInput::Tokens(tokens) => {
179            let js = tokens_to_js(tokens);
180            (LitStr::new(&js, Span::call_site()), js)
181        }
182    };
183    if let Err(message) = validate_js(&js_string) {
184        return syn::Error::new(Span::call_site(), message)
185            .to_compile_error()
186            .into();
187    }
188
189    let output = quote! {
190        maud::html! {
191            script {
192                (maud::PreEscaped(#content_lit))
193            }
194        }
195    };
196
197    TokenStream::from(output)
198}
199
200#[proc_macro]
201pub fn inline_js(input: TokenStream) -> TokenStream {
202    let tokens: TokenStream2 = input.into();
203    let output = quote! {
204        fn js() -> maud::Markup {
205            ::maud_extensions::js! { #tokens }
206        }
207    };
208
209    TokenStream::from(output)
210}
211
212#[proc_macro]
213pub fn inline_css(input: TokenStream) -> TokenStream {
214    let tokens: TokenStream2 = input.into();
215    let output = quote! {
216        fn css() -> maud::Markup {
217            ::maud_extensions::css! { #tokens }
218        }
219    };
220
221    TokenStream::from(output)
222}
223
224fn tokens_to_js(tokens: TokenStream2) -> String {
225    let mut out = String::new();
226    let mut prev_word = false;
227
228    for token in tokens {
229        match token {
230            TokenTree::Group(group) => {
231                let (open, close) = match group.delimiter() {
232                    proc_macro2::Delimiter::Parenthesis => ('(', ')'),
233                    proc_macro2::Delimiter::Bracket => ('[', ']'),
234                    proc_macro2::Delimiter::Brace => ('{', '}'),
235                    proc_macro2::Delimiter::None => (' ', ' '),
236                };
237                let needs_space = prev_word
238                    && matches!(
239                        group.delimiter(),
240                        proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
241                    );
242                if needs_space {
243                    out.push(' ');
244                }
245                if open != ' ' {
246                    out.push(open);
247                }
248                out.push_str(&tokens_to_js(group.stream()));
249                if close != ' ' {
250                    out.push(close);
251                }
252                prev_word = false;
253            }
254            TokenTree::Ident(ident) => {
255                if prev_word {
256                    out.push(' ');
257                }
258                out.push_str(&ident.to_string());
259                prev_word = true;
260            }
261            TokenTree::Literal(literal) => {
262                if prev_word {
263                    out.push(' ');
264                }
265                out.push_str(&literal.to_string());
266                prev_word = true;
267            }
268            TokenTree::Punct(punct) => {
269                out.push(punct.as_char());
270                prev_word = false;
271            }
272        }
273    }
274
275    out
276}
277
278fn validate_js(js: &str) -> core::result::Result<(), String> {
279    let cm = SourceMap::default();
280    let fm = cm.new_source_file(
281        FileName::Custom("inline.js".to_string()).into(),
282        js.to_string(),
283    );
284    let input = StringInput::from(&*fm);
285    let mut parser = Parser::new(Syntax::Es(EsSyntax::default()), input, None);
286    match parser.parse_script() {
287        Ok(_) => Ok(()),
288        Err(err) => Err(format!("js! could not parse JavaScript: {err:#?}")),
289    }
290}
291
292struct FontFace {
293    path: LitStr,
294    family: LitStr,
295    weight: Option<LitStr>,
296    style: Option<LitStr>,
297}
298
299impl Parse for FontFace {
300    fn parse(input: ParseStream) -> syn::Result<Self> {
301        let path: LitStr = input.parse()?;
302        input.parse::<Token![,]>()?;
303        let family: LitStr = input.parse()?;
304
305        let weight = if input.peek(Token![,]) {
306            input.parse::<Token![,]>()?;
307            if input.peek(LitStr) {
308                Some(input.parse()?)
309            } else {
310                None
311            }
312        } else {
313            None
314        };
315
316        let style = if weight.is_some() && input.peek(Token![,]) {
317            input.parse::<Token![,]>()?;
318            if input.peek(LitStr) {
319                Some(input.parse()?)
320            } else {
321                None
322            }
323        } else {
324            None
325        };
326
327        Ok(FontFace {
328            path,
329            family,
330            weight,
331            style,
332        })
333    }
334}
335
336struct FontFaceList {
337    fonts: Punctuated<FontFace, Token![;]>,
338}
339
340impl Parse for FontFaceList {
341    fn parse(input: ParseStream) -> syn::Result<Self> {
342        let fonts = Punctuated::parse_terminated(input)?;
343        Ok(FontFaceList { fonts })
344    }
345}
346
347#[proc_macro]
348pub fn font_face(input: TokenStream) -> TokenStream {
349    let font = parse_macro_input!(input as FontFace);
350
351    let path = font.path;
352    let family = font.family;
353    let weight = font
354        .weight
355        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
356    let style = font
357        .style
358        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
359
360    let expanded = quote! {
361        {
362            use base64::Engine;
363            use base64::engine::general_purpose::STANDARD;
364            use maud::PreEscaped;
365
366            let font_bytes = include_bytes!(#path);
367            let mut base64_string = String::new();
368
369            STANDARD.encode_string(font_bytes, &mut base64_string);
370
371            let path_str = #path;
372            let format = if path_str.ends_with(".ttf") {
373                "truetype"
374            } else if path_str.ends_with(".otf") {
375                "opentype"
376            } else if path_str.ends_with(".woff") {
377                "woff"
378            } else if path_str.ends_with(".woff2") {
379                "woff2"
380            } else {
381                "truetype"
382            };
383
384            let font_type = if path_str.ends_with(".woff2") {
385                "woff2"
386            } else if path_str.ends_with(".woff") {
387                "woff"
388            } else if path_str.ends_with(".otf") {
389                "opentype"
390            } else {
391                "truetype"
392            };
393
394            let css = format!(
395                "@font-face {{\n    font-family: '{}';\n    src: url('data:font/{};base64,{}') format('{}');\n    font-weight: {};\n    font-style: {};\n}}",
396                #family,
397                font_type,
398                base64_string,
399                format,
400                #weight,
401                #style
402            );
403
404            PreEscaped(css)
405        }
406    };
407
408    expanded.into()
409}
410
411#[proc_macro]
412pub fn font_faces(input: TokenStream) -> TokenStream {
413    let fonts = parse_macro_input!(input as FontFaceList);
414
415    let font_faces = fonts.fonts.iter().map(|font| {
416        let path = &font.path;
417        let family = &font.family;
418        let weight = font
419            .weight
420            .as_ref()
421            .map_or_else(|| quote! { "normal" }, |w| quote! { #w });
422        let style = font
423            .style
424            .as_ref()
425            .map_or_else(|| quote! { "normal" }, |s| quote! { #s });
426
427        quote! {
428            {
429                use maud_extensions::font_face;
430                let face = font_face!(#path, #family, #weight, #style);
431                css.push_str(&face.0);
432            }
433        }
434    });
435
436    let expanded = quote! {
437        {
438            use maud::PreEscaped;
439            let mut css = String::new();
440
441            #(#font_faces)*
442
443            PreEscaped(css)
444        }
445    };
446
447    expanded.into()
448}