mirror of https://github.com/yewstack/yew
709 lines
25 KiB
Rust
709 lines
25 KiB
Rust
use boolinator::Boolinator;
|
|
use proc_macro2::{Delimiter, Span, TokenStream};
|
|
use proc_macro_error::emit_warning;
|
|
use quote::{quote, quote_spanned, ToTokens};
|
|
use syn::buffer::Cursor;
|
|
use syn::parse::{Parse, ParseStream};
|
|
use syn::spanned::Spanned;
|
|
use syn::{Block, Expr, Ident, Lit, LitStr, Token};
|
|
|
|
use super::{HtmlChildrenTree, HtmlDashedName, TagTokens};
|
|
use crate::props::{ClassesForm, ElementProps, Prop, PropDirective};
|
|
use crate::stringify::{Stringify, Value};
|
|
use crate::{is_ide_completion, non_capitalized_ascii, Peek, PeekValue};
|
|
|
|
pub struct HtmlElement {
|
|
pub name: TagName,
|
|
pub props: ElementProps,
|
|
pub children: HtmlChildrenTree,
|
|
}
|
|
|
|
impl PeekValue<()> for HtmlElement {
|
|
fn peek(cursor: Cursor) -> Option<()> {
|
|
HtmlElementOpen::peek(cursor)
|
|
.or_else(|| HtmlElementClose::peek(cursor))
|
|
.map(|_| ())
|
|
}
|
|
}
|
|
|
|
impl Parse for HtmlElement {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
if HtmlElementClose::peek(input.cursor()).is_some() {
|
|
return match input.parse::<HtmlElementClose>() {
|
|
Ok(close) => Err(syn::Error::new_spanned(
|
|
close.to_spanned(),
|
|
"this closing tag has no corresponding opening tag",
|
|
)),
|
|
Err(err) => Err(err),
|
|
};
|
|
}
|
|
|
|
let open = input.parse::<HtmlElementOpen>()?;
|
|
// Return early if it's a self-closing tag
|
|
if open.is_self_closing() {
|
|
return Ok(HtmlElement {
|
|
name: open.name,
|
|
props: open.props,
|
|
children: HtmlChildrenTree::new(),
|
|
});
|
|
}
|
|
|
|
if let TagName::Lit(name) = &open.name {
|
|
// Void elements should not have children.
|
|
// See https://html.spec.whatwg.org/multipage/syntax.html#void-elements
|
|
//
|
|
// For dynamic tags this is done at runtime!
|
|
match name.to_ascii_lowercase_string().as_str() {
|
|
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link"
|
|
| "meta" | "param" | "source" | "track" | "wbr" => {
|
|
return Err(syn::Error::new_spanned(
|
|
open.to_spanned(),
|
|
format!(
|
|
"the tag `<{name}>` is a void element and cannot have children (hint: \
|
|
rewrite this as `<{name} />`)",
|
|
),
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let open_key = open.name.get_key();
|
|
let mut children = HtmlChildrenTree::new();
|
|
loop {
|
|
if input.is_empty() {
|
|
if is_ide_completion() {
|
|
break;
|
|
}
|
|
return Err(syn::Error::new_spanned(
|
|
open.to_spanned(),
|
|
"this opening tag has no corresponding closing tag",
|
|
));
|
|
}
|
|
if let Some(close_key) = HtmlElementClose::peek(input.cursor()) {
|
|
if open_key == close_key {
|
|
break;
|
|
}
|
|
}
|
|
|
|
children.parse_child(input)?;
|
|
}
|
|
|
|
if !input.is_empty() || !is_ide_completion() {
|
|
input.parse::<HtmlElementClose>()?;
|
|
}
|
|
|
|
Ok(Self {
|
|
name: open.name,
|
|
props: open.props,
|
|
children,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ToTokens for HtmlElement {
|
|
#[allow(clippy::cognitive_complexity)]
|
|
fn to_tokens(&self, tokens: &mut TokenStream) {
|
|
let Self {
|
|
name,
|
|
props,
|
|
children,
|
|
} = self;
|
|
|
|
let ElementProps {
|
|
classes,
|
|
attributes,
|
|
booleans,
|
|
value,
|
|
checked,
|
|
listeners,
|
|
special,
|
|
} = &props;
|
|
|
|
// attributes with special treatment
|
|
|
|
let node_ref = special.wrap_node_ref_attr();
|
|
let key = special.wrap_key_attr();
|
|
let value = value
|
|
.as_ref()
|
|
.map(|prop| wrap_attr_value(prop.value.optimize_literals()))
|
|
.unwrap_or(quote! { ::std::option::Option::None });
|
|
let checked = checked
|
|
.as_ref()
|
|
.map(|attr| {
|
|
let value = &attr.value;
|
|
quote! { ::std::option::Option::Some( #value ) }
|
|
})
|
|
.unwrap_or(quote! { ::std::option::Option::None });
|
|
|
|
// other attributes
|
|
|
|
let attributes = {
|
|
let normal_attrs = attributes.iter().map(
|
|
|Prop {
|
|
label,
|
|
value,
|
|
directive,
|
|
..
|
|
}| {
|
|
(
|
|
label.to_lit_str(),
|
|
value.optimize_literals_tagged(),
|
|
*directive,
|
|
)
|
|
},
|
|
);
|
|
let boolean_attrs = booleans.iter().filter_map(
|
|
|Prop {
|
|
label,
|
|
value,
|
|
directive,
|
|
..
|
|
}| {
|
|
let key = label.to_lit_str();
|
|
Some((
|
|
key.clone(),
|
|
match value {
|
|
Expr::Lit(e) => match &e.lit {
|
|
Lit::Bool(b) => Value::Static(if b.value {
|
|
quote! { #key }
|
|
} else {
|
|
return None;
|
|
}),
|
|
_ => Value::Dynamic(quote_spanned! {value.span()=> {
|
|
::yew::utils::__ensure_type::<::std::primitive::bool>(#value);
|
|
#key
|
|
}}),
|
|
},
|
|
expr => Value::Dynamic(
|
|
quote_spanned! {expr.span().resolved_at(Span::call_site())=>
|
|
if #expr {
|
|
::std::option::Option::Some(
|
|
::yew::virtual_dom::AttrValue::Static(#key)
|
|
)
|
|
} else {
|
|
::std::option::Option::None
|
|
}
|
|
},
|
|
),
|
|
},
|
|
*directive,
|
|
))
|
|
},
|
|
);
|
|
let class_attr = classes.as_ref().and_then(|classes| match classes {
|
|
ClassesForm::Tuple(classes) => {
|
|
let span = classes.span();
|
|
let classes: Vec<_> = classes.elems.iter().collect();
|
|
let n = classes.len();
|
|
|
|
let deprecation_warning = quote_spanned! {span=>
|
|
#[deprecated(
|
|
note = "the use of `(...)` with the attribute `class` is deprecated and will be removed in version 0.19. Use the `classes!` macro instead."
|
|
)]
|
|
fn deprecated_use_of_class() {}
|
|
|
|
if false {
|
|
deprecated_use_of_class();
|
|
};
|
|
};
|
|
|
|
Some((
|
|
LitStr::new("class", span),
|
|
Value::Dynamic(quote! {
|
|
{
|
|
#deprecation_warning
|
|
|
|
let mut __yew_classes = ::yew::html::Classes::with_capacity(#n);
|
|
#(__yew_classes.push(#classes);)*
|
|
__yew_classes
|
|
}
|
|
}),
|
|
None,
|
|
))
|
|
}
|
|
ClassesForm::Single(classes) => {
|
|
match classes.try_into_lit() {
|
|
Some(lit) => {
|
|
if lit.value().is_empty() {
|
|
None
|
|
} else {
|
|
Some((
|
|
LitStr::new("class", lit.span()),
|
|
Value::Static(quote! { #lit }),
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
None => {
|
|
Some((
|
|
LitStr::new("class", classes.span()),
|
|
Value::Dynamic(quote! {
|
|
::std::convert::Into::<::yew::html::Classes>::into(#classes)
|
|
}),
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
fn apply_as(directive: Option<&PropDirective>) -> TokenStream {
|
|
match directive {
|
|
Some(PropDirective::ApplyAsProperty(token)) => {
|
|
quote_spanned!(token.span()=> ::yew::virtual_dom::ApplyAttributeAs::Property)
|
|
}
|
|
None => quote!(::yew::virtual_dom::ApplyAttributeAs::Attribute),
|
|
}
|
|
}
|
|
|
|
/// Try to turn attribute list into a `::yew::virtual_dom::Attributes::Static`
|
|
fn try_into_static(
|
|
src: &[(LitStr, Value, Option<PropDirective>)],
|
|
) -> Option<TokenStream> {
|
|
let mut kv = Vec::with_capacity(src.len());
|
|
for (k, v, directive) in src.iter() {
|
|
let v = match v {
|
|
Value::Static(v) => quote! { #v },
|
|
Value::Dynamic(_) => return None,
|
|
};
|
|
let apply_as = apply_as(directive.as_ref());
|
|
kv.push(quote! { ( #k, #v, #apply_as ) });
|
|
}
|
|
|
|
Some(quote! { ::yew::virtual_dom::Attributes::Static(&[#(#kv),*]) })
|
|
}
|
|
|
|
let attrs = normal_attrs
|
|
.chain(boolean_attrs)
|
|
.chain(class_attr)
|
|
.collect::<Vec<(LitStr, Value, Option<PropDirective>)>>();
|
|
try_into_static(&attrs).unwrap_or_else(|| {
|
|
let keys = attrs.iter().map(|(k, ..)| quote! { #k });
|
|
let values = attrs.iter().map(|(_, v, directive)| {
|
|
let apply_as = apply_as(directive.as_ref());
|
|
let value = wrap_attr_value(v);
|
|
quote! { ::std::option::Option::map(#value, |it| (it, #apply_as)) }
|
|
});
|
|
quote! {
|
|
::yew::virtual_dom::Attributes::Dynamic{
|
|
keys: &[#(#keys),*],
|
|
values: ::std::boxed::Box::new([#(#values),*]),
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
let listeners = if listeners.is_empty() {
|
|
quote! { ::yew::virtual_dom::listeners::Listeners::None }
|
|
} else {
|
|
let listeners_it = listeners.iter().map(|Prop { label, value, .. }| {
|
|
let name = &label.name;
|
|
quote! {
|
|
::yew::html::#name::Wrapper::__macro_new(#value)
|
|
}
|
|
});
|
|
|
|
quote! {
|
|
::yew::virtual_dom::listeners::Listeners::Pending(
|
|
::std::boxed::Box::new([#(#listeners_it),*])
|
|
)
|
|
}
|
|
};
|
|
|
|
// TODO: if none of the children have possibly None expressions or literals as keys, we can
|
|
// compute `VList.fully_keyed` at compile time.
|
|
let children = children.to_vnode_tokens();
|
|
|
|
tokens.extend(match &name {
|
|
TagName::Lit(dashedname) => {
|
|
let name_span = dashedname.span();
|
|
let name = dashedname.to_ascii_lowercase_string();
|
|
if name != dashedname.to_string() {
|
|
emit_warning!(
|
|
dashedname.span(),
|
|
format!(
|
|
"The tag '{dashedname}' is not matching its normalized form '{name}'. If you want \
|
|
to keep this form, change this to a dynamic tag `@{{\"{dashedname}\"}}`."
|
|
)
|
|
)
|
|
}
|
|
let node = match &*name {
|
|
"input" => {
|
|
quote! {
|
|
::std::convert::Into::<::yew::virtual_dom::VNode>::into(
|
|
::yew::virtual_dom::VTag::__new_input(
|
|
#value,
|
|
#checked,
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
"textarea" => {
|
|
quote! {
|
|
::std::convert::Into::<::yew::virtual_dom::VNode>::into(
|
|
::yew::virtual_dom::VTag::__new_textarea(
|
|
#value,
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
_ => {
|
|
quote! {
|
|
::std::convert::Into::<::yew::virtual_dom::VNode>::into(
|
|
::yew::virtual_dom::VTag::__new_other(
|
|
::std::borrow::Cow::<'static, ::std::primitive::str>::Borrowed(#name),
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
#children,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
};
|
|
// the return value can be inlined without the braces when this is stable:
|
|
// https://github.com/rust-lang/rust/issues/15701
|
|
quote_spanned!{
|
|
name_span =>
|
|
{
|
|
#[allow(clippy::redundant_clone, unused_braces)]
|
|
let node = #node;
|
|
node
|
|
}
|
|
}
|
|
}
|
|
TagName::Expr(name) => {
|
|
let vtag = Ident::new("__yew_vtag", name.span());
|
|
let expr = &name.expr;
|
|
let vtag_name = Ident::new("__yew_vtag_name", expr.span());
|
|
|
|
let void_children = Ident::new("__yew_void_children", Span::mixed_site());
|
|
|
|
// handle special attribute value
|
|
let handle_value_attr = props.value.as_ref().map(|prop| {
|
|
let v = prop.value.optimize_literals();
|
|
quote_spanned! {v.span()=> {
|
|
__yew_vtag.__macro_push_attr("value", #v);
|
|
}}
|
|
});
|
|
|
|
#[cfg(nightly_yew)]
|
|
let invalid_void_tag_msg_start = {
|
|
let span = vtag.span().unwrap();
|
|
let source_file = span.source_file().path();
|
|
let source_file = source_file.display();
|
|
let start = span.start();
|
|
format!("[{}:{}:{}] ", source_file, start.line(), start.column())
|
|
};
|
|
|
|
#[cfg(not(nightly_yew))]
|
|
let invalid_void_tag_msg_start = "";
|
|
|
|
// this way we get a nice error message (with the correct span) when the expression
|
|
// doesn't return a valid value
|
|
quote_spanned! {expr.span()=> {
|
|
#[allow(unused_braces)]
|
|
// e.g. html!{<@{"div"}/>} will set `#expr` to `{"div"}`
|
|
// (note the extra braces). Hence the need for the `allow`.
|
|
// Anyways to remove the braces?
|
|
let mut #vtag_name = ::std::convert::Into::<
|
|
::std::borrow::Cow::<'static, ::std::primitive::str>
|
|
>::into(#expr);
|
|
::std::debug_assert!(
|
|
#vtag_name.is_ascii(),
|
|
"a dynamic tag returned a tag name containing non ASCII characters: `{}`",
|
|
#vtag_name,
|
|
);
|
|
|
|
#[allow(clippy::redundant_clone, unused_braces, clippy::let_and_return)]
|
|
let mut #vtag = match () {
|
|
_ if "input".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
|
|
::yew::virtual_dom::VTag::__new_textarea(
|
|
#value,
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
)
|
|
}
|
|
_ if "textarea".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
|
|
::yew::virtual_dom::VTag::__new_textarea(
|
|
#value,
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
)
|
|
}
|
|
_ => {
|
|
let mut __yew_vtag = ::yew::virtual_dom::VTag::__new_other(
|
|
#vtag_name,
|
|
#node_ref,
|
|
#key,
|
|
#attributes,
|
|
#listeners,
|
|
#children,
|
|
);
|
|
|
|
#handle_value_attr
|
|
|
|
__yew_vtag
|
|
}
|
|
};
|
|
|
|
// These are the runtime-checks exclusive to dynamic tags.
|
|
// For literal tags this is already done at compile-time.
|
|
//
|
|
// check void element
|
|
if !::std::matches!(
|
|
::yew::virtual_dom::VTag::children(&#vtag),
|
|
::std::option::Option::Some(::yew::virtual_dom::VNode::VList(ref #void_children)) if ::std::vec::Vec::is_empty(#void_children)
|
|
) {
|
|
::std::debug_assert!(
|
|
!::std::matches!(#vtag.tag().to_ascii_lowercase().as_str(),
|
|
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
|
|
| "link" | "meta" | "param" | "source" | "track" | "wbr"
|
|
),
|
|
concat!(#invalid_void_tag_msg_start, "a dynamic tag tried to create a `<{0}>` tag with children. `<{0}>` is a void element which can't have any children."),
|
|
#vtag.tag(),
|
|
);
|
|
}
|
|
|
|
::std::convert::Into::<::yew::virtual_dom::VNode>::into(#vtag)
|
|
}}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn wrap_attr_value<T: ToTokens>(value: T) -> TokenStream {
|
|
quote_spanned! {value.span()=>
|
|
::yew::html::IntoPropValue::<
|
|
::std::option::Option<
|
|
::yew::virtual_dom::AttrValue
|
|
>
|
|
>
|
|
::into_prop_value(#value)
|
|
}
|
|
}
|
|
|
|
pub struct DynamicName {
|
|
at: Token![@],
|
|
expr: Option<Block>,
|
|
}
|
|
|
|
impl Peek<'_, ()> for DynamicName {
|
|
fn peek(cursor: Cursor) -> Option<((), Cursor)> {
|
|
let (punct, cursor) = cursor.punct()?;
|
|
(punct.as_char() == '@').as_option()?;
|
|
|
|
// move cursor past block if there is one
|
|
let cursor = cursor
|
|
.group(Delimiter::Brace)
|
|
.map(|(_, _, cursor)| cursor)
|
|
.unwrap_or(cursor);
|
|
|
|
Some(((), cursor))
|
|
}
|
|
}
|
|
|
|
impl Parse for DynamicName {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
let at = input.parse()?;
|
|
// the expression block is optional, closing tags don't have it.
|
|
let expr = if input.cursor().group(Delimiter::Brace).is_some() {
|
|
Some(input.parse()?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(Self { at, expr })
|
|
}
|
|
}
|
|
|
|
impl ToTokens for DynamicName {
|
|
fn to_tokens(&self, tokens: &mut TokenStream) {
|
|
let Self { at, expr } = self;
|
|
tokens.extend(quote! {#at #expr});
|
|
}
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum TagKey {
|
|
Lit(HtmlDashedName),
|
|
Expr,
|
|
}
|
|
|
|
pub enum TagName {
|
|
Lit(HtmlDashedName),
|
|
Expr(DynamicName),
|
|
}
|
|
|
|
impl TagName {
|
|
fn get_key(&self) -> TagKey {
|
|
match self {
|
|
TagName::Lit(name) => TagKey::Lit(name.clone()),
|
|
TagName::Expr(_) => TagKey::Expr,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Peek<'_, TagKey> for TagName {
|
|
fn peek(cursor: Cursor) -> Option<(TagKey, Cursor)> {
|
|
if let Some((_, cursor)) = DynamicName::peek(cursor) {
|
|
Some((TagKey::Expr, cursor))
|
|
} else {
|
|
HtmlDashedName::peek(cursor).map(|(name, cursor)| (TagKey::Lit(name), cursor))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Parse for TagName {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
if DynamicName::peek(input.cursor()).is_some() {
|
|
DynamicName::parse(input).map(Self::Expr)
|
|
} else {
|
|
HtmlDashedName::parse(input).map(Self::Lit)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ToTokens for TagName {
|
|
fn to_tokens(&self, tokens: &mut TokenStream) {
|
|
match self {
|
|
TagName::Lit(name) => name.to_tokens(tokens),
|
|
TagName::Expr(name) => name.to_tokens(tokens),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HtmlElementOpen {
|
|
tag: TagTokens,
|
|
name: TagName,
|
|
props: ElementProps,
|
|
}
|
|
impl HtmlElementOpen {
|
|
fn is_self_closing(&self) -> bool {
|
|
self.tag.div.is_some()
|
|
}
|
|
|
|
fn to_spanned(&self) -> impl ToTokens {
|
|
self.tag.to_spanned()
|
|
}
|
|
}
|
|
|
|
impl PeekValue<TagKey> for HtmlElementOpen {
|
|
fn peek(cursor: Cursor) -> Option<TagKey> {
|
|
let (punct, cursor) = cursor.punct()?;
|
|
(punct.as_char() == '<').as_option()?;
|
|
|
|
let (tag_key, cursor) = TagName::peek(cursor)?;
|
|
if let TagKey::Lit(name) = &tag_key {
|
|
// Avoid parsing `<key=[...]>` as an element. It needs to be parsed as an `HtmlList`.
|
|
if name.to_string() == "key" {
|
|
let (punct, _) = cursor.punct()?;
|
|
// ... unless it isn't followed by a '='. `<key></key>` is a valid element!
|
|
(punct.as_char() != '=').as_option()?;
|
|
} else {
|
|
non_capitalized_ascii(&name.to_string()).as_option()?;
|
|
}
|
|
}
|
|
|
|
Some(tag_key)
|
|
}
|
|
}
|
|
|
|
impl Parse for HtmlElementOpen {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
TagTokens::parse_start_content(input, |input, tag| {
|
|
let name = input.parse::<TagName>()?;
|
|
let mut props = input.parse::<ElementProps>()?;
|
|
|
|
match &name {
|
|
TagName::Lit(name) => {
|
|
// Don't treat value as special for non input / textarea fields
|
|
// For dynamic tags this is done at runtime!
|
|
match name.to_ascii_lowercase_string().as_str() {
|
|
"input" | "textarea" => {}
|
|
_ => {
|
|
if let Some(attr) = props.value.take() {
|
|
props.attributes.push(attr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TagName::Expr(name) => {
|
|
if name.expr.is_none() {
|
|
return Err(syn::Error::new_spanned(
|
|
name,
|
|
"this dynamic tag is missing an expression block defining its value",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Self { tag, name, props })
|
|
})
|
|
}
|
|
}
|
|
|
|
struct HtmlElementClose {
|
|
tag: TagTokens,
|
|
_name: TagName,
|
|
}
|
|
impl HtmlElementClose {
|
|
fn to_spanned(&self) -> impl ToTokens {
|
|
self.tag.to_spanned()
|
|
}
|
|
}
|
|
|
|
impl PeekValue<TagKey> for HtmlElementClose {
|
|
fn peek(cursor: Cursor) -> Option<TagKey> {
|
|
let (punct, cursor) = cursor.punct()?;
|
|
(punct.as_char() == '<').as_option()?;
|
|
|
|
let (punct, cursor) = cursor.punct()?;
|
|
(punct.as_char() == '/').as_option()?;
|
|
|
|
let (tag_key, cursor) = TagName::peek(cursor)?;
|
|
if let TagKey::Lit(name) = &tag_key {
|
|
non_capitalized_ascii(&name.to_string()).as_option()?;
|
|
}
|
|
|
|
let (punct, _) = cursor.punct()?;
|
|
(punct.as_char() == '>').as_option()?;
|
|
|
|
Some(tag_key)
|
|
}
|
|
}
|
|
|
|
impl Parse for HtmlElementClose {
|
|
fn parse(input: ParseStream) -> syn::Result<Self> {
|
|
TagTokens::parse_end_content(input, |input, tag| {
|
|
let name = input.parse()?;
|
|
|
|
if let TagName::Expr(name) = &name {
|
|
if let Some(expr) = &name.expr {
|
|
return Err(syn::Error::new_spanned(
|
|
expr,
|
|
"dynamic closing tags must not have a body (hint: replace it with just \
|
|
`</@>`)",
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(Self { tag, _name: name })
|
|
})
|
|
}
|
|
}
|