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 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 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 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 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 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 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 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 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 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 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 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 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 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 if name.to_string() == "key" {
672 let (punct, _) = cursor.punct()?;
673 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 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}