Skip to main content

maud_extensions/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4//! Proc macros for Maud views with component-scoped helpers and runtime assets.
5//!
6//! Supported workflows:
7//! - `js!`, `css!`, and `component!` for file-scoped components
8//! - `inline_js!`, `inline_css!`, `js_file!`, and `css_file!` for direct asset injection
9//! - `surreal_scope_inline!()` for the bundled `surreal.js` and `css-scope-inline.js`
10//! - `signals_inline!()` and `surreal_scope_signals_inline!()` for bundled Signals helpers
11//! - `font_face!` and `font_faces!` for embedding font files as data URLs
12//!
13//! Support policy:
14//! - MSRV: Rust 1.85
15//! - Supported Maud version: 0.27
16//!
17//! Important limits:
18//! - `component!` accepts exactly one top-level Maud element with a body block. It doesn't accept
19//!   control-flow roots or every possible Maud token pattern.
20//! - `inline_js!` parses the emitted JavaScript with SWC before generating markup.
21//! - `inline_css!` performs a lightweight syntax check before forwarding the stylesheet as written.
22//! - Signals support stays JS-first: markup provides anchors, while `js!` owns signals and DOM
23//!   binding.
24//! - Slot helpers live in the companion `maud-extensions-runtime` crate.
25
26use proc_macro::TokenStream;
27use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream as TokenStream2, TokenTree};
28use quote::{format_ident, quote};
29use swc_common::{FileName, SourceMap};
30use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
31use syn::{
32    Data, DeriveInput, Expr, Fields, GenericParam, Generics, LitStr, Result, Token, Type, TypePath,
33    ext::IdentExt,
34    parse::{Nothing, Parse, ParseStream},
35    parse_macro_input, parse_quote,
36    punctuated::Punctuated,
37    spanned::Spanned,
38};
39
40const SURREAL_JS_BUNDLE: &str = include_str!("../assets/surreal.js");
41const CSS_SCOPE_INLINE_JS_BUNDLE: &str = include_str!("../assets/css-scope-inline.js");
42const SIGNALS_CORE_JS_BUNDLE: &str = include_str!("../assets/signals-core.min.js");
43const SIGNALS_ADAPTER_JS_BUNDLE: &str = include_str!("../assets/signals-adapter.js");
44const COMPONENT_JS_HELPER_FN: &str =
45    "__maud_extensions_component_requires_js_macro_in_scope_can_be_empty";
46const COMPONENT_CSS_HELPER_FN: &str =
47    "__maud_extensions_component_requires_css_macro_in_scope_can_be_empty";
48const COMPONENT_JS_MODE_ATTR: &str = "data-mx-js-mode";
49const COMPONENT_JS_RAN_ATTR: &str = "data-mx-js-ran";
50const COMPONENT_SYNTAX_ERROR: &str = "component! expects optional directives first (`@js-once` or `@js-always`) followed by exactly one top-level element with a body block, e.g. component! { @js-once article { ... } }";
51
52#[derive(Clone, Copy, PartialEq, Eq)]
53enum ComponentJsMode {
54    Always,
55    Once,
56}
57
58impl ComponentJsMode {
59    fn as_str(self) -> &'static str {
60        match self {
61            ComponentJsMode::Always => "always",
62            ComponentJsMode::Once => "once",
63        }
64    }
65}
66
67enum JsInput {
68    Literal(LitStr),
69    Tokens(TokenStream2),
70}
71
72impl Parse for JsInput {
73    fn parse(input: ParseStream) -> Result<Self> {
74        if input.peek(LitStr) {
75            let content: LitStr = input.parse()?;
76            Ok(JsInput::Literal(content))
77        } else {
78            let tokens: TokenStream2 = input.parse()?;
79            Ok(JsInput::Tokens(tokens))
80        }
81    }
82}
83
84enum CssInput {
85    Literal(LitStr),
86    Tokens(TokenStream2),
87}
88
89impl Parse for CssInput {
90    fn parse(input: ParseStream) -> Result<Self> {
91        if input.peek(LitStr) {
92            let content: LitStr = input.parse()?;
93            Ok(CssInput::Literal(content))
94        } else {
95            let tokens: TokenStream2 = input.parse()?;
96            Ok(CssInput::Tokens(tokens))
97        }
98    }
99}
100
101struct CssHelperInput {
102    helper_name: Option<LitStr>,
103    css: CssInput,
104}
105
106impl Parse for CssHelperInput {
107    fn parse(input: ParseStream) -> Result<Self> {
108        if input.peek(LitStr) && input.peek2(Token![,]) {
109            let helper_name: LitStr = input.parse()?;
110            input.parse::<Token![,]>()?;
111
112            let css = if input.peek(LitStr) {
113                CssInput::Literal(input.parse()?)
114            } else if input.peek(syn::token::Brace) {
115                let content;
116                syn::braced!(content in input);
117                CssInput::Tokens(content.parse()?)
118            } else {
119                CssInput::Tokens(input.parse()?)
120            };
121
122            if !input.is_empty() {
123                return Err(input.error("unexpected trailing tokens after named css! helper"));
124            }
125
126            Ok(Self {
127                helper_name: Some(helper_name),
128                css,
129            })
130        } else {
131            Ok(Self {
132                helper_name: None,
133                css: input.parse()?,
134            })
135        }
136    }
137}
138
139fn expand_css_markup(css_input: CssInput) -> TokenStream {
140    let css = match css_input {
141        CssInput::Literal(content) => content.value(),
142        CssInput::Tokens(tokens) => match css_tokens_to_source(tokens) {
143            Ok(css) => css,
144            Err(err) => return err.to_compile_error().into(),
145        },
146    };
147
148    if let Err(message) = validate_css(&css) {
149        return syn::Error::new(Span::call_site(), message)
150            .to_compile_error()
151            .into();
152    }
153
154    let content_lit = LitStr::new(&css, Span::call_site());
155
156    let output = quote! {
157        {
158            fn callsite_id(prefix: &str, file: &str, line: u32, col: u32) -> String {
159                let mut h: u64 = 0xcbf29ce484222325;
160                for b in file.as_bytes() {
161                    h ^= *b as u64;
162                    h = h.wrapping_mul(0x100000001b3);
163                }
164                for b in line.to_le_bytes() {
165                    h ^= b as u64;
166                    h = h.wrapping_mul(0x100000001b3);
167                }
168                for b in col.to_le_bytes() {
169                    h ^= b as u64;
170                    h = h.wrapping_mul(0x100000001b3);
171                }
172
173                format!("{prefix}{h:016x}")
174            }
175
176            let __id = callsite_id("mx-css-", file!(), line!(), column!());
177
178            maud::html! {
179                style data-mx-css-id=(__id) {
180                    (maud::PreEscaped(#content_lit))
181                }
182            }
183        }
184    };
185
186    TokenStream::from(output)
187}
188
189fn parse_helper_ident(helper_name: LitStr, macro_name: &str) -> Result<Ident> {
190    let value = helper_name.value();
191    let parsed: TokenStream2 = value.parse().map_err(|_| {
192        syn::Error::new(
193            helper_name.span(),
194            format!("{macro_name}! helper name must be a valid Rust identifier string"),
195        )
196    })?;
197
198    let mut tokens = parsed.into_iter();
199    match (tokens.next(), tokens.next()) {
200        (Some(TokenTree::Ident(mut ident)), None) => {
201            ident.set_span(helper_name.span());
202            Ok(ident)
203        }
204        _ => Err(syn::Error::new(
205            helper_name.span(),
206            format!("{macro_name}! helper name must be a valid Rust identifier string"),
207        )),
208    }
209}
210
211fn expand_css_helper(input: CssHelperInput) -> TokenStream {
212    let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
213    let use_default_component_helper = input.helper_name.is_none();
214    let css_fn_ident = match input.helper_name {
215        Some(name) => match parse_helper_ident(name, "css") {
216            Ok(ident) => ident,
217            Err(err) => return err.to_compile_error().into(),
218        },
219        None => Ident::new("css", Span::call_site()),
220    };
221    let css_input = match input.css {
222        CssInput::Literal(content) => quote!(#content),
223        CssInput::Tokens(tokens) => quote!(#tokens),
224    };
225
226    let output = quote! {
227        fn #css_fn_ident() -> maud::Markup {
228            ::maud_extensions::inline_css!(#css_input)
229        }
230    };
231
232    if use_default_component_helper {
233        TokenStream::from(quote! {
234            #output
235
236            #[doc(hidden)]
237            fn #component_css_helper_ident() -> maud::Markup {
238                #css_fn_ident()
239            }
240        })
241    } else {
242        TokenStream::from(output)
243    }
244}
245
246/// Generates a local CSS helper for Maud markup.
247///
248/// The macro accepts either a string literal or CSS-like tokens. Token input is flattened into a
249/// stylesheet string and checked for basic CSS syntax before it is emitted.
250///
251/// Use `raw!(r#"..."#)` inside token input as an escape hatch for CSS fragments that are not
252/// valid Rust token syntax.
253///
254/// `css! { ... }` generates the default `fn css() -> maud::Markup` helper used by `component!`.
255/// `css! { "card_css", { ... } }` generates `fn card_css() -> maud::Markup` instead, which lets
256/// you define additional stylesheet helpers in the same scope.
257///
258/// ```rust
259/// use maud_extensions::{component, css, js};
260///
261/// fn view() -> maud::Markup {
262///     js! {}
263///     let markup = component! {
264///         div class="card" {
265///             "Hello"
266///         }
267///     };
268///     css! {
269///         me { color: red; }
270///     }
271///     css! { "tokens_css", raw!(r#":root { --font-display: 'Newsreader', Georgia, serif; }"#) }
272///     css! { "card_border", {
273///         .card { border: 1px solid #ddd; }
274///     } }
275///     let _ = card_border();
276///     let _ = tokens_css();
277///     markup
278/// }
279/// ```
280#[proc_macro]
281pub fn css(input: TokenStream) -> TokenStream {
282    let input = parse_macro_input!(input as CssHelperInput);
283    expand_css_helper(input)
284}
285
286struct RawCssInput {
287    css: LitStr,
288}
289
290impl Parse for RawCssInput {
291    fn parse(input: ParseStream) -> Result<Self> {
292        let css: LitStr = input.parse()?;
293        if !input.is_empty() {
294            return Err(input.error("raw! expects exactly one string literal argument"));
295        }
296        Ok(Self { css })
297    }
298}
299
300fn parse_raw_css_fragment(group: &Group) -> Result<String> {
301    let input = syn::parse2::<RawCssInput>(group.stream()).map_err(|_| {
302        syn::Error::new(
303            group.span(),
304            "raw! expects exactly one string literal argument",
305        )
306    })?;
307    Ok(input.css.value())
308}
309
310fn css_tokens_to_source(tokens: TokenStream2) -> Result<String> {
311    css_token_trees_to_source(tokens.into_iter().collect())
312}
313
314fn css_token_trees_to_source(tokens: Vec<TokenTree>) -> Result<String> {
315    let mut out = String::new();
316    let mut prev_word = false;
317    let mut index = 0usize;
318
319    while index < tokens.len() {
320        if let Some(raw_css) = try_parse_raw_css(&tokens, &mut index)? {
321            if prev_word {
322                out.push(' ');
323            }
324            out.push_str(&raw_css);
325            prev_word = false;
326            continue;
327        }
328
329        match &tokens[index] {
330            TokenTree::Group(group) => {
331                let (open, close) = match group.delimiter() {
332                    Delimiter::Parenthesis => ('(', ')'),
333                    Delimiter::Bracket => ('[', ']'),
334                    Delimiter::Brace => ('{', '}'),
335                    Delimiter::None => (' ', ' '),
336                };
337                let needs_space =
338                    prev_word && matches!(group.delimiter(), Delimiter::Brace | Delimiter::None);
339                if needs_space {
340                    out.push(' ');
341                }
342                if open != ' ' {
343                    out.push(open);
344                }
345                out.push_str(&css_tokens_to_source(group.stream())?);
346                if close != ' ' {
347                    out.push(close);
348                }
349                prev_word = false;
350            }
351            TokenTree::Ident(ident) => {
352                if prev_word {
353                    out.push(' ');
354                }
355                out.push_str(&ident.to_string());
356                prev_word = true;
357            }
358            TokenTree::Literal(literal) => {
359                if prev_word {
360                    out.push(' ');
361                }
362                out.push_str(&literal.to_string());
363                prev_word = true;
364            }
365            TokenTree::Punct(punct) => {
366                out.push(punct.as_char());
367                prev_word = false;
368            }
369        }
370
371        index += 1;
372    }
373
374    Ok(out)
375}
376
377fn try_parse_raw_css(tokens: &[TokenTree], index: &mut usize) -> Result<Option<String>> {
378    let Some(TokenTree::Ident(ident)) = tokens.get(*index) else {
379        return Ok(None);
380    };
381
382    if ident != "raw" {
383        return Ok(None);
384    }
385
386    let Some(TokenTree::Punct(punct)) = tokens.get(*index + 1) else {
387        return Ok(None);
388    };
389
390    if punct.as_char() != '!' {
391        return Ok(None);
392    }
393
394    let Some(TokenTree::Group(group)) = tokens.get(*index + 2) else {
395        return Err(syn::Error::new(
396            punct.span(),
397            "raw! expects exactly one string literal argument",
398        ));
399    };
400
401    let raw_css = parse_raw_css_fragment(group)?;
402    *index += 2;
403    Ok(Some(raw_css))
404}
405
406fn tokens_to_source(tokens: TokenStream2) -> String {
407    let mut out = String::new();
408    let mut prev_word = false;
409
410    for token in tokens {
411        match token {
412            TokenTree::Group(group) => {
413                let (open, close) = match group.delimiter() {
414                    Delimiter::Parenthesis => ('(', ')'),
415                    Delimiter::Bracket => ('[', ']'),
416                    Delimiter::Brace => ('{', '}'),
417                    Delimiter::None => (' ', ' '),
418                };
419                let needs_space =
420                    prev_word && matches!(group.delimiter(), Delimiter::Brace | Delimiter::None);
421                if needs_space {
422                    out.push(' ');
423                }
424                if open != ' ' {
425                    out.push(open);
426                }
427                out.push_str(&tokens_to_source(group.stream()));
428                if close != ' ' {
429                    out.push(close);
430                }
431                prev_word = false;
432            }
433            TokenTree::Ident(ident) => {
434                if prev_word {
435                    out.push(' ');
436                }
437                out.push_str(&ident.to_string());
438                prev_word = true;
439            }
440            TokenTree::Literal(literal) => {
441                if prev_word {
442                    out.push(' ');
443                }
444                out.push_str(&literal.to_string());
445                prev_word = true;
446            }
447            TokenTree::Punct(punct) => {
448                out.push(punct.as_char());
449                prev_word = false;
450            }
451        }
452    }
453
454    out
455}
456
457fn validate_css(css: &str) -> core::result::Result<(), String> {
458    let mut input = cssparser::ParserInput::new(css);
459    let mut parser = cssparser::Parser::new(&mut input);
460    loop {
461        match parser.next_including_whitespace_and_comments() {
462            Ok(_) => {}
463            Err(err) => match err.kind {
464                cssparser::BasicParseErrorKind::EndOfInput => return Ok(()),
465                _ => return Err("inline_css! could not parse CSS tokens".to_string()),
466            },
467        }
468    }
469}
470
471fn emit_script_bundles(bundles: impl IntoIterator<Item = &'static str>) -> TokenStream {
472    let bundles: Vec<LitStr> = bundles
473        .into_iter()
474        .map(|bundle| LitStr::new(bundle, Span::call_site()))
475        .collect();
476
477    quote! {
478        maud::html! {
479            #(
480                script {
481                    (maud::PreEscaped(#bundles))
482                }
483            )*
484        }
485    }
486    .into()
487}
488
489fn expand_js_markup(js_input: JsInput) -> TokenStream {
490    let (content_lit, js_string) = match js_input {
491        JsInput::Literal(content) => {
492            let js_string = content.value();
493            (content, js_string)
494        }
495        JsInput::Tokens(tokens) => {
496            let js = tokens_to_source(tokens);
497            (LitStr::new(&js, Span::call_site()), js)
498        }
499    };
500    if let Err(message) = validate_js(&js_string) {
501        return syn::Error::new(Span::call_site(), message)
502            .to_compile_error()
503            .into();
504    }
505
506    let output = quote! {
507        maud::html! {
508            script {
509                (maud::PreEscaped(#content_lit))
510            }
511        }
512    };
513
514    TokenStream::from(output)
515}
516
517fn expand_js_helper(js_input: JsInput) -> TokenStream {
518    let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
519    let js_mode_attr = COMPONENT_JS_MODE_ATTR;
520    let js_ran_attr = COMPONENT_JS_RAN_ATTR;
521    let js_markup = match js_input {
522        JsInput::Literal(content) => {
523            let wrapped = format!(
524                "const __mx_script = document.currentScript;\n\
525                 const __mx_root = __mx_script && __mx_script.parentElement;\n\
526                 const __mx_mode = __mx_root ? __mx_root.getAttribute(\"{js_mode_attr}\") : null;\n\
527                 let __mx_should_run = true;\n\
528                 if (__mx_mode === \"once\" && __mx_root) {{\n\
529                 if (__mx_root.hasAttribute(\"{js_ran_attr}\")) {{\n\
530                 __mx_should_run = false;\n\
531                 }} else {{\n\
532                 __mx_root.setAttribute(\"{js_ran_attr}\", \"\");\n\
533                 }}\n\
534                 }}\n\
535                 if (__mx_should_run) {{\n\
536                 {}\n\
537                 }}",
538                content.value()
539            );
540            let wrapped_lit = LitStr::new(&wrapped, Span::call_site());
541            quote! {
542                ::maud_extensions::inline_js!(#wrapped_lit)
543            }
544        }
545        JsInput::Tokens(tokens) => {
546            let js_mode_attr = LitStr::new(js_mode_attr, Span::call_site());
547            let js_ran_attr = LitStr::new(js_ran_attr, Span::call_site());
548            quote! {
549                ::maud_extensions::inline_js! {
550                    const __mx_script = document.currentScript;
551                    const __mx_root = __mx_script && __mx_script.parentElement;
552                    const __mx_mode = __mx_root ? __mx_root.getAttribute(#js_mode_attr) : null;
553
554                    let __mx_should_run = true;
555                    if (__mx_mode === "once" && __mx_root) {
556                        if (__mx_root.hasAttribute(#js_ran_attr)) {
557                            __mx_should_run = false;
558                        } else {
559                            __mx_root.setAttribute(#js_ran_attr, "");
560                        }
561                    }
562
563                    if (__mx_should_run) {
564                        #tokens
565                    }
566                }
567            }
568        }
569    };
570
571    let output = quote! {
572        fn js() -> maud::Markup {
573            #js_markup
574        }
575
576        #[doc(hidden)]
577        fn #component_js_helper_ident() -> maud::Markup {
578            js()
579        }
580    };
581
582    TokenStream::from(output)
583}
584
585/// Generates a local `fn js() -> maud::Markup` helper for `component!`.
586///
587/// The macro accepts either a string literal or JavaScript-like tokens. The generated helper is
588/// wrapped so `component!` can honor `@js-once` and `@js-always`.
589///
590/// ```rust
591/// use maud_extensions::{component, css, js};
592///
593/// fn view() -> maud::Markup {
594///     js! {
595///         me().class_add("ready");
596///     }
597///     let markup = component! {
598///         div class="card" {
599///             "Hello"
600///         }
601///     };
602///     css! {}
603///     markup
604/// }
605/// ```
606#[proc_macro]
607pub fn js(input: TokenStream) -> TokenStream {
608    let js_input = parse_macro_input!(input as JsInput);
609    expand_js_helper(js_input)
610}
611
612/// Emits a `<script>` tag directly from a JavaScript string literal or token block.
613///
614/// The JavaScript is parsed with SWC before the markup is generated.
615///
616/// ```rust
617/// use maud_extensions::inline_js;
618///
619/// fn view() -> maud::Markup {
620///     maud::html! {
621///         (inline_js! {
622///             console.log("ready");
623///         })
624///     }
625/// }
626/// ```
627#[proc_macro]
628pub fn inline_js(input: TokenStream) -> TokenStream {
629    let js_input = parse_macro_input!(input as JsInput);
630    expand_js_markup(js_input)
631}
632
633/// Emits a `<style>` tag directly from a CSS string literal or token block.
634///
635/// The CSS is checked for basic syntax errors before the markup is generated.
636/// Use `raw!(r#"..."#)` inside token input as an escape hatch for CSS fragments that are not
637/// valid Rust token syntax.
638///
639/// ```rust
640/// use maud_extensions::inline_css;
641///
642/// fn view() -> maud::Markup {
643///     maud::html! {
644///         (inline_css! {
645///             .card { display: block; }
646///         })
647///     }
648/// }
649/// ```
650#[proc_macro]
651pub fn inline_css(input: TokenStream) -> TokenStream {
652    let css_input = parse_macro_input!(input as CssInput);
653    expand_css_markup(css_input)
654}
655
656fn component_syntax_error(span: Span) -> syn::Error {
657    syn::Error::new(span, COMPONENT_SYNTAX_ERROR)
658}
659
660fn component_directive_error(span: Span, message: &str) -> syn::Error {
661    syn::Error::new(span, message)
662}
663
664fn is_punct(token: &TokenTree, ch: char) -> bool {
665    matches!(token, TokenTree::Punct(punct) if punct.as_char() == ch)
666}
667
668fn is_ident(token: &TokenTree, expected: &str) -> bool {
669    matches!(token, TokenTree::Ident(ident) if ident == expected)
670}
671
672fn token_span(token: Option<&TokenTree>) -> Span {
673    token.map(TokenTree::span).unwrap_or_else(Span::call_site)
674}
675
676fn parse_component_js_directive(tokens: &[TokenTree]) -> Result<(ComponentJsMode, usize)> {
677    if tokens.len() < 4 {
678        return Err(component_directive_error(
679            token_span(tokens.first()),
680            "component! directive is incomplete. Use `@js-once` or `@js-always`.",
681        ));
682    }
683
684    if !is_ident(&tokens[1], "js") || !is_punct(&tokens[2], '-') {
685        return Err(component_directive_error(
686            tokens[1].span(),
687            "unknown component! directive. Supported directives are `@js-once` and `@js-always`.",
688        ));
689    }
690
691    let mode = if is_ident(&tokens[3], "once") {
692        ComponentJsMode::Once
693    } else if is_ident(&tokens[3], "always") {
694        ComponentJsMode::Always
695    } else {
696        return Err(component_directive_error(
697            tokens[3].span(),
698            "unknown component! directive. Supported directives are `@js-once` and `@js-always`.",
699        ));
700    };
701
702    let mut consumed = 4usize;
703    if matches!(tokens.get(consumed), Some(token) if is_punct(token, ';')) {
704        consumed += 1;
705    }
706    Ok((mode, consumed))
707}
708
709fn find_component_body_index(tokens: &[TokenTree]) -> Result<usize> {
710    if tokens.is_empty() {
711        return Err(component_syntax_error(Span::call_site()));
712    }
713    if !matches!(tokens.first(), Some(TokenTree::Ident(_))) {
714        return Err(component_syntax_error(token_span(tokens.first())));
715    }
716
717    if let Some(token) = tokens
718        .iter()
719        .find(|token| matches!(token, TokenTree::Punct(punct) if punct.as_char() == '@'))
720    {
721        return Err(component_directive_error(
722            token.span(),
723            "component! directives must appear before the root element.",
724        ));
725    }
726
727    let Some(body_index) = tokens.iter().position(
728        |token| matches!(token, TokenTree::Group(group) if group.delimiter() == Delimiter::Brace),
729    ) else {
730        return Err(component_syntax_error(token_span(tokens.last())));
731    };
732
733    let trailing = tokens
734        .iter()
735        .enumerate()
736        .skip(body_index + 1)
737        .find(|(_, token)| !matches!(token, TokenTree::Punct(punct) if punct.as_char() == ';'));
738    if let Some((_, token)) = trailing {
739        return Err(component_syntax_error(token.span()));
740    }
741
742    Ok(body_index)
743}
744
745/// Wraps a single top-level Maud element and injects the local `js!` and `css!` helpers inside
746/// that root element.
747///
748/// `component!` performs compile-time shape checks over the token stream it observes. It accepts
749/// one top-level element with a body block plus an optional `@js-once` or `@js-always` directive.
750///
751/// ```rust
752/// use maud_extensions::{component, css, js};
753///
754/// fn view() -> maud::Markup {
755///     js! {
756///         me().class_add("ready");
757///     }
758///     let markup = component! {
759///         @js-once
760///         article class="card" {
761///             p { "Hello" }
762///         }
763///     };
764///     css! {
765///         me { border: 1px solid #ddd; }
766///     }
767///     markup
768/// }
769/// ```
770#[proc_macro]
771pub fn component(input: TokenStream) -> TokenStream {
772    let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
773    let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
774    let mut tokens: Vec<TokenTree> = TokenStream2::from(input).into_iter().collect();
775
776    while matches!(
777        tokens.last(),
778        Some(TokenTree::Punct(punct)) if punct.as_char() == ';'
779    ) {
780        tokens.pop();
781    }
782
783    if tokens.is_empty() {
784        return component_syntax_error(Span::call_site())
785            .to_compile_error()
786            .into();
787    }
788
789    let mut js_mode = ComponentJsMode::Always;
790    let mut seen_mode_directive = false;
791    let mut consumed = 0usize;
792
793    while matches!(tokens.get(consumed), Some(token) if is_punct(token, '@')) {
794        let (mode, directive_len) = match parse_component_js_directive(&tokens[consumed..]) {
795            Ok(parsed) => parsed,
796            Err(err) => return err.to_compile_error().into(),
797        };
798
799        if seen_mode_directive {
800            return component_directive_error(
801                tokens[consumed].span(),
802                "component! accepts at most one JS mode directive (`@js-once` or `@js-always`).",
803            )
804            .to_compile_error()
805            .into();
806        }
807
808        js_mode = mode;
809        seen_mode_directive = true;
810        consumed += directive_len;
811    }
812
813    if consumed > 0 {
814        tokens.drain(0..consumed);
815    }
816
817    let body_index = match find_component_body_index(&tokens) {
818        Ok(index) => index,
819        Err(err) => return err.to_compile_error().into(),
820    };
821
822    let Some(TokenTree::Group(root_group)) = tokens.get(body_index) else {
823        return component_syntax_error(token_span(tokens.last()))
824            .to_compile_error()
825            .into();
826    };
827
828    let mut injected_body = root_group.stream();
829    injected_body.extend(quote! { (#component_js_helper_ident()) (#component_css_helper_ident()) });
830    let mut updated_group = Group::new(Delimiter::Brace, injected_body);
831    updated_group.set_span(root_group.span());
832    tokens[body_index] = TokenTree::Group(updated_group);
833
834    let js_mode_lit = LitStr::new(js_mode.as_str(), Span::call_site());
835    tokens.splice(
836        body_index..body_index,
837        quote! {
838            data-mx-component=""
839            data-mx-js-mode=(#js_mode_lit)
840        },
841    );
842
843    let root_tokens: TokenStream2 = tokens.into_iter().collect();
844    quote! {
845        maud::html! {
846            #root_tokens
847        }
848    }
849    .into()
850}
851
852/// Emits a `<script>` tag from a file path accepted by `include_str!`.
853///
854/// ```rust
855/// use maud_extensions::js_file;
856///
857/// fn view() -> maud::Markup {
858///     maud::html! {
859///         (js_file!(concat!(
860///             env!("CARGO_MANIFEST_DIR"),
861///             "/tests/fixtures/runtime.js"
862///         )))
863///     }
864/// }
865/// ```
866#[proc_macro]
867pub fn js_file(input: TokenStream) -> TokenStream {
868    let path = parse_macro_input!(input as Expr);
869    let output = quote! {
870        maud::html! {
871            script {
872                (maud::PreEscaped(include_str!(#path)))
873            }
874        }
875    };
876
877    TokenStream::from(output)
878}
879
880/// Emits a `<style>` tag from a file path accepted by `include_str!`.
881///
882/// ```rust
883/// use maud_extensions::css_file;
884///
885/// fn view() -> maud::Markup {
886///     maud::html! {
887///         (css_file!(concat!(
888///             env!("CARGO_MANIFEST_DIR"),
889///             "/tests/fixtures/runtime.css"
890///         )))
891///     }
892/// }
893/// ```
894#[proc_macro]
895pub fn css_file(input: TokenStream) -> TokenStream {
896    let path = parse_macro_input!(input as Expr);
897    let output = quote! {
898        maud::html! {
899            style {
900                (maud::PreEscaped(include_str!(#path)))
901            }
902        }
903    };
904
905    TokenStream::from(output)
906}
907
908/// Emits the bundled `surreal.js` and `css-scope-inline.js` runtime helpers.
909///
910/// ```rust
911/// use maud_extensions::surreal_scope_inline;
912///
913/// fn view() -> maud::Markup {
914///     maud::html! {
915///         head {
916///             (surreal_scope_inline!())
917///         }
918///     }
919/// }
920/// ```
921#[proc_macro]
922pub fn surreal_scope_inline(input: TokenStream) -> TokenStream {
923    let _ = parse_macro_input!(input as Nothing);
924    emit_script_bundles([SURREAL_JS_BUNDLE, CSS_SCOPE_INLINE_JS_BUNDLE])
925}
926
927/// Emits the bundled Signals core runtime plus the Maud adapter helpers.
928///
929/// This macro installs the `window.mx` namespace and the binder helpers used by the
930/// `surreal_scope_signals_inline!()` workflow.
931///
932/// ```rust
933/// use maud_extensions::signals_inline;
934///
935/// fn view() -> maud::Markup {
936///     maud::html! {
937///         head {
938///             (signals_inline!())
939///         }
940///     }
941/// }
942/// ```
943#[proc_macro]
944pub fn signals_inline(input: TokenStream) -> TokenStream {
945    let _ = parse_macro_input!(input as Nothing);
946    emit_script_bundles([SIGNALS_CORE_JS_BUNDLE, SIGNALS_ADAPTER_JS_BUNDLE])
947}
948
949/// Emits the bundled `surreal.js`, `css-scope-inline.js`, Signals core, and Maud Signals adapter.
950///
951/// This is the supported runtime include when a page uses `component!`, `js!`, and the Signals
952/// DOM binders together.
953///
954/// ```rust
955/// use maud_extensions::surreal_scope_signals_inline;
956///
957/// fn view() -> maud::Markup {
958///     maud::html! {
959///         head {
960///             (surreal_scope_signals_inline!())
961///         }
962///     }
963/// }
964/// ```
965#[proc_macro]
966pub fn surreal_scope_signals_inline(input: TokenStream) -> TokenStream {
967    let _ = parse_macro_input!(input as Nothing);
968    emit_script_bundles([
969        SURREAL_JS_BUNDLE,
970        CSS_SCOPE_INLINE_JS_BUNDLE,
971        SIGNALS_CORE_JS_BUNDLE,
972        SIGNALS_ADAPTER_JS_BUNDLE,
973    ])
974}
975
976fn validate_js(js: &str) -> core::result::Result<(), String> {
977    let cm = SourceMap::default();
978    let fm = cm.new_source_file(
979        FileName::Custom("inline.js".to_string()).into(),
980        js.to_string(),
981    );
982    let input = StringInput::from(&*fm);
983    let mut parser = Parser::new(Syntax::Es(EsSyntax::default()), input, None);
984    match parser.parse_script() {
985        Ok(_) => Ok(()),
986        Err(err) => Err(format!("inline_js! could not parse JavaScript: {err:#?}")),
987    }
988}
989
990struct FontFace {
991    path: Expr,
992    family: LitStr,
993    weight: Option<LitStr>,
994    style: Option<LitStr>,
995}
996
997impl Parse for FontFace {
998    fn parse(input: ParseStream) -> syn::Result<Self> {
999        let path: Expr = input.parse()?;
1000        input.parse::<Token![,]>()?;
1001        let family: LitStr = input.parse()?;
1002
1003        let weight = if input.peek(Token![,]) {
1004            input.parse::<Token![,]>()?;
1005            if input.peek(LitStr) {
1006                Some(input.parse()?)
1007            } else {
1008                None
1009            }
1010        } else {
1011            None
1012        };
1013
1014        let style = if weight.is_some() && input.peek(Token![,]) {
1015            input.parse::<Token![,]>()?;
1016            if input.peek(LitStr) {
1017                Some(input.parse()?)
1018            } else {
1019                None
1020            }
1021        } else {
1022            None
1023        };
1024
1025        Ok(FontFace {
1026            path,
1027            family,
1028            weight,
1029            style,
1030        })
1031    }
1032}
1033
1034struct FontFaceList {
1035    fonts: Punctuated<FontFace, Token![;]>,
1036}
1037
1038impl Parse for FontFaceList {
1039    fn parse(input: ParseStream) -> syn::Result<Self> {
1040        let fonts = Punctuated::parse_terminated(input)?;
1041        Ok(FontFaceList { fonts })
1042    }
1043}
1044
1045fn expand_font_face_css(
1046    path: &Expr,
1047    family: &LitStr,
1048    weight: &LitStr,
1049    style: &LitStr,
1050) -> TokenStream2 {
1051    quote! {{
1052        fn __mx_encode_base64(bytes: &[u8]) -> String {
1053            const TABLE: &[u8; 64] =
1054                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1055
1056            let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
1057            let mut chunks = bytes.chunks_exact(3);
1058            for chunk in &mut chunks {
1059                let combined =
1060                    ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
1061                out.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
1062                out.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
1063                out.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
1064                out.push(TABLE[(combined & 0x3f) as usize] as char);
1065            }
1066
1067            match chunks.remainder() {
1068                [only] => {
1069                    let combined = (*only as u32) << 16;
1070                    out.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
1071                    out.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
1072                    out.push('=');
1073                    out.push('=');
1074                }
1075                [first, second] => {
1076                    let combined = ((*first as u32) << 16) | ((*second as u32) << 8);
1077                    out.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
1078                    out.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
1079                    out.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
1080                    out.push('=');
1081                }
1082                [] => {}
1083                _ => unreachable!("chunks_exact(3) only leaves 0, 1, or 2 trailing bytes"),
1084            }
1085
1086            out
1087        }
1088
1089        static __MX_FONT_FACE_CSS: ::std::sync::OnceLock<String> = ::std::sync::OnceLock::new();
1090
1091        __MX_FONT_FACE_CSS
1092            .get_or_init(|| {
1093                let __mx_bytes = include_bytes!(#path);
1094                let __mx_path = (#path).to_ascii_lowercase();
1095                let (__mx_font_type, __mx_format) = if __mx_path.ends_with(".woff2") {
1096                    ("woff2", "woff2")
1097                } else if __mx_path.ends_with(".woff") {
1098                    ("woff", "woff")
1099                } else if __mx_path.ends_with(".otf") {
1100                    ("opentype", "opentype")
1101                } else {
1102                    ("truetype", "truetype")
1103                };
1104                let __mx_base64 = __mx_encode_base64(__mx_bytes);
1105                format!(
1106                    "@font-face {{\n    font-family: '{}';\n    src: url('data:font/{};base64,{}') format('{}');\n    font-weight: {};\n    font-style: {};\n}}",
1107                    #family,
1108                    __mx_font_type,
1109                    __mx_base64,
1110                    __mx_format,
1111                    #weight,
1112                    #style
1113                )
1114            })
1115            .clone()
1116    }}
1117}
1118
1119/// Embeds a font file as a single `@font-face` block.
1120///
1121/// The path expression must be accepted by `include_bytes!`, for example a string literal or
1122/// `concat!(env!("CARGO_MANIFEST_DIR"), "/path/to/font.woff2")`.
1123///
1124/// ```rust
1125/// use maud_extensions::font_face;
1126///
1127/// fn view() -> maud::Markup {
1128///     maud::html! {
1129///         style {
1130///             (font_face!(
1131///                 concat!(
1132///                     env!("CARGO_MANIFEST_DIR"),
1133///                     "/examples/assets/demo-font.woff2"
1134///                 ),
1135///                 "Demo Sans"
1136///             ))
1137///         }
1138///     }
1139/// }
1140/// ```
1141#[proc_macro]
1142pub fn font_face(input: TokenStream) -> TokenStream {
1143    let font = parse_macro_input!(input as FontFace);
1144
1145    let weight = font
1146        .weight
1147        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
1148    let style = font
1149        .style
1150        .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
1151    let css = expand_font_face_css(&font.path, &font.family, &weight, &style);
1152
1153    quote! {{
1154        maud::PreEscaped(#css)
1155    }}
1156    .into()
1157}
1158
1159/// Embeds multiple font files as adjacent `@font-face` blocks.
1160///
1161/// ```rust
1162/// use maud_extensions::font_faces;
1163///
1164/// fn view() -> maud::Markup {
1165///     maud::html! {
1166///         style {
1167///             (font_faces!(
1168///                 concat!(
1169///                     env!("CARGO_MANIFEST_DIR"),
1170///                     "/examples/assets/demo-font.woff2"
1171///                 ), "Demo Sans";
1172///                 concat!(
1173///                     env!("CARGO_MANIFEST_DIR"),
1174///                     "/examples/assets/demo-font-bold.woff2"
1175///                 ), "Demo Sans", "700", "normal"
1176///             ))
1177///         }
1178///     }
1179/// }
1180/// ```
1181#[proc_macro]
1182pub fn font_faces(input: TokenStream) -> TokenStream {
1183    let fonts = parse_macro_input!(input as FontFaceList);
1184
1185    let font_faces = fonts.fonts.iter().map(|font| {
1186        let weight = font
1187            .weight
1188            .as_ref()
1189            .cloned()
1190            .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
1191        let style = font
1192            .style
1193            .as_ref()
1194            .cloned()
1195            .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
1196        let css = expand_font_face_css(&font.path, &font.family, &weight, &style);
1197
1198        quote! {
1199            css.push_str(&#css);
1200        }
1201    });
1202
1203    quote! {{
1204        let mut css = String::new();
1205        #(#font_faces)*
1206        maud::PreEscaped(css)
1207    }}
1208    .into()
1209}
1210
1211#[derive(Clone)]
1212enum BuilderFieldKind {
1213    Required,
1214    Optional { inner: Type },
1215    Repeated { inner: Type },
1216    Defaulted,
1217}
1218
1219#[derive(Clone, Default)]
1220struct SlotAttr {
1221    is_slot: bool,
1222    is_default: bool,
1223}
1224
1225#[derive(Clone, Default)]
1226struct BuilderAttr {
1227    use_default: bool,
1228    each_method: Option<Ident>,
1229}
1230
1231#[derive(Clone)]
1232enum BuilderInputMode {
1233    Direct(Box<Type>),
1234    RenderToMarkup,
1235}
1236
1237#[derive(Clone)]
1238struct BuilderField {
1239    ident: Ident,
1240    ty: Type,
1241    kind: BuilderFieldKind,
1242    slot: SlotAttr,
1243    builder: BuilderAttr,
1244    setter_input: BuilderInputMode,
1245    repeated_item_input: Option<BuilderInputMode>,
1246    state_ident: Option<Ident>,
1247}
1248
1249struct BuilderExpansionCtx<'a, 'b> {
1250    builder_ident: &'a Ident,
1251    existing_args: &'a [TokenStream2],
1252    builder_generics: &'a Generics,
1253    built_ident: &'a Ident,
1254    built_field_ident: &'a Ident,
1255    fields: &'a [BuilderField],
1256    required_fields: &'a [&'b BuilderField],
1257}
1258
1259/// Derives a typed builder for a named component struct.
1260///
1261/// Required fields are plain fields unless they use `Option<T>`, `Vec<T>`, or `#[builder(default)]`.
1262/// The builder exposes one setter per field, `maybe_field(option)` helpers for optional fields
1263/// using the field's exact `Option<T>` type, and optional repeated-item setters from
1264/// `#[builder(each = "item_name")]`. Regular setters for fields written as `Markup`,
1265/// `maud::Markup`, or `::maud::Markup` accept any `maud::Render` value.
1266///
1267/// Slot metadata can be declared with `#[slot]` and `#[slot(default)]` so the component contract
1268/// is explicit before higher-level composition sugar exists.
1269///
1270/// ```rust
1271/// use maud::{Markup, Render, html};
1272/// use maud_extensions::ComponentBuilder;
1273///
1274/// #[derive(ComponentBuilder)]
1275/// struct Card<'a> {
1276///     title: &'a str,
1277///     #[slot(optional)]
1278///     header: Option<Markup>,
1279///     #[slot(default)]
1280///     body: Markup,
1281/// }
1282///
1283/// impl<'a> Render for Card<'a> {
1284///     fn render(&self) -> Markup {
1285///         html! {
1286///             article {
1287///                 @if let Some(header) = &self.header {
1288///                     header { (header) }
1289///                 }
1290///                 main { (self.body) }
1291///             }
1292///         }
1293///     }
1294/// }
1295///
1296/// fn view() -> Markup {
1297///     Card::new()
1298///         .title("Status")
1299///         .header(html! { h2 { "Live" } })
1300///         .body(html! { p { "All systems green" } })
1301///         .render()
1302/// }
1303/// ```
1304#[proc_macro_derive(ComponentBuilder, attributes(builder, slot))]
1305pub fn component_builder(input: TokenStream) -> TokenStream {
1306    let input = parse_macro_input!(input as DeriveInput);
1307    expand_component_builder(input)
1308}
1309
1310fn expand_component_builder(input: DeriveInput) -> TokenStream {
1311    let ident = input.ident;
1312    let vis = input.vis;
1313    let generics = input.generics;
1314
1315    let Data::Struct(data_struct) = input.data else {
1316        return syn::Error::new(
1317            ident.span(),
1318            "ComponentBuilder only supports structs with named fields.",
1319        )
1320        .to_compile_error()
1321        .into();
1322    };
1323
1324    let Fields::Named(fields_named) = data_struct.fields else {
1325        return syn::Error::new(
1326            ident.span(),
1327            "ComponentBuilder only supports structs with named fields.",
1328        )
1329        .to_compile_error()
1330        .into();
1331    };
1332
1333    let parsed_fields = match fields_named
1334        .named
1335        .iter()
1336        .enumerate()
1337        .map(|(index, field)| parse_builder_field(index, field))
1338        .collect::<syn::Result<Vec<_>>>()
1339    {
1340        Ok(fields) => fields,
1341        Err(err) => return err.to_compile_error().into(),
1342    };
1343
1344    if let Err(err) = validate_builder_fields(&parsed_fields) {
1345        return err.to_compile_error().into();
1346    }
1347
1348    let builder_ident = format_ident!("{ident}Builder");
1349    let existing_args = generic_args_from_generics(&generics);
1350    let component_ty = component_type_tokens(&ident, &existing_args);
1351    let built_ident = format_ident!("__Built");
1352    let built_field_ident = format_ident!("__maud_extensions_built");
1353
1354    let required_fields: Vec<&BuilderField> = parsed_fields
1355        .iter()
1356        .filter(|field| matches!(field.kind, BuilderFieldKind::Required))
1357        .collect();
1358
1359    let mut builder_generics = generics.clone();
1360    for field in &required_fields {
1361        let state_ident = field
1362            .state_ident
1363            .as_ref()
1364            .expect("required fields always carry a state ident");
1365        builder_generics
1366            .params
1367            .push(parse_quote!(const #state_ident: bool));
1368    }
1369    builder_generics
1370        .params
1371        .push(parse_quote!(#built_ident = #component_ty));
1372
1373    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
1374    let (_builder_impl_generics, _builder_ty_generics, builder_where_clause) =
1375        builder_generics.split_for_impl();
1376
1377    let new_builder_ty = builder_type_tokens(
1378        &builder_ident,
1379        &existing_args,
1380        required_fields.iter().map(|_| quote!(false)).collect(),
1381        None,
1382    );
1383
1384    let builder_struct_fields = parsed_fields.iter().map(|field| {
1385        let ident = &field.ident;
1386        let storage_ty = builder_storage_ty(field);
1387        quote! { #ident: #storage_ty }
1388    });
1389    let builder_marker_field = quote! {
1390        #built_field_ident: ::core::marker::PhantomData<fn() -> #built_ident>
1391    };
1392
1393    let builder_init_fields = parsed_fields.iter().map(|field| {
1394        let ident = &field.ident;
1395        let init = builder_init_expr(field);
1396        quote! { #ident: #init }
1397    });
1398    let builder_marker_init = quote! {
1399        #built_field_ident: ::core::marker::PhantomData
1400    };
1401
1402    let component_new_impl = quote! {
1403        impl #impl_generics #ident #ty_generics #where_clause {
1404            #[must_use]
1405            pub fn new() -> #new_builder_ty {
1406                #builder_ident {
1407                    #(#builder_init_fields,)*
1408                    #builder_marker_init
1409                }
1410            }
1411
1412            #[must_use]
1413            pub fn builder() -> #new_builder_ty {
1414                Self::new()
1415            }
1416        }
1417    };
1418
1419    let setters = parsed_fields
1420        .iter()
1421        .map(|field| {
1422            let ctx = BuilderExpansionCtx {
1423                builder_ident: &builder_ident,
1424                existing_args: &existing_args,
1425                builder_generics: &builder_generics,
1426                built_ident: &built_ident,
1427                built_field_ident: &built_field_ident,
1428                fields: &parsed_fields,
1429                required_fields: &required_fields,
1430            };
1431            let method = expand_builder_field_setter(&ctx, field);
1432            let maybe = expand_builder_optional_setter(&ctx, field);
1433            let each = expand_builder_each_setter(&ctx, field);
1434
1435            quote! {
1436                #method
1437                #maybe
1438                #each
1439            }
1440        })
1441        .collect::<Vec<_>>();
1442
1443    let build_ctx = BuilderExpansionCtx {
1444        builder_ident: &builder_ident,
1445        existing_args: &existing_args,
1446        builder_generics: &builder_generics,
1447        built_ident: &built_ident,
1448        built_field_ident: &built_field_ident,
1449        fields: &parsed_fields,
1450        required_fields: &required_fields,
1451    };
1452    let build_impl = expand_builder_build_impl(&build_ctx, &ident, &generics, &component_ty);
1453
1454    let output = quote! {
1455        #vis struct #builder_ident #builder_generics #builder_where_clause {
1456            #(#builder_struct_fields,)*
1457            #builder_marker_field
1458        }
1459
1460        #component_new_impl
1461        #(#setters)*
1462        #build_impl
1463    };
1464
1465    output.into()
1466}
1467
1468fn parse_builder_field(field_index: usize, field: &syn::Field) -> syn::Result<BuilderField> {
1469    let ident = field
1470        .ident
1471        .clone()
1472        .ok_or_else(|| syn::Error::new(field.span(), "ComponentBuilder requires named fields."))?;
1473
1474    let slot = parse_slot_attr(&field.attrs)?;
1475    let builder = parse_builder_attr(&field.attrs)?;
1476    let kind = classify_builder_field(&field.ty, builder.use_default);
1477    let setter_input = match &kind {
1478        BuilderFieldKind::Repeated { .. } => BuilderInputMode::Direct(Box::new(field.ty.clone())),
1479        _ => setter_input_mode(&field.ty, &kind),
1480    };
1481    let repeated_item_input = repeated_item_input_mode(&kind);
1482    let state_ident = matches!(kind, BuilderFieldKind::Required)
1483        .then(|| format_ident!("__MAUD_EXTENSIONS_REQUIRED_FIELD_{field_index}_SET"));
1484
1485    Ok(BuilderField {
1486        ident,
1487        ty: field.ty.clone(),
1488        kind,
1489        slot,
1490        builder,
1491        setter_input,
1492        repeated_item_input,
1493        state_ident,
1494    })
1495}
1496
1497fn parse_slot_attr(attrs: &[syn::Attribute]) -> syn::Result<SlotAttr> {
1498    let mut slot = SlotAttr::default();
1499
1500    for attr in attrs {
1501        if !attr.path().is_ident("slot") {
1502            continue;
1503        }
1504
1505        slot.is_slot = true;
1506        if matches!(&attr.meta, syn::Meta::Path(_)) {
1507            continue;
1508        }
1509
1510        attr.parse_nested_meta(|meta| {
1511            if meta.path.is_ident("default") {
1512                slot.is_default = true;
1513                return Ok(());
1514            }
1515
1516            if meta.path.is_ident("optional") {
1517                return Ok(());
1518            }
1519
1520            Err(meta.error(
1521                "unsupported slot attribute. Supported forms are `#[slot]` and `#[slot(default)]`.",
1522            ))
1523        })?;
1524    }
1525
1526    Ok(slot)
1527}
1528
1529fn parse_builder_attr(attrs: &[syn::Attribute]) -> syn::Result<BuilderAttr> {
1530    let mut builder = BuilderAttr::default();
1531
1532    for attr in attrs {
1533        if !attr.path().is_ident("builder") {
1534            continue;
1535        }
1536
1537        attr.parse_nested_meta(|meta| {
1538            if meta.path.is_ident("default") {
1539                builder.use_default = true;
1540                return Ok(());
1541            }
1542
1543            if meta.path.is_ident("each") {
1544                let value = meta.value()?;
1545                let lit: LitStr = value.parse()?;
1546                builder.each_method = Some(Ident::new(&lit.value(), lit.span()));
1547                return Ok(());
1548            }
1549
1550            Err(meta.error(
1551                "unsupported builder attribute. Supported forms are `#[builder(default)]` and `#[builder(each = \"item\")]`.",
1552            ))
1553        })?;
1554    }
1555
1556    Ok(builder)
1557}
1558
1559fn classify_builder_field(ty: &Type, use_default: bool) -> BuilderFieldKind {
1560    if let Some(inner) = option_inner_ty(ty) {
1561        return BuilderFieldKind::Optional { inner };
1562    }
1563
1564    if let Some(inner) = vec_inner_ty(ty) {
1565        return BuilderFieldKind::Repeated { inner };
1566    }
1567
1568    if use_default {
1569        return BuilderFieldKind::Defaulted;
1570    }
1571
1572    BuilderFieldKind::Required
1573}
1574
1575fn validate_builder_fields(fields: &[BuilderField]) -> syn::Result<()> {
1576    let default_slots = fields.iter().filter(|field| field.slot.is_default).count();
1577    if default_slots > 1 {
1578        let duplicate = fields
1579            .iter()
1580            .find(|field| field.slot.is_default)
1581            .expect("count verified");
1582        return Err(syn::Error::new(
1583            duplicate.ident.span(),
1584            "ComponentBuilder allows at most one `#[slot(default)]` field.",
1585        ));
1586    }
1587
1588    for field in fields {
1589        if field.builder.each_method.is_some()
1590            && !matches!(field.kind, BuilderFieldKind::Repeated { .. })
1591        {
1592            return Err(syn::Error::new(
1593                field.ident.span(),
1594                "`#[builder(each = \"...\")]` only applies to `Vec<T>` fields.",
1595            ));
1596        }
1597
1598        if let Some(each) = &field.builder.each_method {
1599            if each == &field.ident {
1600                return Err(syn::Error::new(
1601                    each.span(),
1602                    "`#[builder(each = \"...\")]` must use a method name different from the field name.",
1603                ));
1604            }
1605        }
1606    }
1607
1608    let mut method_names = std::collections::BTreeSet::new();
1609    method_names.insert("build".to_string());
1610    method_names.insert("render".to_string());
1611
1612    for field in fields {
1613        let field_method = field.ident.unraw().to_string();
1614        if !method_names.insert(field_method.clone()) {
1615            return Err(syn::Error::new(
1616                field.ident.span(),
1617                format!("duplicate generated builder method `{field_method}`."),
1618            ));
1619        }
1620
1621        if let Some(maybe) = optional_setter_ident(field) {
1622            let maybe_method = maybe.unraw().to_string();
1623            if !method_names.insert(maybe_method.clone()) {
1624                return Err(syn::Error::new(
1625                    maybe.span(),
1626                    format!("duplicate generated builder method `{maybe_method}`."),
1627                ));
1628            }
1629        }
1630
1631        if let Some(each) = &field.builder.each_method {
1632            let each_method = each.unraw().to_string();
1633            if !method_names.insert(each_method.clone()) {
1634                return Err(syn::Error::new(
1635                    each.span(),
1636                    format!("duplicate generated builder method `{each_method}`."),
1637                ));
1638            }
1639        }
1640    }
1641
1642    Ok(())
1643}
1644
1645fn option_inner_ty(ty: &Type) -> Option<Type> {
1646    generic_inner_ty(
1647        ty,
1648        &[
1649            &["Option"],
1650            &["std", "option", "Option"],
1651            &["core", "option", "Option"],
1652        ],
1653    )
1654}
1655
1656fn vec_inner_ty(ty: &Type) -> Option<Type> {
1657    generic_inner_ty(
1658        ty,
1659        &[&["Vec"], &["std", "vec", "Vec"], &["alloc", "vec", "Vec"]],
1660    )
1661}
1662
1663fn generic_inner_ty(ty: &Type, accepted_paths: &[&[&str]]) -> Option<Type> {
1664    let Type::Path(TypePath { qself: None, path }) = ty else {
1665        return None;
1666    };
1667
1668    if !path_matches_any(path, accepted_paths) {
1669        return None;
1670    }
1671
1672    let segment = path.segments.last()?;
1673    let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
1674        return None;
1675    };
1676
1677    if args.args.len() != 1 {
1678        return None;
1679    }
1680
1681    let syn::GenericArgument::Type(inner) = args.args.first()? else {
1682        return None;
1683    };
1684
1685    Some(inner.clone())
1686}
1687
1688fn is_markup_ty(ty: &Type) -> bool {
1689    let Type::Path(TypePath { qself: None, path }) = ty else {
1690        return false;
1691    };
1692
1693    path_matches_any(path, &[&["Markup"], &["maud", "Markup"]])
1694}
1695
1696fn path_matches_any(path: &syn::Path, accepted_paths: &[&[&str]]) -> bool {
1697    accepted_paths
1698        .iter()
1699        .any(|segments| path_matches_segments(path, segments))
1700}
1701
1702fn path_matches_segments(path: &syn::Path, expected_segments: &[&str]) -> bool {
1703    if path.segments.len() != expected_segments.len() {
1704        return false;
1705    }
1706
1707    path.segments
1708        .iter()
1709        .zip(expected_segments.iter())
1710        .all(|(segment, expected)| segment.ident == expected)
1711}
1712
1713fn builder_storage_ty(field: &BuilderField) -> TokenStream2 {
1714    match field.kind {
1715        BuilderFieldKind::Required => {
1716            let ty = &field.ty;
1717            quote!(::core::option::Option<#ty>)
1718        }
1719        _ => {
1720            let ty = &field.ty;
1721            quote!(#ty)
1722        }
1723    }
1724}
1725
1726fn builder_init_expr(field: &BuilderField) -> TokenStream2 {
1727    match field.kind {
1728        BuilderFieldKind::Required => quote!(::core::option::Option::None),
1729        BuilderFieldKind::Optional { .. } => quote!(::core::option::Option::None),
1730        BuilderFieldKind::Repeated { .. } => quote!(::std::vec::Vec::new()),
1731        BuilderFieldKind::Defaulted => quote!(::core::default::Default::default()),
1732    }
1733}
1734
1735fn setter_input_mode(ty: &Type, kind: &BuilderFieldKind) -> BuilderInputMode {
1736    match kind {
1737        BuilderFieldKind::Required | BuilderFieldKind::Defaulted => {
1738            if is_markup_ty(ty) {
1739                BuilderInputMode::RenderToMarkup
1740            } else {
1741                BuilderInputMode::Direct(Box::new(ty.clone()))
1742            }
1743        }
1744        BuilderFieldKind::Optional { inner } => {
1745            if is_markup_ty(inner) {
1746                BuilderInputMode::RenderToMarkup
1747            } else {
1748                BuilderInputMode::Direct(Box::new(inner.clone()))
1749            }
1750        }
1751        BuilderFieldKind::Repeated { .. } => {
1752            unreachable!("repeated fields use repeated_item_input_mode")
1753        }
1754    }
1755}
1756
1757fn repeated_item_input_mode(kind: &BuilderFieldKind) -> Option<BuilderInputMode> {
1758    let BuilderFieldKind::Repeated { inner } = kind else {
1759        return None;
1760    };
1761
1762    Some(if is_markup_ty(inner) {
1763        BuilderInputMode::RenderToMarkup
1764    } else {
1765        BuilderInputMode::Direct(Box::new(inner.clone()))
1766    })
1767}
1768
1769fn generic_args_from_generics(generics: &Generics) -> Vec<TokenStream2> {
1770    generics
1771        .params
1772        .iter()
1773        .map(|param| match param {
1774            GenericParam::Type(param) => {
1775                let ident = &param.ident;
1776                quote!(#ident)
1777            }
1778            GenericParam::Lifetime(param) => {
1779                let lifetime = &param.lifetime;
1780                quote!(#lifetime)
1781            }
1782            GenericParam::Const(param) => {
1783                let ident = &param.ident;
1784                quote!(#ident)
1785            }
1786        })
1787        .collect()
1788}
1789
1790fn builder_type_tokens(
1791    builder_ident: &Ident,
1792    existing_args: &[TokenStream2],
1793    state_args: Vec<TokenStream2>,
1794    built_arg: Option<TokenStream2>,
1795) -> TokenStream2 {
1796    let mut all_args = existing_args.to_vec();
1797    all_args.extend(state_args);
1798    if let Some(built_arg) = built_arg {
1799        all_args.push(built_arg);
1800    }
1801
1802    if all_args.is_empty() {
1803        quote!(#builder_ident)
1804    } else {
1805        quote!(#builder_ident < #(#all_args),* >)
1806    }
1807}
1808
1809fn optional_setter_ident(field: &BuilderField) -> Option<Ident> {
1810    matches!(field.kind, BuilderFieldKind::Optional { .. })
1811        .then(|| format_ident!("maybe_{}", field.ident.unraw(), span = field.ident.span()))
1812}
1813
1814fn current_state_args(ctx: &BuilderExpansionCtx<'_, '_>) -> Vec<TokenStream2> {
1815    ctx.required_fields
1816        .iter()
1817        .map(|required| {
1818            let state_ident = required
1819                .state_ident
1820                .as_ref()
1821                .expect("required field state ident");
1822            quote!(#state_ident)
1823        })
1824        .collect()
1825}
1826
1827fn component_type_tokens(component_ident: &Ident, existing_args: &[TokenStream2]) -> TokenStream2 {
1828    if existing_args.is_empty() {
1829        quote!(#component_ident)
1830    } else {
1831        quote!(#component_ident < #(#existing_args),* >)
1832    }
1833}
1834
1835fn expand_builder_field_setter(
1836    ctx: &BuilderExpansionCtx<'_, '_>,
1837    field: &BuilderField,
1838) -> TokenStream2 {
1839    let (impl_generics, _ty_generics, where_clause) = ctx.builder_generics.split_for_impl();
1840    let method_ident = &field.ident;
1841    let builder_ident = ctx.builder_ident;
1842    let built_ident = ctx.built_ident;
1843    let built_field_ident = ctx.built_field_ident;
1844    let current_state_args = current_state_args(ctx);
1845
1846    let return_state_args = ctx
1847        .required_fields
1848        .iter()
1849        .map(|required| {
1850            if required.ident == field.ident {
1851                quote!(true)
1852            } else {
1853                let state_ident = required
1854                    .state_ident
1855                    .as_ref()
1856                    .expect("required field state ident");
1857                quote!(#state_ident)
1858            }
1859        })
1860        .collect::<Vec<_>>();
1861
1862    let current_ty = builder_type_tokens(
1863        builder_ident,
1864        ctx.existing_args,
1865        current_state_args,
1866        Some(quote!(#built_ident)),
1867    );
1868    let return_ty = builder_type_tokens(
1869        builder_ident,
1870        ctx.existing_args,
1871        return_state_args,
1872        Some(quote!(#built_ident)),
1873    );
1874    let rebuild_fields = ctx.fields.iter().map(|other| {
1875        let ident = &other.ident;
1876        if ident == &field.ident {
1877            let value_expr = setter_value_expr(field);
1878            quote!(#ident: #value_expr)
1879        } else {
1880            quote!(#ident: self.#ident)
1881        }
1882    });
1883
1884    let (arg_tokens, setter_prelude) = setter_arg_tokens(field);
1885
1886    quote! {
1887        impl #impl_generics #current_ty #where_clause {
1888            #[must_use]
1889            pub fn #method_ident(self, #arg_tokens) -> #return_ty {
1890                #setter_prelude
1891                #builder_ident {
1892                    #(#rebuild_fields,)*
1893                    #built_field_ident: ::core::marker::PhantomData
1894                }
1895            }
1896        }
1897    }
1898}
1899
1900fn expand_builder_optional_setter(
1901    ctx: &BuilderExpansionCtx<'_, '_>,
1902    field: &BuilderField,
1903) -> TokenStream2 {
1904    let Some(method_ident) = optional_setter_ident(field) else {
1905        return TokenStream2::new();
1906    };
1907    let BuilderFieldKind::Optional { inner } = &field.kind else {
1908        return TokenStream2::new();
1909    };
1910
1911    let (impl_generics, _ty_generics, where_clause) = ctx.builder_generics.split_for_impl();
1912    let builder_ident = ctx.builder_ident;
1913    let built_ident = ctx.built_ident;
1914    let built_field_ident = ctx.built_field_ident;
1915    let current_state_args = current_state_args(ctx);
1916
1917    let current_ty = builder_type_tokens(
1918        builder_ident,
1919        ctx.existing_args,
1920        current_state_args,
1921        Some(quote!(#built_ident)),
1922    );
1923    let rebuild_fields = ctx.fields.iter().map(|other| {
1924        let ident = &other.ident;
1925        if ident == &field.ident {
1926            quote!(#ident: value)
1927        } else {
1928            quote!(#ident: self.#ident)
1929        }
1930    });
1931
1932    quote! {
1933        impl #impl_generics #current_ty #where_clause {
1934            #[must_use]
1935            pub fn #method_ident(self, value: ::core::option::Option<#inner>) -> Self {
1936                #builder_ident {
1937                    #(#rebuild_fields,)*
1938                    #built_field_ident: ::core::marker::PhantomData
1939                }
1940            }
1941        }
1942    }
1943}
1944
1945fn expand_builder_each_setter(
1946    ctx: &BuilderExpansionCtx<'_, '_>,
1947    field: &BuilderField,
1948) -> TokenStream2 {
1949    let Some(each_ident) = &field.builder.each_method else {
1950        return TokenStream2::new();
1951    };
1952
1953    let (impl_generics, _ty_generics, where_clause) = ctx.builder_generics.split_for_impl();
1954    let builder_ident = ctx.builder_ident;
1955    let built_ident = ctx.built_ident;
1956    let built_field_ident = ctx.built_field_ident;
1957    let current_state_args = current_state_args(ctx);
1958
1959    let current_ty = builder_type_tokens(
1960        builder_ident,
1961        ctx.existing_args,
1962        current_state_args,
1963        Some(quote!(#built_ident)),
1964    );
1965    let repeated_field_ident = &field.ident;
1966    let rebuild_fields = ctx.fields.iter().map(|other| {
1967        let ident = &other.ident;
1968        if ident == repeated_field_ident {
1969            quote!(#ident: #repeated_field_ident)
1970        } else {
1971            quote!(#ident: self.#ident)
1972        }
1973    });
1974
1975    let (arg_tokens, push_expr) = each_setter_arg_tokens(field);
1976
1977    quote! {
1978        impl #impl_generics #current_ty #where_clause {
1979            #[must_use]
1980            pub fn #each_ident(self, #arg_tokens) -> Self {
1981                let mut #repeated_field_ident = self.#repeated_field_ident;
1982                #push_expr
1983                #builder_ident {
1984                    #(#rebuild_fields,)*
1985                    #built_field_ident: ::core::marker::PhantomData
1986                }
1987            }
1988        }
1989    }
1990}
1991
1992fn setter_arg_tokens(field: &BuilderField) -> (TokenStream2, TokenStream2) {
1993    match &field.kind {
1994        BuilderFieldKind::Repeated { .. } => match field
1995            .repeated_item_input
1996            .as_ref()
1997            .expect("repeated fields always expose item input")
1998        {
1999            BuilderInputMode::Direct(inner) => (
2000                quote!(values: impl ::core::iter::IntoIterator<Item = #inner>),
2001                quote!(),
2002            ),
2003            BuilderInputMode::RenderToMarkup => (
2004                quote!(values: impl ::core::iter::IntoIterator<Item = impl ::maud::Render>),
2005                quote!(),
2006            ),
2007        },
2008        _ => match &field.setter_input {
2009            BuilderInputMode::Direct(ty) => (quote!(value: #ty), quote!()),
2010            BuilderInputMode::RenderToMarkup => (quote!(value: impl ::maud::Render), quote!()),
2011        },
2012    }
2013}
2014
2015fn setter_value_expr(field: &BuilderField) -> TokenStream2 {
2016    match &field.kind {
2017        BuilderFieldKind::Required => match &field.setter_input {
2018            BuilderInputMode::Direct(_) => quote!(::core::option::Option::Some(value)),
2019            BuilderInputMode::RenderToMarkup => {
2020                quote!(::core::option::Option::Some(::maud::Render::render(&value)))
2021            }
2022        },
2023        BuilderFieldKind::Optional { .. } => match &field.setter_input {
2024            BuilderInputMode::Direct(_) => quote!(::core::option::Option::Some(value)),
2025            BuilderInputMode::RenderToMarkup => {
2026                quote!(::core::option::Option::Some(::maud::Render::render(&value)))
2027            }
2028        },
2029        BuilderFieldKind::Repeated { .. } => match field
2030            .repeated_item_input
2031            .as_ref()
2032            .expect("repeated fields always expose item input")
2033        {
2034            BuilderInputMode::Direct(_) => quote!(values.into_iter().collect()),
2035            BuilderInputMode::RenderToMarkup => {
2036                quote!(
2037                    values
2038                        .into_iter()
2039                        .map(|value| ::maud::Render::render(&value))
2040                        .collect()
2041                )
2042            }
2043        },
2044        BuilderFieldKind::Defaulted => match &field.setter_input {
2045            BuilderInputMode::Direct(_) => quote!(value),
2046            BuilderInputMode::RenderToMarkup => quote!(::maud::Render::render(&value)),
2047        },
2048    }
2049}
2050
2051fn each_setter_arg_tokens(field: &BuilderField) -> (TokenStream2, TokenStream2) {
2052    let repeated_field_ident = &field.ident;
2053    match field
2054        .repeated_item_input
2055        .as_ref()
2056        .expect("each setters only exist for repeated fields")
2057    {
2058        BuilderInputMode::Direct(inner) => (
2059            quote!(value: #inner),
2060            quote!(#repeated_field_ident.push(value);),
2061        ),
2062        BuilderInputMode::RenderToMarkup => (
2063            quote!(value: impl ::maud::Render),
2064            quote!(#repeated_field_ident.push(::maud::Render::render(&value));),
2065        ),
2066    }
2067}
2068
2069fn expand_builder_build_impl(
2070    ctx: &BuilderExpansionCtx<'_, '_>,
2071    component_ident: &Ident,
2072    generics: &Generics,
2073    component_ty: &TokenStream2,
2074) -> TokenStream2 {
2075    let builder_ident = ctx.builder_ident;
2076    let existing_args = ctx.existing_args;
2077    let built_ident = ctx.built_ident;
2078    let built_field_ident = ctx.built_field_ident;
2079    let fields = ctx.fields;
2080    let required_fields = ctx.required_fields;
2081    let complete_builder_ty = builder_type_tokens(
2082        builder_ident,
2083        existing_args,
2084        required_fields.iter().map(|_| quote!(true)).collect(),
2085        Some(quote!(#built_ident)),
2086    );
2087    let complete_component_builder_ty = builder_type_tokens(
2088        builder_ident,
2089        existing_args,
2090        required_fields.iter().map(|_| quote!(true)).collect(),
2091        None,
2092    );
2093
2094    let build_fields = fields.iter().map(|field| {
2095        let ident = &field.ident;
2096        match field.kind {
2097            BuilderFieldKind::Required => {
2098                let field_name = ident.to_string();
2099                quote! {
2100                    #ident: #ident.expect(concat!(
2101                        "ComponentBuilder state bug: missing required field `",
2102                        #field_name,
2103                        "` at build time."
2104                    ))
2105                }
2106            }
2107            _ => quote!(#ident: #ident),
2108        }
2109    });
2110
2111    let destructure_fields = fields.iter().map(|field| &field.ident);
2112    let mut builder_generics = generics.clone();
2113    builder_generics
2114        .params
2115        .push(parse_quote!(#built_ident = #component_ty));
2116    let (builder_impl_generics, _builder_ty_generics, builder_where_clause) =
2117        builder_generics.split_for_impl();
2118    let (impl_generics, _ty_generics, where_clause) = generics.split_for_impl();
2119
2120    quote! {
2121        impl #builder_impl_generics #complete_builder_ty #builder_where_clause {
2122            #[must_use]
2123            pub fn build(self) -> #built_ident
2124            where
2125                #component_ty: ::core::convert::Into<#built_ident>,
2126            {
2127                let Self {
2128                    #(#destructure_fields,)*
2129                    #built_field_ident: _
2130                } = self;
2131                let component = #component_ident {
2132                    #(#build_fields),*
2133                };
2134                ::core::convert::Into::into(component)
2135            }
2136
2137            #[must_use]
2138            pub fn render(self) -> ::maud::Markup
2139            where
2140                #component_ty: ::core::convert::Into<#built_ident>,
2141                #built_ident: ::maud::Render,
2142            {
2143                let component = self.build();
2144                ::maud::Render::render(&component)
2145            }
2146        }
2147
2148        impl #impl_generics ::core::convert::From<#complete_component_builder_ty> for #component_ty #where_clause {
2149            fn from(builder: #complete_component_builder_ty) -> Self {
2150                builder.build()
2151            }
2152        }
2153    }
2154}