This is unreleased documentation for Yew Next version.
For up-to-date documentation, see the latest version on docs.rs.

yew_macro/html_tree/
html_element.rs

1use proc_macro2::{Delimiter, Group, Span, TokenStream};
2use proc_macro_error::emit_warning;
3use quote::{quote, quote_spanned, ToTokens};
4use syn::buffer::Cursor;
5use syn::parse::{Parse, ParseStream};
6use syn::spanned::Spanned;
7use syn::{Expr, Ident, Lit, LitStr, Token};
8
9use super::{HtmlChildrenTree, HtmlDashedName, TagTokens};
10use crate::props::{ElementProps, Prop, PropDirective};
11use crate::stringify::{Stringify, Value};
12use crate::{is_ide_completion, non_capitalized_ascii, Peek, PeekValue};
13
14fn is_normalised_element_name(name: &str) -> bool {
15    match name {
16        "animateMotion"
17        | "animateTransform"
18        | "clipPath"
19        | "feBlend"
20        | "feColorMatrix"
21        | "feComponentTransfer"
22        | "feComposite"
23        | "feConvolveMatrix"
24        | "feDiffuseLighting"
25        | "feDisplacementMap"
26        | "feDistantLight"
27        | "feDropShadow"
28        | "feFlood"
29        | "feFuncA"
30        | "feFuncB"
31        | "feFuncG"
32        | "feFuncR"
33        | "feGaussianBlur"
34        | "feImage"
35        | "feMerge"
36        | "feMergeNode"
37        | "feMorphology"
38        | "feOffset"
39        | "fePointLight"
40        | "feSpecularLighting"
41        | "feSpotLight"
42        | "feTile"
43        | "feTurbulence"
44        | "foreignObject"
45        | "glyphRef"
46        | "linearGradient"
47        | "radialGradient"
48        | "textPath" => true,
49        _ => !name.chars().any(|c| c.is_ascii_uppercase()),
50    }
51}
52
53pub struct HtmlElement {
54    pub name: TagName,
55    pub props: ElementProps,
56    pub children: HtmlChildrenTree,
57}
58
59impl PeekValue<()> for HtmlElement {
60    fn peek(cursor: Cursor) -> Option<()> {
61        HtmlElementOpen::peek(cursor)
62            .or_else(|| HtmlElementClose::peek(cursor))
63            .map(|_| ())
64    }
65}
66
67impl Parse for HtmlElement {
68    fn parse(input: ParseStream) -> syn::Result<Self> {
69        if HtmlElementClose::peek(input.cursor()).is_some() {
70            return match input.parse::<HtmlElementClose>() {
71                Ok(close) => Err(syn::Error::new_spanned(
72                    close.to_spanned(),
73                    "this closing tag has no corresponding opening tag",
74                )),
75                Err(err) => Err(err),
76            };
77        }
78
79        let open = input.parse::<HtmlElementOpen>()?;
80        // Return early if it's a self-closing tag
81        if open.is_self_closing() {
82            return Ok(HtmlElement {
83                name: open.name,
84                props: open.props,
85                children: HtmlChildrenTree::new(),
86            });
87        }
88
89        if let TagName::Lit(name) = &open.name {
90            // Void elements should not have children.
91            // See https://html.spec.whatwg.org/multipage/syntax.html#void-elements
92            //
93            // For dynamic tags this is done at runtime!
94            match name.to_ascii_lowercase_string().as_str() {
95                "textarea" => {
96                    return Err(syn::Error::new_spanned(
97                        open.to_spanned(),
98                        "the tag `<textarea>` is a void element and cannot have children (hint: \
99                         to provide value to it, rewrite it as `<textarea value={x} />`. If you \
100                         wish to set the default value, rewrite it as `<textarea defaultvalue={x} \
101                         />`)",
102                    ))
103                }
104
105                "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link"
106                | "meta" | "param" | "source" | "track" | "wbr" => {
107                    return Err(syn::Error::new_spanned(
108                        open.to_spanned(),
109                        format!(
110                            "the tag `<{name}>` is a void element and cannot have children (hint: \
111                             rewrite this as `<{name} />`)",
112                        ),
113                    ))
114                }
115
116                _ => {}
117            }
118        }
119
120        let open_key = open.name.get_key();
121        let mut children = HtmlChildrenTree::new();
122        loop {
123            if input.is_empty() {
124                if is_ide_completion() {
125                    break;
126                }
127                return Err(syn::Error::new_spanned(
128                    open.to_spanned(),
129                    "this opening tag has no corresponding closing tag",
130                ));
131            }
132            if let Some(close_key) = HtmlElementClose::peek(input.cursor()) {
133                if open_key == close_key {
134                    break;
135                }
136            }
137
138            children.parse_child(input)?;
139        }
140
141        if !input.is_empty() || !is_ide_completion() {
142            input.parse::<HtmlElementClose>()?;
143        }
144
145        Ok(Self {
146            name: open.name,
147            props: open.props,
148            children,
149        })
150    }
151}
152
153impl ToTokens for HtmlElement {
154    #[allow(clippy::cognitive_complexity)]
155    fn to_tokens(&self, tokens: &mut TokenStream) {
156        let Self {
157            name,
158            props,
159            children,
160        } = self;
161
162        let ElementProps {
163            classes,
164            attributes,
165            booleans,
166            value,
167            checked,
168            listeners,
169            special,
170            defaultvalue,
171        } = &props;
172
173        // attributes with special treatment
174
175        let node_ref = special.wrap_node_ref_attr();
176        let key = special.wrap_key_attr();
177        let value = || {
178            value
179                .as_ref()
180                .map(|prop| wrap_attr_value(prop.value.optimize_literals()))
181                .unwrap_or(quote! { ::std::option::Option::None })
182        };
183        let checked = || {
184            checked
185                .as_ref()
186                .map(|attr| {
187                    let value = &attr.value;
188                    quote! { ::std::option::Option::Some( #value ) }
189                })
190                .unwrap_or(quote! { ::std::option::Option::None })
191        };
192        let defaultvalue = || {
193            defaultvalue
194                .as_ref()
195                .map(|prop| wrap_attr_value(prop.value.optimize_literals()))
196                .unwrap_or(quote! { ::std::option::Option::None })
197        };
198
199        // other attributes
200
201        let attributes = {
202            let normal_attrs = attributes.iter().map(
203                |Prop {
204                     label,
205                     value,
206                     directive,
207                     ..
208                 }| {
209                    (
210                        label.to_lit_str(),
211                        value.optimize_literals_tagged(),
212                        *directive,
213                    )
214                },
215            );
216            let boolean_attrs = booleans.iter().filter_map(
217                |Prop {
218                     label,
219                     value,
220                     directive,
221                     ..
222                 }| {
223                    let key = label.to_lit_str();
224                    Some((
225                        key.clone(),
226                        match value {
227                            Expr::Lit(e) => match &e.lit {
228                                Lit::Bool(b) => Value::Static(if b.value {
229                                    quote! { #key }
230                                } else {
231                                    return None;
232                                }),
233                                _ => Value::Dynamic(quote_spanned! {value.span()=> {
234                                    ::yew::utils::__ensure_type::<::std::primitive::bool>(#value);
235                                    #key
236                                }}),
237                            },
238                            expr => Value::Dynamic(
239                                quote_spanned! {expr.span().resolved_at(Span::call_site())=>
240                                    if #expr {
241                                        ::std::option::Option::Some(
242                                            ::yew::virtual_dom::AttrValue::Static(#key)
243                                        )
244                                    } else {
245                                        ::std::option::Option::None
246                                    }
247                                },
248                            ),
249                        },
250                        *directive,
251                    ))
252                },
253            );
254
255            let class_attr =
256                classes
257                    .as_ref()
258                    .and_then(|classes| match classes.value.try_into_lit() {
259                        Some(lit) => {
260                            if lit.value().is_empty() {
261                                None
262                            } else {
263                                Some((
264                                    LitStr::new("class", lit.span()),
265                                    Value::Static(quote! { #lit }),
266                                    None,
267                                ))
268                            }
269                        }
270                        None => {
271                            let expr = &classes.value;
272                            Some((
273                                LitStr::new("class", classes.label.span()),
274                                Value::Dynamic(quote! {
275                                    ::std::convert::Into::<::yew::html::Classes>::into(#expr)
276                                }),
277                                None,
278                            ))
279                        }
280                    });
281
282            /// Try to turn attribute list into a `::yew::virtual_dom::Attributes::Static`
283            fn try_into_static(
284                src: &[(LitStr, Value, Option<PropDirective>)],
285            ) -> Option<TokenStream> {
286                if src
287                    .iter()
288                    .any(|(_, _, d)| matches!(d, Some(PropDirective::ApplyAsProperty(_))))
289                {
290                    // don't try to make a static attribute list if there are any properties to
291                    // assign
292                    return None;
293                }
294                let mut kv = Vec::with_capacity(src.len());
295                for (k, v, directive) in src.iter() {
296                    let v = match v {
297                        Value::Static(v) => quote! { #v },
298                        Value::Dynamic(_) => return None,
299                    };
300                    let v = match directive {
301                        Some(PropDirective::ApplyAsProperty(token)) => {
302                            quote_spanned!(token.span()=> ::yew::virtual_dom::AttributeOrProperty::Property(
303                                ::std::convert::Into::into(#v)
304                            ))
305                        }
306                        None => quote!(::yew::virtual_dom::AttributeOrProperty::Static(
307                            #v
308                        )),
309                    };
310                    kv.push(quote! { ( #k, #v) });
311                }
312
313                Some(quote! { ::yew::virtual_dom::Attributes::Static(&[#(#kv),*]) })
314            }
315
316            let attrs = normal_attrs
317                .chain(boolean_attrs)
318                .chain(class_attr)
319                .collect::<Vec<(LitStr, Value, Option<PropDirective>)>>();
320            try_into_static(&attrs).unwrap_or_else(|| {
321                let keys = attrs.iter().map(|(k, ..)| quote! { #k });
322                let values = attrs.iter().map(|(_, v, directive)| {
323                    let value = match directive {
324                        Some(PropDirective::ApplyAsProperty(token)) => {
325                            quote_spanned!(token.span()=> ::std::option::Option::Some(
326                                ::yew::virtual_dom::AttributeOrProperty::Property(
327                                    ::std::convert::Into::into(#v)
328                                ))
329                            )
330                        }
331                        None => {
332                            let value = wrap_attr_value(v);
333                            quote! {
334                                ::std::option::Option::map(#value, ::yew::virtual_dom::AttributeOrProperty::Attribute)
335                            }
336                        },
337                    };
338                    quote! { #value }
339                });
340                quote! {
341                    ::yew::virtual_dom::Attributes::Dynamic{
342                        keys: &[#(#keys),*],
343                        values: ::std::boxed::Box::new([#(#values),*]),
344                    }
345                }
346            })
347        };
348
349        let listeners = if listeners.is_empty() {
350            quote! { ::yew::virtual_dom::listeners::Listeners::None }
351        } else {
352            let listeners_it = listeners.iter().map(|Prop { label, value, .. }| {
353                let name = &label.name;
354                quote! {
355                    ::yew::html::#name::Wrapper::__macro_new(#value)
356                }
357            });
358
359            quote! {
360                ::yew::virtual_dom::listeners::Listeners::Pending(
361                    ::std::boxed::Box::new([#(#listeners_it),*])
362                )
363            }
364        };
365
366        // TODO: if none of the children have possibly None expressions or literals as keys, we can
367        // compute `VList.fully_keyed` at compile time.
368        let children = children.to_vnode_tokens();
369
370        tokens.extend(match &name {
371            TagName::Lit(dashedname) => {
372                let name_span = dashedname.span();
373                let name = dashedname.to_ascii_lowercase_string();
374                if !is_normalised_element_name(&dashedname.to_string()) {
375                    emit_warning!(
376                        name_span.clone(),
377                        format!(
378                            "The tag '{dashedname}' is not matching its normalized form '{name}'. If you want \
379                             to keep this form, change this to a dynamic tag `@{{\"{dashedname}\"}}`."
380                        )
381                    )
382                }
383                let node = match &*name {
384                    "input" => {
385                        let value = value();
386                        let checked = checked();
387                        quote! {
388                            ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
389                                ::yew::virtual_dom::VTag::__new_input(
390                                    #value,
391                                    #checked,
392                                    #node_ref,
393                                    #key,
394                                    #attributes,
395                                    #listeners,
396                                ),
397                            )
398                        }
399                    }
400                    "textarea" => {
401                        let value = value();
402                        let defaultvalue = defaultvalue();
403                        quote! {
404                            ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
405                                ::yew::virtual_dom::VTag::__new_textarea(
406                                    #value,
407                                    #defaultvalue,
408                                    #node_ref,
409                                    #key,
410                                    #attributes,
411                                    #listeners,
412                                ),
413                            )
414                        }
415                    }
416                    _ => {
417                        quote! {
418                            ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
419                                ::yew::virtual_dom::VTag::__new_other(
420                                    ::yew::virtual_dom::AttrValue::Static(#name),
421                                    #node_ref,
422                                    #key,
423                                    #attributes,
424                                    #listeners,
425                                    #children,
426                                ),
427                            )
428                        }
429                    }
430                };
431                // the return value can be inlined without the braces when this is stable:
432                // https://github.com/rust-lang/rust/issues/15701
433                quote_spanned!{
434                    name_span =>
435                    {
436                        #[allow(clippy::redundant_clone, unused_braces)]
437                        let node = #node;
438                        node
439                    }
440                }
441            }
442            TagName::Expr(name) => {
443                let vtag = Ident::new("__yew_vtag", name.span());
444                let expr = name.expr.as_ref().map(Group::stream);
445                let vtag_name = Ident::new("__yew_vtag_name", expr.span());
446
447                let void_children = Ident::new("__yew_void_children", Span::mixed_site());
448
449                // handle special attribute value
450                let handle_value_attr = props.value.as_ref().map(|prop| {
451                    let v = prop.value.optimize_literals();
452                    quote_spanned! {v.span()=> {
453                        __yew_vtag.__macro_push_attr("value", #v);
454                    }}
455                });
456
457                #[cfg(nightly_yew)]
458                let invalid_void_tag_msg_start = {
459                    let span = vtag.span().unwrap();
460                    let source_file = span.source_file().path();
461                    let source_file = source_file.display();
462                    let start = span.start();
463                    format!("[{}:{}:{}] ", source_file, start.line(), start.column())
464                };
465
466                #[cfg(not(nightly_yew))]
467                let invalid_void_tag_msg_start = "";
468
469                let value = value();
470                let checked = checked();
471                let defaultvalue = defaultvalue();
472                // this way we get a nice error message (with the correct span) when the expression
473                // doesn't return a valid value
474                quote_spanned! {expr.span()=> {
475                    let mut #vtag_name = ::std::convert::Into::<
476                        ::yew::virtual_dom::AttrValue
477                    >::into(#expr);
478                    ::std::debug_assert!(
479                        #vtag_name.is_ascii(),
480                        "a dynamic tag returned a tag name containing non ASCII characters: `{}`",
481                        #vtag_name,
482                    );
483
484                    #[allow(clippy::redundant_clone, unused_braces, clippy::let_and_return)]
485                    let mut #vtag = match () {
486                        _ if "input".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
487                            ::yew::virtual_dom::VTag::__new_input(
488                                #value,
489                                #checked,
490                                #node_ref,
491                                #key,
492                                #attributes,
493                                #listeners,
494                            )
495                        }
496                        _ if "textarea".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
497                            ::yew::virtual_dom::VTag::__new_textarea(
498                                #value,
499                                #defaultvalue,
500                                #node_ref,
501                                #key,
502                                #attributes,
503                                #listeners,
504                            )
505                        }
506                        _ => {
507                            let mut __yew_vtag = ::yew::virtual_dom::VTag::__new_other(
508                                #vtag_name,
509                                #node_ref,
510                                #key,
511                                #attributes,
512                                #listeners,
513                                #children,
514                            );
515
516                            #handle_value_attr
517
518                            __yew_vtag
519                        }
520                    };
521
522                    // These are the runtime-checks exclusive to dynamic tags.
523                    // For literal tags this is already done at compile-time.
524                    //
525                    // check void element
526                    if ::yew::virtual_dom::VTag::children(&#vtag).is_some() &&
527                       !::std::matches!(
528                        ::yew::virtual_dom::VTag::children(&#vtag),
529                        ::std::option::Option::Some(::yew::virtual_dom::VNode::VList(ref #void_children)) if ::std::vec::Vec::is_empty(#void_children)
530                    ) {
531                        ::std::debug_assert!(
532                            !::std::matches!(#vtag.tag().to_ascii_lowercase().as_str(),
533                                "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
534                                    | "link" | "meta" | "param" | "source" | "track" | "wbr" | "textarea"
535                            ),
536                            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."),
537                            #vtag.tag(),
538                        );
539                    }
540
541                    ::std::convert::Into::<::yew::virtual_dom::VNode>::into(#vtag)
542                }}
543            }
544        });
545    }
546}
547
548fn wrap_attr_value<T: ToTokens>(value: T) -> TokenStream {
549    quote_spanned! {value.span()=>
550        ::yew::html::IntoPropValue::<
551            ::std::option::Option<
552                ::yew::virtual_dom::AttrValue
553            >
554        >
555        ::into_prop_value(#value)
556    }
557}
558
559pub struct DynamicName {
560    at: Token![@],
561    expr: Option<Group>,
562}
563
564impl Peek<'_, ()> for DynamicName {
565    fn peek(cursor: Cursor) -> Option<((), Cursor)> {
566        let (punct, cursor) = cursor.punct()?;
567        if punct.as_char() != '@' {
568            return None;
569        }
570
571        // move cursor past block if there is one
572        let cursor = cursor
573            .group(Delimiter::Brace)
574            .map(|(_, _, cursor)| cursor)
575            .unwrap_or(cursor);
576
577        Some(((), cursor))
578    }
579}
580
581impl Parse for DynamicName {
582    fn parse(input: ParseStream) -> syn::Result<Self> {
583        let at = input.parse()?;
584        // the expression block is optional, closing tags don't have it.
585        let expr = input.parse().ok();
586        Ok(Self { at, expr })
587    }
588}
589
590impl ToTokens for DynamicName {
591    fn to_tokens(&self, tokens: &mut TokenStream) {
592        let Self { at, expr } = self;
593        tokens.extend(quote! {#at #expr});
594    }
595}
596
597#[derive(PartialEq)]
598enum TagKey {
599    Lit(HtmlDashedName),
600    Expr,
601}
602
603pub enum TagName {
604    Lit(HtmlDashedName),
605    Expr(DynamicName),
606}
607
608impl TagName {
609    fn get_key(&self) -> TagKey {
610        match self {
611            TagName::Lit(name) => TagKey::Lit(name.clone()),
612            TagName::Expr(_) => TagKey::Expr,
613        }
614    }
615}
616
617impl Peek<'_, TagKey> for TagName {
618    fn peek(cursor: Cursor) -> Option<(TagKey, Cursor)> {
619        if let Some((_, cursor)) = DynamicName::peek(cursor) {
620            Some((TagKey::Expr, cursor))
621        } else {
622            HtmlDashedName::peek(cursor).map(|(name, cursor)| (TagKey::Lit(name), cursor))
623        }
624    }
625}
626
627impl Parse for TagName {
628    fn parse(input: ParseStream) -> syn::Result<Self> {
629        if DynamicName::peek(input.cursor()).is_some() {
630            DynamicName::parse(input).map(Self::Expr)
631        } else {
632            HtmlDashedName::parse(input).map(Self::Lit)
633        }
634    }
635}
636
637impl ToTokens for TagName {
638    fn to_tokens(&self, tokens: &mut TokenStream) {
639        match self {
640            TagName::Lit(name) => name.to_tokens(tokens),
641            TagName::Expr(name) => name.to_tokens(tokens),
642        }
643    }
644}
645
646struct HtmlElementOpen {
647    tag: TagTokens,
648    name: TagName,
649    props: ElementProps,
650}
651impl HtmlElementOpen {
652    fn is_self_closing(&self) -> bool {
653        self.tag.div.is_some()
654    }
655
656    fn to_spanned(&self) -> impl ToTokens {
657        self.tag.to_spanned()
658    }
659}
660
661impl PeekValue<TagKey> for HtmlElementOpen {
662    fn peek(cursor: Cursor) -> Option<TagKey> {
663        let (punct, cursor) = cursor.punct()?;
664        if punct.as_char() != '<' {
665            return None;
666        }
667
668        let (tag_key, cursor) = TagName::peek(cursor)?;
669        if let TagKey::Lit(name) = &tag_key {
670            // Avoid parsing `<key=[...]>` as an element. It needs to be parsed as an `HtmlList`.
671            if name.to_string() == "key" {
672                let (punct, _) = cursor.punct()?;
673                // ... unless it isn't followed by a '='. `<key></key>` is a valid element!
674                if punct.as_char() == '=' {
675                    return None;
676                }
677            } else if !non_capitalized_ascii(&name.to_string()) {
678                return None;
679            }
680        }
681
682        Some(tag_key)
683    }
684}
685
686impl Parse for HtmlElementOpen {
687    fn parse(input: ParseStream) -> syn::Result<Self> {
688        TagTokens::parse_start_content(input, |input, tag| {
689            let name = input.parse::<TagName>()?;
690            let mut props = input.parse::<ElementProps>()?;
691
692            match &name {
693                TagName::Lit(name) => {
694                    // Don't treat value as special for non input / textarea fields
695                    // For dynamic tags this is done at runtime!
696                    match name.to_ascii_lowercase_string().as_str() {
697                        "input" | "textarea" => {}
698                        _ => {
699                            if let Some(attr) = props.value.take() {
700                                props.attributes.push(attr);
701                            }
702                            if let Some(attr) = props.checked.take() {
703                                props.attributes.push(attr);
704                            }
705                        }
706                    }
707                }
708                TagName::Expr(name) => {
709                    if name.expr.is_none() {
710                        return Err(syn::Error::new_spanned(
711                            name,
712                            "this dynamic tag is missing an expression block defining its value",
713                        ));
714                    }
715                }
716            }
717
718            Ok(Self { tag, name, props })
719        })
720    }
721}
722
723struct HtmlElementClose {
724    tag: TagTokens,
725    _name: TagName,
726}
727impl HtmlElementClose {
728    fn to_spanned(&self) -> impl ToTokens {
729        self.tag.to_spanned()
730    }
731}
732
733impl PeekValue<TagKey> for HtmlElementClose {
734    fn peek(cursor: Cursor) -> Option<TagKey> {
735        let (punct, cursor) = cursor.punct()?;
736        if punct.as_char() != '<' {
737            return None;
738        }
739
740        let (punct, cursor) = cursor.punct()?;
741        if punct.as_char() != '/' {
742            return None;
743        }
744
745        let (tag_key, cursor) = TagName::peek(cursor)?;
746        if matches!(&tag_key, TagKey::Lit(name) if !non_capitalized_ascii(&name.to_string())) {
747            return None;
748        }
749
750        let (punct, _) = cursor.punct()?;
751        (punct.as_char() == '>').then_some(tag_key)
752    }
753}
754
755impl Parse for HtmlElementClose {
756    fn parse(input: ParseStream) -> syn::Result<Self> {
757        TagTokens::parse_end_content(input, |input, tag| {
758            let name = input.parse()?;
759
760            if let TagName::Expr(name) = &name {
761                if let Some(expr) = &name.expr {
762                    return Err(syn::Error::new_spanned(
763                        expr,
764                        "dynamic closing tags must not have a body (hint: replace it with just \
765                         `</@>`)",
766                    ));
767                }
768            }
769
770            Ok(Self { tag, _name: name })
771        })
772    }
773}