From cf38a9bc52ef4fad2d00e94a4ca392140ed0eb99 Mon Sep 17 00:00:00 2001 From: XX Date: Mon, 2 Mar 2026 15:07:37 +0300 Subject: [PATCH 01/13] feat: use builder for component instantiation --- Cargo.lock | 21 + Cargo.toml | 1 - hypertext-macros/src/component.rs | 144 ++++++- hypertext-macros/src/derive.rs | 66 +++- hypertext-macros/src/html/component.rs | 23 +- hypertext-macros/src/html/syntaxes/maud.rs | 1 - hypertext-macros/src/html/syntaxes/rsx.rs | 6 - hypertext-macros/src/lib.rs | 9 +- hypertext/Cargo.toml | 1 + hypertext/src/lib.rs | 5 +- hypertext/src/macros/mod.rs | 1 + hypertext/tests/builder.rs | 435 +++++++++++++++++++++ hypertext/tests/main.rs | 48 +-- 13 files changed, 678 insertions(+), 83 deletions(-) create mode 100644 hypertext/tests/builder.rs diff --git a/Cargo.lock b/Cargo.lock index 3c190d9..c1d5ac2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1525,6 +1525,7 @@ dependencies = [ "ryu", "salvo_core", "tide", + "typed-builder", "warp", ] @@ -3669,6 +3670,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-builder" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34085c17941e36627a879208083e25d357243812c30e7d7387c3b954f30ade16" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03ca4cb38206e2bef0700092660bb74d696f808514dae47fa1467cbfe26e96e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index baca3be..dc01e21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,4 +30,3 @@ too_long_first_doc_paragraph = "allow" missing_copy_implementations = "warn" missing_debug_implementations = "warn" missing_docs = "warn" - diff --git a/hypertext-macros/src/component.rs b/hypertext-macros/src/component.rs index 04dcd7b..556fb98 100644 --- a/hypertext-macros/src/component.rs +++ b/hypertext-macros/src/component.rs @@ -1,31 +1,111 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{FnArg, Ident, ItemFn, Pat, PatType, Type, Visibility, parse::Parse}; +use syn::{ + 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(syn::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(syn::Error::new(lit_bool.span(), "unexpected `true`")); + } + Self::False + } else { + Self::Path(input.parse()?) + }; + Ok(builder) + } +} + pub struct ComponentArgs { visibility: Visibility, ident: Option, + builder: Option, + attrs: Option>, } impl Parse for ComponentArgs { 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 attrs: Option> = None; + + while !input.is_empty() { + if input.peek(Ident) && input.peek2(Token![=]) { + let ident = input.fork().parse::()?; + if ident == "attrs" { + let _attrs = input.parse::()?; + + input.parse::()?; + + let content; + syn::bracketed!(content in input); + + attrs + .get_or_insert_default() + .extend(content.parse_terminated(Path::parse, Token![,])?); + } else { + builder = Some(input.parse()?); + } } else { - None - }, + visibility = input.parse()?; + if input.peek(Ident) { + ident = Some(input.parse()?); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(Self { + visibility, + ident, + builder, + attrs, }) } } -pub fn generate(args: ComponentArgs, fn_item: &ItemFn) -> syn::Result { +#[allow(clippy::too_many_lines)] +pub fn generate(args: ComponentArgs, mut fn_item: ItemFn) -> syn::Result { let mut fields = Vec::new(); let mut field_names = Vec::new(); let mut field_refs = Vec::new(); + let mut component_attrs = Vec::new(); + + let builder = args.builder.or_else(|| { + if fn_item.sig.inputs.is_empty() { + None + } else { + if args.attrs.is_none() { + component_attrs = vec![parse_quote!(builder)]; + } + + Some(BuilderArg::Path(parse_quote!(::hypertext::TypedBuilder))) + } + }); + + component_attrs.extend(args.attrs.unwrap_or_default()); let vis = if args.visibility == Visibility::Inherited { fn_item.vis.clone() @@ -33,8 +113,8 @@ pub fn generate(args: ComponentArgs, fn_item: &ItemFn) -> syn::Result &pat_ident.ident, _ => { @@ -54,14 +134,20 @@ pub fn generate(args: ComponentArgs, fn_item: &ItemFn) -> syn::Result (ty, None), + _ => (&*ty, None), }; + + let field_attrs = attrs + .extract_if(.., |attr| component_attrs.contains(attr.path())) + .collect::>(); + fields.push(quote! { + #(#field_attrs)* #vis #ident: #ty }); field_names.push(ident.clone()); @@ -74,6 +160,20 @@ pub fn generate(args: ComponentArgs, fn_item: &ItemFn) -> syn::Result>(); + + if let Some(BuilderArg::Path(path)) = builder { + struct_attrs.push(quote! { + #[derive(#path)] + }); + } + let fn_name = &fn_item.sig.ident; let struct_name = args @@ -90,14 +190,34 @@ pub fn generate(args: ComponentArgs, 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/hypertext-macros/src/derive.rs b/hypertext-macros/src/derive.rs index e023353..01dc00a 100644 --- a/hypertext-macros/src/derive.rs +++ b/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::{ AttributeValueNode, Context, Document, Maud, Nodes, Rsx, @@ -135,3 +135,67 @@ fn attribute_renderable(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(syn::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("unrecognized 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 { + Self::default() + } + + #vis fn build(self) -> Self { + self + } + + #(#methods)* + } + }; + + Ok(output) +} diff --git a/hypertext-macros/src/html/component.rs b/hypertext-macros/src/html/component.rs index d37d758..3af8cf4 100644 --- a/hypertext-macros/src/html/component.rs +++ b/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, LitBool, LitChar, LitFloat, LitInt, LitStr, Token, parse::{Parse, ParseStream}, - spanned::Spanned, token::{Brace, Paren}, }; @@ -13,7 +12,6 @@ use crate::{AttributeValueNode, Context}; pub struct Component { pub name: Ident, pub attrs: Vec, - pub dotdot: Option, pub body: ElementBody, } @@ -21,11 +19,11 @@ impl Generate for Component { const CONTEXT: Context = 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; let value = &attr.value_expr(); - quote!(#name: #value,) + quote!(.#name(#value)) }); let children = match &self.body { @@ -46,7 +44,7 @@ impl Generate for Component { let children_ident = Ident::new("children", self.name.span()); quote!( - #children_ident: #lazy, + .#children_ident(#lazy) ) } ElementBody::Void => quote!(), @@ -54,18 +52,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(), Self::CONTEXT, &init); diff --git a/hypertext-macros/src/html/syntaxes/maud.rs b/hypertext-macros/src/html/syntaxes/maud.rs index 0a47781..7922c93 100644 --- a/hypertext-macros/src/html/syntaxes/maud.rs +++ b/hypertext-macros/src/html/syntaxes/maud.rs @@ -132,7 +132,6 @@ impl Parse for Component { attrs }, - dotdot: input.parse()?, body: input.parse()?, }) } diff --git a/hypertext-macros/src/html/syntaxes/rsx.rs b/hypertext-macros/src/html/syntaxes/rsx.rs index 00cf578..dff38f0 100644 --- a/hypertext-macros/src/html/syntaxes/rsx.rs +++ b/hypertext-macros/src/html/syntaxes/rsx.rs @@ -34,8 +34,6 @@ impl ElementNode { attrs.push(input.parse()?); } - let dotdot = input.parse()?; - let solidus = input.parse::>()?; input.parse::]>()?; @@ -43,7 +41,6 @@ impl ElementNode { Ok(Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, })) } else { @@ -56,7 +53,6 @@ impl ElementNode { Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, }), ); @@ -78,7 +74,6 @@ impl ElementNode { Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Void, }), ); @@ -90,7 +85,6 @@ impl ElementNode { Ok(Self::Component(Component { name, attrs, - dotdot, body: ElementBody::Normal { children: Nodes(children), closing_name: Some(parse_quote!(#closing_name)), diff --git a/hypertext-macros/src/lib.rs b/hypertext-macros/src/lib.rs index 2b2d3f7..7feb7f9 100644 --- a/hypertext-macros/src/lib.rs +++ b/hypertext-macros/src/lib.rs @@ -95,7 +95,14 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream { let attr = parse_macro_input!(attr as ComponentArgs); let item = parse_macro_input!(item as ItemFn); - component::generate(attr, &item) + component::generate(attr, item) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +#[proc_macro_derive(DefaultBuilder, attributes(builder))] +pub fn derive_builder(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + derive::default_builder(parse_macro_input!(input as DeriveInput)) .unwrap_or_else(|err| err.to_compile_error()) .into() } diff --git a/hypertext/Cargo.toml b/hypertext/Cargo.toml index c52918b..2bb75f0 100644 --- a/hypertext/Cargo.toml +++ b/hypertext/Cargo.toml @@ -27,6 +27,7 @@ rocket = { version = "0.5", default-features = false, optional = true } ryu = { version = "1", optional = true } salvo_core = { version = "0.81", default-features = false, optional = true } tide = { version = "0.16", default-features = false, optional = true } +typed-builder = "0.16" warp = { version = "0.4", default-features = false, optional = true } [features] diff --git a/hypertext/src/lib.rs b/hypertext/src/lib.rs index 634d8c4..e6d1af1 100644 --- a/hypertext/src/lib.rs +++ b/hypertext/src/lib.rs @@ -152,8 +152,9 @@ //! components. //! //! ```rust -//! use hypertext::{Buffer, prelude::*}; +//! use hypertext::{Buffer, TypedBuilder, prelude::*}; //! +//! #[derive(TypedBuilder)] //! struct Repeater { //! count: usize, //! children: R, @@ -207,6 +208,8 @@ mod web_frameworks; use core::{fmt::Debug, marker::PhantomData}; +pub use typed_builder::{self, TypedBuilder}; + #[cfg(feature = "alloc")] pub use self::alloc::*; use self::context::{AttributeValue, Context, Node}; diff --git a/hypertext/src/macros/mod.rs b/hypertext/src/macros/mod.rs index ba10614..4f80d96 100644 --- a/hypertext/src/macros/mod.rs +++ b/hypertext/src/macros/mod.rs @@ -1,6 +1,7 @@ #[cfg(feature = "alloc")] mod alloc; +pub use hypertext_macros::DefaultBuilder; /// Generate static HTML attributes. /// /// This will return a [`RawAttribute<&'static str>`](crate::RawAttribute), diff --git a/hypertext/tests/builder.rs b/hypertext/tests/builder.rs new file mode 100644 index 0000000..761cd98 --- /dev/null +++ b/hypertext/tests/builder.rs @@ -0,0 +1,435 @@ +//! Tests for the `hypertext` crate. +#![cfg(feature = "alloc")] + +use hypertext::{Buffer, DefaultBuilder, Lazy, TypedBuilder, prelude::*}; + +#[test] +fn default() { + #[component] + fn element_a<'a>( + #[builder(default)] id: &'a str, + #[builder(default = 1)] tabindex: u32, + #[builder(default)] children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + #[component(builder = TypedBuilder, attrs = [builder])] + fn element_b<'a>( + #[builder(default)] id: &'a str, + #[builder(default = 1)] tabindex: u32, + #[builder(default)] children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + #[component(builder = DefaultBuilder)] + fn element_c<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + impl<'a> Default for ElementC<'a> { + fn default() -> Self { + Self { + id: Default::default(), + tabindex: 1, + children: Default::default(), + } + } + } + + #[component(builder = DefaultBuilder)] + #[derive(Default)] + fn element_d<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + let maud_result = maud! { + ElementA; + ElementB; + ElementC; + ElementD; + } + .render(); + + let rsx_result = rsx! { + + + + + } + .render(); + + let element_html = r#"
"#; + let expected_result = format!( + r#"{}
"#, + element_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA id="test"; + ElementB id="test"; + ElementC id="test"; + ElementD id="test"; + } + .render(); + + let rsx_result = rsx! { + + + + + } + .render(); + + let element_html = r#"
"#; + let expected_result = format!( + r#"{}
"#, + element_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA { + h1 { "hello" } + } + ElementB { + h1 { "hello" } + } + ElementC { + h1 { "hello" } + } + ElementD { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ +

hello

+
+ +

hello

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

hello

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

hello

"#, + element_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA tabindex=2 id="element" { + h1 { "hello" } + } + ElementB tabindex=2 id="element" { + h1 { "hello" } + } + ElementC tabindex=2 id="element" { + h1 { "hello" } + } + ElementD tabindex=2 id="element" { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

+
+ +

hello

+
+ +

hello

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

hello

"#; + let expected_result = element_html.repeat(4); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA { + ElementA id="nested" { + h1 { "hello" } + } + } + ElementB { + ElementB id="nested" { + h1 { "hello" } + } + } + ElementC { + ElementC id="nested" { + h1 { "hello" } + } + } + ElementD { + ElementD id="nested" { + h1 { "hello" } + } + } + } + .render(); + + let rsx_result = rsx! { + + +

"hello"

+
+
+ + +

"hello"

+
+
+ + +

"hello"

+
+
+ + +

"hello"

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

hello

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

hello

"#, + element_html.repeat(3) + ); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); +} + +#[test] +fn custom() { + #[component(builder = false)] + fn element_a<'a>( + id: &'a str, + tabindex: u32, + children: Lazy, + ) -> impl Renderable { + rsx! { +
+ (children) +
+ } + } + + impl<'a> ElementA<'a> { + fn builder() -> Self { + Self { + id: "custom", + tabindex: 2, + children: Default::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(TypedBuilder)] + struct ElementB<'a> { + #[builder(default = "custom")] + id: &'a str, + + #[builder(default = 2)] + tabindex: u32, + + #[builder(default)] + children: Lazy, + } + + impl<'a> Renderable for ElementB<'a> { + fn render_to(&self, buf: &mut Buffer) { + rsx! { +
+ (self.children) +
+ } + .render_to(buf) + } + } + + let maud_result = maud! { + ElementA; + ElementB; + } + .render(); + + let rsx_result = rsx! { + + + } + .render(); + + let element_html = r#"
"#; + let expected_result = element_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA id="test"; + ElementB id="test"; + } + .render(); + + let rsx_result = rsx! { + + + } + .render(); + + let element_html = r#"
"#; + let expected_result = element_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA { + h1 { "hello" } + } + ElementB { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

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

hello

"#; + let expected_result = element_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); + + let maud_result = maud! { + ElementA tabindex=1 id="element" { + h1 { "hello" } + } + ElementB tabindex=1 id="element" { + h1 { "hello" } + } + } + .render(); + + let rsx_result = rsx! { + +

hello

+
+ +

hello

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

hello

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

"hello"

+
+
+ + +

"hello"

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

hello

"#; + let expected_result = element_html.repeat(2); + assert_eq!(maud_result.as_inner(), &expected_result); + assert_eq!(rsx_result.as_inner(), &expected_result); +} diff --git a/hypertext/tests/main.rs b/hypertext/tests/main.rs index 9d93de0..34e1bd0 100644 --- a/hypertext/tests/main.rs +++ b/hypertext/tests/main.rs @@ -3,7 +3,9 @@ use std::fmt::{self, Display, Formatter}; -use hypertext::{Buffer, Lazy, Raw, maud_borrow, maud_static, prelude::*, rsx_borrow, rsx_static}; +use hypertext::{ + Buffer, Raw, TypedBuilder, maud_borrow, maud_static, prelude::*, rsx_borrow, rsx_static, +}; #[test] fn readme() { @@ -543,6 +545,7 @@ fn void_elements() { #[test] fn component() { + #[derive(TypedBuilder)] struct Repeater { count: usize, children: R, @@ -749,46 +752,3 @@ fn toggles() { ); assert_eq!(rsx_result.as_inner(), r#""#); } - -#[test] -fn derive_default() { - #[derive(Default)] - struct Element<'a> { - pub id: &'a str, - pub tabindex: u32, - pub children: Lazy, - } - - impl<'a> Renderable for Element<'a> { - fn render_to(&self, buf: &mut Buffer) { - rsx! { -
- (self.children) -
- } - .render_to(buf) - } - } - - let with_children = rsx! { - -

hello

-
- } - .render(); - - assert_eq!( - with_children.as_inner(), - r#"

hello

"# - ); - - let without_children = rsx! { - - } - .render(); - - assert_eq!( - without_children.as_inner(), - r#"
"# - ); -} From 69dd44c8851627edfae205b9768bd89caad0b566 Mon Sep 17 00:00:00 2001 From: XX Date: Sat, 7 Mar 2026 19:16:24 +0300 Subject: [PATCH 02/13] feat: use bon::Builder instead TypedBuilder --- Cargo.lock | 103 ++++++++++++++++------ crates/hypertext-macros/src/renderable.rs | 2 +- crates/hypertext/Cargo.toml | 2 +- crates/hypertext/src/lib.rs | 2 +- crates/hypertext/tests/alloc.rs | 4 +- crates/hypertext/tests/builder.rs | 6 +- 6 files changed, 85 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 494ebd9..9e8445f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,6 +527,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.19.0" @@ -756,6 +781,40 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.4.0" @@ -933,7 +992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1494,6 +1553,7 @@ version = "0.12.1" dependencies = [ "actix-web", "axum-core", + "bon", "html-escape", "hypertext-macros", "itoa", @@ -1503,7 +1563,6 @@ dependencies = [ "ryu", "salvo_core", "tide", - "typed-builder", "warp", ] @@ -1609,6 +1668,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.0.3" @@ -1677,7 +1742,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2815,7 +2880,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3134,7 +3199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3225,6 +3290,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" @@ -3370,7 +3441,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3694,26 +3765,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typed-builder" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34085c17941e36627a879208083e25d357243812c30e7d7387c3b954f30ade16" -dependencies = [ - "typed-builder-macro", -] - -[[package]] -name = "typed-builder-macro" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03ca4cb38206e2bef0700092660bb74d696f808514dae47fa1467cbfe26e96e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "typeid" version = "1.0.3" diff --git a/crates/hypertext-macros/src/renderable.rs b/crates/hypertext-macros/src/renderable.rs index e5f6db8..2aa5c2d 100644 --- a/crates/hypertext-macros/src/renderable.rs +++ b/crates/hypertext-macros/src/renderable.rs @@ -101,7 +101,7 @@ pub fn generate(args: RenderableArgs, mut fn_item: ItemFn) -> syn::Result { count: usize, children: R, diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs index 3fd28e7..13e29a3 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -1,7 +1,7 @@ //! Tests for the `hypertext` crate. #![cfg(feature = "alloc")] -use hypertext::{Buffer, DefaultBuilder, Lazy, TypedBuilder, prelude::*, renderable}; +use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, prelude::*, renderable}; #[test] #[allow(clippy::too_many_lines)] @@ -19,7 +19,7 @@ fn default() { } } - #[renderable(builder = TypedBuilder, attrs = [builder])] + #[renderable(builder = Builder, attrs = [builder])] fn element_b<'a>( #[builder(default)] id: &'a str, #[builder(default = 1)] tabindex: u32, @@ -294,7 +294,7 @@ fn custom() { } } - #[derive(TypedBuilder)] + #[derive(Builder)] struct ElementB<'a> { #[builder(default = "custom")] id: &'a str, From c50b506e0720d277153c673dab5554f698f6d975 Mon Sep 17 00:00:00 2001 From: XX Date: Sat, 7 Mar 2026 19:47:19 +0300 Subject: [PATCH 03/13] chore: add docs for DefaultBuilder --- crates/hypertext/src/macros/mod.rs | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/hypertext/src/macros/mod.rs b/crates/hypertext/src/macros/mod.rs index 64704b9..96d5d1d 100644 --- a/crates/hypertext/src/macros/mod.rs +++ b/crates/hypertext/src/macros/mod.rs @@ -4,6 +4,58 @@ 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) +///
+/// } +/// } +/// ``` +/// It will generate something like this: +/// ```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). From 434cdaa95cf6b6acee35b2b181f95593664b0928 Mon Sep 17 00:00:00 2001 From: XX Date: Sat, 7 Mar 2026 19:51:39 +0300 Subject: [PATCH 04/13] fix: rename element to component in the builder test --- crates/hypertext/tests/builder.rs | 250 +++++++++++++++--------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs index 13e29a3..939ba92 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -7,7 +7,7 @@ use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, prelude::*, renderable}; #[allow(clippy::too_many_lines)] fn default() { #[renderable] - fn element_a<'a>( + fn component_a<'a>( #[builder(default)] id: &'a str, #[builder(default = 1)] tabindex: u32, #[builder(default)] children: Lazy, @@ -20,7 +20,7 @@ fn default() { } #[renderable(builder = Builder, attrs = [builder])] - fn element_b<'a>( + fn component_b<'a>( #[builder(default)] id: &'a str, #[builder(default = 1)] tabindex: u32, #[builder(default)] children: Lazy, @@ -33,7 +33,7 @@ fn default() { } #[renderable(builder = DefaultBuilder)] - fn element_c<'a>( + fn component_c<'a>( id: &'a str, tabindex: u32, children: Lazy, @@ -45,7 +45,7 @@ fn default() { } } - impl Default for ElementC<'_> { + impl Default for ComponentC<'_> { fn default() -> Self { Self { id: Default::default(), @@ -57,7 +57,7 @@ fn default() { #[renderable(builder = DefaultBuilder)] #[derive(Default)] - fn element_d<'a>( + fn component_d<'a>( id: &'a str, tabindex: u32, children: Lazy, @@ -70,148 +70,148 @@ fn default() { } let maud_result = maud! { - ElementA; - ElementB; - ElementC; - ElementD; + ComponentA; + ComponentB; + ComponentC; + ComponentD; } .render(); let rsx_result = rsx! { - - - - + + + + } .render(); - let element_html = r#"
"#; + let component_html = r#"
"#; let expected_result = format!( r#"{}
"#, - element_html.repeat(3) + component_html.repeat(3) ); assert_eq!(maud_result.as_inner(), &expected_result); assert_eq!(rsx_result.as_inner(), &expected_result); let maud_result = maud! { - ElementA id="test"; - ElementB id="test"; - ElementC id="test"; - ElementD id="test"; + ComponentA id="test"; + ComponentB id="test"; + ComponentC id="test"; + ComponentD id="test"; } .render(); let rsx_result = rsx! { - - - - + + + + } .render(); - let element_html = r#"
"#; + let component_html = r#"
"#; let expected_result = format!( r#"{}
"#, - element_html.repeat(3) + component_html.repeat(3) ); assert_eq!(maud_result.as_inner(), &expected_result); assert_eq!(rsx_result.as_inner(), &expected_result); let maud_result = maud! { - ElementA { + ComponentA { h1 { "hello" } } - ElementB { + ComponentB { h1 { "hello" } } - ElementC { + ComponentC { h1 { "hello" } } - ElementD { + ComponentD { h1 { "hello" } } } .render(); let rsx_result = rsx! { - +

hello

-
- +
+

hello

-
- +
+

hello

-
- + +

hello

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

hello

"#; + let component_html = r#"

hello

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

hello

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

hello

-
- + +

hello

-
- + +

hello

-
- + +

hello

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

hello

"#; - let expected_result = element_html.repeat(4); + 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! { - ElementA { - ElementA id="nested" { + ComponentA { + ComponentA id="nested" { h1 { "hello" } } } - ElementB { - ElementB id="nested" { + ComponentB { + ComponentB id="nested" { h1 { "hello" } } } - ElementC { - ElementC id="nested" { + ComponentC { + ComponentC id="nested" { h1 { "hello" } } } - ElementD { - ElementD id="nested" { + ComponentD { + ComponentD id="nested" { h1 { "hello" } } } @@ -219,34 +219,34 @@ fn default() { .render(); let rsx_result = rsx! { - - + +

"hello"

-
-
- - + + + +

"hello"

-
-
- - + + + +

"hello"

-
-
- - + + + +

"hello"

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

hello

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

hello

"#, - element_html.repeat(3) + component_html.repeat(3) ); assert_eq!(maud_result.as_inner(), &expected_result); assert_eq!(rsx_result.as_inner(), &expected_result); @@ -256,7 +256,7 @@ fn default() { #[allow(clippy::too_many_lines)] fn custom() { #[renderable(builder = false)] - fn element_a<'a>( + fn component_a<'a>( id: &'a str, tabindex: u32, children: Lazy, @@ -268,7 +268,7 @@ fn custom() { } } - impl<'a> ElementA<'a> { + impl<'a> ComponentA<'a> { fn builder() -> Self { Self { id: "custom", @@ -295,7 +295,7 @@ fn custom() { } #[derive(Builder)] - struct ElementB<'a> { + struct ComponentB<'a> { #[builder(default = "custom")] id: &'a str, @@ -306,7 +306,7 @@ fn custom() { children: Lazy, } - impl Renderable for ElementB<'_> { + impl Renderable for ComponentB<'_> { fn render_to(&self, buf: &mut Buffer) { rsx! {
@@ -318,97 +318,97 @@ fn custom() { } let maud_result = maud! { - ElementA; - ElementB; + ComponentA; + ComponentB; } .render(); let rsx_result = rsx! { - - + + } .render(); - let element_html = r#"
"#; - let expected_result = element_html.repeat(2); + 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! { - ElementA id="test"; - ElementB id="test"; + ComponentA id="test"; + ComponentB id="test"; } .render(); let rsx_result = rsx! { - - + + } .render(); - let element_html = r#"
"#; - let expected_result = element_html.repeat(2); + 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! { - ElementA { + ComponentA { h1 { "hello" } } - ElementB { + ComponentB { h1 { "hello" } } } .render(); let rsx_result = rsx! { - +

hello

-
- +
+

hello

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

hello

"#; - let expected_result = element_html.repeat(2); + 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! { - ElementA tabindex=1 id="element" { + ComponentA tabindex=1 id="component" { h1 { "hello" } } - ElementB tabindex=1 id="element" { + ComponentB tabindex=1 id="component" { h1 { "hello" } } } .render(); let rsx_result = rsx! { - +

hello

-
- +
+

hello

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

hello

"#; - let expected_result = element_html.repeat(2); + 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! { - ElementA { - ElementA id="nested" { + ComponentA { + ComponentA id="nested" { h1 { "hello" } } } - ElementB { - ElementB id="nested" { + ComponentB { + ComponentB id="nested" { h1 { "hello" } } } @@ -416,22 +416,22 @@ fn custom() { .render(); let rsx_result = rsx! { - - + +

"hello"

-
-
- - + + + +

"hello"

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

hello

"#; - let expected_result = element_html.repeat(2); + let expected_result = component_html.repeat(2); assert_eq!(maud_result.as_inner(), &expected_result); assert_eq!(rsx_result.as_inner(), &expected_result); } From 255cd5de5af5aa69ad60b8d2c7edc1a49cb03aae Mon Sep 17 00:00:00 2001 From: XX Date: Sat, 7 Mar 2026 19:54:00 +0300 Subject: [PATCH 05/13] chore: remove .orig file --- .../src/html/component.rs.orig | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 crates/hypertext-macros/src/html/component.rs.orig diff --git a/crates/hypertext-macros/src/html/component.rs.orig b/crates/hypertext-macros/src/html/component.rs.orig deleted file mode 100644 index 3a1310d..0000000 --- a/crates/hypertext-macros/src/html/component.rs.orig +++ /dev/null @@ -1,131 +0,0 @@ -use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; -use syn::{ - Ident, Lit, Token, - parse::{Parse, ParseStream}, - token::{Brace, Paren}, -}; - -use super::{ElementBody, Generate, Generator, ParenExpr, Syntax}; -use crate::{AttributeValue, html::Node}; - -pub struct Component { - pub name: Ident, - pub attrs: Vec, - pub body: ElementBody, -} - -impl Generate for Component { - type Context = Node; - - fn generate(&self, g: &mut Generator) { - let props = self.attrs.iter().map(|attr| { - let name = &attr.name; -<<<<<<< HEAD:hypertext-macros/src/html/component.rs - let value = &attr.value_expr(); - - quote!(.#name(#value)) -======= - attr.value_expr() - .map_or_else(|| quote!(#name,), |value| quote!(#name: #value,)) ->>>>>>> upstream/main:crates/hypertext-macros/src/html/component.rs - }); - - 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 children_ident = Ident::new("children", self.name.span()); - - quote!( - .#children_ident(#lazy) - ) - } - ElementBody::Void => quote!(), - }; - - let name = &self.name; - - let init = quote! { - #name::builder() - #(#props)* - #children - .build() - }; - - g.push_expr::(Paren::default(), &init); - } -} - -pub struct ComponentAttribute { - name: Ident, - value: Option, -} - -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); - }); - - tokens - } - }) - } -} - -impl Parse for ComponentAttribute { - fn parse(input: ParseStream) -> syn::Result { - Ok(Self { - name: input.parse()?, - value: { - if input.peek(Token![=]) { - input.parse::()?; - - Some(input.parse()?) - } else { - None - } - }, - }) - } -} - -pub enum ComponentAttributeValue { - Literal(Lit), - Ident(Ident), - Expr(ParenExpr), -} - -impl Parse for ComponentAttributeValue { - 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()) - } - } -} From e1d49e2c2e74ca2856f841dd9221bc117ba82831 Mon Sep 17 00:00:00 2001 From: Vidhan Bhatt Date: Sat, 7 Mar 2026 13:13:21 -0500 Subject: [PATCH 06/13] fully declare `default()` call --- crates/hypertext-macros/src/derive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/hypertext-macros/src/derive.rs b/crates/hypertext-macros/src/derive.rs index c840fb1..62c4220 100644 --- a/crates/hypertext-macros/src/derive.rs +++ b/crates/hypertext-macros/src/derive.rs @@ -183,7 +183,7 @@ pub fn default_builder(input: DeriveInput) -> syn::Result { #[automatically_derived] impl #impl_generics #struct_name #ty_generics #where_clause { #vis fn builder() -> Self { - Self::default() + ::default() } #vis fn build(self) -> Self { From cb9c85b182977c7baa7934f788342d4c322fd40f Mon Sep 17 00:00:00 2001 From: Vidhan Bhatt Date: Sat, 7 Mar 2026 13:20:35 -0500 Subject: [PATCH 07/13] remove `derive(Renderable)` --- crates/hypertext-macros/src/derive.rs | 133 +--------------------- crates/hypertext-macros/src/lib.rs | 7 -- crates/hypertext/src/macros/renderable.rs | 88 -------------- crates/hypertext/src/renderable/mod.rs | 28 ----- 4 files changed, 1 insertion(+), 255 deletions(-) diff --git a/crates/hypertext-macros/src/derive.rs b/crates/hypertext-macros/src/derive.rs index 62c4220..59c16dc 100644 --- a/crates/hypertext-macros/src/derive.rs +++ b/crates/hypertext-macros/src/derive.rs @@ -1,138 +1,7 @@ -use proc_macro2::{Span, TokenStream}; +use proc_macro2::TokenStream; use quote::quote; use syn::{Data, DeriveInput, Error, spanned::Spanned}; -use crate::{ - AttributeValue, Config, Document, Many, Maud, Rsx, Semantics, - html::{Context, generate::Generator}, -}; - -#[allow(clippy::needless_pass_by_value)] -pub fn renderable(input: DeriveInput) -> syn::Result { - match (renderable_node(&input), renderable_attribute(&input)) { - (Ok(None), Ok(None)) => Err(Error::new( - Span::call_site(), - "expected at least one of `#[maud(...)]`, `#[rsx(...)]`, or `#[attribute(...)]`", - )), - (Ok(element), Ok(attribute)) => Ok(quote! { - #element - #attribute - }), - (Ok(_), Err(e)) | (Err(e), Ok(_)) => Err(e), - (Err(mut e1), Err(e2)) => { - e1.combine(e2); - Err(e1) - } - } -} - -fn renderable_node(input: &DeriveInput) -> syn::Result> { - let mut attrs = input - .attrs - .iter() - .filter_map(|attr| { - if attr.path().is_ident("maud") { - Some(( - attr, - (|tokens| Config::Lazy(Semantics::Move).generate::>(tokens)) - as fn(_) -> _, - )) - } else if attr.path().is_ident("rsx") { - Some(( - attr, - (|tokens| Config::Lazy(Semantics::Move).generate::>(tokens)) - as fn(_) -> _, - )) - } else { - None - } - }) - .peekable(); - - let (generate_fn, tokens) = match (attrs.next(), attrs.peek()) { - (Some((attr, f)), None) => (f, attr.meta.require_list()?.tokens.clone()), - (Some((attr, _)), Some(_)) => { - let mut error = Error::new( - attr.span(), - "cannot have multiple `#[maud(...)]` or `#[rsx(...)]` attributes", - ); - for (attr, _) in attrs { - error.combine(Error::new( - attr.span(), - "cannot have multiple `#[maud(...)]` or `#[rsx(...)]` attributes", - )); - } - return Err(error); - } - (None, _) => { - return Ok(None); - } - }; - - let lazy = generate_fn(tokens)?; - - let name = &input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - let buffer_ident = Generator::buffer_ident(); - let output = quote! { - #[automatically_derived] - impl #impl_generics ::hypertext::Renderable for #name #ty_generics #where_clause { - fn render_to(&self, #buffer_ident: &mut ::hypertext::Buffer) { - #buffer_ident.push(#lazy); - } - } - }; - Ok(Some(output)) -} - -fn renderable_attribute(input: &DeriveInput) -> syn::Result> { - let mut attrs = input - .attrs - .iter() - .filter(|attr| attr.path().is_ident("attribute")) - .peekable(); - - let tokens = match (attrs.next(), attrs.peek()) { - (Some(attr), None) => attr.meta.require_list()?.tokens.clone(), - (Some(_), Some(_)) => { - let mut error = Error::new( - Span::call_site(), - "cannot have multiple `#[attribute(...)]` attributes", - ); - for attr in attrs { - error.combine(Error::new( - attr.span(), - "cannot have multiple `#[attribute(...)]` attributes", - )); - } - return Err(error); - } - (None, _) => { - return Ok(None); - } - }; - - let lazy = Config::Lazy(Semantics::Move).generate::>(tokens)?; - let name = &input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - let buffer_ident = Generator::buffer_ident(); - let ctx = AttributeValue::marker_type(); - let output = quote! { - #[automatically_derived] - impl #impl_generics ::hypertext::Renderable<#ctx> for #name #ty_generics - #where_clause { - fn render_to( - &self, - #buffer_ident: &mut ::hypertext::AttributeBuffer, - ) { - #buffer_ident.push(#lazy); - } - } - }; - - 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 { diff --git a/crates/hypertext-macros/src/lib.rs b/crates/hypertext-macros/src/lib.rs index 970e224..ce3de2e 100644 --- a/crates/hypertext-macros/src/lib.rs +++ b/crates/hypertext-macros/src/lib.rs @@ -63,13 +63,6 @@ create_variants! { } } -#[proc_macro_derive(Renderable, attributes(maud, rsx, attribute))] -pub fn derive_renderable(input: TokenStream) -> TokenStream { - derive::renderable(parse_macro_input!(input)) - .unwrap_or_else(|err| err.to_compile_error()) - .into() -} - #[proc_macro_attribute] pub fn renderable(attr: TokenStream, item: TokenStream) -> TokenStream { renderable::generate(parse_macro_input!(attr), parse_macro_input!(item)) diff --git a/crates/hypertext/src/macros/renderable.rs b/crates/hypertext/src/macros/renderable.rs index b1c90c1..5d84635 100644 --- a/crates/hypertext/src/macros/renderable.rs +++ b/crates/hypertext/src/macros/renderable.rs @@ -1,93 +1,5 @@ #![expect(clippy::doc_markdown)] -/// Derives [`Renderable`](crate::Renderable) for a type. -/// -/// This is used in conjunction with `#[maud]`/`#[rsx]`, as well as -/// `#[attribute]`. -/// -/// # Examples -/// -/// ## `#[maud(...)]` -/// -/// Derives [`Renderable`](crate::Renderable) via the contents of -/// `#[maud(...)]`, which will be interpreted as input to -/// [`maud!`](crate::maud!). -/// -/// This is mutually exclusive with `#[rsx(...)]`. -/// -/// ``` -/// use hypertext::prelude::*; -/// -/// #[derive(Renderable)] -/// #[maud(span { "My name is " (self.name) "!" })] -/// pub struct Person { -/// name: String, -/// } -/// -/// assert_eq!( -/// maud! { div { (Person { name: "Alice".into() }) } } -/// .render() -/// .as_inner(), -/// "
My name is Alice!
" -/// ); -/// ``` -/// -/// ## `#[rsx(...)]` -/// -/// Derives [`Renderable`](crate::Renderable) via the contents of `#[rsx(...)]`, -/// which will be interpreted as input to [`rsx!`](crate::rsx!). -/// -/// This is mutually exclusive with `#[maud(...)]`. -/// -/// ``` -/// use hypertext::prelude::*; -/// -/// #[derive(Renderable)] -/// #[rsx( -/// "My name is " (self.name) "!" -/// )] -/// pub struct Person { -/// name: String, -/// } -/// -/// assert_eq!( -/// rsx! {
(Person { name: "Alice".into() })
} -/// .render() -/// .as_inner(), -/// "
My name is Alice!
" -/// ); -/// ``` -/// -/// ## `#[attribute(...)]` -/// -/// Derives [`Renderable`](crate::Renderable) -/// via the contents of `#[attribute(...)]`, which will be interpreted as input -/// to [`attribute!`](crate::attribute!). -/// -/// This can be used in conjunction with `#[rsx]`/`#[maud]`, as this will -/// derive the [`Renderable`](crate::Renderable) implementation, -/// whereas `#[maud(...)]`/`#[rsx(...)]` will derive the -/// [`Renderable`](crate::Renderable) implementation. -/// -/// ``` -/// use hypertext::prelude::*; -/// -/// #[derive(Renderable)] -/// #[attribute((self.x) "," (self.y))] -/// pub struct Coordinates { -/// x: i32, -/// y: i32, -/// } -/// -/// assert_eq!( -/// maud! { div title=(Coordinates { x: 10, y: 20 }) { "Location" } } -/// .render() -/// .as_inner(), -/// r#"
Location
"# -/// ); -/// ``` -#[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 /// struct that implements [`Renderable`](crate::Renderable). /// diff --git a/crates/hypertext/src/renderable/mod.rs b/crates/hypertext/src/renderable/mod.rs index 59b5e11..43ef1e6 100644 --- a/crates/hypertext/src/renderable/mod.rs +++ b/crates/hypertext/src/renderable/mod.rs @@ -66,34 +66,6 @@ use crate::{ /// ); /// ``` /// -/// ### [`#[derive(Renderable)]`](derive@crate::Renderable) -/// -/// ``` -/// use hypertext::prelude::*; -/// -/// #[derive(Renderable)] -/// #[maud( -/// div { -/// h1 { (self.name) } -/// p { "Age: " (self.age) } -/// } -/// )] -/// struct Person { -/// name: String, -/// age: u8, -/// } -/// -/// let person = Person { -/// name: "Alice".into(), -/// age: 20, -/// }; -/// -/// assert_eq!( -/// maud! { main { (person) } }.render().as_inner(), -/// "

Alice

Age: 20

", -/// ); -/// ``` -/// /// ### [`#[renderable]`](crate::renderable) /// /// ``` From f60efbfe5ba35fbe42c031523e722926c765be32 Mon Sep 17 00:00:00 2001 From: Vidhan Bhatt Date: Sat, 7 Mar 2026 13:24:26 -0500 Subject: [PATCH 08/13] reword --- crates/hypertext/src/macros/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/hypertext/src/macros/mod.rs b/crates/hypertext/src/macros/mod.rs index 96d5d1d..0833a8f 100644 --- a/crates/hypertext/src/macros/mod.rs +++ b/crates/hypertext/src/macros/mod.rs @@ -26,7 +26,9 @@ pub mod rsx; /// } /// } /// ``` -/// It will generate something like this: +/// +/// Expands to: +/// /// ```ignore /// impl<'a> Component<'a> { /// fn builder() -> Self { From 100125d18828cc8baf42ee98bb0af6d32f25aeb2 Mon Sep 17 00:00:00 2001 From: XX Date: Sat, 7 Mar 2026 21:37:51 +0300 Subject: [PATCH 09/13] fix: use expect instead allow attr and change error msg --- crates/hypertext-macros/src/derive.rs | 2 +- crates/hypertext-macros/src/renderable.rs | 2 +- crates/hypertext/tests/builder.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/hypertext-macros/src/derive.rs b/crates/hypertext-macros/src/derive.rs index 59c16dc..097aa4d 100644 --- a/crates/hypertext-macros/src/derive.rs +++ b/crates/hypertext-macros/src/derive.rs @@ -31,7 +31,7 @@ pub fn default_builder(input: DeriveInput) -> syn::Result { return Ok(()); } - Err(meta.error("unrecognized builder")) + Err(meta.error("unexpected param for `#[builder(...)]`")) }) .map(|()| true) })?; diff --git a/crates/hypertext-macros/src/renderable.rs b/crates/hypertext-macros/src/renderable.rs index 2aa5c2d..de9ae65 100644 --- a/crates/hypertext-macros/src/renderable.rs +++ b/crates/hypertext-macros/src/renderable.rs @@ -86,7 +86,7 @@ impl Parse for RenderableArgs { } } -#[allow(clippy::too_many_lines)] +#[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(); diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs index 939ba92..37e59b1 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -4,7 +4,7 @@ use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, prelude::*, renderable}; #[test] -#[allow(clippy::too_many_lines)] +#[expect(clippy::too_many_lines)] fn default() { #[renderable] fn component_a<'a>( @@ -253,7 +253,7 @@ fn default() { } #[test] -#[allow(clippy::too_many_lines)] +#[expect(clippy::too_many_lines)] fn custom() { #[renderable(builder = false)] fn component_a<'a>( From 6317e0a5e94b860517ad7522b05ce1fbad40f5bf Mon Sep 17 00:00:00 2001 From: Vidhan Bhatt Date: Sat, 7 Mar 2026 15:00:29 -0500 Subject: [PATCH 10/13] Revert "remove `derive(Renderable)`" This reverts commit cb9c85b182977c7baa7934f788342d4c322fd40f. --- crates/hypertext-macros/src/derive.rs | 133 +++++++++++++++++++++- crates/hypertext-macros/src/lib.rs | 7 ++ crates/hypertext/src/macros/renderable.rs | 88 ++++++++++++++ crates/hypertext/src/renderable/mod.rs | 28 +++++ 4 files changed, 255 insertions(+), 1 deletion(-) diff --git a/crates/hypertext-macros/src/derive.rs b/crates/hypertext-macros/src/derive.rs index 097aa4d..68f95a9 100644 --- a/crates/hypertext-macros/src/derive.rs +++ b/crates/hypertext-macros/src/derive.rs @@ -1,7 +1,138 @@ -use proc_macro2::TokenStream; +use proc_macro2::{Span, TokenStream}; use quote::quote; use syn::{Data, DeriveInput, Error, spanned::Spanned}; +use crate::{ + AttributeValue, Config, Document, Many, Maud, Rsx, Semantics, + html::{Context, generate::Generator}, +}; + +#[allow(clippy::needless_pass_by_value)] +pub fn renderable(input: DeriveInput) -> syn::Result { + match (renderable_node(&input), renderable_attribute(&input)) { + (Ok(None), Ok(None)) => Err(Error::new( + Span::call_site(), + "expected at least one of `#[maud(...)]`, `#[rsx(...)]`, or `#[attribute(...)]`", + )), + (Ok(element), Ok(attribute)) => Ok(quote! { + #element + #attribute + }), + (Ok(_), Err(e)) | (Err(e), Ok(_)) => Err(e), + (Err(mut e1), Err(e2)) => { + e1.combine(e2); + Err(e1) + } + } +} + +fn renderable_node(input: &DeriveInput) -> syn::Result> { + let mut attrs = input + .attrs + .iter() + .filter_map(|attr| { + if attr.path().is_ident("maud") { + Some(( + attr, + (|tokens| Config::Lazy(Semantics::Move).generate::>(tokens)) + as fn(_) -> _, + )) + } else if attr.path().is_ident("rsx") { + Some(( + attr, + (|tokens| Config::Lazy(Semantics::Move).generate::>(tokens)) + as fn(_) -> _, + )) + } else { + None + } + }) + .peekable(); + + let (generate_fn, tokens) = match (attrs.next(), attrs.peek()) { + (Some((attr, f)), None) => (f, attr.meta.require_list()?.tokens.clone()), + (Some((attr, _)), Some(_)) => { + let mut error = Error::new( + attr.span(), + "cannot have multiple `#[maud(...)]` or `#[rsx(...)]` attributes", + ); + for (attr, _) in attrs { + error.combine(Error::new( + attr.span(), + "cannot have multiple `#[maud(...)]` or `#[rsx(...)]` attributes", + )); + } + return Err(error); + } + (None, _) => { + return Ok(None); + } + }; + + let lazy = generate_fn(tokens)?; + + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let buffer_ident = Generator::buffer_ident(); + let output = quote! { + #[automatically_derived] + impl #impl_generics ::hypertext::Renderable for #name #ty_generics #where_clause { + fn render_to(&self, #buffer_ident: &mut ::hypertext::Buffer) { + #buffer_ident.push(#lazy); + } + } + }; + Ok(Some(output)) +} + +fn renderable_attribute(input: &DeriveInput) -> syn::Result> { + let mut attrs = input + .attrs + .iter() + .filter(|attr| attr.path().is_ident("attribute")) + .peekable(); + + let tokens = match (attrs.next(), attrs.peek()) { + (Some(attr), None) => attr.meta.require_list()?.tokens.clone(), + (Some(_), Some(_)) => { + let mut error = Error::new( + Span::call_site(), + "cannot have multiple `#[attribute(...)]` attributes", + ); + for attr in attrs { + error.combine(Error::new( + attr.span(), + "cannot have multiple `#[attribute(...)]` attributes", + )); + } + return Err(error); + } + (None, _) => { + return Ok(None); + } + }; + + let lazy = Config::Lazy(Semantics::Move).generate::>(tokens)?; + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let buffer_ident = Generator::buffer_ident(); + let ctx = AttributeValue::marker_type(); + let output = quote! { + #[automatically_derived] + impl #impl_generics ::hypertext::Renderable<#ctx> for #name #ty_generics + #where_clause { + fn render_to( + &self, + #buffer_ident: &mut ::hypertext::AttributeBuffer, + ) { + #buffer_ident.push(#lazy); + } + } + }; + + 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 { diff --git a/crates/hypertext-macros/src/lib.rs b/crates/hypertext-macros/src/lib.rs index ce3de2e..970e224 100644 --- a/crates/hypertext-macros/src/lib.rs +++ b/crates/hypertext-macros/src/lib.rs @@ -63,6 +63,13 @@ create_variants! { } } +#[proc_macro_derive(Renderable, attributes(maud, rsx, attribute))] +pub fn derive_renderable(input: TokenStream) -> TokenStream { + derive::renderable(parse_macro_input!(input)) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + #[proc_macro_attribute] pub fn renderable(attr: TokenStream, item: TokenStream) -> TokenStream { renderable::generate(parse_macro_input!(attr), parse_macro_input!(item)) diff --git a/crates/hypertext/src/macros/renderable.rs b/crates/hypertext/src/macros/renderable.rs index 5d84635..b1c90c1 100644 --- a/crates/hypertext/src/macros/renderable.rs +++ b/crates/hypertext/src/macros/renderable.rs @@ -1,5 +1,93 @@ #![expect(clippy::doc_markdown)] +/// Derives [`Renderable`](crate::Renderable) for a type. +/// +/// This is used in conjunction with `#[maud]`/`#[rsx]`, as well as +/// `#[attribute]`. +/// +/// # Examples +/// +/// ## `#[maud(...)]` +/// +/// Derives [`Renderable`](crate::Renderable) via the contents of +/// `#[maud(...)]`, which will be interpreted as input to +/// [`maud!`](crate::maud!). +/// +/// This is mutually exclusive with `#[rsx(...)]`. +/// +/// ``` +/// use hypertext::prelude::*; +/// +/// #[derive(Renderable)] +/// #[maud(span { "My name is " (self.name) "!" })] +/// pub struct Person { +/// name: String, +/// } +/// +/// assert_eq!( +/// maud! { div { (Person { name: "Alice".into() }) } } +/// .render() +/// .as_inner(), +/// "
My name is Alice!
" +/// ); +/// ``` +/// +/// ## `#[rsx(...)]` +/// +/// Derives [`Renderable`](crate::Renderable) via the contents of `#[rsx(...)]`, +/// which will be interpreted as input to [`rsx!`](crate::rsx!). +/// +/// This is mutually exclusive with `#[maud(...)]`. +/// +/// ``` +/// use hypertext::prelude::*; +/// +/// #[derive(Renderable)] +/// #[rsx( +/// "My name is " (self.name) "!" +/// )] +/// pub struct Person { +/// name: String, +/// } +/// +/// assert_eq!( +/// rsx! {
(Person { name: "Alice".into() })
} +/// .render() +/// .as_inner(), +/// "
My name is Alice!
" +/// ); +/// ``` +/// +/// ## `#[attribute(...)]` +/// +/// Derives [`Renderable`](crate::Renderable) +/// via the contents of `#[attribute(...)]`, which will be interpreted as input +/// to [`attribute!`](crate::attribute!). +/// +/// This can be used in conjunction with `#[rsx]`/`#[maud]`, as this will +/// derive the [`Renderable`](crate::Renderable) implementation, +/// whereas `#[maud(...)]`/`#[rsx(...)]` will derive the +/// [`Renderable`](crate::Renderable) implementation. +/// +/// ``` +/// use hypertext::prelude::*; +/// +/// #[derive(Renderable)] +/// #[attribute((self.x) "," (self.y))] +/// pub struct Coordinates { +/// x: i32, +/// y: i32, +/// } +/// +/// assert_eq!( +/// maud! { div title=(Coordinates { x: 10, y: 20 }) { "Location" } } +/// .render() +/// .as_inner(), +/// r#"
Location
"# +/// ); +/// ``` +#[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 /// struct that implements [`Renderable`](crate::Renderable). /// diff --git a/crates/hypertext/src/renderable/mod.rs b/crates/hypertext/src/renderable/mod.rs index 43ef1e6..59b5e11 100644 --- a/crates/hypertext/src/renderable/mod.rs +++ b/crates/hypertext/src/renderable/mod.rs @@ -66,6 +66,34 @@ use crate::{ /// ); /// ``` /// +/// ### [`#[derive(Renderable)]`](derive@crate::Renderable) +/// +/// ``` +/// use hypertext::prelude::*; +/// +/// #[derive(Renderable)] +/// #[maud( +/// div { +/// h1 { (self.name) } +/// p { "Age: " (self.age) } +/// } +/// )] +/// struct Person { +/// name: String, +/// age: u8, +/// } +/// +/// let person = Person { +/// name: "Alice".into(), +/// age: 20, +/// }; +/// +/// assert_eq!( +/// maud! { main { (person) } }.render().as_inner(), +/// "

Alice

Age: 20

", +/// ); +/// ``` +/// /// ### [`#[renderable]`](crate::renderable) /// /// ``` From 78cbc02e16248b9a101f68bf79af0b297d6c912c Mon Sep 17 00:00:00 2001 From: Vidhan Bhatt Date: Sat, 7 Mar 2026 17:37:11 -0500 Subject: [PATCH 11/13] feat: add renderable + builder examples --- crates/hypertext/src/macros/renderable.rs | 99 +++++++++++++++++++++++ crates/hypertext/tests/builder.rs | 98 +++++++++++++++++++++- 2 files changed, 196 insertions(+), 1 deletion(-) 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/tests/builder.rs b/crates/hypertext/tests/builder.rs index 37e59b1..2aa098c 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -1,7 +1,7 @@ //! Tests for the `hypertext` crate. #![cfg(feature = "alloc")] -use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, prelude::*, renderable}; +use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, Renderable, prelude::*, renderable}; #[test] #[expect(clippy::too_many_lines)] @@ -435,3 +435,99 @@ fn custom() { 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); +} From 9b19bc1bb9d069d30a22a624f80f864a16edba70 Mon Sep 17 00:00:00 2001 From: XX Date: Sun, 8 Mar 2026 13:28:10 +0300 Subject: [PATCH 12/13] feat: use fn_attrs instead attrs param of the renderable attribute --- crates/hypertext-macros/src/renderable.rs | 68 ++++++++++------------- crates/hypertext/tests/builder.rs | 2 +- 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/crates/hypertext-macros/src/renderable.rs b/crates/hypertext-macros/src/renderable.rs index de9ae65..acee977 100644 --- a/crates/hypertext-macros/src/renderable.rs +++ b/crates/hypertext-macros/src/renderable.rs @@ -38,7 +38,7 @@ pub struct RenderableArgs { visibility: Visibility, ident: Option, builder: Option, - attrs: Option>, + fn_attrs: Vec, } impl Parse for RenderableArgs { @@ -46,30 +46,27 @@ impl Parse for RenderableArgs { let mut visibility = Visibility::Inherited; let mut ident = None; let mut builder = None; - let mut attrs: Option> = None; + let mut fn_attrs = Vec::new(); while !input.is_empty() { - if input.peek(Ident) && input.peek2(Token![=]) { - let ident = input.fork().parse::()?; - if ident == "attrs" { - let _attrs = input.parse::()?; - - input.parse::()?; - - let content; - syn::bracketed!(content in input); - - attrs - .get_or_insert_default() - .extend(content.parse_terminated(Path::parse, Token![,])?); - } else { + 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 { + } else if input.peek(Token![pub]) { visibility = input.parse()?; - if input.peek(Ident) { - ident = Some(input.parse()?); - } + } else { + return Err(Error::new(input.span(), "unexpected attribute parameter")); } if input.peek(Token![,]) { @@ -81,7 +78,7 @@ impl Parse for RenderableArgs { visibility, ident, builder, - attrs, + fn_attrs, }) } } @@ -91,21 +88,6 @@ pub fn generate(args: RenderableArgs, mut fn_item: ItemFn) -> syn::Result syn::Result>(); fields.push(quote! { @@ -162,12 +144,18 @@ pub fn generate(args: RenderableArgs, mut 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)] diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs index 2aa098c..9f8313d 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -19,7 +19,7 @@ fn default() { } } - #[renderable(builder = Builder, attrs = [builder])] + #[renderable(builder = Builder)] fn component_b<'a>( #[builder(default)] id: &'a str, #[builder(default = 1)] tabindex: u32, From bd71e070784898d5eadf1375183179e71980b5ef Mon Sep 17 00:00:00 2001 From: XX Date: Sun, 8 Mar 2026 20:03:22 +0300 Subject: [PATCH 13/13] feat: add children test --- crates/hypertext/tests/builder.rs | 88 +++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/hypertext/tests/builder.rs b/crates/hypertext/tests/builder.rs index 9f8313d..6d6b5ec 100644 --- a/crates/hypertext/tests/builder.rs +++ b/crates/hypertext/tests/builder.rs @@ -436,6 +436,94 @@ fn custom() { 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() {