├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── security-audit.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── deny.toml ├── html-node-core ├── Cargo.toml └── src │ ├── http.rs │ ├── lib.rs │ ├── node │ ├── comment.rs │ ├── doctype.rs │ ├── element.rs │ ├── fragment.rs │ ├── mod.rs │ ├── text.rs │ └── unsafe_text.rs │ ├── pretty.rs │ └── typed │ ├── elements.rs │ └── mod.rs ├── html-node-macro ├── Cargo.toml └── src │ ├── lib.rs │ └── node_handlers │ ├── mod.rs │ └── typed.rs ├── html-node ├── Cargo.toml ├── examples │ ├── axum.rs │ ├── escaping.rs │ └── typed_custom_attributes.rs ├── src │ ├── lib.rs │ ├── macros.rs │ └── typed.rs └── tests │ ├── main.rs │ └── typed.rs ├── rustfmt.toml └── taplo.toml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | 11 | permissions: 12 | contents: read 13 | 14 | runs-on: ubuntu-latest 15 | 16 | container: 17 | image: rust:latest 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Cache dependencies 24 | uses: Swatinem/rust-cache@v2 25 | 26 | - name: Execute tests 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: test 30 | args: --all-features --no-fail-fast 31 | 32 | check: 33 | name: Check 34 | 35 | permissions: 36 | contents: read 37 | 38 | runs-on: ubuntu-latest 39 | 40 | container: 41 | image: rust:latest 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | 47 | - name: Cache dependencies 48 | uses: Swatinem/rust-cache@v2 49 | 50 | - name: Install `clippy` 51 | run: rustup component add clippy 52 | 53 | - name: Check code 54 | uses: actions-rs/cargo@v1 55 | with: 56 | command: clippy 57 | args: --all-features --all-targets -- -D warnings 58 | 59 | format: 60 | name: Format 61 | 62 | permissions: 63 | contents: read 64 | 65 | runs-on: ubuntu-latest 66 | 67 | container: 68 | image: rustlang/rust:nightly 69 | 70 | steps: 71 | - name: Checkout repository 72 | uses: actions/checkout@v4 73 | 74 | - name: Cache dependencies 75 | uses: Swatinem/rust-cache@v2 76 | 77 | - name: Check formatting 78 | uses: actions-rs/cargo@v1 79 | with: 80 | command: fmt 81 | args: --all -- --check 82 | -------------------------------------------------------------------------------- /.github/workflows/security-audit.yml: -------------------------------------------------------------------------------- 1 | name: Security Audit 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | push: 8 | paths: 9 | - "**/Cargo.toml" 10 | - "**/Cargo.lock" 11 | - "**/deny.toml" 12 | 13 | pull_request: 14 | 15 | jobs: 16 | cargo-deny: 17 | name: Security Audit 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Run `cargo-deny` 26 | uses: EmbarkStudios/cargo-deny-action@v1 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,rust 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,rust 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### Rust ### 38 | # Generated by Cargo 39 | # will have compiled files and executables 40 | debug/ 41 | target/ 42 | 43 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 44 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 45 | # Cargo.lock 46 | 47 | # These are backup files generated by rustfmt 48 | **/*.rs.bk 49 | 50 | # MSVC Windows builds of rustc generate these, which store debugging information 51 | *.pdb 52 | 53 | ### VisualStudioCode ### 54 | .vscode/* 55 | !.vscode/settings.json 56 | !.vscode/tasks.json 57 | !.vscode/launch.json 58 | !.vscode/extensions.json 59 | !.vscode/*.code-snippets 60 | 61 | # Local History for Visual Studio Code 62 | .history/ 63 | 64 | # Built Visual Studio Code Extensions 65 | *.vsix 66 | 67 | ### VisualStudioCode Patch ### 68 | # Ignore all local history of files 69 | .history 70 | .ionide 71 | 72 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,rust 73 | 74 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 75 | -------------------------------------------------------------------------------- /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 = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "async-trait" 22 | version = "0.1.74" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" 25 | dependencies = [ 26 | "proc-macro2", 27 | "quote", 28 | "syn", 29 | ] 30 | 31 | [[package]] 32 | name = "axum" 33 | version = "0.6.20" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" 36 | dependencies = [ 37 | "async-trait", 38 | "axum-core", 39 | "bitflags", 40 | "bytes", 41 | "futures-util", 42 | "http", 43 | "http-body", 44 | "hyper", 45 | "itoa", 46 | "matchit", 47 | "memchr", 48 | "mime", 49 | "percent-encoding", 50 | "pin-project-lite", 51 | "rustversion", 52 | "serde", 53 | "serde_json", 54 | "serde_path_to_error", 55 | "serde_urlencoded", 56 | "sync_wrapper", 57 | "tokio", 58 | "tower", 59 | "tower-layer", 60 | "tower-service", 61 | ] 62 | 63 | [[package]] 64 | name = "axum-core" 65 | version = "0.3.4" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" 68 | dependencies = [ 69 | "async-trait", 70 | "bytes", 71 | "futures-util", 72 | "http", 73 | "http-body", 74 | "mime", 75 | "rustversion", 76 | "tower-layer", 77 | "tower-service", 78 | ] 79 | 80 | [[package]] 81 | name = "backtrace" 82 | version = "0.3.69" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 85 | dependencies = [ 86 | "addr2line", 87 | "cc", 88 | "cfg-if", 89 | "libc", 90 | "miniz_oxide", 91 | "object", 92 | "rustc-demangle", 93 | ] 94 | 95 | [[package]] 96 | name = "bitflags" 97 | version = "1.3.2" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 100 | 101 | [[package]] 102 | name = "bytes" 103 | version = "1.5.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" 106 | 107 | [[package]] 108 | name = "cc" 109 | version = "1.0.83" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 112 | dependencies = [ 113 | "libc", 114 | ] 115 | 116 | [[package]] 117 | name = "cfg-if" 118 | version = "1.0.0" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 121 | 122 | [[package]] 123 | name = "fnv" 124 | version = "1.0.7" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 127 | 128 | [[package]] 129 | name = "form_urlencoded" 130 | version = "1.2.1" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 133 | dependencies = [ 134 | "percent-encoding", 135 | ] 136 | 137 | [[package]] 138 | name = "futures-channel" 139 | version = "0.3.29" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" 142 | dependencies = [ 143 | "futures-core", 144 | ] 145 | 146 | [[package]] 147 | name = "futures-core" 148 | version = "0.3.29" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" 151 | 152 | [[package]] 153 | name = "futures-task" 154 | version = "0.3.29" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" 157 | 158 | [[package]] 159 | name = "futures-util" 160 | version = "0.3.29" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" 163 | dependencies = [ 164 | "futures-core", 165 | "futures-task", 166 | "pin-project-lite", 167 | "pin-utils", 168 | ] 169 | 170 | [[package]] 171 | name = "gimli" 172 | version = "0.28.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" 175 | 176 | [[package]] 177 | name = "hermit-abi" 178 | version = "0.3.3" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" 181 | 182 | [[package]] 183 | name = "html-escape" 184 | version = "0.2.13" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" 187 | dependencies = [ 188 | "utf8-width", 189 | ] 190 | 191 | [[package]] 192 | name = "html-node" 193 | version = "0.5.0" 194 | dependencies = [ 195 | "axum", 196 | "html-node-core", 197 | "html-node-macro", 198 | "tokio", 199 | ] 200 | 201 | [[package]] 202 | name = "html-node-core" 203 | version = "0.5.0" 204 | dependencies = [ 205 | "axum", 206 | "html-escape", 207 | "paste", 208 | "serde", 209 | ] 210 | 211 | [[package]] 212 | name = "html-node-macro" 213 | version = "0.5.0" 214 | dependencies = [ 215 | "proc-macro2", 216 | "proc-macro2-diagnostics", 217 | "quote", 218 | "rstml", 219 | "syn", 220 | "syn_derive", 221 | ] 222 | 223 | [[package]] 224 | name = "http" 225 | version = "0.2.11" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" 228 | dependencies = [ 229 | "bytes", 230 | "fnv", 231 | "itoa", 232 | ] 233 | 234 | [[package]] 235 | name = "http-body" 236 | version = "0.4.5" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" 239 | dependencies = [ 240 | "bytes", 241 | "http", 242 | "pin-project-lite", 243 | ] 244 | 245 | [[package]] 246 | name = "httparse" 247 | version = "1.8.0" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" 250 | 251 | [[package]] 252 | name = "httpdate" 253 | version = "1.0.3" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 256 | 257 | [[package]] 258 | name = "hyper" 259 | version = "0.14.27" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" 262 | dependencies = [ 263 | "bytes", 264 | "futures-channel", 265 | "futures-core", 266 | "futures-util", 267 | "http", 268 | "http-body", 269 | "httparse", 270 | "httpdate", 271 | "itoa", 272 | "pin-project-lite", 273 | "socket2 0.4.10", 274 | "tokio", 275 | "tower-service", 276 | "tracing", 277 | "want", 278 | ] 279 | 280 | [[package]] 281 | name = "itoa" 282 | version = "1.0.9" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" 285 | 286 | [[package]] 287 | name = "libc" 288 | version = "0.2.150" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" 291 | 292 | [[package]] 293 | name = "log" 294 | version = "0.4.20" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 297 | 298 | [[package]] 299 | name = "matchit" 300 | version = "0.7.3" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 303 | 304 | [[package]] 305 | name = "memchr" 306 | version = "2.6.4" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" 309 | 310 | [[package]] 311 | name = "mime" 312 | version = "0.3.17" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 315 | 316 | [[package]] 317 | name = "miniz_oxide" 318 | version = "0.7.1" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 321 | dependencies = [ 322 | "adler", 323 | ] 324 | 325 | [[package]] 326 | name = "mio" 327 | version = "0.8.9" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" 330 | dependencies = [ 331 | "libc", 332 | "wasi", 333 | "windows-sys", 334 | ] 335 | 336 | [[package]] 337 | name = "num_cpus" 338 | version = "1.16.0" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 341 | dependencies = [ 342 | "hermit-abi", 343 | "libc", 344 | ] 345 | 346 | [[package]] 347 | name = "object" 348 | version = "0.32.1" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" 351 | dependencies = [ 352 | "memchr", 353 | ] 354 | 355 | [[package]] 356 | name = "once_cell" 357 | version = "1.18.0" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 360 | 361 | [[package]] 362 | name = "paste" 363 | version = "1.0.14" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" 366 | 367 | [[package]] 368 | name = "percent-encoding" 369 | version = "2.3.1" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 372 | 373 | [[package]] 374 | name = "pin-project" 375 | version = "1.1.3" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" 378 | dependencies = [ 379 | "pin-project-internal", 380 | ] 381 | 382 | [[package]] 383 | name = "pin-project-internal" 384 | version = "1.1.3" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" 387 | dependencies = [ 388 | "proc-macro2", 389 | "quote", 390 | "syn", 391 | ] 392 | 393 | [[package]] 394 | name = "pin-project-lite" 395 | version = "0.2.13" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" 398 | 399 | [[package]] 400 | name = "pin-utils" 401 | version = "0.1.0" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 404 | 405 | [[package]] 406 | name = "proc-macro-error" 407 | version = "1.0.4" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 410 | dependencies = [ 411 | "proc-macro-error-attr", 412 | "proc-macro2", 413 | "quote", 414 | "version_check", 415 | ] 416 | 417 | [[package]] 418 | name = "proc-macro-error-attr" 419 | version = "1.0.4" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 422 | dependencies = [ 423 | "proc-macro2", 424 | "quote", 425 | "version_check", 426 | ] 427 | 428 | [[package]] 429 | name = "proc-macro2" 430 | version = "1.0.69" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" 433 | dependencies = [ 434 | "unicode-ident", 435 | ] 436 | 437 | [[package]] 438 | name = "proc-macro2-diagnostics" 439 | version = "0.10.1" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 442 | dependencies = [ 443 | "proc-macro2", 444 | "quote", 445 | "syn", 446 | "version_check", 447 | ] 448 | 449 | [[package]] 450 | name = "quote" 451 | version = "1.0.33" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 454 | dependencies = [ 455 | "proc-macro2", 456 | ] 457 | 458 | [[package]] 459 | name = "rstml" 460 | version = "0.11.2" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "fe542870b8f59dd45ad11d382e5339c9a1047cde059be136a7016095bbdefa77" 463 | dependencies = [ 464 | "proc-macro2", 465 | "proc-macro2-diagnostics", 466 | "quote", 467 | "syn", 468 | "syn_derive", 469 | "thiserror", 470 | ] 471 | 472 | [[package]] 473 | name = "rustc-demangle" 474 | version = "0.1.23" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 477 | 478 | [[package]] 479 | name = "rustversion" 480 | version = "1.0.14" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" 483 | 484 | [[package]] 485 | name = "ryu" 486 | version = "1.0.15" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" 489 | 490 | [[package]] 491 | name = "serde" 492 | version = "1.0.193" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" 495 | dependencies = [ 496 | "serde_derive", 497 | ] 498 | 499 | [[package]] 500 | name = "serde_derive" 501 | version = "1.0.193" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" 504 | dependencies = [ 505 | "proc-macro2", 506 | "quote", 507 | "syn", 508 | ] 509 | 510 | [[package]] 511 | name = "serde_json" 512 | version = "1.0.108" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" 515 | dependencies = [ 516 | "itoa", 517 | "ryu", 518 | "serde", 519 | ] 520 | 521 | [[package]] 522 | name = "serde_path_to_error" 523 | version = "0.1.14" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" 526 | dependencies = [ 527 | "itoa", 528 | "serde", 529 | ] 530 | 531 | [[package]] 532 | name = "serde_urlencoded" 533 | version = "0.7.1" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 536 | dependencies = [ 537 | "form_urlencoded", 538 | "itoa", 539 | "ryu", 540 | "serde", 541 | ] 542 | 543 | [[package]] 544 | name = "socket2" 545 | version = "0.4.10" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" 548 | dependencies = [ 549 | "libc", 550 | "winapi", 551 | ] 552 | 553 | [[package]] 554 | name = "socket2" 555 | version = "0.5.5" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" 558 | dependencies = [ 559 | "libc", 560 | "windows-sys", 561 | ] 562 | 563 | [[package]] 564 | name = "syn" 565 | version = "2.0.39" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" 568 | dependencies = [ 569 | "proc-macro2", 570 | "quote", 571 | "unicode-ident", 572 | ] 573 | 574 | [[package]] 575 | name = "syn_derive" 576 | version = "0.1.8" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" 579 | dependencies = [ 580 | "proc-macro-error", 581 | "proc-macro2", 582 | "quote", 583 | "syn", 584 | ] 585 | 586 | [[package]] 587 | name = "sync_wrapper" 588 | version = "0.1.2" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" 591 | 592 | [[package]] 593 | name = "thiserror" 594 | version = "1.0.50" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" 597 | dependencies = [ 598 | "thiserror-impl", 599 | ] 600 | 601 | [[package]] 602 | name = "thiserror-impl" 603 | version = "1.0.50" 604 | source = "registry+https://github.com/rust-lang/crates.io-index" 605 | checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" 606 | dependencies = [ 607 | "proc-macro2", 608 | "quote", 609 | "syn", 610 | ] 611 | 612 | [[package]] 613 | name = "tokio" 614 | version = "1.34.0" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" 617 | dependencies = [ 618 | "backtrace", 619 | "libc", 620 | "mio", 621 | "num_cpus", 622 | "pin-project-lite", 623 | "socket2 0.5.5", 624 | "tokio-macros", 625 | "windows-sys", 626 | ] 627 | 628 | [[package]] 629 | name = "tokio-macros" 630 | version = "2.2.0" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" 633 | dependencies = [ 634 | "proc-macro2", 635 | "quote", 636 | "syn", 637 | ] 638 | 639 | [[package]] 640 | name = "tower" 641 | version = "0.4.13" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 644 | dependencies = [ 645 | "futures-core", 646 | "futures-util", 647 | "pin-project", 648 | "pin-project-lite", 649 | "tokio", 650 | "tower-layer", 651 | "tower-service", 652 | "tracing", 653 | ] 654 | 655 | [[package]] 656 | name = "tower-layer" 657 | version = "0.3.2" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" 660 | 661 | [[package]] 662 | name = "tower-service" 663 | version = "0.3.2" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" 666 | 667 | [[package]] 668 | name = "tracing" 669 | version = "0.1.40" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" 672 | dependencies = [ 673 | "log", 674 | "pin-project-lite", 675 | "tracing-core", 676 | ] 677 | 678 | [[package]] 679 | name = "tracing-core" 680 | version = "0.1.32" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" 683 | dependencies = [ 684 | "once_cell", 685 | ] 686 | 687 | [[package]] 688 | name = "try-lock" 689 | version = "0.2.4" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" 692 | 693 | [[package]] 694 | name = "unicode-ident" 695 | version = "1.0.12" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 698 | 699 | [[package]] 700 | name = "utf8-width" 701 | version = "0.1.7" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" 704 | 705 | [[package]] 706 | name = "version_check" 707 | version = "0.9.4" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 710 | 711 | [[package]] 712 | name = "want" 713 | version = "0.3.1" 714 | source = "registry+https://github.com/rust-lang/crates.io-index" 715 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 716 | dependencies = [ 717 | "try-lock", 718 | ] 719 | 720 | [[package]] 721 | name = "wasi" 722 | version = "0.11.0+wasi-snapshot-preview1" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 725 | 726 | [[package]] 727 | name = "winapi" 728 | version = "0.3.9" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 731 | dependencies = [ 732 | "winapi-i686-pc-windows-gnu", 733 | "winapi-x86_64-pc-windows-gnu", 734 | ] 735 | 736 | [[package]] 737 | name = "winapi-i686-pc-windows-gnu" 738 | version = "0.4.0" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 741 | 742 | [[package]] 743 | name = "winapi-x86_64-pc-windows-gnu" 744 | version = "0.4.0" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 747 | 748 | [[package]] 749 | name = "windows-sys" 750 | version = "0.48.0" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 753 | dependencies = [ 754 | "windows-targets", 755 | ] 756 | 757 | [[package]] 758 | name = "windows-targets" 759 | version = "0.48.5" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 762 | dependencies = [ 763 | "windows_aarch64_gnullvm", 764 | "windows_aarch64_msvc", 765 | "windows_i686_gnu", 766 | "windows_i686_msvc", 767 | "windows_x86_64_gnu", 768 | "windows_x86_64_gnullvm", 769 | "windows_x86_64_msvc", 770 | ] 771 | 772 | [[package]] 773 | name = "windows_aarch64_gnullvm" 774 | version = "0.48.5" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 777 | 778 | [[package]] 779 | name = "windows_aarch64_msvc" 780 | version = "0.48.5" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 783 | 784 | [[package]] 785 | name = "windows_i686_gnu" 786 | version = "0.48.5" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 789 | 790 | [[package]] 791 | name = "windows_i686_msvc" 792 | version = "0.48.5" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 795 | 796 | [[package]] 797 | name = "windows_x86_64_gnu" 798 | version = "0.48.5" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 801 | 802 | [[package]] 803 | name = "windows_x86_64_gnullvm" 804 | version = "0.48.5" 805 | source = "registry+https://github.com/rust-lang/crates.io-index" 806 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 807 | 808 | [[package]] 809 | name = "windows_x86_64_msvc" 810 | version = "0.48.5" 811 | source = "registry+https://github.com/rust-lang/crates.io-index" 812 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 813 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | default-members = ["./html-node*"] 3 | members = ["./html-node*"] 4 | resolver = "2" 5 | 6 | [workspace.package] 7 | authors = ["Vidhan Bhatt "] 8 | categories = ["template-engine"] 9 | description = "A html to node macro powered by rstml." 10 | edition = "2021" 11 | homepage = "https://github.com/vidhanio/html-node" 12 | keywords = ["html", "macro", "rstml"] 13 | license = "MIT" 14 | readme = "README.md" 15 | repository = "https://github.com/vidhanio/html-node" 16 | version = "0.5.0" 17 | 18 | [workspace.lints] 19 | [workspace.lints.rust] 20 | missing_copy_implementations = "warn" 21 | missing_debug_implementations = "warn" 22 | missing_docs = "warn" 23 | unsafe_code = "forbid" 24 | 25 | [workspace.lints.clippy] 26 | nursery = "warn" 27 | pedantic = "warn" 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vidhan Bhatt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `html-node` 2 | 3 | HTML nodes in Rust. \[powered by [rstml](https://github.com/rs-tml/rstml)\]. 4 | 5 | ## Note 6 | 7 | If you want to use this purely for building websites in rust, consider using my other library, [hypertext](https://github.com/vidhanio/hypertext). It puts performance and type-checking first, and supports this crate's syntax as well as [maud](https://maud.lambda.xyz)'s syntax, which is much cleaner. 8 | 9 | ## Features 10 | 11 | - Text escaping 12 | - Pretty-printing 13 | - Customizable compile-time type-checked elements and attributes ([docs](https://docs.rs/html-node/latest/html_node/typed/index.html)) 14 | - completely optional, and can be mixed with untyped elements when needed! 15 | 16 | ## Example 17 | 18 | ```rust 19 | let shopping_list = vec!["milk", "eggs", "bread"]; 20 | 21 | let shopping_list_html = html! { 22 |
23 |

Shopping List

24 | 32 |
33 | }; 34 | ``` 35 | 36 |
37 | HTML Output 38 | 39 | ```rust 40 | // the `#` flag enables pretty-printing 41 | println!("{shopping_list_html:#}"); 42 | ``` 43 | 44 | ```html 45 |
46 |

47 | Shopping List 48 |

49 |
    50 |
  • 51 | 52 | 55 |
  • 56 |
  • 57 | 58 | 61 |
  • 62 |
  • 63 | 64 | 67 |
  • 68 |
69 |
70 | ``` 71 | 72 |
73 | 74 |
75 | Rust Output 76 | 77 | ```rust 78 | println!("{shopping_list_html:#?}"); 79 | ``` 80 | 81 | ```rust 82 | Element( 83 | Element { 84 | name: "div", 85 | attributes: [], 86 | children: Some( 87 | [ 88 | Element( 89 | Element { 90 | name: "h1", 91 | attributes: [], 92 | children: Some( 93 | [ 94 | Text( 95 | Text { 96 | text: "Shopping List", 97 | }, 98 | ), 99 | ], 100 | ), 101 | }, 102 | ), 103 | Element( 104 | Element { 105 | name: "ul", 106 | attributes: [], 107 | children: Some( 108 | [ 109 | Fragment( 110 | Fragment { 111 | children: [ 112 | Element( 113 | Element { 114 | name: "li", 115 | attributes: [ 116 | ( 117 | "class", 118 | Some( 119 | "item", 120 | ), 121 | ), 122 | ], 123 | children: Some( 124 | [ 125 | Element( 126 | Element { 127 | name: "input", 128 | attributes: [ 129 | ( 130 | "type", 131 | Some( 132 | "checkbox", 133 | ), 134 | ), 135 | ( 136 | "id", 137 | Some( 138 | "item-1", 139 | ), 140 | ), 141 | ], 142 | children: None, 143 | }, 144 | ), 145 | Element( 146 | Element { 147 | name: "label", 148 | attributes: [ 149 | ( 150 | "for", 151 | Some( 152 | "item-1", 153 | ), 154 | ), 155 | ], 156 | children: Some( 157 | [ 158 | Text( 159 | Text { 160 | text: "milk", 161 | }, 162 | ), 163 | ], 164 | ), 165 | }, 166 | ), 167 | ], 168 | ), 169 | }, 170 | ), 171 | Element( 172 | Element { 173 | name: "li", 174 | attributes: [ 175 | ( 176 | "class", 177 | Some( 178 | "item", 179 | ), 180 | ), 181 | ], 182 | children: Some( 183 | [ 184 | Element( 185 | Element { 186 | name: "input", 187 | attributes: [ 188 | ( 189 | "type", 190 | Some( 191 | "checkbox", 192 | ), 193 | ), 194 | ( 195 | "id", 196 | Some( 197 | "item-2", 198 | ), 199 | ), 200 | ], 201 | children: None, 202 | }, 203 | ), 204 | Element( 205 | Element { 206 | name: "label", 207 | attributes: [ 208 | ( 209 | "for", 210 | Some( 211 | "item-2", 212 | ), 213 | ), 214 | ], 215 | children: Some( 216 | [ 217 | Text( 218 | Text { 219 | text: "eggs", 220 | }, 221 | ), 222 | ], 223 | ), 224 | }, 225 | ), 226 | ], 227 | ), 228 | }, 229 | ), 230 | Element( 231 | Element { 232 | name: "li", 233 | attributes: [ 234 | ( 235 | "class", 236 | Some( 237 | "item", 238 | ), 239 | ), 240 | ], 241 | children: Some( 242 | [ 243 | Element( 244 | Element { 245 | name: "input", 246 | attributes: [ 247 | ( 248 | "type", 249 | Some( 250 | "checkbox", 251 | ), 252 | ), 253 | ( 254 | "id", 255 | Some( 256 | "item-3", 257 | ), 258 | ), 259 | ], 260 | children: None, 261 | }, 262 | ), 263 | Element( 264 | Element { 265 | name: "label", 266 | attributes: [ 267 | ( 268 | "for", 269 | Some( 270 | "item-3", 271 | ), 272 | ), 273 | ], 274 | children: Some( 275 | [ 276 | Text( 277 | Text { 278 | text: "bread", 279 | }, 280 | ), 281 | ], 282 | ), 283 | }, 284 | ), 285 | ], 286 | ), 287 | }, 288 | ), 289 | ], 290 | }, 291 | ), 292 | ], 293 | ), 294 | }, 295 | ), 296 | ], 297 | ), 298 | }, 299 | ) 300 | ``` 301 | 302 |
303 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [licenses] 2 | default = "allow" 3 | -------------------------------------------------------------------------------- /html-node-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors.workspace = true 3 | categories.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | homepage.workspace = true 7 | keywords.workspace = true 8 | license.workspace = true 9 | name = "html-node-core" 10 | readme.workspace = true 11 | repository.workspace = true 12 | version.workspace = true 13 | 14 | [package.metadata.docs.rs] 15 | all-features = true 16 | rustdoc-args = ["--cfg", "docsrs"] 17 | 18 | [dependencies] 19 | axum = { version = "0.6", optional = true, default-features = false } 20 | serde = { version = "1.0", optional = true, features = ["derive"] } 21 | 22 | html-escape = "0.2" 23 | paste = "1.0.14" 24 | 25 | [features] 26 | axum = ["dep:axum"] 27 | pretty = [] 28 | serde = ["dep:serde"] 29 | typed = [] 30 | 31 | [lints] 32 | workspace = true 33 | -------------------------------------------------------------------------------- /html-node-core/src/http.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "axum")] 2 | mod axum { 3 | use axum::response::{Html, IntoResponse, Response}; 4 | 5 | #[cfg(feature = "pretty")] 6 | use crate::pretty::Pretty; 7 | use crate::Node; 8 | 9 | impl IntoResponse for Node { 10 | fn into_response(self) -> Response { 11 | Html(self.to_string()).into_response() 12 | } 13 | } 14 | 15 | #[cfg(feature = "pretty")] 16 | impl IntoResponse for Pretty { 17 | fn into_response(self) -> Response { 18 | Html(self.to_string()).into_response() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /html-node-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The core crate for [`html-node`](https://docs.rs/html-node). 2 | 3 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 4 | 5 | /// HTTP Server integrations. 6 | mod http; 7 | 8 | /// [`Node`] variant definitions. 9 | mod node; 10 | 11 | /// Pretty printing utilities. 12 | #[cfg(feature = "pretty")] 13 | pub mod pretty; 14 | 15 | /// Typed HTML Nodes. 16 | #[cfg(feature = "typed")] 17 | pub mod typed; 18 | 19 | use std::fmt::{self, Display, Formatter}; 20 | 21 | pub use self::node::*; 22 | #[cfg(feature = "typed")] 23 | use self::typed::TypedElement; 24 | 25 | /// An HTML node. 26 | #[derive(Debug, Clone, PartialEq, Eq)] 27 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 28 | pub enum Node { 29 | /// A comment. 30 | /// 31 | /// ```html 32 | /// 33 | /// ``` 34 | Comment(Comment), 35 | 36 | /// A doctype. 37 | /// 38 | /// ```html 39 | /// 40 | /// ``` 41 | Doctype(Doctype), 42 | 43 | /// A fragment. 44 | /// 45 | /// ```html 46 | /// <> 47 | /// I'm in a fragment! 48 | /// 49 | /// ``` 50 | Fragment(Fragment), 51 | 52 | /// An element. 53 | /// 54 | /// ```html 55 | ///
56 | /// I'm in an element! 57 | ///
58 | /// ``` 59 | Element(Element), 60 | 61 | /// A text node. 62 | /// 63 | /// ```html 64 | ///
65 | /// I'm a text node! 66 | ///
67 | /// ``` 68 | Text(Text), 69 | 70 | /// An unsafe text node. 71 | /// 72 | /// # Warning 73 | /// 74 | /// [`Node::UnsafeText`] is not escaped when rendered, and as such, can 75 | /// allow for XSS attacks. Use with caution! 76 | UnsafeText(UnsafeText), 77 | } 78 | 79 | impl Node { 80 | /// A [`Node::Fragment`] with no children. 81 | pub const EMPTY: Self = Self::Fragment(Fragment::EMPTY); 82 | 83 | /// Create a new [`Node`] from a [`TypedElement`]. 84 | #[cfg(feature = "typed")] 85 | pub fn from_typed(element: E, children: Option>) -> Self { 86 | element.into_node(children) 87 | } 88 | 89 | /// Wrap the node in a pretty-printing wrapper. 90 | #[cfg(feature = "pretty")] 91 | #[must_use] 92 | pub fn pretty(self) -> pretty::Pretty { 93 | self.into() 94 | } 95 | 96 | /// Borrow the children of the node, if it is an element (with children) or 97 | /// a fragment. 98 | #[must_use] 99 | pub fn as_children(&self) -> Option<&[Self]> { 100 | match self { 101 | Self::Fragment(fragment) => Some(&fragment.children), 102 | Self::Element(element) => element.children.as_deref(), 103 | _ => None, 104 | } 105 | } 106 | 107 | /// Iterate over the children of the node. 108 | pub fn children_iter(&self) -> impl Iterator { 109 | self.as_children().unwrap_or_default().iter() 110 | } 111 | 112 | /// The children of the node, if it is an element (with children) or 113 | /// a fragment. 114 | #[must_use] 115 | pub fn children(self) -> Option> { 116 | match self { 117 | Self::Fragment(fragment) => Some(fragment.children), 118 | Self::Element(element) => element.children, 119 | _ => None, 120 | } 121 | } 122 | 123 | /// Iterate over the children of the node, consuming it. 124 | pub fn into_children(self) -> impl Iterator { 125 | self.children().unwrap_or_default().into_iter() 126 | } 127 | 128 | /// Try to get this node as a [`Comment`], if it is one. 129 | #[must_use] 130 | pub const fn as_comment(&self) -> Option<&Comment> { 131 | if let Self::Comment(comment) = self { 132 | Some(comment) 133 | } else { 134 | None 135 | } 136 | } 137 | 138 | /// Try to get this node as a [`Doctype`], if it is one. 139 | #[must_use] 140 | pub const fn as_doctype(&self) -> Option<&Doctype> { 141 | if let Self::Doctype(doctype) = self { 142 | Some(doctype) 143 | } else { 144 | None 145 | } 146 | } 147 | 148 | /// Try to get this node as a [`Fragment`], if it is one. 149 | #[must_use] 150 | pub const fn as_fragment(&self) -> Option<&Fragment> { 151 | if let Self::Fragment(fragment) = self { 152 | Some(fragment) 153 | } else { 154 | None 155 | } 156 | } 157 | 158 | /// Try to get this node as an [`Element`], if it is one. 159 | #[must_use] 160 | pub const fn as_element(&self) -> Option<&Element> { 161 | if let Self::Element(element) = self { 162 | Some(element) 163 | } else { 164 | None 165 | } 166 | } 167 | 168 | /// Try to get this node as a [`Text`], if it is one. 169 | #[must_use] 170 | pub const fn as_text(&self) -> Option<&Text> { 171 | if let Self::Text(text) = self { 172 | Some(text) 173 | } else { 174 | None 175 | } 176 | } 177 | 178 | /// Try to get this node as an [`UnsafeText`], if it is one. 179 | #[must_use] 180 | pub const fn as_unsafe_text(&self) -> Option<&UnsafeText> { 181 | if let Self::UnsafeText(text) = self { 182 | Some(text) 183 | } else { 184 | None 185 | } 186 | } 187 | } 188 | 189 | impl Default for Node { 190 | fn default() -> Self { 191 | Self::EMPTY 192 | } 193 | } 194 | 195 | impl Display for Node { 196 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 197 | match &self { 198 | Self::Comment(comment) => comment.fmt(f), 199 | Self::Doctype(doctype) => doctype.fmt(f), 200 | Self::Fragment(fragment) => fragment.fmt(f), 201 | Self::Element(element) => element.fmt(f), 202 | Self::Text(text) => text.fmt(f), 203 | Self::UnsafeText(unsafe_text) => unsafe_text.fmt(f), 204 | } 205 | } 206 | } 207 | 208 | impl From for Node 209 | where 210 | I: IntoIterator, 211 | N: Into, 212 | { 213 | fn from(iter: I) -> Self { 214 | Self::Fragment(iter.into()) 215 | } 216 | } 217 | 218 | impl From for Node { 219 | fn from(comment: Comment) -> Self { 220 | Self::Comment(comment) 221 | } 222 | } 223 | 224 | impl From for Node { 225 | fn from(doctype: Doctype) -> Self { 226 | Self::Doctype(doctype) 227 | } 228 | } 229 | 230 | impl From for Node { 231 | fn from(fragment: Fragment) -> Self { 232 | Self::Fragment(fragment) 233 | } 234 | } 235 | 236 | impl From for Node { 237 | fn from(element: Element) -> Self { 238 | Self::Element(element) 239 | } 240 | } 241 | 242 | impl From for Node { 243 | fn from(text: Text) -> Self { 244 | Self::Text(text) 245 | } 246 | } 247 | 248 | impl From for Node { 249 | fn from(text: UnsafeText) -> Self { 250 | Self::UnsafeText(text) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /html-node-core/src/node/comment.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | /// A comment. 4 | /// 5 | /// ```html 6 | /// 7 | /// ``` 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 10 | pub struct Comment { 11 | /// The text of the comment. 12 | /// 13 | /// ```html 14 | /// 15 | /// ``` 16 | pub comment: String, 17 | } 18 | 19 | impl Display for Comment { 20 | /// Format as an HTML comment. 21 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 22 | write!(f, "", self.comment) 23 | } 24 | } 25 | 26 | impl From for Comment 27 | where 28 | C: Into, 29 | { 30 | /// Create a new comment from anything that can be converted into a string. 31 | fn from(comment: C) -> Self { 32 | Self { 33 | comment: comment.into(), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /html-node-core/src/node/doctype.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | /// A doctype. 4 | /// 5 | /// ```html 6 | /// 7 | /// ``` 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 10 | pub struct Doctype { 11 | /// The value of the doctype. 12 | /// 13 | /// ```html 14 | /// 15 | /// ``` 16 | pub syntax: String, 17 | } 18 | 19 | impl Display for Doctype { 20 | /// Format as an HTML doctype element. 21 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 22 | write!(f, "", self.syntax) 23 | } 24 | } 25 | 26 | impl From for Doctype 27 | where 28 | S: Into, 29 | { 30 | /// Create a new doctype element with a syntax attribute set 31 | /// from anything that can be converted into a string. 32 | fn from(syntax: S) -> Self { 33 | Self { 34 | syntax: syntax.into(), 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /html-node-core/src/node/element.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use super::write_children; 4 | use crate::Node; 5 | 6 | /// An element. 7 | /// 8 | /// ```html 9 | ///
10 | /// I'm in an element! 11 | ///
12 | /// ``` 13 | #[derive(Debug, Clone, PartialEq, Eq)] 14 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 15 | pub struct Element { 16 | /// The name of the element. 17 | /// 18 | /// ```html 19 | /// 20 | /// ``` 21 | pub name: String, 22 | 23 | /// The attributes of the element. 24 | /// 25 | /// ```html 26 | ///
27 | /// ``` 28 | pub attributes: Vec<(String, Option)>, 29 | 30 | /// The children of the element. 31 | /// 32 | /// ```html 33 | ///
34 | /// 35 | /// I'm another child! 36 | ///
37 | /// ``` 38 | pub children: Option>, 39 | } 40 | 41 | impl Display for Element { 42 | /// Format as an HTML element. 43 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 44 | write!(f, "<{}", self.name)?; 45 | 46 | for (key, value) in &self.attributes { 47 | write!(f, " {key}")?; 48 | 49 | if let Some(value) = value { 50 | let encoded_value = html_escape::encode_double_quoted_attribute(value); 51 | write!(f, r#"="{encoded_value}""#)?; 52 | } 53 | } 54 | write!(f, ">")?; 55 | 56 | if let Some(children) = &self.children { 57 | write_children(f, children, false)?; 58 | 59 | write!(f, "", self.name)?; 60 | }; 61 | 62 | Ok(()) 63 | } 64 | } 65 | 66 | impl From for Element 67 | where 68 | N: Into, 69 | { 70 | /// Create an HTML element directly from a string. 71 | /// 72 | /// This [`Element`] has no attributes and no children. 73 | fn from(name: N) -> Self { 74 | Self { 75 | name: name.into(), 76 | attributes: Vec::new(), 77 | children: None, 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /html-node-core/src/node/fragment.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use super::write_children; 4 | use crate::Node; 5 | 6 | /// A fragment. 7 | /// 8 | /// ```html 9 | /// <> 10 | /// I'm in a fragment! 11 | /// 12 | /// ``` 13 | #[derive(Debug, Clone, PartialEq, Eq)] 14 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 15 | pub struct Fragment { 16 | /// The children of the fragment. 17 | /// 18 | /// ```html 19 | /// <> 20 | /// 21 | /// I'm another child! 22 | /// 23 | pub children: Vec, 24 | } 25 | 26 | impl Fragment { 27 | /// A fragment with no children. 28 | pub const EMPTY: Self = Self { 29 | children: Vec::new(), 30 | }; 31 | } 32 | 33 | impl Default for Fragment { 34 | fn default() -> Self { 35 | Self::EMPTY 36 | } 37 | } 38 | 39 | impl Display for Fragment { 40 | /// Format the fragment's childrent as HTML elements. 41 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 42 | write_children(f, &self.children, true) 43 | } 44 | } 45 | 46 | impl FromIterator for Fragment 47 | where 48 | N: Into, 49 | { 50 | /// Create a new fragment from an iterator of anything that 51 | /// can be converted into a [`crate::Node`]. 52 | fn from_iter(iter: I) -> Self 53 | where 54 | I: IntoIterator, 55 | { 56 | Self { 57 | children: iter.into_iter().map(Into::into).collect(), 58 | } 59 | } 60 | } 61 | 62 | impl From for Fragment 63 | where 64 | I: IntoIterator, 65 | N: Into, 66 | { 67 | /// Create a new fragment from any iterator of anything that 68 | /// can be converted into a [`crate::Node`]. 69 | fn from(iter: I) -> Self { 70 | Self::from_iter(iter) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /html-node-core/src/node/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | mod comment; 4 | mod doctype; 5 | mod element; 6 | mod fragment; 7 | mod text; 8 | mod unsafe_text; 9 | 10 | pub use self::{ 11 | comment::Comment, doctype::Doctype, element::Element, fragment::Fragment, text::Text, 12 | unsafe_text::UnsafeText, 13 | }; 14 | use crate::Node; 15 | 16 | /// Writes the children of a node. 17 | /// 18 | /// If the formatter is in alternate mode, then the children are put on their 19 | /// own lines. 20 | /// 21 | /// If alternate mode is enabled and `is_fragment` is false, then each line 22 | /// is indented by 4 spaces. 23 | fn write_children(f: &mut Formatter<'_>, children: &[Node], is_fragment: bool) -> fmt::Result { 24 | if f.alternate() { 25 | let mut children_iter = children.iter(); 26 | 27 | if is_fragment { 28 | if let Some(first_child) = children_iter.next() { 29 | write!(f, "{first_child:#}")?; 30 | 31 | for child in children_iter { 32 | write!(f, "\n{child:#}")?; 33 | } 34 | } 35 | } else { 36 | for child_str in children_iter.map(|child| format!("{child:#}")) { 37 | for line in child_str.lines() { 38 | write!(f, "\n {line}")?; 39 | } 40 | } 41 | 42 | // exit inner block 43 | writeln!(f)?; 44 | } 45 | } else { 46 | for child in children { 47 | child.fmt(f)?; 48 | } 49 | } 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /html-node-core/src/node/text.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | /// A text node. 4 | /// 5 | /// ```html 6 | ///
7 | /// I'm a text node! 8 | ///
9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 11 | pub struct Text { 12 | /// The text of the node. 13 | /// 14 | /// ```html 15 | ///
16 | /// text 17 | ///
18 | pub text: String, 19 | } 20 | 21 | impl Display for Text { 22 | /// Format as HTML encoded string. 23 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 24 | let encoded_value = html_escape::encode_text_minimal(&self.text); 25 | write!(f, "{encoded_value}") 26 | } 27 | } 28 | 29 | impl From for Text 30 | where 31 | T: Into, 32 | { 33 | /// Create a new text element from anything that can 34 | /// be converted into a string. 35 | fn from(text: T) -> Self { 36 | Self { text: text.into() } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /html-node-core/src/node/unsafe_text.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | /// An unsafe text node. 4 | /// 5 | /// # Warning 6 | /// 7 | /// [`UnsafeText`] is not escaped when rendered, and as such, can allow 8 | /// for XSS attacks. Use with caution! 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 11 | pub struct UnsafeText { 12 | /// The text of the node. 13 | pub text: String, 14 | } 15 | 16 | impl Display for UnsafeText { 17 | /// Unescaped text. 18 | /// 19 | /// This string is **not** HTML encoded! 20 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 21 | write!(f, "{}", self.text) 22 | } 23 | } 24 | 25 | impl From for UnsafeText 26 | where 27 | T: Into, 28 | { 29 | /// Create a new unsafe text element from anything 30 | /// that can be converted into a string. 31 | fn from(text: T) -> Self { 32 | Self { text: text.into() } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /html-node-core/src/pretty.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use crate::Node; 4 | 5 | /// A wrapper around [`Node`] that is always pretty printed. 6 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 7 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 8 | pub struct Pretty(pub Node); 9 | 10 | impl Pretty { 11 | /// Extract the inner node. 12 | #[must_use] 13 | pub fn into_inner(self) -> Node { 14 | self.0 15 | } 16 | 17 | /// Borrow the inner node. 18 | #[must_use] 19 | pub const fn as_inner(&self) -> &Node { 20 | &self.0 21 | } 22 | } 23 | 24 | impl Display for Pretty { 25 | /// Format as a pretty printed HTML node. 26 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 27 | write!(f, "{:#}", self.0) 28 | } 29 | } 30 | 31 | impl From for Pretty 32 | where 33 | N: Into, 34 | { 35 | /// Create a new pretty wrapper around the given node. 36 | fn from(node: N) -> Self { 37 | Self(node.into()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /html-node-core/src/typed/elements.rs: -------------------------------------------------------------------------------- 1 | //! Predefined HTML elements. 2 | 3 | use crate::typed_elements; 4 | 5 | typed_elements! { pub 6 | // Main root [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#main_root] 7 | html { 8 | xmlns, 9 | }; 10 | 11 | // Document metadata [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#document_metadata] 12 | base { 13 | href, 14 | target, 15 | }; 16 | head {}; 17 | link { 18 | r#as, 19 | crossorigin, 20 | disabled, 21 | fetchpriority, 22 | href, 23 | hreflang, 24 | imagesizes, 25 | imagesrcset, 26 | integrity, 27 | media, 28 | prefetch, 29 | referrerpolicy, 30 | rel, 31 | sizes, 32 | r#type, 33 | blocking, 34 | }; 35 | meta { 36 | charset, 37 | content, 38 | http_equiv, 39 | name, 40 | }; 41 | style { 42 | media, 43 | blocking, 44 | }; 45 | title {}; 46 | 47 | // Sectioning root [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#sectioning_root] 48 | body {}; 49 | 50 | // Content sectioning [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#content_sectioning] 51 | address {}; 52 | article {}; 53 | aside {}; 54 | footer {}; 55 | header {}; 56 | h1 {}; 57 | h2 {}; 58 | h3 {}; 59 | h4 {}; 60 | h5 {}; 61 | h6 {}; 62 | hgroup {}; 63 | main {}; 64 | nav {}; 65 | section {}; 66 | search {}; 67 | 68 | // Text content [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#text_content] 69 | blockquote { 70 | cite, 71 | }; 72 | dd {}; 73 | div {}; 74 | dl {}; 75 | dt {}; 76 | figcaption {}; 77 | figure {}; 78 | hr {}; 79 | li { 80 | value, 81 | }; 82 | menu {}; 83 | ol { 84 | reversed, 85 | start, 86 | r#type, 87 | }; 88 | p {}; 89 | pre {}; 90 | ul {}; 91 | 92 | // Inline text semantics [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#inline_text_semantics] 93 | a { 94 | download, 95 | href, 96 | hreflang, 97 | ping, 98 | referrerpolicy, 99 | rel, 100 | target, 101 | r#type, 102 | }; 103 | abbr {}; 104 | b {}; 105 | bdi {}; 106 | bdo {}; 107 | br {}; 108 | cite {}; 109 | code {}; 110 | data { 111 | value, 112 | }; 113 | dfn {}; 114 | em {}; 115 | i {}; 116 | kbd {}; 117 | mark {}; 118 | q { 119 | cite, 120 | }; 121 | rp {}; 122 | rt {}; 123 | ruby {}; 124 | s {}; 125 | samp {}; 126 | small {}; 127 | span {}; 128 | strong {}; 129 | sub {}; 130 | sup {}; 131 | time { 132 | datetime, 133 | }; 134 | u {}; 135 | var {}; 136 | wbr {}; 137 | 138 | // Image and multimedia [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#image_and_multimedia] 139 | area { 140 | alt, 141 | coords, 142 | download, 143 | href, 144 | ping, 145 | referrerpolicy, 146 | rel, 147 | shape, 148 | target, 149 | }; 150 | audio { 151 | autoplay, 152 | controls, 153 | controlslist, 154 | crossorigin, 155 | disableremoteplayback, 156 | r#loop, 157 | muted, 158 | preload, 159 | src, 160 | }; 161 | img { 162 | alt, 163 | crossorigin, 164 | decoding, 165 | elementtiming, 166 | fetchpriority, 167 | height, 168 | ismap, 169 | loading, 170 | referrerpolicy, 171 | sizes, 172 | src, 173 | srcset, 174 | width, 175 | usemap, 176 | }; 177 | map { 178 | name, 179 | }; 180 | track { 181 | default, 182 | kind, 183 | label, 184 | src, 185 | srclang, 186 | }; 187 | video { 188 | autoplay, 189 | controls, 190 | controlslist, 191 | crossorigin, 192 | disablepictureinpicture, 193 | disableremoteplayback, 194 | height, 195 | r#loop, 196 | muted, 197 | playsinline, 198 | poster, 199 | preload, 200 | src, 201 | width, 202 | }; 203 | 204 | // Embedded content [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#embedded_content] 205 | embed { 206 | height, 207 | src, 208 | r#type, 209 | width, 210 | }; 211 | iframe { 212 | allow, 213 | allowfullscreen, 214 | allowpaymentrequest, 215 | credentialless, 216 | csp, 217 | height, 218 | loading, 219 | name, 220 | referrerpolicy, 221 | sandbox, 222 | src, 223 | srcdoc, 224 | width, 225 | }; 226 | object { 227 | data, 228 | form, 229 | height, 230 | name, 231 | r#type, 232 | usemap, 233 | width, 234 | }; 235 | picture {}; 236 | portal { 237 | referrerpolicy, 238 | src, 239 | }; 240 | source { 241 | r#type, 242 | src, 243 | srcset, 244 | sizes, 245 | media, 246 | height, 247 | width, 248 | }; 249 | 250 | // SVG and MathML [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#svg_and_mathml] 251 | svg { 252 | height, 253 | preserveaspectratio, 254 | viewBox, 255 | width, 256 | x, 257 | y, 258 | }; 259 | math { 260 | display, 261 | }; 262 | 263 | // Scripting [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#scripting] 264 | canvas { 265 | height, 266 | width, 267 | }; 268 | noscript {}; 269 | script { 270 | r#async, 271 | crossorigin, 272 | defer, 273 | fetchpriority, 274 | integrity, 275 | nomodule, 276 | referrerpolicy, 277 | src, 278 | r#type, 279 | blocking, 280 | }; 281 | 282 | // Demarcating edits [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#demarcating_edits] 283 | del { 284 | cite, 285 | datetime, 286 | }; 287 | ins { 288 | cite, 289 | datetime, 290 | }; 291 | 292 | // Table content [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#table_content] 293 | caption {}; 294 | col { 295 | span, 296 | }; 297 | colgroup { 298 | span, 299 | }; 300 | table {}; 301 | tbody {}; 302 | td { 303 | colspan, 304 | headers, 305 | rowspan, 306 | }; 307 | tfoot {}; 308 | th { 309 | abbr, 310 | colspan, 311 | headers, 312 | rowspan, 313 | scope, 314 | }; 315 | thead {}; 316 | tr {}; 317 | 318 | // Forms [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#forms] 319 | button { 320 | disabled, 321 | form, 322 | formaction, 323 | formenctype, 324 | formmethod, 325 | formnovalidate, 326 | formtarget, 327 | name, 328 | popovertarget, 329 | popovertargetaction, 330 | r#type, 331 | value, 332 | }; 333 | datalist {}; 334 | fieldset { 335 | disabled, 336 | form, 337 | name, 338 | }; 339 | form { 340 | acceptcharset, 341 | autocomplete, 342 | name, 343 | rel, 344 | action, 345 | enctype, 346 | method, 347 | novalidate, 348 | target, 349 | }; 350 | input { 351 | accept, 352 | alt, 353 | autocomplete, 354 | capture, 355 | checked, 356 | dirname, 357 | disabled, 358 | form, 359 | formaction, 360 | formenctype, 361 | formmethod, 362 | formnovalidate, 363 | formtarget, 364 | height, 365 | list, 366 | max, 367 | maxlength, 368 | min, 369 | minlength, 370 | multiple, 371 | name, 372 | pattern, 373 | placeholder, 374 | popovertarget, 375 | popovertargetaction, 376 | readonly, 377 | required, 378 | size, 379 | src, 380 | step, 381 | r#type, 382 | value, 383 | width, 384 | autocorrect, 385 | incremental, 386 | mozactionhint, 387 | orient, 388 | results, 389 | webkitdirectory, 390 | }; 391 | label { 392 | r#for, 393 | }; 394 | legend {}; 395 | meter { 396 | min, 397 | max, 398 | low, 399 | high, 400 | optimum, 401 | }; 402 | optgroup { 403 | disabled, 404 | label, 405 | }; 406 | option { 407 | disabled, 408 | label, 409 | selected, 410 | value, 411 | }; 412 | output { 413 | r#for, 414 | form, 415 | name, 416 | }; 417 | progress { 418 | max, 419 | value, 420 | }; 421 | select { 422 | autocomplete, 423 | disabled, 424 | form, 425 | multiple, 426 | name, 427 | required, 428 | size, 429 | }; 430 | textarea { 431 | autocomplete, 432 | autocorrect, 433 | cols, 434 | dirname, 435 | disabled, 436 | form, 437 | maxlength, 438 | minlength, 439 | name, 440 | placeholder, 441 | readonly, 442 | required, 443 | rows, 444 | wrap, 445 | }; 446 | 447 | // Interactive elements [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#interactive_elements] 448 | details { 449 | open, 450 | }; 451 | dialog { 452 | open, 453 | }; 454 | 455 | // Web Components [https://developer.mozilla.org/en-US/docs/Web/HTML/Element#web_components] 456 | slot { 457 | name, 458 | }; 459 | template { 460 | shadowrootmode, 461 | }; 462 | } 463 | -------------------------------------------------------------------------------- /html-node-core/src/typed/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions)] 2 | #![allow(non_snake_case)] 3 | 4 | pub mod elements; 5 | #[doc(hidden)] 6 | pub use paste::paste; 7 | 8 | use crate::Node; 9 | 10 | /// A typed HTML element. 11 | pub trait TypedElement { 12 | /// The attributes of the element. 13 | type Attributes; 14 | 15 | /// Create an element from its attributes. 16 | fn from_attributes( 17 | attributes: Self::Attributes, 18 | other_attributes: Vec<(String, Option)>, 19 | ) -> Self; 20 | 21 | /// Convert the typed element into a [`Node`]. 22 | fn into_node(self, children: Option>) -> Node; 23 | } 24 | 25 | /// A typed set of HTML attributes. 26 | pub trait TypedAttributes { 27 | /// Convert the typed attributes into a set of attributes. 28 | fn into_attributes(self) -> Vec<(String, Option)>; 29 | } 30 | 31 | /// A typed attribute. 32 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] 33 | pub enum Attribute { 34 | /// The attribute is present and has a value. 35 | /// 36 | /// ```html 37 | ///
38 | /// ``` 39 | Present(T), 40 | 41 | /// The attribute is present but has no value. 42 | /// 43 | /// ```html 44 | /// 45 | /// ``` 46 | Empty, 47 | 48 | /// The attribute is not present. 49 | #[default] 50 | Missing, 51 | } 52 | 53 | impl Attribute { 54 | /// Convert the attribute into a double layered [`Option`]. 55 | pub fn into_option(self) -> Option> { 56 | match self { 57 | Self::Present(value) => Some(Some(value)), 58 | Self::Empty => Some(None), 59 | Self::Missing => None, 60 | } 61 | } 62 | } 63 | 64 | #[allow(missing_docs)] 65 | #[macro_export] 66 | macro_rules! typed_elements { 67 | ($vis:vis $($ElementName:ident $(($name:literal))? $([$AttributeName:ident])? $({ $($attribute:ident),* $(,)? })?;)*) => { 68 | $( 69 | $crate::typed_element!{ 70 | $vis $ElementName $(($name))? $([$AttributeName])? $({ $($attribute),* })? 71 | } 72 | )* 73 | }; 74 | } 75 | 76 | #[allow(missing_docs)] 77 | #[macro_export] 78 | macro_rules! typed_element { 79 | ($vis:vis $ElementName:ident $(($name:literal))? $([$AttributeName:ident])? $({ $($attribute:ident $(: $atype:ty)?),* $(,)? })?) => { 80 | $crate::typed_attributes!{ 81 | ($vis $ElementName) $([$vis $AttributeName])? $({ 82 | accesskey, 83 | autocapitalize, 84 | autofocus, 85 | class, 86 | contenteditable, 87 | dir, 88 | draggable, 89 | enterkeyhint, 90 | exportparts, 91 | hidden, 92 | id, 93 | inert, 94 | inputmode, 95 | is, 96 | itemid, 97 | itemprop, 98 | itemref, 99 | itemscope, 100 | itemtype, 101 | lang, 102 | nonce, 103 | part, 104 | popover, 105 | role, 106 | slot, 107 | spellcheck, 108 | style, 109 | tabindex, 110 | title, 111 | translate, 112 | virtualkeyboardpolicy, 113 | $($attribute $(: $atype)?),* 114 | })? 115 | } 116 | 117 | #[derive(::std::fmt::Debug, ::std::default::Default)] 118 | #[allow(non_camel_case_types)] 119 | #[allow(missing_docs)] 120 | $vis struct $ElementName { 121 | $vis attributes: ::Attributes, 122 | $vis other_attributes: ::std::vec::Vec<(::std::string::String, ::std::option::Option<::std::string::String>)>, 123 | } 124 | 125 | impl $crate::typed::TypedElement for $ElementName { 126 | type Attributes = $crate::typed_attributes!(@NAME ($ElementName) $([$AttributeName])?); 127 | 128 | fn from_attributes( 129 | attributes: Self::Attributes, 130 | other_attributes: ::std::vec::Vec<(::std::string::String, ::std::option::Option<::std::string::String>)>, 131 | ) -> Self { 132 | Self { attributes, other_attributes } 133 | } 134 | 135 | fn into_node(mut self, children: ::std::option::Option<::std::vec::Vec<$crate::Node>>) -> $crate::Node { 136 | let mut attributes = $crate::typed::TypedAttributes::into_attributes(self.attributes); 137 | attributes.append(&mut self.other_attributes); 138 | 139 | $crate::Node::Element( 140 | $crate::Element { 141 | name: ::std::convert::From::from($crate::typed_element!(@NAME_STR $ElementName$(($name))?)), 142 | attributes, 143 | children, 144 | } 145 | ) 146 | } 147 | } 148 | }; 149 | (@NAME_STR $ElementName:ident) => { 150 | stringify!($ElementName) 151 | }; 152 | (@NAME_STR $ElementName:ident($name:literal)) => { 153 | $name 154 | }; 155 | } 156 | 157 | #[allow(missing_docs)] 158 | #[macro_export] 159 | macro_rules! typed_attributes { 160 | { 161 | $(($vise:vis $ElementName:ident))? $([$visa:vis $AttributeName:ident])? { 162 | $($attribute:ident $(: $atype:ty)?),* $(,)? 163 | } 164 | } => { 165 | $crate::typed_attributes!(@STRUCT $(($vise $ElementName))? $([$visa $AttributeName])? { $($attribute $(: $atype)?),* }); 166 | 167 | impl $crate::typed::TypedAttributes for $crate::typed_attributes!(@NAME $(($ElementName))? $([$AttributeName])?) { 168 | fn into_attributes(self) -> ::std::vec::Vec<(::std::string::String, ::std::option::Option<::std::string::String>)> { 169 | [$((::std::stringify!($attribute), self.$attribute.into_option().map(|opt| opt.map(|a| ::std::string::ToString::to_string(&a))))),*] 170 | .into_iter() 171 | .flat_map(|(key, maybe_value)| { 172 | maybe_value.map(|value| (key.strip_prefix("r#").unwrap_or(key).replace('_', "-"), value)) 173 | }) 174 | .collect() 175 | } 176 | } 177 | }; 178 | (($_vise:vis $_ElementName:ident) $([$_visa:vis $_AttributeName:ident])?) => {}; 179 | (@NAME ($ElementName:ident)) => { 180 | $crate::typed::paste!([< $ElementName:camel Attributes >]) 181 | }; 182 | (@NAME $(($ElementName:ident))? [$AttributeName:ident]) => { 183 | $AttributeName 184 | }; 185 | { 186 | @STRUCT ($vis:vis $ElementName:ident) { 187 | $($attribute:ident $(:$atype:ty)?),* $(,)? 188 | } 189 | } => { 190 | $crate::typed::paste! { 191 | #[derive(::std::fmt::Debug, ::std::default::Default)] 192 | #[allow(missing_docs)] 193 | $vis struct [< $ElementName:camel Attributes >] { 194 | $($vis $attribute: $crate::typed::Attribute<$crate::typed_attributes!(@ATTR_TYPE $($atype)?)>),* 195 | } 196 | } 197 | }; 198 | { 199 | @STRUCT $(($_vis:vis $ElementName:ident))? [$vis:vis $AttributeName:ident] { 200 | $($attribute:ident $(: $atype:ty)?),* $(,)? 201 | } 202 | } => { 203 | #[derive(::std::fmt::Debug, ::std::default::Default)] 204 | #[allow(missing_docs)] 205 | $vis struct $AttributeName { 206 | $($vis $attribute: $crate::typed::Attribute<$crate::typed_attributes!(@ATTR_TYPE $($atype)?)>),* 207 | } 208 | }; 209 | (@ATTR_TYPE $atype:ty) => {$atype}; 210 | (@ATTR_TYPE) => {::std::string::String}; 211 | } 212 | 213 | #[allow(missing_docs)] 214 | #[macro_export] 215 | macro_rules! typed_component_attributes { 216 | { 217 | $(($vise:vis $ElementName:ident))? $([$visa:vis $AttributeName:ident])? { 218 | $($attribute:ident: $atype:ty),* $(,)? 219 | } 220 | } => { 221 | $crate::typed_component_attributes!(@STRUCT $(($vise $ElementName))? $([$visa $AttributeName])? { $($attribute: $atype),* }); 222 | }; 223 | (($_vise:vis $_ElementName:ident) $([$_visa:vis $_AttributeName:ident])?) => {}; 224 | { 225 | @STRUCT ($vis:vis $ElementName:ident) { 226 | $($attribute:ident: $atype:ty),* $(,)? 227 | } 228 | } => { 229 | $crate::typed::paste! { 230 | #[derive(::std::fmt::Debug)] 231 | #[allow(missing_docs)] 232 | $vis struct [< $ElementName:camel Attributes >] { 233 | $($vis $attribute: $atype),* 234 | } 235 | } 236 | }; 237 | { 238 | @STRUCT $(($_vis:vis $ElementName:ident))? [$vis:vis $AttributeName:ident] { 239 | $($attribute:ident: $atype:ty),* $(,)? 240 | } 241 | } => { 242 | #[derive(::std::fmt::Debug)] 243 | #[allow(missing_docs)] 244 | $vis struct $AttributeName { 245 | $($vis $attribute: $atype),* 246 | } 247 | }; 248 | } 249 | 250 | #[allow(missing_docs)] 251 | #[macro_export] 252 | macro_rules! typed_component { 253 | ( 254 | $vis:vis $ElementName:ident $([$AttributeName:ident])? $({ 255 | $($attribute:ident $(: $atype:ty)?),* $(,)? 256 | })?; 257 | 258 | |$attributes:pat_param, $extra_attributes:pat_param, $children:pat_param| $body:expr 259 | ) => { 260 | $crate::typed_component_attributes!{ 261 | ($vis $ElementName) $([$vis $AttributeName])? $({ 262 | $($attribute $(: $atype)?),* 263 | })? 264 | } 265 | 266 | #[derive(::std::fmt::Debug)] 267 | #[allow(non_camel_case_types)] 268 | #[allow(missing_docs)] 269 | $vis struct $ElementName { 270 | $vis attributes: ::Attributes, 271 | $vis extra_attributes: ::std::vec::Vec<(::std::string::String, ::std::option::Option<::std::string::String>)>, 272 | } 273 | 274 | impl $crate::typed::TypedElement for $ElementName { 275 | type Attributes = $crate::typed_attributes!(@NAME ($ElementName) $([$AttributeName])?); 276 | 277 | fn from_attributes( 278 | attributes: Self::Attributes, 279 | extra_attributes: ::std::vec::Vec<(::std::string::String, ::std::option::Option<::std::string::String>)>, 280 | ) -> Self { 281 | Self { attributes, extra_attributes } 282 | } 283 | 284 | fn into_node(self, $children: ::std::option::Option<::std::vec::Vec<$crate::Node>>) -> $crate::Node { 285 | let $attributes = self.attributes; 286 | let $extra_attributes = self.extra_attributes; 287 | 288 | { 289 | $body 290 | } 291 | } 292 | } 293 | }; 294 | } 295 | -------------------------------------------------------------------------------- /html-node-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "html-node-macro" 3 | authors.workspace = true 4 | categories.workspace = true 5 | description.workspace = true 6 | edition.workspace = true 7 | homepage.workspace = true 8 | keywords.workspace = true 9 | license.workspace = true 10 | readme.workspace = true 11 | repository.workspace = true 12 | version.workspace = true 13 | 14 | [package.metadata.docs.rs] 15 | all-features = true 16 | rustdoc-args = ["--cfg", "docsrs"] 17 | 18 | [lib] 19 | proc-macro = true 20 | 21 | [dependencies] 22 | proc-macro2 = "1" 23 | proc-macro2-diagnostics = { version = "0.10", default-features = false } 24 | quote = "1" 25 | rstml = { version = "0.11", default-features = false } 26 | syn = "2" 27 | syn_derive = { version = "0.1", optional = true } 28 | 29 | [features] 30 | typed = ["dep:syn_derive"] 31 | -------------------------------------------------------------------------------- /html-node-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 2 | 3 | mod node_handlers; 4 | 5 | use std::collections::{HashMap, HashSet}; 6 | 7 | use node_handlers::{ 8 | handle_block, handle_comment, handle_doctype, handle_element, handle_fragment, handle_raw_text, 9 | handle_text, 10 | }; 11 | use proc_macro::TokenStream; 12 | use proc_macro2::{Ident, TokenStream as TokenStream2}; 13 | use proc_macro2_diagnostics::Diagnostic; 14 | use quote::quote; 15 | use rstml::{node::Node, Parser, ParserConfig}; 16 | use syn::Type; 17 | 18 | #[proc_macro] 19 | pub fn html(tokens: TokenStream) -> TokenStream { 20 | html_inner(tokens.into(), None) 21 | } 22 | 23 | #[cfg(feature = "typed")] 24 | #[proc_macro] 25 | pub fn typed_html(tokens: TokenStream) -> TokenStream { 26 | use syn::{punctuated::Punctuated, token::Paren, Token}; 27 | 28 | #[derive(syn_derive::Parse)] 29 | struct ColonAndType { 30 | _colon_token: syn::Token![:], 31 | ty: Type, 32 | } 33 | 34 | #[derive(syn_derive::Parse)] 35 | enum MaybeColonAndType { 36 | #[parse(peek = Token![:])] 37 | ColonAndType(ColonAndType), 38 | Nothing, 39 | } 40 | 41 | #[derive(syn_derive::Parse)] 42 | struct Extension { 43 | prefix: Ident, 44 | colon_and_type: MaybeColonAndType, 45 | } 46 | 47 | #[derive(syn_derive::Parse)] 48 | struct Extensions { 49 | #[syn(parenthesized)] 50 | #[allow(dead_code)] 51 | paren_token: Paren, 52 | 53 | #[syn(in = paren_token)] 54 | #[parse(Punctuated::parse_terminated)] 55 | extensions: Punctuated, 56 | } 57 | 58 | #[derive(syn_derive::Parse)] 59 | enum MaybeExtensions { 60 | #[parse(peek = Paren)] 61 | Extensions(Extensions), 62 | Nothing, 63 | } 64 | 65 | #[derive(syn_derive::Parse)] 66 | struct TypedHtmlOptions { 67 | extensions: MaybeExtensions, 68 | tokens: TokenStream2, 69 | } 70 | 71 | let options = syn::parse_macro_input!(tokens as TypedHtmlOptions); 72 | 73 | let mut extensions = match options.extensions { 74 | MaybeExtensions::Extensions(extensions) => extensions 75 | .extensions 76 | .into_iter() 77 | .map(|extension| match extension.colon_and_type { 78 | MaybeColonAndType::ColonAndType(ColonAndType { ty, .. }) => { 79 | (extension.prefix, Some(ty)) 80 | } 81 | MaybeColonAndType::Nothing => (extension.prefix, None), 82 | }) 83 | .collect::>(), 84 | MaybeExtensions::Nothing => HashMap::new(), 85 | }; 86 | 87 | extensions.insert(Ident::new("data", proc_macro2::Span::call_site()), None); 88 | extensions.insert(Ident::new("aria", proc_macro2::Span::call_site()), None); 89 | 90 | html_inner(options.tokens, Some(&extensions)) 91 | } 92 | 93 | fn html_inner( 94 | tokens: TokenStream2, 95 | extensions: Option<&HashMap>>, 96 | ) -> TokenStream { 97 | // from: https://html.spec.whatwg.org/dev/syntax.html#void-elements 98 | let void_elements = [ 99 | "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", 100 | "track", "wbr", 101 | ] 102 | .into_iter() 103 | .collect::>(); 104 | 105 | // from: https://html.spec.whatwg.org/dev/syntax.html#raw-text-elements 106 | let raw_text_elements = ["script", "style"].into_iter().collect(); 107 | 108 | let config = ParserConfig::new() 109 | .recover_block(true) 110 | .always_self_closed_elements(void_elements.clone()) 111 | .raw_text_elements(raw_text_elements); 112 | 113 | let parser = Parser::new(config); 114 | let (parsed_nodes, parsing_diagnostics) = parser.parse_recoverable(tokens).split_vec(); 115 | let (tokenized_nodes, tokenization_diagnostics) = 116 | tokenize_nodes(&void_elements, extensions, &parsed_nodes); 117 | 118 | let node = match &*tokenized_nodes { 119 | [node] => quote!(#node), 120 | nodes => { 121 | quote! { 122 | ::html_node::Node::Fragment( 123 | ::html_node::Fragment { 124 | children: ::std::vec![#(#nodes),*], 125 | } 126 | ) 127 | } 128 | } 129 | }; 130 | 131 | let errors = parsing_diagnostics 132 | .into_iter() 133 | .chain(tokenization_diagnostics) 134 | .map(Diagnostic::emit_as_expr_tokens); 135 | 136 | quote! { 137 | { 138 | #(#errors;)* 139 | #node 140 | } 141 | } 142 | .into() 143 | } 144 | 145 | fn tokenize_nodes( 146 | void_elements: &HashSet<&str>, 147 | extensions: Option<&HashMap>>, 148 | nodes: &[Node], 149 | ) -> (Vec, Vec) { 150 | let (token_streams, diagnostics) = nodes 151 | .iter() 152 | .map(|node| match node { 153 | Node::Comment(comment) => (handle_comment(comment), vec![]), 154 | Node::Doctype(doctype) => (handle_doctype(doctype), vec![]), 155 | Node::Fragment(fragment) => handle_fragment(void_elements, extensions, fragment), 156 | Node::Element(element) => handle_element(void_elements, extensions, element), 157 | Node::Block(block) => (handle_block(block), vec![]), 158 | Node::Text(text) => (handle_text(text), vec![]), 159 | Node::RawText(text) => (handle_raw_text(text), vec![]), 160 | }) 161 | .unzip::<_, _, Vec<_>, Vec<_>>(); 162 | 163 | let diagnostics = diagnostics.into_iter().flatten().collect(); 164 | 165 | (token_streams, diagnostics) 166 | } 167 | -------------------------------------------------------------------------------- /html-node-macro/src/node_handlers/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "typed")] 2 | mod typed; 3 | 4 | use std::collections::{HashMap, HashSet}; 5 | 6 | use proc_macro2::{Ident, Literal, TokenStream as TokenStream2}; 7 | use proc_macro2_diagnostics::{Diagnostic, SpanDiagnosticExt}; 8 | use quote::{quote, ToTokens}; 9 | use rstml::node::{ 10 | KeyedAttribute, NodeAttribute, NodeBlock, NodeComment, NodeDoctype, NodeElement, NodeFragment, 11 | NodeName, NodeText, RawText, 12 | }; 13 | use syn::{spanned::Spanned, Expr, ExprCast, Type}; 14 | 15 | use crate::tokenize_nodes; 16 | 17 | pub fn handle_comment(comment: &NodeComment) -> TokenStream2 { 18 | let comment = &comment.value; 19 | 20 | quote! { 21 | ::html_node::Node::Comment( 22 | ::html_node::Comment { 23 | comment: ::std::convert::Into::<::std::string::String>::into(#comment), 24 | } 25 | ) 26 | } 27 | } 28 | 29 | pub fn handle_doctype(doctype: &NodeDoctype) -> TokenStream2 { 30 | let syntax = &doctype.value.to_token_stream_string(); 31 | 32 | quote! { 33 | ::html_node::Node::Doctype( 34 | ::html_node::Doctype { 35 | syntax: ::std::convert::Into::<::std::string::String>::into(#syntax), 36 | } 37 | ) 38 | } 39 | } 40 | 41 | pub fn handle_fragment( 42 | void_elements: &HashSet<&str>, 43 | extensions: Option<&HashMap>>, 44 | fragment: &NodeFragment, 45 | ) -> (TokenStream2, Vec) { 46 | let (inner_nodes, inner_diagnostics) = 47 | tokenize_nodes(void_elements, extensions, &fragment.children); 48 | 49 | let children = quote!(::std::vec![#(#inner_nodes),*]); 50 | 51 | ( 52 | quote! { 53 | ::html_node::Node::Fragment( 54 | ::html_node::Fragment { 55 | children: #children, 56 | } 57 | ) 58 | }, 59 | inner_diagnostics, 60 | ) 61 | } 62 | 63 | pub fn handle_element( 64 | void_elements: &HashSet<&str>, 65 | extensions: Option<&HashMap>>, 66 | element: &NodeElement, 67 | ) -> (TokenStream2, Vec) { 68 | extensions.map_or_else( 69 | || handle_element_untyped(void_elements, element), 70 | |extensions| typed::handle_element(void_elements, extensions, element), 71 | ) 72 | } 73 | 74 | pub fn handle_element_untyped( 75 | void_elements: &HashSet<&str>, 76 | element: &NodeElement, 77 | ) -> (TokenStream2, Vec) { 78 | handle_element_inner( 79 | |block| { 80 | let attribute_tokens = quote! { 81 | ( 82 | ::std::convert::Into::<::std::string::String>::into( 83 | #[allow(unused_braces)] 84 | #block, 85 | ), 86 | ::std::option::Option::None, 87 | ) 88 | }; 89 | 90 | (attribute_tokens, None) 91 | }, 92 | |attribute| { 93 | let key = node_name_to_literal(&attribute.key); 94 | 95 | let key = quote!(::std::convert::Into::<::std::string::String>::into(#key)); 96 | 97 | let value = attribute.value().map(|value| match value { 98 | Expr::Cast(ExprCast { expr, ty, .. }) => (&**expr, Some(ty)), 99 | _ => (value, None), 100 | }); 101 | 102 | let attribute_tokens = value.map_or_else( 103 | || quote!((#key, ::std::option::Option::None)), 104 | |(value, ty)| ty.map_or_else( 105 | || quote!{ 106 | ( 107 | #key, 108 | ::std::option::Option::Some(::std::string::ToString::to_string(&#value)), 109 | ) 110 | }, 111 | |ty| quote!{ 112 | ( 113 | #key, 114 | ::std::option::Option::<#ty>::from(#value).map(|v| ::std::string::ToString::to_string(&v)), 115 | ) 116 | }, 117 | ), 118 | ); 119 | 120 | (attribute_tokens, None) 121 | }, 122 | |element, attributes, children| { 123 | let name = node_name_to_literal(element.name()); 124 | 125 | quote! { 126 | ::html_node::Node::Element( 127 | ::html_node::Element { 128 | name: ::std::convert::Into::<::std::string::String>::into(#name), 129 | attributes: ::std::vec![#(#attributes),*], 130 | children: #children, 131 | } 132 | ) 133 | } 134 | }, 135 | void_elements, 136 | None, 137 | element, 138 | ) 139 | } 140 | 141 | fn handle_element_inner( 142 | handle_block: impl Fn(&NodeBlock) -> (T, Option), 143 | handle_keyed: impl Fn(&KeyedAttribute) -> (T, Option), 144 | to_element: impl Fn(&NodeElement, Vec, TokenStream2) -> TokenStream2, 145 | void_elements: &HashSet<&str>, 146 | extensions: Option<&HashMap>>, 147 | element: &NodeElement, 148 | ) -> (TokenStream2, Vec) { 149 | let (attributes, attribute_diagnostics) = element 150 | .attributes() 151 | .iter() 152 | .map(|attribute| match attribute { 153 | NodeAttribute::Block(block) => handle_block(block), 154 | NodeAttribute::Attribute(attribute) => handle_keyed(attribute), 155 | }) 156 | .unzip::<_, _, Vec<_>, Vec<_>>(); 157 | 158 | let is_void_element = void_elements.contains(element.open_tag.name.to_string().as_str()); 159 | 160 | let (children, void_diagnostics) = if is_void_element { 161 | let diagnostic = if element.children.is_empty() { 162 | vec![] 163 | } else { 164 | vec![element 165 | .span() 166 | .warning("void elements' children will be ignored")] 167 | }; 168 | 169 | (quote!(::std::option::Option::None), diagnostic) 170 | } else { 171 | let (inner_nodes, inner_diagnostics) = 172 | tokenize_nodes(void_elements, extensions, &element.children); 173 | 174 | ( 175 | quote!(::std::option::Option::Some(::std::vec![#(#inner_nodes),*])), 176 | inner_diagnostics, 177 | ) 178 | }; 179 | 180 | let element = to_element(element, attributes, children); 181 | 182 | let diagnostics = attribute_diagnostics 183 | .into_iter() 184 | .flatten() 185 | .chain(void_diagnostics) 186 | .collect::>(); 187 | 188 | (element, diagnostics) 189 | } 190 | 191 | pub fn handle_block(block: &NodeBlock) -> TokenStream2 { 192 | quote! { 193 | ::std::convert::Into::<::html_node::Node>::into(#[allow(unused_braces)] #block) 194 | } 195 | } 196 | 197 | pub fn handle_text(text: &NodeText) -> TokenStream2 { 198 | let text = &text.value; 199 | 200 | quote! { 201 | ::html_node::Node::Text( 202 | ::html_node::Text { 203 | text: ::std::convert::Into::<::std::string::String>::into(#text), 204 | } 205 | ) 206 | } 207 | } 208 | 209 | pub fn handle_raw_text(raw_text: &RawText) -> TokenStream2 { 210 | let tokens = raw_text.to_string_best(); 211 | let mut text = Literal::string(&tokens); 212 | text.set_span(raw_text.span()); 213 | 214 | quote! { 215 | ::html_node::Node::Text( 216 | ::html_node::Text { 217 | text: ::std::convert::Into::<::std::string::String>::into(#text), 218 | } 219 | ) 220 | } 221 | } 222 | 223 | fn node_name_to_literal(node_name: &NodeName) -> TokenStream2 { 224 | match node_name { 225 | NodeName::Block(block) => quote!(#[allow(unused_braces)] #block), 226 | other_node_name => { 227 | let mut literal = Literal::string(&other_node_name.to_string()); 228 | literal.set_span(other_node_name.span()); 229 | literal.to_token_stream() 230 | } 231 | } 232 | } 233 | 234 | #[cfg(not(feature = "typed"))] 235 | mod typed { 236 | use std::collections::{HashMap, HashSet}; 237 | 238 | use rstml::node::NodeElement; 239 | use syn::{Ident, Type}; 240 | 241 | pub fn handle_element( 242 | _void_elements: &HashSet<&str>, 243 | _extensions: &HashMap>, 244 | _element: &NodeElement, 245 | ) -> ! { 246 | unreachable!("`typed::handle_element` should be unreachable without the `typed` feature") 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /html-node-macro/src/node_handlers/typed.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use proc_macro2::{Ident, Punct, Span, TokenStream as TokenStream2}; 4 | use proc_macro2_diagnostics::{Diagnostic, SpanDiagnosticExt}; 5 | use quote::{quote, ToTokens}; 6 | use rstml::node::{KeyedAttribute, NodeElement, NodeName, NodeNameFragment}; 7 | use syn::{ 8 | punctuated::{Pair, Punctuated}, 9 | spanned::Spanned, 10 | ExprPath, Type, 11 | }; 12 | 13 | use super::{handle_element_inner, node_name_to_literal}; 14 | 15 | enum AttrType { 16 | Component, 17 | TypeChecked { 18 | key: TokenStream2, 19 | value: Option, 20 | }, 21 | Extension { 22 | ty: Option, 23 | key: TokenStream2, 24 | value: TokenStream2, 25 | }, 26 | } 27 | 28 | #[allow(clippy::too_many_lines)] 29 | pub fn handle_element( 30 | void_elements: &HashSet<&str>, 31 | extensions: &HashMap>, 32 | element: &NodeElement, 33 | ) -> (TokenStream2, Vec) { 34 | handle_element_inner( 35 | |block| { 36 | let diagnostic = block 37 | .span() 38 | .error("typed elements don't support block attributes"); 39 | 40 | ( 41 | AttrType::TypeChecked { 42 | key: TokenStream2::new(), 43 | value: None, 44 | }, 45 | Some(diagnostic), 46 | ) 47 | }, 48 | |attr| handle_attribute(attr, extensions), 49 | |element, attributes, children| { 50 | let name = element.name(); 51 | 52 | let ( 53 | component, 54 | (type_checked_keys, type_checked_values), 55 | (other_keys, other_values), 56 | extensions, 57 | ) = attributes.into_iter().fold( 58 | ( 59 | false, 60 | (Vec::new(), Vec::new()), 61 | (Vec::new(), Vec::new()), 62 | HashMap::<_, (Vec<_>, Vec<_>)>::new(), 63 | ), 64 | |(mut component, mut type_checked, mut other, mut extension), attribute| { 65 | match attribute { 66 | AttrType::Component => component = true, 67 | AttrType::TypeChecked { key, value } => { 68 | type_checked.0.push(key); 69 | type_checked.1.push(value); 70 | } 71 | AttrType::Extension { 72 | ty: type_, 73 | key, 74 | value, 75 | } => { 76 | if let Some(type_) = type_ { 77 | let extensions_vecs = extension.entry(type_).or_default(); 78 | 79 | extensions_vecs.0.push(key); 80 | extensions_vecs.1.push(value); 81 | } else { 82 | other.0.push(key); 83 | other.1.push(value); 84 | } 85 | } 86 | } 87 | 88 | (component, type_checked, other, extension) 89 | }, 90 | ); 91 | 92 | let extensions = extensions.into_iter().map(|(type_, (keys, values))| { 93 | quote! { 94 | ::html_node::typed::TypedAttributes::into_attributes( 95 | #[allow(clippy::needless_update, clippy::unnecessary_struct_initialization)] 96 | #type_ { 97 | #(#keys: #values,)* 98 | ..::std::default::Default::default() 99 | } 100 | ) 101 | } 102 | }); 103 | 104 | let extensions = quote! { 105 | { 106 | let mut v = ::std::vec::Vec::new(); 107 | #( 108 | v.append(&mut #extensions); 109 | )* 110 | v.append(&mut ::std::vec![#((#other_keys, #other_values),)*]); 111 | 112 | v 113 | } 114 | }; 115 | 116 | let default = if component { 117 | TokenStream2::new() 118 | } else { 119 | quote! { 120 | ..::std::default::Default::default() 121 | } 122 | }; 123 | 124 | let type_checked_values = if component { 125 | Box::new(type_checked_values.into_iter().map(|value| { 126 | quote! { 127 | #value 128 | } 129 | })) as Box> 130 | } else { 131 | Box::new(type_checked_values.into_iter().map(|value| { 132 | quote! { 133 | ::html_node::typed::Attribute::Present( 134 | #value 135 | ) 136 | } 137 | })) as Box> 138 | }; 139 | 140 | quote! { 141 | { 142 | type ElementAttributes = <#name as ::html_node::typed::TypedElement>::Attributes; 143 | <#name as ::html_node::typed::TypedElement>::into_node( 144 | <#name as ::html_node::typed::TypedElement>::from_attributes( 145 | #[allow(clippy::needless_update, clippy::unnecessary_struct_initialization)] 146 | ElementAttributes { 147 | #(#type_checked_keys: #type_checked_values,)* 148 | #default 149 | }, 150 | #extensions, 151 | ), 152 | #children 153 | ) 154 | } 155 | } 156 | }, 157 | void_elements, 158 | Some(extensions), 159 | element, 160 | ) 161 | } 162 | 163 | fn handle_attribute( 164 | attribute: &KeyedAttribute, 165 | extensions: &HashMap>, 166 | ) -> (AttrType, Option) { 167 | let attr = match &attribute.key { 168 | NodeName::Block(block) => Err(block 169 | .span() 170 | .error("block attribute keys are not supported for typed elements")), 171 | NodeName::Path(path) => handle_path_attribute(path), 172 | NodeName::Punctuated(punctuated) => { 173 | handle_punctuated_attribute(&attribute.key, punctuated, extensions) 174 | } 175 | }; 176 | 177 | let attr = match attr { 178 | Ok(attr) => attr, 179 | Err(diagnostic) => { 180 | return ( 181 | AttrType::TypeChecked { 182 | key: TokenStream2::new(), 183 | value: None, 184 | }, 185 | Some(diagnostic), 186 | ) 187 | } 188 | }; 189 | 190 | let attribute = match attr { 191 | AttrType::Component => AttrType::Component, 192 | AttrType::TypeChecked { key, .. } => { 193 | let value = attribute 194 | .value() 195 | .map(|value| quote!(::std::convert::Into::into(#value))); 196 | 197 | AttrType::TypeChecked { key, value } 198 | } 199 | AttrType::Extension { ty, key, .. } => { 200 | if let Some(ty) = ty { 201 | let value = attribute.value().map_or_else( 202 | || quote!(::html_node::typed::Attribute::Empty), 203 | |value| { 204 | quote! { 205 | ::html_node::typed::Attribute::Present( 206 | ::std::convert::Into::into(#value) 207 | ) 208 | } 209 | }, 210 | ); 211 | 212 | AttrType::Extension { 213 | ty: Some(ty), 214 | key, 215 | value, 216 | } 217 | } else { 218 | let value = attribute.value().map_or_else( 219 | || quote!(::std::option::Option::None), 220 | |value| { 221 | quote! { 222 | ::std::option::Option::Some( 223 | ::std::string::ToString::to_string(&#value), 224 | ) 225 | } 226 | }, 227 | ); 228 | 229 | AttrType::Extension { 230 | ty: None, 231 | key, 232 | value, 233 | } 234 | } 235 | } 236 | }; 237 | 238 | (attribute, None) 239 | } 240 | 241 | fn handle_path_attribute(path: &ExprPath) -> Result { 242 | if !path.attrs.is_empty() { 243 | Err(path 244 | .span() 245 | .error("typed elements don't support attributes on path keys")) 246 | } else if path.qself.is_some() { 247 | Err(path 248 | .span() 249 | .error("typed elements don't support qualified self on path keys")) 250 | } else if path.path.leading_colon.is_some() { 251 | Err(path 252 | .span() 253 | .error("typed elements don't support leading colons on path keys")) 254 | } else if path.path.segments.len() != 1 { 255 | Err(path 256 | .span() 257 | .error("typed elements don't support multiple segments on path keys")) 258 | } else { 259 | let segment = &path.path.segments[0]; 260 | 261 | let ident = &segment.ident; 262 | 263 | if ident == &Ident::new("component", Span::call_site()) { 264 | Ok(AttrType::Component) 265 | } else { 266 | let ident = Ident::new_raw(&ident.to_string(), path.span()); 267 | 268 | Ok(AttrType::TypeChecked { 269 | key: ident.to_token_stream(), 270 | value: None, 271 | }) 272 | } 273 | } 274 | } 275 | 276 | fn handle_punctuated_attribute( 277 | node_name: &NodeName, 278 | punctuated: &Punctuated, 279 | extensions: &HashMap>, 280 | ) -> Result { 281 | if let Some(Pair::Punctuated(n, p)) = punctuated.pairs().next() { 282 | let name = n.to_string(); 283 | if p.as_char() == '-' { 284 | extensions 285 | .get(&Ident::new(&name, Span::call_site())) 286 | .map_or_else( 287 | || { 288 | hyphenated_to_underscored(punctuated).map(|name| AttrType::TypeChecked { 289 | key: Ident::new_raw(&name, punctuated.span()).to_token_stream(), 290 | value: None, 291 | }) 292 | }, 293 | |type_| { 294 | type_.as_ref().map_or_else( 295 | || { 296 | let literal = node_name_to_literal(node_name); 297 | Ok(AttrType::Extension { 298 | ty: None, 299 | key: quote! { 300 | ::std::convert::Into::<::std::string::String>::into(#literal) 301 | }, 302 | value: TokenStream2::new(), 303 | }) 304 | }, 305 | |type_| { 306 | hyphenated_to_underscored(punctuated).map(|name| { 307 | AttrType::Extension { 308 | ty: Some(type_.clone()), 309 | key: Ident::new_raw(&name, punctuated.span()) 310 | .to_token_stream(), 311 | value: TokenStream2::new(), 312 | } 313 | }) 314 | }, 315 | ) 316 | }, 317 | ) 318 | } else { 319 | Err(punctuated 320 | .span() 321 | .error("empty punctuated keys are not supported")) 322 | } 323 | } else if let Some(Pair::End(ident)) = punctuated.pairs().next() { 324 | Ok(AttrType::TypeChecked { 325 | key: ident.to_token_stream(), 326 | value: None, 327 | }) 328 | } else { 329 | Err(punctuated 330 | .span() 331 | .error("empty punctuated keys are not supported")) 332 | } 333 | } 334 | 335 | fn hyphenated_to_underscored( 336 | punctuated: &Punctuated, 337 | ) -> Result { 338 | punctuated 339 | .pairs() 340 | .map(|pair| match pair { 341 | Pair::Punctuated(ident, punct) => { 342 | if punct.as_char() == '-' { 343 | Ok(format!("{ident}_")) 344 | } else { 345 | Err(punct 346 | .span() 347 | .error("only hyphens can be converted to underscores in attribute names")) 348 | } 349 | } 350 | Pair::End(ident) => Ok(ident.to_string()), 351 | }) 352 | .collect() 353 | } 354 | -------------------------------------------------------------------------------- /html-node/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors.workspace = true 3 | categories.workspace = true 4 | description.workspace = true 5 | documentation = "https://docs.rs/html-node" 6 | edition.workspace = true 7 | homepage.workspace = true 8 | keywords.workspace = true 9 | license.workspace = true 10 | name = "html-node" 11 | readme.workspace = true 12 | repository.workspace = true 13 | version.workspace = true 14 | 15 | [package.metadata.docs.rs] 16 | all-features = true 17 | rustdoc-args = ["--cfg", "docsrs"] 18 | 19 | 20 | [[example]] 21 | name = "axum" 22 | required-features = ["axum"] 23 | 24 | [[example]] 25 | name = "typed_custom_attributes" 26 | required-features = ["typed"] 27 | 28 | [dependencies] 29 | html-node-core = { version = "0.5", path = "../html-node-core" } 30 | html-node-macro = { version = "0.5", path = "../html-node-macro" } 31 | 32 | 33 | [dev-dependencies] 34 | axum = "0.6" 35 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 36 | 37 | [features] 38 | axum = ["html-node-core/axum"] 39 | pretty = ["html-node-core/pretty"] 40 | serde = ["html-node-core/serde"] 41 | typed = ["html-node-core/typed", "html-node-macro/typed"] 42 | 43 | 44 | [lints] 45 | workspace = true 46 | -------------------------------------------------------------------------------- /html-node/examples/axum.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | 3 | use std::{ 4 | collections::HashMap, 5 | net::{Ipv4Addr, SocketAddr}, 6 | }; 7 | 8 | use axum::{extract::Query, routing::get, Router, Server}; 9 | use html_node::{html, text, Node}; 10 | use html_node_core::pretty::Pretty; 11 | 12 | #[tokio::main] 13 | async fn main() { 14 | let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 3000)); 15 | 16 | println!("listening on {addr}..."); 17 | 18 | Server::bind(&addr) 19 | .serve(router().into_make_service()) 20 | .await 21 | .unwrap(); 22 | } 23 | 24 | fn router() -> Router { 25 | Router::new() 26 | .route("/", get(home)) 27 | .route("/about", get(about)) 28 | .route("/contact", get(contact)) 29 | .route("/greet", get(greet)) 30 | .route("/pretty", get(pretty)) 31 | } 32 | 33 | fn layout(content: Node) -> Node { 34 | const NAV_PAGES: &[(&str, &str)] = 35 | &[("/", "home"), ("/about", "about"), ("/contact", "contact")]; 36 | 37 | html! { 38 | 39 | 40 | "my website" 41 | 42 | 43 | 52 |
53 | {content} 54 |
55 | 56 | 57 | } 58 | } 59 | 60 | async fn home() -> Node { 61 | layout(html! { 62 |

"home"

63 | "welcome to my site!" 64 | 65 |

"use the form to get a personalized greeting!"

66 |
67 | 68 | 69 | 70 |
71 | }) 72 | } 73 | 74 | async fn about() -> Node { 75 | layout(html! { 76 |

"about"

77 | "my name is vidhan, and i'm a rust developer and a university student." 78 | }) 79 | } 80 | 81 | async fn contact() -> Node { 82 | layout(html! { 83 |

"contact"

84 | }) 85 | } 86 | 87 | async fn greet(Query(params): Query>) -> Node { 88 | let name = params 89 | .get("name") 90 | .map_or("stranger", std::string::String::as_str); 91 | 92 | layout(html! { 93 |

{text!("hello, {name}")}!

94 | }) 95 | } 96 | 97 | async fn pretty() -> Pretty { 98 | Pretty(layout(html! { 99 |
100 |

Pretty

101 |
102 | })) 103 | } 104 | -------------------------------------------------------------------------------- /html-node/examples/escaping.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | 3 | use html_node::{html, text, unsafe_text}; 4 | 5 | fn main() { 6 | let evil = ""; 7 | 8 | let safe_html = html! { 9 |
10 |

{text!("Hello, world!")}

11 |
12 | }; 13 | 14 | let unsafe_html = html! { 15 |
16 |

{unsafe_text!("{evil}")}

17 |
18 | }; 19 | 20 | println!("--- safe ---\n{safe_html:#}"); 21 | println!(); 22 | println!("--- unsafe ---\n{unsafe_html:#}"); 23 | } 24 | -------------------------------------------------------------------------------- /html-node/examples/typed_custom_attributes.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] 2 | 3 | use std::fmt::Display; 4 | 5 | use html_node::typed; 6 | 7 | #[derive(Debug, Clone)] 8 | struct Location { 9 | x: i32, 10 | y: i32, 11 | } 12 | 13 | impl Display for Location { 14 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 15 | write!(f, "{},{}", self.x, self.y) 16 | } 17 | } 18 | 19 | typed::element! { 20 | CustomElement("custom-element") { 21 | location: Location, 22 | } 23 | } 24 | 25 | fn main() { 26 | let html = typed::html!(); 27 | 28 | assert_eq!( 29 | html.to_string(), 30 | r#""# 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /html-node/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A HTML to [`Node`] macro powered by [rstml](https://github.com/rs-tml/rstml). 2 | //! 3 | //! Values returned from braced blocks (`{ ... }`) are expected to return 4 | //! something that implements [`Into`]. This is already implemented for 5 | //! anything that implements [`IntoIterator`](IntoIterator), so you 6 | //! can return something like a [`Vec`] or an 7 | //! [`Iterator`](Iterator) directly. 8 | //! 9 | //! Due to Rust's trait implementation rules, you cannot directly return 10 | //! [`String`]s. Instead, you can use the [`text!`] macro to convert the 11 | //! [`String`] to a [`Node::Text`]. 12 | //! 13 | //! [`Node`] implements [`Display`][std::fmt::Display] (and by extension 14 | //! [`ToString`]), so you can turn it into a string representation easily using 15 | //! [`Node::to_string()`][ToString::to_string]. 16 | //! 17 | //! # Typed HTML 18 | //! 19 | //! This crate also supports typed HTML, which is all nested into the [`typed`] 20 | //! module. note that the feature `typed` must be enabled to use it. 21 | //! 22 | //! # Examples 23 | //! 24 | //! ## Basic 25 | //! 26 | //! ```rust 27 | //! use html_node::{html, text}; 28 | //! 29 | //! let shopping_list = vec!["milk", "eggs", "bread"]; 30 | //! 31 | //! let html = html! { 32 | //!
33 | //!

Shopping List

34 | //!
    35 | //! { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 36 | //!
  • 37 | //! 38 | //! 39 | //!
  • 40 | //! }) } 41 | //!
42 | //!
43 | //! }; 44 | //! 45 | //! let expected = "\ 46 | //!
\ 47 | //!

Shopping List

\ 48 | //!
    \ 49 | //!
  • \ 50 | //! \ 51 | //! \ 52 | //!
  • \ 53 | //!
  • \ 54 | //! \ 55 | //! \ 56 | //!
  • \ 57 | //!
  • \ 58 | //! \ 59 | //! \ 60 | //!
  • \ 61 | //!
\ 62 | //!
\ 63 | //! "; 64 | //! 65 | //! assert_eq!(html.to_string(), expected); 66 | //! ``` 67 | //! 68 | //! ## Pretty-Printing 69 | //! 70 | //! Pretty-printing is supported by default when formatting a [`Node`] using the 71 | //! alternate formatter, specified by a `#` in the format string. 72 | //! 73 | //! If you want to avoid specifying the alternate formatter, enabling the 74 | //! `pretty` feature will provide a convenience method [`Node::pretty()`] that 75 | //! returns a wrapper around the node that will always be pretty-printed. 76 | //! 77 | //! ```rust 78 | //! use html_node::{html, text}; 79 | //! 80 | //! let html = html! { 81 | //!
82 | //!

Shopping List

83 | //!
    84 | //!
  • Eggs
  • 85 | //!
  • Milk
  • 86 | //!
  • Bread
  • 87 | //!
88 | //!
89 | //! }; 90 | //! 91 | //! let expected = "\ 92 | //!
93 | //!

94 | //! Shopping List 95 | //!

96 | //!
    97 | //!
  • 98 | //! Eggs 99 | //!
  • 100 | //!
  • 101 | //! Milk 102 | //!
  • 103 | //!
  • 104 | //! Bread 105 | //!
  • 106 | //!
107 | //!
\ 108 | //! "; 109 | //! 110 | //! // Note the `#` in the format string, which enables pretty-printing 111 | //! let formatted_html = format!("{html:#}"); 112 | //! 113 | //! assert_eq!(formatted_html, expected); 114 | //! 115 | //! # #[cfg(feature = "pretty")] 116 | //! # { 117 | //! // Wrap the HTML node in a pretty-printing wrapper. 118 | //! let pretty = html.pretty(); 119 | //! 120 | //! // Get the pretty-printed HTML as a string by invoking the [`Display`][std::fmt::Display] trait. 121 | //! let pretty_html_string = pretty.to_string(); 122 | //! // Note the '#' is not required here. 123 | //! let pretty_html_format = format!("{pretty}"); 124 | //! 125 | //! assert_eq!(pretty_html_string, expected); 126 | //! assert_eq!(pretty_html_format, expected); 127 | //! assert_eq!(pretty_html_string, pretty_html_format); 128 | //! # } 129 | //! ``` 130 | 131 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 132 | 133 | mod macros; 134 | #[cfg(feature = "typed")] 135 | pub mod typed; 136 | 137 | #[cfg(feature = "pretty")] 138 | pub use html_node_core::pretty; 139 | pub use html_node_core::{Comment, Doctype, Element, Fragment, Node, Text, UnsafeText}; 140 | /// The HTML to [`Node`] macro. 141 | /// 142 | /// See the [crate-level documentation](crate) for more information. 143 | pub use html_node_macro::html; 144 | 145 | pub use self::macros::*; 146 | -------------------------------------------------------------------------------- /html-node/src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Creates a [`Node::Comment`][crate::Node::Comment]. 2 | #[macro_export] 3 | macro_rules! comment { 4 | ($($tt:tt)*) => { 5 | ::html_node::Node::Comment(::html_node::Comment { 6 | comment: ::std::format!($($tt)*), 7 | }) 8 | }; 9 | } 10 | 11 | /// Creates a [`Node::Text`][crate::Node::Text]. 12 | #[macro_export] 13 | macro_rules! text { 14 | ($($tt:tt)*) => { 15 | ::html_node::Node::Text(::html_node::Text { 16 | text: ::std::format!($($tt)*), 17 | }) 18 | }; 19 | } 20 | 21 | /// Creates a [`Node::UnsafeText`][crate::Node::UnsafeText]. 22 | /// 23 | /// # Warning 24 | /// 25 | /// [`Node::UnsafeText`][crate::Node::UnsafeText] is not escaped when rendered, 26 | /// and as such, can allow for XSS attacks. Use with caution! 27 | #[macro_export] 28 | macro_rules! unsafe_text { 29 | ($($tt:tt)*) => { 30 | ::html_node::Node::UnsafeText(::html_node::UnsafeText { 31 | text: ::std::format!($($tt)*), 32 | }) 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /html-node/src/typed.rs: -------------------------------------------------------------------------------- 1 | //! Typed HTML nodes. 2 | //! 3 | //! # Examples 4 | //! 5 | //! ```rust 6 | //! use html_node::typed::{self, elements::*}; 7 | //! // ^^^^^^^^^^^ 8 | //! // required to bring type definitions 9 | //! // of all basic html elements into 10 | //! // the current scope. 11 | //! // (can also use `elements::div`, etc.) 12 | //! 13 | //! #[derive(Clone, Debug)] 14 | //! struct Location { 15 | //! x: i32, 16 | //! y: i32, 17 | //! } 18 | //! 19 | //! impl std::fmt::Display for Location { 20 | //! fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | //! write!(f, "{},{}", self.x, self.y) 22 | //! } 23 | //! } 24 | //! 25 | //! // defines a custom element named `CustomElement`, with the specified attributes. 26 | //! // underscores in attributes get converted to and from hyphens in the 27 | //! // `typed::html!` macro and rendering. 28 | //! 29 | //! // note that global attributes like `id` will be pre-defined when 30 | //! // using the `typed::element!` macro. 31 | //! 32 | //! typed::element! { 33 | //! CustomElement("custom-element") { 34 | //! custom_attr, // implictly typed as a `String` 35 | //! location: Location, 36 | //! } 37 | //! } 38 | //! 39 | //! typed::attributes! { 40 | //! [TestAttrs] { 41 | //! test_val: i32, 42 | //! } 43 | //! } 44 | //! 45 | //! // creates a normal `Node`, but checks types at compile-time! 46 | //! let html = typed::html! { (test: TestAttrs, any) 47 | //! // ^^^^^^^^^^^^^^^^^^^^^^ these are extension attributes. 48 | //! // they are not required, but allow you to specify extra attributes 49 | //! // which will be available within this macro invocation. 50 | //! // those of the form `attr-prefix: Type` will be type checked, and 51 | //! // those with just `attr-prefix` will be considered "catch-all" prefixes 52 | //! // allowing any attribute with that prefix to be specified. 53 | //! // `data-*` and `aria-*` are predefined as catch-all prefixes. 54 | //!
55 | //! 56 | //!
57 | //! }; 58 | //! 59 | //! assert_eq!( 60 | //! html.to_string(), 61 | //! "\ 62 | //!
\ 63 | //! \ 64 | //! \ 65 | //!
\ 66 | //! ", 67 | //! ); 68 | 69 | #[allow(clippy::module_name_repetitions)] 70 | pub use html_node_core::typed::{elements, Attribute, TypedAttributes, TypedElement}; 71 | /// Make a typed set of HTML attributes. 72 | /// 73 | /// Used internally by [`element!`]. 74 | pub use html_node_core::typed_attributes as attributes; 75 | /// Make a typed HTML node. 76 | /// 77 | /// # Examples 78 | /// 79 | /// ## Passing Type-Checking 80 | /// 81 | /// ```rust 82 | /// use html_node::typed::{self, elements::*}; 83 | /// 84 | /// typed::component! { 85 | /// CustomBody { 86 | /// r: u8, 87 | /// g: u8, 88 | /// b: u8, 89 | /// width: i32, 90 | /// }; 91 | /// 92 | /// |CustomBodyAttributes { r, g, b, width }, _, children| typed::html! { 93 | ///
94 | /// { children } 95 | ///
96 | /// } 97 | /// } 98 | /// 99 | /// let html = typed::html! { 100 | /// "Hello, world!" 101 | /// }; 102 | /// 103 | /// let expected = "\ 104 | ///
\ 105 | /// Hello, world!\ 106 | ///
\ 107 | /// "; 108 | /// 109 | /// assert_eq!(html.to_string(), expected); 110 | /// ``` 111 | pub use html_node_core::typed_component as component; 112 | /// Make a typed element. 113 | /// 114 | /// # Examples 115 | /// 116 | /// ## Fully Generated (With Custom Name) 117 | /// 118 | /// ```rust 119 | /// use html_node::typed; 120 | /// 121 | /// typed::element! { 122 | /// CustomElement("custom-element") { 123 | /// custom_attr, 124 | /// } 125 | /// } 126 | /// 127 | /// // note that global attributes like `id` will be pre-defined when 128 | /// // using the `typed::element!` macro. 129 | /// assert_eq!( 130 | /// typed::html!().to_string(), 131 | /// r#""#, 132 | /// ); 133 | /// ``` 134 | /// 135 | /// ## Fully Generated (With Default Name) 136 | /// 137 | /// ```rust 138 | /// use html_node::typed; 139 | /// 140 | /// typed::element! { 141 | /// CustomElement { 142 | /// custom_attr, 143 | /// } 144 | /// } 145 | /// 146 | /// assert_eq!( 147 | /// typed::html!().to_string(), 148 | /// r#""#, 149 | /// ); 150 | /// ``` 151 | /// 152 | /// ## Generated With Custom Attributes Name 153 | /// 154 | /// ```rust 155 | /// use html_node::typed::{self, TypedAttributes}; 156 | /// 157 | /// typed::element! { 158 | /// CustomElement [CustomElementAttributesDifferent] { 159 | /// custom_attr, 160 | /// } 161 | /// } 162 | /// 163 | /// assert_eq!( 164 | /// typed::html!().to_string(), 165 | /// r#""#, 166 | /// ); 167 | /// ``` 168 | /// 169 | /// ## Generated With Custom Attributes 170 | /// 171 | /// ```rust 172 | /// use html_node::typed::{self, Attribute, TypedAttributes}; 173 | /// 174 | /// #[derive(Debug, Clone, Default)] 175 | /// struct CustomElementAttributes { 176 | /// custom_attr: Attribute, 177 | /// } 178 | /// 179 | /// impl TypedAttributes for CustomElementAttributes { 180 | /// fn into_attributes(self) -> Vec<(String, Option)> { 181 | /// vec![self.custom_attr.into_option().map(|v| ("custom-attr".into(), v))] 182 | /// .into_iter() 183 | /// .flatten() 184 | /// .collect() 185 | /// } 186 | /// } 187 | /// 188 | /// typed::element! { 189 | /// CustomElement [CustomElementAttributes] 190 | /// } 191 | /// 192 | /// // note that global attributes like `id` will not be allowed here 193 | /// // because they are not defined in `CustomElementAttributes`. 194 | /// assert_eq!( 195 | /// typed::html!().to_string(), 196 | /// r#""#, 197 | /// ); 198 | /// ``` 199 | pub use html_node_core::typed_element as element; 200 | /// Make many typed elements. 201 | /// 202 | /// This uses the same syntax as [`element!`], but repeated and seperated 203 | /// by semicolons (`;`). 204 | pub use html_node_core::typed_elements as elements; 205 | /// Make a typed HTML node. 206 | /// 207 | /// # Examples 208 | /// 209 | /// ## Passing Type-Checking 210 | /// 211 | /// ```rust 212 | /// use html_node::typed::{self, elements::*}; 213 | /// 214 | /// let html = typed::html! { 215 | ///
216 | /// "Hello, world!" 217 | ///
218 | /// }; 219 | /// 220 | /// let expected = "\ 221 | ///
\ 222 | /// Hello, world!\ 223 | ///
\ 224 | /// "; 225 | /// 226 | /// assert_eq!(html.to_string(), expected); 227 | /// ``` 228 | /// 229 | /// ## Failing Type-Checking 230 | /// 231 | /// ```compile_fail 232 | /// use html_node::typed::{self, elements::*}; 233 | /// 234 | /// let html = typed::html! { 235 | /// // ERROR: struct `html_node::typed::elements::DivAttributes` has no field named `my_attr` 236 | ///
237 | /// {text!("Hello, world!")} 238 | ///
239 | /// }; 240 | /// ``` 241 | pub use html_node_macro::typed_html as html; 242 | -------------------------------------------------------------------------------- /html-node/tests/main.rs: -------------------------------------------------------------------------------- 1 | use html_node::{html, text}; 2 | 3 | #[test] 4 | fn basic() { 5 | let shopping_list = vec!["milk", "eggs", "bread"]; 6 | 7 | let html = html! { 8 |
9 |

Shopping List

10 |
    11 | { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 12 |
  • 13 | 14 | 15 |
  • 16 | }) } 17 |
18 |
19 | }; 20 | 21 | let expected = "\ 22 |
\ 23 |

Shopping List

\ 24 |
    \ 25 |
  • \ 26 | \ 27 | \ 28 |
  • \ 29 |
  • \ 30 | \ 31 | \ 32 |
  • \ 33 |
  • \ 34 | \ 35 | \ 36 |
  • \ 37 |
\ 38 |
\ 39 | "; 40 | 41 | assert_eq!(html.to_string(), expected); 42 | } 43 | 44 | #[test] 45 | fn pretty_printed_format() { 46 | let shopping_list = vec!["milk", "eggs", "bread"]; 47 | 48 | let html = html! { 49 |
50 |

Shopping List

51 |
    52 | { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 53 |
  • 54 | 55 | 56 |
  • 57 | }) } 58 |
59 |
60 | }; 61 | 62 | println!("--- pretty-printed ---\n{html:#}"); 63 | 64 | let expected = "\ 65 |
66 |

67 | Shopping List 68 |

69 |
    70 |
  • 71 | 72 | 75 |
  • 76 |
  • 77 | 78 | 81 |
  • 82 |
  • 83 | 84 | 87 |
  • 88 |
89 |
\ 90 | "; 91 | 92 | let pretty_html = format!("{html:#}"); 93 | 94 | assert_eq!(pretty_html, expected); 95 | } 96 | 97 | #[cfg(feature = "pretty")] 98 | #[test] 99 | fn pretty_printed_helper() { 100 | let pretty_html = html! { 101 |
102 |
103 |

Pretty Printing Wrapper Test

104 |

This test should be pretty printed!

105 |
106 |
107 | } 108 | .pretty(); 109 | 110 | println!("Pretty helper:\n{pretty_html}"); 111 | 112 | let expected = r#"
113 |
114 |

115 | Pretty Printing Wrapper Test 116 |

117 |

118 | This test should be 119 | 120 | pretty printed! 121 | 122 |

123 |
124 |
"#; 125 | assert_eq!(expected, pretty_html.to_string()); 126 | } 127 | -------------------------------------------------------------------------------- /html-node/tests/typed.rs: -------------------------------------------------------------------------------- 1 | use html_node::{ 2 | text, 3 | typed::{self, elements::*, html}, 4 | }; 5 | 6 | #[test] 7 | fn basic() { 8 | let shopping_list = vec!["milk", "eggs", "bread"]; 9 | 10 | let html = html! { 11 |
12 |

Shopping List

13 |
    14 | { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 15 |
  • 16 | 17 | 18 |
  • 19 | }) } 20 |
21 |
22 | }; 23 | 24 | let expected = "\ 25 |
\ 26 |

Shopping List

\ 27 |
    \ 28 |
  • \ 29 | \ 30 | \ 31 |
  • \ 32 |
  • \ 33 | \ 34 | \ 35 |
  • \ 36 |
  • \ 37 | \ 38 | \ 39 |
  • \ 40 |
\ 41 |
\ 42 | "; 43 | 44 | assert_eq!(html.to_string(), expected); 45 | } 46 | 47 | #[test] 48 | fn pretty_printed() { 49 | let shopping_list = vec!["milk", "eggs", "bread"]; 50 | 51 | let html = html! { 52 |
53 |

Shopping List

54 |
    55 | { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 56 |
  • 57 | 58 | 59 |
  • 60 | }) } 61 |
62 |
63 | }; 64 | 65 | println!("--- pretty-printed ---\n{html:#}"); 66 | 67 | let expected = "\ 68 |
69 |

70 | Shopping List 71 |

72 |
    73 |
  • 74 | 75 | 78 |
  • 79 |
  • 80 | 81 | 84 |
  • 85 |
  • 86 | 87 | 90 |
  • 91 |
92 |
\ 93 | "; 94 | 95 | let pretty_html = format!("{html:#}"); 96 | 97 | assert_eq!(pretty_html, expected); 98 | } 99 | 100 | #[test] 101 | fn component() { 102 | typed::component! { 103 | ShoppingListItem { 104 | index: i32, 105 | }; 106 | 107 | |ShoppingListItemAttributes { index }, _, children| html! { 108 |
  • 109 | 110 | 111 |
  • 112 | } 113 | } 114 | 115 | let shopping_list = vec!["milk", "eggs", "bread"]; 116 | 117 | let html = html! { 118 |
    119 |

    Shopping List

    120 |
      121 | { shopping_list.into_iter().zip(1..).map(|(item, i)| html! { 122 | {text!("{item}")} 123 | }) } 124 |
    125 |
    126 | }; 127 | 128 | let expected = "\ 129 |
    \ 130 |

    Shopping List

    \ 131 |
      \ 132 |
    • \ 133 | \ 134 | \ 135 |
    • \ 136 |
    • \ 137 | \ 138 | \ 139 |
    • \ 140 |
    • \ 141 | \ 142 | \ 143 |
    • \ 144 |
    \ 145 |
    \ 146 | "; 147 | 148 | assert_eq!(html.to_string(), expected); 149 | } 150 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | 3 | format_macro_matchers = true 4 | group_imports = "StdExternalCrate" 5 | imports_granularity = "Crate" 6 | reorder_impl_items = true 7 | wrap_comments = true 8 | -------------------------------------------------------------------------------- /taplo.toml: -------------------------------------------------------------------------------- 1 | [formatting] 2 | indent_string = " " 3 | indent_tables = true 4 | reorder_arrays = true 5 | reorder_keys = true 6 | trailing_newline = true 7 | 8 | [[rule]] 9 | include = ["**/Cargo.toml"] 10 | keys = ["package"] 11 | 12 | [rule.formatting] 13 | reorder_keys = false 14 | --------------------------------------------------------------------------------