├── .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 | //
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 | //
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 | //
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("");
199 | output.push_str(&tag.name.to_string());
200 | 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("");
215 | output.push_str(&tag.name.to_string());
216 | 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! { {col} | })
153 | .collect::>()}
154 |
155 | }
156 | })
157 | .collect::>()}
158 |
159 | };
160 |
161 | assert_eq!(
162 | component.to_string(),
163 | ""
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! { }
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 | ""
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(), "headshtml
");
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#""#
263 | );
264 | }
265 |
266 | #[test]
267 | fn it_works_with_simple_loops() {
268 | fn List(elements: Elements) -> Component {
269 | html! { }
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#""#
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#""#
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 |
--------------------------------------------------------------------------------