diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc7cc1..bd411f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ref`/`move` marker as the last attribute of a component to choose whether its + children block is passed by reference (`&Lazy<_>`) or by value (`Lazy<_>`). +- `children-move` feature to make passing children by value the default when no + `ref`/`move` marker is given. + ## [0.12.1](https://github.com/vidhanio/hypertext/compare/hypertext-v0.12.0...hypertext-v0.12.1) - 2025-08-09 ### Other diff --git a/crates/hypertext-macros/Cargo.toml b/crates/hypertext-macros/Cargo.toml index e976ab3..9895db3 100644 --- a/crates/hypertext-macros/Cargo.toml +++ b/crates/hypertext-macros/Cargo.toml @@ -14,6 +14,9 @@ categories.workspace = true [lib] proc-macro = true +[features] +children-move = [] + [dependencies] html-escape.workspace = true proc-macro2 = "1" diff --git a/crates/hypertext-macros/src/html/component.rs b/crates/hypertext-macros/src/html/component.rs index 071810b..5dc9ec1 100644 --- a/crates/hypertext-macros/src/html/component.rs +++ b/crates/hypertext-macros/src/html/component.rs @@ -2,6 +2,7 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::{ Ident, Lit, Token, + ext::IdentExt, parse::{Parse, ParseStream}, token::{Brace, Paren}, }; @@ -9,12 +10,101 @@ use syn::{ use super::{AttributeValue, ElementBody, Generate, Generator, ParenExpr, Syntax}; use crate::html::Node; +/// How the children block is handed to a component's `children` setter. +pub enum ChildrenMode { + /// `ref`: pass `&Lazy<_>`, so the component borrows the children. + /// + /// Required by components storing children behind a reference, such as + /// `children: &dyn Renderable`. + Ref(Token![ref]), + + /// `move`: pass `Lazy<_>` by value, so the component owns it. + /// + /// Required by components storing children by value, such as + /// `children: Lazy`. + Move(Token![move]), +} + +impl ChildrenMode { + /// Whether children are passed by value, falling back to the + /// `children-move` feature when the call site does not say. + const fn is_move(this: Option<&Self>) -> bool { + match this { + Some(Self::Move(_)) => true, + Some(Self::Ref(_)) => false, + None => cfg!(feature = "children-move"), + } + } + + /// Parses a trailing `ref`/`move` marker from a component's attribute + /// list. + /// + /// Both are Rust keywords, so they can never collide with an attribute + /// name: a struct field cannot be called `ref` or `move` either. + pub fn parse_opt(input: ParseStream) -> syn::Result> { + let mode = if input.peek(Token![ref]) { + Self::Ref(input.parse()?) + } else if input.peek(Token![move]) { + Self::Move(input.parse()?) + } else { + return Ok(None); + }; + + if input.peek(Ident::peek_any) || input.peek(Token![ref]) || input.peek(Token![move]) { + return Err(input.error(format!( + "`{}` must be the last attribute of the component", + mode.as_str() + ))); + } + + Ok(Some(mode)) + } + + const fn as_str(&self) -> &'static str { + match self { + Self::Ref(_) => "ref", + Self::Move(_) => "move", + } + } +} + pub struct Component { pub name: Ident, pub attrs: Vec, + pub children_mode: Option, pub body: ElementBody, } +impl Component { + /// Creates a component, rejecting a `ref`/`move` marker on a component + /// that has no children block for it to apply to. + pub fn new( + name: Ident, + attrs: Vec, + children_mode: Option, + body: ElementBody, + ) -> syn::Result { + if let (Some(mode), ElementBody::Void { .. }) = (&children_mode, &body) { + let span = match mode { + ChildrenMode::Ref(token) => token.span, + ChildrenMode::Move(token) => token.span, + }; + + return Err(syn::Error::new( + span, + format!("`{}` requires a children block to apply to", mode.as_str()), + )); + } + + Ok(Self { + name, + attrs, + children_mode, + body, + }) + } +} + impl Generate for Component { type Context = Node; @@ -41,9 +131,14 @@ impl Generate for Component { }; let children_ident = Ident::new("children", self.name.span()); + let ampersand = if ChildrenMode::is_move(self.children_mode.as_ref()) { + None + } else { + Some(::default()) + }; quote!( - .#children_ident(#lazy) + .#children_ident(#ampersand #lazy) ) } ElementBody::Void { .. } => quote!(), diff --git a/crates/hypertext-macros/src/html/mod.rs b/crates/hypertext-macros/src/html/mod.rs index e698758..df832a8 100644 --- a/crates/hypertext-macros/src/html/mod.rs +++ b/crates/hypertext-macros/src/html/mod.rs @@ -23,7 +23,7 @@ use syn::{ pub use self::syntaxes::{Maud, Rsx}; use self::{ basics::{Literal, UnquotedName}, - component::Component, + component::{ChildrenMode, Component}, control::Control, generate::{ AnyBlock, AttributeCheck, AttributeCheckKind, ElementCheck, ElementKind, Generate, diff --git a/crates/hypertext-macros/src/html/syntaxes/maud.rs b/crates/hypertext-macros/src/html/syntaxes/maud.rs index 47ff709..8039e87 100644 --- a/crates/hypertext-macros/src/html/syntaxes/maud.rs +++ b/crates/hypertext-macros/src/html/syntaxes/maud.rs @@ -9,8 +9,8 @@ use syn::{ }; use crate::html::{ - Attribute, Component, Doctype, Element, ElementBody, Group, Node, Syntax, UnquotedName, - XmlDecl, kw, + Attribute, ChildrenMode, Component, Doctype, Element, ElementBody, Group, Node, Syntax, + UnquotedName, XmlDecl, kw, }; pub struct Maud; @@ -144,18 +144,20 @@ impl Parse for ElementBody { impl Parse for Component { fn parse(input: ParseStream) -> syn::Result { - Ok(Self { - name: input.parse()?, - attrs: { - let mut attrs = Vec::new(); + let name = input.parse()?; - while !(input.peek(Token![..]) || input.peek(Token![;]) || input.peek(Brace)) { - attrs.push(input.parse()?); - } + let mut attrs = Vec::new(); + let mut children_mode = None; - attrs - }, - body: input.parse()?, - }) + while !(input.peek(Token![..]) || input.peek(Token![;]) || input.peek(Brace)) { + if let Some(mode) = ChildrenMode::parse_opt(input)? { + children_mode = Some(mode); + break; + } + + attrs.push(input.parse()?); + } + + Self::new(name, attrs, children_mode, input.parse()?) } } diff --git a/crates/hypertext-macros/src/html/syntaxes/rsx.rs b/crates/hypertext-macros/src/html/syntaxes/rsx.rs index 79ad4bc..e2c0bf3 100644 --- a/crates/hypertext-macros/src/html/syntaxes/rsx.rs +++ b/crates/hypertext-macros/src/html/syntaxes/rsx.rs @@ -9,8 +9,8 @@ use syn::{ }; use crate::html::{ - Component, Doctype, Element, ElementBody, Group, Literal, Many, Node, Syntax, UnquotedName, - XmlDecl, kw, + ChildrenMode, Component, Doctype, Element, ElementBody, Group, Literal, Many, Node, Syntax, + UnquotedName, XmlDecl, kw, }; pub struct Rsx; @@ -24,12 +24,18 @@ impl Node { let name = input.parse::()?; let mut attrs = Vec::new(); + let mut children_mode = None; #[expect(clippy::suspicious_operation_groupings)] while !(input.peek(Token![..]) || input.peek(Token![>]) || (input.peek(Token![/]) && input.peek2(Token![>]))) { + if let Some(mode) = ChildrenMode::parse_opt(input)? { + children_mode = Some(mode); + break; + } + attrs.push(input.parse()?); } @@ -37,13 +43,15 @@ impl Node { input.parse::]>()?; if let Some(solidus) = solidus { - Ok(Self::Component(Component { + Component::new( name, attrs, - body: ElementBody::Void { + children_mode, + ElementBody::Void { solidus: Some(solidus.span), }, - })) + ) + .map(Self::Component) } else { let mut children = Vec::new(); @@ -51,11 +59,12 @@ impl Node { if input.is_empty() { children.insert( 0, - Self::Component(Component { + Self::Component(Component::new( name, attrs, - body: ElementBody::Void { solidus: None }, - }), + children_mode, + ElementBody::Void { solidus: None }, + )?), ); return Ok(Self::Group(Group(Many(children)))); @@ -73,25 +82,28 @@ impl Node { } else { children.insert( 0, - Self::Component(Component { + Self::Component(Component::new( name, attrs, - body: ElementBody::Void { solidus: None }, - }), + children_mode, + ElementBody::Void { solidus: None }, + )?), ); return Ok(Self::Group(Group(Many(children)))); } input.parse::]>()?; - Ok(Self::Component(Component { + Component::new( name, attrs, - body: ElementBody::Normal { + children_mode, + ElementBody::Normal { children: Many(children), closing_name: Some(parse_quote!(#closing_name)), }, - })) + ) + .map(Self::Component) } } diff --git a/crates/hypertext/Cargo.toml b/crates/hypertext/Cargo.toml index 0580743..ec755a8 100644 --- a/crates/hypertext/Cargo.toml +++ b/crates/hypertext/Cargo.toml @@ -34,6 +34,7 @@ actix-web = ["alloc", "dep:actix-web"] alloc = ["dep:html-escape", "dep:itoa", "dep:ryu"] alpine = [] axum = ["alloc", "dep:axum-core"] +children-move = ["hypertext-macros/children-move"] default = ["alloc"] htmx = [] hyperscript = [] diff --git a/crates/hypertext/src/macros/renderable.rs b/crates/hypertext/src/macros/renderable.rs index 160a104..9439ee5 100644 --- a/crates/hypertext/src/macros/renderable.rs +++ b/crates/hypertext/src/macros/renderable.rs @@ -244,5 +244,70 @@ pub use hypertext_macros::Renderable; /// "
" /// ); /// ``` +/// +/// # Passing children by reference or by value +/// +/// When a component has a `children` parameter and is invoked with a nested +/// block, the block is compiled into a [`Lazy`](crate::Lazy) and handed to the +/// component's `children` setter. It can be passed either **by reference** +/// (`&Lazy<_>`, borrowing the caller's block) or **by value** (`Lazy<_>`, +/// transferring ownership). Which one a component accepts depends on how it +/// stores `children`: +/// +/// | `children` parameter | Needs | +/// |----------------------|-------| +/// | `R`, `&R`, `&dyn Renderable`, other reference types | by reference | +/// | `Lazy`, other owned types | by value | +/// +/// The call site chooses with a trailing `ref` or `move` marker, which must be +/// the **last** attribute of the component: +/// +/// ``` +/// use hypertext::prelude::*; +/// +/// #[renderable] +/// fn by_ref<'a>(children: &'a dyn Renderable) -> impl Renderable { +/// maud! { div { (children) } } +/// } +/// +/// assert_eq!( +/// maud! { +/// ByRef ref { +/// span { "borrowed" } +/// } +/// } +/// .render() +/// .as_inner(), +/// "
borrowed
" +/// ); +/// ``` +/// +/// ``` +/// use hypertext::{Buffer, DefaultBuilder, Lazy, prelude::*}; +/// +/// #[renderable(builder = DefaultBuilder)] +/// #[derive(Default)] +/// fn by_move(children: Lazy) -> impl Renderable { +/// maud! { div { (children) } } +/// } +/// +/// assert_eq!( +/// rsx! { +/// +/// "owned" +/// +/// } +/// .render() +/// .as_inner(), +/// "
owned
" +/// ); +/// ``` +/// +/// If no marker is given, children are passed **by reference** by default. +/// Enabling the `children-move` feature flips the default to **by value**; the +/// markers always override it, so both styles remain usable either way. +/// +/// This mirrors how [`maud!`](crate::maud!) already reads `ref`/`move`: both +/// are Rust keywords, so they can never be confused with an attribute name. #[cfg_attr(all(docsrs, not(doctest)), doc(cfg(feature = "alloc")))] pub use hypertext_macros::renderable; diff --git a/crates/hypertext/tests/components.rs b/crates/hypertext/tests/components.rs index 8d13a49..bd861d4 100644 --- a/crates/hypertext/tests/components.rs +++ b/crates/hypertext/tests/components.rs @@ -1,7 +1,7 @@ //! Component and derive macro tests. #![cfg(feature = "alloc")] -use hypertext::{Builder, prelude::*}; +use hypertext::{Buffer, Builder, DefaultBuilder, Lazy, prelude::*}; #[derive(Builder, Renderable)] #[maud(span { "Hello, " (self.name) "!" })] @@ -703,3 +703,228 @@ fn component_with_loop_over_field() { r#""#, ); } + +#[renderable] +fn layout_dyn<'a>(title: &'a str, children: &'a dyn Renderable) -> impl Renderable { + maud! { + html { + head { title { (title) } } + body { (children) } + } + } +} + +#[test] +fn renderable_function_with_dyn_children_maud() { + let result = maud! { + LayoutDyn title="My Page" ref { + h1 { "Welcome" } + p { "Content" } + } + } + .render(); + + assert_eq!( + result.as_inner(), + "My Page

Welcome

Content

" + ); +} + +#[test] +fn renderable_function_with_dyn_children_rsx() { + let result = rsx! { + +

Welcome

+

Content

+
+ } + .render(); + + assert_eq!( + result.as_inner(), + "My Page

Welcome

Content

" + ); +} + +#[renderable] +fn dyn_slot<'a>(children: &'a dyn Renderable) -> impl Renderable { + maud! { div .slot { (children) } } +} + +#[test] +fn dyn_children_allow_runtime_selection() { + let emphasis = maud! { em { "A" } }; + let strong = maud! { strong { "B" } }; + + for (use_emphasis, expected) in [ + (true, r#"
A
"#), + (false, r#"
B
"#), + ] { + let chosen: &dyn Renderable = if use_emphasis { &emphasis } else { &strong }; + + let result = maud! { DynSlot children=(chosen); }.render(); + assert_eq!(result.as_inner(), expected); + } +} + +#[renderable] +fn owning_slot(children: &R) -> impl Renderable { + maud! { div .owned { (children) } } +} + +#[test] +fn move_children_are_owned_by_the_component_maud() { + let result = maud! { + OwningSlot move { + span { "Alice" } + } + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
Alice
"# + ); +} + +#[test] +fn move_children_are_owned_by_the_component_rsx() { + let result = rsx! { + + "Bob" + + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
Bob
"# + ); +} + +#[test] +fn ref_children_are_borrowed_by_the_component_maud() { + let result = maud! { + OwningSlot ref { + span { "Carol" } + } + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
Carol
"# + ); +} + +#[test] +fn ref_children_are_borrowed_by_the_component_rsx() { + let result = rsx! { + + "David" + + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
David
"# + ); +} + +/// A component storing children by value, which only `move` can satisfy. +#[renderable(builder = DefaultBuilder)] +#[derive(Default)] +fn fn_ptr_slot(children: Lazy) -> impl Renderable { + maud! { div .fn_ptr { (children) } } +} + +#[test] +fn move_children_into_fn_pointer_component() { + let maud_result = maud! { + FnPtrSlot move { + span { "by value" } + } + } + .render(); + + let rsx_result = rsx! { + + "by value" + + } + .render(); + + for result in [maud_result, rsx_result] { + assert_eq!( + result.as_inner(), + r#"
by value
"# + ); + } +} + +/// Children captured by reference, which only `ref` can satisfy. +#[renderable] +fn dyn_ref_slot<'a>(children: &'a dyn Renderable) -> impl Renderable { + maud! { div .dyn_ref { (children) } } +} + +#[test] +fn ref_children_into_dyn_component() { + let maud_result = maud! { + DynRefSlot ref { + span { "by reference" } + } + } + .render(); + + let rsx_result = rsx! { + + "by reference" + + } + .render(); + + for result in [maud_result, rsx_result] { + assert_eq!( + result.as_inner(), + r#"
by reference
"# + ); + } +} + +#[cfg(not(feature = "children-move"))] +#[test] +fn default_children_mode_is_ref() { + // No marker: only compiles because the default passes `&Lazy<_>`, which + // is what `DynRefSlot`'s `&dyn Renderable` requires. + let result = maud! { + DynRefSlot { + span { "default" } + } + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
default
"# + ); +} + +#[cfg(feature = "children-move")] +#[test] +fn default_children_mode_is_move() { + // No marker: only compiles because `children-move` makes the default + // pass `Lazy<_>` by value, which is what `FnPtrSlot` requires. + let result = maud! { + FnPtrSlot { + span { "default" } + } + } + .render(); + + assert_eq!( + result.as_inner(), + r#"
default
"# + ); +}