| 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**"; |