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
footer
"#,
+ );
+}