Clone in a terminal:
Commit c95af61
Johan Mattsson - 2026-03-03T17:29:48+00:00
c95af61ea29baa0ac337ff39aad22d08e2c34b46johan.mattsson.m@gmail.com

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)

Diff

diff --git a/.gitignore b/.gitignorenew file mode 100644index 0000000..eb5a316--- /dev/null+++ b/.gitignore@@ -0,0 +1 @@+targetdiff --git a/Cargo.lock b/Cargo.locknew file mode 100644index 0000000..946fef0--- /dev/null+++ b/Cargo.lock@@ -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"diff --git a/Cargo.toml b/Cargo.tomlnew file mode 100644index 0000000..60e220e--- /dev/null+++ b/Cargo.toml@@ -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"diff --git a/README.md b/README.mdnew file mode 100644index 0000000..3d55fbc--- /dev/null+++ b/README.md@@ -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 &lt;b&gt;Johan&lt;/b&gt;</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.diff --git a/src/htmlcontentbuilder.rs b/src/htmlcontentbuilder.rsnew file mode 100644index 0000000..02f6620--- /dev/null+++ b/src/htmlcontentbuilder.rs@@ -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+}diff --git a/src/htmllexer.rs b/src/htmllexer.rsnew file mode 100644index 0000000..7cceaa0--- /dev/null+++ b/src/htmllexer.rs@@ -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)+}diff --git a/src/htmlquotes.rs b/src/htmlquotes.rsnew file mode 100644index 0000000..fbde6f1--- /dev/null+++ b/src/htmlquotes.rs@@ -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("&lt;"),+                        '>' => __escaped.push_str("&gt;"),+                        '/' => __escaped.push_str("&#x2f;"),+                        '&' => __escaped.push_str("&amp;"),+                        '\'' => __escaped.push_str("&apos;"),+                        '"' => __escaped.push_str("&quot;"),+                        '\\' => __escaped.push_str("&#x5c;"),+                        '=' => __escaped.push_str("&equals;"),+                        _ => __escaped.push(c),+                    }+                }++                __html_macro_content.push_str(&__escaped.as_str());+            } else {+                __html_macro_content.push_str(&__html_variable.as_str());+            }+        "#,+        );++        result+    }+}diff --git a/src/lib.rs b/src/lib.rsnew file mode 100644index 0000000..ba4f6dc--- /dev/null+++ b/src/lib.rs@@ -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();+}diff --git a/test-fail-messages/include/Cargo.lock b/test-fail-messages/include/Cargo.locknew file mode 100644index 0000000..abc0b18--- /dev/null+++ b/test-fail-messages/include/Cargo.lock@@ -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"diff --git a/test-fail-messages/include/Cargo.toml b/test-fail-messages/include/Cargo.tomlnew file mode 100644index 0000000..3862d3e--- /dev/null+++ b/test-fail-messages/include/Cargo.toml@@ -0,0 +1,13 @@+[workspace]++[package]+name = "include"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "include"+path = "main.rs"diff --git a/test-fail-messages/include/main.rs b/test-fail-messages/include/main.rsnew file mode 100644index 0000000..d2d529b--- /dev/null+++ b/test-fail-messages/include/main.rs@@ -0,0 +1,12 @@+use htmlmacro::html;++fn main() {+    // missing slash in include+    let html = html! {{+        <html>+            <body>+                <include include_tag_function() >+            </body>+        </html>+    }};+}diff --git a/test-fail-messages/start/Cargo.lock b/test-fail-messages/start/Cargo.locknew file mode 100644index 0000000..abc0b18--- /dev/null+++ b/test-fail-messages/start/Cargo.lock@@ -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"diff --git a/test-fail-messages/start/Cargo.toml b/test-fail-messages/start/Cargo.tomlnew file mode 100644index 0000000..3862d3e--- /dev/null+++ b/test-fail-messages/start/Cargo.toml@@ -0,0 +1,13 @@+[workspace]++[package]+name = "include"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "include"+path = "main.rs"diff --git a/test-fail-messages/start/main.rs b/test-fail-messages/start/main.rsnew file mode 100644index 0000000..7d6a237--- /dev/null+++ b/test-fail-messages/start/main.rs@@ -0,0 +1,8 @@+use htmlmacro::html;++fn main() {+    // macro start with double brackets: html! {{+    let html = html! {+        <html></html>+    };+}diff --git a/test-fail-messages/structmember/Cargo.lock b/test-fail-messages/structmember/Cargo.locknew file mode 100644index 0000000..00c5164--- /dev/null+++ b/test-fail-messages/structmember/Cargo.lock@@ -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"diff --git a/test-fail-messages/structmember/Cargo.toml b/test-fail-messages/structmember/Cargo.tomlnew file mode 100644index 0000000..6d6b626--- /dev/null+++ b/test-fail-messages/structmember/Cargo.toml@@ -0,0 +1,13 @@+[workspace]++[package]+name = "structmember"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "structmember"+path = "main.rs"diff --git a/test-fail-messages/structmember/main.rs b/test-fail-messages/structmember/main.rsnew file mode 100644index 0000000..2efc14c--- /dev/null+++ b/test-fail-messages/structmember/main.rs@@ -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>+    }};+}diff --git a/test-fail-messages/variable/Cargo.lock b/test-fail-messages/variable/Cargo.locknew file mode 100644index 0000000..f223bb8--- /dev/null+++ b/test-fail-messages/variable/Cargo.lock@@ -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"diff --git a/test-fail-messages/variable/Cargo.toml b/test-fail-messages/variable/Cargo.tomlnew file mode 100644index 0000000..83beee6--- /dev/null+++ b/test-fail-messages/variable/Cargo.toml@@ -0,0 +1,13 @@+[workspace]++[package]+name = "variable"+version = "0.1.0"+edition = "2024"++[dependencies]+htmlmacro = { path = "../../" }++[[bin]]+name = "variable"+path = "main.rs"diff --git a/test-fail-messages/variable/main.rs b/test-fail-messages/variable/main.rsnew file mode 100644index 0000000..29c0e46--- /dev/null+++ b/test-fail-messages/variable/main.rs@@ -0,0 +1,12 @@+use htmlmacro::html;++fn main() {+    // missing variable does_not_exist+    let html = html! {{+        <html>+            <body>+                $does_not_exist+            </body>+        </html>+    }};+}diff --git a/tests/injection.rs b/tests/injection.rsnew file mode 100644index 0000000..edf49e7--- /dev/null+++ b/tests/injection.rs@@ -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("&lt;script&gt;test&lt;&#x2f;script&gt;"));+    assert!(html.contains("&lt;script&gt;anothertest&lt;&#x2f;script&gt;"));++    assert!(html.contains("<includedtag>OK</includedtag>"));+}diff --git a/tests/interleaved.rs b/tests/interleaved.rsnew file mode 100644index 0000000..2d7152e--- /dev/null+++ b/tests/interleaved.rs@@ -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"));+}diff --git a/tests/other.rs b/tests/other.rsnew file mode 100644index 0000000..8e1015e--- /dev/null+++ b/tests/other.rs@@ -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>"));+}diff --git a/tests/tag.rs b/tests/tag.rsnew file mode 100644index 0000000..e7e7d27--- /dev/null+++ b/tests/tag.rs@@ -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>"));+}

Contact

Email: johan.mattsson.m@gmail.com
Mastodon: typo.social/@birdfont
GitHub: johanmattssonm
LinkedIn: johan-mattsson-2ab324112