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                #[rustversion::since(1.89)]
458                fn derive_debug_tag(vtag: &Ident) -> String {
459                    let span = vtag.span().unwrap();
460                    format!("[{}:{}:{}] ", span.file(), span.line(), span.column())
461                }
462                #[rustversion::before(1.89)]
463                fn derive_debug_tag(_: &Ident) -> &'static str {
464                    ""
465                }
466                let invalid_void_tag_msg_start = derive_debug_tag(&vtag);
467
468                let value = value();
469                let checked = checked();
470                let defaultvalue = defaultvalue();
471                // this way we get a nice error message (with the correct span) when the expression
472                // doesn't return a valid value
473                quote_spanned! {expr.span()=> {
474                    let mut #vtag_name = ::std::convert::Into::<
475                        ::yew::virtual_dom::AttrValue
476                    >::into(#expr);
477                    ::std::debug_assert!(
478                        #vtag_name.is_ascii(),
479                        "a dynamic tag returned a tag name containing non ASCII characters: `{}`",
480                        #vtag_name,
481                    );
482
483                    #[allow(clippy::redundant_clone, unused_braces, clippy::let_and_return)]
484                    let mut #vtag = match () {
485                        _ if "input".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
486                            ::yew::virtual_dom::VTag::__new_input(
487                                #value,
488                                #checked,
489                                #node_ref,
490                                #key,
491                                #attributes,
492                                #listeners,
493                            )
494                        }
495                        _ if "textarea".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
496                            ::yew::virtual_dom::VTag::__new_textarea(
497                                #value,
498                                #defaultvalue,
499                                #node_ref,
500                                #key,
501                                #attributes,
502                                #listeners,
503                            )
504                        }
505                        _ => {
506                            let mut __yew_vtag = ::yew::virtual_dom::VTag::__new_other(
507                                #vtag_name,
508                                #node_ref,
509                                #key,
510                                #attributes,
511                                #listeners,
512                                #children,
513                            );
514
515                            #handle_value_attr
516
517                            __yew_vtag
518                        }
519                    };
520
521                    // These are the runtime-checks exclusive to dynamic tags.
522                    // For literal tags this is already done at compile-time.
523                    //
524                    // check void element
525                    if ::yew::virtual_dom::VTag::children(&#vtag).is_some() &&
526                       !::std::matches!(
527                        ::yew::virtual_dom::VTag::children(&#vtag),
528                        ::std::option::Option::Some(::yew::virtual_dom::VNode::VList(ref #void_children)) if ::std::vec::Vec::is_empty(#void_children)
529                    ) {
530                        ::std::debug_assert!(
531                            !::std::matches!(#vtag.tag().to_ascii_lowercase().as_str(),
532                                "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
533                                    | "link" | "meta" | "param" | "source" | "track" | "wbr" | "textarea"
534                            ),
535                            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."),
536                            #vtag.tag(),
537                        );
538                    }
539
540                    ::std::convert::Into::<::yew::virtual_dom::VNode>::into(#vtag)
541                }}
542            }
543        });
544    }
545}
546
547fn wrap_attr_value<T: ToTokens>(value: T) -> TokenStream {
548    quote_spanned! {value.span()=>
549        ::yew::html::IntoPropValue::<
550            ::std::option::Option<
551                ::yew::virtual_dom::AttrValue
552            >
553        >
554        ::into_prop_value(#value)
555    }
556}
557
558pub struct DynamicName {
559    at: Token![@],
560    expr: Option<Group>,
561}
562
563impl Peek<'_, ()> for DynamicName {
564    fn peek(cursor: Cursor) -> Option<((), Cursor)> {
565        let (punct, cursor) = cursor.punct()?;
566        if punct.as_char() != '@' {
567            return None;
568        }
569
570        // move cursor past block if there is one
571        let cursor = cursor
572            .group(Delimiter::Brace)
573            .map(|(_, _, cursor)| cursor)
574            .unwrap_or(cursor);
575
576        Some(((), cursor))
577    }
578}
579
580impl Parse for DynamicName {
581    fn parse(input: ParseStream) -> syn::Result<Self> {
582        let at = input.parse()?;
583        // the expression block is optional, closing tags don't have it.
584        let expr = input.parse().ok();
585        Ok(Self { at, expr })
586    }
587}
588
589impl ToTokens for DynamicName {
590    fn to_tokens(&self, tokens: &mut TokenStream) {
591        let Self { at, expr } = self;
592        tokens.extend(quote! {#at #expr});
593    }
594}
595
596#[derive(PartialEq)]
597enum TagKey {
598    Lit(HtmlDashedName),
599    Expr,
600}
601
602pub enum TagName {
603    Lit(HtmlDashedName),
604    Expr(DynamicName),
605}
606
607impl TagName {
608    fn get_key(&self) -> TagKey {
609        match self {
610            TagName::Lit(name) => TagKey::Lit(name.clone()),
611            TagName::Expr(_) => TagKey::Expr,
612        }
613    }
614}
615
616impl Peek<'_, TagKey> for TagName {
617    fn peek(cursor: Cursor) -> Option<(TagKey, Cursor)> {
618        if let Some((_, cursor)) = DynamicName::peek(cursor) {
619            Some((TagKey::Expr, cursor))
620        } else {
621            HtmlDashedName::peek(cursor).map(|(name, cursor)| (TagKey::Lit(name), cursor))
622        }
623    }
624}
625
626impl Parse for TagName {
627    fn parse(input: ParseStream) -> syn::Result<Self> {
628        if DynamicName::peek(input.cursor()).is_some() {
629            DynamicName::parse(input).map(Self::Expr)
630        } else {
631            HtmlDashedName::parse(input).map(Self::Lit)
632        }
633    }
634}
635
636impl ToTokens for TagName {
637    fn to_tokens(&self, tokens: &mut TokenStream) {
638        match self {
639            TagName::Lit(name) => name.to_tokens(tokens),
640            TagName::Expr(name) => name.to_tokens(tokens),
641        }
642    }
643}
644
645struct HtmlElementOpen {
646    tag: TagTokens,
647    name: TagName,
648    props: ElementProps,
649}
650impl HtmlElementOpen {
651    fn is_self_closing(&self) -> bool {
652        self.tag.div.is_some()
653    }
654
655    fn to_spanned(&self) -> impl ToTokens {
656        self.tag.to_spanned()
657    }
658}
659
660impl PeekValue<TagKey> for HtmlElementOpen {
661    fn peek(cursor: Cursor) -> Option<TagKey> {
662        let (punct, cursor) = cursor.punct()?;
663        if punct.as_char() != '<' {
664            return None;
665        }
666
667        let (tag_key, cursor) = TagName::peek(cursor)?;
668        if let TagKey::Lit(name) = &tag_key {
669            // Avoid parsing `<key=[...]>` as an element. It needs to be parsed as an `HtmlList`.
670            if name.to_string() == "key" {
671                let (punct, _) = cursor.punct()?;
672                // ... unless it isn't followed by a '='. `<key></key>` is a valid element!
673                if punct.as_char() == '=' {
674                    return None;
675                }
676            } else if !non_capitalized_ascii(&name.to_string()) {
677                return None;
678            }
679        }
680
681        Some(tag_key)
682    }
683}
684
685impl Parse for HtmlElementOpen {
686    fn parse(input: ParseStream) -> syn::Result<Self> {
687        TagTokens::parse_start_content(input, |input, tag| {
688            let name = input.parse::<TagName>()?;
689            let mut props = input.parse::<ElementProps>()?;
690
691            match &name {
692                TagName::Lit(name) => {
693                    // Don't treat value as special for non input / textarea fields
694                    // For dynamic tags this is done at runtime!
695                    match name.to_ascii_lowercase_string().as_str() {
696                        "input" | "textarea" => {}
697                        _ => {
698                            if let Some(attr) = props.value.take() {
699                                props.attributes.push(attr);
700                            }
701                            if let Some(attr) = props.checked.take() {
702                                props.attributes.push(attr);
703                            }
704                        }
705                    }
706                }
707                TagName::Expr(name) => {
708                    if name.expr.is_none() {
709                        return Err(syn::Error::new_spanned(
710                            name,
711                            "this dynamic tag is missing an expression block defining its value",
712                        ));
713                    }
714                }
715            }
716
717            Ok(Self { tag, name, props })
718        })
719    }
720}
721
722struct HtmlElementClose {
723    tag: TagTokens,
724    _name: TagName,
725}
726impl HtmlElementClose {
727    fn to_spanned(&self) -> impl ToTokens {
728        self.tag.to_spanned()
729    }
730}
731
732impl PeekValue<TagKey> for HtmlElementClose {
733    fn peek(cursor: Cursor) -> Option<TagKey> {
734        let (punct, cursor) = cursor.punct()?;
735        if punct.as_char() != '<' {
736            return None;
737        }
738
739        let (punct, cursor) = cursor.punct()?;
740        if punct.as_char() != '/' {
741            return None;
742        }
743
744        let (tag_key, cursor) = TagName::peek(cursor)?;
745        if matches!(&tag_key, TagKey::Lit(name) if !non_capitalized_ascii(&name.to_string())) {
746            return None;
747        }
748
749        let (punct, _) = cursor.punct()?;
750        (punct.as_char() == '>').then_some(tag_key)
751    }
752}
753
754impl Parse for HtmlElementClose {
755    fn parse(input: ParseStream) -> syn::Result<Self> {
756        TagTokens::parse_end_content(input, |input, tag| {
757            let name = input.parse()?;
758
759            if let TagName::Expr(name) = &name {
760                if let Some(expr) = &name.expr {
761                    return Err(syn::Error::new_spanned(
762                        expr,
763                        "dynamic closing tags must not have a body (hint: replace it with just \
764                         `</@>`)",
765                    ));
766                }
767            }
768
769            Ok(Self { tag, _name: name })
770        })
771    }
772}