diff --git a/CHANGELOG.md b/CHANGELOG.md index 073ba41c..b8667e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Update to support axum 0.2 [#303](https://github.com/lambda-fairy/maud/pull/303) +- Add support for `Option` attributes using the `attr=[value]` syntax. + [#306](https://github.com/lambda-fairy/maud/pull/306) ## [0.22.3] - 2021-09-27 diff --git a/docs/content/elements-attributes.md b/docs/content/elements-attributes.md index 868cb250..561e851d 100644 --- a/docs/content/elements-attributes.md +++ b/docs/content/elements-attributes.md @@ -101,6 +101,26 @@ html! { # ; ``` +## Optional attributes: `title=[Some("value")]` + +Add optional attributes to an element using `attr=[value]` syntax, with *square* +brackets. These are only rendered if the value is `Some`, and entirely +omitted if the value is `None`. + +```rust +# let _ = maud:: +html! { + p title=[Some("Good password")] { "Correct horse" } + + @let value = Some(42); + input value=[value]; + + @let title: Option<&str> = None; + p title=[title] { "Battery staple" } +} +# ; +``` + ## Empty attributes: `checked` Declare an empty attribute by omitting the value. diff --git a/maud/tests/basic_syntax.rs b/maud/tests/basic_syntax.rs index e7f73ca1..e72eccf7 100644 --- a/maud/tests/basic_syntax.rs +++ b/maud/tests/basic_syntax.rs @@ -118,6 +118,47 @@ fn empty_attributes_question_mark() { assert_eq!(result.into_string(), ""); } +#[test] +fn optional_attribute_some() { + let result = html! { input value=[Some("value")]; }; + assert_eq!(result.into_string(), r#""#); +} + +#[test] +fn optional_attribute_none() { + let result = html! { input value=[None as Option<&str>]; }; + assert_eq!(result.into_string(), r#""#); +} + +#[test] +fn optional_attribute_non_string_some() { + let result = html! { input value=[Some(42)]; }; + assert_eq!(result.into_string(), r#""#); +} + +#[test] +fn optional_attribute_variable() { + let x = Some(42); + let result = html! { input value=[x]; }; + assert_eq!(result.into_string(), r#""#); +} + +#[test] +fn optional_attribute_inner_value_evaluated_only_once() { + let mut count = 0; + html! { input value=[{ count += 1; Some("picklebarrelkumquat") }]; }; + assert_eq!(count, 1); +} + +#[test] +fn optional_attribute_braces() { + struct Pony { + cuteness: Option, + } + let result = html! { input value=[Pony { cuteness: Some(9000) }.cuteness]; }; + assert_eq!(result.into_string(), r#""#); +} + #[test] fn colons_in_names() { let result = html! { pon-pon:controls-alpha { a on:click="yay()" { "Yay!" } } }; diff --git a/maud_macros/src/ast.rs b/maud_macros/src/ast.rs index fdf35d68..76749651 100644 --- a/maud_macros/src/ast.rs +++ b/maud_macros/src/ast.rs @@ -170,6 +170,7 @@ impl Attribute { #[derive(Debug)] pub enum AttrType { Normal { value: Markup }, + Optional { toggler: Toggler }, Empty { toggler: Option }, } @@ -177,6 +178,7 @@ impl AttrType { fn span(&self) -> Option { match *self { AttrType::Normal { ref value } => Some(value.span()), + AttrType::Optional { ref toggler } => Some(toggler.span()), AttrType::Empty { ref toggler } => toggler.as_ref().map(Toggler::span), } } diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs index 9fa60fef..ff5729ac 100644 --- a/maud_macros/src/generate.rs +++ b/maud_macros/src/generate.rs @@ -51,7 +51,7 @@ impl Generator { } Markup::Literal { content, .. } => build.push_escaped(&content), Markup::Symbol { symbol } => self.name(symbol, build), - Markup::Splice { expr, .. } => build.push_tokens(self.splice(expr)), + Markup::Splice { expr, .. } => self.splice(expr, build), Markup::Element { name, attrs, body } => self.element(name, attrs, body, build), Markup::Let { tokens, .. } => build.push_tokens(tokens), Markup::Special { segments } => { @@ -89,12 +89,13 @@ impl Generator { TokenStream::from(block) } - fn splice(&self, expr: TokenStream) -> TokenStream { + fn splice(&self, expr: TokenStream, build: &mut Builder) { let output_ident = self.output_ident.clone(); - quote!({ + let tokens = quote!({ use maud::render::{RenderInternal, RenderWrapper}; RenderWrapper(&#expr).__maud_render_to(&mut #output_ident); - }) + }); + build.push_tokens(tokens); } fn element(&self, name: TokenStream, attrs: Vec, body: ElementBody, build: &mut Builder) { @@ -124,21 +125,35 @@ impl Generator { self.markup(value, build); build.push_str("\""); } + AttrType::Optional { + toggler: Toggler { cond, .. }, + } => { + let inner_value = quote!(inner_value); + let body = { + let mut build = self.builder(); + build.push_str(" "); + self.name(name, &mut build); + build.push_str("=\""); + self.splice(inner_value.clone(), &mut build); + build.push_str("\""); + build.finish() + }; + build.push_tokens(quote!(if let Some(#inner_value) = (#cond) { #body })); + } AttrType::Empty { toggler: None } => { build.push_str(" "); self.name(name, build); } AttrType::Empty { - toggler: Some(toggler), + toggler: Some(Toggler { cond, .. }), } => { - let head = desugar_toggler(toggler); - build.push_tokens({ + let body = { let mut build = self.builder(); build.push_str(" "); self.name(name, &mut build); - let body = build.finish(); - quote!(#head { #body }) - }) + build.finish() + }; + build.push_tokens(quote!(if (#cond) { #body })); } } } @@ -196,16 +211,16 @@ fn desugar_classes_or_ids( for name in values_static { markups.extend(prepend_leading_space(name, &mut leading_space)); } - for (name, toggler) in values_toggled { + for (name, Toggler { cond, cond_span }) in values_toggled { let body = Block { markups: prepend_leading_space(name, &mut leading_space), - outer_span: toggler.cond_span, + // TODO: is this correct? + outer_span: cond_span, }; - let head = desugar_toggler(toggler); markups.push(Markup::Special { segments: vec![Special { at_span: SpanRange::call_site(), - head, + head: quote!(if (#cond)), body, }], }); @@ -234,26 +249,6 @@ fn prepend_leading_space(name: Markup, leading_space: &mut bool) -> Vec markups } -fn desugar_toggler( - Toggler { - mut cond, - cond_span, - }: Toggler, -) -> TokenStream { - // If the expression contains an opening brace `{`, - // wrap it in parentheses to avoid parse errors - if cond.clone().into_iter().any(is_braced_block) { - let mut wrapped_cond = TokenTree::Group(Group::new(Delimiter::Parenthesis, cond)); - wrapped_cond.set_span(cond_span.collapse()); - cond = TokenStream::from(wrapped_cond); - } - quote!(if #cond) -} - -fn is_braced_block(token: TokenTree) -> bool { - matches!(token, TokenTree::Group(ref group) if group.delimiter() == Delimiter::Brace) -} - //////////////////////////////////////////////////////// struct Builder { diff --git a/maud_macros/src/parse.rs b/maud_macros/src/parse.rs index bf78f2dd..2d3d3f3a 100644 --- a/maud_macros/src/parse.rs +++ b/maud_macros/src/parse.rs @@ -571,13 +571,16 @@ impl Parser { // Parse a value under an attribute context assert!(self.current_attr.is_none()); self.current_attr = Some(ast::name_to_string(name.clone())); - let value = self.markup(); + let attr_type = match self.attr_toggler() { + Some(toggler) => ast::AttrType::Optional { toggler }, + None => { + let value = self.markup(); + ast::AttrType::Normal { value } + } + }; self.current_attr = None; attrs.push(ast::Attr::Attribute { - attribute: ast::Attribute { - name, - attr_type: ast::AttrType::Normal { value }, - }, + attribute: ast::Attribute { name, attr_type }, }); } // Empty attribute (legacy syntax)