authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-08 20:30:08-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-12 22:09:03-08:00
log525512dd8576feeeeb56a895aa90a2240cdd7c7a
tree9221fc6c455408d0e088619859422da94fad01bf
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

initial commit

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
3please 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
5be 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
21you are not familiar with the version 6 syntax changes: see
22<https://markojs.com/docs/reference/language> for the full explainer. here is a
23basic 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
7markdown-it does not support failiable plugins, so instead errors are lowered
8into `ErrorBlock` and then the errors are combined to form the final error list.
9all the rules are essentially validations for the Marko syntax, and get
10reprinted 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
22these 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.
3version = 4
4
5[[package]]
6name = "aho-corasick"
7version = "1.1.4"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10dependencies = [
11 "memchr",
12]
13
14[[package]]
15name = "allocator-api2"
16version = "0.2.21"
17source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
20[[package]]
21name = "anstream"
22version = "0.6.21"
23source = "registry+https://github.com/rust-lang/crates.io-index"
24checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
25dependencies = [
26 "anstyle",
27 "anstyle-parse",
28 "anstyle-query",
29 "anstyle-wincon",
30 "colorchoice",
31 "is_terminal_polyfill",
32 "utf8parse",
33]
34
35[[package]]
36name = "anstyle"
37version = "1.0.13"
38source = "registry+https://github.com/rust-lang/crates.io-index"
39checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
40
41[[package]]
42name = "anstyle-parse"
43version = "0.2.7"
44source = "registry+https://github.com/rust-lang/crates.io-index"
45checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
46dependencies = [
47 "utf8parse",
48]
49
50[[package]]
51name = "anstyle-query"
52version = "1.1.5"
53source = "registry+https://github.com/rust-lang/crates.io-index"
54checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
55dependencies = [
56 "windows-sys 0.61.2",
57]
58
59[[package]]
60name = "anstyle-wincon"
61version = "3.0.11"
62source = "registry+https://github.com/rust-lang/crates.io-index"
63checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
64dependencies = [
65 "anstyle",
66 "once_cell_polyfill",
67 "windows-sys 0.61.2",
68]
69
70[[package]]
71name = "anyhow"
72version = "1.0.101"
73source = "registry+https://github.com/rust-lang/crates.io-index"
74checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
75
76[[package]]
77name = "ar_archive_writer"
78version = "0.5.1"
79source = "registry+https://github.com/rust-lang/crates.io-index"
80checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
81dependencies = [
82 "object",
83]
84
85[[package]]
86name = "argparse"
87version = "0.2.2"
88source = "registry+https://github.com/rust-lang/crates.io-index"
89checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47"
90
91[[package]]
92name = "autocfg"
93version = "1.5.0"
94source = "registry+https://github.com/rust-lang/crates.io-index"
95checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
96
97[[package]]
98name = "bitflags"
99version = "2.10.0"
100source = "registry+https://github.com/rust-lang/crates.io-index"
101checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
102
103[[package]]
104name = "bumpalo"
105version = "3.19.0"
106source = "registry+https://github.com/rust-lang/crates.io-index"
107checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
108dependencies = [
109 "allocator-api2",
110]
111
112[[package]]
113name = "castaway"
114version = "0.2.4"
115source = "registry+https://github.com/rust-lang/crates.io-index"
116checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
117dependencies = [
118 "rustversion",
119]
120
121[[package]]
122name = "cc"
123version = "1.2.55"
124source = "registry+https://github.com/rust-lang/crates.io-index"
125checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
126dependencies = [
127 "find-msvc-tools",
128 "shlex",
129]
130
131[[package]]
132name = "cfg-if"
133version = "1.0.4"
134source = "registry+https://github.com/rust-lang/crates.io-index"
135checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
136
137[[package]]
138name = "clap"
139version = "4.5.57"
140source = "registry+https://github.com/rust-lang/crates.io-index"
141checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
142dependencies = [
143 "clap_builder",
144 "clap_derive",
145]
146
147[[package]]
148name = "clap_builder"
149version = "4.5.57"
150source = "registry+https://github.com/rust-lang/crates.io-index"
151checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
152dependencies = [
153 "anstream",
154 "anstyle",
155 "clap_lex",
156 "strsim",
157]
158
159[[package]]
160name = "clap_derive"
161version = "4.5.55"
162source = "registry+https://github.com/rust-lang/crates.io-index"
163checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
164dependencies = [
165 "heck",
166 "proc-macro2",
167 "quote",
168 "syn 2.0.114",
169]
170
171[[package]]
172name = "clap_lex"
173version = "0.7.7"
174source = "registry+https://github.com/rust-lang/crates.io-index"
175checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
176
177[[package]]
178name = "colorchoice"
179version = "1.0.4"
180source = "registry+https://github.com/rust-lang/crates.io-index"
181checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
182
183[[package]]
184name = "compact_str"
185version = "0.9.0"
186source = "registry+https://github.com/rust-lang/crates.io-index"
187checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
188dependencies = [
189 "castaway",
190 "cfg-if",
191 "itoa",
192 "rustversion",
193 "ryu",
194 "static_assertions",
195]
196
197[[package]]
198name = "const_format"
199version = "0.2.35"
200source = "registry+https://github.com/rust-lang/crates.io-index"
201checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad"
202dependencies = [
203 "const_format_proc_macros",
204]
205
206[[package]]
207name = "const_format_proc_macros"
208version = "0.2.34"
209source = "registry+https://github.com/rust-lang/crates.io-index"
210checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
211dependencies = [
212 "proc-macro2",
213 "quote",
214 "unicode-xid",
215]
216
217[[package]]
218name = "convert_case"
219version = "0.4.0"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
222
223[[package]]
224name = "cow-utils"
225version = "0.1.3"
226source = "registry+https://github.com/rust-lang/crates.io-index"
227checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
228
229[[package]]
230name = "derivative"
231version = "2.2.0"
232source = "registry+https://github.com/rust-lang/crates.io-index"
233checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
234dependencies = [
235 "proc-macro2",
236 "quote",
237 "syn 1.0.109",
238]
239
240[[package]]
241name = "derive_more"
242version = "0.99.20"
243source = "registry+https://github.com/rust-lang/crates.io-index"
244checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
245dependencies = [
246 "convert_case",
247 "proc-macro2",
248 "quote",
249 "rustc_version",
250 "syn 2.0.114",
251]
252
253[[package]]
254name = "downcast-rs"
255version = "1.2.1"
256source = "registry+https://github.com/rust-lang/crates.io-index"
257checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
258
259[[package]]
260name = "dragonbox_ecma"
261version = "0.0.5"
262source = "registry+https://github.com/rust-lang/crates.io-index"
263checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66"
264
265[[package]]
266name = "entities"
267version = "1.0.1"
268source = "registry+https://github.com/rust-lang/crates.io-index"
269checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
270
271[[package]]
272name = "equivalent"
273version = "1.0.2"
274source = "registry+https://github.com/rust-lang/crates.io-index"
275checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
276
277[[package]]
278name = "fastrand"
279version = "2.3.0"
280source = "registry+https://github.com/rust-lang/crates.io-index"
281checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
282
283[[package]]
284name = "find-msvc-tools"
285version = "0.1.9"
286source = "registry+https://github.com/rust-lang/crates.io-index"
287checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
288
289[[package]]
290name = "hashbrown"
291version = "0.16.1"
292source = "registry+https://github.com/rust-lang/crates.io-index"
293checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
294dependencies = [
295 "allocator-api2",
296]
297
298[[package]]
299name = "heck"
300version = "0.5.0"
301source = "registry+https://github.com/rust-lang/crates.io-index"
302checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
303
304[[package]]
305name = "html-escape"
306version = "0.2.13"
307source = "registry+https://github.com/rust-lang/crates.io-index"
308checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
309dependencies = [
310 "utf8-width",
311]
312
313[[package]]
314name = "idna"
315version = "0.3.0"
316source = "registry+https://github.com/rust-lang/crates.io-index"
317checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
318dependencies = [
319 "unicode-bidi",
320 "unicode-normalization",
321]
322
323[[package]]
324name = "indexmap"
325version = "2.13.0"
326source = "registry+https://github.com/rust-lang/crates.io-index"
327checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
328dependencies = [
329 "equivalent",
330 "hashbrown",
331]
332
333[[package]]
334name = "is_terminal_polyfill"
335version = "1.70.2"
336source = "registry+https://github.com/rust-lang/crates.io-index"
337checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
338
339[[package]]
340name = "itoa"
341version = "1.0.17"
342source = "registry+https://github.com/rust-lang/crates.io-index"
343checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
344
345[[package]]
346name = "libc"
347version = "0.2.180"
348source = "registry+https://github.com/rust-lang/crates.io-index"
349checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
350
351[[package]]
352name = "libyml"
353version = "0.0.5"
354source = "registry+https://github.com/rust-lang/crates.io-index"
355checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
356dependencies = [
357 "anyhow",
358 "version_check",
359]
360
361[[package]]
362name = "linkify"
363version = "0.10.0"
364source = "registry+https://github.com/rust-lang/crates.io-index"
365checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
366dependencies = [
367 "memchr",
368]
369
370[[package]]
371name = "markdown-it"
372version = "0.6.1"
373source = "registry+https://github.com/rust-lang/crates.io-index"
374checksum = "f99c010929c8217b2dc0940954267a2e15a15f17cb309cd1f299e21933f84fac"
375dependencies = [
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]]
393name = "markodown"
394version = "0.1.0"
395dependencies = [
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]]
411name = "mdurl"
412version = "0.3.1"
413source = "registry+https://github.com/rust-lang/crates.io-index"
414checksum = "5736ba45bbac8f7ccc99a897f88ce85e508a18baec973a040f2514e6cdbff0d2"
415dependencies = [
416 "idna",
417 "once_cell",
418 "regex",
419]
420
421[[package]]
422name = "memchr"
423version = "2.8.0"
424source = "registry+https://github.com/rust-lang/crates.io-index"
425checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
426
427[[package]]
428name = "nonmax"
429version = "0.5.5"
430source = "registry+https://github.com/rust-lang/crates.io-index"
431checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
432
433[[package]]
434name = "num-bigint"
435version = "0.4.6"
436source = "registry+https://github.com/rust-lang/crates.io-index"
437checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
438dependencies = [
439 "num-integer",
440 "num-traits",
441]
442
443[[package]]
444name = "num-integer"
445version = "0.1.46"
446source = "registry+https://github.com/rust-lang/crates.io-index"
447checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
448dependencies = [
449 "num-traits",
450]
451
452[[package]]
453name = "num-traits"
454version = "0.2.19"
455source = "registry+https://github.com/rust-lang/crates.io-index"
456checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
457dependencies = [
458 "autocfg",
459]
460
461[[package]]
462name = "object"
463version = "0.37.3"
464source = "registry+https://github.com/rust-lang/crates.io-index"
465checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
466dependencies = [
467 "memchr",
468]
469
470[[package]]
471name = "once_cell"
472version = "1.21.3"
473source = "registry+https://github.com/rust-lang/crates.io-index"
474checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
475
476[[package]]
477name = "once_cell_polyfill"
478version = "1.70.2"
479source = "registry+https://github.com/rust-lang/crates.io-index"
480checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
481
482[[package]]
483name = "owo-colors"
484version = "4.2.3"
485source = "registry+https://github.com/rust-lang/crates.io-index"
486checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
487
488[[package]]
489name = "oxc-miette"
490version = "2.7.0"
491source = "registry+https://github.com/rust-lang/crates.io-index"
492checksum = "60a7ba54c704edefead1f44e9ef09c43e5cfae666bdc33516b066011f0e6ebf7"
493dependencies = [
494 "cfg-if",
495 "owo-colors",
496 "oxc-miette-derive",
497 "textwrap",
498 "thiserror",
499 "unicode-segmentation",
500 "unicode-width",
501]
502
503[[package]]
504name = "oxc-miette-derive"
505version = "2.7.0"
506source = "registry+https://github.com/rust-lang/crates.io-index"
507checksum = "d4faecb54d0971f948fbc1918df69b26007e6f279a204793669542e1e8b75eb3"
508dependencies = [
509 "proc-macro2",
510 "quote",
511 "syn 2.0.114",
512]
513
514[[package]]
515name = "oxc_allocator"
516version = "0.96.0"
517source = "registry+https://github.com/rust-lang/crates.io-index"
518checksum = "71ef2dba21be1ce515378b2b7143eaa2a912f9e6ffe162ae20639d56f53d60e3"
519dependencies = [
520 "allocator-api2",
521 "bumpalo",
522 "hashbrown",
523 "oxc_data_structures",
524 "rustc-hash",
525]
526
527[[package]]
528name = "oxc_ast"
529version = "0.96.0"
530source = "registry+https://github.com/rust-lang/crates.io-index"
531checksum = "2fad9195311a1961bb6ef1de0ce6a52147bccea50b5a40423b7b44e8448ed4fc"
532dependencies = [
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]]
545name = "oxc_ast_macros"
546version = "0.96.0"
547source = "registry+https://github.com/rust-lang/crates.io-index"
548checksum = "7f03da6fac191c0817a32ae1a7dde27fd27d98732c61fcaeb55a99a4d543ba49"
549dependencies = [
550 "phf",
551 "proc-macro2",
552 "quote",
553 "syn 2.0.114",
554]
555
556[[package]]
557name = "oxc_data_structures"
558version = "0.96.0"
559source = "registry+https://github.com/rust-lang/crates.io-index"
560checksum = "c5f5171d7b8bc907a1b29e557d14f8478509a2154272d56db9ee8aed6bfe8dec"
561
562[[package]]
563name = "oxc_diagnostics"
564version = "0.96.0"
565source = "registry+https://github.com/rust-lang/crates.io-index"
566checksum = "8ef2bf6a713fd27bc65812d695bdfde3f8fcef735f00b861258518346642721b"
567dependencies = [
568 "cow-utils",
569 "oxc-miette",
570 "percent-encoding",
571]
572
573[[package]]
574name = "oxc_ecmascript"
575version = "0.96.0"
576source = "registry+https://github.com/rust-lang/crates.io-index"
577checksum = "f908100cb2759dd2f42ca33d95ea158b8d78e2591b577757729fc9a4a4c63bc3"
578dependencies = [
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]]
589name = "oxc_estree"
590version = "0.96.0"
591source = "registry+https://github.com/rust-lang/crates.io-index"
592checksum = "5644d3399116ff3f0cfb81f9a790c4b8173b504ed52274ecc757b57f30098ad1"
593
594[[package]]
595name = "oxc_index"
596version = "4.1.0"
597source = "registry+https://github.com/rust-lang/crates.io-index"
598checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b"
599dependencies = [
600 "nonmax",
601 "serde",
602]
603
604[[package]]
605name = "oxc_parser"
606version = "0.96.0"
607source = "registry+https://github.com/rust-lang/crates.io-index"
608checksum = "e080498b7a4456a63111f9c65b4dd1b98147955347854b809b6ad4cc5d6a0c0a"
609dependencies = [
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]]
628name = "oxc_regular_expression"
629version = "0.96.0"
630source = "registry+https://github.com/rust-lang/crates.io-index"
631checksum = "cb87ab0b072e1e97d8101cb1678204bc3873d84f13255ae5aa088f1b85f7a8e1"
632dependencies = [
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]]
644name = "oxc_span"
645version = "0.96.0"
646source = "registry+https://github.com/rust-lang/crates.io-index"
647checksum = "41422232cfd9915d31dbb76ba2e5ae212884cad232e37203bdcb15bd1466951d"
648dependencies = [
649 "compact_str",
650 "oxc-miette",
651 "oxc_allocator",
652 "oxc_ast_macros",
653 "oxc_estree",
654]
655
656[[package]]
657name = "oxc_syntax"
658version = "0.96.0"
659source = "registry+https://github.com/rust-lang/crates.io-index"
660checksum = "9ea81736f2343df141c7d8de78a91d155be4f712dfa6cd1bdd9a8b4f0676f01f"
661dependencies = [
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]]
677name = "percent-encoding"
678version = "2.3.2"
679source = "registry+https://github.com/rust-lang/crates.io-index"
680checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
681
682[[package]]
683name = "phf"
684version = "0.13.1"
685source = "registry+https://github.com/rust-lang/crates.io-index"
686checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
687dependencies = [
688 "phf_macros",
689 "phf_shared",
690 "serde",
691]
692
693[[package]]
694name = "phf_generator"
695version = "0.13.1"
696source = "registry+https://github.com/rust-lang/crates.io-index"
697checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
698dependencies = [
699 "fastrand",
700 "phf_shared",
701]
702
703[[package]]
704name = "phf_macros"
705version = "0.13.1"
706source = "registry+https://github.com/rust-lang/crates.io-index"
707checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
708dependencies = [
709 "phf_generator",
710 "phf_shared",
711 "proc-macro2",
712 "quote",
713 "syn 2.0.114",
714]
715
716[[package]]
717name = "phf_shared"
718version = "0.13.1"
719source = "registry+https://github.com/rust-lang/crates.io-index"
720checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
721dependencies = [
722 "siphasher",
723]
724
725[[package]]
726name = "proc-macro2"
727version = "1.0.106"
728source = "registry+https://github.com/rust-lang/crates.io-index"
729checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
730dependencies = [
731 "unicode-ident",
732]
733
734[[package]]
735name = "psm"
736version = "0.1.30"
737source = "registry+https://github.com/rust-lang/crates.io-index"
738checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
739dependencies = [
740 "ar_archive_writer",
741 "cc",
742]
743
744[[package]]
745name = "quote"
746version = "1.0.44"
747source = "registry+https://github.com/rust-lang/crates.io-index"
748checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
749dependencies = [
750 "proc-macro2",
751]
752
753[[package]]
754name = "readonly"
755version = "0.2.13"
756source = "registry+https://github.com/rust-lang/crates.io-index"
757checksum = "f2a62d85ed81ca5305dc544bd42c8804c5060b78ffa5ad3c64b0fb6a8c13d062"
758dependencies = [
759 "proc-macro2",
760 "quote",
761 "syn 2.0.114",
762]
763
764[[package]]
765name = "regex"
766version = "1.12.3"
767source = "registry+https://github.com/rust-lang/crates.io-index"
768checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
769dependencies = [
770 "aho-corasick",
771 "memchr",
772 "regex-automata",
773 "regex-syntax",
774]
775
776[[package]]
777name = "regex-automata"
778version = "0.4.14"
779source = "registry+https://github.com/rust-lang/crates.io-index"
780checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
781dependencies = [
782 "aho-corasick",
783 "memchr",
784 "regex-syntax",
785]
786
787[[package]]
788name = "regex-syntax"
789version = "0.8.9"
790source = "registry+https://github.com/rust-lang/crates.io-index"
791checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
792
793[[package]]
794name = "rustc-hash"
795version = "2.1.1"
796source = "registry+https://github.com/rust-lang/crates.io-index"
797checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
798
799[[package]]
800name = "rustc_version"
801version = "0.4.1"
802source = "registry+https://github.com/rust-lang/crates.io-index"
803checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
804dependencies = [
805 "semver",
806]
807
808[[package]]
809name = "rustversion"
810version = "1.0.22"
811source = "registry+https://github.com/rust-lang/crates.io-index"
812checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
813
814[[package]]
815name = "ryu"
816version = "1.0.23"
817source = "registry+https://github.com/rust-lang/crates.io-index"
818checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
819
820[[package]]
821name = "semver"
822version = "1.0.27"
823source = "registry+https://github.com/rust-lang/crates.io-index"
824checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
825
826[[package]]
827name = "seq-macro"
828version = "0.3.6"
829source = "registry+https://github.com/rust-lang/crates.io-index"
830checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
831
832[[package]]
833name = "serde"
834version = "1.0.228"
835source = "registry+https://github.com/rust-lang/crates.io-index"
836checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
837dependencies = [
838 "serde_core",
839 "serde_derive",
840]
841
842[[package]]
843name = "serde_core"
844version = "1.0.228"
845source = "registry+https://github.com/rust-lang/crates.io-index"
846checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
847dependencies = [
848 "serde_derive",
849]
850
851[[package]]
852name = "serde_derive"
853version = "1.0.228"
854source = "registry+https://github.com/rust-lang/crates.io-index"
855checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
856dependencies = [
857 "proc-macro2",
858 "quote",
859 "syn 2.0.114",
860]
861
862[[package]]
863name = "serde_json"
864version = "1.0.149"
865source = "registry+https://github.com/rust-lang/crates.io-index"
866checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
867dependencies = [
868 "itoa",
869 "memchr",
870 "serde",
871 "serde_core",
872 "zmij",
873]
874
875[[package]]
876name = "serde_yml"
877version = "0.0.12"
878source = "registry+https://github.com/rust-lang/crates.io-index"
879checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
880dependencies = [
881 "indexmap",
882 "itoa",
883 "libyml",
884 "memchr",
885 "ryu",
886 "serde",
887 "version_check",
888]
889
890[[package]]
891name = "shlex"
892version = "1.3.0"
893source = "registry+https://github.com/rust-lang/crates.io-index"
894checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
895
896[[package]]
897name = "siphasher"
898version = "1.0.2"
899source = "registry+https://github.com/rust-lang/crates.io-index"
900checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
901
902[[package]]
903name = "smawk"
904version = "0.3.2"
905source = "registry+https://github.com/rust-lang/crates.io-index"
906checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
907
908[[package]]
909name = "stacker"
910version = "0.1.23"
911source = "registry+https://github.com/rust-lang/crates.io-index"
912checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
913dependencies = [
914 "cc",
915 "cfg-if",
916 "libc",
917 "psm",
918 "windows-sys 0.59.0",
919]
920
921[[package]]
922name = "static_assertions"
923version = "1.1.0"
924source = "registry+https://github.com/rust-lang/crates.io-index"
925checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
926
927[[package]]
928name = "streaming-iterator"
929version = "0.1.9"
930source = "registry+https://github.com/rust-lang/crates.io-index"
931checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
932
933[[package]]
934name = "strsim"
935version = "0.11.1"
936source = "registry+https://github.com/rust-lang/crates.io-index"
937checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
938
939[[package]]
940name = "syn"
941version = "1.0.109"
942source = "registry+https://github.com/rust-lang/crates.io-index"
943checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
944dependencies = [
945 "proc-macro2",
946 "quote",
947 "unicode-ident",
948]
949
950[[package]]
951name = "syn"
952version = "2.0.114"
953source = "registry+https://github.com/rust-lang/crates.io-index"
954checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
955dependencies = [
956 "proc-macro2",
957 "quote",
958 "unicode-ident",
959]
960
961[[package]]
962name = "textwrap"
963version = "0.16.2"
964source = "registry+https://github.com/rust-lang/crates.io-index"
965checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
966dependencies = [
967 "smawk",
968 "unicode-linebreak",
969 "unicode-width",
970]
971
972[[package]]
973name = "thiserror"
974version = "2.0.18"
975source = "registry+https://github.com/rust-lang/crates.io-index"
976checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
977dependencies = [
978 "thiserror-impl",
979]
980
981[[package]]
982name = "thiserror-impl"
983version = "2.0.18"
984source = "registry+https://github.com/rust-lang/crates.io-index"
985checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
986dependencies = [
987 "proc-macro2",
988 "quote",
989 "syn 2.0.114",
990]
991
992[[package]]
993name = "tinyvec"
994version = "1.10.0"
995source = "registry+https://github.com/rust-lang/crates.io-index"
996checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
997dependencies = [
998 "tinyvec_macros",
999]
1000
1001[[package]]
1002name = "tinyvec_macros"
1003version = "0.1.1"
1004source = "registry+https://github.com/rust-lang/crates.io-index"
1005checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1006
1007[[package]]
1008name = "tree-sitter"
1009version = "0.24.7"
1010source = "registry+https://github.com/rust-lang/crates.io-index"
1011checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75"
1012dependencies = [
1013 "cc",
1014 "regex",
1015 "regex-syntax",
1016 "streaming-iterator",
1017 "tree-sitter-language",
1018]
1019
1020[[package]]
1021name = "tree-sitter-language"
1022version = "0.1.7"
1023source = "registry+https://github.com/rust-lang/crates.io-index"
1024checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
1025
1026[[package]]
1027name = "tree-sitter-typescript"
1028version = "0.23.2"
1029source = "registry+https://github.com/rust-lang/crates.io-index"
1030checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
1031dependencies = [
1032 "cc",
1033 "tree-sitter-language",
1034]
1035
1036[[package]]
1037name = "unicode-bidi"
1038version = "0.3.18"
1039source = "registry+https://github.com/rust-lang/crates.io-index"
1040checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
1041
1042[[package]]
1043name = "unicode-general-category"
1044version = "0.6.0"
1045source = "registry+https://github.com/rust-lang/crates.io-index"
1046checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7"
1047
1048[[package]]
1049name = "unicode-id-start"
1050version = "1.4.0"
1051source = "registry+https://github.com/rust-lang/crates.io-index"
1052checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007"
1053
1054[[package]]
1055name = "unicode-ident"
1056version = "1.0.23"
1057source = "registry+https://github.com/rust-lang/crates.io-index"
1058checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
1059
1060[[package]]
1061name = "unicode-linebreak"
1062version = "0.1.5"
1063source = "registry+https://github.com/rust-lang/crates.io-index"
1064checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1065
1066[[package]]
1067name = "unicode-normalization"
1068version = "0.1.25"
1069source = "registry+https://github.com/rust-lang/crates.io-index"
1070checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
1071dependencies = [
1072 "tinyvec",
1073]
1074
1075[[package]]
1076name = "unicode-segmentation"
1077version = "1.12.0"
1078source = "registry+https://github.com/rust-lang/crates.io-index"
1079checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
1080
1081[[package]]
1082name = "unicode-width"
1083version = "0.2.2"
1084source = "registry+https://github.com/rust-lang/crates.io-index"
1085checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1086
1087[[package]]
1088name = "unicode-xid"
1089version = "0.2.6"
1090source = "registry+https://github.com/rust-lang/crates.io-index"
1091checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
1092
1093[[package]]
1094name = "utf8-width"
1095version = "0.1.8"
1096source = "registry+https://github.com/rust-lang/crates.io-index"
1097checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
1098
1099[[package]]
1100name = "utf8parse"
1101version = "0.2.2"
1102source = "registry+https://github.com/rust-lang/crates.io-index"
1103checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1104
1105[[package]]
1106name = "version_check"
1107version = "0.9.5"
1108source = "registry+https://github.com/rust-lang/crates.io-index"
1109checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1110
1111[[package]]
1112name = "windows-link"
1113version = "0.2.1"
1114source = "registry+https://github.com/rust-lang/crates.io-index"
1115checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1116
1117[[package]]
1118name = "windows-sys"
1119version = "0.59.0"
1120source = "registry+https://github.com/rust-lang/crates.io-index"
1121checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1122dependencies = [
1123 "windows-targets",
1124]
1125
1126[[package]]
1127name = "windows-sys"
1128version = "0.61.2"
1129source = "registry+https://github.com/rust-lang/crates.io-index"
1130checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1131dependencies = [
1132 "windows-link",
1133]
1134
1135[[package]]
1136name = "windows-targets"
1137version = "0.52.6"
1138source = "registry+https://github.com/rust-lang/crates.io-index"
1139checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1140dependencies = [
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]]
1152name = "windows_aarch64_gnullvm"
1153version = "0.52.6"
1154source = "registry+https://github.com/rust-lang/crates.io-index"
1155checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1156
1157[[package]]
1158name = "windows_aarch64_msvc"
1159version = "0.52.6"
1160source = "registry+https://github.com/rust-lang/crates.io-index"
1161checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1162
1163[[package]]
1164name = "windows_i686_gnu"
1165version = "0.52.6"
1166source = "registry+https://github.com/rust-lang/crates.io-index"
1167checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1168
1169[[package]]
1170name = "windows_i686_gnullvm"
1171version = "0.52.6"
1172source = "registry+https://github.com/rust-lang/crates.io-index"
1173checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1174
1175[[package]]
1176name = "windows_i686_msvc"
1177version = "0.52.6"
1178source = "registry+https://github.com/rust-lang/crates.io-index"
1179checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1180
1181[[package]]
1182name = "windows_x86_64_gnu"
1183version = "0.52.6"
1184source = "registry+https://github.com/rust-lang/crates.io-index"
1185checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1186
1187[[package]]
1188name = "windows_x86_64_gnullvm"
1189version = "0.52.6"
1190source = "registry+https://github.com/rust-lang/crates.io-index"
1191checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1192
1193[[package]]
1194name = "windows_x86_64_msvc"
1195version = "0.52.6"
1196source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1198
1199[[package]]
1200name = "zmij"
1201version = "1.0.20"
1202source = "registry+https://github.com/rust-lang/crates.io-index"
1203checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
Cargo.toml created+22
...@@ -0,0 +1,22 @@
1[package]
2name = "markodown"
3version = "0.1.0"
4edition = "2024"
5
6[lib]
7crate-type = ["cdylib", "rlib"]
8
9[dependencies]
10serde = { version = "1.0", features = ["derive"] }
11clap = { version = "4.5.57", features = ["derive"] }
12markdown-it = { version = "0.6.1", default-features = false, features = ["linkify"] }
13oxc_allocator = "0.96.0"
14oxc_ast = "0.96.0"
15oxc_diagnostics = "0.96.0"
16oxc_parser = "0.96.0"
17oxc_span = "0.96.0"
18serde_json = "1.0"
19serde_yml = "0.0.12"
20tree-sitter = "0.24"
21tree-sitter-typescript = "0.23"
22
README.md created+159
...@@ -0,0 +1,159 @@
1# Markodown
2
3This 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
5components are really easy to write, it makes this a great tool for writing
6interactive blog posts. Markdown is compiled directly into `.marko` syntax,
7leveraging 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---
21meta:
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
29i'm a catgirl and i love to **exist**. meow meow meow
30
31nyaa!!!!!
32
33```ts
34function yapfest() {
35 return 200;
36}
37```
38
39and then the demo of the above:
40
41<include "./examples/01-first-example.marko" />
42
43## in conclusion
44
45it's sweet
46
47</blog-layout>
48````
49
50## Syntax Reference
51
52### Paragraphs
53
54All text blocks with spaces around them will be wrapped in a paragraph, like
55markdown does.
56
57```
58<div>not wrapped</div>
59<div>
60not wrapped
61</div>
62
63<div>
64
65this paragraph gets wrapped in a `<p>` tag
66
67</div>
68```
69
70Note 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
84All Marko features, such as [tag resolution], [attribute tags], [class
85shorthands], 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
103Line, Block, and HTML comments work like they do in Marko/JavaScript.
104
105```
106# My Blog
107
108Text that is complete.
109
110// ## An unfinished section of the blog
111//
112// TODO: we gotta add this!
113```
114
115### Frontmatter
116
117All frontmatter fields are converted into exports. For example, a framework that
118reads the `meta` export for Open Graph can be easily satisfied with frontmatter.
119
120```
121---
122meta:
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
137Define module-level functions and variables.
138
139```
140static function sort(items: string[]) {
141 while (!isSorted()) {
142 shuffle(items);
143 }
144 return items;
145}
146```
147
148Note 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
158Though you can say import as long as its not the first item.
159```
examples/hoj.mdo created+98
...@@ -0,0 +1,98 @@
1---
2meta:
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
10theme:
11 fg: #fff
12 bg: #442233
13 primary: #ff8b47
14---
15import "./history-of-japan-reanimated.css";
16client import "./history-of-japan-reanimated.client.ts";
17
18<main>
19<nav>[back to home](/)</nav>
20
21# history of japan reanimated
22
23me and 16 other artists (music, narration, and animation) have been spending the
24past couple months on a recreation of bill wurtz's legendary "[history of japan]"
25to celebrate the 10 year anniversary of his release. i contributed two animation
26sections. the canonical release is on [LunaPogi's YouTube page][yt], but i am
27providing 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
40the 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
62the 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
66from the success of this project, a much more ambitous project is being planned:
67the reanimation for 'history of the entire world, i guess'. i am joining the
68project as one of the co-hosts. we are currently doing initial planning, all
69artists (music, visual, narration) interested should fill out the [interest form].
70by submitting the form, i'll be able to reach out when it's actually time to
71start 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
78here is a 55 minute dive into my process, as well as nearly every little trick i
79used on this project. additionally, for one of the first times from me, a free
80download 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---
2meta:
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---
11export { 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
19As I've been using [Next.js] professionally on my employer's web app, I find the
20core design of their App Router and [React Server Components] (RSC) to be
21extremely frustrating. And it's not small bugs or that the API is confusing,
22but large disagreements about the fundamental design decisions that Vercel and
23the React team made when building it.
24
25The more webdev events I go to, the more I see people who dislike Next.js, but
26still get stuck using it. By the end of this article, I will share how me and
27my 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
37The pitch of RSC is that components are put into two categories,
38<b.server>"server"</b> components and <b.client>"client"</b>
39components. Server components don't have `useState`, `useEffect`, but can be
40`async function`s and refer to backend tools like directly calling into a
41database. Client components are the existing
42model, where there is code on the backend to generate HTML text and frontend
43code 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
53This <b.server>Server</b>/<b.client>Client</b> component model
54is interesting. Since built-ins like `<Suspense />` get serialized across the
55network, data fetching can be very trivially modeled with async
56<b.server>server components</b>, and the fallback UI works as if it were
57client-side.
58
59```tsx filename="src/app/[username]/page.tsx" tint="server"
60// For this article, server components will be highlighted in red
61export 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
80async 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
88async function UserPostList({ username }) {
89 const posts = await fetchUserPostList(username);
90 return /* post list ui omitted for brevity */;
91}
92```
93
94If we ignore the 40kB gzipped bundle size of React itself, the above example
95has zero JavaScript for the UI and data fetching &mdash; it just streams the
96markup! For example, the imagined markdown parser within the `<Markdown />`
97component stays on the backend. When an interactive frontend is needed, <b.client>Client
98components</b> can be created by putting them in a file starting with `"use
99client"`.
100
101```tsx filename="src/components/CopyButton.tsx" tint="client"
102"use client"; // This comment marks the file for client-side bundling.
103
104export 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"
116export 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
132After quitting [Bun] as a runtime engineer (I implemented [Server Components
133bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
134front lines: a Next.js app with a Hono backend. The following notes are
135simplifications from the real world problems I've encountered when trying to
136maintain and develop new features. As a result of all of these, everyone's time
137is wasted either working around design flaws, or explaining to each other why
138what 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
146The Next.js documentation for performing mutations [does not mention optimistic
147updates][nextjs-updating-data]; it appears this case was not thought about.
148Components rendered by the <b.server>React Server</b>, by design, can
149not be modified after mounting. Elements that could change need to be inside a
150client component, but data fetching cannot happen on the client components,
151even during SSR on the backend. This results in awkwardly small server
152components that only do data fetching and then have a client component that
153contains a mostly-static version of the page.
154
155```tsx filename="src/app/user/[username]/page.tsx" tint="server"
156
157export 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
168export 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
187As more of the page needs interactivity, it gets messier trying to keep the
188static parts truly server-side. On the work app, nearly every piece of UI
189displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
190updates (for example, a user card's online state along with their basic
191profile). Since these component setups are harder to understand and maintain
192for engineers, almost all of our pages are entirely `"use client"` with a
193`page.tsx` that defines the data fetching.
194
195A more concrete example of what this looks like in practice with the
196data-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.
203export 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"
209export 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";
227export 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
239This example has to be three separate files because of the rules of server
240component bundling. (The client component needs `"use client"`, and server
241component files often can't be imported on the client due to server-only
242imports.). In the Pages router, this could've been a single file because of the
243tree-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
250Since the App Router starts every page as a server component, with (ideally)
251small areas of interactivity, a navigation to a new page *has* to fetch the
252Next.js server, regardless of what data the client already has available! Even
253with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
254going back to `/` will show the loading state while it re-fetches the homepage.
255
256The only case this works is for **perfectly static content**, where instant
257navigations and prefetching work great. But **web apps are not static**, they
258have lots of dynamic content. Being logged in affects the homepage, which is
259infuriating because the client literally has everything needed to display the
260page 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
292The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
293experimental and "not recommended for production". The fact this is a
294non-default afterthought configuration option is embarrassing. Even if we used
295it, you cannot make multiple pages that refer to the same underlying data share
296any of it.
297
298One form of loading state that cannot be represented with the App Router is
299having a page such as a page like a git project's issue page, and clicking on a
300user name to navigate to their profile page. With `loading.tsx`, the entire
301page is a skeleton, but when modeling these queries with TanStack Query it is
302possible to show the username and avatar instantly while the user's bio and
303repositories are fetched in. Server components don't support this form of
304navigation because the data is only available in rendered components, so it
305must 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
310In our Next.js site, we have this line of code on our server component data
311fetchers to make soft navigations faster by skipping the data fetch phase all
312together.
313
314```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
315export 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
328In addition to this, `loading.tsx` should contain the `useQuery` calls so that
329while the network request for the empty RSC happens, the data is being fetched
330if it actually is needed. In fact, the `loading.tsx` state can just be the
331actual client component, and you'll see the client page.
332
333```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
334"use client";
335export 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
350Layouts can perform data fetching, but they can't observe or alter the request
351in any way. This is done so that Next.js can fetch and cache layouts whenever they
352want. In every other framework, layouts are just regular components that have
353no feature difference compared to page components.
354
355Fetching layouts in isolation is a cute idea, but it ends up being silly
356because it also means that any data fetching has to be re-done per layout. You
357can'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
360When a coworker asks me about why Next.js rejects some code, I've given up on
361explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
362I'm going to blow it up soon don't worry."* These rules are too hard for normal
363developers 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
369Unlike the ["Islands Architecture"][islands], Server Components still have to
370be hydrated on the frontend to support `Suspense` and preserving client
371component state. When doing soft navigations, the "RSC Payload" (which is not
372HTML 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
374not contained within that HTML. React's solution is to **send a second copy of
375the entire page's markup**. An example of what a Next.js production server
376would 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
418This solution **doubles the size of the initial HTML payload**. Except it's
419worse, because the RSC payload includes JSON quoted in JS string literals,
420which is a is much less efficient format than HTML. While it seems to compress
421fine with brotli and render fast in the browser, this is wasteful. With the
422hydration pattern, at least the data locally could be re-used for interactivity
423and other pages.
424
425Even on pages that have little to no interactivity, you pay the cost. To use
426the Next.js documentation as an example, loading [its
427homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
428HTML and the 500kB of script tags), and content is in there twice.
429
430You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
431on 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
433blog, 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
435Components.
436
437This RSC format certainly has more waste. But I really don't feel like digging into
438why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
439times. 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
445This 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
451I wouldn't have given this point a section in the blog normally, but I want to
452point out three actual examples directly from the project.
453
454The first is a place where during some refactoring to satisfy the Server/Client
455component models, I accidentally made a Client component `async`. This one was
456quite annoying because it didn't say at all where the issue was, but only
457contained the <b.server>server</b> stack trace.
458
459![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
460
461Another case of a terrible error message:
462
463![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
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
468The final one is the dozen times I place a debugger breakpoint and the
469variable 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"]`
471and other bullshit.
472
473Okay. This all sucks. What can we do?
474
475<h2 id='ditching-nextjs'>Seamlessly Ditching Next.js and Vercel at Work</>
476
477There 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
482And Next.js is the wrong tool for both of these jobs. If you're in the first
483category with a static web site, go for [Astro] or [Fresh]. For everyone who
484needs the full power of React, this section is about how I replaced the vendor
485locked 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
491It started with this Vite config.
492
493```ts filename="vite.config.ts"
494const 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
530Then, I looked for every usage of a Next.js API, and either removed it or made
531a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
532`next/link`:
533
534```tsx filename="src/tanstack-next/link.tsx"
535import { Link } from "@tanstack/react-router";
536import type { LinkProps } from "next/link";
537
538export 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
548Now, the new site can import nearly every client component by either stubbing
549out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
550re-implement logic on a file-by-file basis. And shortly after, I got the site's
551homepage to work in TanStack Start, and we merged the branch.
552
553![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
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
559What was left was porting every other route over. The one thing we lose in
560migrating from Next.js to any other framework is the ability to `await`
561data-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.
563For pages that had multiple fetches, these could be combined into a single,
564special API call that would return all of the relevant data for that page.
565
566To re-iterate in bold font: <strong style='color:var(--secondary)'>The
567migration path from Server Components is to just simplify your code &mdash; RSC
568inherently drives you down a chaotic road of things you do not need</strong>.
569Nearly every complex part of our site got easier to understand for all
570engineers. The exception to this was having everyone get used to the new file
571system routing conventions. With enough examples, we all got the hang of it.
572
573With the incremental migration in place, new code did not break the existing
574deployment. TanStack slowly took over the codebase, and we eventually deleted
575all of the Next.js stubs and gained all of the beautiful [type-safety features]
576that the TanStack Router provides. At the end, the site performed faster from
577every angle: Development Mode, Production page load times, Soft navigations,
578and 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
582We're not the only ones seeing the change. While I try and keep myself off of
583social media, someone sent me [the results of Brian Anglin's work at
584Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
585Start. I also recall ChatGPT switching from Next.js to Remix (random online
586chatter: [[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
595In my opinion, this is one of the only good APIs Next.js has, and was the one
596place in our code where moving to TanStack made things harder to do. Instead of
597worsening the code, I just ported their metadata API into a regular function,
598so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
599year I simplified it's API into one short and understandable
600file. 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],
602or 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
612import * as meta from "@clo/lib/meta.ts";
613
614export 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...
623export 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
648function Page() {
649 ...
650}
651```
652
653My version wasn't concerned with covering the entire space of Next.js's metadata
654object, 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
661No 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
665At the Next.js Conf 2024, everyone there was raving about Server Components. I
666forget exactly who I talked to, but the big people were all in on this. I,
667having implemented the bundler end of RSC, saw a couple of the problems in the
668format. With Next 15 "stabilizing" the App Router last year, many companies are
669building their products on it, realizing these pitfalls first-hand.
670
671I came into the Next.js game late, only starting in June with version 15.
672But everyone I've talked to at events sympathize with my notes. All the people
673I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
674at Vercel told me they don't like how Next.js is to actually use.
675
676I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
677wants.
678
679<h2 id='prefer-respectful-tools'>Prefer Tools that Respect You</>
680
681A lot of in the JavaScript ecosystem is a mess. That mess is why web
682development gets made fun of. There were a lot of times I thought that working
683with the web was an unrecoverable mess, but the mess was actually just the
684commonly-used libraries I surrounded myself with. When that is peeled back,
685modern web development technologies are awesome.
686
687I've been making this website from scratch without any framework since late
6882024, by writing systems like my own [TUI progress widget][progress], [static
689file proxy][file-cache], incremental build system, and many more components.
690Working on this code has produced some of my best coding sessions (by
691happiness) in years. The viewers of *[paper clover]* get a better quality
692website; the mini-libraries I create get [extracted for public use][lib],
693everyone wins.
694
695This level of from-scratch is too much for most people, especially at the
696workplace. I say that at the minimum, we should only give our attention and
697money to high quality tools that respect us. And Next.js and the company behind
698it, Vercel, are not that.
699
700If you use Next.js, and feel that the experience doesn't remind you of respect
701too, consider whether you and your colleagues want to continue supporting their
702[serverless empire]. The Vite ecosystem seems pretty decent to build on right
703now, but I still have little experience in using their tools at scale in
704production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
705only time will tell if these venture-funded tools will respect us (end-users
706and developers) long term.
707
708Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
709purchasing a $800 ticket, I decided to put that money [toward the TanStack
710team][tanstack-donate] for [respecting and improving the web development
711ecosystem][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
726Slowly, I've been replacing many pieces of software that disrespect me with
727better alternatives. Some examples of this are GitHub, Visual Studio Code,
728DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
729write more on this blog about the technical things I do (that progress library,
730the purpose of my own site generator, learnings from my current job), including
731some of my past projects at Bun (details on HMR, the crash reporter, and the
732crazy system for bundling built-in modules). If it interests you, please
733subscribe 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 @@
1pub mod marko;
2pub mod plugin;
3pub mod typescript;
4
5use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
6use plugin::tags::{MarkoClose, MarkoOpen};
7
8pub struct Output {
9 pub text: String,
10 pub format: OutputFormat,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15 Html,
16 Marko,
17}
18
19pub 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.
67fn 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.
87pub 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
97fn 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
125fn 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 @@
1use clap::{Parser, ValueEnum};
2use markodown::{transform, OutputFormat};
3use oxc_diagnostics::GraphicalReportHandler;
4use std::fs;
5use std::path::PathBuf;
6use std::process;
7
8#[derive(Debug, Clone, Copy, ValueEnum)]
9enum CliOutputFormat {
10 Html,
11 Marko,
12}
13
14#[derive(Parser)]
15#[command(
16 name = "markodown",
17 about = "A markdown compiler with custom extensions"
18)]
19struct 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
28fn 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 @@
1use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
2use oxc_span::Span;
3
4use 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)]
10pub struct Open<'a> {
11 pub tag_name: &'a str,
12 pub content: &'a str,
13 pub self_closing: bool,
14}
15
16impl 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)]
35pub struct Close<'a> {
36 pub tag_name: Option<&'a str>,
37 pub length: usize,
38}
39
40impl 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
54pub enum OpenOrClose<'a> {
55 Open(Open<'a>),
56 Close(Close<'a>),
57}
58
59pub 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
68pub struct LexState<'a> {
69 pub src: &'a str,
70 pub offset: usize,
71}
72
73impl<'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
140pub 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
263pub 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
282fn 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
307fn 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
324fn 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
360fn 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)]
388mod 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 @@
1use markdown_it::parser::inline::{InlineRule, InlineState};
2use markdown_it::{Node, NodeValue, Renderer};
3
4/// Custom autolink for relative paths (./path and ../path)
5#[derive(Debug)]
6pub struct RelativeAutolink {
7 pub href: String,
8}
9
10impl 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>
19pub struct Rule;
20
21impl 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 @@
1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;
3
4use crate::plugin::{get_line_raw, RawBlock};
5
6/// Parse line comments (//) and HTML comments (<!--)
7pub(crate) struct Rule;
8
9impl 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
44fn 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 @@
1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;
3use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
4
5use crate::plugin::{get_line_raw, ErrorBlock, RawBlock};
6
7/// Parse frontmatter (---) at document start
8pub(crate) struct Rule;
9
10/// Strip '//' comments from YAML content
11fn 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
19fn 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
37fn 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
68impl 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
7use markdown_it::parser::core::CoreRule;
8use markdown_it::parser::inline::builtin::InlineParserRule;
9use markdown_it::{MarkdownIt, Node};
10
11use super::tags::{MarkoInlineCloseMarker, MarkoInlineOpenMarker, MarkoInlineTag};
12use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
13
14pub fn add(md: &mut MarkdownIt) {
15 md.add_rule::<InlineTagMatcher>()
16 .after::<InlineParserRule>();
17}
18
19struct InlineTagMatcher;
20
21impl CoreRule for InlineTagMatcher {
22 fn run(root: &mut Node, _md: &MarkdownIt) {
23 walk_recursive(root);
24 }
25}
26
27fn 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
37fn 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
96pub 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 @@
1pub mod autolink;
2pub mod comment;
3pub mod frontmatter;
4pub mod inline_tags;
5pub mod statement;
6pub mod tags;
7pub mod template;
8
9use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};
10use oxc_diagnostics::OxcDiagnostic;
11
12/// Register all markodown extensions
13pub 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)]
29pub(crate) struct ErrorBlock {
30 /// Positions are local to where this parsing began, for simplicity.
31 pub errors: Vec<OxcDiagnostic>,
32}
33
34impl 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)]
42pub(crate) struct RawBlock {
43 pub content: String,
44}
45
46impl 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
53pub(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 @@
1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;
3
4use crate::adjust_err;
5use crate::plugin::{ErrorBlock, RawBlock};
6use crate::typescript::find_statement_extent;
7
8/// Parse JavaScript statements (import, export, static X, server X, client X)
9pub struct Rule;
10
11const KEYWORDS: &[&str] = &["import ", "export ", "static ", "server ", "client "];
12const MARKO_PREFIXES: &[&str] = &["static ", "server ", "client "];
13
14/// Check if a line is a valid statement (starts with keyword and parses successfully).
15fn 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
28impl 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 @@
1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState};
3use markdown_it::{Node, NodeValue, Renderer};
4
5use crate::marko::{self, OpenOrClose};
6use crate::plugin::{get_line_raw, ErrorBlock};
7
8/// An opening Marko tag: <div>, <if=cond>, <for|item| of=items>, etc.
9#[derive(Debug)]
10pub 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
17impl 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)]
26pub 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
33impl 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)]
42pub struct MarkoSelfClosing {
43 pub content: String,
44}
45
46impl 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)]
56pub 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
63impl 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)]
78pub struct MarkoInlineOpenMarker {
79 pub content: String,
80 pub tag_name: String,
81 pub tag_name_len: usize,
82}
83
84impl 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)]
93pub struct MarkoInlineCloseMarker {
94 pub content: String,
95 pub tag_name: Option<String>, // None for </>
96 pub tag_name_len: Option<usize>,
97}
98
99impl 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)]
108pub struct MarkoInlineTag {
109 pub open_tag: String,
110 pub close_tag: String,
111 pub tag_name: String,
112}
113
114impl 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)]
125pub 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
132impl 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.
146fn 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
200pub struct Rule;
201
202impl 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
352impl 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 @@
1use markdown_it::parser::inline::{InlineRule, InlineState};
2use markdown_it::Node;
3use oxc_allocator::Allocator;
4use oxc_diagnostics::OxcDiagnostic;
5use oxc_parser::Parser;
6use oxc_span::{GetSpan, SourceType};
7
8use crate::plugin::{ErrorBlock, RawBlock};
9
10/// Inline rule for ${...} TypeScript expressions.
11pub(crate) struct Rule;
12
13impl 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
2use crate::{adjust_err, marko::LexState};
3
4use oxc_allocator::Allocator;
5use oxc_ast::ast::Expression;
6use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
7use oxc_span::{GetSpan, SourceType};
8use std::borrow::Cow;
9use tree_sitter::Parser;
10
11fn 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
16pub 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
53fn 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
120pub 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
130pub 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)`
155pub 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`
186pub 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
226pub 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 }`
247pub 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.
279fn 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)]
297enum LeftmostSearch {
298 As,
299 Function,
300 Assignment,
301}
302
303/// Walk down the left/object side of an expression to find a FunctionExpression.
304fn 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
372fn 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)]
401mod 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.rs
tests/fixtures.rs created+149
...@@ -0,0 +1,149 @@
1use markodown::{transform, OutputFormat};
2use std::fs;
3use 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.
9fn 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]
63fn 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]
72fn fixture_02_imports_and_components() {
73 run_fixture("02-imports-and-components");
74}
75
76#[test]
77fn fixture_03_static_and_expressions() {
78 run_fixture("03-static-and-expressions");
79}
80
81#[test]
82fn fixture_04_control_flow() {
83 run_fixture("04-control-flow");
84}
85
86#[test]
87fn fixture_05_nested_tags_and_loops() {
88 run_fixture("05-nested-tags-and-loops");
89}
90
91#[test]
92fn fixture_06_comments() {
93 run_fixture("06-comments");
94}
95
96#[test]
97fn fixture_07_server_client() {
98 run_fixture("07-server-client");
99}
100
101#[test]
102fn fixture_08_inline_tags() {
103 run_fixture("08-inline-tags");
104}
105
106#[test]
107fn fixture_09_marko_shorthands() {
108 run_fixture("09-marko-shorthands");
109}
110
111#[test]
112fn fixture_10_attribute_tags() {
113 run_fixture("10-attribute-tags");
114}
115
116#[test]
117fn fixture_11_autolinks() {
118 run_fixture("11-autolinks");
119}
120
121#[test]
122fn fixture_12_frontmatter() {
123 run_fixture("12-frontmatter");
124}
125
126#[test]
127fn fixture_13_kitchen_sink() {
128 run_fixture("13-kitchen-sink");
129}
130
131#[test]
132fn fixture_14_evil_braces_and_typescript() {
133 run_fixture("14-evil-braces-and-typescript");
134}
135
136#[test]
137fn fixture_15_multiline_tag() {
138 run_fixture("15-multiline-tag");
139}
140
141#[test]
142fn fixture_16_same_line_text() {
143 run_fixture("16-same-line-text");
144}
145
146#[test]
147fn 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
3This is a **simple** document with *emphasis*.
4
5- one
6- two
7- three
8
9## Code
10
11```rust
12fn main() {}
13```
14
15> A blockquote.
16
17A [link](https://example.com) and an ![image](logo.png).
tests/fixtures/02-imports-and-components.marko created+5
...@@ -0,0 +1,5 @@
1import Chart from "./chart.marko";
2import { 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 @@
1import Chart from "./chart.marko";
2import { sum } from "./math.js";
3
4# Dashboard
5
6Here is the **chart**:
7
8<Chart data=salesData />
tests/fixtures/03-static-and-expressions.marko created+6
...@@ -0,0 +1,6 @@
1static const answer = 41;
2static 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 @@
1static const answer = 41;
2static function getAnswer() {
3 return answer + 1;
4}
5
6# The Answer
7
8The 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 @@
1import 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 @@
1import Chart from "./chart.marko";
2
3# Report
4
5<if=showDetails>
6
7## Details
8
9Here is a detailed **breakdown**:
10
11<for|quarter| of=quarters>
12
13### Q${quarter.num}
14
15Revenue: **${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 @@
1hello world
2
3// this is a comment
4
5<!-- html comment -->
6
7good morning
tests/fixtures/07-server-client.marko created+4
...@@ -0,0 +1,4 @@
1server console.log("on the server")
2client 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 @@
1server console.log("on the server")
2client console.log("in the browser")
3
4# Hello
5
6This 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
3Click <button onClick() { alert("hi") }>here</button> to continue.
4
5This has a <badge count=3>premium</badge> feature and **bold** text.
6
7You 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
8Regular 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 @@
1Visit <https://example.com> for more info.
2
3See also <./other-page.mdo> and <../parent.mdo>.
tests/fixtures/12-frontmatter.marko created+5
...@@ -0,0 +1,5 @@
1export const title = "My Page";
2export 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---
2title: My Page
3date: 2026-02-08
4---
5
6# ${title}
7
8Published on ${date}.
tests/fixtures/13-kitchen-sink.marko created+26
...@@ -0,0 +1,26 @@
1export const title = "Full Example";
2
3import Chart from "./chart.marko";
4static const year = 2026;
5export 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>
9server {
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---
2title: Full Example
3---
4
5import Chart from "./chart.marko";
6
7static const year = 2026;
8
9export const slug = "full-example";
10
11// Page starts here
12
13# ${title}
14
15This is a **complete** example of *markodown* featuring ${year}.
16
17server {
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 @@
1export interface Input {
2 name: string;
3 items: Array<{ id: number; label: string }>;
4}
5static const message = "Hello {world} with {braces}";
6static const template = `
7 This is a template literal {
8 with nested braces
9 } and expressions ${1 + 1}
10`;
11static function evil() {
12 const obj = { a: "{", b: "}" };
13 const regex = /\{.*\}/;
14 return `{${obj.a}${obj.b}}`;
15}
16static {
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 @@
1export interface Input {
2 name: string;
3 items: Array<{ id: number; label: string }>;
4}
5
6static const message = "Hello {world} with {braces}";
7static const template = `
8 This is a template literal {
9 with nested braces
10 } and expressions ${1 + 1}
11`;
12
13static function evil() {
14 const obj = { a: "{", b: "}" };
15 const regex = /\{.*\}/;
16 return `{${obj.a}${obj.b}}`;
17}
18
19static {
20 console.log("Block with {braces} in string");
21 const data = { key: "value {nested}" };
22}
23
24# Evil Braces Test
25
26This tests that string literals with braces don't confuse the parser.
27
28The message is: **${message}**
tests/fixtures/15-multiline-tag.marko created+5
...@@ -0,0 +1,5 @@
1<p>good morning</p>
2<blog-layout
3attribute>
4<p>hello <strong>world</strong></p>
5</blog-layout>
tests/fixtures/15-multiline-tag.mdo created+8
...@@ -0,0 +1,8 @@
1good morning
2
3<blog-layout
4attribute>
5
6hello **world**
7
8</blog-layout>
tests/fixtures/16-same-line-text.marko created+8
...@@ -0,0 +1,8 @@
1<div>
2samelinetext
3<p>hello <strong>world</strong></p>
4</div>
5<div>
6sameline <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
4hello **world**
5
6</div>
7
8<div> sameline **text**
9
10hello **world**
11
12</div>
tests/fixtures/17-consecutive-statements.marko created+2
...@@ -0,0 +1,2 @@
1static const y = "**hello**";
2export const z = y + " **world**";
tests/fixtures/17-consecutive-statements.mdo created+2
...@@ -0,0 +1,2 @@
1static const y = "**hello**";
2export const z = y + " **world**";