1use super::template::{TAttr, TNode};
13use crate::token::Span;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Severity {
17 Error,
18 Warning,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum FindingKind {
23 ContentOutsideBlock,
25 DuplicateBlock(String),
27 DuplicateChildren,
29 BindOnNonInput(String),
31 ClassToggleOnComponent(String),
33 ImageWithoutAlt,
35 ButtonWithoutName,
37 InputWithoutLabel,
39 LinkWithoutHref,
41 FormWithoutCsrf(String),
43 InlineEventHandler(String),
45 BindNotHonoured(&'static str),
55 RewrittenByParser(ParserRewrite, String),
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ParserRewrite {
65 WrapsRowInTbody,
67 WrapsCellInRow,
69 WrapsColInColgroup,
71 MovesOutOfTable,
73 ClosesParagraph,
75 ClosesLink,
77 DropsNestedForm,
79 ClosesButton,
81 ClosesItem,
83 ClosesOption,
85 ClosesHeading,
87}
88
89impl ParserRewrite {
90 fn says(self) -> (&'static str, &'static str) {
92 use ParserRewrite::*;
93 match self {
94 WrapsRowInTbody => ("wraps the row in a `<tbody>`", "Put the rows in `<tbody>…</tbody>` (or `<thead>`/`<tfoot>`)."),
95 WrapsCellInRow => ("wraps the cell in a `<tr>`", "Put the cells in a `<tr>`."),
96 WrapsColInColgroup => ("wraps it in a `<colgroup>`", "Put the `<col>`s in a `<colgroup>`."),
97 MovesOutOfTable => ("moves it out, in front of the table", "Put it inside a cell (`<td>`), or outside the table."),
98 ClosesParagraph => ("closes the `<p>` before it, so it and what follows it land outside the paragraph", "Use a `<div>` in place of the `<p>`, or keep the paragraph to text and inline elements."),
99 ClosesLink => ("closes the outer link first, so the two links end up side by side", "Links cannot nest: move the inner one out, or make the outer one a `<div>`."),
100 DropsNestedForm => ("drops the inner form's tag", "Forms cannot nest: close the outer form first."),
101 ClosesButton => ("closes the outer button first", "Buttons cannot nest: move the inner one out."),
102 ClosesItem => ("closes the outer item first", "Nest a list (`<ul>`, `<ol>`, `<dl>`) between the two items."),
103 ClosesOption => ("closes the outer option first", "Options cannot nest: group them with `<optgroup>` inside the `<select>`."),
104 ClosesHeading => ("closes the outer heading first", "Headings cannot nest: use a `<span>` inside the heading."),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Finding {
111 pub kind: FindingKind,
112 pub severity: Severity,
113 pub span: Span,
114 pub message: String,
115}
116
117fn finding(kind: FindingKind, span: Span) -> Finding {
118 use FindingKind::*;
119 let (severity, message) = match &kind {
120 ContentOutsideBlock => (Severity::Error, "A view that `{extends}` a layout fills the layout's blocks: put this content inside a `{block name:} … {end}` (or drop the `{extends}`).".to_string()),
121 DuplicateBlock(n) => (Severity::Error, format!("`{{block {n}}}` is defined twice in this template; a block name is a slot, so name each one once.")),
122 DuplicateChildren => (Severity::Error, "`{children}` can appear once: it is where the caller's content goes. For several slots, use named `{block}`s.".to_string()),
123 BindOnNonInput(tag) => (Severity::Error, format!("`bind={{…}}` two-way binds a form control's value; `<{tag}>` has none. Use it on `<input>`, `<textarea>` or `<select>`, or show the value with a hole `{{…}}`.")),
124 ClassToggleOnComponent(name) => (Severity::Error, format!("`class:` toggles a class on an HTML element; `<{name}>` is a component and takes props. Pass the flag as a prop (`<{name} done={{…}}>`) and toggle inside its view.")),
125 ImageWithoutAlt => (Severity::Warning, "This `<img>` has no `alt`. Describe it (`alt=\"A cat\"`), or mark it decorative with `alt=\"\"` so screen readers skip it.".to_string()),
126 ButtonWithoutName => (Severity::Warning, "This `<button>` announces nothing: give it text, a `{…}` hole, or `aria-label=\"…\"`.".to_string()),
127 InputWithoutLabel => (Severity::Warning, "This form control has no label. Add `<label for=\"id\">` with a matching `id`, wrap it in `<label>…</label>`, or set `aria-label=\"…\"`.".to_string()),
128 LinkWithoutHref => (Severity::Warning, "This `<a>` has no `href`, so it is not a link: add `href=\"…\"`, or use a `<button>` for an action.".to_string()),
129 FormWithoutCsrf(method) => (Severity::Error, format!("This `<form method=\"{method}\">` changes something, so a browser can be made to submit it from another site. Add the token this page was given: `<input type=\"hidden\" name=\"_csrf\" value={{token}}>`, and check it with `csrfValid` in the route that answers. (A `get` form changes nothing and needs none.)")),
133 BindNotHonoured(why) => (Severity::Error, format!("{why}.")),
142 RewrittenByParser(rewrite, what) => {
143 let (does, fix) = rewrite.says();
144 (Severity::Warning, format!("A browser's HTML parser does not keep {what} where this view puts it: it {does}. The page a browser builds from the pre-rendered markup is then not the page this view draws, so the page is drawn again as it loads, and what a visitor focused or typed first is lost. {fix}"))
145 }
146 InlineEventHandler(name) => (Severity::Error, format!("`{name}` is an inline JavaScript handler, and a view cannot carry one: behavior belongs in `Actions:`. Write the action and attach it with `on-{}={{…}}` — note the hyphen, which is what tells the framework to wire it up. (Escaping cannot help inside `{name}`: its value is script, not markup.)", name.strip_prefix("on").unwrap_or(name))),
147 };
148 Finding {
149 kind,
150 severity,
151 span,
152 message,
153 }
154}
155
156fn has_csrf_field(nodes: &[TNode]) -> bool {
161 nodes.iter().any(|n| match n {
162 TNode::Element {
163 tag,
164 attrs,
165 children,
166 ..
167 } => {
168 (tag.eq_ignore_ascii_case("input")
169 && static_attr(attrs, "name").is_some_and(|v| v == "_csrf"))
170 || has_csrf_field(children)
171 }
172 TNode::If {
173 arms, otherwise, ..
174 } => {
175 arms.iter().any(|(_, body)| has_csrf_field(body))
176 || otherwise.as_ref().is_some_and(|o| has_csrf_field(o))
177 }
178 TNode::For { body, empty, .. } => {
179 has_csrf_field(body) || empty.as_ref().is_some_and(|e| has_csrf_field(e))
180 }
181 TNode::Inspect { arms, .. } => arms.iter().any(|(_, body)| has_csrf_field(body)),
182 TNode::Block {
183 default: Some(body),
184 ..
185 } => has_csrf_field(body),
186 TNode::Component { children, .. } => has_csrf_field(children),
187 _ => false,
188 })
189}
190
191pub fn check(nodes: &[TNode]) -> Vec<Finding> {
193 let mut out = Vec::new();
194 if matches!(nodes.first(), Some(TNode::Extends { .. })) {
196 for n in &nodes[1..] {
197 match n {
198 TNode::Block { .. } => {}
199 TNode::Text { text, .. } if text.trim().is_empty() => {}
200 other => out.push(finding(FindingKind::ContentOutsideBlock, node_span(other))),
201 }
202 }
203 }
204 let mut blocks: Vec<String> = Vec::new();
205 let mut children_seen = 0usize;
206 let labels_for = collect_label_targets(nodes);
207 walk(
208 nodes,
209 false,
210 &labels_for,
211 &mut blocks,
212 &mut children_seen,
213 &mut out,
214 );
215 parser_rewrites(nodes, &mut Vec::new(), &mut out);
216 out
217}
218
219const TABLE_PARTS: &[&str] = &["table", "tbody", "thead", "tfoot", "tr"];
221
222const CLOSES_P: &[&str] = &[
224 "address",
225 "article",
226 "aside",
227 "blockquote",
228 "details",
229 "dialog",
230 "div",
231 "dl",
232 "fieldset",
233 "figcaption",
234 "figure",
235 "footer",
236 "form",
237 "h1",
238 "h2",
239 "h3",
240 "h4",
241 "h5",
242 "h6",
243 "header",
244 "hgroup",
245 "hr",
246 "main",
247 "menu",
248 "nav",
249 "ol",
250 "p",
251 "pre",
252 "search",
253 "section",
254 "table",
255 "ul",
256];
257
258const SCOPE_BOUNDS: &[&str] = &[
260 "applet", "caption", "html", "table", "td", "th", "marquee", "object", "template",
261];
262
263const ITEM_BOUNDS: &[&str] = &[
266 "applet",
267 "article",
268 "aside",
269 "blockquote",
270 "body",
271 "button",
272 "caption",
273 "center",
274 "colgroup",
275 "details",
276 "dir",
277 "dl",
278 "fieldset",
279 "figcaption",
280 "figure",
281 "footer",
282 "form",
283 "h1",
284 "h2",
285 "h3",
286 "h4",
287 "h5",
288 "h6",
289 "header",
290 "hgroup",
291 "html",
292 "iframe",
293 "main",
294 "marquee",
295 "menu",
296 "nav",
297 "object",
298 "ol",
299 "pre",
300 "section",
301 "select",
302 "summary",
303 "table",
304 "tbody",
305 "td",
306 "template",
307 "textarea",
308 "tfoot",
309 "th",
310 "thead",
311 "tr",
312 "ul",
313];
314
315const HEADINGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
316
317fn open_within(open: &[String], hits: &[&str], bounds: &[&str]) -> bool {
320 for a in open.iter().rev() {
321 if hits.contains(&a.as_str()) {
322 return true;
323 }
324 if bounds.contains(&a.as_str()) {
325 return false;
326 }
327 }
328 false
329}
330
331fn rewrite_of(tag: &str, open: &[String]) -> Option<ParserRewrite> {
333 use ParserRewrite::*;
334 let parent = open.last().map(String::as_str).unwrap_or("");
335 let table_content = |t: &str| match parent {
336 "table" => matches!(
337 t,
338 "caption"
339 | "colgroup"
340 | "col"
341 | "thead"
342 | "tbody"
343 | "tfoot"
344 | "tr"
345 | "script"
346 | "template"
347 | "style"
348 ),
349 "tbody" | "thead" | "tfoot" => {
350 matches!(t, "tr" | "td" | "th" | "script" | "template" | "style")
351 }
352 "tr" => matches!(t, "td" | "th" | "script" | "template" | "style"),
353 _ => true,
354 };
355 if parent == "table" && tag == "tr" {
356 return Some(WrapsRowInTbody);
357 }
358 if matches!(parent, "table" | "tbody" | "thead" | "tfoot") && matches!(tag, "td" | "th") {
359 return Some(WrapsCellInRow);
360 }
361 if parent == "table" && tag == "col" {
362 return Some(WrapsColInColgroup);
363 }
364 if !table_content(tag) {
365 return Some(MovesOutOfTable);
366 }
367 if CLOSES_P.contains(&tag) && open_within(open, &["p"], &[SCOPE_BOUNDS, &["button"]].concat()) {
368 return Some(ClosesParagraph);
369 }
370 match tag {
371 "a" if open_within(
372 open,
373 &["a"],
374 &[
375 "applet", "caption", "marquee", "object", "template", "td", "th",
376 ],
377 ) =>
378 {
379 Some(ClosesLink)
380 }
381 "form" if open.iter().any(|a| a == "form") => Some(DropsNestedForm),
382 "button" if open_within(open, &["button"], SCOPE_BOUNDS) => Some(ClosesButton),
383 "li" if open_within(open, &["li"], ITEM_BOUNDS) => Some(ClosesItem),
384 "dd" | "dt" if open_within(open, &["dd", "dt"], ITEM_BOUNDS) => Some(ClosesItem),
385 "option" if parent == "option" => Some(ClosesOption),
386 "optgroup" if matches!(parent, "option" | "optgroup") => Some(ClosesOption),
387 _ if HEADINGS.contains(&tag) && HEADINGS.contains(&parent) => Some(ClosesHeading),
388 _ => None,
389 }
390}
391
392fn parser_rewrites(nodes: &[TNode], open: &mut Vec<String>, out: &mut Vec<Finding>) {
396 for n in nodes {
397 match n {
398 TNode::Element {
399 tag,
400 children,
401 span,
402 ..
403 } => {
404 let t = tag.to_ascii_lowercase();
405 if let Some(rewrite) = rewrite_of(&t, open) {
406 out.push(finding(
407 FindingKind::RewrittenByParser(rewrite, format!("`<{t}>`")),
408 *span,
409 ));
410 }
411 open.push(t);
412 parser_rewrites(children, open, out);
413 open.pop();
414 }
415 TNode::Text { text, span }
416 if !text.trim().is_empty()
417 && TABLE_PARTS.contains(&open.last().map(String::as_str).unwrap_or("")) =>
418 {
419 out.push(finding(
420 FindingKind::RewrittenByParser(ParserRewrite::MovesOutOfTable, "text".into()),
421 *span,
422 ));
423 }
424 TNode::Hole { span, .. }
425 if TABLE_PARTS.contains(&open.last().map(String::as_str).unwrap_or("")) =>
426 {
427 out.push(finding(
428 FindingKind::RewrittenByParser(ParserRewrite::MovesOutOfTable, "text".into()),
429 *span,
430 ));
431 }
432 TNode::Component { children, .. } => parser_rewrites(children, &mut Vec::new(), out),
433 TNode::Block {
434 default: Some(body),
435 ..
436 } => parser_rewrites(body, open, out),
437 TNode::If {
438 arms, otherwise, ..
439 } => {
440 for (_, body) in arms {
441 parser_rewrites(body, open, out);
442 }
443 if let Some(o) = otherwise {
444 parser_rewrites(o, open, out);
445 }
446 }
447 TNode::For { body, empty, .. } => {
448 parser_rewrites(body, open, out);
449 if let Some(e) = empty {
450 parser_rewrites(e, open, out);
451 }
452 }
453 TNode::Inspect { arms, .. } => {
454 for (_, body) in arms {
455 parser_rewrites(body, open, out);
456 }
457 }
458 _ => {}
459 }
460 }
461}
462
463fn node_span(n: &TNode) -> Span {
464 match n {
465 TNode::Element { span, .. }
466 | TNode::Text { span, .. }
467 | TNode::Hole { span, .. }
468 | TNode::If { span, .. }
469 | TNode::For { span, .. }
470 | TNode::Inspect { span, .. }
471 | TNode::Let { span, .. }
472 | TNode::Extends { span, .. }
473 | TNode::Block { span, .. }
474 | TNode::Children { span }
475 | TNode::Component { span, .. } => *span,
476 }
477}
478
479fn collect_label_targets(nodes: &[TNode]) -> Vec<String> {
482 let mut out = Vec::new();
483 fn go(nodes: &[TNode], out: &mut Vec<String>) {
484 for n in nodes {
485 match n {
486 TNode::Element {
487 tag,
488 attrs,
489 children,
490 ..
491 } => {
492 if tag.eq_ignore_ascii_case("label") {
493 if let Some(v) = static_attr(attrs, "for") {
494 out.push(v.to_string());
495 }
496 }
497 go(children, out);
498 }
499 TNode::Component { children, .. }
500 | TNode::Block {
501 default: Some(children),
502 ..
503 } => go(children, out),
504 TNode::If {
505 arms, otherwise, ..
506 } => {
507 for (_, b) in arms {
508 go(b, out);
509 }
510 if let Some(o) = otherwise {
511 go(o, out);
512 }
513 }
514 TNode::For { body, empty, .. } => {
515 go(body, out);
516 if let Some(e) = empty {
517 go(e, out);
518 }
519 }
520 TNode::Inspect { arms, .. } => {
521 for (_, b) in arms {
522 go(b, out);
523 }
524 }
525 _ => {}
526 }
527 }
528 }
529 go(nodes, &mut out);
530 out
531}
532
533fn static_attr<'a>(attrs: &'a [TAttr], name: &str) -> Option<&'a str> {
534 attrs.iter().find_map(|a| match a {
535 TAttr::Static { name: n, value, .. } if n.eq_ignore_ascii_case(name) => {
536 Some(value.as_deref().unwrap_or(""))
537 }
538 _ => None,
539 })
540}
541
542fn has_attr(attrs: &[TAttr], name: &str) -> bool {
543 attrs.iter().any(|a| match a {
544 TAttr::Static { name: n, .. } | TAttr::Dynamic { name: n, .. } => {
545 n.eq_ignore_ascii_case(name)
546 }
547 _ => false,
548 })
549}
550
551fn inline_handler_attr(attrs: &[TAttr]) -> Option<String> {
562 attrs.iter().find_map(|a| {
563 let name = match a {
564 TAttr::Static { name, .. } | TAttr::Dynamic { name, .. } => name,
565 _ => return None,
566 };
567 let lower = name.to_ascii_lowercase();
568 let rest = lower.strip_prefix("on")?;
569 rest.chars()
570 .next()
571 .filter(char::is_ascii_alphabetic)
572 .map(|_| lower)
573 })
574}
575
576fn method_changes_state(method: &str) -> bool {
578 matches!(
579 method.to_ascii_lowercase().as_str(),
580 "post" | "put" | "patch" | "delete"
581 )
582}
583
584fn state_changing_method(attrs: &[TAttr], children: &[TNode]) -> Option<String> {
599 match static_attr(attrs, "method") {
601 Some(m) if method_changes_state(m) => return Some(m.to_ascii_lowercase()),
602 Some(_) => {}
603 None if has_attr(attrs, "method") => return Some("post".to_string()),
604 None => {}
605 }
606 overriding_method(children)
607}
608
609fn overriding_method(nodes: &[TNode]) -> Option<String> {
612 nodes.iter().find_map(|n| match n {
613 TNode::Element {
614 attrs, children, ..
615 } => match static_attr(attrs, "formmethod") {
616 Some(m) if method_changes_state(m) => Some(m.to_ascii_lowercase()),
617 Some(_) => overriding_method(children),
618 None if has_attr(attrs, "formmethod") => Some("post".to_string()),
619 None => overriding_method(children),
620 },
621 TNode::If {
622 arms, otherwise, ..
623 } => arms
624 .iter()
625 .find_map(|(_, b)| overriding_method(b))
626 .or_else(|| otherwise.as_deref().and_then(overriding_method)),
627 TNode::For { body, empty, .. } => {
628 overriding_method(body).or_else(|| empty.as_deref().and_then(overriding_method))
629 }
630 TNode::Inspect { arms, .. } => arms.iter().find_map(|(_, b)| overriding_method(b)),
631 TNode::Block {
632 default: Some(body),
633 ..
634 } => overriding_method(body),
635 TNode::Component { children, .. } => overriding_method(children),
636 _ => None,
637 })
638}
639
640fn has_visible_content(children: &[TNode]) -> bool {
641 children.iter().any(|c| match c {
642 TNode::Text { text, .. } => !text.trim().is_empty(),
643 TNode::Hole { .. }
644 | TNode::Component { .. }
645 | TNode::Children { .. }
646 | TNode::Block { .. } => true,
647 TNode::Element {
648 tag,
649 attrs,
650 children,
651 ..
652 } => {
653 (tag.eq_ignore_ascii_case("img")
654 && static_attr(attrs, "alt").is_some_and(|a| !a.is_empty()))
655 || has_visible_content(children)
656 }
657 TNode::If {
658 arms, otherwise, ..
659 } => {
660 arms.iter().any(|(_, b)| has_visible_content(b))
661 || otherwise.as_deref().is_some_and(has_visible_content)
662 }
663 TNode::For { body, .. } => has_visible_content(body),
664 TNode::Inspect { arms, .. } => arms.iter().any(|(_, b)| has_visible_content(b)),
665 _ => false,
666 })
667}
668
669fn walk(
670 nodes: &[TNode],
671 inside_label: bool,
672 labels_for: &[String],
673 blocks: &mut Vec<String>,
674 children_seen: &mut usize,
675 out: &mut Vec<Finding>,
676) {
677 for n in nodes {
678 match n {
679 TNode::Element {
680 tag,
681 attrs,
682 children,
683 span,
684 } => {
685 let t = tag.to_ascii_lowercase();
686 if attrs.iter().any(|a| matches!(a, TAttr::Bind { .. }))
687 && !matches!(t.as_str(), "input" | "textarea" | "select")
688 {
689 out.push(finding(FindingKind::BindOnNonInput(tag.clone()), *span));
690 }
691 if attrs.iter().any(|a| matches!(a, TAttr::Bind { .. })) {
695 let refused = if t == "select" && has_attr(attrs, "multiple") {
696 crate::ast::view::BoundInput::SelectMultiple.refusal()
697 } else if t == "input"
698 && static_attr(attrs, "type")
699 .is_some_and(|v| v.eq_ignore_ascii_case("file"))
700 {
701 crate::ast::view::BoundInput::File.refusal()
702 } else {
703 None
704 };
705 if let Some(why) = refused {
706 out.push(finding(FindingKind::BindNotHonoured(why), *span));
707 }
708 }
709 if let Some(handler) = inline_handler_attr(attrs) {
710 out.push(finding(FindingKind::InlineEventHandler(handler), *span));
711 }
712 if t == "form" {
716 if let Some(method) = state_changing_method(attrs, children) {
717 if !has_csrf_field(children) {
718 out.push(finding(FindingKind::FormWithoutCsrf(method), *span));
719 }
720 }
721 }
722 match t.as_str() {
723 "img" if !has_attr(attrs, "alt") => {
724 out.push(finding(FindingKind::ImageWithoutAlt, *span))
725 }
726 "button"
727 if !has_visible_content(children)
728 && !has_attr(attrs, "aria-label")
729 && !has_attr(attrs, "aria-labelledby")
730 && !has_attr(attrs, "title") =>
731 {
732 out.push(finding(FindingKind::ButtonWithoutName, *span))
733 }
734 "input" | "textarea" | "select" => {
735 let ty = static_attr(attrs, "type")
736 .unwrap_or("text")
737 .to_ascii_lowercase();
738 let needs_label = t != "input"
739 || !matches!(
740 ty.as_str(),
741 "hidden" | "submit" | "button" | "reset" | "image"
742 );
743 let labelled = inside_label
744 || has_attr(attrs, "aria-label")
745 || has_attr(attrs, "aria-labelledby")
746 || attrs.iter().any(|a| matches!(a, TAttr::Dynamic { name, .. } if name.eq_ignore_ascii_case("id")))
747 || static_attr(attrs, "id").is_some_and(|id| labels_for.iter().any(|l| l == id));
748 if needs_label && !labelled {
749 out.push(finding(FindingKind::InputWithoutLabel, *span));
750 }
751 }
752 "a" if !has_attr(attrs, "href") => {
753 out.push(finding(FindingKind::LinkWithoutHref, *span))
754 }
755 _ => {}
756 }
757 walk(
758 children,
759 inside_label || t == "label",
760 labels_for,
761 blocks,
762 children_seen,
763 out,
764 );
765 }
766 TNode::Component {
767 name,
768 props,
769 children,
770 span,
771 } => {
772 if props.iter().any(|a| matches!(a, TAttr::Class { .. })) {
773 out.push(finding(
774 FindingKind::ClassToggleOnComponent(name.clone()),
775 *span,
776 ));
777 }
778 walk(
779 children,
780 inside_label,
781 labels_for,
782 blocks,
783 children_seen,
784 out,
785 );
786 }
787 TNode::Block {
788 name,
789 default,
790 span,
791 } => {
792 if blocks.iter().any(|b| b == name) {
793 out.push(finding(FindingKind::DuplicateBlock(name.clone()), *span));
794 } else {
795 blocks.push(name.clone());
796 }
797 if let Some(d) = default {
798 walk(d, inside_label, labels_for, blocks, children_seen, out);
799 }
800 }
801 TNode::Children { span } => {
802 *children_seen += 1;
803 if *children_seen > 1 {
804 out.push(finding(FindingKind::DuplicateChildren, *span));
805 }
806 }
807 TNode::If {
808 arms, otherwise, ..
809 } => {
810 for (_, b) in arms {
811 walk(b, inside_label, labels_for, blocks, children_seen, out);
812 }
813 if let Some(o) = otherwise {
814 walk(o, inside_label, labels_for, blocks, children_seen, out);
815 }
816 }
817 TNode::For { body, empty, .. } => {
818 walk(body, inside_label, labels_for, blocks, children_seen, out);
819 if let Some(e) = empty {
820 walk(e, inside_label, labels_for, blocks, children_seen, out);
821 }
822 }
823 TNode::Inspect { arms, .. } => {
824 for (_, b) in arms {
825 walk(b, inside_label, labels_for, blocks, children_seen, out);
826 }
827 }
828 TNode::Text { .. } | TNode::Hole { .. } | TNode::Let { .. } | TNode::Extends { .. } => {
829 }
830 }
831 }
832}
833
834const HEAD_ELEMENTS: &[&str] = &["title", "meta", "link", "base", "html"];
845
846pub fn first_non_head_element(
852 nodes: &[crate::lgx::template::TNode],
853) -> Option<(String, crate::token::Span)> {
854 use crate::lgx::template::TNode;
855 for n in nodes {
856 match n {
857 TNode::Element {
858 tag,
859 children,
860 span,
861 ..
862 } => {
863 if !HEAD_ELEMENTS.contains(&tag.to_ascii_lowercase().as_str()) {
864 return Some((tag.clone(), *span));
865 }
866 if let Some(bad) = first_non_head_element(children) {
870 return Some(bad);
871 }
872 }
873 TNode::Component { name, span, .. } => return Some((name.clone(), *span)),
874 _ => {}
875 }
876 }
877 None
878}