Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/hypertext-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ categories.workspace = true
[lib]
proc-macro = true

[features]
children-move = []

[dependencies]
html-escape.workspace = true
proc-macro2 = "1"
Expand Down
97 changes: 96 additions & 1 deletion crates/hypertext-macros/src/html/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,109 @@ use proc_macro2::TokenStream;
use quote::{ToTokens, quote};
use syn::{
Ident, Lit, Token,
ext::IdentExt,
parse::{Parse, ParseStream},
token::{Brace, Paren},
};

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<fn(&mut Buffer)>`.
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<Option<Self>> {
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<S: Syntax> {
pub name: Ident,
pub attrs: Vec<ComponentAttribute>,
pub children_mode: Option<ChildrenMode>,
pub body: ElementBody<S>,
}

impl<S: Syntax> Component<S> {
/// 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<ComponentAttribute>,
children_mode: Option<ChildrenMode>,
body: ElementBody<S>,
) -> syn::Result<Self> {
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<S: Syntax> Generate for Component<S> {
type Context = Node<S>;

Expand All @@ -41,9 +131,14 @@ impl<S: Syntax> Generate for Component<S> {
};

let children_ident = Ident::new("children", self.name.span());
let ampersand = if ChildrenMode::is_move(self.children_mode.as_ref()) {
None
} else {
Some(<Token![&]>::default())
};

quote!(
.#children_ident(#lazy)
.#children_ident(#ampersand #lazy)
)
}
ElementBody::Void { .. } => quote!(),
Expand Down
2 changes: 1 addition & 1 deletion crates/hypertext-macros/src/html/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 15 additions & 13 deletions crates/hypertext-macros/src/html/syntaxes/maud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,18 +144,20 @@ impl Parse for ElementBody<Maud> {

impl Parse for Component<Maud> {
fn parse(input: ParseStream) -> syn::Result<Self> {
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()?)
}
}
40 changes: 26 additions & 14 deletions crates/hypertext-macros/src/html/syntaxes/rsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,38 +24,47 @@ impl Node<Rsx> {
let name = input.parse::<Ident>()?;

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()?);
}

let solidus = input.parse::<Option<Token![/]>>()?;
input.parse::<Token![>]>()?;

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();

while !(input.peek(Token![<]) && input.peek2(Token![/])) {
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))));
Expand All @@ -73,25 +82,28 @@ impl Node<Rsx> {
} 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::<Token![>]>()?;

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)
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/hypertext/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
65 changes: 65 additions & 0 deletions crates/hypertext/src/macros/renderable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,70 @@ pub use hypertext_macros::Renderable;
/// "<div><nav><h1>My Nav Bar</h1><h2>My Subtitle</h2><span>:)</span></nav></div>"
/// );
/// ```
///
/// # 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<fn(&mut Buffer)>`, 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(),
/// "<div><span>borrowed</span></div>"
/// );
/// ```
///
/// ```
/// use hypertext::{Buffer, DefaultBuilder, Lazy, prelude::*};
///
/// #[renderable(builder = DefaultBuilder)]
/// #[derive(Default)]
/// fn by_move(children: Lazy<fn(&mut Buffer)>) -> impl Renderable {
/// maud! { div { (children) } }
/// }
///
/// assert_eq!(
/// rsx! {
/// <ByMove move>
/// <span>"owned"</span>
/// </ByMove>
/// }
/// .render()
/// .as_inner(),
/// "<div><span>owned</span></div>"
/// );
/// ```
///
/// 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;
Loading