├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── shtml_macros ├── Cargo.lock ├── Cargo.toml └── src │ ├── chaos.rs │ └── lib.rs └── src └── lib.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "itoa" 7 | version = "1.0.11" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 10 | 11 | [[package]] 12 | name = "proc-macro-error" 13 | version = "1.0.4" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 16 | dependencies = [ 17 | "proc-macro-error-attr", 18 | "proc-macro2", 19 | "quote", 20 | "version_check", 21 | ] 22 | 23 | [[package]] 24 | name = "proc-macro-error-attr" 25 | version = "1.0.4" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 28 | dependencies = [ 29 | "proc-macro2", 30 | "quote", 31 | "version_check", 32 | ] 33 | 34 | [[package]] 35 | name = "proc-macro2" 36 | version = "1.0.81" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" 39 | dependencies = [ 40 | "unicode-ident", 41 | ] 42 | 43 | [[package]] 44 | name = "proc-macro2-diagnostics" 45 | version = "0.10.1" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 48 | dependencies = [ 49 | "proc-macro2", 50 | "quote", 51 | "syn", 52 | "version_check", 53 | "yansi", 54 | ] 55 | 56 | [[package]] 57 | name = "quote" 58 | version = "1.0.36" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 61 | dependencies = [ 62 | "proc-macro2", 63 | ] 64 | 65 | [[package]] 66 | name = "rstml" 67 | version = "0.11.2" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "fe542870b8f59dd45ad11d382e5339c9a1047cde059be136a7016095bbdefa77" 70 | dependencies = [ 71 | "proc-macro2", 72 | "proc-macro2-diagnostics", 73 | "quote", 74 | "syn", 75 | "syn_derive", 76 | "thiserror", 77 | ] 78 | 79 | [[package]] 80 | name = "ryu" 81 | version = "1.0.17" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" 84 | 85 | [[package]] 86 | name = "shtml" 87 | version = "0.2.0" 88 | dependencies = [ 89 | "itoa", 90 | "ryu", 91 | "shtml_macros", 92 | ] 93 | 94 | [[package]] 95 | name = "shtml_macros" 96 | version = "0.2.0" 97 | dependencies = [ 98 | "proc-macro2", 99 | "quote", 100 | "rstml", 101 | "syn", 102 | ] 103 | 104 | [[package]] 105 | name = "syn" 106 | version = "2.0.60" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" 109 | dependencies = [ 110 | "proc-macro2", 111 | "quote", 112 | "unicode-ident", 113 | ] 114 | 115 | [[package]] 116 | name = "syn_derive" 117 | version = "0.1.8" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" 120 | dependencies = [ 121 | "proc-macro-error", 122 | "proc-macro2", 123 | "quote", 124 | "syn", 125 | ] 126 | 127 | [[package]] 128 | name = "thiserror" 129 | version = "1.0.59" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" 132 | dependencies = [ 133 | "thiserror-impl", 134 | ] 135 | 136 | [[package]] 137 | name = "thiserror-impl" 138 | version = "1.0.59" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" 141 | dependencies = [ 142 | "proc-macro2", 143 | "quote", 144 | "syn", 145 | ] 146 | 147 | [[package]] 148 | name = "unicode-ident" 149 | version = "1.0.12" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 152 | 153 | [[package]] 154 | name = "version_check" 155 | version = "0.9.4" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 158 | 159 | [[package]] 160 | name = "yansi" 161 | version = "1.0.1" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 164 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shtml" 3 | version = "0.2.0" 4 | edition = "2021" 5 | resolver = "2" 6 | 7 | [dependencies] 8 | shtml_macros = { path = "shtml_macros" } 9 | itoa = "1.0" 10 | ryu = "1.0" 11 | 12 | [features] 13 | chaos = ["shtml_macros/chaos"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shtml 2 | 3 | shtml is a rust library for rendering html. 4 | 5 | ## Installation 6 | 7 | ``` 8 | cargo add --git https://github.com/swlkr/shtml shtml 9 | ``` 10 | 11 | ## Examples 12 | 13 | Just write or copy/paste plain old html 14 | 15 | ```rust 16 | use shtml::{html, Elements, Component, Render}; 17 | 18 | let result = html! { 19 | 20 | 21 | 22 | shtml the s is silent 23 | 24 | } 25 | .to_string(); 26 | ``` 27 | 28 | Get this back in the result var 29 | 30 | ```html 31 | 32 | 33 | 34 | shtml the s is silent 35 | 36 | ``` 37 | 38 | Attrs work like you would expect 39 | 40 | ```rust 41 | let class = "flex items-center h-full"; 42 | let result = html! {
}.to_string(); 43 | 44 | //
45 | ``` 46 | 47 | Pass in rust exprs in curlies just make sure they impl `Render` 48 | 49 | ```rust 50 | let x = 1; 51 | let result = html! {
{x}
}.to_string(); 52 | 53 | //
1
54 | ``` 55 | 56 | Strings get escaped 57 | 58 | ```rust 59 | let x = ""; 60 | let result = html! {
{x}
}.to_string(); 61 | 62 | //
<script>alert("pwned")</script>
63 | ``` 64 | 65 | Components work like jsx 66 | 67 | ```rust 68 | #![allow(non_snake_case)] 69 | 70 | fn HStack(elements: Elements) -> Component { 71 | html! {
{elements}
} 72 | } 73 | 74 | let component = html! { 75 | 76 |
1
77 |
2
78 |
3
79 |
80 | }.to_string(); 81 | 82 | //
1
2
3
83 | ``` 84 | 85 | Attrs with components work as well 86 | 87 | ```rust 88 | #![allow(non_snake_case)] 89 | 90 | fn Hypermedia(target: &str) -> Component { 91 | html! {
} 92 | } 93 | 94 | let x = "body"; 95 | let result = html! { }.to_string(); 96 | 97 | //
98 | ``` 99 | 100 | Nested components 101 | 102 | ```rust 103 | #![allow(non_snake_case)] 104 | 105 | fn HStack(elements: Elements) -> Component { 106 | html! {
{elements}
} 107 | } 108 | 109 | fn VStack(elements: Elements) -> Component { 110 | html! {
{elements}
} 111 | } 112 | 113 | let component = html! { 114 | 115 | 116 |
1
117 |
2
118 |
119 |
120 | }.to_string(); 121 | 122 | //
1
2
123 | ``` 124 | 125 | Attrs + nested components 126 | 127 | ```rust 128 | fn Heading(class: &str, els: Elements) -> Component { 129 | html! {

{els}

} 130 | } 131 | 132 | let result = html! { 133 | 134 |

How now brown cow

135 |
136 | }.to_string(); 137 | 138 | //

How now brown cow

139 | ``` 140 | 141 | Fragments just pass through their children 142 | 143 | ```rust 144 | #![allow(non_snake_case)] 145 | 146 | fn HStack(elements: Elements) -> Component { 147 | html! {
{elements}
} 148 | } 149 | 150 | fn VStack(elements: Elements) -> Component { 151 | html! {
{elements}
} 152 | } 153 | 154 | let component = html! { 155 | 156 | <> 157 | 158 |
1
159 |
2
160 |
161 | 162 |
163 | }.to_string(); 164 | 165 | //
1
2
166 | ``` 167 | 168 | The `Render` trait is only implemented for `Vec` 169 | 170 | ```rust 171 | #![allow(non_snake_case)] 172 | 173 | fn List(elements: Elements) -> Component { 174 | html! { } 175 | } 176 | 177 | fn Item(elements: Elements) -> Component { 178 | html! {
  • {elements}
  • } 179 | } 180 | 181 | let items = vec![1, 2, 3]; 182 | 183 | let result = html! { 184 | 185 | { 186 | items 187 | .iter() 188 | .map(|i| html! { 189 | {i} 190 | }) 191 | .collect::>() 192 | } 193 | 194 | }.to_string(); 195 | 196 | // 197 | ``` 198 | 199 | # Feature flags 200 | 201 | - chaos 202 | 203 | The `chaos` feature flag requires that you annotate all component functions with a `#[component]` macro attribute and allows you to specify any attr order: 204 | 205 | ```rust 206 | #[component] 207 | fn Chaos(a: &str, b: u8, c: String) -> Component { 208 | html! {
    } 209 | } 210 | let result = html! { }.to_string(); 211 | 212 | //
    213 | 214 | // without the chaos feature flag you need to specify the attrs 215 | // in the same order as the fn args 216 | html! { 217 | 218 | } 219 | ``` 220 | 221 | # Tips and tricks 222 | 223 | - [leptosfmt](https://github.com/bram209/leptosfmt) with this override `rustfmt = { overrideCommand = ["leptosfmt", "--stdin", "--rustfmt", "--override-macro-names", "html"] }` 224 | - [tree-sitter-rstml](https://github.com/rayliwell/tree-sitter-rstml) for html autocomplete inside of html! macros 225 | 226 | For helix users: the html! macro should just work and have correct syntax highlighting and autocomplete with the default html lsp + tailwind if that's your jam 227 | 228 | ```toml 229 | [language-server.tailwind-ls] 230 | command = "tailwindcss-language-server" 231 | args = ["--stdio"] 232 | 233 | [language-server.tailwind-ls.config] 234 | tailwindCSS = { experimental = { classRegex = ["class=\"(.*)\""] } } 235 | 236 | [[language]] 237 | name = "rust" 238 | language-servers = ["rust-analyzer", "vscode-html-language-server", "tailwind-ls"] 239 | ``` 240 | -------------------------------------------------------------------------------- /shtml_macros/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "proc-macro-error" 7 | version = "1.0.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 10 | dependencies = [ 11 | "proc-macro-error-attr", 12 | "proc-macro2", 13 | "quote", 14 | "version_check", 15 | ] 16 | 17 | [[package]] 18 | name = "proc-macro-error-attr" 19 | version = "1.0.4" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 22 | dependencies = [ 23 | "proc-macro2", 24 | "quote", 25 | "version_check", 26 | ] 27 | 28 | [[package]] 29 | name = "proc-macro2" 30 | version = "1.0.86" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 33 | dependencies = [ 34 | "unicode-ident", 35 | ] 36 | 37 | [[package]] 38 | name = "proc-macro2-diagnostics" 39 | version = "0.10.1" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 42 | dependencies = [ 43 | "proc-macro2", 44 | "quote", 45 | "syn", 46 | "version_check", 47 | "yansi", 48 | ] 49 | 50 | [[package]] 51 | name = "quote" 52 | version = "1.0.36" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 55 | dependencies = [ 56 | "proc-macro2", 57 | ] 58 | 59 | [[package]] 60 | name = "rstml" 61 | version = "0.11.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "fe542870b8f59dd45ad11d382e5339c9a1047cde059be136a7016095bbdefa77" 64 | dependencies = [ 65 | "proc-macro2", 66 | "proc-macro2-diagnostics", 67 | "quote", 68 | "syn", 69 | "syn_derive", 70 | "thiserror", 71 | ] 72 | 73 | [[package]] 74 | name = "shtml_macros" 75 | version = "0.2.0" 76 | dependencies = [ 77 | "proc-macro2", 78 | "quote", 79 | "rstml", 80 | "syn", 81 | ] 82 | 83 | [[package]] 84 | name = "syn" 85 | version = "2.0.72" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" 88 | dependencies = [ 89 | "proc-macro2", 90 | "quote", 91 | "unicode-ident", 92 | ] 93 | 94 | [[package]] 95 | name = "syn_derive" 96 | version = "0.1.8" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" 99 | dependencies = [ 100 | "proc-macro-error", 101 | "proc-macro2", 102 | "quote", 103 | "syn", 104 | ] 105 | 106 | [[package]] 107 | name = "thiserror" 108 | version = "1.0.63" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" 111 | dependencies = [ 112 | "thiserror-impl", 113 | ] 114 | 115 | [[package]] 116 | name = "thiserror-impl" 117 | version = "1.0.63" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" 120 | dependencies = [ 121 | "proc-macro2", 122 | "quote", 123 | "syn", 124 | ] 125 | 126 | [[package]] 127 | name = "unicode-ident" 128 | version = "1.0.12" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 131 | 132 | [[package]] 133 | name = "version_check" 134 | version = "0.9.5" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 137 | 138 | [[package]] 139 | name = "yansi" 140 | version = "1.0.1" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 143 | -------------------------------------------------------------------------------- /shtml_macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shtml_macros" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | proc-macro = true 8 | 9 | [dependencies] 10 | proc-macro2 = "1" 11 | quote = "1" 12 | syn = { version = "2", features = ["full", "extra-traits", "parsing"] } 13 | rstml = { version = "0.11" } 14 | 15 | [features] 16 | chaos = [] 17 | -------------------------------------------------------------------------------- /shtml_macros/src/chaos.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream as TokenStream2}; 2 | use quote::quote; 3 | use syn::{Ident, ItemFn, Lifetime, PatType, Result, Signature, Type, TypeReference}; 4 | 5 | pub fn component_macro(item_fn: ItemFn) -> Result { 6 | let ItemFn { 7 | vis, 8 | sig, 9 | block, 10 | attrs, 11 | .. 12 | } = item_fn; 13 | let Signature { 14 | ident, 15 | inputs, 16 | // TODO generics 17 | // TODO verify output type 18 | .. 19 | } = sig; 20 | let field_names = inputs 21 | .iter() 22 | .map(|fn_arg| match fn_arg { 23 | syn::FnArg::Receiver(_) => unimplemented!(), 24 | syn::FnArg::Typed(pat_type) => &pat_type.pat, 25 | }) 26 | .collect::>(); 27 | 28 | let fields = inputs 29 | .iter() 30 | .enumerate() 31 | .map(|(i, fn_arg)| match fn_arg { 32 | syn::FnArg::Receiver(_) => unimplemented!(), 33 | syn::FnArg::Typed(PatType { pat, ty, .. }) => match &**ty { 34 | Type::Path(type_path) => (None, quote! { #pat: #type_path }), 35 | Type::Reference(TypeReference { 36 | and_token, 37 | lifetime, 38 | mutability, 39 | elem, 40 | }) => { 41 | let lifetime = match lifetime { 42 | Some(lifetime) => lifetime.to_owned(), 43 | None => Lifetime { 44 | apostrophe: Span::call_site(), 45 | ident: Ident::new( 46 | &(((i + 97) as u8) as char).to_string(), 47 | Span::call_site(), 48 | ), 49 | }, 50 | }; 51 | 52 | ( 53 | Some(lifetime.clone()), 54 | quote! { #pat: #and_token #lifetime #mutability #elem }, 55 | ) 56 | } 57 | _ => unimplemented!(), 58 | }, 59 | }) 60 | .collect::>(); 61 | 62 | let lifetime_tokens = fields 63 | .iter() 64 | .filter_map(|(lifetime, _)| match lifetime { 65 | Some(lifetime) => Some(lifetime), 66 | None => None, 67 | }) 68 | .collect::>(); 69 | let lifetime_tokens = match lifetime_tokens.is_empty() { 70 | true => quote! {}, 71 | false => quote! { 72 | <#(#lifetime_tokens,)*> 73 | }, 74 | }; 75 | 76 | let fields = fields.iter().map(|(_, field)| field).collect::>(); 77 | 78 | let output = quote! { 79 | #(#attrs,)* 80 | #vis struct #ident #lifetime_tokens { 81 | #(#fields,)* 82 | } 83 | 84 | impl #lifetime_tokens #ident #lifetime_tokens { 85 | pub fn to_component(&self) -> Component { 86 | let Self { #(#field_names,)* } = self; 87 | #block 88 | } 89 | } 90 | 91 | impl #lifetime_tokens Render for #ident #lifetime_tokens { 92 | fn render_to_string(&self, buffer: &mut String) { 93 | buffer.push_str(&self.to_component().to_string()) 94 | } 95 | } 96 | }; 97 | 98 | Ok(output) 99 | } 100 | -------------------------------------------------------------------------------- /shtml_macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod chaos; 2 | 3 | use proc_macro::TokenStream; 4 | use proc_macro2::{Span, TokenStream as TokenStream2}; 5 | use quote::{quote, ToTokens}; 6 | use rstml::{self, node::Node, Parser, ParserConfig}; 7 | use std::{collections::HashSet, fmt::Debug}; 8 | use syn::{parse_macro_input, Ident, ItemFn, LitStr, Result}; 9 | 10 | #[proc_macro] 11 | pub fn html(input: TokenStream) -> TokenStream { 12 | match html_macro(input) { 13 | Ok(s) => s.to_token_stream().into(), 14 | Err(e) => e.to_compile_error().into(), 15 | } 16 | } 17 | 18 | fn html_macro(input: TokenStream) -> Result { 19 | let size_hint = input.to_string().len(); 20 | let config = ParserConfig::new() 21 | .recover_block(true) 22 | .always_self_closed_elements(HashSet::from([ 23 | "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", 24 | "track", "wbr", 25 | ])); 26 | let parser = Parser::new(config); 27 | 28 | let nodes = parser.parse_simple(input)?; 29 | let buf = Ident::new("__shtml_buf", Span::call_site()); 30 | let mut output = Output { 31 | buf: buf.clone(), 32 | static_string: String::new(), 33 | tokens: vec![], 34 | }; 35 | nodes 36 | .into_iter() 37 | .for_each(|node| render(&mut output, &node)); 38 | 39 | let tokens = output.to_token_stream(); 40 | 41 | Ok(quote! { 42 | { 43 | let mut #buf = String::with_capacity(#size_hint); 44 | #tokens 45 | Component { html: #buf } 46 | } 47 | }) 48 | } 49 | 50 | fn render(output: &mut Output, node: &Node) { 51 | match node { 52 | Node::Comment(c) => { 53 | output.push_str(""); 56 | } 57 | Node::Doctype(d) => { 58 | output.push_str(""); 63 | } 64 | Node::Fragment(n) => { 65 | for node in &n.children { 66 | render(output, &node) 67 | } 68 | } 69 | Node::Element(n) => { 70 | let component_name = match &n.name() { 71 | rstml::node::NodeName::Path(syn::ExprPath { path, .. }) => match path.get_ident() { 72 | Some(ident) => match ident.to_string().get(0..1) { 73 | Some(first_letter) => match first_letter.to_uppercase() == first_letter { 74 | true => Some(ident), 75 | false => None, 76 | }, 77 | None => None, 78 | }, 79 | None => todo!(), 80 | }, 81 | rstml::node::NodeName::Punctuated(_) => todo!(), 82 | rstml::node::NodeName::Block(_) => todo!(), 83 | }; 84 | match component_name { 85 | Some(fn_name) => { 86 | let mut inputs = n 87 | .open_tag 88 | .attributes 89 | .iter() 90 | .map(|attr| match attr { 91 | rstml::node::NodeAttribute::Block(_) => todo!(), 92 | rstml::node::NodeAttribute::Attribute(attr) => { 93 | #[cfg(feature = "chaos")] 94 | let key = &attr.key; 95 | let value = attr.value(); 96 | 97 | #[cfg(feature = "chaos")] 98 | quote! { #key: #value } 99 | 100 | #[cfg(not(feature = "chaos"))] 101 | quote! { #value } 102 | } 103 | }) 104 | .collect::>(); 105 | 106 | let mut inner_output = Output::new(output.buf.clone()); 107 | 108 | for node in &n.children { 109 | render(&mut inner_output, &node); 110 | } 111 | 112 | let buf = inner_output.buf.clone(); 113 | let inner_tokens = inner_output.to_token_stream(); 114 | 115 | match inner_tokens.is_empty() { 116 | false => { 117 | let inner_tokens = quote! { 118 | { 119 | let mut #buf = String::new(); 120 | #inner_tokens 121 | Component { html: #buf } 122 | } 123 | }; 124 | 125 | inputs.push(inner_tokens); 126 | } 127 | _ => {} 128 | } 129 | 130 | #[cfg(feature = "chaos")] 131 | let tokens = quote! { #fn_name { #(#inputs,)* } }; 132 | 133 | #[cfg(not(feature = "chaos"))] 134 | let tokens = quote! { #fn_name(#(#inputs,)*) }; 135 | 136 | output.push_tokens(tokens); 137 | } 138 | None => { 139 | output.push_str("<"); 140 | output.push_str(&n.open_tag.name.to_string()); 141 | for attr in &n.open_tag.attributes { 142 | match attr { 143 | rstml::node::NodeAttribute::Block(block) => { 144 | match block { 145 | rstml::node::NodeBlock::ValidBlock(valid_block) => { 146 | for stmt in &valid_block.stmts { 147 | match stmt { 148 | syn::Stmt::Expr(expr_expr, _expr_semi) => { 149 | match expr_expr { 150 | syn::Expr::Range(expr_range) => { 151 | match &expr_range.end { 152 | Some(box_expr) => { 153 | let tokens = (*box_expr.clone()).to_token_stream(); 154 | 155 | output.push_tokens(tokens); 156 | } 157 | _ => {} 158 | } 159 | } 160 | _ => {} 161 | } 162 | } 163 | _ => {} 164 | } 165 | } 166 | } 167 | _ => {} 168 | } 169 | }, 170 | rstml::node::NodeAttribute::Attribute(attr) => { 171 | output.static_string.push(' '); 172 | output.push_str(&attr.key.to_string()); 173 | match attr.value_literal_string() { 174 | Some(s) => { 175 | output.push_str("=\""); 176 | output.push_str(&s); 177 | output.push_str("\""); 178 | } 179 | None => match attr.value() { 180 | Some(expr) => { 181 | output.push_str("=\""); 182 | let tokens = expr.to_token_stream(); 183 | output.push_tokens(tokens); 184 | output.push_str("\""); 185 | } 186 | None => { 187 | // TODO: bool attr? 188 | } 189 | }, 190 | } 191 | } 192 | } 193 | } 194 | match &n.children.is_empty() { 195 | true => match &n.close_tag { 196 | Some(tag) => { 197 | output.push_str(">"); 198 | output.push_str(""); 201 | } 202 | None => { 203 | output.push_str("/>"); 204 | } 205 | }, 206 | false => { 207 | output.push_str(">"); 208 | for child in &n.children { 209 | render(output, &child); 210 | } 211 | 212 | match &n.close_tag { 213 | Some(tag) => { 214 | output.push_str(""); 217 | } 218 | None => { 219 | output.push_str("/>"); 220 | } 221 | } 222 | } 223 | } 224 | } 225 | } 226 | } 227 | Node::Block(n) => { 228 | let tokens = n.to_token_stream(); 229 | output.push_tokens(tokens); 230 | } 231 | Node::Text(n) => output.push_str(&n.value_string()), 232 | Node::RawText(n) => output.push_str(&n.to_token_stream_string()), 233 | } 234 | } 235 | 236 | #[derive(Debug)] 237 | struct Output { 238 | buf: Ident, 239 | static_string: String, 240 | tokens: Vec, 241 | } 242 | 243 | impl Output { 244 | fn new(buf: Ident) -> Self { 245 | Self { 246 | buf, 247 | tokens: vec![], 248 | static_string: String::new(), 249 | } 250 | } 251 | 252 | fn push_str(&mut self, string: &str) { 253 | self.static_string.push_str(string); 254 | } 255 | 256 | fn push_tokens(&mut self, tokens: TokenStream2) { 257 | self.push_expr(); 258 | let buf = &self.buf; 259 | let tokens = quote! { 260 | #tokens.render_to_string(&mut #buf); 261 | }; 262 | self.tokens.push(tokens); 263 | } 264 | 265 | fn push_expr(&mut self) { 266 | if self.static_string.is_empty() { 267 | return; 268 | } 269 | let expr = { 270 | let output_ident = self.buf.clone(); 271 | let string = LitStr::new(&self.static_string, Span::call_site()); 272 | quote!(#output_ident.push_str(#string);) 273 | }; 274 | self.static_string.clear(); 275 | self.tokens.push(expr); 276 | } 277 | 278 | fn to_token_stream(mut self) -> TokenStream2 { 279 | self.push_expr(); 280 | self.tokens.into_iter().collect() 281 | } 282 | } 283 | 284 | #[proc_macro_attribute] 285 | pub fn component(_args: TokenStream, input: TokenStream) -> TokenStream { 286 | let item_fn = parse_macro_input!(input as ItemFn); 287 | match chaos::component_macro(item_fn) { 288 | Ok(s) => s.to_token_stream().into(), 289 | Err(e) => e.to_compile_error().into(), 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | #![no_std] 3 | 4 | extern crate alloc; 5 | use alloc::{borrow::Cow, string::String, vec::Vec}; 6 | use core::fmt; 7 | 8 | pub use shtml_macros::html; 9 | 10 | #[cfg(not(feature = "chaos"))] 11 | #[cfg(test)] 12 | mod tests { 13 | use super::*; 14 | use alloc::{string::ToString, vec::Vec}; 15 | 16 | #[test] 17 | fn it_works() { 18 | let result = html! { 19 | 20 | 21 | 22 | shtml 23 | 24 | } 25 | .to_string(); 26 | 27 | assert_eq!( 28 | result, 29 | r#"shtml"# 30 | ); 31 | } 32 | 33 | #[test] 34 | fn it_works_with_blocks() { 35 | let x = 1; 36 | let result = html! {
    {x}
    }.to_string(); 37 | 38 | assert_eq!(result, r#"
    1
    "#); 39 | } 40 | 41 | #[test] 42 | fn it_works_with_attr_blocks() { 43 | let class = "flex items-center h-full"; 44 | let result = html! {
    }.to_string(); 45 | 46 | assert_eq!(result, r#"
    "#); 47 | } 48 | 49 | #[test] 50 | fn it_works_with_components() { 51 | fn Hello(name: &str) -> Component { 52 | html! {
    {name}
    } 53 | } 54 | 55 | let x = ""; 56 | let result = html! { }.to_string(); 57 | 58 | assert_eq!(result, r#"
    <script>shtml</script>
    "#); 59 | } 60 | 61 | #[test] 62 | fn it_works_with_attrs() { 63 | fn Hypermedia(target: &str) -> Component { 64 | html! {
    } 65 | } 66 | 67 | let x = "body"; 68 | let result = html! { }.to_string(); 69 | 70 | assert_eq!(result, r#"
    "#); 71 | } 72 | 73 | #[test] 74 | fn it_works_with_escaped_components() { 75 | fn Hello(elements: Elements) -> Component { 76 | html! { {elements} } 77 | } 78 | 79 | let x = ""; 80 | let result = html! { 81 | 82 |
    {x}
    83 |
    84 | } 85 | .to_string(); 86 | 87 | assert_eq!( 88 | result, 89 | r#"
    <script>alert("owned")</script>
    "# 90 | ); 91 | } 92 | 93 | #[test] 94 | fn it_works_with_components_with_attrs_and_children() { 95 | fn Heading(class: &str, els: Elements) -> Component { 96 | html! {

    {els}

    } 97 | } 98 | 99 | let result = html! { 100 | 101 |

    How now brown cow

    102 |
    103 | }; 104 | 105 | assert_eq!( 106 | result.to_string(), 107 | r#"

    How now brown cow

    "# 108 | ); 109 | } 110 | 111 | #[test] 112 | fn it_works_with_components_with_children() { 113 | fn Hello(name: &str, elements: Elements) -> Component { 114 | html! { 115 | {elements} 116 |
    {name}
    117 | } 118 | } 119 | 120 | let x = "shtml"; 121 | let result = html! { 122 | 123 | "mr." 124 | 125 | } 126 | .to_string(); 127 | 128 | assert_eq!(result, r#"mr.
    shtml
    "#); 129 | } 130 | 131 | #[test] 132 | fn it_works_for_tables() { 133 | const SIZE: usize = 2; 134 | let mut rows = Vec::with_capacity(SIZE); 135 | for _ in 0..SIZE { 136 | let mut inner = Vec::with_capacity(SIZE); 137 | for i in 0..SIZE { 138 | inner.push(i); 139 | } 140 | rows.push(inner); 141 | } 142 | 143 | let component = html! { 144 | 145 | {rows 146 | .iter() 147 | .map(|cols| { 148 | html! { 149 | 150 | {cols 151 | .iter() 152 | .map(|col| html! { }) 153 | .collect::>()} 154 | 155 | } 156 | }) 157 | .collect::>()} 158 |
    {col}
    159 | }; 160 | 161 | assert_eq!( 162 | component.to_string(), 163 | "
    01
    01
    " 164 | ); 165 | } 166 | 167 | #[test] 168 | fn it_works_for_tables_with_components() { 169 | const SIZE: usize = 2; 170 | let mut rows = Vec::with_capacity(SIZE); 171 | for _ in 0..SIZE { 172 | let mut inner = Vec::with_capacity(SIZE); 173 | for i in 0..SIZE { 174 | inner.push(i); 175 | } 176 | rows.push(inner); 177 | } 178 | 179 | fn Table(rows: Elements) -> Component { 180 | html! { {rows}
    } 181 | } 182 | 183 | fn Row(cols: Elements) -> Component { 184 | html! { {cols} } 185 | } 186 | 187 | fn Col(i: Elements) -> Component { 188 | html! { {i} } 189 | } 190 | 191 | let component = html! { 192 | 193 | {rows 194 | .iter() 195 | .map(|cols| { 196 | html! { 197 | 198 | {cols.iter().map(|i| html! { {i} }).collect::>()} 199 | 200 | } 201 | }) 202 | .collect::>()} 203 |
    204 | }; 205 | 206 | assert_eq!( 207 | component.to_string(), 208 | "
    01
    01
    " 209 | ); 210 | } 211 | 212 | #[test] 213 | fn it_works_with_multiple_children_components() { 214 | fn Html(component: Elements) -> Component { 215 | html! { 216 | 217 | {component} 218 | } 219 | } 220 | 221 | fn Head(component: Elements) -> Component { 222 | html! { {component} } 223 | } 224 | 225 | fn Body(component: Elements) -> Component { 226 | html! { {component} } 227 | } 228 | 229 | let component = html! { 230 | 231 | 232 | 233 | head 234 | 235 | 236 |
    shtml
    237 | 238 | 239 | }; 240 | 241 | assert_eq!(component.to_string(), "head
    shtml
    "); 242 | } 243 | 244 | #[test] 245 | fn it_works_with_fragments() { 246 | fn HStack(elements: Elements) -> Component { 247 | html! {
    {elements}
    } 248 | } 249 | 250 | let component = html! { 251 | 252 | <> 253 |
    1
    254 |
    2
    255 |
    3
    256 | 257 |
    258 | }; 259 | 260 | assert_eq!( 261 | component.to_string(), 262 | r#"
    1
    2
    3
    "# 263 | ); 264 | } 265 | 266 | #[test] 267 | fn it_works_with_simple_loops() { 268 | fn List(elements: Elements) -> Component { 269 | html! {
      {elements}
    } 270 | } 271 | 272 | fn Item(elements: Elements) -> Component { 273 | html! {
  • {elements}
  • } 274 | } 275 | 276 | let items = Vec::from([1, 2, 3]); 277 | 278 | let component = html! { {items.iter().map(|i| html! { {i} }).collect::>()} }; 279 | 280 | assert_eq!( 281 | component.to_string(), 282 | r#"
    • 1
    • 2
    • 3
    "# 283 | ); 284 | } 285 | 286 | #[test] 287 | fn it_works_with_fragments_and_components() { 288 | fn HStack(elements: Elements) -> Component { 289 | html! {
    {elements}
    } 290 | } 291 | 292 | fn VStack(elements: Elements) -> Component { 293 | html! {
    {elements}
    } 294 | } 295 | 296 | let component = html! { 297 | 298 | 299 |
    1
    300 |
    2
    301 |
    302 |
    303 | }; 304 | 305 | assert_eq!( 306 | component.to_string(), 307 | r#"
    1
    2
    "# 308 | ); 309 | } 310 | 311 | #[test] 312 | fn it_works_with_floats() { 313 | let x = 3.14; 314 | let result = html! {
    {x}
    }.to_string(); 315 | 316 | assert_eq!(result, r#"
    3.14
    "#); 317 | } 318 | 319 | #[test] 320 | fn it_works_with_special_characters() { 321 | let special_characters = "<>&\"'"; 322 | let result = html! {
    {special_characters}
    }.to_string(); 323 | 324 | assert_eq!(result, r#"
    <>&"'
    "#); 325 | } 326 | 327 | #[test] 328 | fn it_works_with_strings() { 329 | let string = "Hi".to_string(); 330 | let result = html! {
    {string}
    }.to_string(); 331 | 332 | assert_eq!(result, r#"
    Hi
    "#); 333 | } 334 | 335 | #[test] 336 | fn it_works_with_string_refs() { 337 | let string_ref = &"Hi".to_string(); 338 | let result = html! {
    {string_ref}
    }.to_string(); 339 | 340 | assert_eq!(result, r#"
    Hi
    "#); 341 | } 342 | 343 | #[test] 344 | fn it_works_with_spread_attributes() { 345 | let attrs = Vec::from([ ("data-test".to_string(), "test".to_string() ) ]); 346 | 347 | let result = html! {
    Test
    }.to_string(); 348 | 349 | assert_eq!(result, r#"
    Test
    "#); 350 | } 351 | } 352 | 353 | pub type Elements = Component; 354 | 355 | #[derive(Debug, PartialEq, Eq)] 356 | pub struct Component { 357 | pub html: String, 358 | } 359 | 360 | pub trait Render { 361 | fn render_to_string(&self, buffer: &mut String); 362 | } 363 | 364 | macro_rules! impl_render_int { 365 | ($t:ty) => { 366 | impl Render for $t { 367 | fn render_to_string(&self, buffer: &mut String) { 368 | let mut b = itoa::Buffer::new(); 369 | buffer.push_str(b.format(*self)); 370 | } 371 | } 372 | }; 373 | } 374 | 375 | macro_rules! impl_render_float { 376 | ($t:ty) => { 377 | impl Render for $t { 378 | fn render_to_string(&self, buffer: &mut String) { 379 | let mut b = ryu::Buffer::new(); 380 | buffer.push_str(b.format(*self)); 381 | } 382 | } 383 | }; 384 | } 385 | 386 | impl_render_int!(u8); 387 | impl_render_int!(i8); 388 | impl_render_int!(u16); 389 | impl_render_int!(i16); 390 | impl_render_int!(i64); 391 | impl_render_int!(u64); 392 | impl_render_int!(i32); 393 | impl_render_int!(u32); 394 | impl_render_int!(usize); 395 | impl_render_int!(isize); 396 | 397 | impl_render_float!(f64); 398 | impl_render_float!(f32); 399 | 400 | impl Render for Component { 401 | fn render_to_string(&self, buffer: &mut String) { 402 | buffer.push_str(&self.html); 403 | } 404 | } 405 | 406 | impl Render for String { 407 | fn render_to_string(&self, buffer: &mut String) { 408 | buffer.push_str(&escape(self)) 409 | } 410 | } 411 | 412 | impl Render for &str { 413 | fn render_to_string(&self, buffer: &mut String) { 414 | buffer.push_str(&escape(*self)) 415 | } 416 | } 417 | 418 | impl Render for Vec 419 | where 420 | T: Render, 421 | { 422 | fn render_to_string(&self, buffer: &mut String) { 423 | self.iter().for_each(|s| s.render_to_string(buffer)); 424 | } 425 | } 426 | 427 | impl Render for Vec<(T, T)> 428 | where 429 | T: Render, 430 | { 431 | fn render_to_string(&self, buffer: &mut String) { 432 | self.iter().for_each(|(key, value)| { 433 | buffer.push_str(" "); 434 | key.render_to_string(buffer); 435 | buffer.push_str("="); 436 | buffer.push_str(r#"""#); 437 | value.render_to_string(buffer); 438 | buffer.push_str(r#"""#); 439 | }); 440 | } 441 | } 442 | 443 | impl fmt::Display for Component { 444 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 445 | f.write_fmt(format_args!("{}", self.html)) 446 | } 447 | } 448 | 449 | pub fn escape<'a, S: Into>>(input: S) -> Cow<'a, str> { 450 | let input = input.into(); 451 | fn needs_escaping(c: char) -> bool { 452 | c == '<' || c == '>' || c == '&' || c == '"' || c == '\'' 453 | } 454 | 455 | if let Some(first) = input.find(needs_escaping) { 456 | let mut output = String::from(&input[0..first]); 457 | output.reserve(input.len() - first); 458 | let rest = input[first..].chars(); 459 | for c in rest { 460 | match c { 461 | '<' => output.push_str("<"), 462 | '>' => output.push_str(">"), 463 | '&' => output.push_str("&"), 464 | '"' => output.push_str("""), 465 | '\'' => output.push_str("'"), 466 | _ => output.push(c), 467 | } 468 | } 469 | Cow::Owned(output) 470 | } else { 471 | input 472 | } 473 | } 474 | 475 | #[cfg(feature = "chaos")] 476 | pub use shtml_macros::component; 477 | 478 | #[cfg(feature = "chaos")] 479 | #[cfg(test)] 480 | mod tests { 481 | use super::*; 482 | use alloc::string::ToString; 483 | 484 | #[test] 485 | fn it_works_with_out_of_order_attr_components() { 486 | #[component] 487 | fn Chaos(c: String, b: u8, a: &str) -> Component { 488 | html! {
    } 489 | } 490 | 491 | let result = html! { }.to_string(); 492 | 493 | assert_eq!(result, r#"
    "#); 494 | } 495 | 496 | #[test] 497 | fn it_works_with_out_of_order_attr_components_without_refs() { 498 | #[component] 499 | fn Chaos(b: u8, c: String) -> Component { 500 | html! {
    } 501 | } 502 | let result = html! { }.to_string(); 503 | 504 | assert_eq!(result, r#"
    "#); 505 | } 506 | } 507 | --------------------------------------------------------------------------------