Skip to main content

maud_extensions/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream as TokenStream2, TokenTree};
3use quote::quote;
4use swc_common::{FileName, SourceMap};
5use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
6use syn::{
7    Expr, LitStr, Result, Token,
8    parse::{Nothing, Parse, ParseStream},
9    parse_macro_input,
10    punctuated::Punctuated,
11};
12
13const SURREAL_JS_BUNDLE: &str = include_str!("../assets/surreal.js");
14const CSS_SCOPE_INLINE_JS_BUNDLE: &str = include_str!("../assets/css-scope-inline.js");
15const COMPONENT_JS_HELPER_FN: &str =
16    "__maud_extensions_component_requires_js_macro_in_scope_can_be_empty";
17const COMPONENT_CSS_HELPER_FN: &str =
18    "__maud_extensions_component_requires_css_macro_in_scope_can_be_empty";
19
20enum JsInput {
21    Literal(LitStr),
22    Tokens(TokenStream2),
23}
24
25impl Parse for JsInput {
26    fn parse(input: ParseStream) -> Result<Self> {
27        if input.peek(LitStr) {
28            let content: LitStr = input.parse()?;
29            Ok(JsInput::Literal(content))
30        } else {
31            let tokens: TokenStream2 = input.parse()?;
32            Ok(JsInput::Tokens(tokens))
33        }
34    }
35}
36
37enum CssInput {
38    Literal(LitStr),
39    Tokens(TokenStream2),
40}
41
42impl Parse for CssInput {
43    fn parse(input: ParseStream) -> Result<Self> {
44        if input.peek(LitStr) {
45            let content: LitStr = input.parse()?;
46            Ok(CssInput::Literal(content))
47        } else {
48            let tokens: TokenStream2 = input.parse()?;
49            Ok(CssInput::Tokens(tokens))
50        }
51    }
52}
53
54fn expand_css_markup(css_input: CssInput) -> TokenStream {
55    let content_lit = match css_input {
56        CssInput::Literal(content) => content,
57        CssInput::Tokens(tokens) => {
58            let css = tokens_to_css(tokens);
59            if let Err(message) = validate_css(&css) {
60                return syn::Error::new(Span::call_site(), message)
61                    .to_compile_error()
62                    .into();
63            }
64            LitStr::new(&css, Span::call_site())
65        }
66    };
67
68    let output = quote! {
69        {
70            fn callsite_id(prefix: &str, file: &str, line: u32, col: u32) -> String {
71                // Stable, cheap hash. You can swap this for blake3 if you want.
72                let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset
73                for b in file.as_bytes() {
74                    h ^= *b as u64;
75                    h = h.wrapping_mul(0x100000001b3);
76                }
77                for b in line.to_le_bytes() {
78                    h ^= b as u64;
79                    h = h.wrapping_mul(0x100000001b3);
80                }
81                for b in col.to_le_bytes() {
82                    h ^= b as u64;
83                    h = h.wrapping_mul(0x100000001b3);
84                }
85
86                // HTML id safe, short, deterministic.
87                format!("{prefix}{h:016x}")
88            }
89
90            let __id = callsite_id(
91                "mx-css-",
92                file!(),
93                line!(),
94                column!(),
95            );
96
97            maud::html! {
98                style data-mx-css-id=(__id) {
99                    (maud::PreEscaped(#content_lit))
100                }
101            }
102        }
103    };
104
105    TokenStream::from(output)
106}
107
108fn expand_css_helper(tokens: TokenStream2) -> TokenStream {
109    let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
110    let output = quote! {
111        fn css() -> maud::Markup {
112            ::maud_extensions::inline_css! { #tokens }
113        }
114
115        #[doc(hidden)]
116        fn #component_css_helper_ident() -> maud::Markup {
117            css()
118        }
119    };
120
121    TokenStream::from(output)
122}
123
124#[proc_macro]
125pub fn css(input: TokenStream) -> TokenStream {
126    let tokens: TokenStream2 = input.into();
127    expand_css_helper(tokens)
128}
129
130fn tokens_to_css(tokens: TokenStream2) -> String {
131    let mut out = String::new();
132    let mut prev_word = false;
133
134    for token in tokens {
135        match token {
136            TokenTree::Group(group) => {
137                let (open, close) = match group.delimiter() {
138                    proc_macro2::Delimiter::Parenthesis => ('(', ')'),
139                    proc_macro2::Delimiter::Bracket => ('[', ']'),
140                    proc_macro2::Delimiter::Brace => ('{', '}'),
141                    proc_macro2::Delimiter::None => (' ', ' '),
142                };
143                let needs_space = prev_word
144                    && matches!(
145                        group.delimiter(),
146                        proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
147                    );
148                if needs_space {
149                    out.push(' ');
150                }
151                if open != ' ' {
152                    out.push(open);
153                }
154                out.push_str(&tokens_to_css(group.stream()));
155                if close != ' ' {
156                    out.push(close);
157                }
158                prev_word = false;
159            }
160            TokenTree::Ident(ident) => {
161                if prev_word {
162                    out.push(' ');
163                }
164                out.push_str(&ident.to_string());
165                prev_word = true;
166            }
167            TokenTree::Literal(literal) => {
168                if prev_word {
169                    out.push(' ');
170                }
171                out.push_str(&literal.to_string());
172                prev_word = true;
173            }
174            TokenTree::Punct(punct) => {
175                out.push(punct.as_char());
176                prev_word = false;
177            }
178        }
179    }
180
181    out
182}
183
184fn validate_css(css: &str) -> core::result::Result<(), String> {
185    let mut input = cssparser::ParserInput::new(css);
186    let mut parser = cssparser::Parser::new(&mut input);
187    loop {
188        match parser.next_including_whitespace_and_comments() {
189            Ok(_) => {}
190            Err(err) => match err.kind {
191                cssparser::BasicParseErrorKind::EndOfInput => return Ok(()),
192                _ => return Err("inline_css! could not parse CSS tokens".to_string()),
193            },
194        }
195    }
196}
197
198fn expand_js_markup(js_input: JsInput) -> TokenStream {
199    let (content_lit, js_string) = match js_input {
200        JsInput::Literal(content) => {
201            let js_string = content.value();
202            (content, js_string)
203        }
204        JsInput::Tokens(tokens) => {
205            let js = tokens_to_js(tokens);
206            (LitStr::new(&js, Span::call_site()), js)
207        }
208    };
209    if let Err(message) = validate_js(&js_string) {
210        return syn::Error::new(Span::call_site(), message)
211            .to_compile_error()
212            .into();
213    }
214
215    let output = quote! {
216        maud::html! {
217            script {
218                (maud::PreEscaped(#content_lit))
219            }
220        }
221    };
222
223    TokenStream::from(output)
224}
225
226fn expand_js_helper(tokens: TokenStream2) -> TokenStream {
227    let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
228    let output = quote! {
229        fn js() -> maud::Markup {
230            ::maud_extensions::inline_js! { #tokens }
231        }
232
233        #[doc(hidden)]
234        fn #component_js_helper_ident() -> maud::Markup {
235            js()
236        }
237    };
238
239    TokenStream::from(output)
240}
241
242#[proc_macro]
243pub fn js(input: TokenStream) -> TokenStream {
244    let tokens: TokenStream2 = input.into();
245    expand_js_helper(tokens)
246}
247
248#[proc_macro]
249pub fn inline_js(input: TokenStream) -> TokenStream {
250    let js_input = parse_macro_input!(input as JsInput);
251    expand_js_markup(js_input)
252}
253
254#[proc_macro]
255pub fn inline_css(input: TokenStream) -> TokenStream {
256    let css_input = parse_macro_input!(input as CssInput);
257    expand_css_markup(css_input)
258}
259
260fn component_syntax_error() -> syn::Error {
261    syn::Error::new(
262        Span::call_site(),
263        "component! expects exactly one top-level element with a body block, e.g. component! { article { ... } }",
264    )
265}
266
267#[proc_macro]
268pub fn component(input: TokenStream) -> TokenStream {
269    let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
270    let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
271    let mut tokens: Vec<TokenTree> = TokenStream2::from(input).into_iter().collect();
272
273    while matches!(
274        tokens.last(),
275        Some(TokenTree::Punct(punct)) if punct.as_char() == ';'
276    ) {
277        tokens.pop();
278    }
279
280    if tokens.is_empty() {
281        return component_syntax_error().to_compile_error().into();
282    }
283
284    if !matches!(tokens.first(), Some(TokenTree::Ident(_))) {
285        return component_syntax_error().to_compile_error().into();
286    }
287
288    let root_body_count = tokens
289        .iter()
290        .filter(|token| matches!(token, TokenTree::Group(group) if group.delimiter() == Delimiter::Brace))
291        .count();
292
293    if root_body_count != 1 {
294        return component_syntax_error().to_compile_error().into();
295    }
296
297    let Some(TokenTree::Group(root_group)) = tokens.last() else {
298        return component_syntax_error().to_compile_error().into();
299    };
300    if root_group.delimiter() != Delimiter::Brace {
301        return component_syntax_error().to_compile_error().into();
302    }
303
304    let mut injected_body = root_group.stream();
305    injected_body.extend(quote! { (#component_js_helper_ident()) (#component_css_helper_ident()) });
306    let mut updated_group = Group::new(Delimiter::Brace, injected_body);
307    updated_group.set_span(root_group.span());
308    let last_index = tokens.len() - 1;
309    tokens[last_index] = TokenTree::Group(updated_group);
310
311    let root_tokens: TokenStream2 = tokens.into_iter().collect();
312    let output = quote! {
313        maud::html! {
314            #root_tokens
315        }
316    };
317
318    output.into()
319}
320
321#[proc_macro]
322pub fn js_file(input: TokenStream) -> TokenStream {
323    let path = parse_macro_input!(input as Expr);
324    let output = quote! {
325        maud::html! {
326            script {
327                (maud::PreEscaped(include_str!(#path)))
328            }
329        }
330    };
331
332    TokenStream::from(output)
333}
334
335#[proc_macro]
336pub fn css_file(input: TokenStream) -> TokenStream {
337    let path = parse_macro_input!(input as Expr);
338    let output = quote! {
339        maud::html! {
340            style {
341                (maud::PreEscaped(include_str!(#path)))
342            }
343        }
344    };
345
346    TokenStream::from(output)
347}
348
349#[proc_macro]
350pub fn surreal_scope_inline(input: TokenStream) -> TokenStream {
351    let _ = parse_macro_input!(input as Nothing);
352    let surreal_js = LitStr::new(SURREAL_JS_BUNDLE, Span::call_site());
353    let css_scope_inline_js = LitStr::new(CSS_SCOPE_INLINE_JS_BUNDLE, Span::call_site());
354    let output = quote! {
355        maud::html! {
356            script {
357                (maud::PreEscaped(#surreal_js))
358            }
359            script {
360                (maud::PreEscaped(#css_scope_inline_js))
361            }
362        }
363    };
364
365    TokenStream::from(output)
366}
367
368fn tokens_to_js(tokens: TokenStream2) -> String {
369    let mut out = String::new();
370    let mut prev_word = false;
371
372    for token in tokens {
373        match token {
374            TokenTree::Group(group) => {
375                let (open, close) = match group.delimiter() {
376                    proc_macro2::Delimiter::Parenthesis => ('(', ')'),
377                    proc_macro2::Delimiter::Bracket => ('[', ']'),
378                    proc_macro2::Delimiter::Brace => ('{', '}'),
379                    proc_macro2::Delimiter::None => (' ', ' '),
380                };
381                let needs_space = prev_word
382                    && matches!(
383                        group.delimiter(),
384                        proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
385                    );
386                if needs_space {
387                    out.push(' ');
388                }
389                if open != ' ' {
390                    out.push(open);
391                }
392                out.push_str(&tokens_to_js(group.stream()));
393                if close != ' ' {
394                    out.push(close);
395                }
396                prev_word = false;
397            }
398            TokenTree::Ident(ident) => {
399                if prev_word {
400                    out.push(' ');
401                }
402                out.push_str(&ident.to_string());
403                prev_word = true;
404            }
405            TokenTree::Literal(literal) => {
406                if prev_word {
407                    out.push(' ');
408                }
409                out.push_str(&literal.to_string());
410                prev_word = true;
411            }
412            TokenTree::Punct(punct) => {
413                out.push(punct.as_char());
414                prev_word = false;
415            }
416        }
417    }
418
419    out
420}
421
422fn validate_js(js: &str) -> core::result::Result<(), String> {
423    let cm = SourceMap::default();
424    let fm = cm.new_source_file(
425        FileName::Custom("inline.js".to_string()).into(),
426        js.to_string(),
427    );
428    let input = StringInput::from(&*fm);
429    let mut parser = Parser::new(Syntax::Es(EsSyntax::default()), input, None);
430    match parser.parse_script() {
431        Ok(_) => Ok(()),
432        Err(err) => Err(format!("inline_js! could not parse JavaScript: {err:#?}")),
433    }
434}
435
436struct FontFace {
437    path: LitStr,
438    family: LitStr,
439    weight: Option<LitStr>,
440    style: Option<LitStr>,
441}
442
443impl Parse for FontFace {
444    fn parse(input: ParseStream) -> syn::Result<Self> {
445        let path: LitStr = input.parse()?;
446        input.parse::<Token![,]>()?;
447        let family: LitStr = input.parse()?;
448
449        let weight = if input.peek(Token![,]) {
450            input.parse::<Token![,]>()?;
451            if input.peek(LitStr) {
452                Some(input.parse()?)
453            } else {
454                None
455            }
456        } else {
457            None
458        };
459
460        let style = if weight.is_some() && input.peek(Token![,]) {
461            input.parse::<Token![,]>()?;
462            if input.peek(LitStr) {
463                Some(input.parse()?)
464            } else {
465                None
466            }
467        } else {
468            None
469        };
470
471        Ok(FontFace {
472            path,
473            family,
474            weight,
475            style,
476        })
477    }
478}
479
480struct FontFaceList {
481    fonts: Punctuated<FontFace, Token![;]>,
482}
483
484impl Parse for FontFaceList {
485    fn parse(input: ParseStream) -> syn::Result<Self> {
486        let fonts = Punctuated::parse_terminated(input)?;
487        Ok(FontFaceList { fonts })
488    }
489}
490
491#[proc_macro]
492pub fn font_face(input: TokenStream) -> TokenStream {
493    let font = parse_macro_input!(input as FontFace);
494
495    let path = font.path;
496    let family = font.family;
497    let weight = font
498        .weight
499        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
500    let style = font
501        .style
502        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
503
504    let expanded = quote! {
505        {
506            use base64::Engine;
507            use base64::engine::general_purpose::STANDARD;
508            use maud::PreEscaped;
509
510            let font_bytes = include_bytes!(#path);
511            let mut base64_string = String::new();
512
513            STANDARD.encode_string(font_bytes, &mut base64_string);
514
515            let path_str = #path;
516            let format = if path_str.ends_with(".ttf") {
517                "truetype"
518            } else if path_str.ends_with(".otf") {
519                "opentype"
520            } else if path_str.ends_with(".woff") {
521                "woff"
522            } else if path_str.ends_with(".woff2") {
523                "woff2"
524            } else {
525                "truetype"
526            };
527
528            let font_type = if path_str.ends_with(".woff2") {
529                "woff2"
530            } else if path_str.ends_with(".woff") {
531                "woff"
532            } else if path_str.ends_with(".otf") {
533                "opentype"
534            } else {
535                "truetype"
536            };
537
538            let css = format!(
539                "@font-face {{\n    font-family: '{}';\n    src: url('data:font/{};base64,{}') format('{}');\n    font-weight: {};\n    font-style: {};\n}}",
540                #family,
541                font_type,
542                base64_string,
543                format,
544                #weight,
545                #style
546            );
547
548            PreEscaped(css)
549        }
550    };
551
552    expanded.into()
553}
554
555#[proc_macro]
556pub fn font_faces(input: TokenStream) -> TokenStream {
557    let fonts = parse_macro_input!(input as FontFaceList);
558
559    let font_faces = fonts.fonts.iter().map(|font| {
560        let path = &font.path;
561        let family = &font.family;
562        let weight = font
563            .weight
564            .as_ref()
565            .map_or_else(|| quote! { "normal" }, |w| quote! { #w });
566        let style = font
567            .style
568            .as_ref()
569            .map_or_else(|| quote! { "normal" }, |s| quote! { #s });
570
571        quote! {
572            {
573                use maud_extensions::font_face;
574                let face = font_face!(#path, #family, #weight, #style);
575                css.push_str(&face.0);
576            }
577        }
578    });
579
580    let expanded = quote! {
581        {
582            use maud::PreEscaped;
583            let mut css = String::new();
584
585            #(#font_faces)*
586
587            PreEscaped(css)
588        }
589    };
590
591    expanded.into()
592}