Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion maud/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions maud/tests/unchecked.rs
Original file line number Diff line number Diff line change
@@ -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(), "<div><p>");
}

#[test]
fn unchecked_accepts_void_element_with_braces() {
let result = html_unchecked! {
br {}
img {}
};
assert_eq!(result.into_string(), "<br></br><img></img>");
}
13 changes: 13 additions & 0 deletions maud/tests/warnings/block-element-with-semicolon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use maud::html;

fn main() {
html! {
div;
p;
my-custom-element;
.thing;
@if true {
span;
}
};
}
39 changes: 39 additions & 0 deletions maud/tests/warnings/block-element-with-semicolon.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
error: `<div>` 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: `<p>` 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: `<my-custom-element>` 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: `<div>` 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: `<span>` 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 `{}`
12 changes: 12 additions & 0 deletions maud/tests/warnings/void-element-with-block.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use maud::html;

fn main() {
html! {
br {}
img alt="some text" {}
meta charset="utf-8" {}
div {
a { hr {} }
}
};
}
31 changes: 31 additions & 0 deletions maud/tests/warnings/void-element-with-block.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
error: `<br>` 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: `<img>` 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: `<meta>` 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: `<hr>` 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 `;`
93 changes: 93 additions & 0 deletions maud_macros/src/check.rs
Original file line number Diff line number Diff line change
@@ -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<Element>, diagnostics: &mut Vec<Diagnostic>) {
for markup in &markups.markups {
check_markup(markup, diagnostics);
}
}

fn check_markup(markup: &Markup<Element>, diagnostics: &mut Vec<Diagnostic>) {
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<Element>, diagnostics: &mut Vec<Diagnostic>) {
check_elements(&block.markups, diagnostics);
}

fn check_element(element: &Element, diagnostics: &mut Vec<Diagnostic>) {
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<Element>, diagnostics: &mut Vec<Diagnostic>) {
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<Element>, diagnostics: &mut Vec<Diagnostic>) {
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),
}
}
}
16 changes: 14 additions & 2 deletions maud_macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
extern crate proc_macro;

mod ast;
mod check;
mod escape;
mod generate;

Expand All @@ -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();
Expand All @@ -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());
Expand Down
Loading