| author | |
| committer | |
| log | 525512dd8576feeeeb56a895aa90a2240cdd7c7a |
| tree | 9221fc6c455408d0e088619859422da94fad01bf |
| signature |
alot of this is slop but i think its little usable. this commit has many
known bugs, but the next.js blog sample works and there is room to grow.56 files changed, 6155 insertions(+), 0 deletions(-)
.gitignore created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | /target | ||
| 2 | .tmp | ||
AGENTS.md created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | # instructions for ai agents | ||
| 2 | |||
| 3 | please read @README.md to understand the project's goals. also read the | ||
| 4 | @ARCHITECTURE.md file to get a code breakdown. without these documents, it will | ||
| 5 | be extremely hard to succeed. | ||
| 6 | |||
| 7 | ## contributing rules | ||
| 8 | |||
| 9 | - code must be good, neatly organized, minimal, and testable. | ||
| 10 | - components should assert correctness, fixing root causes over band aids. do | ||
| 11 | not discard suspicious data or patch assertions away. | ||
| 12 | - documentation must be human written. do not alter `README.md` | ||
| 13 | - your environment is sandboxed; writing outside this directory may cause | ||
| 14 | concurrency issues. instead, write to `./.tmp/`. | ||
| 15 | - please maintain objective, high quality tests with `cargo test` | ||
| 16 | - never execute `git` commands. if you have broken your environment or are | ||
| 17 | unsure how to continue, yield to the user. | ||
| 18 | |||
| 19 | ## marko v6 tag syntax legend | ||
| 20 | |||
| 21 | you are not familiar with the version 6 syntax changes: see | ||
| 22 | <https://markojs.com/docs/reference/language> for the full explainer. here is a | ||
| 23 | basic breakdown: | ||
| 24 | |||
| 25 | ``` | ||
| 26 | <tag|...params|/var ...attrs> | ||
| 27 | content with ${placeholders} | ||
| 28 | <@attr-tags/> | ||
| 29 | </> | ||
| 30 | ``` | ||
| 31 | |||
| 32 | - `/var` - js local variables. ex `<id/uniqueId />` | ||
| 33 | - `...params` - js parameters. ex `<for|item| of=list>` | ||
| 34 | - `...attrs` - key value pairs, any one of of: | ||
| 35 | - `key=expr` where value is a js expression | ||
| 36 | - `...expr` spread any js expression | ||
| 37 | - `value:=expr` two way binding | ||
| 38 | - `<tag=value>` shorthand `value` alias, eg `<if=!hide>` | ||
| 39 | - tags can close with their name `</div>` or just `</>` | ||
| 40 | - there are many more features | ||
ARCHITECTURE.md created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | # architecture | ||
| 2 | |||
| 3 | - `src/lib.rs` - main library, primary function `transform` | ||
| 4 | - `src/main.rs` - main cli | ||
| 5 | - `src/plugin/` - `markdown-it` plugin and all rules | ||
| 6 | |||
| 7 | markdown-it does not support failiable plugins, so instead errors are lowered | ||
| 8 | into `ErrorBlock` and then the errors are combined to form the final error list. | ||
| 9 | all the rules are essentially validations for the Marko syntax, and get | ||
| 10 | reprinted as text at the end. the set of rules: | ||
| 11 | |||
| 12 | - block | ||
| 13 | - `frontmatter.rs` frontmatter | ||
| 14 | - `statement.rs` support top-level `import`, `static function`, and others | ||
| 15 | - `comment.rs` allow comments | ||
| 16 | - `tags.rs` parser for Marko tags at the block level | ||
| 17 | - inline | ||
| 18 | - `autolink.rs` custom autolink implementation to disambiguate | ||
| 19 | - `template.rs` template literals | ||
| 20 | - `inline_tags.rs` parser for Marko tags inline | ||
| 21 | |||
| 22 | these rules are configured in `plugin/mod.rs` in `add_all`. | ||
Cargo.lock created+1203| ... | @@ -0,0 +1,1203 @@ | ||
| 1 | # This file is automatically @generated by Cargo. | ||
| 2 | # It is not intended for manual editing. | ||
| 3 | version = 4 | ||
| 4 | |||
| 5 | [[package]] | ||
| 6 | name = "aho-corasick" | ||
| 7 | version = "1.1.4" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | ||
| 10 | dependencies = [ | ||
| 11 | "memchr", | ||
| 12 | ] | ||
| 13 | |||
| 14 | [[package]] | ||
| 15 | name = "allocator-api2" | ||
| 16 | version = "0.2.21" | ||
| 17 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | ||
| 19 | |||
| 20 | [[package]] | ||
| 21 | name = "anstream" | ||
| 22 | version = "0.6.21" | ||
| 23 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 24 | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" | ||
| 25 | dependencies = [ | ||
| 26 | "anstyle", | ||
| 27 | "anstyle-parse", | ||
| 28 | "anstyle-query", | ||
| 29 | "anstyle-wincon", | ||
| 30 | "colorchoice", | ||
| 31 | "is_terminal_polyfill", | ||
| 32 | "utf8parse", | ||
| 33 | ] | ||
| 34 | |||
| 35 | [[package]] | ||
| 36 | name = "anstyle" | ||
| 37 | version = "1.0.13" | ||
| 38 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 39 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" | ||
| 40 | |||
| 41 | [[package]] | ||
| 42 | name = "anstyle-parse" | ||
| 43 | version = "0.2.7" | ||
| 44 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 45 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" | ||
| 46 | dependencies = [ | ||
| 47 | "utf8parse", | ||
| 48 | ] | ||
| 49 | |||
| 50 | [[package]] | ||
| 51 | name = "anstyle-query" | ||
| 52 | version = "1.1.5" | ||
| 53 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 54 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | ||
| 55 | dependencies = [ | ||
| 56 | "windows-sys 0.61.2", | ||
| 57 | ] | ||
| 58 | |||
| 59 | [[package]] | ||
| 60 | name = "anstyle-wincon" | ||
| 61 | version = "3.0.11" | ||
| 62 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 63 | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | ||
| 64 | dependencies = [ | ||
| 65 | "anstyle", | ||
| 66 | "once_cell_polyfill", | ||
| 67 | "windows-sys 0.61.2", | ||
| 68 | ] | ||
| 69 | |||
| 70 | [[package]] | ||
| 71 | name = "anyhow" | ||
| 72 | version = "1.0.101" | ||
| 73 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 74 | checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" | ||
| 75 | |||
| 76 | [[package]] | ||
| 77 | name = "ar_archive_writer" | ||
| 78 | version = "0.5.1" | ||
| 79 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 80 | checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" | ||
| 81 | dependencies = [ | ||
| 82 | "object", | ||
| 83 | ] | ||
| 84 | |||
| 85 | [[package]] | ||
| 86 | name = "argparse" | ||
| 87 | version = "0.2.2" | ||
| 88 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 89 | checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47" | ||
| 90 | |||
| 91 | [[package]] | ||
| 92 | name = "autocfg" | ||
| 93 | version = "1.5.0" | ||
| 94 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 95 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | ||
| 96 | |||
| 97 | [[package]] | ||
| 98 | name = "bitflags" | ||
| 99 | version = "2.10.0" | ||
| 100 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 101 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" | ||
| 102 | |||
| 103 | [[package]] | ||
| 104 | name = "bumpalo" | ||
| 105 | version = "3.19.0" | ||
| 106 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 107 | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" | ||
| 108 | dependencies = [ | ||
| 109 | "allocator-api2", | ||
| 110 | ] | ||
| 111 | |||
| 112 | [[package]] | ||
| 113 | name = "castaway" | ||
| 114 | version = "0.2.4" | ||
| 115 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 116 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | ||
| 117 | dependencies = [ | ||
| 118 | "rustversion", | ||
| 119 | ] | ||
| 120 | |||
| 121 | [[package]] | ||
| 122 | name = "cc" | ||
| 123 | version = "1.2.55" | ||
| 124 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 125 | checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" | ||
| 126 | dependencies = [ | ||
| 127 | "find-msvc-tools", | ||
| 128 | "shlex", | ||
| 129 | ] | ||
| 130 | |||
| 131 | [[package]] | ||
| 132 | name = "cfg-if" | ||
| 133 | version = "1.0.4" | ||
| 134 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 135 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | ||
| 136 | |||
| 137 | [[package]] | ||
| 138 | name = "clap" | ||
| 139 | version = "4.5.57" | ||
| 140 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 141 | checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" | ||
| 142 | dependencies = [ | ||
| 143 | "clap_builder", | ||
| 144 | "clap_derive", | ||
| 145 | ] | ||
| 146 | |||
| 147 | [[package]] | ||
| 148 | name = "clap_builder" | ||
| 149 | version = "4.5.57" | ||
| 150 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 151 | checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" | ||
| 152 | dependencies = [ | ||
| 153 | "anstream", | ||
| 154 | "anstyle", | ||
| 155 | "clap_lex", | ||
| 156 | "strsim", | ||
| 157 | ] | ||
| 158 | |||
| 159 | [[package]] | ||
| 160 | name = "clap_derive" | ||
| 161 | version = "4.5.55" | ||
| 162 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 163 | checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" | ||
| 164 | dependencies = [ | ||
| 165 | "heck", | ||
| 166 | "proc-macro2", | ||
| 167 | "quote", | ||
| 168 | "syn 2.0.114", | ||
| 169 | ] | ||
| 170 | |||
| 171 | [[package]] | ||
| 172 | name = "clap_lex" | ||
| 173 | version = "0.7.7" | ||
| 174 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 175 | checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" | ||
| 176 | |||
| 177 | [[package]] | ||
| 178 | name = "colorchoice" | ||
| 179 | version = "1.0.4" | ||
| 180 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 181 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" | ||
| 182 | |||
| 183 | [[package]] | ||
| 184 | name = "compact_str" | ||
| 185 | version = "0.9.0" | ||
| 186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 187 | checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" | ||
| 188 | dependencies = [ | ||
| 189 | "castaway", | ||
| 190 | "cfg-if", | ||
| 191 | "itoa", | ||
| 192 | "rustversion", | ||
| 193 | "ryu", | ||
| 194 | "static_assertions", | ||
| 195 | ] | ||
| 196 | |||
| 197 | [[package]] | ||
| 198 | name = "const_format" | ||
| 199 | version = "0.2.35" | ||
| 200 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 201 | checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" | ||
| 202 | dependencies = [ | ||
| 203 | "const_format_proc_macros", | ||
| 204 | ] | ||
| 205 | |||
| 206 | [[package]] | ||
| 207 | name = "const_format_proc_macros" | ||
| 208 | version = "0.2.34" | ||
| 209 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 210 | checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" | ||
| 211 | dependencies = [ | ||
| 212 | "proc-macro2", | ||
| 213 | "quote", | ||
| 214 | "unicode-xid", | ||
| 215 | ] | ||
| 216 | |||
| 217 | [[package]] | ||
| 218 | name = "convert_case" | ||
| 219 | version = "0.4.0" | ||
| 220 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 221 | checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" | ||
| 222 | |||
| 223 | [[package]] | ||
| 224 | name = "cow-utils" | ||
| 225 | version = "0.1.3" | ||
| 226 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 227 | checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" | ||
| 228 | |||
| 229 | [[package]] | ||
| 230 | name = "derivative" | ||
| 231 | version = "2.2.0" | ||
| 232 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 233 | checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" | ||
| 234 | dependencies = [ | ||
| 235 | "proc-macro2", | ||
| 236 | "quote", | ||
| 237 | "syn 1.0.109", | ||
| 238 | ] | ||
| 239 | |||
| 240 | [[package]] | ||
| 241 | name = "derive_more" | ||
| 242 | version = "0.99.20" | ||
| 243 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 244 | checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" | ||
| 245 | dependencies = [ | ||
| 246 | "convert_case", | ||
| 247 | "proc-macro2", | ||
| 248 | "quote", | ||
| 249 | "rustc_version", | ||
| 250 | "syn 2.0.114", | ||
| 251 | ] | ||
| 252 | |||
| 253 | [[package]] | ||
| 254 | name = "downcast-rs" | ||
| 255 | version = "1.2.1" | ||
| 256 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 257 | checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | ||
| 258 | |||
| 259 | [[package]] | ||
| 260 | name = "dragonbox_ecma" | ||
| 261 | version = "0.0.5" | ||
| 262 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 263 | checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66" | ||
| 264 | |||
| 265 | [[package]] | ||
| 266 | name = "entities" | ||
| 267 | version = "1.0.1" | ||
| 268 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 269 | checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" | ||
| 270 | |||
| 271 | [[package]] | ||
| 272 | name = "equivalent" | ||
| 273 | version = "1.0.2" | ||
| 274 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 275 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | ||
| 276 | |||
| 277 | [[package]] | ||
| 278 | name = "fastrand" | ||
| 279 | version = "2.3.0" | ||
| 280 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 281 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" | ||
| 282 | |||
| 283 | [[package]] | ||
| 284 | name = "find-msvc-tools" | ||
| 285 | version = "0.1.9" | ||
| 286 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 287 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | ||
| 288 | |||
| 289 | [[package]] | ||
| 290 | name = "hashbrown" | ||
| 291 | version = "0.16.1" | ||
| 292 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 293 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | ||
| 294 | dependencies = [ | ||
| 295 | "allocator-api2", | ||
| 296 | ] | ||
| 297 | |||
| 298 | [[package]] | ||
| 299 | name = "heck" | ||
| 300 | version = "0.5.0" | ||
| 301 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 302 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | ||
| 303 | |||
| 304 | [[package]] | ||
| 305 | name = "html-escape" | ||
| 306 | version = "0.2.13" | ||
| 307 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 308 | checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" | ||
| 309 | dependencies = [ | ||
| 310 | "utf8-width", | ||
| 311 | ] | ||
| 312 | |||
| 313 | [[package]] | ||
| 314 | name = "idna" | ||
| 315 | version = "0.3.0" | ||
| 316 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 317 | checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" | ||
| 318 | dependencies = [ | ||
| 319 | "unicode-bidi", | ||
| 320 | "unicode-normalization", | ||
| 321 | ] | ||
| 322 | |||
| 323 | [[package]] | ||
| 324 | name = "indexmap" | ||
| 325 | version = "2.13.0" | ||
| 326 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 327 | checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" | ||
| 328 | dependencies = [ | ||
| 329 | "equivalent", | ||
| 330 | "hashbrown", | ||
| 331 | ] | ||
| 332 | |||
| 333 | [[package]] | ||
| 334 | name = "is_terminal_polyfill" | ||
| 335 | version = "1.70.2" | ||
| 336 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 337 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | ||
| 338 | |||
| 339 | [[package]] | ||
| 340 | name = "itoa" | ||
| 341 | version = "1.0.17" | ||
| 342 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 343 | checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" | ||
| 344 | |||
| 345 | [[package]] | ||
| 346 | name = "libc" | ||
| 347 | version = "0.2.180" | ||
| 348 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 349 | checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" | ||
| 350 | |||
| 351 | [[package]] | ||
| 352 | name = "libyml" | ||
| 353 | version = "0.0.5" | ||
| 354 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 355 | checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" | ||
| 356 | dependencies = [ | ||
| 357 | "anyhow", | ||
| 358 | "version_check", | ||
| 359 | ] | ||
| 360 | |||
| 361 | [[package]] | ||
| 362 | name = "linkify" | ||
| 363 | version = "0.10.0" | ||
| 364 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 365 | checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" | ||
| 366 | dependencies = [ | ||
| 367 | "memchr", | ||
| 368 | ] | ||
| 369 | |||
| 370 | [[package]] | ||
| 371 | name = "markdown-it" | ||
| 372 | version = "0.6.1" | ||
| 373 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 374 | checksum = "f99c010929c8217b2dc0940954267a2e15a15f17cb309cd1f299e21933f84fac" | ||
| 375 | dependencies = [ | ||
| 376 | "argparse", | ||
| 377 | "const_format", | ||
| 378 | "derivative", | ||
| 379 | "derive_more", | ||
| 380 | "downcast-rs", | ||
| 381 | "entities", | ||
| 382 | "html-escape", | ||
| 383 | "linkify", | ||
| 384 | "mdurl", | ||
| 385 | "once_cell", | ||
| 386 | "readonly", | ||
| 387 | "regex", | ||
| 388 | "stacker", | ||
| 389 | "unicode-general-category", | ||
| 390 | ] | ||
| 391 | |||
| 392 | [[package]] | ||
| 393 | name = "markodown" | ||
| 394 | version = "0.1.0" | ||
| 395 | dependencies = [ | ||
| 396 | "clap", | ||
| 397 | "markdown-it", | ||
| 398 | "oxc_allocator", | ||
| 399 | "oxc_ast", | ||
| 400 | "oxc_diagnostics", | ||
| 401 | "oxc_parser", | ||
| 402 | "oxc_span", | ||
| 403 | "serde", | ||
| 404 | "serde_json", | ||
| 405 | "serde_yml", | ||
| 406 | "tree-sitter", | ||
| 407 | "tree-sitter-typescript", | ||
| 408 | ] | ||
| 409 | |||
| 410 | [[package]] | ||
| 411 | name = "mdurl" | ||
| 412 | version = "0.3.1" | ||
| 413 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 414 | checksum = "5736ba45bbac8f7ccc99a897f88ce85e508a18baec973a040f2514e6cdbff0d2" | ||
| 415 | dependencies = [ | ||
| 416 | "idna", | ||
| 417 | "once_cell", | ||
| 418 | "regex", | ||
| 419 | ] | ||
| 420 | |||
| 421 | [[package]] | ||
| 422 | name = "memchr" | ||
| 423 | version = "2.8.0" | ||
| 424 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 425 | checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" | ||
| 426 | |||
| 427 | [[package]] | ||
| 428 | name = "nonmax" | ||
| 429 | version = "0.5.5" | ||
| 430 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 431 | checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" | ||
| 432 | |||
| 433 | [[package]] | ||
| 434 | name = "num-bigint" | ||
| 435 | version = "0.4.6" | ||
| 436 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 437 | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" | ||
| 438 | dependencies = [ | ||
| 439 | "num-integer", | ||
| 440 | "num-traits", | ||
| 441 | ] | ||
| 442 | |||
| 443 | [[package]] | ||
| 444 | name = "num-integer" | ||
| 445 | version = "0.1.46" | ||
| 446 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 447 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" | ||
| 448 | dependencies = [ | ||
| 449 | "num-traits", | ||
| 450 | ] | ||
| 451 | |||
| 452 | [[package]] | ||
| 453 | name = "num-traits" | ||
| 454 | version = "0.2.19" | ||
| 455 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 456 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | ||
| 457 | dependencies = [ | ||
| 458 | "autocfg", | ||
| 459 | ] | ||
| 460 | |||
| 461 | [[package]] | ||
| 462 | name = "object" | ||
| 463 | version = "0.37.3" | ||
| 464 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 465 | checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" | ||
| 466 | dependencies = [ | ||
| 467 | "memchr", | ||
| 468 | ] | ||
| 469 | |||
| 470 | [[package]] | ||
| 471 | name = "once_cell" | ||
| 472 | version = "1.21.3" | ||
| 473 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 474 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" | ||
| 475 | |||
| 476 | [[package]] | ||
| 477 | name = "once_cell_polyfill" | ||
| 478 | version = "1.70.2" | ||
| 479 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 480 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | ||
| 481 | |||
| 482 | [[package]] | ||
| 483 | name = "owo-colors" | ||
| 484 | version = "4.2.3" | ||
| 485 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 486 | checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" | ||
| 487 | |||
| 488 | [[package]] | ||
| 489 | name = "oxc-miette" | ||
| 490 | version = "2.7.0" | ||
| 491 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 492 | checksum = "60a7ba54c704edefead1f44e9ef09c43e5cfae666bdc33516b066011f0e6ebf7" | ||
| 493 | dependencies = [ | ||
| 494 | "cfg-if", | ||
| 495 | "owo-colors", | ||
| 496 | "oxc-miette-derive", | ||
| 497 | "textwrap", | ||
| 498 | "thiserror", | ||
| 499 | "unicode-segmentation", | ||
| 500 | "unicode-width", | ||
| 501 | ] | ||
| 502 | |||
| 503 | [[package]] | ||
| 504 | name = "oxc-miette-derive" | ||
| 505 | version = "2.7.0" | ||
| 506 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 507 | checksum = "d4faecb54d0971f948fbc1918df69b26007e6f279a204793669542e1e8b75eb3" | ||
| 508 | dependencies = [ | ||
| 509 | "proc-macro2", | ||
| 510 | "quote", | ||
| 511 | "syn 2.0.114", | ||
| 512 | ] | ||
| 513 | |||
| 514 | [[package]] | ||
| 515 | name = "oxc_allocator" | ||
| 516 | version = "0.96.0" | ||
| 517 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 518 | checksum = "71ef2dba21be1ce515378b2b7143eaa2a912f9e6ffe162ae20639d56f53d60e3" | ||
| 519 | dependencies = [ | ||
| 520 | "allocator-api2", | ||
| 521 | "bumpalo", | ||
| 522 | "hashbrown", | ||
| 523 | "oxc_data_structures", | ||
| 524 | "rustc-hash", | ||
| 525 | ] | ||
| 526 | |||
| 527 | [[package]] | ||
| 528 | name = "oxc_ast" | ||
| 529 | version = "0.96.0" | ||
| 530 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 531 | checksum = "2fad9195311a1961bb6ef1de0ce6a52147bccea50b5a40423b7b44e8448ed4fc" | ||
| 532 | dependencies = [ | ||
| 533 | "bitflags", | ||
| 534 | "oxc_allocator", | ||
| 535 | "oxc_ast_macros", | ||
| 536 | "oxc_data_structures", | ||
| 537 | "oxc_diagnostics", | ||
| 538 | "oxc_estree", | ||
| 539 | "oxc_regular_expression", | ||
| 540 | "oxc_span", | ||
| 541 | "oxc_syntax", | ||
| 542 | ] | ||
| 543 | |||
| 544 | [[package]] | ||
| 545 | name = "oxc_ast_macros" | ||
| 546 | version = "0.96.0" | ||
| 547 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 548 | checksum = "7f03da6fac191c0817a32ae1a7dde27fd27d98732c61fcaeb55a99a4d543ba49" | ||
| 549 | dependencies = [ | ||
| 550 | "phf", | ||
| 551 | "proc-macro2", | ||
| 552 | "quote", | ||
| 553 | "syn 2.0.114", | ||
| 554 | ] | ||
| 555 | |||
| 556 | [[package]] | ||
| 557 | name = "oxc_data_structures" | ||
| 558 | version = "0.96.0" | ||
| 559 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 560 | checksum = "c5f5171d7b8bc907a1b29e557d14f8478509a2154272d56db9ee8aed6bfe8dec" | ||
| 561 | |||
| 562 | [[package]] | ||
| 563 | name = "oxc_diagnostics" | ||
| 564 | version = "0.96.0" | ||
| 565 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 566 | checksum = "8ef2bf6a713fd27bc65812d695bdfde3f8fcef735f00b861258518346642721b" | ||
| 567 | dependencies = [ | ||
| 568 | "cow-utils", | ||
| 569 | "oxc-miette", | ||
| 570 | "percent-encoding", | ||
| 571 | ] | ||
| 572 | |||
| 573 | [[package]] | ||
| 574 | name = "oxc_ecmascript" | ||
| 575 | version = "0.96.0" | ||
| 576 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 577 | checksum = "f908100cb2759dd2f42ca33d95ea158b8d78e2591b577757729fc9a4a4c63bc3" | ||
| 578 | dependencies = [ | ||
| 579 | "cow-utils", | ||
| 580 | "num-bigint", | ||
| 581 | "num-traits", | ||
| 582 | "oxc_allocator", | ||
| 583 | "oxc_ast", | ||
| 584 | "oxc_span", | ||
| 585 | "oxc_syntax", | ||
| 586 | ] | ||
| 587 | |||
| 588 | [[package]] | ||
| 589 | name = "oxc_estree" | ||
| 590 | version = "0.96.0" | ||
| 591 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 592 | checksum = "5644d3399116ff3f0cfb81f9a790c4b8173b504ed52274ecc757b57f30098ad1" | ||
| 593 | |||
| 594 | [[package]] | ||
| 595 | name = "oxc_index" | ||
| 596 | version = "4.1.0" | ||
| 597 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 598 | checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b" | ||
| 599 | dependencies = [ | ||
| 600 | "nonmax", | ||
| 601 | "serde", | ||
| 602 | ] | ||
| 603 | |||
| 604 | [[package]] | ||
| 605 | name = "oxc_parser" | ||
| 606 | version = "0.96.0" | ||
| 607 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 608 | checksum = "e080498b7a4456a63111f9c65b4dd1b98147955347854b809b6ad4cc5d6a0c0a" | ||
| 609 | dependencies = [ | ||
| 610 | "bitflags", | ||
| 611 | "cow-utils", | ||
| 612 | "memchr", | ||
| 613 | "num-bigint", | ||
| 614 | "num-traits", | ||
| 615 | "oxc_allocator", | ||
| 616 | "oxc_ast", | ||
| 617 | "oxc_data_structures", | ||
| 618 | "oxc_diagnostics", | ||
| 619 | "oxc_ecmascript", | ||
| 620 | "oxc_regular_expression", | ||
| 621 | "oxc_span", | ||
| 622 | "oxc_syntax", | ||
| 623 | "rustc-hash", | ||
| 624 | "seq-macro", | ||
| 625 | ] | ||
| 626 | |||
| 627 | [[package]] | ||
| 628 | name = "oxc_regular_expression" | ||
| 629 | version = "0.96.0" | ||
| 630 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 631 | checksum = "cb87ab0b072e1e97d8101cb1678204bc3873d84f13255ae5aa088f1b85f7a8e1" | ||
| 632 | dependencies = [ | ||
| 633 | "bitflags", | ||
| 634 | "oxc_allocator", | ||
| 635 | "oxc_ast_macros", | ||
| 636 | "oxc_diagnostics", | ||
| 637 | "oxc_span", | ||
| 638 | "phf", | ||
| 639 | "rustc-hash", | ||
| 640 | "unicode-id-start", | ||
| 641 | ] | ||
| 642 | |||
| 643 | [[package]] | ||
| 644 | name = "oxc_span" | ||
| 645 | version = "0.96.0" | ||
| 646 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 647 | checksum = "41422232cfd9915d31dbb76ba2e5ae212884cad232e37203bdcb15bd1466951d" | ||
| 648 | dependencies = [ | ||
| 649 | "compact_str", | ||
| 650 | "oxc-miette", | ||
| 651 | "oxc_allocator", | ||
| 652 | "oxc_ast_macros", | ||
| 653 | "oxc_estree", | ||
| 654 | ] | ||
| 655 | |||
| 656 | [[package]] | ||
| 657 | name = "oxc_syntax" | ||
| 658 | version = "0.96.0" | ||
| 659 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 660 | checksum = "9ea81736f2343df141c7d8de78a91d155be4f712dfa6cd1bdd9a8b4f0676f01f" | ||
| 661 | dependencies = [ | ||
| 662 | "bitflags", | ||
| 663 | "cow-utils", | ||
| 664 | "dragonbox_ecma", | ||
| 665 | "nonmax", | ||
| 666 | "oxc_allocator", | ||
| 667 | "oxc_ast_macros", | ||
| 668 | "oxc_data_structures", | ||
| 669 | "oxc_estree", | ||
| 670 | "oxc_index", | ||
| 671 | "oxc_span", | ||
| 672 | "phf", | ||
| 673 | "unicode-id-start", | ||
| 674 | ] | ||
| 675 | |||
| 676 | [[package]] | ||
| 677 | name = "percent-encoding" | ||
| 678 | version = "2.3.2" | ||
| 679 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 680 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | ||
| 681 | |||
| 682 | [[package]] | ||
| 683 | name = "phf" | ||
| 684 | version = "0.13.1" | ||
| 685 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 686 | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" | ||
| 687 | dependencies = [ | ||
| 688 | "phf_macros", | ||
| 689 | "phf_shared", | ||
| 690 | "serde", | ||
| 691 | ] | ||
| 692 | |||
| 693 | [[package]] | ||
| 694 | name = "phf_generator" | ||
| 695 | version = "0.13.1" | ||
| 696 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 697 | checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" | ||
| 698 | dependencies = [ | ||
| 699 | "fastrand", | ||
| 700 | "phf_shared", | ||
| 701 | ] | ||
| 702 | |||
| 703 | [[package]] | ||
| 704 | name = "phf_macros" | ||
| 705 | version = "0.13.1" | ||
| 706 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 707 | checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" | ||
| 708 | dependencies = [ | ||
| 709 | "phf_generator", | ||
| 710 | "phf_shared", | ||
| 711 | "proc-macro2", | ||
| 712 | "quote", | ||
| 713 | "syn 2.0.114", | ||
| 714 | ] | ||
| 715 | |||
| 716 | [[package]] | ||
| 717 | name = "phf_shared" | ||
| 718 | version = "0.13.1" | ||
| 719 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 720 | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" | ||
| 721 | dependencies = [ | ||
| 722 | "siphasher", | ||
| 723 | ] | ||
| 724 | |||
| 725 | [[package]] | ||
| 726 | name = "proc-macro2" | ||
| 727 | version = "1.0.106" | ||
| 728 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 729 | checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | ||
| 730 | dependencies = [ | ||
| 731 | "unicode-ident", | ||
| 732 | ] | ||
| 733 | |||
| 734 | [[package]] | ||
| 735 | name = "psm" | ||
| 736 | version = "0.1.30" | ||
| 737 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 738 | checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" | ||
| 739 | dependencies = [ | ||
| 740 | "ar_archive_writer", | ||
| 741 | "cc", | ||
| 742 | ] | ||
| 743 | |||
| 744 | [[package]] | ||
| 745 | name = "quote" | ||
| 746 | version = "1.0.44" | ||
| 747 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 748 | checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" | ||
| 749 | dependencies = [ | ||
| 750 | "proc-macro2", | ||
| 751 | ] | ||
| 752 | |||
| 753 | [[package]] | ||
| 754 | name = "readonly" | ||
| 755 | version = "0.2.13" | ||
| 756 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 757 | checksum = "f2a62d85ed81ca5305dc544bd42c8804c5060b78ffa5ad3c64b0fb6a8c13d062" | ||
| 758 | dependencies = [ | ||
| 759 | "proc-macro2", | ||
| 760 | "quote", | ||
| 761 | "syn 2.0.114", | ||
| 762 | ] | ||
| 763 | |||
| 764 | [[package]] | ||
| 765 | name = "regex" | ||
| 766 | version = "1.12.3" | ||
| 767 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 768 | checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" | ||
| 769 | dependencies = [ | ||
| 770 | "aho-corasick", | ||
| 771 | "memchr", | ||
| 772 | "regex-automata", | ||
| 773 | "regex-syntax", | ||
| 774 | ] | ||
| 775 | |||
| 776 | [[package]] | ||
| 777 | name = "regex-automata" | ||
| 778 | version = "0.4.14" | ||
| 779 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 780 | checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" | ||
| 781 | dependencies = [ | ||
| 782 | "aho-corasick", | ||
| 783 | "memchr", | ||
| 784 | "regex-syntax", | ||
| 785 | ] | ||
| 786 | |||
| 787 | [[package]] | ||
| 788 | name = "regex-syntax" | ||
| 789 | version = "0.8.9" | ||
| 790 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 791 | checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" | ||
| 792 | |||
| 793 | [[package]] | ||
| 794 | name = "rustc-hash" | ||
| 795 | version = "2.1.1" | ||
| 796 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 797 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" | ||
| 798 | |||
| 799 | [[package]] | ||
| 800 | name = "rustc_version" | ||
| 801 | version = "0.4.1" | ||
| 802 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 803 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | ||
| 804 | dependencies = [ | ||
| 805 | "semver", | ||
| 806 | ] | ||
| 807 | |||
| 808 | [[package]] | ||
| 809 | name = "rustversion" | ||
| 810 | version = "1.0.22" | ||
| 811 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 812 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | ||
| 813 | |||
| 814 | [[package]] | ||
| 815 | name = "ryu" | ||
| 816 | version = "1.0.23" | ||
| 817 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 818 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 819 | |||
| 820 | [[package]] | ||
| 821 | name = "semver" | ||
| 822 | version = "1.0.27" | ||
| 823 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 824 | checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" | ||
| 825 | |||
| 826 | [[package]] | ||
| 827 | name = "seq-macro" | ||
| 828 | version = "0.3.6" | ||
| 829 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 830 | checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" | ||
| 831 | |||
| 832 | [[package]] | ||
| 833 | name = "serde" | ||
| 834 | version = "1.0.228" | ||
| 835 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 836 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | ||
| 837 | dependencies = [ | ||
| 838 | "serde_core", | ||
| 839 | "serde_derive", | ||
| 840 | ] | ||
| 841 | |||
| 842 | [[package]] | ||
| 843 | name = "serde_core" | ||
| 844 | version = "1.0.228" | ||
| 845 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 846 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | ||
| 847 | dependencies = [ | ||
| 848 | "serde_derive", | ||
| 849 | ] | ||
| 850 | |||
| 851 | [[package]] | ||
| 852 | name = "serde_derive" | ||
| 853 | version = "1.0.228" | ||
| 854 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 855 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | ||
| 856 | dependencies = [ | ||
| 857 | "proc-macro2", | ||
| 858 | "quote", | ||
| 859 | "syn 2.0.114", | ||
| 860 | ] | ||
| 861 | |||
| 862 | [[package]] | ||
| 863 | name = "serde_json" | ||
| 864 | version = "1.0.149" | ||
| 865 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 866 | checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" | ||
| 867 | dependencies = [ | ||
| 868 | "itoa", | ||
| 869 | "memchr", | ||
| 870 | "serde", | ||
| 871 | "serde_core", | ||
| 872 | "zmij", | ||
| 873 | ] | ||
| 874 | |||
| 875 | [[package]] | ||
| 876 | name = "serde_yml" | ||
| 877 | version = "0.0.12" | ||
| 878 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 879 | checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" | ||
| 880 | dependencies = [ | ||
| 881 | "indexmap", | ||
| 882 | "itoa", | ||
| 883 | "libyml", | ||
| 884 | "memchr", | ||
| 885 | "ryu", | ||
| 886 | "serde", | ||
| 887 | "version_check", | ||
| 888 | ] | ||
| 889 | |||
| 890 | [[package]] | ||
| 891 | name = "shlex" | ||
| 892 | version = "1.3.0" | ||
| 893 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 894 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" | ||
| 895 | |||
| 896 | [[package]] | ||
| 897 | name = "siphasher" | ||
| 898 | version = "1.0.2" | ||
| 899 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 900 | checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" | ||
| 901 | |||
| 902 | [[package]] | ||
| 903 | name = "smawk" | ||
| 904 | version = "0.3.2" | ||
| 905 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 906 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" | ||
| 907 | |||
| 908 | [[package]] | ||
| 909 | name = "stacker" | ||
| 910 | version = "0.1.23" | ||
| 911 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 912 | checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" | ||
| 913 | dependencies = [ | ||
| 914 | "cc", | ||
| 915 | "cfg-if", | ||
| 916 | "libc", | ||
| 917 | "psm", | ||
| 918 | "windows-sys 0.59.0", | ||
| 919 | ] | ||
| 920 | |||
| 921 | [[package]] | ||
| 922 | name = "static_assertions" | ||
| 923 | version = "1.1.0" | ||
| 924 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 925 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | ||
| 926 | |||
| 927 | [[package]] | ||
| 928 | name = "streaming-iterator" | ||
| 929 | version = "0.1.9" | ||
| 930 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 931 | checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" | ||
| 932 | |||
| 933 | [[package]] | ||
| 934 | name = "strsim" | ||
| 935 | version = "0.11.1" | ||
| 936 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 937 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | ||
| 938 | |||
| 939 | [[package]] | ||
| 940 | name = "syn" | ||
| 941 | version = "1.0.109" | ||
| 942 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 943 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" | ||
| 944 | dependencies = [ | ||
| 945 | "proc-macro2", | ||
| 946 | "quote", | ||
| 947 | "unicode-ident", | ||
| 948 | ] | ||
| 949 | |||
| 950 | [[package]] | ||
| 951 | name = "syn" | ||
| 952 | version = "2.0.114" | ||
| 953 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 954 | checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" | ||
| 955 | dependencies = [ | ||
| 956 | "proc-macro2", | ||
| 957 | "quote", | ||
| 958 | "unicode-ident", | ||
| 959 | ] | ||
| 960 | |||
| 961 | [[package]] | ||
| 962 | name = "textwrap" | ||
| 963 | version = "0.16.2" | ||
| 964 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 965 | checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" | ||
| 966 | dependencies = [ | ||
| 967 | "smawk", | ||
| 968 | "unicode-linebreak", | ||
| 969 | "unicode-width", | ||
| 970 | ] | ||
| 971 | |||
| 972 | [[package]] | ||
| 973 | name = "thiserror" | ||
| 974 | version = "2.0.18" | ||
| 975 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 976 | checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" | ||
| 977 | dependencies = [ | ||
| 978 | "thiserror-impl", | ||
| 979 | ] | ||
| 980 | |||
| 981 | [[package]] | ||
| 982 | name = "thiserror-impl" | ||
| 983 | version = "2.0.18" | ||
| 984 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 985 | checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" | ||
| 986 | dependencies = [ | ||
| 987 | "proc-macro2", | ||
| 988 | "quote", | ||
| 989 | "syn 2.0.114", | ||
| 990 | ] | ||
| 991 | |||
| 992 | [[package]] | ||
| 993 | name = "tinyvec" | ||
| 994 | version = "1.10.0" | ||
| 995 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 996 | checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" | ||
| 997 | dependencies = [ | ||
| 998 | "tinyvec_macros", | ||
| 999 | ] | ||
| 1000 | |||
| 1001 | [[package]] | ||
| 1002 | name = "tinyvec_macros" | ||
| 1003 | version = "0.1.1" | ||
| 1004 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1005 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | ||
| 1006 | |||
| 1007 | [[package]] | ||
| 1008 | name = "tree-sitter" | ||
| 1009 | version = "0.24.7" | ||
| 1010 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1011 | checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" | ||
| 1012 | dependencies = [ | ||
| 1013 | "cc", | ||
| 1014 | "regex", | ||
| 1015 | "regex-syntax", | ||
| 1016 | "streaming-iterator", | ||
| 1017 | "tree-sitter-language", | ||
| 1018 | ] | ||
| 1019 | |||
| 1020 | [[package]] | ||
| 1021 | name = "tree-sitter-language" | ||
| 1022 | version = "0.1.7" | ||
| 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1024 | checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" | ||
| 1025 | |||
| 1026 | [[package]] | ||
| 1027 | name = "tree-sitter-typescript" | ||
| 1028 | version = "0.23.2" | ||
| 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1030 | checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" | ||
| 1031 | dependencies = [ | ||
| 1032 | "cc", | ||
| 1033 | "tree-sitter-language", | ||
| 1034 | ] | ||
| 1035 | |||
| 1036 | [[package]] | ||
| 1037 | name = "unicode-bidi" | ||
| 1038 | version = "0.3.18" | ||
| 1039 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1040 | checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" | ||
| 1041 | |||
| 1042 | [[package]] | ||
| 1043 | name = "unicode-general-category" | ||
| 1044 | version = "0.6.0" | ||
| 1045 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1046 | checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" | ||
| 1047 | |||
| 1048 | [[package]] | ||
| 1049 | name = "unicode-id-start" | ||
| 1050 | version = "1.4.0" | ||
| 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1052 | checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" | ||
| 1053 | |||
| 1054 | [[package]] | ||
| 1055 | name = "unicode-ident" | ||
| 1056 | version = "1.0.23" | ||
| 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1058 | checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" | ||
| 1059 | |||
| 1060 | [[package]] | ||
| 1061 | name = "unicode-linebreak" | ||
| 1062 | version = "0.1.5" | ||
| 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1064 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" | ||
| 1065 | |||
| 1066 | [[package]] | ||
| 1067 | name = "unicode-normalization" | ||
| 1068 | version = "0.1.25" | ||
| 1069 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1070 | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | ||
| 1071 | dependencies = [ | ||
| 1072 | "tinyvec", | ||
| 1073 | ] | ||
| 1074 | |||
| 1075 | [[package]] | ||
| 1076 | name = "unicode-segmentation" | ||
| 1077 | version = "1.12.0" | ||
| 1078 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1079 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" | ||
| 1080 | |||
| 1081 | [[package]] | ||
| 1082 | name = "unicode-width" | ||
| 1083 | version = "0.2.2" | ||
| 1084 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1085 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | ||
| 1086 | |||
| 1087 | [[package]] | ||
| 1088 | name = "unicode-xid" | ||
| 1089 | version = "0.2.6" | ||
| 1090 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1091 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" | ||
| 1092 | |||
| 1093 | [[package]] | ||
| 1094 | name = "utf8-width" | ||
| 1095 | version = "0.1.8" | ||
| 1096 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1097 | checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" | ||
| 1098 | |||
| 1099 | [[package]] | ||
| 1100 | name = "utf8parse" | ||
| 1101 | version = "0.2.2" | ||
| 1102 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1103 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | ||
| 1104 | |||
| 1105 | [[package]] | ||
| 1106 | name = "version_check" | ||
| 1107 | version = "0.9.5" | ||
| 1108 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1109 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | ||
| 1110 | |||
| 1111 | [[package]] | ||
| 1112 | name = "windows-link" | ||
| 1113 | version = "0.2.1" | ||
| 1114 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1115 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 1116 | |||
| 1117 | [[package]] | ||
| 1118 | name = "windows-sys" | ||
| 1119 | version = "0.59.0" | ||
| 1120 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1121 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" | ||
| 1122 | dependencies = [ | ||
| 1123 | "windows-targets", | ||
| 1124 | ] | ||
| 1125 | |||
| 1126 | [[package]] | ||
| 1127 | name = "windows-sys" | ||
| 1128 | version = "0.61.2" | ||
| 1129 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1130 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 1131 | dependencies = [ | ||
| 1132 | "windows-link", | ||
| 1133 | ] | ||
| 1134 | |||
| 1135 | [[package]] | ||
| 1136 | name = "windows-targets" | ||
| 1137 | version = "0.52.6" | ||
| 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1139 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | ||
| 1140 | dependencies = [ | ||
| 1141 | "windows_aarch64_gnullvm", | ||
| 1142 | "windows_aarch64_msvc", | ||
| 1143 | "windows_i686_gnu", | ||
| 1144 | "windows_i686_gnullvm", | ||
| 1145 | "windows_i686_msvc", | ||
| 1146 | "windows_x86_64_gnu", | ||
| 1147 | "windows_x86_64_gnullvm", | ||
| 1148 | "windows_x86_64_msvc", | ||
| 1149 | ] | ||
| 1150 | |||
| 1151 | [[package]] | ||
| 1152 | name = "windows_aarch64_gnullvm" | ||
| 1153 | version = "0.52.6" | ||
| 1154 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1155 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | ||
| 1156 | |||
| 1157 | [[package]] | ||
| 1158 | name = "windows_aarch64_msvc" | ||
| 1159 | version = "0.52.6" | ||
| 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1161 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | ||
| 1162 | |||
| 1163 | [[package]] | ||
| 1164 | name = "windows_i686_gnu" | ||
| 1165 | version = "0.52.6" | ||
| 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1167 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | ||
| 1168 | |||
| 1169 | [[package]] | ||
| 1170 | name = "windows_i686_gnullvm" | ||
| 1171 | version = "0.52.6" | ||
| 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1173 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | ||
| 1174 | |||
| 1175 | [[package]] | ||
| 1176 | name = "windows_i686_msvc" | ||
| 1177 | version = "0.52.6" | ||
| 1178 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1179 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | ||
| 1180 | |||
| 1181 | [[package]] | ||
| 1182 | name = "windows_x86_64_gnu" | ||
| 1183 | version = "0.52.6" | ||
| 1184 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1185 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | ||
| 1186 | |||
| 1187 | [[package]] | ||
| 1188 | name = "windows_x86_64_gnullvm" | ||
| 1189 | version = "0.52.6" | ||
| 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1191 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | ||
| 1192 | |||
| 1193 | [[package]] | ||
| 1194 | name = "windows_x86_64_msvc" | ||
| 1195 | version = "0.52.6" | ||
| 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1197 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | ||
| 1198 | |||
| 1199 | [[package]] | ||
| 1200 | name = "zmij" | ||
| 1201 | version = "1.0.20" | ||
| 1202 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1203 | checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" | ||
Cargo.toml created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | [package] | ||
| 2 | name = "markodown" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | |||
| 6 | [lib] | ||
| 7 | crate-type = ["cdylib", "rlib"] | ||
| 8 | |||
| 9 | [dependencies] | ||
| 10 | serde = { version = "1.0", features = ["derive"] } | ||
| 11 | clap = { version = "4.5.57", features = ["derive"] } | ||
| 12 | markdown-it = { version = "0.6.1", default-features = false, features = ["linkify"] } | ||
| 13 | oxc_allocator = "0.96.0" | ||
| 14 | oxc_ast = "0.96.0" | ||
| 15 | oxc_diagnostics = "0.96.0" | ||
| 16 | oxc_parser = "0.96.0" | ||
| 17 | oxc_span = "0.96.0" | ||
| 18 | serde_json = "1.0" | ||
| 19 | serde_yml = "0.0.12" | ||
| 20 | tree-sitter = "0.24" | ||
| 21 | tree-sitter-typescript = "0.23" | ||
| 22 | |||
README.md created+159| ... | @@ -0,0 +1,159 @@ | ||
| 1 | # Markodown | ||
| 2 | |||
| 3 | This is a weird markup language that combines features of [Markdown] and | ||
| 4 | [Marko]. You can think of this as an alternative universe to MDX. Since Marko | ||
| 5 | components are really easy to write, it makes this a great tool for writing | ||
| 6 | interactive blog posts. Markdown is compiled directly into `.marko` syntax, | ||
| 7 | leveraging the existing ecosystem. | ||
| 8 | |||
| 9 | [Markdown]: https://en.wikipedia.org/wiki/Markdown | ||
| 10 | [Marko]: https://markojs.com/ | ||
| 11 | |||
| 12 | > STATUS: Functional, but many components have not gone through battle testing. | ||
| 13 | > There are likely edge cases where syntax breaks. Some portions of the overall | ||
| 14 | > glue is generated by AI without full audits. The Marko tag parser is hand | ||
| 15 | > written according to the documentation, but that document is not a | ||
| 16 | > specification and there are likely implementation differences. Treat with | ||
| 17 | > caution. | ||
| 18 | |||
| 19 | ```` | ||
| 20 | --- | ||
| 21 | meta: | ||
| 22 | title: some interesting blog post | ||
| 23 | description: i could be really interesting | ||
| 24 | embed: | ||
| 25 | image: /something/fire.png | ||
| 26 | --- | ||
| 27 | <blog-layout title=meta.title description=meta.description> | ||
| 28 | |||
| 29 | i'm a catgirl and i love to **exist**. meow meow meow | ||
| 30 | |||
| 31 | nyaa!!!!! | ||
| 32 | |||
| 33 | ```ts | ||
| 34 | function yapfest() { | ||
| 35 | return 200; | ||
| 36 | } | ||
| 37 | ``` | ||
| 38 | |||
| 39 | and then the demo of the above: | ||
| 40 | |||
| 41 | <include "./examples/01-first-example.marko" /> | ||
| 42 | |||
| 43 | ## in conclusion | ||
| 44 | |||
| 45 | it's sweet | ||
| 46 | |||
| 47 | </blog-layout> | ||
| 48 | ```` | ||
| 49 | |||
| 50 | ## Syntax Reference | ||
| 51 | |||
| 52 | ### Paragraphs | ||
| 53 | |||
| 54 | All text blocks with spaces around them will be wrapped in a paragraph, like | ||
| 55 | markdown does. | ||
| 56 | |||
| 57 | ``` | ||
| 58 | <div>not wrapped</div> | ||
| 59 | <div> | ||
| 60 | not wrapped | ||
| 61 | </div> | ||
| 62 | |||
| 63 | <div> | ||
| 64 | |||
| 65 | this paragraph gets wrapped in a `<p>` tag | ||
| 66 | |||
| 67 | </div> | ||
| 68 | ``` | ||
| 69 | |||
| 70 | Note that all text is still processed for other block types | ||
| 71 | |||
| 72 | ``` | ||
| 73 | <div>[back to home](/)</div> | ||
| 74 | // all other | ||
| 75 | <nav> | ||
| 76 | - [homepage](/) | ||
| 77 | - [second page](/second) | ||
| 78 | - [third page](/second) | ||
| 79 | </nav> | ||
| 80 | ``` | ||
| 81 | |||
| 82 | ### Components | ||
| 83 | |||
| 84 | All Marko features, such as [tag resolution], [attribute tags], [class | ||
| 85 | shorthands], and template expressions. | ||
| 86 | |||
| 87 | ``` | ||
| 88 | ## cool video | ||
| 89 | |||
| 90 | <clover-video src="/2025/in the summer/in the summer.mp4"> | ||
| 91 | <@header>**music video**: in the summer</> | ||
| 92 | </clover-video> | ||
| 93 | |||
| 94 | <div.footer> | ||
| 95 | (c) ${new Date().getFullYear()} | ||
| 96 | </div> | ||
| 97 | ``` | ||
| 98 | |||
| 99 | [tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags | ||
| 100 | |||
| 101 | ### Comments | ||
| 102 | |||
| 103 | Line, Block, and HTML comments work like they do in Marko/JavaScript. | ||
| 104 | |||
| 105 | ``` | ||
| 106 | # My Blog | ||
| 107 | |||
| 108 | Text that is complete. | ||
| 109 | |||
| 110 | // ## An unfinished section of the blog | ||
| 111 | // | ||
| 112 | // TODO: we gotta add this! | ||
| 113 | ``` | ||
| 114 | |||
| 115 | ### Frontmatter | ||
| 116 | |||
| 117 | All frontmatter fields are converted into exports. For example, a framework that | ||
| 118 | reads the `meta` export for Open Graph can be easily satisfied with frontmatter. | ||
| 119 | |||
| 120 | ``` | ||
| 121 | --- | ||
| 122 | meta: | ||
| 123 | title: I Love Modular Software | ||
| 124 | description: a very cute little post by me | ||
| 125 | author: clover caruso | ||
| 126 | embed: | ||
| 127 | thumbnail: /file/blog.png | ||
| 128 | --- | ||
| 129 | |||
| 130 | // And since `export const` puts the value in scope, this works too: | ||
| 131 | |||
| 132 | # ${meta.title} | ||
| 133 | ``` | ||
| 134 | |||
| 135 | ### Static Statements | ||
| 136 | |||
| 137 | Define module-level functions and variables. | ||
| 138 | |||
| 139 | ``` | ||
| 140 | static function sort(items: string[]) { | ||
| 141 | while (!isSorted()) { | ||
| 142 | shuffle(items); | ||
| 143 | } | ||
| 144 | return items; | ||
| 145 | } | ||
| 146 | ``` | ||
| 147 | |||
| 148 | Note that this means writing a paragraph starting with the lowercase words | ||
| 149 | `static`, `export`, `import`, `server`, and `client` all must be escaped. | ||
| 150 | |||
| 151 | ``` | ||
| 152 | # All About RSC | ||
| 153 | |||
| 154 | \server components are stupid. (markdown backslash) | ||
| 155 | |||
| 156 | ${"server"} components are stupid. (template literal) | ||
| 157 | |||
| 158 | Though you can say import as long as its not the first item. | ||
| 159 | ``` | ||
examples/hoj.mdo created+98| ... | @@ -0,0 +1,98 @@ | ||
| 1 | --- | ||
| 2 | meta: | ||
| 3 | title: history of japan reanimated | ||
| 4 | description: >- | ||
| 5 | 17 artists and musicians come together to remake bill wurtz's | ||
| 6 | legendary "history of japan" to celebrate its 10 year anniversary. | ||
| 7 | embed: | ||
| 8 | thumbnail: /file/2026/history of japan reanimated/thumbnail.jpeg | ||
| 9 | canonical: /history-of-japan-reanimated | ||
| 10 | theme: | ||
| 11 | fg: #fff | ||
| 12 | bg: #442233 | ||
| 13 | primary: #ff8b47 | ||
| 14 | --- | ||
| 15 | import "./history-of-japan-reanimated.css"; | ||
| 16 | client import "./history-of-japan-reanimated.client.ts"; | ||
| 17 | |||
| 18 | <main> | ||
| 19 | <nav>[back to home](/)</nav> | ||
| 20 | |||
| 21 | # history of japan reanimated | ||
| 22 | |||
| 23 | me and 16 other artists (music, narration, and animation) have been spending the | ||
| 24 | past couple months on a recreation of bill wurtz's legendary "[history of japan]" | ||
| 25 | to celebrate the 10 year anniversary of his release. i contributed two animation | ||
| 26 | sections. the canonical release is on [LunaPogi's YouTube page][yt], but i am | ||
| 27 | providing this ad-free video player. | ||
| 28 | |||
| 29 | [history of japan]: https://billwurtz.com/history-of-japan.html | ||
| 30 | [yt]: https://youtu.be/nqsoti-OuRM | ||
| 31 | |||
| 32 | <clover-video#main-player | ||
| 33 | header="history of japan reanimated" | ||
| 34 | file="/2026/history of japan reanimated/history of japan reanimated.mp4" | ||
| 35 | poster="/2026/history of japan reanimated/thumbnail.jpeg" | ||
| 36 | /> | ||
| 37 | |||
| 38 | <div#live-credits /> | ||
| 39 | |||
| 40 | the wonderful souls who worked on the project, in order of appearance. | ||
| 41 | |||
| 42 | <ul> | ||
| 43 | <li><a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">Kappacap</a> - thumbnail artist</li> | ||
| 44 | <li><a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation <span style="color:#B4A7AD">(0:03-1:02)</></li> | ||
| 45 | <li><a href="https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow">moxley</a> - music and narration <span style="color:#B4A7AD">(0:03-3:10)</>, animation <span style="color:#B4A7AD">(3:51-4:03)</></li> | ||
| 46 | <li><a href="https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw">TheJustinator</a> - animation <span style="color:#B4A7AD">(1:03-1:33, 2:02-2:33)</>, music <span style="color:#B4A7AD">(3:11-6:04)</></li> | ||
| 47 | <li><a href="https://chaosyumi.net">chaosyumi</a> - animation <span style="color:#B4A7AD">(1:34-2:01, 5:34-6:05)</></li> | ||
| 48 | <li><a href="https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ">LunaPogi</a> - animation <span style="color:#B4A7AD">(3:36-3:51, 8:01-9:10)</>, music <span style="color:#B4A7AD">(6:05-9:10)</>, narration <span style="color:#B4A7AD">(8:01-9:10)</></li> | ||
| 49 | <li><a href="https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ">zeccet (bgongo)</a> - animation <span style="color:#B4A7AD">(2:23-3:10)</></li> | ||
| 50 | <li><a href="https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg">TheRealTinyWorld</a> - animation <span style="color:#B4A7AD">(3:11-3:35)</></li> | ||
| 51 | <li><a href="https://www.castingcall.club/fredfrenchiii">Fred French VA</a> - narration <span style="color:#B4A7AD">(3:11-4:35)</></li> | ||
| 52 | <li><a href="https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ">rqfirqfo</a> - animation <span style="color:#B4A7AD">(4:04-4:35)</></li> | ||
| 53 | <li><a href="https://www.youtube.com/@krispykarim">krispykarim</a> - animation <span style="color:#B4A7AD">(4:36 - 5:04)</>, narration <span style="color:#B4A7AD">(4:36-8:01)</></li> | ||
| 54 | <li><a href="https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg">SimplyDoodled Studios</a> - animation <span style="color:#B4A7AD">(5:05-5:33)</></li> | ||
| 55 | <li><span style="color:#ff7de9">paper clover (me)</span> - animation <span style="color:#B4A7AD">(6:05-6:38)</>, credits animation <span style="color:#B4A7AD">(9:11-9:35)</></li> | ||
| 56 | <li><a href="https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg">Coffee</a> - transcription <span style="color:#B4A7AD">(6:05-9:10)</></li> | ||
| 57 | <li><a href="https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw">harjjw</a> - animation <span style="color:#B4A7AD">(6:38-7:03)</></li> | ||
| 58 | <li><a href="https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ">yellowmarkers</a> - animation <span style="color:#B4A7AD">(7:04-7:36)</></li> | ||
| 59 | <li><a href="https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg">TheNothingGuy07</a> - animation <span style="color:#B4A7AD">(7:37-7:59)</></li> | ||
| 60 | </ul> | ||
| 61 | |||
| 62 | the music during the credits is an instrumental by bill wurtz: [hey mom i beat the scale!](https://billwurtz.com/hey-mom-i-beat-the-scale.mp3) | ||
| 63 | |||
| 64 | ## what's next | ||
| 65 | |||
| 66 | from the success of this project, a much more ambitous project is being planned: | ||
| 67 | the reanimation for 'history of the entire world, i guess'. i am joining the | ||
| 68 | project as one of the co-hosts. we are currently doing initial planning, all | ||
| 69 | artists (music, visual, narration) interested should fill out the [interest form]. | ||
| 70 | by submitting the form, i'll be able to reach out when it's actually time to | ||
| 71 | start cooking. you can also join the project's [public discord]. | ||
| 72 | |||
| 73 | [interest form]: https://paperclover.net/hotewig-reanimated | ||
| 74 | [public discord]: https://discord.gg/37axTSuGXd | ||
| 75 | |||
| 76 | ## behind the scenes | ||
| 77 | |||
| 78 | here is a 55 minute dive into my process, as well as nearly every little trick i | ||
| 79 | used on this project. additionally, for one of the first times from me, a free | ||
| 80 | download for [all of the project files][files]. | ||
| 81 | |||
| 82 | <clover-video file="/2026/history of japan reanimated/behind-the-scenes.mp4"> | ||
| 83 | <@header>behind the scenes session with clover</> | ||
| 84 | </> | ||
| 85 | |||
| 86 | [files]: /file/2026/history%20of%20japan%20reanimated/project%20files/history-of-japan-reanimated.zip?dl | ||
| 87 | |||
| 88 | ## more links | ||
| 89 | |||
| 90 | <p> | ||
| 91 | <a href="/q+a">paper clover q&a page</a><br/> | ||
| 92 | <a href="/subscribe">paper clover's mailing list</a> | ||
| 93 | </p> | ||
| 94 | |||
| 95 | ## mentions on the q&a | ||
| 96 | <questions-embed="history-of-japan-reanimated" /> | ||
| 97 | |||
| 98 | </main> | ||
examples/next-js.mdo created+750| ... | @@ -0,0 +1,750 @@ | ||
| 1 | --- | ||
| 2 | meta: | ||
| 3 | title: One Year with Next.js App Router — Why We're Moving On | ||
| 4 | description: A critique of React Server Components and Next.js 15. | ||
| 5 | keywords: ["webdev", "technical analysis", "opinion"] | ||
| 6 | authors: ["clover caruso"] | ||
| 7 | embed: | ||
| 8 | thumbnail: /open-graph/next-js.png | ||
| 9 | canonical: /blog/webdev/one-year-next-app-router | ||
| 10 | --- | ||
| 11 | export { theme } from "@/blog/tags/layout.tsx"; | ||
| 12 | |||
| 13 | <blog-layout | ||
| 14 | meta=meta | ||
| 15 | date="Oct 21st, 2025" | ||
| 16 | slug="webdev/one-year-next-app-router" | ||
| 17 | > | ||
| 18 | |||
| 19 | As I've been using [Next.js] professionally on my employer's web app, I find the | ||
| 20 | core design of their App Router and [React Server Components] (RSC) to be | ||
| 21 | extremely frustrating. And it's not small bugs or that the API is confusing, | ||
| 22 | but large disagreements about the fundamental design decisions that Vercel and | ||
| 23 | the React team made when building it. | ||
| 24 | |||
| 25 | The more webdev events I go to, the more I see people who dislike Next.js, but | ||
| 26 | still get stuck using it. By the end of this article, I will share how me and | ||
| 27 | my colleagues escaped this hell, seamlessly migrating our entire frontend to | ||
| 28 | [TanStack Start]. | ||
| 29 | |||
| 30 | [Next.js]: https://nextjs.org | ||
| 31 | [React Server Components]: https://react.dev/reference/rsc/server-components | ||
| 32 | |||
| 33 | <table-of-contents /> | ||
| 34 | |||
| 35 | <h2#technical-review>A Technical Review: What are Server Components?</> | ||
| 36 | |||
| 37 | The pitch of RSC is that components are put into two categories, | ||
| 38 | <b.server>"server"</b> components and <b.client>"client"</b> | ||
| 39 | components. Server components don't have `useState`, `useEffect`, but can be | ||
| 40 | `async function`s and refer to backend tools like directly calling into a | ||
| 41 | database. Client components are the existing | ||
| 42 | model, where there is code on the backend to generate HTML text and frontend | ||
| 43 | code to manage the DOM using `window.document.*`. | ||
| 44 | |||
| 45 | > The first disaster: naming!! React is now using the wors | ||
| 46 | > <b.server>"server"</b> and <b.client>"client"</b> to refer to | ||
| 47 | > a very specific things, ignoring their existing definitions. This would be | ||
| 48 | > fine, except <b.client>Client</b> components can run on the backend | ||
| 49 | > too! In this article, I'll be using the terms <b>"backend"</b> and | ||
| 50 | > <b>"frontend"</b> to describe the two execution environments that web apps | ||
| 51 | > exist in: a Node.js process and a Web browser, respectively. | ||
| 52 | |||
| 53 | This <b.server>Server</b>/<b.client>Client</b> component model | ||
| 54 | is interesting. Since built-ins like `<Suspense />` get serialized across the | ||
| 55 | network, data fetching can be very trivially modeled with async | ||
| 56 | <b.server>server components</b>, and the fallback UI works as if it were | ||
| 57 | client-side. | ||
| 58 | |||
| 59 | ```tsx filename="src/app/[username]/page.tsx" tint="server" | ||
| 60 | // For this article, server components will be highlighted in red | ||
| 61 | export default async function Page({ params }) { | ||
| 62 | // Page params are given as a resolved promise | ||
| 63 | const { username } = await params; | ||
| 64 | |||
| 65 | // The components `UserInfo` and `UserPostList` will be run at the same | ||
| 66 | // time. Once `UserInfo` is ready, the visitor will see the page with a | ||
| 67 | // `PostListSkeleton` if the post list is not yet ready. | ||
| 68 | return <main> | ||
| 69 | <UserInfo username={username} /> | ||
| 70 | |||
| 71 | <Suspense fallback={<PostListSkeleton />}> | ||
| 72 | <UserPostList username={username} /> | ||
| 73 | </Suspense> | ||
| 74 | </main> | ||
| 75 | } | ||
| 76 | |||
| 77 | // Waterfalls are avoided by having multiple components, which | ||
| 78 | // are all evaluated at the same time. | ||
| 79 | |||
| 80 | async function UserInfo({ username }) { | ||
| 81 | const user = await fetchUserInfo(username); | ||
| 82 | return <> | ||
| 83 | <h1>{user.displayName}</h1> | ||
| 84 | {user.bio ? <Markdown content={user.bio} /> : ""} | ||
| 85 | </> | ||
| 86 | } | ||
| 87 | |||
| 88 | async function UserPostList({ username }) { | ||
| 89 | const posts = await fetchUserPostList(username); | ||
| 90 | return /* post list ui omitted for brevity */; | ||
| 91 | } | ||
| 92 | ``` | ||
| 93 | |||
| 94 | If we ignore the 40kB gzipped bundle size of React itself, the above example | ||
| 95 | has zero JavaScript for the UI and data fetching &mdash; it just streams the | ||
| 96 | markup! For example, the imagined markdown parser within the `<Markdown />` | ||
| 97 | component stays on the backend. When an interactive frontend is needed, <b.client>Client | ||
| 98 | components</b> can be created by putting them in a file starting with `"use | ||
| 99 | client"`. | ||
| 100 | |||
| 101 | ```tsx filename="src/components/CopyButton.tsx" tint="client" | ||
| 102 | "use client"; // This comment marks the file for client-side bundling. | ||
| 103 | |||
| 104 | export function CopyButton({ url }) { | ||
| 105 | return <> | ||
| 106 | <span>{url}</span> | ||
| 107 | <button onClick={() => { | ||
| 108 | const full = new URL(url, location.href); | ||
| 109 | navigator.clipboard.writeText(full.href); | ||
| 110 | // omitting error handling, success ui, styles | ||
| 111 | }}>copy</button> | ||
| 112 | </> | ||
| 113 | } | ||
| 114 | ``` | ||
| 115 | ```tsx filename="src/app/q+a/Card.tsx" tint="server" | ||
| 116 | export function Card() { | ||
| 117 | return <article> | ||
| 118 | <header> | ||
| 119 | {/* Make the browser import the copy button */} | ||
| 120 | <CopyButton url="/q+a/2506010139" /> | ||
| 121 | </header> | ||
| 122 | <p> | ||
| 123 | {/* Process markdown on the backend */} | ||
| 124 | <Markdown content=".........." /> | ||
| 125 | </p> | ||
| 126 | </article> | ||
| 127 | } | ||
| 128 | ``` | ||
| 129 | |||
| 130 | <h2 id="real-world-pitfalls">Real-world Pitfalls of the App Router</> | ||
| 131 | |||
| 132 | After quitting [Bun] as a runtime engineer (I implemented [Server Components | ||
| 133 | bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the | ||
| 134 | front lines: a Next.js app with a Hono backend. The following notes are | ||
| 135 | simplifications from the real world problems I've encountered when trying to | ||
| 136 | maintain and develop new features. As a result of all of these, everyone's time | ||
| 137 | is wasted either working around design flaws, or explaining to each other why | ||
| 138 | what should be a non-issue is an immovable object. | ||
| 139 | |||
| 140 | [Bun]: https://bun.com | ||
| 141 | [Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts | ||
| 142 | [bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react | ||
| 143 | |||
| 144 | <h3 id='optimistic-updates'>Optimistic Updates are Impossible</> | ||
| 145 | |||
| 146 | The Next.js documentation for performing mutations [does not mention optimistic | ||
| 147 | updates][nextjs-updating-data]; it appears this case was not thought about. | ||
| 148 | Components rendered by the <b.server>React Server</b>, by design, can | ||
| 149 | not be modified after mounting. Elements that could change need to be inside a | ||
| 150 | client component, but data fetching cannot happen on the client components, | ||
| 151 | even during SSR on the backend. This results in awkwardly small server | ||
| 152 | components that only do data fetching and then have a client component that | ||
| 153 | contains a mostly-static version of the page. | ||
| 154 | |||
| 155 | ```tsx filename="src/app/user/[username]/page.tsx" tint="server" | ||
| 156 | |||
| 157 | export default async function Page() { | ||
| 158 | const user = await fetchUserInfo(username); | ||
| 159 | return <ProfileLayout> | ||
| 160 | <UserProfile user={user} /> | ||
| 161 | </ProfileLayout>; | ||
| 162 | } | ||
| 163 | ``` | ||
| 164 | ```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client" | ||
| 165 | |||
| 166 | "use client"; // Must separate the client code into a second file! | ||
| 167 | |||
| 168 | export function UserProfile({ user: initialUser }) { | ||
| 169 | // There are many great state management libraries out there; | ||
| 170 | // for simplicity, this example will use one state cell. | ||
| 171 | const [user, optimisticUpdateUser] = useState(initialUser); | ||
| 172 | |||
| 173 | async function onEdit(newUser) { | ||
| 174 | optimisticUpdateUser(newUser); | ||
| 175 | const resp = await fetch("...", { | ||
| 176 | method: 'POST', | ||
| 177 | body: JSON.stringify(newUser), | ||
| 178 | ... // (headers, credentials, tracing, and more) | ||
| 179 | }) | ||
| 180 | if (!resp.ok) /* always remember to test for errors! */ | ||
| 181 | } | ||
| 182 | |||
| 183 | return <main>{/* user interface with editable fields... */}</main>: | ||
| 184 | } | ||
| 185 | ``` | ||
| 186 | |||
| 187 | As more of the page needs interactivity, it gets messier trying to keep the | ||
| 188 | static parts truly server-side. On the work app, nearly every piece of UI | ||
| 189 | displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it | ||
| 190 | updates (for example, a user card's online state along with their basic | ||
| 191 | profile). Since these component setups are harder to understand and maintain | ||
| 192 | for engineers, almost all of our pages are entirely `"use client"` with a | ||
| 193 | `page.tsx` that defines the data fetching. | ||
| 194 | |||
| 195 | A more concrete example of what this looks like in practice with the | ||
| 196 | data-fetching library we use at work, [TanStack Query]. | ||
| 197 | |||
| 198 | [TanStack Query]: https://github.com/tanstack/query#readme | ||
| 199 | |||
| 200 | ```ts filename="src/queries/users.ts" | ||
| 201 | // At work, there is a helper function `defineQuery` for type safety. | ||
| 202 | // Fetchers are trivial and can run on the backend or the frontend. | ||
| 203 | export const queryUserInfo = (username) => ({ | ||
| 204 | queryKey: ['user', username], | ||
| 205 | queryFn: async ({ ... }) => /* fetch data */ | ||
| 206 | }); | ||
| 207 | ``` | ||
| 208 | ```tsx filename="src/app/user/[username]/page.tsx" tint="server" | ||
| 209 | export default async function Page({ params }) { | ||
| 210 | const { username } = await params; | ||
| 211 | |||
| 212 | // There's no global state in the React Server. Since layouts | ||
| 213 | // are executed in parallel, the TanStack `QueryClient` has to | ||
| 214 | // be reconstructed multiple times per route. | ||
| 215 | const queryClient = new QueryClient(); | ||
| 216 | await queryClient.ensureQueryData(queryUserInfo(username)); | ||
| 217 | |||
| 218 | // HydrationBoundary is a client component that passes JSON | ||
| 219 | // data from the React server to the client component. | ||
| 220 | return <HydrationBoundary state={dehydrate(queryClient)}> | ||
| 221 | <ClientPage /> | ||
| 222 | </HydrationBoundary>; | ||
| 223 | } | ||
| 224 | ``` | ||
| 225 | ```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client" | ||
| 226 | "use client"; | ||
| 227 | export function ClientPage() { | ||
| 228 | const { username } = useParams(); | ||
| 229 | const { data: user } = useSuspenseQuery(queryUserInfo(username)); | ||
| 230 | |||
| 231 | // ... some hooks | ||
| 232 | |||
| 233 | return <main> | ||
| 234 | {/* ... an interactive web page */} | ||
| 235 | </main>; | ||
| 236 | } | ||
| 237 | ``` | ||
| 238 | |||
| 239 | This example has to be three separate files because of the rules of server | ||
| 240 | component bundling. (The client component needs `"use client"`, and server | ||
| 241 | component files often can't be imported on the client due to server-only | ||
| 242 | imports.). In the Pages router, this could've been a single file because of the | ||
| 243 | tree-shaking that `getStaticProps` and `getServerSideProps` has. | ||
| 244 | |||
| 245 | [ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API | ||
| 246 | [nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data | ||
| 247 | |||
| 248 | <h3 id='redundant-fetches'>Every Navigation is Another Fetch</> | ||
| 249 | |||
| 250 | Since the App Router starts every page as a server component, with (ideally) | ||
| 251 | small areas of interactivity, a navigation to a new page *has* to fetch the | ||
| 252 | Next.js server, regardless of what data the client already has available! Even | ||
| 253 | with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then | ||
| 254 | going back to `/` will show the loading state while it re-fetches the homepage. | ||
| 255 | |||
| 256 | The only case this works is for **perfectly static content**, where instant | ||
| 257 | navigations and prefetching work great. But **web apps are not static**, they | ||
| 258 | have lots of dynamic content. Being logged in affects the homepage, which is | ||
| 259 | infuriating because the client literally has everything needed to display the | ||
| 260 | page instantly. It's not like the cookies changed. | ||
| 261 | |||
| 262 | > **aside**: In further testing on a blank project, I observe cases where the | ||
| 263 | > Next frontend code would pre-fetch routes, but **without any real contents**. | ||
| 264 | > On the hello world example, this was a 1.8kB RSC payload that pointed to 2 | ||
| 265 | > different JS chunks 4 separate times. This is just pure waste of our | ||
| 266 | > bandwidth and egress, especially considering all of this information is | ||
| 267 | > re-fetched when I actually click the link. | ||
| 268 | > | ||
| 269 | > ```json whitespace="pre-wrap" | ||
| 270 | > 1:"$Sreact.fragment" | ||
| 271 | > 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] | ||
| 272 | > 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] | ||
| 273 | > 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"] | ||
| 274 | > 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"] | ||
| 275 | > 7:"$Sreact.suspense" | ||
| 276 | > 0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false} | ||
| 277 | > 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] | ||
| 278 | > 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"] | ||
| 279 | > 8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]] | ||
| 280 | > ``` | ||
| 281 | > | ||
| 282 | > In review, I found there is actually some content in here: the loading state. | ||
| 283 | > Do you see it? | ||
| 284 | > | ||
| 285 | > ```json | ||
| 286 | > ["$","div","l",{"children":"loading..."}] | ||
| 287 | > ``` | ||
| 288 | > | ||
| 289 | > It's still a lot of waste, since all of this data gets re-emitted in the | ||
| 290 | > actual page RSC. | ||
| 291 | |||
| 292 | The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked | ||
| 293 | experimental and "not recommended for production". The fact this is a | ||
| 294 | non-default afterthought configuration option is embarrassing. Even if we used | ||
| 295 | it, you cannot make multiple pages that refer to the same underlying data share | ||
| 296 | any of it. | ||
| 297 | |||
| 298 | One form of loading state that cannot be represented with the App Router is | ||
| 299 | having a page such as a page like a git project's issue page, and clicking on a | ||
| 300 | user name to navigate to their profile page. With `loading.tsx`, the entire | ||
| 301 | page is a skeleton, but when modeling these queries with TanStack Query it is | ||
| 302 | possible to show the username and avatar instantly while the user's bio and | ||
| 303 | repositories are fetched in. Server components don't support this form of | ||
| 304 | navigation because the data is only available in rendered components, so it | ||
| 305 | must be re-fetched. | ||
| 306 | |||
| 307 | [nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes | ||
| 308 | [nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661 | ||
| 309 | |||
| 310 | In our Next.js site, we have this line of code on our server component data | ||
| 311 | fetchers to make soft navigations faster by skipping the data fetch phase all | ||
| 312 | together. | ||
| 313 | |||
| 314 | ```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server" | ||
| 315 | export function serverSidePrefetchQueries(queries) { | ||
| 316 | if ((await headers()).get("next-url")) { | ||
| 317 | // This is a soft-navigation. SKIP the prefetching to make it faster. | ||
| 318 | // The client might already have this data, and if not, they have the | ||
| 319 | // loading state. Ideally, this server request wouldn't exist -- The | ||
| 320 | // client side has nearly ALL the code since the app is written mostly | ||
| 321 | // as client components. Kind of a design flaw of the App router TBH. | ||
| 322 | return; | ||
| 323 | } | ||
| 324 | // ... data prefetching-logic ... | ||
| 325 | } | ||
| 326 | ``` | ||
| 327 | |||
| 328 | In addition to this, `loading.tsx` should contain the `useQuery` calls so that | ||
| 329 | while the network request for the empty RSC happens, the data is being fetched | ||
| 330 | if it actually is needed. In fact, the `loading.tsx` state can just be the | ||
| 331 | actual client component, and you'll see the client page. | ||
| 332 | |||
| 333 | ```tsx filename="src/app/user/[username]/loading.tsx" tint="client" | ||
| 334 | "use client"; | ||
| 335 | export default function PageLoadingSkeleton() { | ||
| 336 | return <ClientPage />; | ||
| 337 | } | ||
| 338 | ``` | ||
| 339 | |||
| 340 | > At work, we just make our `loading.tsx` files contain the `useQuery` | ||
| 341 | > calls and show a skeleton. This is because when Next.js loads the actual Server | ||
| 342 | > Component, no matter what, the entire page re-mounts. No VDOM diffing here, | ||
| 343 | > meaning all hooks (`useState`) will reset slightly after the request | ||
| 344 | > completes. I tried to reproduce a simple case where I was *begging* Next.js to | ||
| 345 | > just *update the existing DOM* and preserve state, but it just doesn't. | ||
| 346 | > Thankfully, the time the blank RSC call takes is short enough. | ||
| 347 | |||
| 348 | <h3 id='layout-restrictions'>Layouts are Artificially Restricted</> | ||
| 349 | |||
| 350 | Layouts can perform data fetching, but they can't observe or alter the request | ||
| 351 | in any way. This is done so that Next.js can fetch and cache layouts whenever they | ||
| 352 | want. In every other framework, layouts are just regular components that have | ||
| 353 | no feature difference compared to page components. | ||
| 354 | |||
| 355 | Fetching layouts in isolation is a cute idea, but it ends up being silly | ||
| 356 | because it also means that any data fetching has to be re-done per layout. You | ||
| 357 | can't share a `QueryClient`; instead, you must rely on their [monkey-patched | ||
| 358 | `fetch`][nextjs-fetch] to cache the same `GET` request like they promise. | ||
| 359 | |||
| 360 | When a coworker asks me about why Next.js rejects some code, I've given up on | ||
| 361 | explaining the technical intricacies and just say *"It's a Next.js Skill Issue, | ||
| 362 | I'm going to blow it up soon don't worry."* These rules are too hard for normal | ||
| 363 | developers to understand. | ||
| 364 | |||
| 365 | [nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch | ||
| 366 | |||
| 367 | <h3 id='rsc-payload'>You Still Download All the Content Twice</> | ||
| 368 | |||
| 369 | Unlike the ["Islands Architecture"][islands], Server Components still have to | ||
| 370 | be hydrated on the frontend to support `Suspense` and preserving client | ||
| 371 | component state. When doing soft navigations, the "RSC Payload" (which is not | ||
| 372 | HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the | ||
| 373 | [first paint], but the information about Client components and `Suspense` is | ||
| 374 | not contained within that HTML. React's solution is to **send a second copy of | ||
| 375 | the entire page's markup**. An example of what a Next.js production server | ||
| 376 | would send in a dynamic page render would be something like this: | ||
| 377 | |||
| 378 | [first paint]: https://web.dev/articles/fcp | ||
| 379 | |||
| 380 | ```html filename="GET /user/clover" | ||
| 381 | <!DOCTYPE html> | ||
| 382 | <html> | ||
| 383 | <head> | ||
| 384 | {link and meta tags} | ||
| 385 | </head> | ||
| 386 | <body> | ||
| 387 | {server side render} | ||
| 388 | <script> | ||
| 389 | // a bootstrap script that sets up global `__next_f` as | ||
| 390 | // an array. once React loads, this `.push` function | ||
| 391 | // gets overwritten to write new chunks directly to the | ||
| 392 | // RSC decoder. this script has some dom helpers too | ||
| 393 | (self.__next_f=self.__next_f||[]).push([0]) | ||
| 394 | </script> | ||
| 395 | <script> | ||
| 396 | // the RSC payload for the application shell. | ||
| 397 | self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"]) | ||
| 398 | </script> | ||
| 399 | |||
| 400 | <!-- | ||
| 401 | the closing </body> is NOT written yet, since there is a | ||
| 402 | suspense boundary not resolved. time passes, and only | ||
| 403 | then is more data is written | ||
| 404 | --> | ||
| 405 | <div class="user-post-list"> | ||
| 406 | {server side render of a Suspense boundary} | ||
| 407 | </div> | ||
| 408 | <script> | ||
| 409 | // the RSC payload for the suspense boundary | ||
| 410 | self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"]) | ||
| 411 | </script> | ||
| 412 | |||
| 413 | <!-- HTML and script tags repeat until the entire page is done --> | ||
| 414 | </body> | ||
| 415 | </html> | ||
| 416 | ``` | ||
| 417 | |||
| 418 | This solution **doubles the size of the initial HTML payload**. Except it's | ||
| 419 | worse, because the RSC payload includes JSON quoted in JS string literals, | ||
| 420 | which is a is much less efficient format than HTML. While it seems to compress | ||
| 421 | fine with brotli and render fast in the browser, this is wasteful. With the | ||
| 422 | hydration pattern, at least the data locally could be re-used for interactivity | ||
| 423 | and other pages. | ||
| 424 | |||
| 425 | Even on pages that have little to no interactivity, you pay the cost. To use | ||
| 426 | the Next.js documentation as an example, loading [its | ||
| 427 | homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of | ||
| 428 | HTML and the 500kB of script tags), and content is in there twice. | ||
| 429 | |||
| 430 | You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd> | ||
| 431 | on Mac or <kbd>Ctrl</kbd> + <kbd>u</kbd> on other platforms. And then | ||
| 432 | <kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd> to locate any string of the | ||
| 433 | blog, such as "building full-stack web applications". It's there twice. And | ||
| 434 | **there is no way around this**, since it's a fundamental piece of React Server | ||
| 435 | Components. | ||
| 436 | |||
| 437 | This RSC format certainly has more waste. But I really don't feel like digging into | ||
| 438 | why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate | ||
| 439 | times. What the hell, guys? Is your bandwidth free??? | ||
| 440 | |||
| 441 | [islands]: https://www.patterns.dev/vanilla/islands-architecture/ | ||
| 442 | |||
| 443 | <h3 id='turbopack'>Turbopack Sucks</> | ||
| 444 | |||
| 445 | This section is not constructive. | ||
| 446 | |||
| 447 | - Turbopack isn't fast | ||
| 448 | - Turbopack emits code that is hard to debug in a debugger (in development mode) | ||
| 449 | - Turbopack throws bad error messages in many cases | ||
| 450 | |||
| 451 | I wouldn't have given this point a section in the blog normally, but I want to | ||
| 452 | point out three actual examples directly from the project. | ||
| 453 | |||
| 454 | The first is a place where during some refactoring to satisfy the Server/Client | ||
| 455 | component models, I accidentally made a Client component `async`. This one was | ||
| 456 | quite annoying because it didn't say at all where the issue was, but only | ||
| 457 | contained the <b.server>server</b> stack trace. | ||
| 458 | |||
| 459 |  | ||
| 460 | |||
| 461 | Another case of a terrible error message: | ||
| 462 | |||
| 463 |  | ||
| 464 | |||
| 465 | > After fixing the underlying issue in this second error (which I cannot recall), | ||
| 466 | > the Dev server hung and had to be restarted to recover. | ||
| 467 | |||
| 468 | The final one is the dozen times I place a debugger breakpoint and the | ||
| 469 | variable name `hello` gets turned into | ||
| 470 | `__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]` | ||
| 471 | and other bullshit. | ||
| 472 | |||
| 473 | Okay. This all sucks. What can we do? | ||
| 474 | |||
| 475 | <h2 id='ditching-nextjs'>Seamlessly Ditching Next.js and Vercel at Work</> | ||
| 476 | |||
| 477 | There are two types of web projects: | ||
| 478 | |||
| 479 | - A web site with mostly static content. | ||
| 480 | - A web app with majorly dynamic and interactive components. | ||
| 481 | |||
| 482 | And Next.js is the wrong tool for both of these jobs. If you're in the first | ||
| 483 | category with a static web site, go for [Astro] or [Fresh]. For everyone who | ||
| 484 | needs the full power of React, this section is about how I replaced the vendor | ||
| 485 | locked Next with [TanStack Start], incrementally and seamlessly. | ||
| 486 | |||
| 487 | [Astro]: https://astro.build/ | ||
| 488 | [Fresh]: https://fresh.deno.dev/ | ||
| 489 | [TanStack Start]: https://tanstack.com/start/latest | ||
| 490 | |||
| 491 | It started with this Vite config. | ||
| 492 | |||
| 493 | ```ts filename="vite.config.ts" | ||
| 494 | const config = defineConfig(({ mode }) => { | ||
| 495 | const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_"); | ||
| 496 | return { | ||
| 497 | // Use the Next.js default port 3000 | ||
| 498 | server: { port: 3000 }, | ||
| 499 | // Use the Next.js default env prefix "NEXT_PUBLIC_" | ||
| 500 | define: Object.fromEntries(Object.entries(env).map( | ||
| 501 | ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])), | ||
| 502 | plugins: [ | ||
| 503 | viteTsConfigPaths({ projects: ["./tsconfig.json"] }), | ||
| 504 | tailwindcss(), | ||
| 505 | // For ease of understanding from coworkers, I started porting | ||
| 506 | // the routes in `src/tanstack-routes`. When the migration was | ||
| 507 | // done, it would go back to the default `src/routes`. | ||
| 508 | tanstackStart({ | ||
| 509 | router: { routesDirectory: "src/tanstack-routes" }, | ||
| 510 | }), | ||
| 511 | viteReact(), | ||
| 512 | ], | ||
| 513 | resolve: { | ||
| 514 | // The key to the incremental migration: redirect `next` elsewhere | ||
| 515 | alias: { next: path.resolve("./src/tanstack-next/") }, | ||
| 516 | conditions: ["tanstack"], | ||
| 517 | extensions: [ | ||
| 518 | // Allow a file named like `utils/session.tanstack.ts` to | ||
| 519 | // override `utils/session.ts` when imported. | ||
| 520 | ".tanstack.tsx", ".tanstack.ts", | ||
| 521 | // Default import extensions | ||
| 522 | ".mjs", ".js", ".mts", ".ts", | ||
| 523 | ".jsx", ".tsx", ".json", | ||
| 524 | ], | ||
| 525 | }, | ||
| 526 | }; | ||
| 527 | }); | ||
| 528 | ``` | ||
| 529 | |||
| 530 | Then, I looked for every usage of a Next.js API, and either removed it or made | ||
| 531 | a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements | ||
| 532 | `next/link`: | ||
| 533 | |||
| 534 | ```tsx filename="src/tanstack-next/link.tsx" | ||
| 535 | import { Link } from "@tanstack/react-router"; | ||
| 536 | import type { LinkProps } from "next/link"; | ||
| 537 | |||
| 538 | export default function LinkAdapter({ href, ...rest }: LinkProps) { | ||
| 539 | return <Link {...rest} to={href as unknown as any} />; | ||
| 540 | } | ||
| 541 | ``` | ||
| 542 | |||
| 543 | > Some of these stubs can be extremely simple. Starting out, my implementation | ||
| 544 | > of `useRouter` was just `return {}`, but later I had to add a couple methods | ||
| 545 | > to the object. The code here doesn't have to be clean, because it is | ||
| 546 | > temporary. | ||
| 547 | |||
| 548 | Now, the new site can import nearly every client component by either stubbing | ||
| 549 | out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to | ||
| 550 | re-implement logic on a file-by-file basis. And shortly after, I got the site's | ||
| 551 | homepage to work in TanStack Start, and we merged the branch. | ||
| 552 | |||
| 553 |  | ||
| 554 | |||
| 555 | > This first PR only supported one of our pages, and was able to do it in a | ||
| 556 | > thousand lines of added code, and 40 lines deleted. I had previous patches to | ||
| 557 | > remove the few uses of `next/image` and `next/font`. | ||
| 558 | |||
| 559 | What was left was porting every other route over. The one thing we lose in | ||
| 560 | migrating from Next.js to any other framework is the ability to `await` | ||
| 561 | data-fetching functions in the UI. In practice, moving every route into a | ||
| 562 | `loader` function made it much more clear what happened when a page was SSR'd. | ||
| 563 | For pages that had multiple fetches, these could be combined into a single, | ||
| 564 | special API call that would return all of the relevant data for that page. | ||
| 565 | |||
| 566 | To re-iterate in bold font: <strong style='color:var(--secondary)'>The | ||
| 567 | migration path from Server Components is to just simplify your code &mdash; RSC | ||
| 568 | inherently drives you down a chaotic road of things you do not need</strong>. | ||
| 569 | Nearly every complex part of our site got easier to understand for all | ||
| 570 | engineers. The exception to this was having everyone get used to the new file | ||
| 571 | system routing conventions. With enough examples, we all got the hang of it. | ||
| 572 | |||
| 573 | With the incremental migration in place, new code did not break the existing | ||
| 574 | deployment. TanStack slowly took over the codebase, and we eventually deleted | ||
| 575 | all of the Next.js stubs and gained all of the beautiful [type-safety features] | ||
| 576 | that the TanStack Router provides. At the end, the site performed faster from | ||
| 577 | every angle: Development Mode, Production page load times, Soft navigations, | ||
| 578 | and at a lower price than our Next depoyment with Vercel. | ||
| 579 | |||
| 580 | [type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety | ||
| 581 | |||
| 582 | We're not the only ones seeing the change. While I try and keep myself off of | ||
| 583 | social media, someone sent me [the results of Brian Anglin's work at | ||
| 584 | Superwall][superwall-twitter], showing incredible CPU reductions on TanStack | ||
| 585 | Start. I also recall ChatGPT switching from Next.js to Remix (random online | ||
| 586 | chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago. | ||
| 587 | |||
| 588 | [superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m | ||
| 589 | [chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233 | ||
| 590 | [chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix | ||
| 591 | [chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix | ||
| 592 | |||
| 593 | <h3 id='next-metadata'>`next/metadata` is Great</> | ||
| 594 | |||
| 595 | In my opinion, this is one of the only good APIs Next.js has, and was the one | ||
| 596 | place in our code where moving to TanStack made things harder to do. Instead of | ||
| 597 | worsening the code, I just ported their metadata API into a regular function, | ||
| 598 | so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this | ||
| 599 | year I simplified it's API into one short and understandable | ||
| 600 | file. As of this blog post, I have added a TanStack-compatible | ||
| 601 | `meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm], | ||
| 602 | or simply copied into your project. | ||
| 603 | |||
| 604 | > **notice**: Due to time constraints with writing this article, the library | ||
| 605 | > has not yet been updated. I'll probably get around to it by the ~~end of this | ||
| 606 | > week (Oct 24th)~~ some time soon... As a placeholder, I'm able to share the | ||
| 607 | > version that is used at work to my website: | ||
| 608 | > [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts). | ||
| 609 | |||
| 610 | ```tsx | ||
| 611 | // once in your project | ||
| 612 | import * as meta from "@clo/lib/meta.ts"; | ||
| 613 | |||
| 614 | export const defineHead = meta.toTags.bind(null, { | ||
| 615 | // site-wide options | ||
| 616 | base: new URL("https://paperclover.net"), | ||
| 617 | titleTemplate: (title) => [title, "paper clover"] | ||
| 618 | .filter(Boolean).join(' | '), | ||
| 619 | // ... | ||
| 620 | }); | ||
| 621 | |||
| 622 | // for each page... | ||
| 623 | export const Route = createFileRoute("/blog")({ | ||
| 624 | head: () => | ||
| 625 | defineHead({ | ||
| 626 | title: "clover's blog", // templated with `titleTemplate` | ||
| 627 | description: "a catgirl meows about her technology viewpoints", | ||
| 628 | canonical: "/blog", // joined with `base` | ||
| 629 | |||
| 630 | // When specified, configures Open Graph and Twitter embed, | ||
| 631 | // using the page title and description as the default. | ||
| 632 | // The defaults are good, but it supports more options. | ||
| 633 | embed: { | ||
| 634 | image: "/img/blog.webp", | ||
| 635 | }, | ||
| 636 | |||
| 637 | // Every exotic meta tag is done with a JSX fragment. This | ||
| 638 | // doesn't render React, it just loops through the tags. | ||
| 639 | // My goal was to cover the most common 99% of uses. | ||
| 640 | extra: <> | ||
| 641 | <meta name="site-verification" content="waffles" />, | ||
| 642 | </>, | ||
| 643 | }), | ||
| 644 | |||
| 645 | component: Page, | ||
| 646 | }); | ||
| 647 | |||
| 648 | function Page() { | ||
| 649 | ... | ||
| 650 | } | ||
| 651 | ``` | ||
| 652 | |||
| 653 | My version wasn't concerned with covering the entire space of Next.js's metadata | ||
| 654 | object, but instead uses inline JSX to fill that gap. | ||
| 655 | |||
| 656 | [lib-jsr]: https://jsr.io/@clo/lib | ||
| 657 | [lib-npm]: https://npmjs.com/@paperclover/lib | ||
| 658 | |||
| 659 | <h3 id='vercel-og'>`next/og` is Good Too</> | ||
| 660 | |||
| 661 | No strong opinions. I just want to remind everyone that the `@vercel/og` package exists. | ||
| 662 | |||
| 663 | <h2 id='experience-feels-like-the-usual'>My Experience Feels like the Usual</> | ||
| 664 | |||
| 665 | At the Next.js Conf 2024, everyone there was raving about Server Components. I | ||
| 666 | forget exactly who I talked to, but the big people were all in on this. I, | ||
| 667 | having implemented the bundler end of RSC, saw a couple of the problems in the | ||
| 668 | format. With Next 15 "stabilizing" the App Router last year, many companies are | ||
| 669 | building their products on it, realizing these pitfalls first-hand. | ||
| 670 | |||
| 671 | I came into the Next.js game late, only starting in June with version 15. | ||
| 672 | But everyone I've talked to at events sympathize with my notes. All the people | ||
| 673 | I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people | ||
| 674 | at Vercel told me they don't like how Next.js is to actually use. | ||
| 675 | |||
| 676 | I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone | ||
| 677 | wants. | ||
| 678 | |||
| 679 | <h2 id='prefer-respectful-tools'>Prefer Tools that Respect You</> | ||
| 680 | |||
| 681 | A lot of in the JavaScript ecosystem is a mess. That mess is why web | ||
| 682 | development gets made fun of. There were a lot of times I thought that working | ||
| 683 | with the web was an unrecoverable mess, but the mess was actually just the | ||
| 684 | commonly-used libraries I surrounded myself with. When that is peeled back, | ||
| 685 | modern web development technologies are awesome. | ||
| 686 | |||
| 687 | I've been making this website from scratch without any framework since late | ||
| 688 | 2024, by writing systems like my own [TUI progress widget][progress], [static | ||
| 689 | file proxy][file-cache], incremental build system, and many more components. | ||
| 690 | Working on this code has produced some of my best coding sessions (by | ||
| 691 | happiness) in years. The viewers of *[paper clover]* get a better quality | ||
| 692 | website; the mini-libraries I create get [extracted for public use][lib], | ||
| 693 | everyone wins. | ||
| 694 | |||
| 695 | This level of from-scratch is too much for most people, especially at the | ||
| 696 | workplace. I say that at the minimum, we should only give our attention and | ||
| 697 | money to high quality tools that respect us. And Next.js and the company behind | ||
| 698 | it, Vercel, are not that. | ||
| 699 | |||
| 700 | If you use Next.js, and feel that the experience doesn't remind you of respect | ||
| 701 | too, consider whether you and your colleagues want to continue supporting their | ||
| 702 | [serverless empire]. The Vite ecosystem seems pretty decent to build on right | ||
| 703 | now, but I still have little experience in using their tools at scale in | ||
| 704 | production. The [Vite+ launch from Void0][vite-plus] seems interesting, but | ||
| 705 | only time will tell if these venture-funded tools will respect us (end-users | ||
| 706 | and developers) long term. | ||
| 707 | |||
| 708 | Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of | ||
| 709 | purchasing a $800 ticket, I decided to put that money [toward the TanStack | ||
| 710 | team][tanstack-donate] for [respecting and improving the web development | ||
| 711 | ecosystem][tanstack-ethos]. | ||
| 712 | |||
| 713 | [paper clover]: https://paperclover.net/ | ||
| 714 | [lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme | ||
| 715 | [progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts | ||
| 716 | [file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts | ||
| 717 | |||
| 718 | [next-conf]: https://nextjs.org/conf | ||
| 719 | [vite-plus]: https://viteplus.dev/ | ||
| 720 | [tanstack-ethos]: https://tanstack.com/ethos | ||
| 721 | [tanstack-donate]: https://github.com/sponsors/tannerlinsley | ||
| 722 | [serverless empire]: https://youtu.be/SCIfWhAheVw | ||
| 723 | |||
| 724 | ## What the Future Holds | ||
| 725 | |||
| 726 | Slowly, I've been replacing many pieces of software that disrespect me with | ||
| 727 | better alternatives. Some examples of this are GitHub, Visual Studio Code, | ||
| 728 | DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to | ||
| 729 | write more on this blog about the technical things I do (that progress library, | ||
| 730 | the purpose of my own site generator, learnings from my current job), including | ||
| 731 | some of my past projects at Bun (details on HMR, the crash reporter, and the | ||
| 732 | crazy system for bundling built-in modules). If it interests you, please | ||
| 733 | subscribe to the email list: | ||
| 734 | |||
| 735 | <a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">click here to send an email to <code>subscribe@paperclover.net</code>, requesting that you would like to be added to the mailing list.</a> (i manage this mailing list manually) | ||
| 736 | |||
| 737 | [back to top](#top) &mdash; [ask a question about this article](/q+a) | ||
| 738 | |||
| 739 | <br /> | ||
| 740 | <br /> | ||
| 741 | <br /> | ||
| 742 | <br /> | ||
| 743 | <br /> | ||
| 744 | <br /> | ||
| 745 | |||
| 746 | <footer>2025 (c) paper clover</footer> | ||
| 747 | |||
| 748 | </blog-layout> | ||
| 749 | |||
| 750 | <br /> | ||
src/lib.rs created+225| ... | @@ -0,0 +1,225 @@ | ||
| 1 | pub mod marko; | ||
| 2 | pub mod plugin; | ||
| 3 | pub mod typescript; | ||
| 4 | |||
| 5 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | ||
| 6 | use plugin::tags::{MarkoClose, MarkoOpen}; | ||
| 7 | |||
| 8 | pub struct Output { | ||
| 9 | pub text: String, | ||
| 10 | pub format: OutputFormat, | ||
| 11 | } | ||
| 12 | |||
| 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 14 | pub enum OutputFormat { | ||
| 15 | Html, | ||
| 16 | Marko, | ||
| 17 | } | ||
| 18 | |||
| 19 | pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Vec<OxcDiagnostic>> { | ||
| 20 | let md = &mut markdown_it::MarkdownIt::new(); | ||
| 21 | |||
| 22 | plugin::add_all(md); | ||
| 23 | markdown_it::plugins::cmark::add(md); | ||
| 24 | markdown_it::plugins::extra::add(md); | ||
| 25 | |||
| 26 | let mut ast = md.parse(source); | ||
| 27 | |||
| 28 | let mut errors = Vec::new(); | ||
| 29 | collect_errors(&mut ast, &mut errors); | ||
| 30 | if !errors.is_empty() { | ||
| 31 | return Err(errors); | ||
| 32 | } | ||
| 33 | |||
| 34 | // Validate Marko tag open/close matching | ||
| 35 | validate_marko_tags(&ast, &mut errors); | ||
| 36 | if !errors.is_empty() { | ||
| 37 | return Err(errors); | ||
| 38 | } | ||
| 39 | |||
| 40 | // Check for unmatched inline tag markers | ||
| 41 | plugin::inline_tags::check_unmatched_markers(&ast, &mut errors); | ||
| 42 | if !errors.is_empty() { | ||
| 43 | return Err(errors); | ||
| 44 | } | ||
| 45 | |||
| 46 | let format = force.unwrap_or_else(|| { | ||
| 47 | if has_marko_features(&ast) { | ||
| 48 | OutputFormat::Marko | ||
| 49 | } else { | ||
| 50 | OutputFormat::Html | ||
| 51 | } | ||
| 52 | }); | ||
| 53 | |||
| 54 | if force.is_some() && format == OutputFormat::Html && has_marko_features(&ast) { | ||
| 55 | return Err(vec![OxcDiagnostic::error( | ||
| 56 | "Cannot output HTML: document contains Marko-specific features", | ||
| 57 | )]); | ||
| 58 | } | ||
| 59 | |||
| 60 | Ok(Output { | ||
| 61 | text: ast.render(), | ||
| 62 | format, | ||
| 63 | }) | ||
| 64 | } | ||
| 65 | |||
| 66 | /// Extract all `ErrorBlock`s from an AST, offsetting the error positions. | ||
| 67 | fn collect_errors(node: &mut markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | ||
| 68 | let (start, end) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 69 | if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() { | ||
| 70 | errors.extend(error_block.errors.drain(0..).map(|mut err| { | ||
| 71 | if let Some(labels) = err.labels.as_mut() { | ||
| 72 | for label in labels { | ||
| 73 | label.set_span_offset(label.offset() + start + 2); | ||
| 74 | } | ||
| 75 | } else { | ||
| 76 | err.labels = Some(vec![LabeledSpan::new(None, start, end - start)]) | ||
| 77 | } | ||
| 78 | err | ||
| 79 | })); | ||
| 80 | } | ||
| 81 | for child in &mut node.children { | ||
| 82 | collect_errors(child, errors); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | /// Extract all `ErrorBlock`s from an AST, offsetting the error positions. | ||
| 87 | pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic { | ||
| 88 | if let Some(labels) = err.labels.as_mut() { | ||
| 89 | for label in labels { | ||
| 90 | label.set_span_offset(label.offset().saturating_add_signed(offset)); | ||
| 91 | } | ||
| 92 | } | ||
| 93 | err | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Check if the AST contains any Marko-specific features | ||
| 97 | fn has_marko_features(node: &markdown_it::Node) -> bool { | ||
| 98 | // Check if this node is a RawBlock | ||
| 99 | if node.cast::<plugin::RawBlock>().is_some() { | ||
| 100 | return true; | ||
| 101 | } | ||
| 102 | |||
| 103 | // Check for Marko tags | ||
| 104 | if node.cast::<MarkoOpen>().is_some() | ||
| 105 | || node.cast::<MarkoClose>().is_some() | ||
| 106 | || node.cast::<plugin::tags::MarkoSelfClosing>().is_some() | ||
| 107 | || node.cast::<plugin::tags::MarkoOpenWithText>().is_some() | ||
| 108 | || node.cast::<plugin::tags::MarkoInlineTag>().is_some() | ||
| 109 | || node.cast::<plugin::tags::MarkoBlockComplete>().is_some() | ||
| 110 | { | ||
| 111 | return true; | ||
| 112 | } | ||
| 113 | |||
| 114 | // Recursively check children | ||
| 115 | for child in &node.children { | ||
| 116 | if has_marko_features(child) { | ||
| 117 | return true; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | false | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Validate that Marko open/close tags are properly matched | ||
| 125 | fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | ||
| 126 | #[derive(Debug)] | ||
| 127 | struct OpenTag { | ||
| 128 | name: String, | ||
| 129 | /// Byte offset of the tag name in the source (after '<') | ||
| 130 | name_start: usize, | ||
| 131 | /// Length of the tag name | ||
| 132 | name_len: usize, | ||
| 133 | } | ||
| 134 | |||
| 135 | let mut stack: Vec<OpenTag> = vec![]; | ||
| 136 | |||
| 137 | fn walk(node: &markdown_it::Node, stack: &mut Vec<OpenTag>, errors: &mut Vec<OxcDiagnostic>) { | ||
| 138 | if let Some(open) = node.cast::<MarkoOpen>() { | ||
| 139 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 140 | stack.push(OpenTag { | ||
| 141 | name: open.tag_name.clone(), | ||
| 142 | name_start: start + 1, // +1 to skip '<' | ||
| 143 | name_len: open.tag_name_len, | ||
| 144 | }); | ||
| 145 | } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() { | ||
| 146 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 147 | stack.push(OpenTag { | ||
| 148 | name: open.tag_name.clone(), | ||
| 149 | name_start: start + 1, // +1 to skip '<' | ||
| 150 | name_len: open.tag_name_len, | ||
| 151 | }); | ||
| 152 | } else if let Some(close) = node.cast::<MarkoClose>() { | ||
| 153 | let (close_start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 154 | // Close tag name starts after '</' (offset +2) | ||
| 155 | let close_name_start = close_start + 2; | ||
| 156 | let close_name_len = close.tag_name_len.unwrap_or(0); | ||
| 157 | |||
| 158 | match (close.tag_name.as_ref(), stack.pop()) { | ||
| 159 | (None, Some(_)) => { | ||
| 160 | // </> closes anything - valid | ||
| 161 | } | ||
| 162 | (Some(name), Some(top)) if name == &top.name => { | ||
| 163 | // Exact match - valid | ||
| 164 | } | ||
| 165 | (Some(name), Some(top)) => { | ||
| 166 | // Mismatched: expected </top.name>, got </name> | ||
| 167 | errors.push( | ||
| 168 | OxcDiagnostic::error(format!( | ||
| 169 | "Mismatched closing tag: expected </{}>, found </{}>", | ||
| 170 | top.name, name | ||
| 171 | )) | ||
| 172 | .with_labels(vec![ | ||
| 173 | LabeledSpan::new( | ||
| 174 | Some("opened here".into()), | ||
| 175 | top.name_start, | ||
| 176 | top.name_len, | ||
| 177 | ), | ||
| 178 | LabeledSpan::new( | ||
| 179 | Some("closed here".into()), | ||
| 180 | close_name_start, | ||
| 181 | close_name_len, | ||
| 182 | ), | ||
| 183 | ]), | ||
| 184 | ); | ||
| 185 | } | ||
| 186 | (Some(name), None) => { | ||
| 187 | // Close without open | ||
| 188 | errors.push( | ||
| 189 | OxcDiagnostic::error(format!( | ||
| 190 | "Closing tag </{name}> without matching open" | ||
| 191 | )) | ||
| 192 | .with_label(LabeledSpan::new( | ||
| 193 | None, | ||
| 194 | close_name_start, | ||
| 195 | close_name_len, | ||
| 196 | )), | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | (None, None) => { | ||
| 200 | // </> without any open tag - highlight the whole </> | ||
| 201 | errors.push( | ||
| 202 | OxcDiagnostic::error("Closing tag </> without matching open") | ||
| 203 | .with_label(LabeledSpan::new(None, close_start, 3)), | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | // Recurse into children | ||
| 210 | for child in &node.children { | ||
| 211 | walk(child, stack, errors); | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 215 | walk(node, &mut stack, errors); | ||
| 216 | |||
| 217 | // Check for unclosed tags | ||
| 218 | for unclosed in stack { | ||
| 219 | errors.push( | ||
| 220 | OxcDiagnostic::error(format!("Unclosed tag <{}>", unclosed.name)).with_label( | ||
| 221 | LabeledSpan::new(None, unclosed.name_start, unclosed.name_len), | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | } | ||
| 225 | } | ||
src/main.rs created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | use clap::{Parser, ValueEnum}; | ||
| 2 | use markodown::{transform, OutputFormat}; | ||
| 3 | use oxc_diagnostics::GraphicalReportHandler; | ||
| 4 | use std::fs; | ||
| 5 | use std::path::PathBuf; | ||
| 6 | use std::process; | ||
| 7 | |||
| 8 | #[derive(Debug, Clone, Copy, ValueEnum)] | ||
| 9 | enum CliOutputFormat { | ||
| 10 | Html, | ||
| 11 | Marko, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Parser)] | ||
| 15 | #[command( | ||
| 16 | name = "markodown", | ||
| 17 | about = "A markdown compiler with custom extensions" | ||
| 18 | )] | ||
| 19 | struct Cli { | ||
| 20 | /// Path to input markodown source | ||
| 21 | file: PathBuf, | ||
| 22 | |||
| 23 | /// Force the output format (auto-detected by default) | ||
| 24 | #[arg(long)] | ||
| 25 | output_format: Option<CliOutputFormat>, | ||
| 26 | } | ||
| 27 | |||
| 28 | fn main() { | ||
| 29 | let cli = Cli::parse(); | ||
| 30 | |||
| 31 | let force = cli.output_format.map(|f| match f { | ||
| 32 | CliOutputFormat::Html => OutputFormat::Html, | ||
| 33 | CliOutputFormat::Marko => OutputFormat::Marko, | ||
| 34 | }); | ||
| 35 | |||
| 36 | let source = match fs::read_to_string(&cli.file) { | ||
| 37 | Ok(s) => s, | ||
| 38 | Err(e) => { | ||
| 39 | eprintln!("error: failed to read {}: {e}", cli.file.display()); | ||
| 40 | process::exit(1); | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | match transform(&source, force) { | ||
| 45 | Err(errors) => { | ||
| 46 | let handler = GraphicalReportHandler::new(); | ||
| 47 | for error in &errors { | ||
| 48 | let report = error | ||
| 49 | .clone() | ||
| 50 | .with_source_code(oxc_diagnostics::NamedSource::new( | ||
| 51 | cli.file.display().to_string(), | ||
| 52 | source.clone(), | ||
| 53 | )); | ||
| 54 | let mut output_str = String::new(); | ||
| 55 | handler | ||
| 56 | .render_report(&mut output_str, report.as_ref()) | ||
| 57 | .unwrap(); | ||
| 58 | eprint!("{output_str}"); | ||
| 59 | } | ||
| 60 | process::exit(1); | ||
| 61 | } | ||
| 62 | Ok(output) => match output.format { | ||
| 63 | OutputFormat::Html => print!("[STATIC]\n{}", output.text), | ||
| 64 | OutputFormat::Marko => print!("[MARKO]\n{}", output.text), | ||
| 65 | }, | ||
| 66 | } | ||
| 67 | |||
| 68 | // Print any errors to stderr with pretty formatting | ||
| 69 | |||
| 70 | // Print output | ||
| 71 | } | ||
src/marko.rs created+1296| ... | @@ -0,0 +1,1296 @@ | ||
| 1 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | ||
| 2 | use oxc_span::Span; | ||
| 3 | |||
| 4 | use crate::typescript::{ | ||
| 5 | parse_call_arguments, parse_expr, parse_expr_without_gt, parse_fn_params_and_body, | ||
| 6 | parse_var_binding, | ||
| 7 | }; | ||
| 8 | |||
| 9 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 10 | pub struct Open<'a> { | ||
| 11 | pub tag_name: &'a str, | ||
| 12 | pub content: &'a str, | ||
| 13 | pub self_closing: bool, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Open<'_> { | ||
| 17 | /// Returns the span of the tag name relative to the start of the tag. | ||
| 18 | pub fn tag_name_span(&self) -> Span { | ||
| 19 | // fast case, when a literal tag name is passed | ||
| 20 | if !self.tag_name.is_empty() { | ||
| 21 | Span::new(1, 1 + (self.tag_name.len() as u32)) | ||
| 22 | } else { | ||
| 23 | // slow path, re-parse the js expression. not that deep because you | ||
| 24 | // only do this in the error case. TODO: actually store `Open` in | ||
| 25 | // the md tree, the lifetimes are complicated rn | ||
| 26 | let mut l = LexState::new(self.content); | ||
| 27 | l.advance(3); | ||
| 28 | parse_expr(&mut l).expect("validated text should pass"); | ||
| 29 | Span::new(3, l.offset() as u32) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 35 | pub struct Close<'a> { | ||
| 36 | pub tag_name: Option<&'a str>, | ||
| 37 | pub length: usize, | ||
| 38 | } | ||
| 39 | |||
| 40 | impl Close<'_> { | ||
| 41 | /// Returns the span of the tag name relative to the start of the tag. | ||
| 42 | /// For `</div>`, returns Span(2, 5) pointing to "div". | ||
| 43 | /// For `</>`, returns None. | ||
| 44 | pub fn tag_name_span(&self) -> Option<Span> { | ||
| 45 | self.tag_name.map(|name| { | ||
| 46 | // Close tag starts with '</', so tag name starts at offset 2 | ||
| 47 | let start = 2u32; | ||
| 48 | let end = start + (name.len() as u32); | ||
| 49 | Span::new(start, end) | ||
| 50 | }) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | pub enum OpenOrClose<'a> { | ||
| 55 | Open(Open<'a>), | ||
| 56 | Close(Close<'a>), | ||
| 57 | } | ||
| 58 | |||
| 59 | pub fn parse_tag(src: &str) -> Result<OpenOrClose, OxcDiagnostic> { | ||
| 60 | assert_eq!(src.as_bytes()[0], b'<'); | ||
| 61 | if src.starts_with("</") { | ||
| 62 | parse_close(src).map(OpenOrClose::Close) | ||
| 63 | } else { | ||
| 64 | parse_open(src).map(OpenOrClose::Open) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | pub struct LexState<'a> { | ||
| 69 | pub src: &'a str, | ||
| 70 | pub offset: usize, | ||
| 71 | } | ||
| 72 | |||
| 73 | impl<'a> LexState<'a> { | ||
| 74 | pub fn new(src: &'a str) -> LexState<'a> { | ||
| 75 | LexState { src, offset: 0 } | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn offset(&self) -> usize { | ||
| 79 | self.offset | ||
| 80 | } | ||
| 81 | |||
| 82 | pub fn advance(&mut self, n: usize) { | ||
| 83 | self.offset += n; | ||
| 84 | } | ||
| 85 | |||
| 86 | pub fn restore(&mut self, n: usize) { | ||
| 87 | self.offset = n; | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn peek_byte(&self) -> Option<u8> { | ||
| 91 | self.src.as_bytes().get(self.offset).copied() | ||
| 92 | } | ||
| 93 | |||
| 94 | pub fn peek_rest(&self) -> &'a str { | ||
| 95 | &self.src[self.offset..] | ||
| 96 | } | ||
| 97 | |||
| 98 | pub fn expect_byte(&mut self) -> Result<u8, OxcDiagnostic> { | ||
| 99 | if let Some(b) = self.peek_byte() { | ||
| 100 | self.offset += 1; | ||
| 101 | return Ok(b); | ||
| 102 | } | ||
| 103 | Err( | ||
| 104 | OxcDiagnostic::error("Unexpected end of file").and_label(LabeledSpan::new( | ||
| 105 | None, | ||
| 106 | self.offset, | ||
| 107 | 1, | ||
| 108 | )), | ||
| 109 | ) | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn expect(&mut self, expected: &str) -> Result<(), OxcDiagnostic> { | ||
| 113 | if self.src[self.offset..].starts_with(expected) { | ||
| 114 | self.offset += expected.len(); | ||
| 115 | Ok(()) | ||
| 116 | } else { | ||
| 117 | Err( | ||
| 118 | OxcDiagnostic::error(format!("Expected {expected}")).and_label(LabeledSpan::new( | ||
| 119 | None, | ||
| 120 | self.offset, | ||
| 121 | 1, | ||
| 122 | )), | ||
| 123 | ) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | pub fn skip_whitespace(&mut self) { | ||
| 128 | loop { | ||
| 129 | match self.peek_byte() { | ||
| 130 | Some(byte) => match byte { | ||
| 131 | b' ' | b'\n' | b'\t' | b'\r' => self.offset += 1, | ||
| 132 | _ => break, | ||
| 133 | }, | ||
| 134 | None => break, | ||
| 135 | } | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | pub fn parse_open(src: &str) -> Result<Open, OxcDiagnostic> { | ||
| 141 | assert!(src.starts_with("<")); | ||
| 142 | let mut l = LexState { src, offset: 1 }; | ||
| 143 | let tag_name = parse_tag_name(&mut l)?; | ||
| 144 | let is_attribute_tag = tag_name.starts_with('@'); | ||
| 145 | |||
| 146 | // class and id shorthand | ||
| 147 | let mut has_id = false; | ||
| 148 | while let Some(byte @ b'#' | byte @ b'.') = l.peek_byte() { | ||
| 149 | let offset = l.offset; | ||
| 150 | l.advance(1); | ||
| 151 | let name = parse_class_name_shorthand(&mut l, byte == b'#')?; | ||
| 152 | _ = name; | ||
| 153 | |||
| 154 | if byte == b'#' { | ||
| 155 | if has_id { | ||
| 156 | return Err( | ||
| 157 | OxcDiagnostic::error("Cannot specify two ID shorthands".to_string()) | ||
| 158 | .and_label(LabeledSpan::new(None, offset, name.len())), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | has_id = true; | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | // in any order: parameters, arguments, variable | ||
| 166 | // TODO: create errors when things repeat | ||
| 167 | let mut has_js_arguments = false; | ||
| 168 | let mut has_js_params = false; | ||
| 169 | let mut has_js_variable = false; | ||
| 170 | l.skip_whitespace(); | ||
| 171 | while let Some(byte @ b'|' | byte @ b'(' | byte @ b'/') = l.peek_byte() { | ||
| 172 | let offset = l.offset; | ||
| 173 | |||
| 174 | match byte { | ||
| 175 | b'|' => { | ||
| 176 | // parameters | ||
| 177 | l.offset += 1; | ||
| 178 | l.skip_whitespace(); | ||
| 179 | while l.peek_byte() != Some(b'|') && l.peek_byte().is_some() { | ||
| 180 | _ = l.expect("..."); | ||
| 181 | l.skip_whitespace(); | ||
| 182 | parse_var_binding(&mut l)?; | ||
| 183 | l.skip_whitespace(); | ||
| 184 | if l.expect(",").is_err() { | ||
| 185 | break; | ||
| 186 | } | ||
| 187 | l.skip_whitespace(); | ||
| 188 | } | ||
| 189 | l.expect("|")?; | ||
| 190 | l.skip_whitespace(); | ||
| 191 | has_js_params = true; | ||
| 192 | } | ||
| 193 | b'(' => { | ||
| 194 | // arguments | ||
| 195 | // TODO: METHOD SHORTHAND | ||
| 196 | println!("{}", l.peek_rest()); | ||
| 197 | parse_call_arguments(&mut l)?; | ||
| 198 | l.skip_whitespace(); | ||
| 199 | has_js_arguments = true; | ||
| 200 | } | ||
| 201 | b'/' => { | ||
| 202 | if l.peek_rest().starts_with("/>") { | ||
| 203 | break; | ||
| 204 | } | ||
| 205 | l.offset += 1; | ||
| 206 | l.skip_whitespace(); | ||
| 207 | parse_var_binding(&mut l)?; | ||
| 208 | l.skip_whitespace(); | ||
| 209 | has_js_variable = true; | ||
| 210 | } | ||
| 211 | _ => unreachable!(), | ||
| 212 | } | ||
| 213 | |||
| 214 | if is_attribute_tag && byte == b'/' { | ||
| 215 | return Err( | ||
| 216 | OxcDiagnostic::error("Attribute tags do not support variables") | ||
| 217 | .and_label(LabeledSpan::new(None, offset, l.offset - offset)), | ||
| 218 | ); | ||
| 219 | } | ||
| 220 | if is_attribute_tag && byte == b'(' { | ||
| 221 | return Err( | ||
| 222 | OxcDiagnostic::error("Attribute tags do not support arguments") | ||
| 223 | .and_label(LabeledSpan::new(None, offset, l.offset - offset)), | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | // keyless value | ||
| 229 | if l.peek_byte() == Some(b'=') { | ||
| 230 | l.offset += 1; | ||
| 231 | l.skip_whitespace(); | ||
| 232 | parse_expr_without_gt(&mut l)?; | ||
| 233 | l.skip_whitespace(); | ||
| 234 | } | ||
| 235 | |||
| 236 | // attributes | ||
| 237 | let mut self_closing = false; | ||
| 238 | loop { | ||
| 239 | match l.peek_byte() { | ||
| 240 | None => return Err(l.expect_byte().expect_err("err")), | ||
| 241 | Some(b'>') => { | ||
| 242 | l.advance(1); | ||
| 243 | break; | ||
| 244 | } | ||
| 245 | Some(_) => { | ||
| 246 | if l.expect("/>").is_ok() { | ||
| 247 | self_closing = true; | ||
| 248 | break; | ||
| 249 | } | ||
| 250 | parse_attribute(&mut l)?; | ||
| 251 | l.skip_whitespace(); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | Ok(Open { | ||
| 257 | tag_name, | ||
| 258 | content: &src[0..l.offset], | ||
| 259 | self_closing, | ||
| 260 | }) | ||
| 261 | } | ||
| 262 | |||
| 263 | pub fn parse_close(src: &str) -> Result<Close, OxcDiagnostic> { | ||
| 264 | assert!(src.starts_with("</")); | ||
| 265 | let mut l = LexState { src, offset: 2 }; | ||
| 266 | if l.peek_byte() == Some(b'>') { | ||
| 267 | l.offset += 1; | ||
| 268 | Ok(Close { | ||
| 269 | tag_name: None, | ||
| 270 | length: l.offset, | ||
| 271 | }) | ||
| 272 | } else { | ||
| 273 | let tag_name = parse_tag_name(&mut l)?; | ||
| 274 | l.expect(">")?; | ||
| 275 | Ok(Close { | ||
| 276 | tag_name: Some(tag_name), | ||
| 277 | length: l.offset, | ||
| 278 | }) | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | fn parse_tag_name<'a>(l: &mut LexState<'a>) -> Result<&'a str, OxcDiagnostic> { | ||
| 283 | if l.peek_rest().starts_with("${") { | ||
| 284 | l.advance(2); | ||
| 285 | parse_expr(l)?; | ||
| 286 | l.expect("}")?; | ||
| 287 | return Ok(""); | ||
| 288 | } | ||
| 289 | let start = l.offset; | ||
| 290 | loop { | ||
| 291 | match l.peek_byte() { | ||
| 292 | Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-' | b'$' | b'@' | b'0'..=b'9') => { | ||
| 293 | l.offset += 1 | ||
| 294 | } | ||
| 295 | _ => break, | ||
| 296 | } | ||
| 297 | } | ||
| 298 | let tag_name = &l.src[start..l.offset]; | ||
| 299 | if !tag_name.is_empty() { | ||
| 300 | Ok(tag_name) | ||
| 301 | } else { | ||
| 302 | Err(OxcDiagnostic::error("Expected tag name".to_string()) | ||
| 303 | .and_label(LabeledSpan::new(None, l.offset, 1))) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | fn parse_attr_name<'a>(l: &mut LexState<'a>) -> Result<&'a str, OxcDiagnostic> { | ||
| 308 | let start = l.offset; | ||
| 309 | loop { | ||
| 310 | match l.peek_byte() { | ||
| 311 | Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-' | b':') => l.offset += 1, | ||
| 312 | _ => break, | ||
| 313 | } | ||
| 314 | } | ||
| 315 | let tag_name = &l.src[start..l.offset]; | ||
| 316 | if !tag_name.is_empty() { | ||
| 317 | Ok(tag_name) | ||
| 318 | } else { | ||
| 319 | Err(OxcDiagnostic::error("Expected attribute name".to_string()) | ||
| 320 | .and_label(LabeledSpan::new(None, l.offset, 1))) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | fn parse_attribute(l: &mut LexState) -> Result<(), OxcDiagnostic> { | ||
| 325 | if l.expect("...").is_ok() { | ||
| 326 | l.skip_whitespace(); | ||
| 327 | parse_expr_without_gt(l)?; | ||
| 328 | l.skip_whitespace(); | ||
| 329 | return Ok(()); | ||
| 330 | } | ||
| 331 | |||
| 332 | // attribute name | ||
| 333 | parse_attr_name(l)?; | ||
| 334 | let has_ws_or_end = l | ||
| 335 | .peek_byte() | ||
| 336 | .map(|x| x.is_ascii_whitespace()) | ||
| 337 | .unwrap_or(false) | ||
| 338 | || l.peek_rest().starts_with(">") | ||
| 339 | || l.peek_rest().starts_with("/>"); | ||
| 340 | l.skip_whitespace(); | ||
| 341 | |||
| 342 | // attribute value | ||
| 343 | if l.expect(":=").is_ok() || l.expect("=").is_ok() { | ||
| 344 | l.skip_whitespace(); | ||
| 345 | parse_expr_without_gt(l)?; | ||
| 346 | l.skip_whitespace(); | ||
| 347 | } else if l.peek_byte() == Some(b'(') { | ||
| 348 | parse_fn_params_and_body(l)?; | ||
| 349 | l.skip_whitespace(); | ||
| 350 | } else if !has_ws_or_end { | ||
| 351 | return Err( | ||
| 352 | OxcDiagnostic::error("Expected whitespace or '>'".to_string()) | ||
| 353 | .and_label(LabeledSpan::new(None, l.offset, 1)), | ||
| 354 | ); | ||
| 355 | } | ||
| 356 | |||
| 357 | Ok(()) | ||
| 358 | } | ||
| 359 | |||
| 360 | fn parse_class_name_shorthand<'a>( | ||
| 361 | l: &mut LexState<'a>, | ||
| 362 | id: bool, | ||
| 363 | ) -> Result<&'a str, OxcDiagnostic> { | ||
| 364 | let start = l.offset; | ||
| 365 | loop { | ||
| 366 | match l.peek_byte() { | ||
| 367 | Some(b' ' | b'\n' | b'\t' | b'\r' | b'.' | b'#' | b'>' | b'<' | b'(' | b'|') | None => { | ||
| 368 | break | ||
| 369 | } | ||
| 370 | Some(b'$') => todo!(), | ||
| 371 | _ => l.offset += 1, | ||
| 372 | } | ||
| 373 | } | ||
| 374 | let tag_name = &l.src[start..l.offset]; | ||
| 375 | if !tag_name.is_empty() { | ||
| 376 | Ok(tag_name) | ||
| 377 | } else { | ||
| 378 | Err(OxcDiagnostic::error(if id { | ||
| 379 | "Expected id" | ||
| 380 | } else { | ||
| 381 | "Expected class name" | ||
| 382 | }) | ||
| 383 | .and_label(LabeledSpan::new(None, l.offset, 1))) | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | #[cfg(test)] | ||
| 388 | mod tests { | ||
| 389 | use super::*; | ||
| 390 | |||
| 391 | #[test] | ||
| 392 | fn test_open_basic() { | ||
| 393 | assert_eq!( | ||
| 394 | parse_open("<div>"), | ||
| 395 | Ok(Open { | ||
| 396 | tag_name: "div", | ||
| 397 | content: "<div>", | ||
| 398 | self_closing: false, | ||
| 399 | }) | ||
| 400 | ); | ||
| 401 | assert_eq!( | ||
| 402 | parse_open("<footer#id.class meow=purr yolo=4.2 /> etfdas"), | ||
| 403 | Ok(Open { | ||
| 404 | tag_name: "footer", | ||
| 405 | content: "<footer#id.class meow=purr yolo=4.2 />", | ||
| 406 | self_closing: true, | ||
| 407 | }) | ||
| 408 | ); | ||
| 409 | assert_eq!( | ||
| 410 | parse_open("<movie value=x > 4 /> etfdas"), | ||
| 411 | Ok(Open { | ||
| 412 | tag_name: "movie", | ||
| 413 | content: "<movie value=x >", | ||
| 414 | self_closing: false, | ||
| 415 | }) | ||
| 416 | ); | ||
| 417 | } | ||
| 418 | |||
| 419 | #[test] | ||
| 420 | fn test_open_id() { | ||
| 421 | assert_eq!( | ||
| 422 | parse_open("<h2#technical-review>A Technical Review"), | ||
| 423 | Ok(Open { | ||
| 424 | tag_name: "h2", | ||
| 425 | content: "<h2#technical-review>", | ||
| 426 | self_closing: false, | ||
| 427 | }) | ||
| 428 | ); | ||
| 429 | assert_eq!( | ||
| 430 | parse_open("<footer#id.class meow=purr yolo=4.2 /> etfdas"), | ||
| 431 | Ok(Open { | ||
| 432 | tag_name: "footer", | ||
| 433 | content: "<footer#id.class meow=purr yolo=4.2 />", | ||
| 434 | self_closing: true, | ||
| 435 | }) | ||
| 436 | ); | ||
| 437 | assert_eq!( | ||
| 438 | parse_open("<movie value=x > 4 /> etfdas"), | ||
| 439 | Ok(Open { | ||
| 440 | tag_name: "movie", | ||
| 441 | content: "<movie value=x >", | ||
| 442 | self_closing: false, | ||
| 443 | }) | ||
| 444 | ); | ||
| 445 | } | ||
| 446 | |||
| 447 | #[test] | ||
| 448 | fn test_open_self_closing_with_space() { | ||
| 449 | // Self-closing with space before /> works | ||
| 450 | assert_eq!( | ||
| 451 | parse_open("<input />"), | ||
| 452 | Ok(Open { | ||
| 453 | tag_name: "input", | ||
| 454 | content: "<input />", | ||
| 455 | self_closing: true, | ||
| 456 | }) | ||
| 457 | ); | ||
| 458 | } | ||
| 459 | |||
| 460 | #[test] | ||
| 461 | fn test_open_self_closing_no_space() { | ||
| 462 | assert_eq!( | ||
| 463 | parse_open("<br/>"), | ||
| 464 | Ok(Open { | ||
| 465 | tag_name: "br", | ||
| 466 | content: "<br/>", | ||
| 467 | self_closing: true, | ||
| 468 | }) | ||
| 469 | ); | ||
| 470 | assert_eq!( | ||
| 471 | parse_open("<my-component/>"), | ||
| 472 | Ok(Open { | ||
| 473 | tag_name: "my-component", | ||
| 474 | content: "<my-component/>", | ||
| 475 | self_closing: true, | ||
| 476 | }) | ||
| 477 | ); | ||
| 478 | } | ||
| 479 | |||
| 480 | #[test] | ||
| 481 | fn test_open_tag_names() { | ||
| 482 | // kebab-case | ||
| 483 | assert_eq!( | ||
| 484 | parse_open("<my-custom-tag>"), | ||
| 485 | Ok(Open { | ||
| 486 | tag_name: "my-custom-tag", | ||
| 487 | content: "<my-custom-tag>", | ||
| 488 | self_closing: false, | ||
| 489 | }) | ||
| 490 | ); | ||
| 491 | // PascalCase (for custom tags referencing imports) | ||
| 492 | assert_eq!( | ||
| 493 | parse_open("<MyComponent>"), | ||
| 494 | Ok(Open { | ||
| 495 | tag_name: "MyComponent", | ||
| 496 | content: "<MyComponent>", | ||
| 497 | self_closing: false, | ||
| 498 | }) | ||
| 499 | ); | ||
| 500 | // with underscore | ||
| 501 | assert_eq!( | ||
| 502 | parse_open("<my_tag>"), | ||
| 503 | Ok(Open { | ||
| 504 | tag_name: "my_tag", | ||
| 505 | content: "<my_tag>", | ||
| 506 | self_closing: false, | ||
| 507 | }) | ||
| 508 | ); | ||
| 509 | // with $ (valid in Marko) | ||
| 510 | assert_eq!( | ||
| 511 | parse_open("<$tag>"), | ||
| 512 | Ok(Open { | ||
| 513 | tag_name: "$tag", | ||
| 514 | content: "<$tag>", | ||
| 515 | self_closing: false, | ||
| 516 | }) | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | |||
| 520 | #[test] | ||
| 521 | fn test_close() { | ||
| 522 | assert_eq!( | ||
| 523 | parse_close("</>"), | ||
| 524 | Ok(Close { | ||
| 525 | tag_name: None, | ||
| 526 | length: 3 | ||
| 527 | }) | ||
| 528 | ); | ||
| 529 | assert_eq!( | ||
| 530 | parse_close("</>ZZZZZZ"), | ||
| 531 | Ok(Close { | ||
| 532 | tag_name: None, | ||
| 533 | length: 3 | ||
| 534 | }) | ||
| 535 | ); | ||
| 536 | assert_eq!( | ||
| 537 | parse_close("</div>ZZZZZZ"), | ||
| 538 | Ok(Close { | ||
| 539 | tag_name: Some("div"), | ||
| 540 | length: 6 | ||
| 541 | }) | ||
| 542 | ); | ||
| 543 | } | ||
| 544 | |||
| 545 | #[test] | ||
| 546 | fn test_close_named() { | ||
| 547 | assert_eq!( | ||
| 548 | parse_close("</my-component>"), | ||
| 549 | Ok(Close { | ||
| 550 | tag_name: Some("my-component"), | ||
| 551 | length: 15 | ||
| 552 | }) | ||
| 553 | ); | ||
| 554 | assert_eq!( | ||
| 555 | parse_close("</MyComponent>"), | ||
| 556 | Ok(Close { | ||
| 557 | tag_name: Some("MyComponent"), | ||
| 558 | length: 14 | ||
| 559 | }) | ||
| 560 | ); | ||
| 561 | } | ||
| 562 | |||
| 563 | #[test] | ||
| 564 | fn test_attribute_tag_basic_open() { | ||
| 565 | // @attr tag without self-closing works | ||
| 566 | assert_eq!( | ||
| 567 | parse_open("<@header>"), | ||
| 568 | Ok(Open { | ||
| 569 | tag_name: "@header", | ||
| 570 | content: "<@header>", | ||
| 571 | self_closing: false, | ||
| 572 | }) | ||
| 573 | ); | ||
| 574 | } | ||
| 575 | |||
| 576 | #[test] | ||
| 577 | fn test_attribute_tag_self_closing() { | ||
| 578 | assert_eq!( | ||
| 579 | parse_open("<@item/>"), | ||
| 580 | Ok(Open { | ||
| 581 | tag_name: "@item", | ||
| 582 | content: "<@item/>", | ||
| 583 | self_closing: true, | ||
| 584 | }) | ||
| 585 | ); | ||
| 586 | } | ||
| 587 | |||
| 588 | #[test] | ||
| 589 | fn test_attribute_tag_with_string_attr() { | ||
| 590 | assert_eq!( | ||
| 591 | parse_open("<@option value=\"foo\">"), | ||
| 592 | Ok(Open { | ||
| 593 | tag_name: "@option", | ||
| 594 | content: "<@option value=\"foo\">", | ||
| 595 | self_closing: false, | ||
| 596 | }) | ||
| 597 | ); | ||
| 598 | } | ||
| 599 | |||
| 600 | #[test] | ||
| 601 | fn test_attribute_tag_no_variable() { | ||
| 602 | // Attribute tags do not support tag variables - correctly errors | ||
| 603 | let result = parse_open("<@header/myVar>"); | ||
| 604 | assert!(result.is_err()); | ||
| 605 | } | ||
| 606 | |||
| 607 | #[test] | ||
| 608 | fn test_attribute_tag_no_arguments() { | ||
| 609 | // Attribute tags do not support tag arguments - correctly errors | ||
| 610 | let result = parse_open("<@header(arg)>"); | ||
| 611 | assert!(result.is_err()); | ||
| 612 | } | ||
| 613 | |||
| 614 | // =========================================== | ||
| 615 | // Shorthand class and id | ||
| 616 | // =========================================== | ||
| 617 | |||
| 618 | #[test] | ||
| 619 | fn test_shorthand_id() { | ||
| 620 | assert_eq!( | ||
| 621 | parse_open("<div#myId>"), | ||
| 622 | Ok(Open { | ||
| 623 | tag_name: "div", | ||
| 624 | content: "<div#myId>", | ||
| 625 | self_closing: false, | ||
| 626 | }) | ||
| 627 | ); | ||
| 628 | } | ||
| 629 | |||
| 630 | #[test] | ||
| 631 | fn test_shorthand_class() { | ||
| 632 | assert_eq!( | ||
| 633 | parse_open("<div.myClass>"), | ||
| 634 | Ok(Open { | ||
| 635 | tag_name: "div", | ||
| 636 | content: "<div.myClass>", | ||
| 637 | self_closing: false, | ||
| 638 | }) | ||
| 639 | ); | ||
| 640 | } | ||
| 641 | |||
| 642 | #[test] | ||
| 643 | fn test_shorthand_multiple_classes() { | ||
| 644 | assert_eq!( | ||
| 645 | parse_open("<div.cls1.cls2.cls3>"), | ||
| 646 | Ok(Open { | ||
| 647 | tag_name: "div", | ||
| 648 | content: "<div.cls1.cls2.cls3>", | ||
| 649 | self_closing: false, | ||
| 650 | }) | ||
| 651 | ); | ||
| 652 | } | ||
| 653 | |||
| 654 | #[test] | ||
| 655 | fn test_shorthand_id_and_classes() { | ||
| 656 | assert_eq!( | ||
| 657 | parse_open("<div#myId.cls1.cls2>"), | ||
| 658 | Ok(Open { | ||
| 659 | tag_name: "div", | ||
| 660 | content: "<div#myId.cls1.cls2>", | ||
| 661 | self_closing: false, | ||
| 662 | }) | ||
| 663 | ); | ||
| 664 | } | ||
| 665 | |||
| 666 | #[test] | ||
| 667 | fn test_shorthand_classes_then_id() { | ||
| 668 | // Order shouldn't matter | ||
| 669 | assert_eq!( | ||
| 670 | parse_open("<div.cls1#myId.cls2>"), | ||
| 671 | Ok(Open { | ||
| 672 | tag_name: "div", | ||
| 673 | content: "<div.cls1#myId.cls2>", | ||
| 674 | self_closing: false, | ||
| 675 | }) | ||
| 676 | ); | ||
| 677 | } | ||
| 678 | |||
| 679 | #[test] | ||
| 680 | fn test_shorthand_duplicate_id_error() { | ||
| 681 | // Cannot have two ID shorthands - correctly errors | ||
| 682 | let result = parse_open("<div#id1#id2>"); | ||
| 683 | assert!(result.is_err()); | ||
| 684 | } | ||
| 685 | |||
| 686 | #[test] | ||
| 687 | fn test_shorthand_with_hyphen() { | ||
| 688 | // Classes/IDs can contain hyphens | ||
| 689 | assert_eq!( | ||
| 690 | parse_open("<div.my-class#my-id>"), | ||
| 691 | Ok(Open { | ||
| 692 | tag_name: "div", | ||
| 693 | content: "<div.my-class#my-id>", | ||
| 694 | self_closing: false, | ||
| 695 | }) | ||
| 696 | ); | ||
| 697 | } | ||
| 698 | |||
| 699 | #[test] | ||
| 700 | fn test_shorthand_empty_class_error() { | ||
| 701 | // Empty class correctly errors | ||
| 702 | let result = parse_open("<div.>"); | ||
| 703 | assert!(result.is_err()); | ||
| 704 | } | ||
| 705 | |||
| 706 | #[test] | ||
| 707 | fn test_shorthand_empty_id_error() { | ||
| 708 | // Empty id correctly errors | ||
| 709 | let result = parse_open("<div#>"); | ||
| 710 | assert!(result.is_err()); | ||
| 711 | } | ||
| 712 | |||
| 713 | // =========================================== | ||
| 714 | // Tag variables (/var) | ||
| 715 | // =========================================== | ||
| 716 | |||
| 717 | #[test] | ||
| 718 | fn test_tag_variable_simple() { | ||
| 719 | assert_eq!( | ||
| 720 | parse_open("<my-tag/foo>"), | ||
| 721 | Ok(Open { | ||
| 722 | tag_name: "my-tag", | ||
| 723 | content: "<my-tag/foo>", | ||
| 724 | self_closing: false, | ||
| 725 | }) | ||
| 726 | ); | ||
| 727 | } | ||
| 728 | |||
| 729 | // BUG: Variable binding followed by /> fails | ||
| 730 | #[test] | ||
| 731 | fn test_tag_variable_self_closing() { | ||
| 732 | assert_eq!( | ||
| 733 | parse_open("<input/myInput/>"), | ||
| 734 | Ok(Open { | ||
| 735 | tag_name: "input", | ||
| 736 | content: "<input/myInput/>", | ||
| 737 | self_closing: true, | ||
| 738 | }) | ||
| 739 | ); | ||
| 740 | } | ||
| 741 | |||
| 742 | #[test] | ||
| 743 | fn test_tag_variable_destructure_object() { | ||
| 744 | assert_eq!( | ||
| 745 | parse_open("<my-tag/{ a, b }>"), | ||
| 746 | Ok(Open { | ||
| 747 | tag_name: "my-tag", | ||
| 748 | content: "<my-tag/{ a, b }>", | ||
| 749 | self_closing: false, | ||
| 750 | }) | ||
| 751 | ); | ||
| 752 | } | ||
| 753 | |||
| 754 | #[test] | ||
| 755 | fn test_tag_variable_destructure_array() { | ||
| 756 | assert_eq!( | ||
| 757 | parse_open("<my-tag/[x, y]>"), | ||
| 758 | Ok(Open { | ||
| 759 | tag_name: "my-tag", | ||
| 760 | content: "<my-tag/[x, y]>", | ||
| 761 | self_closing: false, | ||
| 762 | }) | ||
| 763 | ); | ||
| 764 | } | ||
| 765 | |||
| 766 | #[test] | ||
| 767 | fn test_tag_variable_with_type() { | ||
| 768 | assert_eq!( | ||
| 769 | parse_open("<const/items: Item[]>"), | ||
| 770 | Ok(Open { | ||
| 771 | tag_name: "const", | ||
| 772 | content: "<const/items: Item[]>", | ||
| 773 | self_closing: false, | ||
| 774 | }) | ||
| 775 | ); | ||
| 776 | } | ||
| 777 | |||
| 778 | #[test] | ||
| 779 | fn test_tag_variable_with_attrs() { | ||
| 780 | assert_eq!( | ||
| 781 | parse_open("<my-tag/result value=42>"), | ||
| 782 | Ok(Open { | ||
| 783 | tag_name: "my-tag", | ||
| 784 | content: "<my-tag/result value=42>", | ||
| 785 | self_closing: false, | ||
| 786 | }) | ||
| 787 | ); | ||
| 788 | } | ||
| 789 | |||
| 790 | // =========================================== | ||
| 791 | // Tag arguments (args) | ||
| 792 | // BUG: Tag arguments without whitespace before ( fail | ||
| 793 | // The parser only enters argument parsing after skip_whitespace, | ||
| 794 | // but <tag()> has no whitespace before ( | ||
| 795 | // =========================================== | ||
| 796 | |||
| 797 | #[test] | ||
| 798 | fn test_tag_arguments_empty() { | ||
| 799 | assert_eq!( | ||
| 800 | parse_open("<my-tag()>"), | ||
| 801 | Ok(Open { | ||
| 802 | tag_name: "my-tag", | ||
| 803 | content: "<my-tag()>", | ||
| 804 | self_closing: false, | ||
| 805 | }) | ||
| 806 | ); | ||
| 807 | } | ||
| 808 | |||
| 809 | #[test] | ||
| 810 | fn test_tag_arguments_simple() { | ||
| 811 | assert_eq!( | ||
| 812 | parse_open("<my-tag(1, 2, 3)>"), | ||
| 813 | Ok(Open { | ||
| 814 | tag_name: "my-tag", | ||
| 815 | content: "<my-tag(1, 2, 3)>", | ||
| 816 | self_closing: false, | ||
| 817 | }) | ||
| 818 | ); | ||
| 819 | } | ||
| 820 | |||
| 821 | #[test] | ||
| 822 | fn test_tag_arguments_expressions() { | ||
| 823 | assert_eq!( | ||
| 824 | parse_open("<my-tag(a + b, fn())>"), | ||
| 825 | Ok(Open { | ||
| 826 | tag_name: "my-tag", | ||
| 827 | content: "<my-tag(a + b, fn())>", | ||
| 828 | self_closing: false, | ||
| 829 | }) | ||
| 830 | ); | ||
| 831 | } | ||
| 832 | |||
| 833 | #[test] | ||
| 834 | fn test_tag_arguments_spread() { | ||
| 835 | assert_eq!( | ||
| 836 | parse_open("<my-tag(...args)>"), | ||
| 837 | Ok(Open { | ||
| 838 | tag_name: "my-tag", | ||
| 839 | content: "<my-tag(...args)>", | ||
| 840 | self_closing: false, | ||
| 841 | }) | ||
| 842 | ); | ||
| 843 | } | ||
| 844 | |||
| 845 | #[test] | ||
| 846 | fn test_tag_arguments_object() { | ||
| 847 | assert_eq!( | ||
| 848 | parse_open("<my-tag({ a: 1, b: 2 })>"), | ||
| 849 | Ok(Open { | ||
| 850 | tag_name: "my-tag", | ||
| 851 | content: "<my-tag({ a: 1, b: 2 })>", | ||
| 852 | self_closing: false, | ||
| 853 | }) | ||
| 854 | ); | ||
| 855 | } | ||
| 856 | |||
| 857 | // Test that tag arguments WITH whitespace work | ||
| 858 | #[test] | ||
| 859 | fn test_tag_arguments_with_space() { | ||
| 860 | assert_eq!( | ||
| 861 | parse_open("<my-tag (1, 2, 3)>"), | ||
| 862 | Ok(Open { | ||
| 863 | tag_name: "my-tag", | ||
| 864 | content: "<my-tag (1, 2, 3)>", | ||
| 865 | self_closing: false, | ||
| 866 | }) | ||
| 867 | ); | ||
| 868 | } | ||
| 869 | |||
| 870 | // =========================================== | ||
| 871 | // Attributes | ||
| 872 | // =========================================== | ||
| 873 | |||
| 874 | // BUG: String literals fail - expression parser doesn't terminate at > after string | ||
| 875 | #[test] | ||
| 876 | fn test_attr_simple_string() { | ||
| 877 | assert_eq!( | ||
| 878 | parse_open("<div class=\"foo\">"), | ||
| 879 | Ok(Open { | ||
| 880 | tag_name: "div", | ||
| 881 | content: "<div class=\"foo\">", | ||
| 882 | self_closing: false, | ||
| 883 | }) | ||
| 884 | ); | ||
| 885 | } | ||
| 886 | |||
| 887 | #[test] | ||
| 888 | fn test_attr_expression() { | ||
| 889 | // Identifier expressions work | ||
| 890 | assert_eq!( | ||
| 891 | parse_open("<div count=1 + 1>"), | ||
| 892 | Ok(Open { | ||
| 893 | tag_name: "div", | ||
| 894 | content: "<div count=1 + 1>", | ||
| 895 | self_closing: false, | ||
| 896 | }) | ||
| 897 | ); | ||
| 898 | } | ||
| 899 | |||
| 900 | // BUG: Template literals fail | ||
| 901 | #[test] | ||
| 902 | fn test_attr_template_literal() { | ||
| 903 | assert_eq!( | ||
| 904 | parse_open("<div class=`hello ${name}`>"), | ||
| 905 | Ok(Open { | ||
| 906 | tag_name: "div", | ||
| 907 | content: "<div class=`hello ${name}`>", | ||
| 908 | self_closing: false, | ||
| 909 | }) | ||
| 910 | ); | ||
| 911 | } | ||
| 912 | |||
| 913 | #[test] | ||
| 914 | fn test_attr_spread() { | ||
| 915 | assert_eq!( | ||
| 916 | parse_open("<div ...props>"), | ||
| 917 | Ok(Open { | ||
| 918 | tag_name: "div", | ||
| 919 | content: "<div ...props>", | ||
| 920 | self_closing: false, | ||
| 921 | }) | ||
| 922 | ); | ||
| 923 | } | ||
| 924 | |||
| 925 | // BUG: Object literals with > in them fail | ||
| 926 | #[test] | ||
| 927 | fn test_attr_spread_object() { | ||
| 928 | assert_eq!( | ||
| 929 | parse_open("<div ...{ a: 1, b: 2 }>"), | ||
| 930 | Ok(Open { | ||
| 931 | tag_name: "div", | ||
| 932 | content: "<div ...{ a: 1, b: 2 }>", | ||
| 933 | self_closing: false, | ||
| 934 | }) | ||
| 935 | ); | ||
| 936 | } | ||
| 937 | |||
| 938 | // BUG: Multiple attributes with string values fail | ||
| 939 | #[test] | ||
| 940 | fn test_attr_multiple_spreads() { | ||
| 941 | assert_eq!( | ||
| 942 | parse_open("<div ...a ...b foo=\"bar\">"), | ||
| 943 | Ok(Open { | ||
| 944 | tag_name: "div", | ||
| 945 | content: "<div ...a ...b foo=\"bar\">", | ||
| 946 | self_closing: false, | ||
| 947 | }) | ||
| 948 | ); | ||
| 949 | } | ||
| 950 | |||
| 951 | #[test] | ||
| 952 | fn test_attr_two_way_binding() { | ||
| 953 | assert_eq!( | ||
| 954 | parse_open("<counter value:=count>"), | ||
| 955 | Ok(Open { | ||
| 956 | tag_name: "counter", | ||
| 957 | content: "<counter value:=count>", | ||
| 958 | self_closing: false, | ||
| 959 | }) | ||
| 960 | ); | ||
| 961 | } | ||
| 962 | |||
| 963 | #[test] | ||
| 964 | fn test_attr_two_way_binding_property() { | ||
| 965 | assert_eq!( | ||
| 966 | parse_open("<counter value:=input.count>"), | ||
| 967 | Ok(Open { | ||
| 968 | tag_name: "counter", | ||
| 969 | content: "<counter value:=input.count>", | ||
| 970 | self_closing: false, | ||
| 971 | }) | ||
| 972 | ); | ||
| 973 | } | ||
| 974 | |||
| 975 | #[test] | ||
| 976 | fn test_attr_method_shorthand() { | ||
| 977 | assert_eq!( | ||
| 978 | parse_open("<button onClick(e) { console.log(e) }>"), | ||
| 979 | Ok(Open { | ||
| 980 | tag_name: "button", | ||
| 981 | content: "<button onClick(e) { console.log(e) }>", | ||
| 982 | self_closing: false, | ||
| 983 | }) | ||
| 984 | ); | ||
| 985 | } | ||
| 986 | |||
| 987 | #[test] | ||
| 988 | fn test_attr_method_with_return_type() { | ||
| 989 | assert_eq!( | ||
| 990 | parse_open("<button onClick(e): void { console.log(e) }>"), | ||
| 991 | Ok(Open { | ||
| 992 | tag_name: "button", | ||
| 993 | content: "<button onClick(e): void { console.log(e) }>", | ||
| 994 | self_closing: false, | ||
| 995 | }) | ||
| 996 | ); | ||
| 997 | } | ||
| 998 | |||
| 999 | #[test] | ||
| 1000 | fn test_attr_boolean() { | ||
| 1001 | assert_eq!( | ||
| 1002 | parse_open("<input type=\"checkbox\" checked>"), | ||
| 1003 | Ok(Open { | ||
| 1004 | tag_name: "input", | ||
| 1005 | content: "<input type=\"checkbox\" checked>", | ||
| 1006 | self_closing: false, | ||
| 1007 | }) | ||
| 1008 | ); | ||
| 1009 | } | ||
| 1010 | |||
| 1011 | #[test] | ||
| 1012 | fn test_attr_boolean_then_attr() { | ||
| 1013 | assert_eq!( | ||
| 1014 | parse_open("<input checked foo=1>"), | ||
| 1015 | Ok(Open { | ||
| 1016 | tag_name: "input", | ||
| 1017 | content: "<input checked foo=1>", | ||
| 1018 | self_closing: false, | ||
| 1019 | }) | ||
| 1020 | ); | ||
| 1021 | } | ||
| 1022 | |||
| 1023 | #[test] | ||
| 1024 | fn test_attr_with_colon() { | ||
| 1025 | assert_eq!( | ||
| 1026 | parse_open("<div data:value=\"test\">"), | ||
| 1027 | Ok(Open { | ||
| 1028 | tag_name: "div", | ||
| 1029 | content: "<div data:value=\"test\">", | ||
| 1030 | self_closing: false, | ||
| 1031 | }) | ||
| 1032 | ); | ||
| 1033 | } | ||
| 1034 | |||
| 1035 | #[test] | ||
| 1036 | fn test_shorthand_value() { | ||
| 1037 | assert_eq!( | ||
| 1038 | parse_open("<my-tag=1>"), | ||
| 1039 | Ok(Open { | ||
| 1040 | tag_name: "my-tag", | ||
| 1041 | content: "<my-tag=1>", | ||
| 1042 | self_closing: false, | ||
| 1043 | }) | ||
| 1044 | ); | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | #[test] | ||
| 1048 | fn test_shorthand_value_expression() { | ||
| 1049 | assert_eq!( | ||
| 1050 | parse_open("<my-tag=a + b>"), | ||
| 1051 | Ok(Open { | ||
| 1052 | tag_name: "my-tag", | ||
| 1053 | content: "<my-tag=a + b>", | ||
| 1054 | self_closing: false, | ||
| 1055 | }) | ||
| 1056 | ); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | #[test] | ||
| 1060 | fn test_shorthand_value_with_other_attrs() { | ||
| 1061 | assert_eq!( | ||
| 1062 | parse_open("<my-tag=42 foo=\"bar\">"), | ||
| 1063 | Ok(Open { | ||
| 1064 | tag_name: "my-tag", | ||
| 1065 | content: "<my-tag=42 foo=\"bar\">", | ||
| 1066 | self_closing: false, | ||
| 1067 | }) | ||
| 1068 | ); | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | #[test] | ||
| 1072 | fn test_combined_variable_and_args() { | ||
| 1073 | assert_eq!( | ||
| 1074 | parse_open("<for/item (items)>"), | ||
| 1075 | Ok(Open { | ||
| 1076 | tag_name: "for", | ||
| 1077 | content: "<for/item (items)>", | ||
| 1078 | self_closing: false, | ||
| 1079 | }) | ||
| 1080 | ); | ||
| 1081 | } | ||
| 1082 | |||
| 1083 | #[test] | ||
| 1084 | fn test_combined_id_class_variable() { | ||
| 1085 | assert_eq!( | ||
| 1086 | parse_open("<div#myId.cls/ref>"), | ||
| 1087 | Ok(Open { | ||
| 1088 | tag_name: "div", | ||
| 1089 | content: "<div#myId.cls/ref>", | ||
| 1090 | self_closing: false, | ||
| 1091 | }) | ||
| 1092 | ); | ||
| 1093 | } | ||
| 1094 | |||
| 1095 | #[test] | ||
| 1096 | fn test_combined_complex() { | ||
| 1097 | assert_eq!( | ||
| 1098 | parse_open("<my-tag#id.cls/result(arg1, arg2) foo=bar ...spread>"), | ||
| 1099 | Ok(Open { | ||
| 1100 | tag_name: "my-tag", | ||
| 1101 | content: "<my-tag#id.cls/result(arg1, arg2) foo=bar ...spread>", | ||
| 1102 | self_closing: false, | ||
| 1103 | }) | ||
| 1104 | ); | ||
| 1105 | } | ||
| 1106 | |||
| 1107 | #[test] | ||
| 1108 | fn test_attr_with_gt_in_parens() { | ||
| 1109 | assert_eq!( | ||
| 1110 | parse_open("<div value=(a > b)>"), | ||
| 1111 | Ok(Open { | ||
| 1112 | tag_name: "div", | ||
| 1113 | content: "<div value=(a > b)>", | ||
| 1114 | self_closing: false, | ||
| 1115 | }) | ||
| 1116 | ); | ||
| 1117 | } | ||
| 1118 | |||
| 1119 | #[test] | ||
| 1120 | fn test_attr_with_gt_in_brackets() { | ||
| 1121 | assert_eq!( | ||
| 1122 | parse_open("<div value=arr[a > 0]>"), | ||
| 1123 | Ok(Open { | ||
| 1124 | tag_name: "div", | ||
| 1125 | content: "<div value=arr[a > 0]>", | ||
| 1126 | self_closing: false, | ||
| 1127 | }) | ||
| 1128 | ); | ||
| 1129 | } | ||
| 1130 | |||
| 1131 | #[test] | ||
| 1132 | fn test_attr_arrow_function() { | ||
| 1133 | assert_eq!( | ||
| 1134 | parse_open("<div onClick=() => console.log(1)>"), | ||
| 1135 | Ok(Open { | ||
| 1136 | tag_name: "div", | ||
| 1137 | content: "<div onClick=() => console.log(1)>", | ||
| 1138 | self_closing: false, | ||
| 1139 | }) | ||
| 1140 | ); | ||
| 1141 | } | ||
| 1142 | |||
| 1143 | #[test] | ||
| 1144 | fn test_attr_ternary() { | ||
| 1145 | assert_eq!( | ||
| 1146 | parse_open("<div class=isActive ? a : b> > > > >"), | ||
| 1147 | Ok(Open { | ||
| 1148 | tag_name: "div", | ||
| 1149 | content: "<div class=isActive ? a : b>", | ||
| 1150 | self_closing: false, | ||
| 1151 | }) | ||
| 1152 | ); | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | #[test] | ||
| 1156 | fn test_error_empty_tag_name() { | ||
| 1157 | let result = parse_open("<>"); | ||
| 1158 | assert!(result.is_err()); | ||
| 1159 | } | ||
| 1160 | |||
| 1161 | #[test] | ||
| 1162 | fn test_error_missing_close() { | ||
| 1163 | let result = parse_open("<div"); | ||
| 1164 | assert!(result.is_err()); | ||
| 1165 | } | ||
| 1166 | |||
| 1167 | #[test] | ||
| 1168 | fn test_error_close_missing_gt() { | ||
| 1169 | let result = parse_close("</div"); | ||
| 1170 | assert!(result.is_err()); | ||
| 1171 | } | ||
| 1172 | |||
| 1173 | #[test] | ||
| 1174 | fn test_parse_tag_open() { | ||
| 1175 | let result = parse_tag("<div>").unwrap(); | ||
| 1176 | match result { | ||
| 1177 | OpenOrClose::Open(open) => { | ||
| 1178 | assert_eq!(open.tag_name, "div"); | ||
| 1179 | } | ||
| 1180 | OpenOrClose::Close(_) => panic!("Expected Open"), | ||
| 1181 | } | ||
| 1182 | } | ||
| 1183 | |||
| 1184 | #[test] | ||
| 1185 | fn test_parse_tag_close() { | ||
| 1186 | let result = parse_tag("</>").unwrap(); | ||
| 1187 | match result { | ||
| 1188 | OpenOrClose::Open(_) => panic!("Expected Close"), | ||
| 1189 | OpenOrClose::Close(close) => { | ||
| 1190 | assert_eq!(close.tag_name, None); | ||
| 1191 | } | ||
| 1192 | } | ||
| 1193 | } | ||
| 1194 | |||
| 1195 | #[test] | ||
| 1196 | fn test_parse_tag_close_named() { | ||
| 1197 | let result = parse_tag("</div>").unwrap(); | ||
| 1198 | match result { | ||
| 1199 | OpenOrClose::Open(_) => panic!("Expected Close"), | ||
| 1200 | OpenOrClose::Close(close) => { | ||
| 1201 | assert_eq!(close.tag_name, Some("div")); | ||
| 1202 | } | ||
| 1203 | } | ||
| 1204 | } | ||
| 1205 | |||
| 1206 | #[test] | ||
| 1207 | fn test_lexstate_peek_byte() { | ||
| 1208 | let l = LexState::new("abc"); | ||
| 1209 | assert_eq!(l.peek_byte(), Some(b'a')); | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | #[test] | ||
| 1213 | fn test_lexstate_peek_byte_empty() { | ||
| 1214 | let l = LexState::new(""); | ||
| 1215 | assert_eq!(l.peek_byte(), None); | ||
| 1216 | } | ||
| 1217 | |||
| 1218 | #[test] | ||
| 1219 | fn test_lexstate_advance() { | ||
| 1220 | let mut l = LexState::new("abc"); | ||
| 1221 | l.advance(1); | ||
| 1222 | assert_eq!(l.peek_byte(), Some(b'b')); | ||
| 1223 | l.advance(1); | ||
| 1224 | assert_eq!(l.peek_byte(), Some(b'c')); | ||
| 1225 | } | ||
| 1226 | |||
| 1227 | #[test] | ||
| 1228 | fn test_lexstate_skip_whitespace() { | ||
| 1229 | let mut l = LexState::new(" abc"); | ||
| 1230 | l.skip_whitespace(); | ||
| 1231 | assert_eq!(l.peek_byte(), Some(b'a')); | ||
| 1232 | } | ||
| 1233 | |||
| 1234 | #[test] | ||
| 1235 | fn test_lexstate_skip_whitespace_mixed() { | ||
| 1236 | let mut l = LexState::new(" \t\n\rabc"); | ||
| 1237 | l.skip_whitespace(); | ||
| 1238 | assert_eq!(l.peek_byte(), Some(b'a')); | ||
| 1239 | } | ||
| 1240 | |||
| 1241 | #[test] | ||
| 1242 | fn test_lexstate_expect() { | ||
| 1243 | let mut l = LexState::new("hello world"); | ||
| 1244 | assert!(l.expect("hello").is_ok()); | ||
| 1245 | assert_eq!(l.offset(), 5); | ||
| 1246 | } | ||
| 1247 | |||
| 1248 | #[test] | ||
| 1249 | fn test_lexstate_expect_fail() { | ||
| 1250 | let mut l = LexState::new("hello"); | ||
| 1251 | assert!(l.expect("world").is_err()); | ||
| 1252 | assert_eq!(l.offset(), 0); // offset unchanged on failure | ||
| 1253 | } | ||
| 1254 | |||
| 1255 | #[test] | ||
| 1256 | fn test_lexstate_restore() { | ||
| 1257 | let mut l = LexState::new("abcdef"); | ||
| 1258 | l.advance(3); | ||
| 1259 | assert_eq!(l.offset(), 3); | ||
| 1260 | l.restore(1); | ||
| 1261 | assert_eq!(l.offset(), 1); | ||
| 1262 | assert_eq!(l.peek_byte(), Some(b'b')); | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | #[test] | ||
| 1266 | fn test_lexstate_peek_rest() { | ||
| 1267 | let mut l = LexState::new("hello world"); | ||
| 1268 | l.advance(6); | ||
| 1269 | assert_eq!(l.peek_rest(), "world"); | ||
| 1270 | } | ||
| 1271 | |||
| 1272 | #[test] | ||
| 1273 | #[ignore = "need to fix parse_var_binding"] | ||
| 1274 | fn test_tag_params_simple() { | ||
| 1275 | parse_open("<for|item| of=items>").unwrap(); | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | #[test] | ||
| 1279 | #[ignore = "need to fix parse_var_binding"] | ||
| 1280 | fn test_tag_params_destructure() { | ||
| 1281 | parse_open("<for|{ name, age }| of=people>").unwrap(); | ||
| 1282 | } | ||
| 1283 | |||
| 1284 | #[test] | ||
| 1285 | #[ignore = "NOT IMPLEMENTED: Dynamic class/id with $ interpolation hits todo!()"] | ||
| 1286 | fn test_shorthand_dynamic_class() { | ||
| 1287 | let result = parse_open("<div.color-${iconName}/>"); | ||
| 1288 | assert!(result.is_ok()); | ||
| 1289 | } | ||
| 1290 | |||
| 1291 | #[test] | ||
| 1292 | fn test_dynamic_tag() { | ||
| 1293 | let result = parse_open("<${MyComponent}/>"); | ||
| 1294 | assert!(result.is_ok()); | ||
| 1295 | } | ||
| 1296 | } | ||
src/plugin/autolink.rs created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | use markdown_it::parser::inline::{InlineRule, InlineState}; | ||
| 2 | use markdown_it::{Node, NodeValue, Renderer}; | ||
| 3 | |||
| 4 | /// Custom autolink for relative paths (./path and ../path) | ||
| 5 | #[derive(Debug)] | ||
| 6 | pub struct RelativeAutolink { | ||
| 7 | pub href: String, | ||
| 8 | } | ||
| 9 | |||
| 10 | impl NodeValue for RelativeAutolink { | ||
| 11 | fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { | ||
| 12 | fmt.open("a", &[("href", self.href.clone())]); | ||
| 13 | fmt.text(&self.href); | ||
| 14 | fmt.close("a"); | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Parse relative path autolinks: <./path> and <../path> | ||
| 19 | pub struct Rule; | ||
| 20 | |||
| 21 | impl InlineRule for Rule { | ||
| 22 | const MARKER: char = '<'; | ||
| 23 | |||
| 24 | fn run(state: &mut InlineState) -> Option<(Node, usize)> { | ||
| 25 | let input = &state.src[state.pos..state.pos_max]; | ||
| 26 | |||
| 27 | if !input.starts_with('<') { | ||
| 28 | return None; | ||
| 29 | } | ||
| 30 | |||
| 31 | let after_bracket = &input[1..]; | ||
| 32 | |||
| 33 | if !after_bracket.starts_with("./") && !after_bracket.starts_with("../") { | ||
| 34 | return None; | ||
| 35 | } | ||
| 36 | |||
| 37 | let end = after_bracket.find('>')?; | ||
| 38 | let href = &after_bracket[..end]; | ||
| 39 | let node = Node::new(RelativeAutolink { | ||
| 40 | href: href.to_string(), | ||
| 41 | }); | ||
| 42 | |||
| 43 | // Return node and number of characters consumed (including < and >) | ||
| 44 | Some((node, end + 2)) | ||
| 45 | } | ||
| 46 | } | ||
src/plugin/comment.rs created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | use markdown_it::parser::block::{BlockRule, BlockState}; | ||
| 2 | use markdown_it::Node; | ||
| 3 | |||
| 4 | use crate::plugin::{get_line_raw, RawBlock}; | ||
| 5 | |||
| 6 | /// Parse line comments (//) and HTML comments (<!--) | ||
| 7 | pub(crate) struct Rule; | ||
| 8 | |||
| 9 | impl BlockRule for Rule { | ||
| 10 | fn run(state: &mut BlockState) -> Option<(Node, usize)> { | ||
| 11 | if state.line >= state.line_max { | ||
| 12 | return None; | ||
| 13 | } | ||
| 14 | |||
| 15 | let line = get_line_raw(state, state.line); | ||
| 16 | |||
| 17 | if !line.starts_with("//") && !line.starts_with("<!--") { | ||
| 18 | return None; | ||
| 19 | } | ||
| 20 | |||
| 21 | let (end_line, is_closed) = if line.starts_with("//") { | ||
| 22 | (state.line, true) | ||
| 23 | } else { | ||
| 24 | find_html_comment_end(state, state.line) | ||
| 25 | }; | ||
| 26 | |||
| 27 | if !is_closed { | ||
| 28 | return None; | ||
| 29 | } | ||
| 30 | |||
| 31 | let mut content_lines = Vec::new(); | ||
| 32 | for line_idx in state.line..=end_line { | ||
| 33 | content_lines.push(get_line_raw(state, line_idx).to_string()); | ||
| 34 | } | ||
| 35 | |||
| 36 | let mut content = content_lines.join("\n"); | ||
| 37 | content.push('\n'); | ||
| 38 | |||
| 39 | Some((Node::new(RawBlock { content }), end_line - state.line + 1)) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Find the end line of an HTML comment and whether it's properly closed | ||
| 44 | fn find_html_comment_end(state: &BlockState, start_line: usize) -> (usize, bool) { | ||
| 45 | for line_idx in start_line..state.line_max { | ||
| 46 | let line = get_line_raw(state, line_idx); | ||
| 47 | if line.contains("-->") { | ||
| 48 | return (line_idx, true); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | // Comment never closed | ||
| 52 | (state.line_max - 1, false) | ||
| 53 | } | ||
src/plugin/frontmatter.rs created+150| ... | @@ -0,0 +1,150 @@ | ||
| 1 | use markdown_it::parser::block::{BlockRule, BlockState}; | ||
| 2 | use markdown_it::Node; | ||
| 3 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | ||
| 4 | |||
| 5 | use crate::plugin::{get_line_raw, ErrorBlock, RawBlock}; | ||
| 6 | |||
| 7 | /// Parse frontmatter (---) at document start | ||
| 8 | pub(crate) struct Rule; | ||
| 9 | |||
| 10 | /// Strip '//' comments from YAML content | ||
| 11 | fn strip_js_comments(yaml: &str) -> String { | ||
| 12 | yaml.lines() | ||
| 13 | .map(|line| line.find("//").map_or(line, |pos| &line[..pos])) | ||
| 14 | .collect::<Vec<_>>() | ||
| 15 | .join("\n") | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Convert a YAML value to a JavaScript literal | ||
| 19 | fn yaml_value_to_js(val: &serde_yml::Value) -> String { | ||
| 20 | match val { | ||
| 21 | serde_yml::Value::String(s) => { | ||
| 22 | let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); | ||
| 23 | format!("\"{escaped}\"") | ||
| 24 | } | ||
| 25 | serde_yml::Value::Number(n) => n.to_string(), | ||
| 26 | serde_yml::Value::Bool(b) => b.to_string(), | ||
| 27 | serde_yml::Value::Null => "null".to_string(), | ||
| 28 | serde_yml::Value::Mapping(_) | serde_yml::Value::Sequence(_) => { | ||
| 29 | // For complex types, serialize as JSON | ||
| 30 | serde_json::to_string(val).unwrap_or_else(|_| "null".to_string()) | ||
| 31 | } | ||
| 32 | serde_yml::Value::Tagged(_) => "null".to_string(), | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Convert YAML mapping to JavaScript export statements | ||
| 37 | fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> { | ||
| 38 | let map = match value { | ||
| 39 | serde_yml::Value::Mapping(m) => m, | ||
| 40 | _ => { | ||
| 41 | return Ok(format!( | ||
| 42 | "export const frontmatter = {};", | ||
| 43 | yaml_value_to_js(value) | ||
| 44 | )) | ||
| 45 | } | ||
| 46 | }; | ||
| 47 | |||
| 48 | let mut exports = Vec::new(); | ||
| 49 | for (key, val) in map { | ||
| 50 | let key_str = match key { | ||
| 51 | serde_yml::Value::String(s) => s, | ||
| 52 | _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")), | ||
| 53 | }; | ||
| 54 | |||
| 55 | if matches!(val, serde_yml::Value::Tagged(_)) { | ||
| 56 | return Err("YAML tags are not supported in markodown frontmatter".to_string()); | ||
| 57 | } | ||
| 58 | |||
| 59 | exports.push(format!( | ||
| 60 | "export const {key_str} = {};", | ||
| 61 | yaml_value_to_js(val) | ||
| 62 | )); | ||
| 63 | } | ||
| 64 | |||
| 65 | Ok(exports.join("\n")) | ||
| 66 | } | ||
| 67 | |||
| 68 | impl BlockRule for Rule { | ||
| 69 | fn run(state: &mut BlockState) -> Option<(Node, usize)> { | ||
| 70 | // Only at the start of the document | ||
| 71 | if state.line != 0 || state.line >= state.line_max { | ||
| 72 | return None; | ||
| 73 | } | ||
| 74 | |||
| 75 | // Check for opening delimiter | ||
| 76 | let line = get_line_raw(state, state.line); | ||
| 77 | if line.trim() != "---" { | ||
| 78 | return None; | ||
| 79 | } | ||
| 80 | |||
| 81 | // Collect frontmatter lines until closing delimiter | ||
| 82 | let start_line = state.line; | ||
| 83 | let mut end_line = state.line + 1; | ||
| 84 | let mut lines = Vec::new(); | ||
| 85 | |||
| 86 | while end_line < state.line_max { | ||
| 87 | let line = get_line_raw(state, end_line); | ||
| 88 | if line.trim() == "---" { | ||
| 89 | end_line += 1; | ||
| 90 | break; | ||
| 91 | } | ||
| 92 | lines.push(line); | ||
| 93 | end_line += 1; | ||
| 94 | } | ||
| 95 | |||
| 96 | // Parse YAML and convert to exports | ||
| 97 | let yaml = strip_js_comments(&lines.join("\n")); | ||
| 98 | let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) { | ||
| 99 | Ok(value) => match yaml_to_exports(&value) { | ||
| 100 | Ok(js) => js, | ||
| 101 | Err(msg) => { | ||
| 102 | return Some(( | ||
| 103 | Node::new(ErrorBlock { | ||
| 104 | errors: vec![OxcDiagnostic::error(msg)], | ||
| 105 | }), | ||
| 106 | end_line - start_line, | ||
| 107 | )); | ||
| 108 | } | ||
| 109 | }, | ||
| 110 | Err(e) => { | ||
| 111 | let offset = e | ||
| 112 | .location() | ||
| 113 | .map(|loc| { | ||
| 114 | yaml.lines() | ||
| 115 | .take(loc.line()) | ||
| 116 | .map(|l| l.len() + 1) | ||
| 117 | .sum::<usize>() | ||
| 118 | + loc.column() | ||
| 119 | }) | ||
| 120 | .unwrap_or(0); | ||
| 121 | |||
| 122 | let error = OxcDiagnostic::error(format!("YAML syntax error: {e}")) | ||
| 123 | .and_label(LabeledSpan::new(None, offset, 1)); | ||
| 124 | |||
| 125 | return Some(( | ||
| 126 | Node::new(ErrorBlock { | ||
| 127 | errors: vec![error], | ||
| 128 | }), | ||
| 129 | end_line - start_line, | ||
| 130 | )); | ||
| 131 | } | ||
| 132 | }; | ||
| 133 | |||
| 134 | // Build output with optional trailing newlines | ||
| 135 | let mut content = exports; | ||
| 136 | if !content.is_empty() { | ||
| 137 | content.push('\n'); | ||
| 138 | } | ||
| 139 | |||
| 140 | let mut lines_consumed = end_line - start_line; | ||
| 141 | |||
| 142 | // Consume trailing blank line for separation | ||
| 143 | if end_line < state.line_max && get_line_raw(state, end_line).trim().is_empty() { | ||
| 144 | content.push('\n'); | ||
| 145 | lines_consumed += 1; | ||
| 146 | } | ||
| 147 | |||
| 148 | Some((Node::new(RawBlock { content }), lines_consumed)) | ||
| 149 | } | ||
| 150 | } | ||
src/plugin/inline_tags.rs created+133| ... | @@ -0,0 +1,133 @@ | ||
| 1 | //! Post-processor for matching inline Marko tag markers | ||
| 2 | //! | ||
| 3 | //! This runs after inline parsing to match `MarkoInlineOpenMarker` and | ||
| 4 | //! `MarkoInlineCloseMarker` pairs, wrapping the content between them | ||
| 5 | //! in `MarkoInlineTag` nodes. | ||
| 6 | |||
| 7 | use markdown_it::parser::core::CoreRule; | ||
| 8 | use markdown_it::parser::inline::builtin::InlineParserRule; | ||
| 9 | use markdown_it::{MarkdownIt, Node}; | ||
| 10 | |||
| 11 | use super::tags::{MarkoInlineCloseMarker, MarkoInlineOpenMarker, MarkoInlineTag}; | ||
| 12 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | ||
| 13 | |||
| 14 | pub fn add(md: &mut MarkdownIt) { | ||
| 15 | md.add_rule::<InlineTagMatcher>() | ||
| 16 | .after::<InlineParserRule>(); | ||
| 17 | } | ||
| 18 | |||
| 19 | struct InlineTagMatcher; | ||
| 20 | |||
| 21 | impl CoreRule for InlineTagMatcher { | ||
| 22 | fn run(root: &mut Node, _md: &MarkdownIt) { | ||
| 23 | walk_recursive(root); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | fn walk_recursive(node: &mut Node) { | ||
| 28 | // Match markers at this level first | ||
| 29 | match_markers(node); | ||
| 30 | |||
| 31 | // Then recursively process children (which may include newly created MarkoInlineTag nodes) | ||
| 32 | for child in node.children.iter_mut() { | ||
| 33 | walk_recursive(child); | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | fn match_markers(node: &mut Node) { | ||
| 38 | let mut i = 0; | ||
| 39 | |||
| 40 | while i < node.children.len() { | ||
| 41 | // Look for an open marker | ||
| 42 | let Some(open) = node.children[i].cast::<MarkoInlineOpenMarker>() else { | ||
| 43 | i += 1; | ||
| 44 | continue; | ||
| 45 | }; | ||
| 46 | |||
| 47 | let open_tag_name = open.tag_name.clone(); | ||
| 48 | let open_content = open.content.clone(); | ||
| 49 | |||
| 50 | // Scan forward for matching close marker | ||
| 51 | let mut found_close = None; | ||
| 52 | for j in (i + 1)..node.children.len() { | ||
| 53 | let Some(close) = node.children[j].cast::<MarkoInlineCloseMarker>() else { | ||
| 54 | continue; | ||
| 55 | }; | ||
| 56 | |||
| 57 | // Match if same tag name or generic </> | ||
| 58 | if close.tag_name.as_ref() == Some(&open_tag_name) || close.tag_name.is_none() { | ||
| 59 | found_close = Some((j, close.content.clone())); | ||
| 60 | break; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | let Some((close_idx, close_content)) = found_close else { | ||
| 65 | // No matching close found - leave marker, will be caught as error | ||
| 66 | i += 1; | ||
| 67 | continue; | ||
| 68 | }; | ||
| 69 | |||
| 70 | // Extract content nodes between open and close | ||
| 71 | let content_nodes: Vec<Node> = node.children.drain((i + 1)..close_idx).collect(); | ||
| 72 | |||
| 73 | // Remove the close marker (now at position i + 1 after drain) | ||
| 74 | node.children.remove(i + 1); | ||
| 75 | |||
| 76 | // Replace open marker with the matched tag | ||
| 77 | let mut tag_node = Node::new(MarkoInlineTag { | ||
| 78 | open_tag: open_content, | ||
| 79 | close_tag: close_content, | ||
| 80 | tag_name: open_tag_name, | ||
| 81 | }); | ||
| 82 | tag_node.children = content_nodes; | ||
| 83 | |||
| 84 | // Preserve source map from the open marker | ||
| 85 | tag_node.srcmap = node.children[i].srcmap; | ||
| 86 | |||
| 87 | node.children[i] = tag_node; | ||
| 88 | |||
| 89 | // Don't increment i - we want to check if the new node has nested markers | ||
| 90 | // Actually, we already processed children recursively, so just increment | ||
| 91 | i += 1; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Check for unmatched markers and convert them to errors | ||
| 96 | pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | ||
| 97 | if let Some(open) = node.cast::<MarkoInlineOpenMarker>() { | ||
| 98 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 99 | errors.push( | ||
| 100 | OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label( | ||
| 101 | LabeledSpan::new( | ||
| 102 | None, | ||
| 103 | start + 1, // +1 to skip '<' | ||
| 104 | open.tag_name_len, | ||
| 105 | ), | ||
| 106 | ), | ||
| 107 | ); | ||
| 108 | } else if let Some(close) = node.cast::<MarkoInlineCloseMarker>() { | ||
| 109 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 110 | if let Some(tag_name) = &close.tag_name { | ||
| 111 | errors.push( | ||
| 112 | OxcDiagnostic::error(format!( | ||
| 113 | "Closing inline tag </{tag_name}> without matching open" | ||
| 114 | )) | ||
| 115 | .with_label(LabeledSpan::new( | ||
| 116 | None, | ||
| 117 | start + 2, // +2 to skip '</' | ||
| 118 | close.tag_name_len.unwrap_or(0), | ||
| 119 | )), | ||
| 120 | ); | ||
| 121 | } else { | ||
| 122 | errors.push( | ||
| 123 | OxcDiagnostic::error("Closing inline tag </> without matching open") | ||
| 124 | .with_label(LabeledSpan::new(None, start, 3)), | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | // Recurse into children | ||
| 130 | for child in &node.children { | ||
| 131 | check_unmatched_markers(child, errors); | ||
| 132 | } | ||
| 133 | } | ||
src/plugin/mod.rs created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | pub mod autolink; | ||
| 2 | pub mod comment; | ||
| 3 | pub mod frontmatter; | ||
| 4 | pub mod inline_tags; | ||
| 5 | pub mod statement; | ||
| 6 | pub mod tags; | ||
| 7 | pub mod template; | ||
| 8 | |||
| 9 | use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; | ||
| 10 | use oxc_diagnostics::OxcDiagnostic; | ||
| 11 | |||
| 12 | /// Register all markodown extensions | ||
| 13 | pub fn add_all(md: &mut MarkdownIt) { | ||
| 14 | md.block.add_rule::<frontmatter::Rule>(); | ||
| 15 | md.block.add_rule::<statement::Rule>(); | ||
| 16 | md.block.add_rule::<comment::Rule>(); | ||
| 17 | md.block.add_rule::<tags::Rule>(); | ||
| 18 | |||
| 19 | md.inline.add_rule::<autolink::Rule>(); | ||
| 20 | md.inline.add_rule::<template::Rule>(); | ||
| 21 | md.inline.add_rule::<tags::Rule>(); | ||
| 22 | |||
| 23 | // Post-processor for matching inline tag markers | ||
| 24 | inline_tags::add(md); | ||
| 25 | } | ||
| 26 | |||
| 27 | /// An AST node representing a JavaScript error | ||
| 28 | #[derive(Debug, Clone)] | ||
| 29 | pub(crate) struct ErrorBlock { | ||
| 30 | /// Positions are local to where this parsing began, for simplicity. | ||
| 31 | pub errors: Vec<OxcDiagnostic>, | ||
| 32 | } | ||
| 33 | |||
| 34 | impl NodeValue for ErrorBlock { | ||
| 35 | fn render(&self, _: &Node, _: &mut dyn Renderer) { | ||
| 36 | panic!("cannot render ErrorBlock"); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | /// A raw node that passes Marko syntax verbaitim | ||
| 41 | #[derive(Debug)] | ||
| 42 | pub(crate) struct RawBlock { | ||
| 43 | pub content: String, | ||
| 44 | } | ||
| 45 | |||
| 46 | impl NodeValue for RawBlock { | ||
| 47 | fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { | ||
| 48 | fmt.text_raw(&self.content); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Helper to get a line with original whitespace preserved | ||
| 53 | pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str { | ||
| 54 | if line < state.line_max { | ||
| 55 | let start = state.line_offsets[line].line_start; | ||
| 56 | let end = state.line_offsets[line].line_end; | ||
| 57 | &state.src[start..end] | ||
| 58 | } else { | ||
| 59 | "" | ||
| 60 | } | ||
| 61 | } | ||
src/plugin/statement.rs created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | use markdown_it::parser::block::{BlockRule, BlockState}; | ||
| 2 | use markdown_it::Node; | ||
| 3 | |||
| 4 | use crate::adjust_err; | ||
| 5 | use crate::plugin::{ErrorBlock, RawBlock}; | ||
| 6 | use crate::typescript::find_statement_extent; | ||
| 7 | |||
| 8 | /// Parse JavaScript statements (import, export, static X, server X, client X) | ||
| 9 | pub struct Rule; | ||
| 10 | |||
| 11 | const KEYWORDS: &[&str] = &["import ", "export ", "static ", "server ", "client "]; | ||
| 12 | const MARKO_PREFIXES: &[&str] = &["static ", "server ", "client "]; | ||
| 13 | |||
| 14 | /// Check if a line is a valid statement (starts with keyword and parses successfully). | ||
| 15 | fn is_valid_statement(line: &str) -> bool { | ||
| 16 | let trimmed = line.trim_start(); | ||
| 17 | let Some(keyword) = KEYWORDS.iter().find(|k| trimmed.starts_with(*k)) else { | ||
| 18 | return false; | ||
| 19 | }; | ||
| 20 | let keyword_trim = MARKO_PREFIXES | ||
| 21 | .iter() | ||
| 22 | .find(|k| *k == keyword) | ||
| 23 | .map(|k| k.len()) | ||
| 24 | .unwrap_or(0); | ||
| 25 | find_statement_extent(&trimmed[keyword_trim..]).is_ok() | ||
| 26 | } | ||
| 27 | |||
| 28 | impl BlockRule for Rule { | ||
| 29 | fn run(state: &mut BlockState) -> Option<(Node, usize)> { | ||
| 30 | if state.line >= state.line_max || state.blk_indent > 0 { | ||
| 31 | return None; | ||
| 32 | } | ||
| 33 | |||
| 34 | let first_line = state.get_line(state.line).trim_start(); | ||
| 35 | let keyword = *KEYWORDS.iter().find(|k| first_line.starts_with(*k))?; | ||
| 36 | |||
| 37 | // Statements must start a new block - they can't be in the middle of a paragraph. | ||
| 38 | // Allow if: start of document, previous line is empty, or previous line is a valid statement. | ||
| 39 | if state.line > 0 { | ||
| 40 | let prev_line = state.get_line(state.line - 1); | ||
| 41 | let prev_is_empty = prev_line.trim().is_empty(); | ||
| 42 | if !prev_is_empty && !is_valid_statement(prev_line) { | ||
| 43 | return None; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | let keyword_trim = MARKO_PREFIXES | ||
| 48 | .iter() | ||
| 49 | .find(|k| **k == keyword) | ||
| 50 | .map(|k| k.len()) | ||
| 51 | .unwrap_or(0); | ||
| 52 | |||
| 53 | let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..]; | ||
| 54 | |||
| 55 | let statement_end = match find_statement_extent(&unbounded_src[keyword_trim..]) { | ||
| 56 | Ok(ok) => ok, | ||
| 57 | Err(err) => { | ||
| 58 | return Some(( | ||
| 59 | Node::new(ErrorBlock { | ||
| 60 | errors: vec![adjust_err(err, keyword_trim.cast_signed())], | ||
| 61 | }), | ||
| 62 | 1, | ||
| 63 | )); | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | let total_end = keyword_trim + statement_end; | ||
| 68 | let content = &unbounded_src[0..unbounded_src.len().min(total_end + 1)]; | ||
| 69 | let line_count = 1 + content[..total_end].chars().filter(|&c| c == '\n').count(); | ||
| 70 | |||
| 71 | let node = Node::new(RawBlock { | ||
| 72 | content: content.into(), | ||
| 73 | }); | ||
| 74 | Some((node, line_count)) | ||
| 75 | } | ||
| 76 | } | ||
src/plugin/tags.rs created+403| ... | @@ -0,0 +1,403 @@ | ||
| 1 | use markdown_it::parser::block::{BlockRule, BlockState}; | ||
| 2 | use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState}; | ||
| 3 | use markdown_it::{Node, NodeValue, Renderer}; | ||
| 4 | |||
| 5 | use crate::marko::{self, OpenOrClose}; | ||
| 6 | use crate::plugin::{get_line_raw, ErrorBlock}; | ||
| 7 | |||
| 8 | /// An opening Marko tag: <div>, <if=cond>, <for|item| of=items>, etc. | ||
| 9 | #[derive(Debug)] | ||
| 10 | pub struct MarkoOpen { | ||
| 11 | pub content: String, | ||
| 12 | pub tag_name: String, | ||
| 13 | /// Length of the tag name (for error span highlighting) | ||
| 14 | pub tag_name_len: usize, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl NodeValue for MarkoOpen { | ||
| 18 | fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { | ||
| 19 | fmt.text_raw(&self.content); | ||
| 20 | fmt.text_raw("\n"); | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | /// A closing Marko tag: </div>, </> | ||
| 25 | #[derive(Debug)] | ||
| 26 | pub struct MarkoClose { | ||
| 27 | pub content: String, | ||
| 28 | pub tag_name: Option<String>, // None for </> | ||
| 29 | /// Length of the tag name (for error span highlighting), None for </> | ||
| 30 | pub tag_name_len: Option<usize>, | ||
| 31 | } | ||
| 32 | |||
| 33 | impl NodeValue for MarkoClose { | ||
| 34 | fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { | ||
| 35 | fmt.text_raw(&self.content); | ||
| 36 | fmt.text_raw("\n"); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | /// A self-closing tag: <input />, <Chart data=x /> | ||
| 41 | #[derive(Debug)] | ||
| 42 | pub struct MarkoSelfClosing { | ||
| 43 | pub content: String, | ||
| 44 | } | ||
| 45 | |||
| 46 | impl NodeValue for MarkoSelfClosing { | ||
| 47 | fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { | ||
| 48 | fmt.text_raw(&self.content); | ||
| 49 | fmt.text_raw("\n"); | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | /// An opening Marko tag with same-line text: <div>text here | ||
| 54 | /// The text is parsed as inline markdown (children) but not wrapped in <p> | ||
| 55 | #[derive(Debug)] | ||
| 56 | pub struct MarkoOpenWithText { | ||
| 57 | pub open_tag: String, | ||
| 58 | pub tag_name: String, | ||
| 59 | /// Length of the tag name (for error span highlighting) | ||
| 60 | pub tag_name_len: usize, | ||
| 61 | } | ||
| 62 | |||
| 63 | impl NodeValue for MarkoOpenWithText { | ||
| 64 | fn render(&self, node: &Node, fmt: &mut dyn Renderer) { | ||
| 65 | fmt.text_raw(&self.open_tag); | ||
| 66 | fmt.text_raw("\n"); | ||
| 67 | fmt.contents(&node.children); | ||
| 68 | fmt.text_raw("\n"); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | // ============================================================================ | ||
| 73 | // Inline tag markers and types | ||
| 74 | // ============================================================================ | ||
| 75 | |||
| 76 | /// Marker for an inline open tag, to be matched by post-processor | ||
| 77 | #[derive(Debug)] | ||
| 78 | pub struct MarkoInlineOpenMarker { | ||
| 79 | pub content: String, | ||
| 80 | pub tag_name: String, | ||
| 81 | pub tag_name_len: usize, | ||
| 82 | } | ||
| 83 | |||
| 84 | impl NodeValue for MarkoInlineOpenMarker { | ||
| 85 | fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) { | ||
| 86 | // Should be replaced by post-processor before rendering | ||
| 87 | panic!("MarkoInlineOpenMarker should be processed before rendering"); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | /// Marker for an inline close tag, to be matched by post-processor | ||
| 92 | #[derive(Debug)] | ||
| 93 | pub struct MarkoInlineCloseMarker { | ||
| 94 | pub content: String, | ||
| 95 | pub tag_name: Option<String>, // None for </> | ||
| 96 | pub tag_name_len: Option<usize>, | ||
| 97 | } | ||
| 98 | |||
| 99 | impl NodeValue for MarkoInlineCloseMarker { | ||
| 100 | fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) { | ||
| 101 | // Should be replaced by post-processor before rendering | ||
| 102 | panic!("MarkoInlineCloseMarker should be processed before rendering"); | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | /// A matched inline Marko tag with content: <button>click me</button> | ||
| 107 | #[derive(Debug)] | ||
| 108 | pub struct MarkoInlineTag { | ||
| 109 | pub open_tag: String, | ||
| 110 | pub close_tag: String, | ||
| 111 | pub tag_name: String, | ||
| 112 | } | ||
| 113 | |||
| 114 | impl NodeValue for MarkoInlineTag { | ||
| 115 | fn render(&self, node: &Node, fmt: &mut dyn Renderer) { | ||
| 116 | fmt.text_raw(&self.open_tag); | ||
| 117 | fmt.contents(&node.children); | ||
| 118 | fmt.text_raw(&self.close_tag); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | /// A complete block-level tag on one line: <tag>content</tag> | ||
| 123 | /// The content is parsed as inline markdown | ||
| 124 | #[derive(Debug)] | ||
| 125 | pub struct MarkoBlockComplete { | ||
| 126 | pub open_tag: String, | ||
| 127 | pub close_tag: String, | ||
| 128 | pub tag_name: String, | ||
| 129 | pub tag_name_len: usize, | ||
| 130 | } | ||
| 131 | |||
| 132 | impl NodeValue for MarkoBlockComplete { | ||
| 133 | fn render(&self, node: &Node, fmt: &mut dyn Renderer) { | ||
| 134 | fmt.text_raw(&self.open_tag); | ||
| 135 | fmt.contents(&node.children); | ||
| 136 | fmt.text_raw(&self.close_tag); | ||
| 137 | fmt.text_raw("\n"); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | /// Find a matching close tag in content for the given tag name. | ||
| 142 | /// Returns (content_before_close, close_tag_text) if found. | ||
| 143 | /// | ||
| 144 | /// This function scans forward through the content, tracking nested tags. | ||
| 145 | /// A close tag only matches the outer tag when nesting depth is zero. | ||
| 146 | fn find_same_line_close<'a>(content: &'a str, tag_name: &str) -> Option<(&'a str, &'a str)> { | ||
| 147 | // Stack of open tag names for tracking nesting | ||
| 148 | let mut stack: Vec<&str> = Vec::new(); | ||
| 149 | let mut pos = 0; | ||
| 150 | |||
| 151 | while pos < content.len() { | ||
| 152 | // Find next '<' | ||
| 153 | let Some(lt_pos) = content[pos..].find('<') else { | ||
| 154 | break; | ||
| 155 | }; | ||
| 156 | let lt_pos = pos + lt_pos; | ||
| 157 | |||
| 158 | // Try to parse as a tag | ||
| 159 | match marko::parse_tag(&content[lt_pos..]) { | ||
| 160 | Ok(marko::OpenOrClose::Open(open)) => { | ||
| 161 | if !open.self_closing { | ||
| 162 | stack.push(open.tag_name); | ||
| 163 | } | ||
| 164 | pos = lt_pos + open.content.len(); | ||
| 165 | } | ||
| 166 | Ok(marko::OpenOrClose::Close(close)) => { | ||
| 167 | if stack.is_empty() { | ||
| 168 | // No nested tags open - this close tag is for the outer tag | ||
| 169 | // Check if it matches our tag name or is generic </> | ||
| 170 | if close.tag_name.map(|n| n == tag_name).unwrap_or(true) { | ||
| 171 | let content_before = &content[..lt_pos]; | ||
| 172 | let close_tag = &content[lt_pos..lt_pos + close.length]; | ||
| 173 | return Some((content_before, close_tag)); | ||
| 174 | } | ||
| 175 | // Named close tag that doesn't match - keep scanning | ||
| 176 | pos = lt_pos + close.length; | ||
| 177 | } else { | ||
| 178 | // Close tag for a nested open tag - pop the stack | ||
| 179 | // Generic </> closes the most recent, named must match | ||
| 180 | if close.tag_name.is_none() { | ||
| 181 | stack.pop(); | ||
| 182 | } else if stack.last() == close.tag_name.as_ref() { | ||
| 183 | stack.pop(); | ||
| 184 | } | ||
| 185 | // If it doesn't match, we still continue (malformed nesting) | ||
| 186 | pos = lt_pos + close.length; | ||
| 187 | } | ||
| 188 | } | ||
| 189 | Err(_) => { | ||
| 190 | // Not a valid tag, skip past this '<' | ||
| 191 | pos = lt_pos + 1; | ||
| 192 | } | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | None | ||
| 197 | } | ||
| 198 | |||
| 199 | /// Parse Marko tags at block level | ||
| 200 | pub struct Rule; | ||
| 201 | |||
| 202 | impl BlockRule for Rule { | ||
| 203 | fn run(state: &mut BlockState) -> Option<(Node, usize)> { | ||
| 204 | let line = get_line_raw(state, state.line); | ||
| 205 | let trimmed = line.trim_start(); | ||
| 206 | if !trimmed.starts_with("<") { | ||
| 207 | return None; | ||
| 208 | } | ||
| 209 | |||
| 210 | let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..]; | ||
| 211 | |||
| 212 | match marko::parse_tag(unbounded_src) { | ||
| 213 | Ok(OpenOrClose::Open(open)) => { | ||
| 214 | // Count how many lines this tag spans | ||
| 215 | let lines_consumed = 1 + open.content.chars().filter(|&c| c == '\n').count(); | ||
| 216 | |||
| 217 | if open.self_closing { | ||
| 218 | // <input /> - self-closing tag | ||
| 219 | return Some(( | ||
| 220 | Node::new(MarkoSelfClosing { | ||
| 221 | content: open.content.to_string(), | ||
| 222 | }), | ||
| 223 | lines_consumed, | ||
| 224 | )); | ||
| 225 | } | ||
| 226 | |||
| 227 | // Check if there's content on the same line after the tag | ||
| 228 | // We need to look at unbounded_src, not trimmed, since tag may span multiple lines | ||
| 229 | let rest_after_tag = &unbounded_src[open.content.len()..]; | ||
| 230 | let rest_of_last_line = rest_after_tag.lines().next().unwrap_or(""); | ||
| 231 | if !rest_of_last_line.trim().is_empty() { | ||
| 232 | let text = rest_of_last_line.trim(); | ||
| 233 | |||
| 234 | // Check if the same-line content contains a matching close tag | ||
| 235 | if let Some((content, close_tag)) = find_same_line_close(text, open.tag_name) { | ||
| 236 | // Complete tag on one line: <tag>content</tag> | ||
| 237 | let content = content.trim(); | ||
| 238 | |||
| 239 | // Calculate byte offset for source mapping | ||
| 240 | let text_start_in_line = open.content.len() | ||
| 241 | + (rest_of_last_line.len() - rest_of_last_line.trim_start().len()); | ||
| 242 | let line_start = state.line_offsets[state.line].first_nonspace; | ||
| 243 | let text_start = line_start + text_start_in_line; | ||
| 244 | |||
| 245 | let mapping = vec![(0, text_start)]; | ||
| 246 | |||
| 247 | let mut node = Node::new(MarkoBlockComplete { | ||
| 248 | open_tag: open.content.to_string(), | ||
| 249 | close_tag: close_tag.to_string(), | ||
| 250 | tag_name: open.tag_name.to_string(), | ||
| 251 | tag_name_len: open.tag_name.len(), | ||
| 252 | }); | ||
| 253 | |||
| 254 | if !content.is_empty() { | ||
| 255 | node.children | ||
| 256 | .push(Node::new(InlineRoot::new(content.to_string(), mapping))); | ||
| 257 | } | ||
| 258 | |||
| 259 | return Some((node, lines_consumed)); | ||
| 260 | } | ||
| 261 | |||
| 262 | // No close tag - just content after open tag | ||
| 263 | // Parse the text as inline markdown (not wrapped in paragraph) | ||
| 264 | let text_start_in_line = open.content.len() | ||
| 265 | + (rest_of_last_line.len() - rest_of_last_line.trim_start().len()); | ||
| 266 | let line_start = state.line_offsets[state.line].first_nonspace; | ||
| 267 | let text_start = line_start + text_start_in_line; | ||
| 268 | |||
| 269 | let mapping = vec![(0, text_start)]; | ||
| 270 | |||
| 271 | let mut node = Node::new(MarkoOpenWithText { | ||
| 272 | open_tag: open.content.to_string(), | ||
| 273 | tag_name: open.tag_name.to_string(), | ||
| 274 | tag_name_len: open.tag_name.len(), | ||
| 275 | }); | ||
| 276 | node.children | ||
| 277 | .push(Node::new(InlineRoot::new(text.to_string(), mapping))); | ||
| 278 | |||
| 279 | return Some((node, lines_consumed)); | ||
| 280 | } | ||
| 281 | |||
| 282 | // Check if next line after the tag has content (non-blank) that isn't another tag | ||
| 283 | let next_line_idx = state.line + lines_consumed; | ||
| 284 | if next_line_idx < state.line_max { | ||
| 285 | let next_line = get_line_raw(state, next_line_idx); | ||
| 286 | let next_trimmed = next_line.trim(); | ||
| 287 | if !next_trimmed.is_empty() && !next_trimmed.starts_with("<") { | ||
| 288 | // Content immediately follows without blank line | ||
| 289 | // Parse as inline markdown (not wrapped in paragraph) | ||
| 290 | let text = next_trimmed; | ||
| 291 | |||
| 292 | // Calculate byte offset of next line's text for source mapping | ||
| 293 | let next_line_start = state.line_offsets[next_line_idx].first_nonspace; | ||
| 294 | |||
| 295 | let mapping = vec![(0, next_line_start)]; | ||
| 296 | |||
| 297 | let mut node = Node::new(MarkoOpenWithText { | ||
| 298 | open_tag: open.content.to_string(), | ||
| 299 | tag_name: open.tag_name.to_string(), | ||
| 300 | tag_name_len: open.tag_name.len(), | ||
| 301 | }); | ||
| 302 | node.children | ||
| 303 | .push(Node::new(InlineRoot::new(text.to_string(), mapping))); | ||
| 304 | |||
| 305 | // Consume the tag lines + the content line | ||
| 306 | return Some((node, lines_consumed + 1)); | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | // Clean case: open tag on its own line(s), followed by blank or another tag | ||
| 311 | Some(( | ||
| 312 | Node::new(MarkoOpen { | ||
| 313 | content: open.content.to_string(), | ||
| 314 | tag_name: open.tag_name.to_string(), | ||
| 315 | tag_name_len: open.tag_name.len(), | ||
| 316 | }), | ||
| 317 | lines_consumed, | ||
| 318 | )) | ||
| 319 | } | ||
| 320 | Ok(OpenOrClose::Close(close)) => { | ||
| 321 | // Close tags should be single line, but check anyway | ||
| 322 | let lines_consumed = 1 + close.length.saturating_sub(1).min( | ||
| 323 | unbounded_src[..close.length] | ||
| 324 | .chars() | ||
| 325 | .filter(|&c| c == '\n') | ||
| 326 | .count(), | ||
| 327 | ); | ||
| 328 | |||
| 329 | // Check if there's content on the same line after the close tag | ||
| 330 | let close_text = &unbounded_src[..close.length]; | ||
| 331 | let rest_after_tag = &unbounded_src[close.length..]; | ||
| 332 | let rest_of_line = rest_after_tag.lines().next().unwrap_or(""); | ||
| 333 | if !rest_of_line.trim().is_empty() { | ||
| 334 | // TODO: content after close tag on same line | ||
| 335 | todo!("content after close tag: {}", rest_of_line.trim()); | ||
| 336 | } | ||
| 337 | |||
| 338 | Some(( | ||
| 339 | Node::new(MarkoClose { | ||
| 340 | content: close_text.to_string(), | ||
| 341 | tag_name: close.tag_name.map(|s| s.to_string()), | ||
| 342 | tag_name_len: close.tag_name.map(|s| s.len()), | ||
| 343 | }), | ||
| 344 | lines_consumed, | ||
| 345 | )) | ||
| 346 | } | ||
| 347 | Err(err) => Some((Node::new(ErrorBlock { errors: vec![err] }), 1)), | ||
| 348 | } | ||
| 349 | } | ||
| 350 | } | ||
| 351 | |||
| 352 | impl InlineRule for Rule { | ||
| 353 | const MARKER: char = '<'; | ||
| 354 | |||
| 355 | fn run(state: &mut InlineState) -> Option<(Node, usize)> { | ||
| 356 | let input = &state.src[state.pos..state.pos_max]; | ||
| 357 | |||
| 358 | // Skip if this doesn't look like a Marko tag | ||
| 359 | if !input.starts_with("<") { | ||
| 360 | return None; | ||
| 361 | } | ||
| 362 | |||
| 363 | // Try to parse as a Marko tag | ||
| 364 | match marko::parse_tag(input) { | ||
| 365 | Ok(OpenOrClose::Open(open)) => { | ||
| 366 | if open.self_closing { | ||
| 367 | // Self-closing inline tag like <br/> | ||
| 368 | return Some(( | ||
| 369 | Node::new(MarkoSelfClosing { | ||
| 370 | content: open.content.to_string(), | ||
| 371 | }), | ||
| 372 | open.content.len(), | ||
| 373 | )); | ||
| 374 | } | ||
| 375 | // Emit open marker - will be matched by post-processor | ||
| 376 | Some(( | ||
| 377 | Node::new(MarkoInlineOpenMarker { | ||
| 378 | content: open.content.to_string(), | ||
| 379 | tag_name: open.tag_name.to_string(), | ||
| 380 | tag_name_len: open.tag_name.len(), | ||
| 381 | }), | ||
| 382 | open.content.len(), | ||
| 383 | )) | ||
| 384 | } | ||
| 385 | Ok(OpenOrClose::Close(close)) => { | ||
| 386 | // Emit close marker - will be matched by post-processor | ||
| 387 | let close_text = &input[..close.length]; | ||
| 388 | Some(( | ||
| 389 | Node::new(MarkoInlineCloseMarker { | ||
| 390 | content: close_text.to_string(), | ||
| 391 | tag_name: close.tag_name.map(|s| s.to_string()), | ||
| 392 | tag_name_len: close.tag_name.map(|s| s.len()), | ||
| 393 | }), | ||
| 394 | close.length, | ||
| 395 | )) | ||
| 396 | } | ||
| 397 | Err(_) => { | ||
| 398 | // Not a valid Marko tag, let other rules handle it | ||
| 399 | None | ||
| 400 | } | ||
| 401 | } | ||
| 402 | } | ||
| 403 | } | ||
src/plugin/template.rs created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | use markdown_it::parser::inline::{InlineRule, InlineState}; | ||
| 2 | use markdown_it::Node; | ||
| 3 | use oxc_allocator::Allocator; | ||
| 4 | use oxc_diagnostics::OxcDiagnostic; | ||
| 5 | use oxc_parser::Parser; | ||
| 6 | use oxc_span::{GetSpan, SourceType}; | ||
| 7 | |||
| 8 | use crate::plugin::{ErrorBlock, RawBlock}; | ||
| 9 | |||
| 10 | /// Inline rule for ${...} TypeScript expressions. | ||
| 11 | pub(crate) struct Rule; | ||
| 12 | |||
| 13 | impl InlineRule for Rule { | ||
| 14 | const MARKER: char = '$'; | ||
| 15 | |||
| 16 | fn run(state: &mut InlineState) -> Option<(Node, usize)> { | ||
| 17 | let input = &state.src[state.pos..state.pos_max]; | ||
| 18 | |||
| 19 | if !input.starts_with("${") { | ||
| 20 | return None; | ||
| 21 | } | ||
| 22 | |||
| 23 | let expr_source = &input["${".len()..]; | ||
| 24 | |||
| 25 | let allocator = Allocator::default(); | ||
| 26 | let source_type = SourceType::default() | ||
| 27 | .with_module(true) | ||
| 28 | .with_typescript(true); | ||
| 29 | |||
| 30 | let expr = match Parser::new(&allocator, expr_source, source_type).parse_expression() { | ||
| 31 | Ok(expr) => expr, | ||
| 32 | Err(errors) => { | ||
| 33 | return Some((Node::new(ErrorBlock { errors }), "${".len())); | ||
| 34 | } | ||
| 35 | }; | ||
| 36 | let mut length = expr.span().end as usize; | ||
| 37 | |||
| 38 | // Verify the next character after whitespace is a } | ||
| 39 | while let Some(char) = expr_source.as_bytes().get(length) { | ||
| 40 | match char { | ||
| 41 | b' ' | b'\t' | b'\n' | b'\r' => length += 1, | ||
| 42 | _ => break, | ||
| 43 | } | ||
| 44 | } | ||
| 45 | if expr_source.as_bytes().get(length) != Some(&b'}') { | ||
| 46 | let error = OxcDiagnostic::error("Invalid template expression"); | ||
| 47 | return Some(( | ||
| 48 | Node::new(ErrorBlock { | ||
| 49 | errors: vec![error], | ||
| 50 | }), | ||
| 51 | input.len(), | ||
| 52 | )); | ||
| 53 | } | ||
| 54 | |||
| 55 | let total_len = "${".len() + length + "}".len(); | ||
| 56 | let content = &input[..total_len]; | ||
| 57 | let node = Node::new(RawBlock { | ||
| 58 | content: content.to_string(), | ||
| 59 | }); | ||
| 60 | |||
| 61 | Some((node, total_len)) | ||
| 62 | } | ||
| 63 | } | ||
src/typescript.rs created+790| ... | @@ -0,0 +1,790 @@ | ||
| 1 | //! implement ts partial parsing helpers | ||
| 2 | use crate::{adjust_err, marko::LexState}; | ||
| 3 | |||
| 4 | use oxc_allocator::Allocator; | ||
| 5 | use oxc_ast::ast::Expression; | ||
| 6 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | ||
| 7 | use oxc_span::{GetSpan, SourceType}; | ||
| 8 | use std::borrow::Cow; | ||
| 9 | use tree_sitter::Parser; | ||
| 10 | |||
| 11 | fn err<T: Into<Cow<'static, str>>>(str: T, offset: usize, length: usize) -> OxcDiagnostic { | ||
| 12 | OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset, length)) | ||
| 13 | } | ||
| 14 | |||
| 15 | /// TODO: Blow up | ||
| 16 | pub fn find_statement_extent(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 17 | let mut parser = Parser::new(); | ||
| 18 | |||
| 19 | let language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(); | ||
| 20 | parser | ||
| 21 | .set_language(&language) | ||
| 22 | .expect("Failed to load TypeScript grammar"); | ||
| 23 | |||
| 24 | let tree = parser.parse(source, None).ok_or_else(|| { | ||
| 25 | OxcDiagnostic::error("Failed to parse").and_label(LabeledSpan::new(None, 0, 1)) | ||
| 26 | })?; | ||
| 27 | let root = tree.root_node(); | ||
| 28 | |||
| 29 | let first_statement = root.named_child(0).ok_or_else(|| { | ||
| 30 | OxcDiagnostic::error("Failed to parse").and_label(LabeledSpan::new(None, 0, 1)) | ||
| 31 | })?; | ||
| 32 | |||
| 33 | if first_statement.kind() == "ERROR" { | ||
| 34 | return Err(OxcDiagnostic::error("Invalid TypeScript/JavaScript syntax") | ||
| 35 | .and_label(LabeledSpan::new(None, 0, first_statement.end_byte()))); | ||
| 36 | } | ||
| 37 | |||
| 38 | let end_byte = first_statement.end_byte(); | ||
| 39 | |||
| 40 | let remaining = &source[end_byte..]; | ||
| 41 | let same_line = remaining | ||
| 42 | .find('\n') | ||
| 43 | .map(|x| &remaining[..x]) | ||
| 44 | .unwrap_or(remaining); | ||
| 45 | if !same_line.trim().is_empty() { | ||
| 46 | return Err(OxcDiagnostic::error("Unexpected content after statement.") | ||
| 47 | .with_label(LabeledSpan::new(None, end_byte, same_line.len()))); | ||
| 48 | } | ||
| 49 | |||
| 50 | Ok(end_byte) | ||
| 51 | } | ||
| 52 | |||
| 53 | fn parse_expr_extra<'alloc, 'src: 'alloc>( | ||
| 54 | source: &'src str, | ||
| 55 | offset: isize, | ||
| 56 | allocator: &'alloc mut Allocator, | ||
| 57 | ) -> Result<Expression<'alloc>, OxcDiagnostic> { | ||
| 58 | if source.is_empty() { | ||
| 59 | return Err(err( | ||
| 60 | "Expected expression, found end of file", | ||
| 61 | offset.max(0).cast_unsigned(), | ||
| 62 | 1, | ||
| 63 | )); | ||
| 64 | } | ||
| 65 | |||
| 66 | let source_type = SourceType::default() | ||
| 67 | .with_module(true) | ||
| 68 | .with_typescript(true); | ||
| 69 | |||
| 70 | let expr = match oxc_parser::Parser::new(allocator, source, source_type).parse_expression() { | ||
| 71 | Ok(expr) => expr, | ||
| 72 | Err(errors) => { | ||
| 73 | let first_err = errors.into_iter().next().expect("no errors but no result!"); | ||
| 74 | let first_err_offset = first_err | ||
| 75 | .labels | ||
| 76 | .as_ref() | ||
| 77 | .ok_or_else(|| err("Expected expression", offset.max(0).cast_unsigned(), 1))? | ||
| 78 | .first() | ||
| 79 | .expect("labels, but no labels!") | ||
| 80 | .offset(); | ||
| 81 | |||
| 82 | let mut candidate = source[0..first_err_offset].trim_end(); | ||
| 83 | let mut whitespace_groups = 0; | ||
| 84 | loop { | ||
| 85 | if candidate.is_empty() { | ||
| 86 | return Err(adjust_err(first_err, offset)); | ||
| 87 | } | ||
| 88 | match oxc_parser::Parser::new(allocator, candidate, source_type).parse_expression() | ||
| 89 | { | ||
| 90 | Ok(expr) => break expr, | ||
| 91 | Err(_) => { | ||
| 92 | let before_trim = &candidate[..candidate.len() - 1]; | ||
| 93 | let after_trim = before_trim.trim_end(); | ||
| 94 | if after_trim.len() < before_trim.len() { | ||
| 95 | whitespace_groups += 1; | ||
| 96 | if whitespace_groups > 1 { | ||
| 97 | return Err(adjust_err(first_err, offset)); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | candidate = after_trim; | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | }; | ||
| 106 | |||
| 107 | let mut expr = expr; | ||
| 108 | while let Expression::SequenceExpression(seq) = expr { | ||
| 109 | expr = seq | ||
| 110 | .unbox() | ||
| 111 | .expressions | ||
| 112 | .into_iter() | ||
| 113 | .next() | ||
| 114 | .expect("no items"); | ||
| 115 | } | ||
| 116 | Ok(expr) | ||
| 117 | } | ||
| 118 | |||
| 119 | /// parse ts expression, stopping at garbage data / comma | ||
| 120 | pub fn parse_expr(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 121 | let mut allocator = Allocator::new(); | ||
| 122 | let expr = parse_expr_extra(l.peek_rest(), l.offset().cast_signed(), &mut allocator)?; | ||
| 123 | let span = expr.span(); | ||
| 124 | let len = (span.end - span.start) as usize; | ||
| 125 | l.advance(len); | ||
| 126 | Ok(len) | ||
| 127 | } | ||
| 128 | |||
| 129 | /// parse ts expression but stop at the first > because of ambiguity with HTML tag end | ||
| 130 | pub fn parse_expr_without_gt(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 131 | use oxc_ast::ast::{BinaryOperator, Expression}; | ||
| 132 | |||
| 133 | let mut allocator = Allocator::new(); | ||
| 134 | let expr = parse_expr_extra(l.peek_rest(), l.offset().cast_signed(), &mut allocator)?; | ||
| 135 | |||
| 136 | let mut e = &expr; | ||
| 137 | while let Expression::BinaryExpression(bin) = e { | ||
| 138 | if bin.operator == BinaryOperator::GreaterThan | ||
| 139 | || bin.operator == BinaryOperator::GreaterEqualThan | ||
| 140 | || bin.operator == BinaryOperator::ShiftRight | ||
| 141 | || bin.operator == BinaryOperator::ShiftRightZeroFill | ||
| 142 | { | ||
| 143 | e = &bin.left; | ||
| 144 | } else { | ||
| 145 | break; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | let length = e.span().end as usize; | ||
| 150 | l.advance(length); | ||
| 151 | Ok(length) | ||
| 152 | } | ||
| 153 | |||
| 154 | /// parse call arguments including the parentheses: `(a, b, ...c)` | ||
| 155 | pub fn parse_call_arguments(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 156 | let source = l.peek_rest(); | ||
| 157 | if !source.starts_with("(") { | ||
| 158 | return Err( | ||
| 159 | OxcDiagnostic::error("Expected `(`").and_label(LabeledSpan::new(None, l.offset(), 1)), | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | |||
| 163 | // Prepend `f` to make it a call expression: "f(a, b, c)" | ||
| 164 | let wrapped = format!("f{source}"); | ||
| 165 | |||
| 166 | let mut allocator = Allocator::default(); | ||
| 167 | let expr = parse_expr_extra(wrapped.as_str(), l.offset().cast_signed(), &mut allocator)?; | ||
| 168 | |||
| 169 | // Walk down the left side of the AST to find the CallExpression | ||
| 170 | let call_span = find_leftmost_call(&expr).ok_or_else(|| { | ||
| 171 | OxcDiagnostic::error("Expected call expression").and_label(LabeledSpan::new( | ||
| 172 | None, | ||
| 173 | l.offset(), | ||
| 174 | 1, | ||
| 175 | )) | ||
| 176 | })?; | ||
| 177 | |||
| 178 | // Subtract the `f` prefix we added | ||
| 179 | let length = call_span.end as usize - 1; | ||
| 180 | l.advance(length); | ||
| 181 | Ok(length) | ||
| 182 | } | ||
| 183 | |||
| 184 | /// parse variable binding. identifier or destructuring pattern | ||
| 185 | /// also parses optional `: Type` | ||
| 186 | pub fn parse_var_binding(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 187 | let offset = l.offset(); | ||
| 188 | let source = l.peek_rest(); | ||
| 189 | if source.is_empty() { | ||
| 190 | return Err(err("Expected variable binding", l.offset(), 1)); | ||
| 191 | } | ||
| 192 | |||
| 193 | let mut allocator = Allocator::default(); | ||
| 194 | let end = { | ||
| 195 | let expr = parse_expr_extra(source, l.offset().cast_signed(), &mut allocator)?; | ||
| 196 | let span = find_leftmost(&expr, LeftmostSearch::Assignment).unwrap(); | ||
| 197 | span.end as usize | ||
| 198 | }; | ||
| 199 | allocator.reset(); | ||
| 200 | |||
| 201 | { | ||
| 202 | let prefix = "function f("; | ||
| 203 | let source = format!("{prefix}{}) {{}}", &source[0..end]); | ||
| 204 | parse_expr_extra( | ||
| 205 | source.as_str(), | ||
| 206 | l.offset().cast_signed() - prefix.len().cast_signed(), | ||
| 207 | &mut allocator, | ||
| 208 | )?; | ||
| 209 | } | ||
| 210 | |||
| 211 | l.advance(end); | ||
| 212 | |||
| 213 | let snapshot = l.offset(); | ||
| 214 | l.skip_whitespace(); | ||
| 215 | if l.expect(":").is_ok() { | ||
| 216 | l.skip_whitespace(); | ||
| 217 | parse_type(l)?; | ||
| 218 | Ok(l.offset() - offset) | ||
| 219 | } else { | ||
| 220 | l.offset = snapshot; | ||
| 221 | Ok(snapshot - offset) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | /// parses a type | ||
| 226 | pub fn parse_type(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 227 | let mut allocator = Allocator::default(); | ||
| 228 | let source = format!("T as {}", l.peek_rest()); | ||
| 229 | println!("{{{source}}} HUH"); | ||
| 230 | let expr = parse_expr_extra(source.as_str(), l.offset().cast_signed(), &mut allocator)?; | ||
| 231 | println!("{{{expr:#?}}}"); | ||
| 232 | |||
| 233 | let span = find_leftmost(&expr, LeftmostSearch::As).ok_or_else(|| { | ||
| 234 | err( | ||
| 235 | "Expected type expression", | ||
| 236 | l.offset(), | ||
| 237 | expr.span().end as usize, | ||
| 238 | ) | ||
| 239 | })?; | ||
| 240 | let len = (span.end - span.start) as usize; | ||
| 241 | |||
| 242 | l.advance(len); | ||
| 243 | Ok(len) | ||
| 244 | } | ||
| 245 | |||
| 246 | /// parses `(params): ReturnType { body }` | ||
| 247 | pub fn parse_fn_params_and_body(l: &mut LexState) -> Result<usize, OxcDiagnostic> { | ||
| 248 | let source = l.peek_rest(); | ||
| 249 | if !source.starts_with("(") { | ||
| 250 | return Err( | ||
| 251 | OxcDiagnostic::error("Expected `(`").and_label(LabeledSpan::new(None, l.offset(), 1)), | ||
| 252 | ); | ||
| 253 | } | ||
| 254 | |||
| 255 | // Prepend `function f` to make it a function expression | ||
| 256 | let prefix = "function f"; | ||
| 257 | let wrapped = format!("{prefix}{source}"); | ||
| 258 | |||
| 259 | let mut allocator = Allocator::default(); | ||
| 260 | |||
| 261 | let expr = parse_expr_extra(&wrapped, l.offset().cast_signed(), &mut allocator)?; | ||
| 262 | |||
| 263 | // Find the function expression (walk left side of any binary/etc expressions) | ||
| 264 | let func_span = find_leftmost(&expr, LeftmostSearch::Function).ok_or_else(|| { | ||
| 265 | OxcDiagnostic::error("Expected function expression").and_label(LabeledSpan::new( | ||
| 266 | None, | ||
| 267 | l.offset(), | ||
| 268 | 1, | ||
| 269 | )) | ||
| 270 | })?; | ||
| 271 | |||
| 272 | // Subtract the prefix we added | ||
| 273 | let length = func_span.end as usize - prefix.len(); | ||
| 274 | l.advance(length); | ||
| 275 | Ok(length) | ||
| 276 | } | ||
| 277 | |||
| 278 | /// Walk down the left/object side of an expression to find a CallExpression. | ||
| 279 | fn find_leftmost_call(expr: &oxc_ast::ast::Expression) -> Option<oxc_span::Span> { | ||
| 280 | use oxc_ast::ast::Expression; | ||
| 281 | |||
| 282 | match expr { | ||
| 283 | Expression::CallExpression(call) => Some(call.span), | ||
| 284 | Expression::BinaryExpression(bin) => find_leftmost_call(&bin.left), | ||
| 285 | Expression::LogicalExpression(log) => find_leftmost_call(&log.left), | ||
| 286 | Expression::AssignmentExpression(assign) => find_leftmost_call(&assign.right), | ||
| 287 | Expression::ConditionalExpression(cond) => find_leftmost_call(&cond.test), | ||
| 288 | Expression::TaggedTemplateExpression(tag) => find_leftmost_call(&tag.tag), | ||
| 289 | Expression::ComputedMemberExpression(mem) => find_leftmost_call(&mem.object), | ||
| 290 | Expression::StaticMemberExpression(mem) => find_leftmost_call(&mem.object), | ||
| 291 | Expression::PrivateFieldExpression(mem) => find_leftmost_call(&mem.object), | ||
| 292 | _ => None, | ||
| 293 | } | ||
| 294 | } | ||
| 295 | |||
| 296 | #[derive(Debug, Clone, Copy, PartialEq)] | ||
| 297 | enum LeftmostSearch { | ||
| 298 | As, | ||
| 299 | Function, | ||
| 300 | Assignment, | ||
| 301 | } | ||
| 302 | |||
| 303 | /// Walk down the left/object side of an expression to find a FunctionExpression. | ||
| 304 | fn find_leftmost(expr: &oxc_ast::ast::Expression, kind: LeftmostSearch) -> Option<oxc_span::Span> { | ||
| 305 | use oxc_ast::ast::Expression::*; | ||
| 306 | |||
| 307 | match expr { | ||
| 308 | FunctionExpression(func) if kind == LeftmostSearch::Function => Some(func.span), | ||
| 309 | TSAsExpression(expr) if kind == LeftmostSearch::As => { | ||
| 310 | let mut expr = expr; | ||
| 311 | loop { | ||
| 312 | match &expr.expression { | ||
| 313 | TSAsExpression(nested) => expr = nested, | ||
| 314 | _ => return Some(expr.type_annotation.span()), | ||
| 315 | } | ||
| 316 | } | ||
| 317 | } | ||
| 318 | Identifier(_) | ObjectExpression(_) | ArrayExpression(_) | ||
| 319 | if kind == LeftmostSearch::Assignment => | ||
| 320 | { | ||
| 321 | Some(expr.span()) | ||
| 322 | } | ||
| 323 | |||
| 324 | AssignmentExpression(assign) => find_leftmost_assign(&assign.left, kind), | ||
| 325 | UpdateExpression(seq) => Some(seq.argument.span()), | ||
| 326 | |||
| 327 | TSAsExpression(expr) => find_leftmost(&expr.expression, kind), | ||
| 328 | TSNonNullExpression(expr) => find_leftmost(&expr.expression, kind), | ||
| 329 | TSSatisfiesExpression(expr) => find_leftmost(&expr.expression, kind), | ||
| 330 | TSInstantiationExpression(expr) => find_leftmost(&expr.expression, kind), | ||
| 331 | BinaryExpression(bin) => find_leftmost(&bin.left, kind), | ||
| 332 | LogicalExpression(log) => find_leftmost(&log.left, kind), | ||
| 333 | ConditionalExpression(cond) => find_leftmost(&cond.test, kind), | ||
| 334 | TaggedTemplateExpression(tag) => find_leftmost(&tag.tag, kind), | ||
| 335 | ComputedMemberExpression(mem) => find_leftmost(&mem.object, kind), | ||
| 336 | StaticMemberExpression(mem) => find_leftmost(&mem.object, kind), | ||
| 337 | PrivateFieldExpression(mem) => find_leftmost(&mem.object, kind), | ||
| 338 | CallExpression(call) => find_leftmost(&call.callee, kind), | ||
| 339 | SequenceExpression(seq) => find_leftmost(seq.expressions.first().unwrap(), kind), | ||
| 340 | |||
| 341 | BooleanLiteral(_) | ||
| 342 | | NullLiteral(_) | ||
| 343 | | NumericLiteral(_) | ||
| 344 | | BigIntLiteral(_) | ||
| 345 | | RegExpLiteral(_) | ||
| 346 | | StringLiteral(_) | ||
| 347 | | TemplateLiteral(_) | ||
| 348 | | Identifier(_) | ||
| 349 | | MetaProperty(_) | ||
| 350 | | Super(_) | ||
| 351 | | ArrayExpression(_) | ||
| 352 | | ArrowFunctionExpression(_) | ||
| 353 | | AwaitExpression(_) | ||
| 354 | | ChainExpression(_) | ||
| 355 | | ClassExpression(_) | ||
| 356 | | FunctionExpression(_) | ||
| 357 | | ImportExpression(_) | ||
| 358 | | NewExpression(_) | ||
| 359 | | ObjectExpression(_) | ||
| 360 | | ParenthesizedExpression(_) | ||
| 361 | | ThisExpression(_) | ||
| 362 | | UnaryExpression(_) | ||
| 363 | | YieldExpression(_) | ||
| 364 | | PrivateInExpression(_) | ||
| 365 | | JSXElement(_) | ||
| 366 | | JSXFragment(_) | ||
| 367 | | TSTypeAssertion(_) => None, | ||
| 368 | V8IntrinsicExpression(_) => unreachable!(), | ||
| 369 | } | ||
| 370 | } | ||
| 371 | |||
| 372 | fn find_leftmost_assign( | ||
| 373 | expr: &oxc_ast::ast::AssignmentTarget, | ||
| 374 | kind: LeftmostSearch, | ||
| 375 | ) -> Option<oxc_span::Span> { | ||
| 376 | use oxc_ast::ast::AssignmentTarget::*; | ||
| 377 | |||
| 378 | match expr { | ||
| 379 | TSAsExpression(expr) if kind == LeftmostSearch::As => Some(expr.type_annotation.span()), | ||
| 380 | |||
| 381 | AssignmentTargetIdentifier(_) | ArrayAssignmentTarget(_) | ObjectAssignmentTarget(_) | ||
| 382 | if kind == LeftmostSearch::Assignment => | ||
| 383 | { | ||
| 384 | Some(expr.span()) | ||
| 385 | } | ||
| 386 | |||
| 387 | AssignmentTargetIdentifier(_) | ||
| 388 | | TSAsExpression(_) | ||
| 389 | | TSSatisfiesExpression(_) | ||
| 390 | | TSNonNullExpression(_) | ||
| 391 | | TSTypeAssertion(_) | ||
| 392 | | ComputedMemberExpression(_) | ||
| 393 | | StaticMemberExpression(_) | ||
| 394 | | ArrayAssignmentTarget(_) | ||
| 395 | | ObjectAssignmentTarget(_) | ||
| 396 | | PrivateFieldExpression(_) => None, | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | #[cfg(test)] | ||
| 401 | mod tests { | ||
| 402 | use super::*; | ||
| 403 | |||
| 404 | fn parse_expr(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 405 | let mut l = LexState::new(source); | ||
| 406 | super::parse_expr(&mut l) | ||
| 407 | } | ||
| 408 | |||
| 409 | #[test] | ||
| 410 | fn test_simple_identifier() { | ||
| 411 | assert_eq!(parse_expr("foo"), Ok(3)); | ||
| 412 | assert_eq!(parse_expr("bar123"), Ok(6)); | ||
| 413 | } | ||
| 414 | |||
| 415 | #[test] | ||
| 416 | fn test_number_literals() { | ||
| 417 | assert_eq!(parse_expr("42"), Ok(2)); | ||
| 418 | assert_eq!(parse_expr("3.14"), Ok(4)); | ||
| 419 | assert_eq!(parse_expr("0xff"), Ok(4)); | ||
| 420 | } | ||
| 421 | |||
| 422 | #[test] | ||
| 423 | fn test_string_literals() { | ||
| 424 | assert_eq!(parse_expr("\"hello\""), Ok(7)); | ||
| 425 | assert_eq!(parse_expr("'world'"), Ok(7)); | ||
| 426 | assert_eq!(parse_expr("`template`"), Ok(10)); | ||
| 427 | } | ||
| 428 | |||
| 429 | #[test] | ||
| 430 | fn test_binary_expressions() { | ||
| 431 | assert_eq!(parse_expr("a + b"), Ok(5)); | ||
| 432 | assert_eq!(parse_expr("x * y / z"), Ok(9)); | ||
| 433 | } | ||
| 434 | |||
| 435 | #[test] | ||
| 436 | fn test_call_expression() { | ||
| 437 | assert_eq!(parse_expr("foo()"), Ok(5)); | ||
| 438 | assert_eq!(parse_expr("foo(a, b)"), Ok(9)); | ||
| 439 | assert_eq!(parse_expr("foo.bar()"), Ok(9)); | ||
| 440 | } | ||
| 441 | |||
| 442 | #[test] | ||
| 443 | fn test_with_trailing_garbage() { | ||
| 444 | assert_eq!(parse_expr("foo > more garbage"), Ok(10)); | ||
| 445 | assert_eq!(parse_expr("42 > stuff"), Ok(10)); | ||
| 446 | assert_eq!(parse_expr("foo @@@"), Ok(3)); | ||
| 447 | assert_eq!(parse_expr("foo 🩵"), Ok(3)); | ||
| 448 | assert_eq!(parse_expr("foo, bar, @@@"), Ok(3)); | ||
| 449 | } | ||
| 450 | |||
| 451 | #[test] | ||
| 452 | fn test_member_expression() { | ||
| 453 | assert_eq!(parse_expr("obj.prop"), Ok(8)); | ||
| 454 | assert_eq!(parse_expr("a.b.c"), Ok(5)); | ||
| 455 | assert_eq!(parse_expr("arr[0]"), Ok(6)); | ||
| 456 | } | ||
| 457 | |||
| 458 | #[test] | ||
| 459 | fn test_arrow_function() { | ||
| 460 | assert_eq!(parse_expr("() => 42"), Ok(8)); | ||
| 461 | assert_eq!(parse_expr("x => x + 1"), Ok(10)); | ||
| 462 | assert_eq!(parse_expr("(a, b) => a + b"), Ok(15)); | ||
| 463 | } | ||
| 464 | |||
| 465 | #[test] | ||
| 466 | fn test_object_literal() { | ||
| 467 | assert_eq!(parse_expr("{ a: 1 }"), Ok(8)); | ||
| 468 | assert_eq!(parse_expr("{ a: 1, b: 2 }"), Ok(14)); | ||
| 469 | } | ||
| 470 | |||
| 471 | #[test] | ||
| 472 | fn test_array_literal() { | ||
| 473 | assert_eq!(parse_expr("[1, 2, 3]"), Ok(9)); | ||
| 474 | assert_eq!(parse_expr("[]"), Ok(2)); | ||
| 475 | } | ||
| 476 | |||
| 477 | #[test] | ||
| 478 | fn test_empty_input() { | ||
| 479 | let result = parse_expr(""); | ||
| 480 | assert!(result.is_err()); | ||
| 481 | } | ||
| 482 | |||
| 483 | #[test] | ||
| 484 | fn test_invalid_syntax() { | ||
| 485 | let result = parse_expr("@@@"); | ||
| 486 | assert!(result.is_err()); | ||
| 487 | } | ||
| 488 | |||
| 489 | fn parse_expr_no_gt(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 490 | let mut l = LexState::new(source); | ||
| 491 | parse_expr_without_gt(&mut l) | ||
| 492 | } | ||
| 493 | |||
| 494 | #[test] | ||
| 495 | fn test_no_gt_simple() { | ||
| 496 | assert_eq!(parse_expr_no_gt("foo"), Ok(3)); | ||
| 497 | assert_eq!(parse_expr_no_gt("42"), Ok(2)); | ||
| 498 | assert_eq!(parse_expr_no_gt("a + b"), Ok(5)); | ||
| 499 | } | ||
| 500 | |||
| 501 | #[test] | ||
| 502 | fn test_no_gt_stops_at_gt() { | ||
| 503 | assert_eq!(parse_expr_no_gt("a > b"), Ok(1)); // just "a" | ||
| 504 | assert_eq!(parse_expr_no_gt("foo > bar"), Ok(3)); // just "foo" | ||
| 505 | assert_eq!(parse_expr_no_gt("x + 1 > y"), Ok(5)); // "x + 1" | ||
| 506 | assert_eq!(parse_expr_no_gt("x + 1 > y > z > y"), Ok(5)); // "x + 1" | ||
| 507 | } | ||
| 508 | |||
| 509 | #[test] | ||
| 510 | fn test_no_gt_parens_protect() { | ||
| 511 | // Parentheses protect the `>` | ||
| 512 | assert_eq!(parse_expr_no_gt("(a > b)"), Ok(7)); | ||
| 513 | assert_eq!(parse_expr_no_gt("(x > y) + z"), Ok(11)); | ||
| 514 | } | ||
| 515 | |||
| 516 | #[test] | ||
| 517 | fn test_no_gt_brackets_protect() { | ||
| 518 | // Array access brackets protect the `>` | ||
| 519 | assert_eq!(parse_expr_no_gt("arr[a > b]"), Ok(10)); | ||
| 520 | assert_eq!(parse_expr_no_gt("obj[x > 0]"), Ok(10)); | ||
| 521 | } | ||
| 522 | |||
| 523 | #[test] | ||
| 524 | fn test_no_gt_call_parens_protect() { | ||
| 525 | // Function call parens protect the `>` | ||
| 526 | assert_eq!(parse_expr_no_gt("fn(a > b)"), Ok(9)); | ||
| 527 | assert_eq!(parse_expr_no_gt("Math.max(x, y > 0 ? 1 : 0)"), Ok(26)); | ||
| 528 | } | ||
| 529 | |||
| 530 | #[test] | ||
| 531 | fn test_no_gt_gte_ok() { | ||
| 532 | assert_eq!(parse_expr_no_gt("a >= b"), Ok(1)); | ||
| 533 | assert_eq!(parse_expr_no_gt("x >= 0"), Ok(1)); | ||
| 534 | } | ||
| 535 | |||
| 536 | #[test] | ||
| 537 | fn test_no_gt_shift_ok() { | ||
| 538 | assert_eq!(parse_expr_no_gt("a >> b"), Ok(1)); | ||
| 539 | assert_eq!(parse_expr_no_gt("x >>> 2"), Ok(1)); | ||
| 540 | } | ||
| 541 | |||
| 542 | fn parse_call_args(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 543 | let mut l = LexState::new(source); | ||
| 544 | parse_call_arguments(&mut l) | ||
| 545 | } | ||
| 546 | |||
| 547 | #[test] | ||
| 548 | fn test_call_args_empty() { | ||
| 549 | assert_eq!(parse_call_args("()"), Ok(2)); | ||
| 550 | assert_eq!(parse_call_args("() garbage"), Ok(2)); | ||
| 551 | } | ||
| 552 | |||
| 553 | #[test] | ||
| 554 | fn test_call_args_simple() { | ||
| 555 | assert_eq!(parse_call_args("(a)"), Ok(3)); | ||
| 556 | assert_eq!(parse_call_args("(a, b)"), Ok(6)); | ||
| 557 | assert_eq!(parse_call_args("(a, b, c)"), Ok(9)); | ||
| 558 | } | ||
| 559 | |||
| 560 | #[test] | ||
| 561 | fn test_call_args_expressions() { | ||
| 562 | assert_eq!(parse_call_args("(1 + 2)"), Ok(7)); | ||
| 563 | assert_eq!(parse_call_args("(a + b, c * d)"), Ok(14)); | ||
| 564 | assert_eq!(parse_call_args("(foo())"), Ok(7)); | ||
| 565 | } | ||
| 566 | |||
| 567 | #[test] | ||
| 568 | fn test_call_args_spread() { | ||
| 569 | assert_eq!(parse_call_args("(...args)"), Ok(9)); | ||
| 570 | assert_eq!(parse_call_args("(a, ...rest)"), Ok(12)); | ||
| 571 | } | ||
| 572 | |||
| 573 | #[test] | ||
| 574 | fn test_call_args_trailing_garbage() { | ||
| 575 | assert_eq!(parse_call_args("(a, b) + more"), Ok(6)); | ||
| 576 | assert_eq!(parse_call_args("(x).foo"), Ok(3)); | ||
| 577 | assert_eq!(parse_call_args("(a, b)[0]"), Ok(6)); | ||
| 578 | } | ||
| 579 | |||
| 580 | #[test] | ||
| 581 | fn test_call_args_nested() { | ||
| 582 | assert_eq!(parse_call_args("(fn(a, b))"), Ok(10)); | ||
| 583 | assert_eq!(parse_call_args("((a + b))"), Ok(9)); | ||
| 584 | } | ||
| 585 | |||
| 586 | #[test] | ||
| 587 | fn test_call_args_no_paren() { | ||
| 588 | let result = parse_call_args("a, b)"); | ||
| 589 | assert!(result.is_err()); | ||
| 590 | } | ||
| 591 | |||
| 592 | fn parse_fn_params_body(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 593 | let mut l = LexState::new(source); | ||
| 594 | parse_fn_params_and_body(&mut l) | ||
| 595 | } | ||
| 596 | |||
| 597 | #[test] | ||
| 598 | fn test_fn_simple() { | ||
| 599 | assert_eq!(parse_fn_params_body("() {}"), Ok(5)); | ||
| 600 | assert_eq!(parse_fn_params_body("() { }"), Ok(6)); | ||
| 601 | } | ||
| 602 | |||
| 603 | #[test] | ||
| 604 | fn test_fn_with_params() { | ||
| 605 | assert_eq!(parse_fn_params_body("(a) {}"), Ok(6)); | ||
| 606 | assert_eq!(parse_fn_params_body("(a, b) {}"), Ok(9)); | ||
| 607 | assert_eq!(parse_fn_params_body("(a, b, c) {}"), Ok(12)); | ||
| 608 | } | ||
| 609 | |||
| 610 | #[test] | ||
| 611 | fn test_fn_with_body() { | ||
| 612 | assert_eq!(parse_fn_params_body("() { return 42; }"), Ok(17)); | ||
| 613 | assert_eq!(parse_fn_params_body("(x) { return x + 1; }"), Ok(21)); | ||
| 614 | } | ||
| 615 | |||
| 616 | #[test] | ||
| 617 | fn test_fn_with_return_type() { | ||
| 618 | assert_eq!(parse_fn_params_body("(): void {}"), Ok(11)); | ||
| 619 | assert_eq!(parse_fn_params_body("(): number { return 42; }"), Ok(25)); | ||
| 620 | assert_eq!( | ||
| 621 | parse_fn_params_body("(a: number): string { return String(a); }"), | ||
| 622 | Ok(41) | ||
| 623 | ); | ||
| 624 | } | ||
| 625 | |||
| 626 | #[test] | ||
| 627 | fn test_fn_with_typed_params() { | ||
| 628 | assert_eq!(parse_fn_params_body("(a: number) {}"), Ok(14)); | ||
| 629 | assert_eq!(parse_fn_params_body("(a: string, b: number) {}"), Ok(25)); | ||
| 630 | } | ||
| 631 | |||
| 632 | #[test] | ||
| 633 | fn test_fn_with_defaults() { | ||
| 634 | assert_eq!(parse_fn_params_body("(a = 1) {}"), Ok(10)); | ||
| 635 | assert_eq!(parse_fn_params_body("(a: number = 1) {}"), Ok(18)); | ||
| 636 | } | ||
| 637 | |||
| 638 | #[test] | ||
| 639 | fn test_fn_with_rest() { | ||
| 640 | assert_eq!(parse_fn_params_body("(...args) {}"), Ok(12)); | ||
| 641 | assert_eq!(parse_fn_params_body("(a, ...rest) {}"), Ok(15)); | ||
| 642 | } | ||
| 643 | |||
| 644 | #[test] | ||
| 645 | fn test_fn_trailing_garbage() { | ||
| 646 | // Should stop at the closing brace | ||
| 647 | assert_eq!(parse_fn_params_body("() {} garbage"), Ok(5)); | ||
| 648 | assert_eq!(parse_fn_params_body("() {} + more"), Ok(5)); | ||
| 649 | } | ||
| 650 | |||
| 651 | #[test] | ||
| 652 | fn test_fn_no_paren() { | ||
| 653 | let result = parse_fn_params_body("a) {}"); | ||
| 654 | assert!(result.is_err()); | ||
| 655 | } | ||
| 656 | |||
| 657 | fn parse_var_binding(source: &str) -> Result<usize, OxcDiagnostic> { | ||
| 658 | let mut l = LexState::new(source); | ||
| 659 | super::parse_var_binding(&mut l) | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn test_var_binding_identifier() { | ||
| 664 | assert_eq!(parse_var_binding("foo"), Ok(3)); | ||
| 665 | assert_eq!(parse_var_binding("bar123"), Ok(6)); | ||
| 666 | assert_eq!(parse_var_binding("_private"), Ok(8)); | ||
| 667 | } | ||
| 668 | |||
| 669 | #[test] | ||
| 670 | fn test_var_binding_with_type() { | ||
| 671 | assert_eq!(parse_var_binding("foo: string meow"), Ok(11)); | ||
| 672 | assert_eq!(parse_var_binding("bar: number"), Ok(11)); | ||
| 673 | assert_eq!(parse_var_binding("x: boolean"), Ok(10)); | ||
| 674 | } | ||
| 675 | |||
| 676 | #[test] | ||
| 677 | fn test_var_binding_object_destructure() { | ||
| 678 | assert_eq!(parse_var_binding("{ a }"), Ok(5)); | ||
| 679 | assert_eq!(parse_var_binding("{ a, b }"), Ok(8)); | ||
| 680 | assert_eq!(parse_var_binding("{ a: x, b: y }"), Ok(14)); | ||
| 681 | } | ||
| 682 | |||
| 683 | #[test] | ||
| 684 | fn test_var_binding_array_destructure() { | ||
| 685 | assert_eq!(parse_var_binding("[a]"), Ok(3)); | ||
| 686 | assert_eq!(parse_var_binding("[a, b]"), Ok(6)); | ||
| 687 | assert_eq!(parse_var_binding("[a, , b]"), Ok(8)); | ||
| 688 | } | ||
| 689 | |||
| 690 | #[test] | ||
| 691 | fn test_var_binding_destructure_with_type() { | ||
| 692 | assert_eq!(parse_var_binding("{ a, b }: T abc"), Ok(11)); | ||
| 693 | assert_eq!(parse_var_binding("[x, y]: [number, number], yolo"), Ok(24)); | ||
| 694 | } | ||
| 695 | |||
| 696 | #[test] | ||
| 697 | fn test_var_binding_no_default() { | ||
| 698 | assert_eq!(parse_var_binding("foo = 123"), Ok(3)); | ||
| 699 | assert_eq!(parse_var_binding("foo: string = 'hi'"), Ok(11)); | ||
| 700 | } | ||
| 701 | |||
| 702 | #[test] | ||
| 703 | fn test_var_binding_empty() { | ||
| 704 | let result = parse_var_binding(""); | ||
| 705 | assert!(result.is_err()); | ||
| 706 | } | ||
| 707 | |||
| 708 | #[test] | ||
| 709 | fn test_var_binding_identifier_garbage() { | ||
| 710 | assert_eq!(parse_var_binding("foo meow meow meow"), Ok(3)); | ||
| 711 | assert_eq!(parse_var_binding("foo|etc"), Ok(3)); | ||
| 712 | assert_eq!(parse_var_binding("foo|etc, 1"), Ok(3)); | ||
| 713 | assert_eq!(parse_var_binding("nya: string, 💥"), Ok(11)); | ||
| 714 | } | ||
| 715 | |||
| 716 | #[test] | ||
| 717 | fn test_var_binding_identifier_invalid_destructure() { | ||
| 718 | assert!(parse_var_binding("{ x: y + 2 }").is_err()); | ||
| 719 | assert!(parse_var_binding("[ y + 2 ]").is_err()); | ||
| 720 | } | ||
| 721 | |||
| 722 | #[test] | ||
| 723 | fn test_var_binding_identifier_unicode() { | ||
| 724 | assert_eq!(parse_var_binding("café"), Ok(5)); | ||
| 725 | } | ||
| 726 | |||
| 727 | #[test] | ||
| 728 | fn test_stmt_function_with_garbage() { | ||
| 729 | let source = "function hello() {\n console.log(1);\n}\n\nrandom markdown garbage"; | ||
| 730 | let end = find_statement_extent(source).unwrap(); | ||
| 731 | assert_eq!(&source[..end], "function hello() {\n console.log(1);\n}"); | ||
| 732 | } | ||
| 733 | |||
| 734 | #[test] | ||
| 735 | fn test_stmt_import_with_garbage() { | ||
| 736 | let source = "import { foo } from 'bar';\n\n# markdown heading"; | ||
| 737 | let end = find_statement_extent(source).unwrap(); | ||
| 738 | assert_eq!(&source[..end], "import { foo } from 'bar';"); | ||
| 739 | } | ||
| 740 | |||
| 741 | #[test] | ||
| 742 | fn test_stmt_interface_with_garbage() { | ||
| 743 | let source = "interface Foo {\n bar: string;\n}\n\nsome text"; | ||
| 744 | let end = find_statement_extent(source).unwrap(); | ||
| 745 | assert_eq!(&source[..end], "interface Foo {\n bar: string;\n}"); | ||
| 746 | } | ||
| 747 | |||
| 748 | #[test] | ||
| 749 | fn test_stmt_const_declaration() { | ||
| 750 | let source = "const answer = 42;\n\n# Next section"; | ||
| 751 | let end = find_statement_extent(source).unwrap(); | ||
| 752 | assert_eq!(&source[..end], "const answer = 42;"); | ||
| 753 | } | ||
| 754 | |||
| 755 | #[test] | ||
| 756 | fn test_stmt_expression_statement() { | ||
| 757 | let source = "console.log('hello');\n\nmore content"; | ||
| 758 | let end = find_statement_extent(source).unwrap(); | ||
| 759 | assert_eq!(&source[..end], "console.log('hello');"); | ||
| 760 | } | ||
| 761 | |||
| 762 | #[test] | ||
| 763 | fn test_stmt_pure_garbage() { | ||
| 764 | let source = "random unexpected garbage"; | ||
| 765 | let result = find_statement_extent(source); | ||
| 766 | assert!(result.is_err()); | ||
| 767 | } | ||
| 768 | |||
| 769 | #[test] | ||
| 770 | fn test_stmt_multiline_function() { | ||
| 771 | let source = "function sort(items) {\n while (!isSorted()) {\n shuffle(items);\n }\n return items;\n}\n\n# Heading"; | ||
| 772 | let end = find_statement_extent(source).unwrap(); | ||
| 773 | assert!(source[..end].contains("return items;")); | ||
| 774 | assert!(source[..end].contains("}")); | ||
| 775 | } | ||
| 776 | |||
| 777 | #[test] | ||
| 778 | fn test_stmt_trailing_content_on_same_line() { | ||
| 779 | let source = "const x = 1; const y = 2;"; | ||
| 780 | let result = find_statement_extent(source); | ||
| 781 | assert!(result.is_err()); | ||
| 782 | } | ||
| 783 | |||
| 784 | #[test] | ||
| 785 | fn test_stmt_trailing_whitespace_ok() { | ||
| 786 | let source = "const x = 1; \n\nmore content"; | ||
| 787 | let result = find_statement_extent(source); | ||
| 788 | assert!(result.is_ok()); | ||
| 789 | } | ||
| 790 | } | ||
src/wasm.rstests/fixtures.rs created+149| ... | @@ -0,0 +1,149 @@ | ||
| 1 | use markodown::{transform, OutputFormat}; | ||
| 2 | use std::fs; | ||
| 3 | use std::path::Path; | ||
| 4 | |||
| 5 | /// Load a fixture pair and test the transform. | ||
| 6 | /// | ||
| 7 | /// Each fixture has an `.mdo` input and either an `.html` expected output | ||
| 8 | /// (static mode) or a `.marko` expected output (marko mode), or both. | ||
| 9 | fn run_fixture(name: &str) { | ||
| 10 | let base = Path::new("tests/fixtures").join(name); | ||
| 11 | let input_path = base.with_extension("mdo"); | ||
| 12 | let html_path = base.with_extension("html"); | ||
| 13 | let marko_path = base.with_extension("marko"); | ||
| 14 | |||
| 15 | let source = fs::read_to_string(&input_path) | ||
| 16 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display())); | ||
| 17 | |||
| 18 | // Test HTML output if expected file exists | ||
| 19 | if html_path.exists() { | ||
| 20 | let expected = fs::read_to_string(&html_path) | ||
| 21 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display())); | ||
| 22 | |||
| 23 | let output = transform(&source, Some(OutputFormat::Html)).unwrap(); | ||
| 24 | |||
| 25 | assert_eq!( | ||
| 26 | output.format, | ||
| 27 | OutputFormat::Html, | ||
| 28 | "[{name}] expected Html output but got Marko" | ||
| 29 | ); | ||
| 30 | |||
| 31 | assert_eq!( | ||
| 32 | output.text.trim(), | ||
| 33 | expected.trim(), | ||
| 34 | "\n\n[{name}] HTML output mismatch\n\n--- expected ---\n{expected}\n--- actual ---\n{}\n", | ||
| 35 | output.text | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | // Test Marko output if expected file exists | ||
| 39 | else if marko_path.exists() { | ||
| 40 | let expected = fs::read_to_string(&marko_path) | ||
| 41 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display())); | ||
| 42 | |||
| 43 | let output = transform(&source, Some(OutputFormat::Marko)).unwrap(); | ||
| 44 | |||
| 45 | assert_eq!( | ||
| 46 | output.format, | ||
| 47 | OutputFormat::Marko, | ||
| 48 | "[{name}] expected Marko output but got Html" | ||
| 49 | ); | ||
| 50 | |||
| 51 | assert_eq!( | ||
| 52 | output.text.trim(), | ||
| 53 | expected.trim(), | ||
| 54 | "\n\n[{name}] Marko output mismatch\n\n--- expected ---\n{expected}\n--- actual ---\n{}\n", | ||
| 55 | output.text | ||
| 56 | ); | ||
| 57 | } else { | ||
| 58 | panic!("no test defined") | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | #[test] | ||
| 63 | fn fixture_01_static_markdown() { | ||
| 64 | run_fixture("01-static-markdown"); | ||
| 65 | } | ||
| 66 | |||
| 67 | // The tests below have .marko expected outputs. They will be validated | ||
| 68 | // once the marko renderer and custom parse rules are implemented. | ||
| 69 | // For now they just verify the transform doesn't panic. | ||
| 70 | |||
| 71 | #[test] | ||
| 72 | fn fixture_02_imports_and_components() { | ||
| 73 | run_fixture("02-imports-and-components"); | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn fixture_03_static_and_expressions() { | ||
| 78 | run_fixture("03-static-and-expressions"); | ||
| 79 | } | ||
| 80 | |||
| 81 | #[test] | ||
| 82 | fn fixture_04_control_flow() { | ||
| 83 | run_fixture("04-control-flow"); | ||
| 84 | } | ||
| 85 | |||
| 86 | #[test] | ||
| 87 | fn fixture_05_nested_tags_and_loops() { | ||
| 88 | run_fixture("05-nested-tags-and-loops"); | ||
| 89 | } | ||
| 90 | |||
| 91 | #[test] | ||
| 92 | fn fixture_06_comments() { | ||
| 93 | run_fixture("06-comments"); | ||
| 94 | } | ||
| 95 | |||
| 96 | #[test] | ||
| 97 | fn fixture_07_server_client() { | ||
| 98 | run_fixture("07-server-client"); | ||
| 99 | } | ||
| 100 | |||
| 101 | #[test] | ||
| 102 | fn fixture_08_inline_tags() { | ||
| 103 | run_fixture("08-inline-tags"); | ||
| 104 | } | ||
| 105 | |||
| 106 | #[test] | ||
| 107 | fn fixture_09_marko_shorthands() { | ||
| 108 | run_fixture("09-marko-shorthands"); | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn fixture_10_attribute_tags() { | ||
| 113 | run_fixture("10-attribute-tags"); | ||
| 114 | } | ||
| 115 | |||
| 116 | #[test] | ||
| 117 | fn fixture_11_autolinks() { | ||
| 118 | run_fixture("11-autolinks"); | ||
| 119 | } | ||
| 120 | |||
| 121 | #[test] | ||
| 122 | fn fixture_12_frontmatter() { | ||
| 123 | run_fixture("12-frontmatter"); | ||
| 124 | } | ||
| 125 | |||
| 126 | #[test] | ||
| 127 | fn fixture_13_kitchen_sink() { | ||
| 128 | run_fixture("13-kitchen-sink"); | ||
| 129 | } | ||
| 130 | |||
| 131 | #[test] | ||
| 132 | fn fixture_14_evil_braces_and_typescript() { | ||
| 133 | run_fixture("14-evil-braces-and-typescript"); | ||
| 134 | } | ||
| 135 | |||
| 136 | #[test] | ||
| 137 | fn fixture_15_multiline_tag() { | ||
| 138 | run_fixture("15-multiline-tag"); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn fixture_16_same_line_text() { | ||
| 143 | run_fixture("16-same-line-text"); | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn fixture_17_consecutive_statements() { | ||
| 148 | run_fixture("17-consecutive-statements"); | ||
| 149 | } | ||
tests/fixtures/01-static-markdown.html created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | <h1>Hello World</h1> | ||
| 2 | <p>This is a <strong>simple</strong> document with <em>emphasis</em>.</p> | ||
| 3 | <ul> | ||
| 4 | <li>one</li> | ||
| 5 | <li>two</li> | ||
| 6 | <li>three</li> | ||
| 7 | </ul> | ||
| 8 | <h2>Code</h2> | ||
| 9 | <pre><code class="language-rust">fn main() {} | ||
| 10 | </code></pre> | ||
| 11 | <blockquote> | ||
| 12 | <p>A blockquote.</p> | ||
| 13 | </blockquote> | ||
| 14 | <p>A <a href="https://example.com">link</a> and an <img src="logo.png" alt="image">.</p> | ||
tests/fixtures/01-static-markdown.mdo created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | # Hello World | ||
| 2 | |||
| 3 | This is a **simple** document with *emphasis*. | ||
| 4 | |||
| 5 | - one | ||
| 6 | - two | ||
| 7 | - three | ||
| 8 | |||
| 9 | ## Code | ||
| 10 | |||
| 11 | ```rust | ||
| 12 | fn main() {} | ||
| 13 | ``` | ||
| 14 | |||
| 15 | > A blockquote. | ||
| 16 | |||
| 17 | A [link](https://example.com) and an . | ||
tests/fixtures/02-imports-and-components.marko created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import Chart from "./chart.marko"; | ||
| 2 | import { sum } from "./math.js"; | ||
| 3 | <h1>Dashboard</h1> | ||
| 4 | <p>Here is the <strong>chart</strong>:</p> | ||
| 5 | <Chart data=salesData /> | ||
tests/fixtures/02-imports-and-components.mdo created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import Chart from "./chart.marko"; | ||
| 2 | import { sum } from "./math.js"; | ||
| 3 | |||
| 4 | # Dashboard | ||
| 5 | |||
| 6 | Here is the **chart**: | ||
| 7 | |||
| 8 | <Chart data=salesData /> | ||
tests/fixtures/03-static-and-expressions.marko created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | static const answer = 41; | ||
| 2 | static function getAnswer() { | ||
| 3 | return answer + 1; | ||
| 4 | } | ||
| 5 | <h1>The Answer</h1> | ||
| 6 | <p>The answer is <strong>${getAnswer()}</strong>.</p> | ||
tests/fixtures/03-static-and-expressions.mdo created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | static const answer = 41; | ||
| 2 | static function getAnswer() { | ||
| 3 | return answer + 1; | ||
| 4 | } | ||
| 5 | |||
| 6 | # The Answer | ||
| 7 | |||
| 8 | The answer is **${getAnswer()}**. | ||
tests/fixtures/04-control-flow.marko created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | <h1>Welcome</h1> | ||
| 2 | <if=showIntro> | ||
| 3 | <p>Welcome to the <strong>site</strong>.</p> | ||
| 4 | <ul> | ||
| 5 | <li>item one</li> | ||
| 6 | <li>item two</li> | ||
| 7 | </ul> | ||
| 8 | </if> | ||
| 9 | <else> | ||
| 10 | <p><strong>Goodbye</strong>, see you later.</p> | ||
| 11 | </else> | ||
tests/fixtures/04-control-flow.mdo created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | # Welcome | ||
| 2 | |||
| 3 | <if=showIntro> | ||
| 4 | |||
| 5 | Welcome to the **site**. | ||
| 6 | |||
| 7 | - item one | ||
| 8 | - item two | ||
| 9 | |||
| 10 | </if> | ||
| 11 | <else> | ||
| 12 | |||
| 13 | **Goodbye**, see you later. | ||
| 14 | |||
| 15 | </else> | ||
tests/fixtures/05-nested-tags-and-loops.marko created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import Chart from "./chart.marko"; | ||
| 2 | <h1>Report</h1> | ||
| 3 | <if=showDetails> | ||
| 4 | <h2>Details</h2> | ||
| 5 | <p>Here is a detailed <strong>breakdown</strong>:</p> | ||
| 6 | <for|quarter| of=quarters> | ||
| 7 | <h3>Q${quarter.num}</h3> | ||
| 8 | <p>Revenue: <strong>${quarter.revenue}</strong></p> | ||
| 9 | <Chart data=quarter.data /> | ||
| 10 | </for> | ||
| 11 | </if> | ||
tests/fixtures/05-nested-tags-and-loops.mdo created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import Chart from "./chart.marko"; | ||
| 2 | |||
| 3 | # Report | ||
| 4 | |||
| 5 | <if=showDetails> | ||
| 6 | |||
| 7 | ## Details | ||
| 8 | |||
| 9 | Here is a detailed **breakdown**: | ||
| 10 | |||
| 11 | <for|quarter| of=quarters> | ||
| 12 | |||
| 13 | ### Q${quarter.num} | ||
| 14 | |||
| 15 | Revenue: **${quarter.revenue}** | ||
| 16 | |||
| 17 | <Chart data=quarter.data /> | ||
| 18 | </for> | ||
| 19 | </if> | ||
tests/fixtures/06-comments.marko created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | <p>hello world</p> | ||
| 2 | // this is a comment | ||
| 3 | <!-- html comment --> | ||
| 4 | <p>good morning</p> | ||
tests/fixtures/06-comments.mdo created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | hello world | ||
| 2 | |||
| 3 | // this is a comment | ||
| 4 | |||
| 5 | <!-- html comment --> | ||
| 6 | |||
| 7 | good morning | ||
tests/fixtures/07-server-client.marko created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | server console.log("on the server") | ||
| 2 | client console.log("in the browser") | ||
| 3 | <h1>Hello</h1> | ||
| 4 | <p>This is a page.</p> | ||
tests/fixtures/07-server-client.mdo created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | server console.log("on the server") | ||
| 2 | client console.log("in the browser") | ||
| 3 | |||
| 4 | # Hello | ||
| 5 | |||
| 6 | This is a page. | ||
tests/fixtures/08-inline-tags.marko created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | <h1>Inline</h1> | ||
| 2 | <p>Click <button onClick() { alert("hi") }>here</button> to continue.</p> | ||
| 3 | <p>This has a <badge count=3>premium</badge> feature and <strong>bold</strong> text.</p> | ||
| 4 | <p>You can go <b>anywhere</b> with inline tags.</p> | ||
tests/fixtures/08-inline-tags.mdo created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | # Inline | ||
| 2 | |||
| 3 | Click <button onClick() { alert("hi") }>here</button> to continue. | ||
| 4 | |||
| 5 | This has a <badge count=3>premium</badge> feature and **bold** text. | ||
| 6 | |||
| 7 | You can go <b>anywhere</b> with inline tags. | ||
tests/fixtures/09-marko-shorthands.marko created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | <div#main.container> | ||
| 2 | <h1>Welcome</h1> | ||
| 3 | <span.highlight>Important <strong>stuff</strong></span> | ||
| 4 | </div> | ||
| 5 | <clover-video src="hello.mp4" /> | ||
tests/fixtures/09-marko-shorthands.mdo created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | <div#main.container> | ||
| 2 | |||
| 3 | # Welcome | ||
| 4 | |||
| 5 | <span.highlight>Important **stuff**</span> | ||
| 6 | |||
| 7 | </div> | ||
| 8 | |||
| 9 | <clover-video src="hello.mp4" /> | ||
tests/fixtures/10-attribute-tags.marko created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | <my-layout title="Welcome"> | ||
| 2 | <@header class="fancy"> | ||
| 3 | <h1>Big things are coming!</h1> | ||
| 4 | </@header> | ||
| 5 | <p>Regular page content in <strong>markdown</strong>.</p> | ||
| 6 | </my-layout> | ||
tests/fixtures/10-attribute-tags.mdo created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | <my-layout title="Welcome"> | ||
| 2 | <@header class="fancy"> | ||
| 3 | |||
| 4 | # Big things are coming! | ||
| 5 | |||
| 6 | </@header> | ||
| 7 | |||
| 8 | Regular page content in **markdown**. | ||
| 9 | |||
| 10 | </my-layout> | ||
tests/fixtures/11-autolinks.marko created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | <p>Visit <a href="https://example.com">example.com</a> for more info.</p> | ||
| 2 | <p>See also <a href="./other-page.mdo">./other-page.mdo</a> and <a href="../parent.mdo">../parent.mdo</a>.</p> | ||
tests/fixtures/11-autolinks.mdo created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | Visit <https://example.com> for more info. | ||
| 2 | |||
| 3 | See also <./other-page.mdo> and <../parent.mdo>. | ||
tests/fixtures/12-frontmatter.marko created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | export const title = "My Page"; | ||
| 2 | export const date = "2026-02-08"; | ||
| 3 | |||
| 4 | <h1>${title}</h1> | ||
| 5 | <p>Published on ${date}.</p> | ||
tests/fixtures/12-frontmatter.mdo created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | --- | ||
| 2 | title: My Page | ||
| 3 | date: 2026-02-08 | ||
| 4 | --- | ||
| 5 | |||
| 6 | # ${title} | ||
| 7 | |||
| 8 | Published on ${date}. | ||
tests/fixtures/13-kitchen-sink.marko created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | export const title = "Full Example"; | ||
| 2 | |||
| 3 | import Chart from "./chart.marko"; | ||
| 4 | static const year = 2026; | ||
| 5 | export const slug = "full-example"; | ||
| 6 | // Page starts here | ||
| 7 | <h1>${title}</h1> | ||
| 8 | <p>This is a <strong>complete</strong> example of <em>markodown</em> featuring ${year}.</p> | ||
| 9 | server { | ||
| 10 | const data = await fetchData(); | ||
| 11 | } | ||
| 12 | <if=data> | ||
| 13 | <h2>Chart</h2> | ||
| 14 | <Chart data=data year=year /> | ||
| 15 | <for|point| of=data.points> | ||
| 16 | <ul> | ||
| 17 | <li><strong>${point.label}</strong>: ${point.value}</li> | ||
| 18 | </ul> | ||
| 19 | </for> | ||
| 20 | </if> | ||
| 21 | <else> | ||
| 22 | <p>No data available. Visit <a href="https://example.com">example.com</a> for help.</p> | ||
| 23 | </else> | ||
| 24 | <div#footer.site-footer> | ||
| 25 | <app-footer year=year /> | ||
| 26 | </div> | ||
tests/fixtures/13-kitchen-sink.mdo created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | --- | ||
| 2 | title: Full Example | ||
| 3 | --- | ||
| 4 | |||
| 5 | import Chart from "./chart.marko"; | ||
| 6 | |||
| 7 | static const year = 2026; | ||
| 8 | |||
| 9 | export const slug = "full-example"; | ||
| 10 | |||
| 11 | // Page starts here | ||
| 12 | |||
| 13 | # ${title} | ||
| 14 | |||
| 15 | This is a **complete** example of *markodown* featuring ${year}. | ||
| 16 | |||
| 17 | server { | ||
| 18 | const data = await fetchData(); | ||
| 19 | } | ||
| 20 | |||
| 21 | <if=data> | ||
| 22 | ## Chart | ||
| 23 | |||
| 24 | <Chart data=data year=year /> | ||
| 25 | |||
| 26 | <for|point| of=data.points> | ||
| 27 | - **${point.label}**: ${point.value} | ||
| 28 | </for> | ||
| 29 | </if> | ||
| 30 | <else> | ||
| 31 | No data available. Visit <https://example.com> for help. | ||
| 32 | </else> | ||
| 33 | |||
| 34 | <div#footer.site-footer> | ||
| 35 | <app-footer year=year /> | ||
| 36 | </div> | ||
tests/fixtures/14-evil-braces-and-typescript.marko created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | export interface Input { | ||
| 2 | name: string; | ||
| 3 | items: Array<{ id: number; label: string }>; | ||
| 4 | } | ||
| 5 | static const message = "Hello {world} with {braces}"; | ||
| 6 | static const template = ` | ||
| 7 | This is a template literal { | ||
| 8 | with nested braces | ||
| 9 | } and expressions ${1 + 1} | ||
| 10 | `; | ||
| 11 | static function evil() { | ||
| 12 | const obj = { a: "{", b: "}" }; | ||
| 13 | const regex = /\{.*\}/; | ||
| 14 | return `{${obj.a}${obj.b}}`; | ||
| 15 | } | ||
| 16 | static { | ||
| 17 | console.log("Block with {braces} in string"); | ||
| 18 | const data = { key: "value {nested}" }; | ||
| 19 | } | ||
| 20 | <h1>Evil Braces Test</h1> | ||
| 21 | <p>This tests that string literals with braces don’t confuse the parser.</p> | ||
| 22 | <p>The message is: <strong>${message}</strong></p> | ||
tests/fixtures/14-evil-braces-and-typescript.mdo created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | export interface Input { | ||
| 2 | name: string; | ||
| 3 | items: Array<{ id: number; label: string }>; | ||
| 4 | } | ||
| 5 | |||
| 6 | static const message = "Hello {world} with {braces}"; | ||
| 7 | static const template = ` | ||
| 8 | This is a template literal { | ||
| 9 | with nested braces | ||
| 10 | } and expressions ${1 + 1} | ||
| 11 | `; | ||
| 12 | |||
| 13 | static function evil() { | ||
| 14 | const obj = { a: "{", b: "}" }; | ||
| 15 | const regex = /\{.*\}/; | ||
| 16 | return `{${obj.a}${obj.b}}`; | ||
| 17 | } | ||
| 18 | |||
| 19 | static { | ||
| 20 | console.log("Block with {braces} in string"); | ||
| 21 | const data = { key: "value {nested}" }; | ||
| 22 | } | ||
| 23 | |||
| 24 | # Evil Braces Test | ||
| 25 | |||
| 26 | This tests that string literals with braces don't confuse the parser. | ||
| 27 | |||
| 28 | The message is: **${message}** | ||
tests/fixtures/15-multiline-tag.marko created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | <p>good morning</p> | ||
| 2 | <blog-layout | ||
| 3 | attribute> | ||
| 4 | <p>hello <strong>world</strong></p> | ||
| 5 | </blog-layout> | ||
tests/fixtures/15-multiline-tag.mdo created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | good morning | ||
| 2 | |||
| 3 | <blog-layout | ||
| 4 | attribute> | ||
| 5 | |||
| 6 | hello **world** | ||
| 7 | |||
| 8 | </blog-layout> | ||
tests/fixtures/16-same-line-text.marko created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | <div> | ||
| 2 | samelinetext | ||
| 3 | <p>hello <strong>world</strong></p> | ||
| 4 | </div> | ||
| 5 | <div> | ||
| 6 | sameline <strong>text</strong> | ||
| 7 | <p>hello <strong>world</strong></p> | ||
| 8 | </div> | ||
tests/fixtures/16-same-line-text.mdo created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | |||
| 2 | <div>samelinetext | ||
| 3 | |||
| 4 | hello **world** | ||
| 5 | |||
| 6 | </div> | ||
| 7 | |||
| 8 | <div> sameline **text** | ||
| 9 | |||
| 10 | hello **world** | ||
| 11 | |||
| 12 | </div> | ||
tests/fixtures/17-consecutive-statements.marko created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | static const y = "**hello**"; | ||
| 2 | export const z = y + " **world**"; | ||
tests/fixtures/17-consecutive-statements.mdo created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | static const y = "**hello**"; | ||
| 2 | export const z = y + " **world**"; | ||