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