diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f50e52..266e95db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ [#492](https://github.com/lambda-fairy/maud/pull/492) - Remove Tide support and documentation references. [#501](https://github.com/lambda-fairy/maud/pull/501) +- Add check for correct closing method of html element. `html_unchecked!` skips this check. + [#505](https://github.com/lambda-fairy/maud/pull/505) ## [0.27.0] - 2025-02-02 diff --git a/maud/src/lib.rs b/maud/src/lib.rs index f885e6c0..b1fb7a50 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -14,7 +14,7 @@ extern crate alloc; use alloc::{borrow::Cow, boxed::Box, string::String}; use core::fmt::{self, Arguments, Display, Write}; -pub use maud_macros::html; +pub use maud_macros::{html, html_unchecked}; mod escape; diff --git a/maud/tests/unchecked.rs b/maud/tests/unchecked.rs new file mode 100644 index 00000000..3e78b918 --- /dev/null +++ b/maud/tests/unchecked.rs @@ -0,0 +1,21 @@ +use maud::html_unchecked; + +// These tests purposefully produce invalid html + +#[test] +fn unchecked_accepts_non_void_element_with_semicolon() { + let result = html_unchecked! { + div; + p; + }; + assert_eq!(result.into_string(), "

"); +} + +#[test] +fn unchecked_accepts_void_element_with_braces() { + let result = html_unchecked! { + br {} + img {} + }; + assert_eq!(result.into_string(), "

"); +} diff --git a/maud/tests/warnings/block-element-with-semicolon.rs b/maud/tests/warnings/block-element-with-semicolon.rs new file mode 100644 index 00000000..ebf31e18 --- /dev/null +++ b/maud/tests/warnings/block-element-with-semicolon.rs @@ -0,0 +1,13 @@ +use maud::html; + +fn main() { + html! { + div; + p; + my-custom-element; + .thing; + @if true { + span; + } + }; +} diff --git a/maud/tests/warnings/block-element-with-semicolon.stderr b/maud/tests/warnings/block-element-with-semicolon.stderr new file mode 100644 index 00000000..52675d67 --- /dev/null +++ b/maud/tests/warnings/block-element-with-semicolon.stderr @@ -0,0 +1,39 @@ +error: `

` is not a void element, so it cannot be closed with `;` + --> tests/warnings/block-element-with-semicolon.rs:5:12 + | +5 | div; + | ^ + | + = help: change this to `{}` + +error: `

` is not a void element, so it cannot be closed with `;` + --> tests/warnings/block-element-with-semicolon.rs:6:10 + | +6 | p; + | ^ + | + = help: change this to `{}` + +error: `` is not a void element, so it cannot be closed with `;` + --> tests/warnings/block-element-with-semicolon.rs:7:26 + | +7 | my-custom-element; + | ^ + | + = help: change this to `{}` + +error: `

` is not a void element, so it cannot be closed with `;` + --> tests/warnings/block-element-with-semicolon.rs:8:15 + | +8 | .thing; + | ^ + | + = help: change this to `{}` + +error: `` is not a void element, so it cannot be closed with `;` + --> tests/warnings/block-element-with-semicolon.rs:10:17 + | +10 | span; + | ^ + | + = help: change this to `{}` diff --git a/maud/tests/warnings/void-element-with-block.rs b/maud/tests/warnings/void-element-with-block.rs new file mode 100644 index 00000000..5a3c143f --- /dev/null +++ b/maud/tests/warnings/void-element-with-block.rs @@ -0,0 +1,12 @@ +use maud::html; + +fn main() { + html! { + br {} + img alt="some text" {} + meta charset="utf-8" {} + div { + a { hr {} } + } + }; +} diff --git a/maud/tests/warnings/void-element-with-block.stderr b/maud/tests/warnings/void-element-with-block.stderr new file mode 100644 index 00000000..bf9a0b13 --- /dev/null +++ b/maud/tests/warnings/void-element-with-block.stderr @@ -0,0 +1,31 @@ +error: `
` is a void element and cannot have a closing tag + --> $DIR/void-element-with-block.rs:5:12 + | +5 | br {} + | ^ + | + = help: change this to `;` + +error: `` is a void element and cannot have a closing tag + --> $DIR/void-element-with-block.rs:6:29 + | +6 | img alt="some text" {} + | ^ + | + = help: change this to `;` + +error: `` is a void element and cannot have a closing tag + --> $DIR/void-element-with-block.rs:7:30 + | +7 | meta charset="utf-8" {} + | ^ + | + = help: change this to `;` + +error: `
` is a void element and cannot have a closing tag + --> $DIR/void-element-with-block.rs:9:20 + | +9 | a { hr {} } + | ^ + | + = help: change this to `;` diff --git a/maud_macros/src/check.rs b/maud_macros/src/check.rs new file mode 100644 index 00000000..49ed1b0e --- /dev/null +++ b/maud_macros/src/check.rs @@ -0,0 +1,93 @@ +use proc_macro2_diagnostics::{Diagnostic, SpanDiagnosticExt}; +use syn::spanned::Spanned; + +use crate::ast::{ + Block, ControlFlow, ControlFlowKind, Element, ElementBody, IfExpr, IfOrBlock, Markup, Markups, +}; + +/// The void elements as defined by the HTML Living Standard. +const VOID_ELEMENTS: &[&str] = &[ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", + "wbr", +]; + +/// Checks whether each element is closed in a valid way. Non-void elements +/// must be closed with `{ ... }`, and void elements with `;`. +/// +/// Emits a diagnostic for each mismatch found. +pub fn check_elements(markups: &Markups, diagnostics: &mut Vec) { + for markup in &markups.markups { + check_markup(markup, diagnostics); + } +} + +fn check_markup(markup: &Markup, diagnostics: &mut Vec) { + match markup { + Markup::Block(block) => check_block(block, diagnostics), + Markup::Element(element) => { + check_element(element, diagnostics); + if let ElementBody::Block(block) = &element.body { + check_block(block, diagnostics); + } + } + Markup::ControlFlow(control_flow) => check_control_flow(control_flow, diagnostics), + Markup::Lit(_) | Markup::Splice { .. } | Markup::Semi(_) => {} + } +} + +fn check_block(block: &Block, diagnostics: &mut Vec) { + check_elements(&block.markups, diagnostics); +} + +fn check_element(element: &Element, diagnostics: &mut Vec) { + let name = element + .name + .as_ref() + .map(|name| name.to_string().to_lowercase()) + .unwrap_or_else(|| "div".to_owned()); + + match &element.body { + ElementBody::Void(semi) if !VOID_ELEMENTS.contains(&name.as_str()) => diagnostics.push( + semi.span() + .error(format!( + "`<{name}>` is not a void element, so it cannot be closed with `;`" + )) + .help("change this to `{}`"), + ), + ElementBody::Block(block) if VOID_ELEMENTS.contains(&name.as_str()) => diagnostics.push( + block + .brace_token + .span + .open() + .error(format!( + "`<{name}>` is a void element and cannot have a closing tag" + )) + .help("change this to `;`"), + ), + _ => {} + } +} + +fn check_control_flow(control_flow: &ControlFlow, diagnostics: &mut Vec) { + match &control_flow.kind { + ControlFlowKind::Let(_) => {} + ControlFlowKind::If(if_) => check_if(if_, diagnostics), + ControlFlowKind::For(for_) => check_block(&for_.body, diagnostics), + ControlFlowKind::While(while_) => check_block(&while_.body, diagnostics), + ControlFlowKind::Match(match_) => { + for arm in &match_.arms { + check_markup(&arm.body, diagnostics); + } + } + } +} + +fn check_if(if_: &IfExpr, diagnostics: &mut Vec) { + check_block(&if_.then_branch, diagnostics); + if let Some((_, _, else_branch)) = &if_.else_branch { + match &**else_branch { + IfOrBlock::If(if_) => check_if(if_, diagnostics), + IfOrBlock::Block(block) => check_block(block, diagnostics), + } + } +} diff --git a/maud_macros/src/lib.rs b/maud_macros/src/lib.rs index b80014be..6c7b7661 100644 --- a/maud_macros/src/lib.rs +++ b/maud_macros/src/lib.rs @@ -5,6 +5,7 @@ extern crate proc_macro; mod ast; +mod check; mod escape; mod generate; @@ -16,10 +17,17 @@ use syn::parse::{ParseStream, Parser}; #[proc_macro] pub fn html(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - expand(input.into()).into() + expand(input.into(), true).into() } -fn expand(input: TokenStream) -> TokenStream { +/// Like [`html`], but does not check that elements are closed in a way that +/// is valid for void elements. +#[proc_macro] +pub fn html_unchecked(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + expand(input.into(), false).into() +} + +fn expand(input: TokenStream, check: bool) -> TokenStream { // Heuristic: the size of the resulting markup tends to correlate with the // code size of the template itself let size_hint = input.to_string().len(); @@ -41,6 +49,10 @@ fn expand(input: TokenStream) -> TokenStream { } }; + if check { + check::check_elements(&markups, &mut diagnostics); + } + let diag_tokens = diagnostics.into_iter().map(Diagnostic::emit_as_expr_tokens); let output_ident = Ident::new("__maud_output", Span::mixed_site());