Commit c95af61
HTML macro for Rust projects This is a macro that creates a Rust String from an HTML block. Example: let content = html!{{ <html></html> }} Created by Johan Mattsson for Kajacode.
Changed files (24)
added.gitignoreaddedCargo.lockaddedCargo.tomladdedREADME.mdaddedsrc/htmlcontentbuilder.rsaddedsrc/htmllexer.rsaddedsrc/htmlquotes.rsaddedsrc/lib.rsaddedtest-fail-messages/include/Cargo.lockaddedtest-fail-messages/include/Cargo.tomladdedtest-fail-messages/include/main.rsaddedtest-fail-messages/start/Cargo.lockaddedtest-fail-messages/start/Cargo.tomladdedtest-fail-messages/start/main.rsaddedtest-fail-messages/structmember/Cargo.lockaddedtest-fail-messages/structmember/Cargo.tomladdedtest-fail-messages/structmember/main.rsaddedtest-fail-messages/variable/Cargo.lockaddedtest-fail-messages/variable/Cargo.tomladdedtest-fail-messages/variable/main.rsaddedtests/injection.rsaddedtests/interleaved.rsaddedtests/other.rsaddedtests/tag.rs
Diff
new file mode 100644@@ -0,0 +1 @@+targetnew file mode 100644@@ -0,0 +1,88 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "kaja-html-macro"+version = "1.0.0"+dependencies = [+ "proc-macro-error",+ "proc-macro2",+ "quote",+ "syn 2.0.112",+]++[[package]]+name = "proc-macro-error"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"+dependencies = [+ "proc-macro-error-attr",+ "proc-macro2",+ "quote",+ "syn 1.0.109",+ "version_check",+]++[[package]]+name = "proc-macro-error-attr"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"+dependencies = [+ "proc-macro2",+ "quote",+ "version_check",+]++[[package]]+name = "proc-macro2"+version = "1.0.104"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "syn"+version = "1.0.109"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"+dependencies = [+ "proc-macro2",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "2.0.112"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"new file mode 100644@@ -0,0 +1,14 @@+[package]+name = "kaja-html-macro"+version = "1.0.0"+edition = "2024"+license = "MIT OR Apache-2.0"++[lib]+proc-macro = true++[dependencies]+proc-macro-error = "1.0.4"+proc-macro2 = "1"+quote = "1"+syn = "2.0.101"new file mode 100644@@ -0,0 +1,121 @@+# html! — Declarative HTML Macro for Rust++`html!` is a declarative macro for generating HTML directly inside your Rust+project. It expands at compile time into `String`.++## Concept++- Write HTML-like markup directly in Rust+- Embed Rust control flow with `<rust>`, plain Rust code, no DSL+- Emit markup dynamically using `<markup>`+- Interpolate variables and expressions+- Returns a `String`++## Interpolation Rules++### `$variable`++Inserts a variable and escapes HTML tags.++```rust+use htmlmacro::html;+let name = "<b>Johan</b>";++let html = html! {{+ <p>Hello $name</p>+}};+```++Output:++```html+<p>Hello <b>Johan</b></p>+```++### `$(expression)`++You can evaluate arbitrary Rust expressions:++- Function calls+- Arithmetic+- Method calls+- Complex expressions++Expressions are also automatically escaped.++```rust+use htmlmacro::html;+fn get_value() -> String {+ "42".to_string()+}++let test = 5;++let html = html! {{+ <div>+ <p>Value: $(get_value())</p>+ <p>Next: $(test + 1)</p>+ </div>+}};++```++## Generating HTML strings with this Rust macro++Rust logic can be embedded inside `<rust>` blocks.+Markup emitted from Rust should be wrapped in `<markup>`.++Use `<include />` to insert HTML content from another component.++```rust+use htmlmacro::html;++fn parent() -> String {+ html! {{+ <div>+ <include child_component_simple_loop() />+ </div>+ }}+}++fn child_component_simple_loop() -> String {+ html! {{+ <h1>Hello From Nested Component</h1>++ <div>+ <ul>+ <rust>+ for i in 1..=3 {+ let element_id = format!("list item {:?}", i);++ <markup>+ <li>Simple $element_id</li>+ </markup>+ }+ </rust>+ </ul>+ </div>+ }}+}+```++## Output++```html+<h1>Hello From Nested Component</h1>++<div>+ <ul>+ <li>Simple list item 1</li>+ <li>Simple list item 2</li>+ <li>Simple list item 3</li>+ </ul>+</div>+```++## Variables and Functions in the Markup++- `$variable_name` -- insert content of the variable and escape tags.+- `$(get_value())` -- insert string from the get_value function and escape tags.+- `$(x + 2)` -- compute x + 2 and add the content to the html string, escaped if needed.+- `<include some_other_component() />` insert trusted HTML content from the function some_other_component, html tags are allowed.new file mode 100644@@ -0,0 +1,370 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use proc_macro2::TokenStream as TokenStream2;+use quote::quote;+use syn::parse_str;++use crate::htmllexer::*;+use crate::htmlquotes::html_quotes;++use proc_macro::Span;+use proc_macro_error::abort;++#[derive(Debug, Clone)]+pub enum ErrType {+ NoIncludeEndSlash,+ IncludeOutsideOfMarkup,+ BadTag,+ VarExpr,+ VarSingleExpr,+ Other,+}++#[derive(Debug, Clone)]+pub struct HtmlError {+ pub message: String,+ pub hint: String,+ pub line: isize,+ pub err_type: ErrType,+}++impl HtmlError {+ fn abort(&self) {+ let mut compile_message = String::new();++ compile_message.push_str("HTML macro error. ");++ if self.line >= 0 {+ let line = format!("Line: {}. ", self.line);+ compile_message.push_str(&line);+ }++ if !self.message.is_empty() {+ compile_message.push_str(&self.message);+ compile_message.push('\n');+ }++ if !self.hint.is_empty() {+ let hint = format!("Hint: {}\n", self.hint);+ compile_message.push_str(&hint);+ }++ let err_type = format!("Type: {:?}", self.err_type);+ compile_message.push_str(&err_type);++ abort!(Span::call_site(), "{}", compile_message);+ }+}++pub struct HtmlContentBuilder {+ is_markup: bool,+ is_include_tag: bool,+ current_text: String,+ first_text_line: isize,+ rust_node_count: u32,+ rust_buffer: String,+}++impl HtmlContentBuilder {+ pub fn new() -> HtmlContentBuilder {+ HtmlContentBuilder {+ is_markup: true,+ is_include_tag: false,+ current_text: String::new(),+ first_text_line: -1,+ rust_node_count: 0,+ rust_buffer: String::new(),+ }+ }++ fn make_text_node(&mut self) -> Result<(), HtmlError> {+ if self.current_text != "" {+ let text = &self.current_text;+ let line = self.first_text_line;+ let rust_push_to_html_string = html_quotes(text.as_str(), line)?;+ self.rust_buffer.push_str(&rust_push_to_html_string);+ self.current_text.clear();+ }++ Ok(())+ }++ pub fn process_tokens(&mut self, input: Vec<HtmlToken>) -> TokenStream2 {+ let result = self.process_tokens_string(input);++ match result {+ Ok(_) => {}+ Err(e) => {+ e.abort();+ }+ }++ let result = self.rust_buffer.clone();+ let mut rust_code = match result.parse::<TokenStream2>() {+ Ok(ts) => ts,+ Err(e) => {+ let err = format!(+ "Invalid generated rust code from html! macro: '{}'. parse error: {}",+ result, e+ );+ let err_ts = quote! { compile_error!(#err); };+ TokenStream2::from(err_ts)+ }+ };++ let code = quote! { __html_macro_content };+ rust_code.extend(TokenStream2::from(code));+ rust_code+ }++ fn process_tokens_string(&mut self, input: Vec<HtmlToken>) -> Result<(), HtmlError> {+ self.rust_buffer+ .push_str("let mut __html_macro_content = String::new();\n");++ let tokens: Vec<HtmlToken> = input;+ let length = tokens.len();+ let mut current_index = 0;++ while current_index < length {+ let token = &tokens[current_index];++ if next_is_include_tag(&tokens, current_index) {+ if !self.is_markup {+ return Err(HtmlError {+ message: "Include tag outside of markup.".into(),+ hint: String::new(),+ line: token.line_number,+ err_type: ErrType::IncludeOutsideOfMarkup,+ });+ }++ self.make_text_node()?;+ self.is_include_tag = true;+ current_index = self.process_include(&tokens, current_index)?;+ continue;+ }++ if next_is_rust_start(&tokens, current_index) {+ self.is_markup = false;+ self.rust_node_count += 1;+ current_index += 3; // skip <rust>+ self.make_text_node()?;+ continue;+ }++ if next_is_rust_end(&tokens, current_index) {+ if self.rust_node_count == 0 {+ return Err(HtmlError {+ message: "</rust> tag seen but no starting <rust> tag found".into(),+ hint: String::new(),+ line: token.line_number,+ err_type: ErrType::BadTag,+ });+ }++ self.is_markup = true;+ current_index += 4; // skip </rust>+ self.rust_node_count -= 1;+ self.make_text_node()?;+ continue;+ }++ if next_is_markup_start(&tokens, current_index) && !self.is_markup {+ self.is_markup = true;+ current_index += 3; // skip <markup>+ self.make_text_node()?;+ continue;+ }++ if self.next_is_markup_end(&tokens, current_index) {+ self.is_markup = false;+ current_index += 4; // skip </markup>+ self.make_text_node()?;+ continue;+ }++ // append token to stream+ let regular_token = token;++ if self.is_markup {+ self.add_token_to_text(regular_token);+ } else {+ let s = regular_token.to_string();+ self.rust_buffer.push_str(&s);+ }++ current_index += 1;+ }++ self.make_text_node()?;+ Ok(())+ }++ fn process_include(+ &mut self,+ tokens: &Vec<HtmlToken>,+ current_index: usize,+ ) -> Result<usize, HtmlError> {+ let mut current_index = current_index;+ let mut include_code_string = String::new();+ let mut rust = String::new();+ let length = tokens.len();+ let start_line = (&tokens[current_index]).line_number;++ current_index += 2;++ loop {+ let token = &tokens[current_index];+ let next = peek(&tokens, current_index, 1);++ if current_index >= length || next.is_none() {+ return Err(HtmlError {+ message: "No end slash for include tag found.".into(),+ hint: "<include example() />".into(),+ line: start_line,+ err_type: ErrType::NoIncludeEndSlash,+ });+ }++ if token.value == "/" && next.unwrap().value == ">" {+ self.is_include_tag = false;+ current_index += 2;++ let inc_code = include_code_string.trim();++ if inc_code.is_empty() {+ return Err(HtmlError {+ message: "Empty include expression.".into(),+ hint: "<include example() />".into(),+ line: start_line,+ err_type: ErrType::Other,+ });+ }++ match parse_str::<syn::Expr>(&inc_code) {+ Ok(_) => {}+ Err(_) => {+ return Err(HtmlError {+ message: format!("Invalid include expression: {}", inc_code),+ hint: "<include example() />".into(),+ line: start_line,+ err_type: ErrType::Other,+ });+ }+ }++ rust.push_str("let __included_html = ");+ rust.push_str(inc_code);+ rust.push_str(";\n");++ rust.push_str("__html_macro_content.push_str(__included_html.as_str());\n");++ self.rust_buffer.push_str(rust.as_str());+ break;+ }++ include_code_string.push_str(token.to_string().as_str());+ current_index += 1;+ }++ return Ok(current_index);+ }++ fn add_token_to_text(&mut self, token: &HtmlToken) {+ let text = token.to_string();+ self.push_str(&text, token.line_number);+ }++ fn push_str(&mut self, token: &str, line_number: isize) {+ if self.current_text.is_empty() {+ self.first_text_line = line_number;+ }++ self.current_text.push_str(token);++ if self.current_text.ends_with("<rust>") {+ // just in case there is a bug+ panic!("{:?} ends with start tag.", self.current_text);+ }++ if self.current_text.ends_with("</rust>") {+ panic!("{:?} ends with end tag.", self.current_text);+ }+ }++ fn next_is_markup_end(&self, tokens: &Vec<HtmlToken>, i: usize) -> bool {+ return is_tag_end("markup", tokens, i);+ }+}++fn next_is_rust_start(tokens: &Vec<HtmlToken>, i: usize) -> bool {+ return is_tag_start("rust", tokens, i);+}++fn next_is_rust_end(tokens: &Vec<HtmlToken>, i: usize) -> bool {+ return is_tag_end("rust", tokens, i);+}++fn next_is_markup_start(tokens: &Vec<HtmlToken>, i: usize) -> bool {+ return is_tag_start("markup", tokens, i);+}++fn peek(tokens: &Vec<HtmlToken>, i: usize, offset: usize) -> Option<HtmlToken> {+ let len = tokens.len();++ if i + offset >= len {+ return None;+ }++ Some(tokens[i + offset].clone())+}++fn is_tag_start(tag: &str, tokens: &Vec<HtmlToken>, i: usize) -> bool {+ if i + 2 >= tokens.len() {+ return false;+ }++ let start = &tokens[i].value;+ let name = &tokens[i + 1].value;+ let end = &tokens[i + 2].value;++ if start == "<" && name == tag && end == ">" {+ return true;+ }++ false+}++fn is_tag_end(tag: &str, tokens: &Vec<HtmlToken>, i: usize) -> bool {+ if i + 3 >= tokens.len() {+ return false;+ }++ let start = &tokens[i].value;+ let slash = &tokens[i + 1].value;+ let name = &tokens[i + 2].value;+ let end = &tokens[i + 3].value;++ if start == "<" && slash == "/" && name == tag && end == ">" {+ return true;+ }++ false+}++fn next_is_include_tag(tokens: &Vec<HtmlToken>, i: usize) -> bool {+ if i + 1 >= tokens.len() {+ return false;+ }++ let start = &tokens[i].value;+ let name = &tokens[i + 1].value;++ if start == "<" && name == "include" {+ return true;+ }++ false+}new file mode 100644@@ -0,0 +1,230 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use std::fmt;+use std::iter::{Iterator, Peekable};++pub struct HtmlDynamicLexer<'a> {+ pub tokens: Vec<HtmlToken>,+ iterator: Peekable<std::str::Chars<'a>>,+ line_number: isize,+}++#[derive(Debug, Clone)]+pub struct HtmlToken {+ pub token_type: TokenType,+ pub value: String,+ pub white_space_before: String,+ pub white_space_after: String,+ pub line_number: isize,+}++#[derive(Debug, Clone, PartialEq)]+pub enum TokenType {+ StartBracket,+ EndBracket,+ StartParen,+ EndParen,+ StartTag,+ EndTag,+ OtherText,+ Dollar,+ Quote,+ Slash,+ Period,+ None,+}++impl fmt::Display for HtmlToken {+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {+ write!(+ f,+ "{}{}{}",+ self.white_space_before, self.value, self.white_space_after+ )+ }+}++impl<'a> HtmlDynamicLexer<'a> {+ pub fn new(input: &'a str, start_line: isize) -> HtmlDynamicLexer<'a> {+ HtmlDynamicLexer {+ tokens: Vec::new(),+ iterator: input.chars().peekable(),+ line_number: start_line,+ }+ }++ fn get_white_space(&mut self) -> String {+ let mut white_space = String::new();++ loop {+ match self.iterator.peek() {+ Some(&next) => {+ if next == '\n' {+ self.line_number += 1;+ }++ if next.is_whitespace() {+ white_space.push(next);+ self.iterator.next();+ } else {+ break;+ }+ }+ _ => {+ break;+ }+ }+ }++ white_space+ }++ pub fn lex(&mut self) {+ loop {+ let white_space_before = self.get_white_space();++ let next = self.peek();++ if next == '\0' {+ break;+ }++ let processed_token_type = match next {+ next if Self::is_other_text(next) => TokenType::OtherText,+ '$' => TokenType::Dollar,+ '"' => TokenType::Quote,+ '{' => TokenType::StartBracket,+ '}' => TokenType::EndBracket,+ '<' => TokenType::StartTag,+ '>' => TokenType::EndTag,+ '(' => TokenType::StartParen,+ ')' => TokenType::EndParen,+ '/' => TokenType::Slash,+ '.' => TokenType::Period,+ _ => panic!("Unhandled character: {}", next),+ };++ let current_text = match processed_token_type {+ TokenType::OtherText => self.process_other(),+ _ => {+ let ch = next.to_string();+ self.iterator.next();+ ch+ }+ };++ let white_space_after = self.get_white_space();++ self.push(+ current_text,+ processed_token_type,+ white_space_before,+ white_space_after,+ self.line_number,+ );+ }+ }++ fn is_other_text(character: char) -> bool {+ match character {+ '"' => false,+ '{' => false,+ '}' => false,+ '<' => false,+ '>' => false,+ '$' => false,+ '(' => false,+ ')' => false,+ '/' => false,+ '.' => false,+ _ => true,+ }+ }++ #[cfg(test)]+ fn join(&self) -> String {+ let mut result = String::new();++ for token in &self.tokens {+ result.push_str(&token.white_space_before.as_str());+ result.push_str(&token.value.as_str());+ result.push_str(&token.white_space_after.as_str())+ }++ result+ }++ fn push(+ &mut self,+ text: String,+ token_type: TokenType,+ white_space_before: String,+ white_space_after: String,+ line_number: isize,+ ) {+ self.tokens.push(HtmlToken {+ token_type,+ value: text,+ white_space_before,+ white_space_after,+ line_number,+ });+ }++ fn process_other(&mut self) -> String {+ let mut current = String::new();++ if self.iterator.peek().unwrap().is_whitespace() {+ panic!("White space should be handled before other.");+ }++ loop {+ match self.iterator.peek() {+ Some(&next) if Self::is_other_text(next) => {+ let breaker = next.is_whitespace();++ if breaker {+ break;+ }++ current.push(next);+ self.iterator.next();+ }+ _ => {+ break;+ }+ }+ }++ current+ }++ fn peek(&mut self) -> char {+ let character = self.iterator.peek();++ match character {+ Some(character) => *character,+ None => '\0',+ }+ }+}++#[test]+fn test_variable_substitution() {+ let text = "<h1>Hello $some_variable $(some_other_expression). $&some_ref. $(&some_struct.member). Tab tab:\t\t and \t\t</h1>";++ let mut lexer = HtmlDynamicLexer::new(text, 1);+ lexer.lex();++ let num_tokens = lexer.tokens.len();+ let joined = lexer.join();++ println!("{:?}", lexer.tokens);+ println!("Num tokens {}", num_tokens);+ println!("To lex: {:?}", &text);+ println!("joined: {:?}", joined);++ assert!(joined == text)+}new file mode 100644@@ -0,0 +1,258 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use quote::quote;+use std::usize;++use crate::htmlcontentbuilder::{ErrType, HtmlError};+use crate::htmllexer::{HtmlDynamicLexer, HtmlToken, TokenType};++pub fn html_quotes(html_content: &str, line_number: isize) -> Result<String, HtmlError> {+ let mut updater = HtmlDynamicLexer::new(html_content, line_number);+ updater.lex();++ let mut code_generator = HtmlQuotes::new(&updater);+ code_generator.generate_rust_code()?;++ return Ok(code_generator.result);+}++struct HtmlQuotes<'a> {+ lexer: &'a HtmlDynamicLexer<'a>,+ result: String,+ current_index: usize,+}++impl<'a> HtmlQuotes<'a> {+ fn new(lexer: &'a HtmlDynamicLexer<'a>) -> HtmlQuotes<'a> {+ HtmlQuotes {+ lexer,+ result: String::new(),+ current_index: 0,+ }+ }++ fn get(&self, i: usize) -> Option<&HtmlToken> {+ if i >= self.lexer.tokens.len() {+ return None;+ }++ return Some(&self.lexer.tokens[i]);+ }++ fn generate_rust_code(&mut self) -> Result<(), HtmlError> {+ let mut pass_through = String::new();++ loop {+ let current = self.get(self.current_index);++ if current.is_none() {+ self.add_pass_through(&mut pass_through);+ return Ok(());+ }++ let current_value = current.unwrap();++ if self.next_is_variable_expression() {+ self.add_pass_through(&mut pass_through);+ let variable_expression_tokens = self.process_variables_expression()?;+ self.result.push_str(variable_expression_tokens.as_str());+ // advance past $ ( identifier ) is done in process_variables_expression+ } else if self.next_is_variable() {+ self.add_pass_through(&mut pass_through);+ let variable_tokens = self.process_variables()?;+ self.result.push_str(variable_tokens.as_str());+ self.current_index += 2; // advance past $ identifier+ } else {+ pass_through.push_str(current_value.to_string().as_str());+ self.current_index += 1;+ }+ }+ }++ fn get_type(&self, i: usize) -> TokenType {+ let token = self.get(i);++ match token {+ Some(t) => t.token_type.clone(),+ None => TokenType::None,+ }+ }++ fn add_pass_through(&mut self, pass_through: &mut String) {+ if pass_through == "" {+ return;+ }++ let html = quote! {+ __html_macro_content.push_str(#pass_through);+ };++ self.result.push_str(html.to_string().as_str());+ self.result.push_str("\n");+ pass_through.clear();+ }++ fn next_is_variable(&self) -> bool {+ let type_current = self.get_type(self.current_index);+ let type_ahead1 = self.get_type(self.current_index + 1);++ if type_current == TokenType::Dollar && type_ahead1 == TokenType::OtherText {+ return true;+ }++ false+ }++ fn next_is_variable_expression(&self) -> bool {+ let type_current = self.get_type(self.current_index);+ let type_ahead1 = self.get_type(self.current_index + 1);+ let type_ahead2 = self.get_type(self.current_index + 2);++ if type_current == TokenType::Dollar+ && type_ahead1 == TokenType::StartParen+ && type_ahead2 == TokenType::OtherText+ {+ return true;+ }++ false+ }++ fn process_variables_expression(&mut self) -> Result<String, HtmlError> {+ let mut idx = self.current_index;++ let ahead1 = self.get(idx + 1);+ let ahead2 = self.get(idx + 2);++ if ahead1.is_none() {+ return Err(HtmlError {+ message: "Expecting a variable or expression name inside $() in html macro".into(),+ hint: String::new(),+ line: -1,+ err_type: ErrType::VarExpr,+ });+ }++ if ahead2.is_none() {+ return Err(HtmlError {+ message: "Expecting $()".into(),+ hint: String::new(),+ line: -1,+ err_type: ErrType::VarExpr,+ });+ }++ // skip '$' and '('+ idx += 2;++ let mut paren_count = 0;+ let mut expr_string = String::new();++ loop {+ let token_opt = self.get(idx);++ if token_opt.is_none() {+ return Err(HtmlError {+ message: "Expecting full $()".into(),+ hint: String::new(),+ line: -1,+ err_type: ErrType::VarExpr,+ });+ }++ let token = token_opt.unwrap();++ if token.value == ")" {+ if paren_count == 0 {+ break;+ } else {+ paren_count -= 1;+ }+ } else if token.value == "(" {+ paren_count += 1;+ }++ expr_string.push_str(token.to_string().as_str());+ idx += 1;+ }++ // idx is at the closing ')'; set current_index to the token after it+ self.current_index = idx + 1;+ let result = Self::get_escaped_code(expr_string);++ Ok(result)+ }++ fn process_variables(&self) -> Result<String, HtmlError> {+ let ahead1 = self.get(self.current_index + 1);++ if ahead1.is_none() {+ return Err(HtmlError {+ message: "Expecting a variable after $ in html macro.".into(),+ hint: String::new(),+ line: -1,+ err_type: ErrType::VarSingleExpr,+ });+ }++ let symbol = ahead1.unwrap();+ let identifier = &symbol.value;++ let mut ident = identifier.clone();+ while ident.ends_with(|c: char| !c.is_alphanumeric() && c != '_') {+ ident.pop();+ }++ if ident.is_empty() {+ let err_mess = format!("Not a valid variable name: {:?}.", identifier);++ return Err(HtmlError {+ message: err_mess,+ hint: String::new(),+ line: -1,+ err_type: ErrType::VarSingleExpr,+ });+ }++ let result = Self::get_escaped_code(ident);+ Ok(result)+ }++ fn get_escaped_code(code: String) -> String {+ let mut result = String::new();++ result.push_str("let __html_variable = format!(\"{}\", ");+ result.push_str(code.as_str());+ result.push_str(");\n");++ result.push_str(+ r#"+ if __html_variable.chars().any(|c| ['<', '>', '/', '&', '\'', '"', '\\', '='].contains(&c)) {+ let mut __escaped = String::with_capacity(2 * __html_variable.len());++ for c in __html_variable.as_str().chars() {+ match c {+ '<' => __escaped.push_str("<"),+ '>' => __escaped.push_str(">"),+ '/' => __escaped.push_str("/"),+ '&' => __escaped.push_str("&"),+ '\'' => __escaped.push_str("'"),+ '"' => __escaped.push_str("""),+ '\\' => __escaped.push_str("\"),+ '=' => __escaped.push_str("="),+ _ => __escaped.push(c),+ }+ }++ __html_macro_content.push_str(&__escaped.as_str());+ } else {+ __html_macro_content.push_str(&__html_variable.as_str());+ }+ "#,+ );++ result+ }+}new file mode 100644@@ -0,0 +1,103 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++#![doc = include_str!("../README.md")]++use crate::htmllexer::HtmlDynamicLexer;+use htmlcontentbuilder::HtmlContentBuilder;+use proc_macro::TokenStream;+use proc_macro::TokenTree;+use quote::{quote, quote_spanned};++mod htmlcontentbuilder;+mod htmllexer;+mod htmlquotes;+use proc_macro_error::proc_macro_error;++/// Declarative HTML macro.+///+/// Full documentation and examples are available+/// in the crate-level docs.+///+/// # Example for generating a String with HTML tags:+///+/// ```rust+/// use htmlmacro::html;+///+/// fn get_header() -> String {+/// let content = html! {{ <h1>Header</h1> }};+/// content+/// }+///+/// let html = html! {{+/// <div>+/// <include get_header() />+/// <ul>+/// <rust>+/// for i in 1..3 {+/// <markup>+/// <li>List item $i</i>+/// </markup>+/// }+/// </rust>+/// </ul>+/// </div>+/// }};+/// ```+#[proc_macro_error]+#[proc_macro]+pub fn html(input: TokenStream) -> TokenStream {+ let mut iter = input.into_iter();+ let first = iter.next().expect("expected a group");++ let group = match first {+ TokenTree::Group(g) => g,+ _ => {+ let span = proc_macro2::Span::from(first.span());+ return quote_spanned! { span =>+ {+ compile_error!(+ "html!: does not use double brackets\n\+ Hint: start the macro with double brackets containing your HTML body.\n\+ Example: let code = html! {{ <div>Hello</div> }}\nNot let code = html! { <div>Hello</div> }"+ );+ }+ }+ .into();+ }+ };++ let html_span = group.span();+ let line = html_span.line() as isize;+ let mut html_input = html_span.source_text().map(|cow| cow.to_string());++ if html_input.is_none() {+ html_input = Some(group.stream().to_string());+ }++ let src = match html_input {+ Some(s) => s,+ None => {+ return quote_spanned! { proc_macro2::Span::from(group.span()) =>+ { compile_error!("html!: Can't get html macro body as text."); }+ }+ .into();+ }+ };++ let html_tags = &src[1..src.len() - 1];+ let mut lexer = HtmlDynamicLexer::new(html_tags, line);+ lexer.lex();++ let mut content_builder = HtmlContentBuilder::new();+ let output = content_builder.process_tokens(lexer.tokens);++ let expanded = quote! {+ {+ #output+ }+ };++ return expanded.into();+}new file mode 100644@@ -0,0 +1,102 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "htmlmacro"+version = "0.1.0"+dependencies = [+ "proc-macro-error",+ "proc-macro2",+ "quote",+ "syn 2.0.111",+ "v_htmlescape",+]++[[package]]+name = "include"+version = "0.1.0"+dependencies = [+ "htmlmacro",+]++[[package]]+name = "proc-macro-error"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"+dependencies = [+ "proc-macro-error-attr",+ "proc-macro2",+ "quote",+ "syn 1.0.109",+ "version_check",+]++[[package]]+name = "proc-macro-error-attr"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"+dependencies = [+ "proc-macro2",+ "quote",+ "version_check",+]++[[package]]+name = "proc-macro2"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "syn"+version = "1.0.109"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"+dependencies = [+ "proc-macro2",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "2.0.111"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"++[[package]]+name = "v_htmlescape"+version = "0.15.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"new file mode 100644@@ -0,0 +1,13 @@+[workspace]++[package]+name = "include"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "include"+path = "main.rs"new file mode 100644@@ -0,0 +1,12 @@+use htmlmacro::html;++fn main() {+ // missing slash in include+ let html = html! {{+ <html>+ <body>+ <include include_tag_function() >+ </body>+ </html>+ }};+}new file mode 100644@@ -0,0 +1,102 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "htmlmacro"+version = "0.1.0"+dependencies = [+ "proc-macro-error",+ "proc-macro2",+ "quote",+ "syn 2.0.111",+ "v_htmlescape",+]++[[package]]+name = "include"+version = "0.1.0"+dependencies = [+ "htmlmacro",+]++[[package]]+name = "proc-macro-error"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"+dependencies = [+ "proc-macro-error-attr",+ "proc-macro2",+ "quote",+ "syn 1.0.109",+ "version_check",+]++[[package]]+name = "proc-macro-error-attr"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"+dependencies = [+ "proc-macro2",+ "quote",+ "version_check",+]++[[package]]+name = "proc-macro2"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "syn"+version = "1.0.109"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"+dependencies = [+ "proc-macro2",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "2.0.111"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"++[[package]]+name = "v_htmlescape"+version = "0.15.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"new file mode 100644@@ -0,0 +1,13 @@+[workspace]++[package]+name = "include"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "include"+path = "main.rs"new file mode 100644@@ -0,0 +1,8 @@+use htmlmacro::html;++fn main() {+ // macro start with double brackets: html! {{+ let html = html! {+ <html></html>+ };+}new file mode 100644@@ -0,0 +1,102 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "htmlmacro"+version = "0.1.0"+dependencies = [+ "proc-macro-error",+ "proc-macro2",+ "quote",+ "syn 2.0.111",+ "v_htmlescape",+]++[[package]]+name = "proc-macro-error"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"+dependencies = [+ "proc-macro-error-attr",+ "proc-macro2",+ "quote",+ "syn 1.0.109",+ "version_check",+]++[[package]]+name = "proc-macro-error-attr"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"+dependencies = [+ "proc-macro2",+ "quote",+ "version_check",+]++[[package]]+name = "proc-macro2"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "structmember"+version = "0.1.0"+dependencies = [+ "htmlmacro",+]++[[package]]+name = "syn"+version = "1.0.109"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"+dependencies = [+ "proc-macro2",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "2.0.111"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"++[[package]]+name = "v_htmlescape"+version = "0.15.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"new file mode 100644@@ -0,0 +1,13 @@+[workspace]++[package]+name = "structmember"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "structmember"+path = "main.rs"new file mode 100644@@ -0,0 +1,18 @@+use htmlmacro::html;++struct Test {+ test_member: u64,+}++fn main() {+ let test = Test { test_member: 7 };++ // should be $(test.member) not $test.member+ let html = html! {{+ <html>+ <body>+ $test.test_member+ </body>+ </html>+ }};+}new file mode 100644@@ -0,0 +1,102 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "htmlmacro"+version = "0.1.0"+dependencies = [+ "proc-macro-error",+ "proc-macro2",+ "quote",+ "syn 2.0.111",+ "v_htmlescape",+]++[[package]]+name = "proc-macro-error"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"+dependencies = [+ "proc-macro-error-attr",+ "proc-macro2",+ "quote",+ "syn 1.0.109",+ "version_check",+]++[[package]]+name = "proc-macro-error-attr"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"+dependencies = [+ "proc-macro2",+ "quote",+ "version_check",+]++[[package]]+name = "proc-macro2"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "syn"+version = "1.0.109"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"+dependencies = [+ "proc-macro2",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "2.0.111"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"++[[package]]+name = "v_htmlescape"+version = "0.15.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"++[[package]]+name = "variable"+version = "0.1.0"+dependencies = [+ "htmlmacro",+]++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"new file mode 100644@@ -0,0 +1,13 @@+[workspace]++[package]+name = "variable"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "variable"+path = "main.rs"new file mode 100644@@ -0,0 +1,12 @@+use htmlmacro::html;++fn main() {+ // missing variable does_not_exist+ let html = html! {{+ <html>+ <body>+ $does_not_exist+ </body>+ </html>+ }};+}new file mode 100644@@ -0,0 +1,52 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use htmlmacro::html;++fn another_injection() -> String {+ return "<script>anothertest</script>".to_string();+}++fn include_tag_function() -> String {+ return "<includedtag>OK</includedtag>".to_string();+}++#[test]+fn test_include() {+ let html = html! {{+ <html>+ <body>+ <include include_tag_function() />+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(html.contains("<includedtag>OK</includedtag>"));+}++#[test]+fn test_escape_tag() {+ let injection = "<script>test</script>";+ let html = html! {{+ <html>+ <body>+ $injection+ $(another_injection())+ <include include_tag_function() />+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);++ assert!(html.contains("<html>"));+ assert!(html.contains("</html>"));++ assert!(!html.contains("<script>test</script>"));+ assert!(html.contains("<script>test</script>"));+ assert!(html.contains("<script>anothertest</script>"));++ assert!(html.contains("<includedtag>OK</includedtag>"));+}new file mode 100644@@ -0,0 +1,76 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use htmlmacro::html;++#[test]+fn test_interleaved() {+ let html = html! {{+ <h1>Headline</h1>+ <rust>+ let a = 1;+ <markup>Content</markup>+ </rust>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(!html.contains("let a = 1;"));+ assert!(!html.contains("markup"));+ assert!(html.contains("Content"));+}++#[test]+fn test_one_rust() {+ let html = html! {{+ <h1>Headline</h1>+ <rust>+ let a = 1;+ </rust>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(!html.contains("let a = 1;"));+}++#[test]+fn test_adding_rust_block_backwards() {+ let content = html! {{+ <div>+ <rust>+ for i in 1..=5 {+ let element_id = format!("row_{:?} ", i);+ </rust>a little bit backwards but it has to work. $element_id.<rust>+ }+ </rust>+ </div>+ }};++ assert!(!content.contains("<rust>"));+ assert!(!content.contains("</rust>"));+ assert!(!content.contains("<markup>"));+ assert!(!content.contains("</markup>"));+ assert!(content.contains("little bit backwards"));+ assert!(content.contains("row_5"));+}++#[test]+fn test_adding_rust_block_and_markup() {+ let content = html! {{+ <div>+ <rust>+ for i in 1..=5 {+ let element_id = format!("id_{:?} ", i);+ <markup>The proper way: $element_id</markup>+ }+ </rust>+ </div>+ }};++ assert!(!content.contains("<rust>"));+ assert!(!content.contains("</rust>"));+ assert!(!content.contains("<markup>"));+ assert!(!content.contains("</markup>"));+ assert!(content.contains("The proper way:"));+ assert!(content.contains("id_5"));+}new file mode 100644@@ -0,0 +1,200 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use htmlmacro::html;++#[test]+fn test_var() {+ let variable = "test";++ let html = html! {{+ <html>+ <body>+ $variable+ </body>+ </html>+ }};++ eprintln!("Generated HTML: {:?}", html);+ assert!(html.contains("test"));+}++#[test]+fn test_html_js_inlcuded() {+ let test_var = "Headline";++ let html = html! {{+ <html>+ <header>+ <script type="module">+ import init, { update_text } from "/static/wasm_module.js";++ async function start() {+ await init();+ update_text("output", "tested");+ }++ start();+ </script>+ </header>+ <body>+ <h1>$test_var<h1/>+ </body>+ </html>+ }};++ eprintln!("Generated HTML: {:?}", html);+ assert!(html.contains("<html>"));+ assert!(html.contains("<script "));+ assert!(html.contains("update_text(\"output\", \"tested\");"));+}++#[test]+fn test_exp() {+ let variable = "test";+ let html = html! {{+ <html>+ <body>+ $(variable)+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(html.contains("test"));+}++fn project_table() -> String {+ return "<table></table>".into();+}++#[test]+fn test_include() {+ let html = html! {{+ <include project_table() />+ }};++ assert_eq!(html.trim(), "<table></table>")+}++#[test]+fn test_separate_code_and_html() {+ let html = html! {{+ <h1>Hello World</h1>++ <div>+ <rust>+ for i in 1..=5 {+ let element_id = format!("row_{}", i);+ <markup>$element_id</markup>+ }+ </rust>+ </div>+ }};++ println!("Generated HTML: {:?}", html);++ assert!(html.contains("<h1>Hello World</h1>"));+ assert!(html.contains("<div>"));+ assert!(html.contains("</div>"));+ assert!(html.contains("row_5"));+ assert!(!html.contains("<rust>"));+}++fn menu(title: &str) -> String {+ let html = html! {{+ <span>$title <a href="index.html">Start</a></span>+ }};++ html+}++#[test]+fn test_should_not_panic() {+ let page_title = "Start";++ let content = html! {{+ <p>Lorem Ipsum</p>+ <p>White space is important here: Lorem Ipsum (test) or (test)</p>++ <include menu(page_title) />++ <div>+ <rust>+ for i in 1..=5 {+ let element_id = format!("row_{:?} ", i);+ </rust>a little bit backwards but it has to work. $element_id.<rust>+ }+ </rust>+ </div>++ <div>+ <rust>+ for i in 1..=5 {+ let element_id = format!("id_{:?} ", i);+ <markup>The proper way: $element_id</markup>+ }+ </rust>+ </div>+ }};++ assert!(content.contains("White space is important here: Lorem Ipsum (test) or (test)"));+ assert!(!content.contains("<rust>"));+ assert!(!content.contains("</rust>"));+ assert!(!content.contains("<markup>"));+ assert!(!content.contains("</markup>"));+ assert!(content.contains("<span>Start"));+}++#[test]+fn test_html_tag() {+ let html = html! {{+ <html>+ <header>+ </header>+ <body>+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);++ assert!(html.contains("<html>"));+ assert!(html.contains("</html>"));++ assert!(html.contains("<body>"));+ assert!(html.contains("</body>"));+}++#[test]+fn test_expression_algebra() {+ let html = html! {{+ <html>+ <body>+ $(1 + 1), $((1.0 * 3) / 2)+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);++ assert!(html.contains("<html>"));+ assert!(html.contains("</html>"));+}++#[test]+fn test_expression() {+ let some_arg = "test";++ let html = html! {{+ <html>+ <body>+ <div arg="$(some_arg)"></div>+ </body>+ </html>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(html.contains("<div arg=\"test\"></div>"));+}new file mode 100644@@ -0,0 +1,133 @@+// Copyright (c) 2026 Johan Mattsson+// Licensed under either of Apache License, Version 2.0 or MIT license at your option.+// See LICENSE-APACHE or LICENSE-MIT for details.++use htmlmacro::html;++struct Test {+ number: u32,+ message: String,+}++impl std::fmt::Display for Test {+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+ write!(+ f,+ "Test {{ number: {}, message: {} }}",+ self.number, self.message+ )+ }+}++fn the_number() -> u64 {+ return 77;+}++fn child_component_simple_loop() -> String {+ let html = html! {{+ <h1>Hello From Nested Component</h1>++ <div>+ <ul>+ <rust>+ for i in 1..=3 {+ let element_id = format!("List item {:?} ", i);++ <markup>+ <li>Simple $element_id</li>+ </markup>+ }+ </rust>+ </ul>+ </div>+ }};+ html+}++fn child_component_expr() -> String {+ let html = html! {{+ <h1>Hello From Nested Component</h1>++ <div>+ <ul>+ <rust>+ for i in 1..=3 {+ <markup>+ <li>Adding 100 to $i: $(i + 100)</li>+ </markup>+ }+ </rust>+ </ul>+ </div>+ }};+ html+}++fn create_component(component_id: String) -> String {+ let html = html! {{+ <div id="$component_id">created</div>+ }};++ html+}+#[test]+fn test_compiles() {+ println!("Generating HTML");++ let some_variable = "this_is_runtime";+ let some_struct = Test {+ number: 42,+ message: "The answer".to_string(),+ };++ let other_struct = Test {+ number: 2,+ message: "Primy".to_string(),+ };++ // TODO: escape {}+ let html = html! {{+ <h1>Hello $some_variable</h1>+ <h1>Struct $some_struct</h1>+ <h1>Number Hello Prime $(&other_struct.number)</h1>+ <h1>Hello Number plus one $(the_number() + 1)</h1>++ <p>Lorem Ipsum ... (test)</p>+ <p>$("White space is important ... here: Lorem Ipsum (test)")</p>++ <include child_component_simple_loop() />+ <include child_component_expr() />++ <div id="self_closing" />++ <div>+ <rust>+ for i in 1..=5 {+ </rust>inline in loop<rust>+ }+ </rust>++ <h2>A Generated List</h2>+ <rust>+ for i in 1..=5 {+ let element_id = format!("Fruits {:?} ", i);+ <markup>SubTreeComponent: $element_id</markup>+ }+ </rust>++ <h2>Five Included Components</h2>+ <rust>+ for i in 1..=5 {+ let element_id = format!("generated_component_{:?}", i);+ <markup><include create_component(element_id) /></markup>+ }+ </rust>+ </div>+ }};++ println!("Generated HTML: {:?}", html);+ assert!(html.contains("<h2>Five Included Components</h2>"));+ assert!(html.contains("inline in loop"));+ assert!(html.contains("SubTreeComponent: Fruits 1"));+ assert!(html.contains("<div id=\"generated_component_1\">created</div>"));+}