diff --git a/crates/hypertext-macros/src/html/component.rs b/crates/hypertext-macros/src/html/component.rs index 071810b..c4d7250 100644 --- a/crates/hypertext-macros/src/html/component.rs +++ b/crates/hypertext-macros/src/html/component.rs @@ -1,13 +1,17 @@ -use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; +use proc_macro2::{Span, TokenStream}; +use quote::{ToTokens, format_ident, quote}; use syn::{ - Ident, Lit, Token, + Ident, LitStr, parse::{Parse, ParseStream}, + spanned::Spanned, token::{Brace, Paren}, }; -use super::{AttributeValue, ElementBody, Generate, Generator, ParenExpr, Syntax}; -use crate::html::Node; +use super::{ + Attribute, AttributeKind, AttributeValue, Class, ElementBody, Generate, Generator, Syntax, + generate::AnyBlock, +}; +use crate::html::{Node, basics::Literal}; pub struct Component { pub name: Ident, @@ -19,27 +23,16 @@ impl Generate for Component { type Context = Node; fn generate(&self, g: &mut Generator) { - let props = self.attrs.iter().map(|attr| { - let name = &attr.name; - attr.value_expr() - .map_or_else(|| quote!(.#name(#name)), |value| quote!(.#name(#value))) - }); + let mut owned: Vec = vec![]; + let props: Vec = self + .attrs + .iter() + .map(|attr| attr.kind_expr(g, &mut owned)) + .collect(); let children = match &self.body { ElementBody::Normal { children, .. } => { - let buffer_ident = Generator::buffer_ident(); - - let block = g.block_with(Brace::default(), |g| { - g.push(children); - }); - - let lazy = quote! { - ::hypertext::Lazy::dangerously_create( - |#buffer_ident: &mut ::hypertext::Buffer| - #block - ) - }; - + let lazy = lazy_block(&children.block(g, Brace::default())); let children_ident = Ident::new("children", self.name.span()); quote!( @@ -51,80 +44,205 @@ impl Generate for Component { let name = &self.name; - let init = quote! { - #name::builder() - #(#props)* - #children - .build() - }; - - g.push_expr::(Paren::default(), &init); + g.push_in_block(Brace::default(), |g| { + g.push_stmt(quote! { #(#owned)* }); + g.push_expr::( + Paren::default(), + quote! { + #name::builder() + #(#props)* + #children + .build() + }, + ); + }); } } -pub struct ComponentAttribute { - name: Ident, - value: Option, +fn lazy_block(block: &AnyBlock) -> TokenStream { + let buffer_ident = Generator::buffer_ident(); + quote! { + ::hypertext::Lazy::dangerously_create( + |#buffer_ident: &mut ::hypertext::Buffer| + #block + ) + } } +pub struct ComponentAttribute(pub Attribute); + impl ComponentAttribute { - fn value_expr(&self) -> Option { - self.value.as_ref().map(|value| match value { - ComponentAttributeValue::Literal(lit) => lit.to_token_stream(), - ComponentAttributeValue::Ident(ident) => ident.to_token_stream(), - ComponentAttributeValue::Expr(expr) => { - let mut tokens = TokenStream::new(); - - expr.paren_token.surround(&mut tokens, |tokens| { - expr.expr.to_tokens(tokens); - }); + fn kind_expr(&self, g: &mut Generator, owned: &mut Vec) -> TokenStream { + let lits = self.0.name.lits(); + let name_str = lits.iter().map(syn::LitStr::value).collect::(); + let span = lits.first().map_or_else(Span::call_site, Spanned::span); + let name = Ident::new(&name_str, span); + let maybe_name = format_ident!("maybe_{name}", span = span); + match &self.0.kind { + AttributeKind::Value { value, toggle } => { + let value_expr = Self::value_expr(value, g, owned); + toggle.as_ref().map_or_else( + || quote!(.#name(#value_expr)), + |toggle| { + let toggle_expr = &toggle.expr; + quote!(.#maybe_name(if #toggle_expr { Some(#value_expr) } else { None })) + }, + ) + } + AttributeKind::Option(toggle) => { + let toggle_expr = &toggle.expr; + quote!(.#maybe_name(#toggle_expr)) + } + AttributeKind::Empty(None) => { + quote!(.#name(true)) + } + AttributeKind::Empty(Some(toggle)) => { + let toggle_expr = &toggle.expr; + quote!(.#maybe_name(if #toggle_expr { Some(true) } else { None })) + } + AttributeKind::ClassList(classes) => { + let to_litstr = |class: &Class| { + let Class::Value { + value, + toggle: None, + } = class + else { + return None; + }; + + let AttributeValue::Literal(literal) = value else { + return None; + }; + + Some(literal.lit_str()) + }; - quote! { - { - #[allow(unused_parens)] - #tokens + let to_class = |static_str: &str| { + let literal = Literal::Str(LitStr::new(static_str, span)); + Class::Value { + value: AttributeValue::Literal(literal), + toggle: None, + } + }; + + let mut current_static: String = String::new(); + let mut class_exprs: Vec = vec![]; + + // Collect consecutive static classes into a single string + for class in classes { + if let Some(stat) = to_litstr(class) { + if !current_static.is_empty() { + current_static.push(' '); + } + current_static.push_str(&stat.value()); + } else { + let static_class = to_class(¤t_static); + class_exprs.push(Self::class_expr(&static_class, g, owned)); + class_exprs.push(Self::class_expr(class, g, owned)); + current_static.clear(); } } + + // If no expressions were pushed, the whole class expression is just a literal: + if class_exprs.is_empty() { + let literal_class = Literal::Str(LitStr::new(¤t_static, span)); + quote!(.class(#literal_class)) + } else { + if !current_static.is_empty() { + let static_class = to_class(¤t_static); + class_exprs.push(Self::class_expr(&static_class, g, owned)); + } + + let ident = Self::push_owned( + owned, + "e! { + [#(#class_exprs),*] + .into_iter() + .flatten() + .fold(String::new(), |mut classes, class| { + if !classes.is_empty() { classes.push(' '); } + classes.push_str(class); + classes + }); + }, + span, + ); + + quote!(.class(&#ident)) + } } - }) + } } -} -impl Parse for ComponentAttribute { - fn parse(input: ParseStream) -> syn::Result { - Ok(Self { - name: input.parse()?, - value: { - if input.peek(Token![=]) { - input.parse::()?; + fn value_expr( + value: &AttributeValue, + g: &mut Generator, + owned: &mut Vec, + ) -> TokenStream { + match value { + AttributeValue::Literal(literal) => literal.into_token_stream(), + AttributeValue::Group(group) => { + let block = g.block_with(Brace::default(), |g| g.push_all(&group.0.0)); + let lazy = lazy_block(&block); + let ident = + Self::push_owned(owned, "e! { #lazy.render().into_inner() }, block.span()); + quote!(&(#ident)) + } + AttributeValue::Control(control) => { + let block = g.block_with(Brace::default(), |g| { + g.push(control); + }); + quote! { #block } + } + AttributeValue::Expr(paren_expr) => { + let expr_tokens = paren_expr.to_token_stream(); + quote!(#[allow(unused_parens)] #expr_tokens) + } + AttributeValue::DisplayExpr(expr) => { + let expr_tokens = expr.wrapped_expr(); + quote! { #expr_tokens } + } + AttributeValue::DebugExpr(expr) => { + let expr_tokens = expr.wrapped_expr(); + quote! { #expr_tokens } + } + AttributeValue::Ident(ident) => quote! { #ident }, + } + } - Some(input.parse()?) - } else { - None - } - }, - }) + fn class_expr(class: &Class, g: &mut Generator, owned: &mut Vec) -> TokenStream { + match class { + Class::Value { + value, + toggle: None, + } => { + let value_expr = Self::value_expr(value, g, owned); + quote! { Some(#value_expr) } + } + Class::Value { + value, + toggle: Some(toggle), + } => { + let value_expr = Self::value_expr(value, g, owned); + let toggle_expr = &toggle.expr; + quote! { if #toggle_expr { Some(#value_expr) } else { None } } + } + Class::Option(toggle) => { + let toggle_expr = &toggle.expr; + quote! { #toggle_expr } + } + } } -} -pub enum ComponentAttributeValue { - Literal(Lit), - Ident(Ident), - Expr(ParenExpr), + fn push_owned(alloc: &mut Vec, expr: &TokenStream, span: Span) -> Ident { + let ident = format_ident!("owned_{}", alloc.len(), span = span); + alloc.push(quote!(let #ident = #expr;)); + ident + } } -impl Parse for ComponentAttributeValue { +impl Parse for ComponentAttribute { fn parse(input: ParseStream) -> syn::Result { - let lookahead = input.lookahead1(); - - if lookahead.peek(Lit) { - input.parse().map(Self::Literal) - } else if lookahead.peek(Ident) { - input.parse().map(Self::Ident) - } else if lookahead.peek(Paren) { - input.parse().map(Self::Expr) - } else { - Err(lookahead.error()) - } + Ok(Self(input.parse()?)) } } diff --git a/crates/hypertext-macros/src/html/mod.rs b/crates/hypertext-macros/src/html/mod.rs index e698758..a347d18 100644 --- a/crates/hypertext-macros/src/html/mod.rs +++ b/crates/hypertext-macros/src/html/mod.rs @@ -471,12 +471,12 @@ pub struct Attribute { impl Attribute { fn parse_id(input: ParseStream) -> syn::Result { let pound_token = input.parse::()?; + let name = parse_quote_spanned!(pound_token.span()=> id); + let value = input.call(AttributeValue::parse_unquoted)?; + let toggle = input.call(Toggle::parse_optional)?; Ok(Self { - name: parse_quote_spanned!(pound_token.span()=> id), - kind: AttributeKind::Value { - value: input.call(AttributeValue::parse_unquoted)?, - toggle: None, - }, + name, + kind: AttributeKind::Value { value, toggle }, }) } @@ -781,14 +781,11 @@ pub enum AttributeValue { impl AttributeValue { fn parse_unquoted(input: ParseStream) -> syn::Result { if input.peek(Ident::peek_any) || input.peek(LitInt) { - Ok(Self::Group(Group(Many( - input - .call(UnquotedName::parse_attr_value)? - .lits() - .into_iter() - .map(|lit| Self::Literal(Literal::Str(lit))) - .collect(), - )))) + let lits = UnquotedName::parse_attr_value(input)?.lits(); + let span = lits.first().map_or_else(Span::call_site, LitStr::span); + let name = lits.into_iter().map(|lit| lit.value()).collect::(); + + Ok(Self::Literal(Literal::Str(LitStr::new(&name, span)))) } else { input.parse() } diff --git a/crates/hypertext-macros/src/html/syntaxes/maud.rs b/crates/hypertext-macros/src/html/syntaxes/maud.rs index 47ff709..93ef27b 100644 --- a/crates/hypertext-macros/src/html/syntaxes/maud.rs +++ b/crates/hypertext-macros/src/html/syntaxes/maud.rs @@ -10,7 +10,7 @@ use syn::{ use crate::html::{ Attribute, Component, Doctype, Element, ElementBody, Group, Node, Syntax, UnquotedName, - XmlDecl, kw, + XmlDecl, component::ComponentAttribute, kw, }; pub struct Maud; @@ -149,6 +149,18 @@ impl Parse for Component { attrs: { let mut attrs = Vec::new(); + if input.peek(Token![#]) { + attrs.push(input.call(Attribute::parse_id).map(ComponentAttribute)?); + } + + if input.peek(Token![.]) { + attrs.push( + input + .call(Attribute::parse_class_list) + .map(ComponentAttribute)?, + ); + } + while !(input.peek(Token![..]) || input.peek(Token![;]) || input.peek(Brace)) { attrs.push(input.parse()?); } diff --git a/crates/hypertext/tests/components.rs b/crates/hypertext/tests/components.rs index 8d13a49..b038a84 100644 --- a/crates/hypertext/tests/components.rs +++ b/crates/hypertext/tests/components.rs @@ -1,7 +1,7 @@ //! Component and derive macro tests. #![cfg(feature = "alloc")] -use hypertext::{Builder, prelude::*}; +use hypertext::{Buffer, Builder, Lazy, prelude::*}; #[derive(Builder, Renderable)] #[maud(span { "Hello, " (self.name) "!" })] @@ -211,6 +211,42 @@ fn component_optional_field_both_variants() { ); } +#[test] +fn component_optional_field_toggle() { + let result = maud! { + Header title=("Hello".into()) subtitle=[None]; + Header title=("Hello".into()) subtitle=[Some("World".into())]; + } + .render(); + + assert_eq!( + result.as_inner(), + "

Hello

Hello

World

" + ); +} + +#[test] +fn component_optional_explicit_field_toggled_set_true() { + let show_subtitle = true; + let result = maud! { + Header title=("Hello".into()) subtitle=("World".into())[show_subtitle]; + } + .render(); + + assert_eq!(result.as_inner(), "

Hello

World

"); +} + +#[test] +fn component_optional_explicit_field_toggled_set_false() { + let show_subtitle = false; + let result = maud! { + Header title=("Hello".into()) subtitle=("World".into())[show_subtitle]; + } + .render(); + + assert_eq!(result.as_inner(), "

Hello

"); +} + #[renderable] fn nav_bar<'a>(title: &'a str, subtitle: &String, add_smiley: bool) -> impl Renderable { maud! { @@ -703,3 +739,309 @@ fn component_with_loop_over_field() { r#""#, ); } + +#[derive(Builder, Renderable)] +#[maud( + div id=[self.id] class=[self.class] hidden[self.hidden] tabindex=[&self.tabindex] { + (&self.children) + } +)] +struct ComponentDiv<'a> { + id: Option<&'a str>, + class: Option<&'a str>, + #[builder(default)] + hidden: bool, + tabindex: Option, + children: Lazy, +} + +#[test] +fn component_with_shorthand_id() { + let result = maud! { + ComponentDiv #good-div {} + } + .render(); + + assert_eq!(result.as_inner(), r#"
"#); +} + +#[test] +fn component_with_toggled_shorthand_id() { + let is_good_div_true = true; + let is_good_div_false = false; + let result = maud! { + ComponentDiv #good-div[is_good_div_true] {} + ComponentDiv #good-div[is_good_div_false] {} + } + .render(); + + assert_eq!(result.as_inner(), r#"
"#); +} + +#[test] +fn component_with_shorthand_class() { + let result = maud! { + ComponentDiv .class .another-class {} + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
"# + ); +} + +#[test] +fn component_with_toggled_shorthand_class() { + let second_class_true = true; + let second_class_false = false; + let result = maud! { + ComponentDiv .first .second[second_class_true] {} + ComponentDiv .first .second[second_class_false] {} + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
"# + ); +} + +#[test] +fn component_with_toggled_boolean() { + let hidden_true = true; + let hidden_false = false; + let result = maud! { + ComponentDiv hidden[hidden_true] {} + ComponentDiv hidden[hidden_false] {} + } + .render(); + + assert_eq!(result.as_inner(), r#"
"#); +} + +#[test] +fn component_with_toggled_string_attribute() { + let result = maud! { + ComponentDiv class=[Some("classname")] {} + ComponentDiv class="classname"[true] {} + ComponentDiv class="classname"[false] {} + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
"# + ); +} + +#[test] +fn component_with_toggled_numerical_attribute() { + let result = maud! { + ComponentDiv tabindex=[None] {} + ComponentDiv tabindex=[Some(42)] {} + } + .render(); + + assert_eq!(result.as_inner(), r#"
"#); +} + +#[test] +fn component_with_ident_attribute() { + let tabindex = 42; + let result = maud! { + ComponentDiv tabindex=tabindex {} + } + .render(); + + assert_eq!(result.as_inner(), r#"
"#); +} + +#[test] +fn component_noncomponent_syntax_compatibility_maud() { + assert_eq!(maud!(ComponentDiv {}).render(), maud!(div {}).render()); + + assert_eq!( + maud!(ComponentDiv #some-id {}).render(), + maud!(div #some-id {}).render() + ); + + assert_eq!( + maud!(ComponentDiv #some-id[true] {}).render(), + maud!(div #some-id[true] {}).render() + ); + + assert_eq!( + maud!(ComponentDiv #some-id[false] {}).render(), + maud!(div #some-id[false] {}).render() + ); + + #[rustfmt::skip] // rustfmt wants spaces around the '-' + assert_eq!( + maud!(ComponentDiv #some-id .single-class {}).render(), + maud!(div #some-id .single-class {}).render() + ); + + #[rustfmt::skip] // rustfmt wants spaces around the '-' + assert_eq!( + maud!(ComponentDiv .single-class {}).render(), + maud!(div .single-class {}).render() + ); + + #[rustfmt::skip] // rustfmt wants spaces around the '-' + assert_eq!( + maud!(ComponentDiv .first-class .second-class .third-class {}).render(), + maud!(div .first-class .second-class .third-class {}).render() + ); + + #[rustfmt::skip] // rustfmt wants spaces around the '-' + assert_eq!( + maud!(ComponentDiv .first-class .second-class[true] .third-class {}).render(), + maud!(div .first-class .second-class[true] .third-class {}).render() + ); + + #[rustfmt::skip] // rustfmt wants spaces around the '-' + assert_eq!( + maud!(ComponentDiv .first-class .second-class[false] .third-class {}).render(), + maud!(div .first-class .second-class[false] .third-class {}).render() + ); + + assert_eq!( + maud!(ComponentDiv hidden {}).render(), + maud!(div hidden {}).render() + ); + + assert_eq!( + maud!(ComponentDiv hidden[false] {}).render(), + maud!(div hidden[false] {}).render() + ); + + assert_eq!( + maud!(ComponentDiv tabindex=4711 {}).render(), + maud!(div tabindex=4711 {}).render() + ); + + assert_eq!( + maud!(ComponentDiv tabindex=[Some(4711)] {}).render(), + maud!(div tabindex=[Some(4711)] {}).render() + ); + + assert_eq!( + maud!(ComponentDiv tabindex=[None::] {}).render(), + maud!(div tabindex=[None::] {}).render() + ); + + let class = "two"; + assert_eq!( + maud!(ComponentDiv class={"one " (class) " three"} {}).render(), + maud!(div class={"one " (class) " three"} {}).render() + ); + + assert_eq!( + maud!(ComponentDiv class={"one " (class) " three"} {}).render(), + maud!(div class="one two three" {}).render() + ); +} + +#[test] +fn component_noncomponent_syntax_compatibility_rsx() { + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + assert_eq!( + rsx!( ).render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!( ).render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!().render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!(]>).render(), + rsx!(
]>
).render() + ); + + let class = "two"; + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); + + assert_eq!( + rsx!().render(), + rsx!(
).render() + ); +} + +#[renderable] +fn named_blocks_component( + title: &Title, + body: &Body, +) -> impl Renderable { + maud! { + div { + div { (title) } + p { "some divider" } + div { (body) } + p { "footer" } + } + } +} + +#[test] +fn component_with_named_children() { + let result = maud! { + NamedBlocksComponent + title=(maud! { span { "This title is " strong { "strong" } } }) + body=(maud! { p { "regular children element" } }); + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
This title is strong

some divider

regular children element

footer

"#, + ); +}