diff --git a/Cargo.lock b/Cargo.lock index faac0b1..a2d4a1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,31 @@ dependencies = [ "piper", ] +[[package]] +name = "bon" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -771,6 +796,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1517,6 +1576,7 @@ version = "0.12.1" dependencies = [ "actix-web", "axum-core", + "bon", "html-escape", "hypertext-macros", "itoa", @@ -1626,6 +1686,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3210,6 +3276,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.4.1" diff --git a/crates/hypertext-macros/src/derive.rs b/crates/hypertext-macros/src/derive.rs index e451830..68f95a9 100644 --- a/crates/hypertext-macros/src/derive.rs +++ b/crates/hypertext-macros/src/derive.rs @@ -1,6 +1,6 @@ use proc_macro2::{Span, TokenStream}; use quote::quote; -use syn::{DeriveInput, Error, spanned::Spanned}; +use syn::{Data, DeriveInput, Error, spanned::Spanned}; use crate::{ AttributeValue, Config, Document, Many, Maud, Rsx, Semantics, @@ -132,3 +132,67 @@ fn renderable_attribute(input: &DeriveInput) -> syn::Result> Ok(Some(output)) } + +#[allow(clippy::needless_pass_by_value)] +pub fn default_builder(input: DeriveInput) -> syn::Result { + let Data::Struct(data_struct) = &input.data else { + return Err(Error::new( + input.span(), + "#[derive(DefaultBuilder)] may only be used on structs", + )); + }; + + let struct_name = &input.ident; + let vis = &input.vis; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut methods = Vec::new(); + for field in &data_struct.fields { + if let Some(name) = &field.ident { + let ty = &field.ty; + + let is_skipped = field + .attrs + .iter() + .find(|attr| attr.path().is_ident("builder")) + .map_or(Ok(false), |builder_attr| { + builder_attr + .parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + return Ok(()); + } + + Err(meta.error("unexpected param for `#[builder(...)]`")) + }) + .map(|()| true) + })?; + + if !is_skipped { + methods.push(quote! { + #[must_use] + #vis fn #name(mut self, #name: #ty) -> Self { + self.#name = #name; + self + } + }); + } + } + } + + let output = quote! { + #[automatically_derived] + impl #impl_generics #struct_name #ty_generics #where_clause { + #vis fn builder() -> Self { + ::default() + } + + #vis fn build(self) -> Self { + self + } + + #(#methods)* + } + }; + + Ok(output) +} diff --git a/crates/hypertext-macros/src/html/component.rs b/crates/hypertext-macros/src/html/component.rs index 06034f0..c300afd 100644 --- a/crates/hypertext-macros/src/html/component.rs +++ b/crates/hypertext-macros/src/html/component.rs @@ -1,9 +1,8 @@ use proc_macro2::TokenStream; -use quote::{ToTokens, quote, quote_spanned}; +use quote::{ToTokens, quote}; use syn::{ Ident, Lit, Token, parse::{Parse, ParseStream}, - spanned::Spanned, token::{Brace, Paren}, }; @@ -13,7 +12,6 @@ use crate::{AttributeValue, html::Node}; pub struct Component { pub name: Ident, pub attrs: Vec, - pub dotdot: Option, pub body: ElementBody, } @@ -21,10 +19,10 @@ impl Generate for Component { type Context = Node; fn generate(&self, g: &mut Generator) { - let fields = self.attrs.iter().map(|attr| { + let props = self.attrs.iter().map(|attr| { let name = &attr.name; attr.value_expr() - .map_or_else(|| quote!(#name,), |value| quote!(#name: #value,)) + .map_or_else(|| quote!(.#name(#name)), |value| quote!(.#name(#value))) }); let children = match &self.body { @@ -45,7 +43,7 @@ impl Generate for Component { let children_ident = Ident::new("children", self.name.span()); quote!( - #children_ident: #lazy, + .#children_ident(#lazy) ) } ElementBody::Void => quote!(), @@ -53,18 +51,11 @@ impl Generate for Component { let name = &self.name; - let default = self - .dotdot - .as_ref() - .map(|dotdot| quote_spanned!(dotdot.span()=> ..::core::default::Default::default())) - .unwrap_or_default(); - let init = quote! { - #name { - #(#fields)* + #name::builder() + #(#props)* #children - #default - } + .build() }; g.push_expr::(Paren::default(), &init); diff --git a/crates/hypertext-macros/src/html/syntaxes/maud.rs b/crates/hypertext-macros/src/html/syntaxes/maud.rs index 4e09f57..912490c 100644 --- a/crates/hypertext-macros/src/html/syntaxes/maud.rs +++ b/crates/hypertext-macros/src/html/syntaxes/maud.rs @@ -131,7 +131,6 @@ impl Parse for Component { attrs }, - dotdot: input.parse()?, body: input.parse()?, }) } diff --git a/crates/hypertext-macros/src/html/syntaxes/rsx.rs b/crates/hypertext-macros/src/html/syntaxes/rsx.rs index dbc1c44..9bb553a 100644 --- a/crates/hypertext-macros/src/html/syntaxes/rsx.rs +++ b/crates/hypertext-macros/src/html/syntaxes/rsx.rs @@ -32,8 +32,6 @@ impl Node { attrs.push(input.parse()?); } - let dotdot = input.parse()?; - let solidus = input.parse::>()?; input.parse::]>()?; @@ -41,7 +39,6 @@ impl Node { Ok(Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, })) } else { @@ -54,7 +51,6 @@ impl Node { Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, }), ); @@ -77,7 +73,6 @@ impl Node { Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, }), ); @@ -89,7 +84,6 @@ impl Node { Ok(Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Normal { children: Many(children), closing_name: Some(parse_quote!(#closing_name)), diff --git a/crates/hypertext-macros/src/lib.rs b/crates/hypertext-macros/src/lib.rs index 1d9775b..970e224 100644 --- a/crates/hypertext-macros/src/lib.rs +++ b/crates/hypertext-macros/src/lib.rs @@ -76,3 +76,10 @@ pub fn renderable(attr: TokenStream, item: TokenStream) -> TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } + +#[proc_macro_derive(DefaultBuilder, attributes(builder))] +pub fn derive_default_builder(input: TokenStream) -> TokenStream { + derive::default_builder(parse_macro_input!(input)) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/crates/hypertext-macros/src/renderable.rs b/crates/hypertext-macros/src/renderable.rs index 65b86c5..acee977 100644 --- a/crates/hypertext-macros/src/renderable.rs +++ b/crates/hypertext-macros/src/renderable.rs @@ -1,29 +1,90 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{Error, FnArg, Ident, ItemFn, Pat, PatType, Type, Visibility, parse::Parse}; +use syn::{ + Error, FnArg, Ident, ItemFn, LitBool, Pat, PatType, Path, Token, Type, Visibility, + parse::Parse, parse_quote, +}; use crate::html::generate::Generator; +pub enum BuilderArg { + False, + Path(Path), +} + +impl Parse for BuilderArg { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + if name != "builder" { + return Err(Error::new(name.span(), "unknown argument")); + } + + input.parse::()?; + + let builder = if input.peek(LitBool) { + let lit_bool = input.parse::()?; + if lit_bool.value { + return Err(Error::new(lit_bool.span(), "unexpected `true`")); + } + Self::False + } else { + Self::Path(input.parse()?) + }; + Ok(builder) + } +} + pub struct RenderableArgs { visibility: Visibility, ident: Option, + builder: Option, + fn_attrs: Vec, } impl Parse for RenderableArgs { fn parse(input: syn::parse::ParseStream) -> syn::Result { - Ok(Self { - visibility: input.parse()?, - ident: if input.peek(Ident) { - Some(input.parse()?) + let mut visibility = Visibility::Inherited; + let mut ident = None; + let mut builder = None; + let mut fn_attrs = Vec::new(); + + while !input.is_empty() { + if input.peek(Ident) { + if input.peek2(Token![=]) { + builder = Some(input.parse()?); + } else { + let name = input.parse::()?; + if name == "fn_attrs" { + let content; + syn::parenthesized!(content in input); + + fn_attrs.extend(content.parse_terminated(Path::parse, Token![,])?); + } else { + ident = Some(name); + } + } + } else if input.peek(Token![pub]) { + visibility = input.parse()?; } else { - None - }, + return Err(Error::new(input.span(), "unexpected attribute parameter")); + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(Self { + visibility, + ident, + builder, + fn_attrs, }) } } -#[expect(clippy::needless_pass_by_value)] -pub fn generate(args: RenderableArgs, fn_item: ItemFn) -> syn::Result { +#[expect(clippy::too_many_lines)] +pub fn generate(args: RenderableArgs, mut fn_item: ItemFn) -> syn::Result { let mut fields = Vec::new(); let mut field_names = Vec::new(); let mut field_refs = Vec::new(); @@ -34,8 +95,8 @@ pub fn generate(args: RenderableArgs, fn_item: ItemFn) -> syn::Result &pat_ident.ident, _ => { @@ -55,14 +116,20 @@ pub fn generate(args: RenderableArgs, fn_item: ItemFn) -> syn::Result (ty, None), + _ => (&*ty, None), }; + + let field_attrs = attrs + .extract_if(.., |attr| !args.fn_attrs.contains(attr.path())) + .collect::>(); + fields.push(quote! { + #(#field_attrs)* #vis #ident: #ty }); field_names.push(ident.clone()); @@ -75,6 +142,26 @@ pub fn generate(args: RenderableArgs, fn_item: ItemFn) -> syn::Result>(); + + let builder = args.builder.or_else(|| { + if fields.is_empty() { + None + } else { + Some(BuilderArg::Path(parse_quote!(::hypertext::Builder))) + } + }); + + if let Some(BuilderArg::Path(path)) = builder { + struct_attrs.push(quote! { + #[derive(#path)] + }); + } + let fn_name = &fn_item.sig.ident; let struct_name = args @@ -91,14 +178,34 @@ pub fn generate(args: RenderableArgs, fn_item: ItemFn) -> syn::Result Self { + Self + } + + #vis fn build(self) -> Self { + self + } + } + }) + } else { + None + }; + let buffer_ident = Generator::buffer_ident(); let output = quote! { #[allow(clippy::needless_lifetimes)] #fn_item + #(#struct_attrs)* #vis struct #struct_name #ty_generics #struct_body + #maybe_unit_builder_impl + #[automatically_derived] impl #impl_generics ::hypertext::Renderable for #struct_name #ty_generics #where_clause { fn render_to(&self, #buffer_ident: &mut ::hypertext::Buffer) { diff --git a/crates/hypertext/Cargo.toml b/crates/hypertext/Cargo.toml index 3b42f19..6943bf6 100644 --- a/crates/hypertext/Cargo.toml +++ b/crates/hypertext/Cargo.toml @@ -17,6 +17,7 @@ all-features = true [dependencies] actix-web = { version = "4", default-features = false, optional = true } axum-core = { version = "0.5", default-features = false, optional = true } +bon = "3.9" html-escape = { workspace = true, optional = true } hypertext-macros.workspace = true itoa = { version = "1", optional = true } diff --git a/crates/hypertext/src/lib.rs b/crates/hypertext/src/lib.rs index 39d610d..0fb13dc 100644 --- a/crates/hypertext/src/lib.rs +++ b/crates/hypertext/src/lib.rs @@ -129,6 +129,8 @@ mod web_frameworks; use core::{fmt::Debug, marker::PhantomData}; +pub use bon::{self, Builder}; + use self::context::{AttributeValue, Context, Node}; pub use self::macros::*; #[cfg(feature = "alloc")] diff --git a/crates/hypertext/src/macros/mod.rs b/crates/hypertext/src/macros/mod.rs index 49b65b0..0833a8f 100644 --- a/crates/hypertext/src/macros/mod.rs +++ b/crates/hypertext/src/macros/mod.rs @@ -4,6 +4,61 @@ pub mod maud; mod renderable; pub mod rsx; +/// Generates simple implementations of the builder methods +/// for a type implementing `Default`. +/// +/// # Example +/// +/// ``` +/// use hypertext::{Buffer, DefaultBuilder, Lazy, prelude::*}; +/// +/// #[renderable(builder = DefaultBuilder)] +/// #[derive(Default)] +/// fn component<'a>( +/// id: &'a str, +/// tabindex: u32, +/// children: Lazy, +/// ) -> impl Renderable { +/// rsx! { +///
+/// (children) +///
+/// } +/// } +/// ``` +/// +/// Expands to: +/// +/// ```ignore +/// impl<'a> Component<'a> { +/// fn builder() -> Self { +/// Self::default() +/// } +/// +/// fn build(self) -> Self { +/// self +/// } +/// +/// #[must_use] +/// fn id(mut self, id: &'a str) -> Self { +/// self.id = id; +/// self +/// } +/// +/// #[must_use] +/// fn tabindex(mut self, tabindex: u32) -> Self { +/// self.tabindex = tabindex; +/// self +/// } +/// +/// #[must_use] +/// fn children(mut self, children: Lazy) -> Self { +/// self.children = children; +/// self +/// } +/// } +/// ``` +pub use hypertext_macros::DefaultBuilder; /// Generates an attribute value, returning a /// [`LazyAttribute`](crate::LazyAttribute). /// diff --git a/crates/hypertext/src/macros/renderable.rs b/crates/hypertext/src/macros/renderable.rs index b1c90c1..160a104 100644 --- a/crates/hypertext/src/macros/renderable.rs +++ b/crates/hypertext/src/macros/renderable.rs @@ -86,6 +86,105 @@ /// r#"
Location
"# /// ); /// ``` +/// +/// ## Using with [`#[derive(Builder)]`](crate::Builder) +/// +/// Combining [`#[derive(Renderable)]`](derive@crate::Renderable) with +/// [`#[derive(Builder)]`](crate::Builder) makes a struct usable as a component +/// in the [`maud!`](crate::maud!) and [`rsx!`](crate::rsx!) macros via the +/// component syntax. +/// +/// ### [`maud!`](crate::maud!) +/// +/// ``` +/// use hypertext::{Builder, prelude::*}; +/// +/// #[derive(Builder, Renderable)] +/// #[maud( +/// div { +/// h1 { (self.title) } +/// p { (self.body) } +/// } +/// )] +/// pub struct Card { +/// title: String, +/// body: String, +/// } +/// +/// assert_eq!( +/// maud! { +/// main { +/// Card title=("My Title".into()) body=("My Body".into()); +/// } +/// } +/// .render() +/// .as_inner(), +/// "

My Title

My Body

", +/// ); +/// ``` +/// +/// ### [`rsx!`](crate::rsx!) +/// +/// ``` +/// use hypertext::{Builder, prelude::*}; +/// +/// #[derive(Builder, Renderable)] +/// #[rsx( +///
+///

(self.title)

+///

(self.body)

+///
+/// )] +/// pub struct Card { +/// title: String, +/// body: String, +/// } +/// +/// assert_eq!( +/// rsx! { +///
+/// +///
+/// } +/// .render() +/// .as_inner(), +/// "

My Title

My Body

", +/// ); +/// ``` +/// +/// ### With default field values +/// +/// [`#[derive(Builder)]`](crate::Builder) automatically treats `Option` +/// fields as optional — their setter accepts a `T` and wraps it in `Some`, +/// and they default to `None` when omitted. +/// +/// ``` +/// use hypertext::{Builder, prelude::*}; +/// +/// #[derive(Builder, Renderable)] +/// #[maud( +/// div { +/// h1 { (self.title) } +/// @if let Some(subtitle) = &self.subtitle { +/// h2 { (subtitle) } +/// } +/// } +/// )] +/// pub struct Header { +/// title: String, +/// subtitle: Option, +/// } +/// +/// assert_eq!( +/// maud! { +/// Header title=("Hello".into()); +/// Header title=("Hello".into()) subtitle=("World".into()); +/// } +/// .render() +/// .as_inner(), +/// "

Hello

Hello

World

", +/// ); +/// ``` #[cfg_attr(all(docsrs, not(doctest)), doc(cfg(feature = "alloc")))] pub use hypertext_macros::Renderable; /// Turns a function returning a [`Renderable`](crate::Renderable) into a diff --git a/crates/hypertext/src/renderable/mod.rs b/crates/hypertext/src/renderable/mod.rs index 1a2ca49..59b5e11 100644 --- a/crates/hypertext/src/renderable/mod.rs +++ b/crates/hypertext/src/renderable/mod.rs @@ -304,6 +304,13 @@ impl), C: Context> Lazy { } } +impl Default for Lazy), C> { + #[inline] + fn default() -> Self { + Self::dangerously_create(|_| ()) + } +} + impl), C: Context> Renderable for Lazy { #[inline] fn render_to(&self, buffer: &mut Buffer) { diff --git a/crates/hypertext/tests/alloc.rs b/crates/hypertext/tests/alloc.rs index 4c72558..eb89a1b 100644 --- a/crates/hypertext/tests/alloc.rs +++ b/crates/hypertext/tests/alloc.rs @@ -4,7 +4,7 @@ use std::fmt::{self, Display, Formatter}; -use hypertext::{Buffer, Raw, prelude::*}; +use hypertext::{Buffer, Builder, Raw, prelude::*}; #[test] fn readme() { @@ -544,6 +544,7 @@ fn void_elements() { #[test] fn component() { + #[derive(Builder)] struct Repeater { count: usize, children: R, diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs new file mode 100644 index 0000000..6d6b5ec --- /dev/null +++ b/crates/hypertext/tests/builder.rs @@ -0,0 +1,621 @@ +//! Tests for the `hypertext` crate. +#![cfg(feature = "alloc")] + +use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, Renderable, prelude::*, renderable}; + +#[test] +#[expect(clippy::too_many_lines)] +fn default() { + #[renderable] + fn component_a<'a>( + #[builder(default)] id: &'a str, + #[builder(default = 1)] tabindex: u32, + #[builder(default)] children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + #[renderable(builder = Builder)] + fn component_b<'a>( + #[builder(default)] id: &'a str, + #[builder(default = 1)] tabindex: u32, + #[builder(default)] children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + #[renderable(builder = DefaultBuilder)] + fn component_c<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + impl Default for ComponentC<'_> { + fn default() -> Self { + Self { + id: Default::default(), + tabindex: 1, + children: Lazy::default(), + } + } + } + + #[renderable(builder = DefaultBuilder)] + #[derive(Default)] + fn component_d<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + let maud_result = maud! { + ComponentA; + ComponentB; + ComponentC; + ComponentD; + } + .render(); + + let rsx_result = rsx! { + + + + + } + .render(); + + let component_html = r#"
"#; + let expected_result = format!( + r#"{}
"#, + component_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA id="test"; + ComponentB id="test"; + ComponentC id="test"; + ComponentD id="test"; + } + .render(); + + let rsx_result = rsx! { + + + + + } + .render(); + + let component_html = r#"
"#; + let expected_result = format!( + r#"{}
"#, + component_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA { + h1 { "hello" } + } + ComponentB { + h1 { "hello" } + } + ComponentC { + h1 { "hello" } + } + ComponentD { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ +

hello

+
+ +

hello

+
+ } + .render(); + + let component_html = r#"

hello

"#; + let expected_result = format!( + r#"{}

hello

"#, + component_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA tabindex=2 id="component" { + h1 { "hello" } + } + ComponentB tabindex=2 id="component" { + h1 { "hello" } + } + ComponentC tabindex=2 id="component" { + h1 { "hello" } + } + ComponentD tabindex=2 id="component" { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ +

hello

+
+ +

hello

+
+ } + .render(); + + let component_html = r#"

hello

"#; + let expected_result = component_html.repeat(4); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA { + ComponentA id="nested" { + h1 { "hello" } + } + } + ComponentB { + ComponentB id="nested" { + h1 { "hello" } + } + } + ComponentC { + ComponentC id="nested" { + h1 { "hello" } + } + } + ComponentD { + ComponentD id="nested" { + h1 { "hello" } + } + } + } + .render(); + + let rsx_result = rsx! { + + +

"hello"

+
+
+ + +

"hello"

+
+
+ + +

"hello"

+
+
+ + +

"hello"

+
+
+ } + .render(); + + let component_html = + r#"

hello

"#; + let expected_result = format!( + r#"{}

hello

"#, + component_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); +} + +#[test] +#[expect(clippy::too_many_lines)] +fn custom() { + #[renderable(builder = false)] + fn component_a<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + impl<'a> ComponentA<'a> { + fn builder() -> Self { + Self { + id: "custom", + tabindex: 2, + children: Lazy::default(), + } + } + + fn id(self, id: &'a str) -> Self { + Self { id, ..self } + } + + fn tabindex(self, tabindex: u32) -> Self { + Self { tabindex, ..self } + } + + fn children(self, children: Lazy) -> Self { + Self { children, ..self } + } + + fn build(self) -> Self { + self + } + } + + #[derive(Builder)] + struct ComponentB<'a> { + #[builder(default = "custom")] + id: &'a str, + + #[builder(default = 2)] + tabindex: u32, + + #[builder(default)] + children: Lazy, + } + + impl Renderable for ComponentB<'_> { + fn render_to(&self, buf: &mut Buffer) { + rsx! { +
+ (self.children) +
+ } + .render_to(buf); + } + } + + let maud_result = maud! { + ComponentA; + ComponentB; + } + .render(); + + let rsx_result = rsx! { + + + } + .render(); + + let component_html = r#"
"#; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA id="test"; + ComponentB id="test"; + } + .render(); + + let rsx_result = rsx! { + + + } + .render(); + + let component_html = r#"
"#; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA { + h1 { "hello" } + } + ComponentB { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ } + .render(); + + let component_html = r#"

hello

"#; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA tabindex=1 id="component" { + h1 { "hello" } + } + ComponentB tabindex=1 id="component" { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ } + .render(); + + let component_html = r#"

hello

"#; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ComponentA { + ComponentA id="nested" { + h1 { "hello" } + } + } + ComponentB { + ComponentB id="nested" { + h1 { "hello" } + } + } + } + .render(); + + let rsx_result = rsx! { + + +

"hello"

+
+
+ + +

"hello"

+
+
+ } + .render(); + + let component_html = + r#"

hello

"#; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); +} + +#[test] +fn children() { + #[renderable] + fn component_a(children: &R) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + #[derive(Builder)] + struct ComponentB { + children: Lazy, + } + + impl Renderable for ComponentB { + fn render_to(&self, buf: &mut Buffer) { + rsx! { +
+ (self.children) +
+ } + .render_to(buf); + } + } + + let msg = "hello".to_string(); + + let maud_result = maud::borrow! { + ComponentA { + h1 { (msg) } + } + ComponentB { + h1 { (msg) } + } + } + .render(); + + let rsx_result = rsx::borrow! { + +

(msg)

+
+ +

(msg)

+
+ } + .render(); + + let component_html = "

hello

"; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud::borrow! { + ComponentA { + ComponentA { + h1 { (msg) } + } + } + ComponentB { + ComponentB { + h1 { (msg) } + } + } + } + .render(); + + let rsx_result = rsx::borrow! { + + +

(msg)

+
+
+ + +

(msg)

+
+
+ } + .render(); + + let component_html = "

hello

"; + let expected_result = component_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); +} + +#[test] +#[expect(unused_parens)] +fn derive_renderable_builder() { + #[derive(Builder, Renderable)] + #[maud( + div { + h1 { (self.title) } + p { (self.body) } + } + )] + struct CardMaud { + title: String, + body: String, + } + + #[derive(Builder, Renderable)] + #[rsx( +
+

(self.title)

+

(self.body)

+
+ )] + struct CardRsx { + title: String, + body: String, + } + + #[derive(Builder, Renderable)] + #[maud( + div { + h1 { (self.title) } + @if let Some(subtitle) = &self.subtitle { + h2 { (subtitle) } + } + } + )] + struct Header { + title: String, + subtitle: Option, + } + + // --- CardMaud --- + let maud_result = maud! { + main { + CardMaud title=("My Title".to_owned()) body=("My Body".to_owned()); + } + } + .render(); + + let rsx_result = rsx! { +
+ +
+ } + .render(); + + let expected = "

My Title

My Body

"; + assert_eq!(maud_result.as_inner(), expected); + assert_eq!(rsx_result.as_inner(), expected); + + // --- CardRsx --- + let maud_result = maud! { + main { + CardRsx title=("My Title".to_owned()) body=("My Body".to_owned()); + } + } + .render(); + + let rsx_result = rsx! { +
+ +
+ } + .render(); + + assert_eq!(maud_result.as_inner(), expected); + assert_eq!(rsx_result.as_inner(), expected); + + // --- Header (with and without optional subtitle) --- + let maud_result = maud! { + Header title=("Hello".to_owned()); + Header title=("Hello".to_owned()) subtitle=("World".to_owned()); + } + .render(); + + let rsx_result = rsx! { +
+
+ } + .render(); + + let expected = "

Hello

Hello

World

"; + assert_eq!(maud_result.as_inner(), expected); + assert_eq!(rsx_result.as_inner(), expected); +}