authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-13 11:15:08-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-13 11:22:01-08:00
log2a3ab659fdddfe407940a926347ff4e79ec41e82
tree6b37d0bcf4ec75e1749c66919da2ac202081cced
parenta4577db2eb8de0f1edf734aa8d476a349b7e8182
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: allow imports before frontmatter


12 files changed, 269 insertions(+), 26 deletions(-)

examples/in-the-summer.mdo created+43
......@@ -0,0 +1,43 @@
1import "./in-the-summer.css";
2---
3meta:
4 title: in the summer
5theme:
6 bg: #7DCC8C
7 fg: #000
8 primary: #9b3706
9---
10
11<main style={ 'max-width': '110ch' }>
12<nav>[back to home](/)</nav>
13<div.contain>
14<header>
15<h1>in the summer</h1>
16<time style="margin-left:.5rem;opacity:.5">(2025-08-01)</time>
17</header>
18
19this is a song about the summer, and related feelings. written in 2022,
20produced in 2025. i had a lot of fun playing with a much larger instrument
21selection, and new 3d workflows. enjoy!
22
23</div>
24<clover-video
25 file="/2025/in the summer/in the summer.mp4"
26 poster="/2025/in the summer/thumbnail.jpeg"
27>
28 <@header>**music video**: in the summer</>
29</>
30<div.contain>
31
32## downloads
33
34- [in the summer.mp3](/file/2025/in%20the%20summer/in%20the%20summer.mp4?view=dl) (AV1, requires modern player)
35- [in the summer.mp4](/file/2025/in%20the%20summer/in%20the%20summer.mp3?view=dl) (Music)
36- [fragments](/file/2025/in%20the%20summer/fragments) (behind the scenes)
37
38## mentions on the q&a
39
40<questions-embed="in-the-summer" />
41
42</div>
43</main>
src/lib.rs+53-20
......@@ -19,28 +19,40 @@ pub enum OutputFormat {
1919}
2020
2121pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Vec<OxcDiagnostic>> {
22 // Pre-process to extract preamble (blank lines, imports) and frontmatter
23 // This is needed because markdown-it skips blank lines before running block rules
24 let (preamble_output, remaining_source, preamble_offset) =
25 match plugin::extract_preamble_and_frontmatter(source) {
26 Ok(Some(result)) => {
27 let offset = result.bytes_consumed;
28 (Some(result.output), &source[offset..], offset)
29 }
30 Ok(None) => (None, source, 0),
31 Err(e) => return Err(vec![e]),
32 };
33
2234 let md = &mut markdown_it::MarkdownIt::new();
2335
2436 plugin::add_all(md);
2537 markdown_it::plugins::cmark::add(md);
2638 markdown_it::plugins::extra::add(md);
2739
28 let mut ast = md.parse(source);
40 let mut ast = md.parse(remaining_source);
2941
3042 let mut errors = Vec::new();
31 collect_errors(&mut ast, &mut errors);
43 collect_errors(&mut ast, &mut errors, preamble_offset);
3244 if !errors.is_empty() {
3345 return Err(errors);
3446 }
3547
3648 // Validate Marko tag open/close matching
37 validate_marko_tags(&ast, &mut errors);
49 validate_marko_tags(&ast, &mut errors, preamble_offset);
3850 if !errors.is_empty() {
3951 return Err(errors);
4052 }
4153
4254 // Check for unmatched inline tag markers
43 plugin::inline_tags::check_unmatched_markers(&ast, &mut errors);
55 plugin::inline_tags::check_unmatched_markers(&ast, &mut errors, preamble_offset);
4456 if !errors.is_empty() {
4557 return Err(errors);
4658 }
......@@ -59,29 +71,41 @@ pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Ve
5971 )]);
6072 }
6173
62 Ok(Output {
63 text: ast.render(),
64 format,
65 })
74 let mut text = ast.render();
75
76 // Prepend preamble output if we extracted it
77 if let Some(preamble) = preamble_output {
78 text = preamble + &text;
79 }
80
81 Ok(Output { text, format })
6682}
6783
6884/// Extract all `ErrorBlock`s from an AST, offsetting the error positions.
69fn collect_errors(node: &mut markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) {
85fn collect_errors(
86 node: &mut markdown_it::Node,
87 errors: &mut Vec<OxcDiagnostic>,
88 preamble_offset: usize,
89) {
7090 let (start, end) = node.srcmap.unwrap().get_byte_offsets();
7191 if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() {
7292 errors.extend(error_block.errors.drain(0..).map(|mut err| {
7393 if let Some(labels) = err.labels.as_mut() {
7494 for label in labels {
75 label.set_span_offset(label.offset() + start + 2);
95 label.set_span_offset(label.offset() + start + preamble_offset + 2);
7696 }
7797 } else {
78 err.labels = Some(vec![LabeledSpan::new(None, start, end - start)])
98 err.labels = Some(vec![LabeledSpan::new(
99 None,
100 start + preamble_offset,
101 end - start,
102 )])
79103 }
80104 err
81105 }));
82106 }
83107 for child in &mut node.children {
84 collect_errors(child, errors);
108 collect_errors(child, errors, preamble_offset);
85109 }
86110}
87111
......@@ -124,7 +148,11 @@ fn has_marko_features(node: &markdown_it::Node) -> bool {
124148}
125149
126150/// Validate that Marko open/close tags are properly matched
127fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) {
151fn validate_marko_tags(
152 node: &markdown_it::Node,
153 errors: &mut Vec<OxcDiagnostic>,
154 preamble_offset: usize,
155) {
128156 #[derive(Debug)]
129157 struct OpenTag {
130158 name: String,
......@@ -136,25 +164,30 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
136164
137165 let mut stack: Vec<OpenTag> = vec![];
138166
139 fn walk(node: &markdown_it::Node, stack: &mut Vec<OpenTag>, errors: &mut Vec<OxcDiagnostic>) {
167 fn walk(
168 node: &markdown_it::Node,
169 stack: &mut Vec<OpenTag>,
170 errors: &mut Vec<OxcDiagnostic>,
171 offset: usize,
172 ) {
140173 if let Some(open) = node.cast::<MarkoOpen>() {
141174 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
142175 stack.push(OpenTag {
143176 name: open.tag_name.clone(),
144 name_start: start + 1, // +1 to skip '<'
177 name_start: start + offset + 1, // +1 to skip '<'
145178 name_len: open.tag_name_len,
146179 });
147180 } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() {
148181 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
149182 stack.push(OpenTag {
150183 name: open.tag_name.clone(),
151 name_start: start + 1, // +1 to skip '<'
184 name_start: start + offset + 1, // +1 to skip '<'
152185 name_len: open.tag_name_len,
153186 });
154187 } else if let Some(close) = node.cast::<MarkoClose>() {
155188 let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();
156189 // Close tag name starts after '</' (offset +2)
157 let close_name_start = close_start + 2;
190 let close_name_start = close_start + offset + 2;
158191 let close_name_len = close.tag_name_len.unwrap_or(0);
159192
160193 match (close.tag_name.as_ref(), stack.pop()) {
......@@ -202,7 +235,7 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
202235 // </> without any open tag - highlight the whole </>
203236 errors.push(
204237 OxcDiagnostic::error("Closing tag </> without matching open")
205 .with_label(LabeledSpan::new(None, close_start, 3)),
238 .with_label(LabeledSpan::new(None, close_start + offset, 3)),
206239 );
207240 }
208241 }
......@@ -210,11 +243,11 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
210243
211244 // Recurse into children
212245 for child in &node.children {
213 walk(child, stack, errors);
246 walk(child, stack, errors, offset);
214247 }
215248 }
216249
217 walk(node, &mut stack, errors);
250 walk(node, &mut stack, errors, preamble_offset);
218251
219252 // Check for unclosed tags
220253 for unclosed in stack {
src/plugin/frontmatter.rs+99-1
......@@ -65,9 +65,107 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
6565 Ok(exports.join("\n"))
6666}
6767
68/// Result of extracting preamble (imports/blank lines) and frontmatter from source
69pub struct PreambleResult {
70 /// The generated output (imports + exports from frontmatter)
71 pub output: String,
72 /// Number of bytes consumed from the source
73 pub bytes_consumed: usize,
74}
75
76/// Extract leading blank lines, import statements, and frontmatter from source.
77/// Returns None if no frontmatter is found after the preamble.
78pub fn extract_preamble_and_frontmatter(
79 source: &str,
80) -> Result<Option<PreambleResult>, OxcDiagnostic> {
81 let lines: Vec<&str> = source.lines().collect();
82 let mut preamble_lines = Vec::new();
83 let mut fm_start = 0;
84
85 // Collect blank lines and import statements
86 while fm_start < lines.len() {
87 let line = lines[fm_start];
88 let trimmed = line.trim();
89 if trimmed.is_empty() || line.starts_with("import ") {
90 preamble_lines.push(line);
91 fm_start += 1;
92 } else {
93 break;
94 }
95 }
96
97 // Check for opening delimiter
98 if fm_start >= lines.len() || lines[fm_start].trim() != "---" {
99 return Ok(None);
100 }
101
102 // Collect frontmatter lines until closing delimiter
103 let mut end_line = fm_start + 1;
104 let mut yaml_lines = Vec::new();
105
106 while end_line < lines.len() {
107 let line = lines[end_line];
108 if line.trim() == "---" {
109 end_line += 1;
110 break;
111 }
112 yaml_lines.push(line);
113 end_line += 1;
114 }
115
116 // Parse YAML and convert to exports
117 let yaml = strip_js_comments(&yaml_lines.join("\n"));
118 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
119 Ok(value) => yaml_to_exports(&value).map_err(OxcDiagnostic::error)?,
120 Err(e) => {
121 let offset = e
122 .location()
123 .map(|loc| {
124 yaml.lines()
125 .take(loc.line())
126 .map(|l| l.len() + 1)
127 .sum::<usize>()
128 + loc.column()
129 })
130 .unwrap_or(0);
131
132 return Err(OxcDiagnostic::error(format!("YAML syntax error: {e}"))
133 .and_label(LabeledSpan::new(None, offset, 1)));
134 }
135 };
136
137 // Build output: preamble + exports
138 let mut output = String::new();
139 for line in &preamble_lines {
140 output.push_str(line);
141 output.push('\n');
142 }
143 output.push_str(&exports);
144 if !exports.is_empty() {
145 output.push('\n');
146 }
147
148 // Calculate bytes consumed (including the trailing newline after closing ---)
149 let mut bytes_consumed = 0;
150 for i in 0..end_line {
151 bytes_consumed += lines[i].len() + 1; // +1 for newline
152 }
153
154 // Consume one trailing blank line for separation if present
155 if end_line < lines.len() && lines[end_line].trim().is_empty() {
156 output.push('\n');
157 bytes_consumed += lines[end_line].len() + 1;
158 }
159
160 Ok(Some(PreambleResult {
161 output,
162 bytes_consumed,
163 }))
164}
165
68166impl BlockRule for Rule {
69167 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
70 // Only at the start of the document
168 // Only at the start of the document (no preamble case - handled by preprocessing)
71169 if state.line != 0 || state.line >= state.line_max {
72170 return None;
73171 }
src/plugin/inline_tags.rs+9-5
......@@ -93,14 +93,18 @@ fn match_markers(node: &mut Node) {
9393}
9494
9595/// Check for unmatched markers and convert them to errors
96pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) {
96pub fn check_unmatched_markers(
97 node: &markdown_it::Node,
98 errors: &mut Vec<OxcDiagnostic>,
99 preamble_offset: usize,
100) {
97101 if let Some(open) = node.cast::<MarkoInlineOpenMarker>() {
98102 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
99103 errors.push(
100104 OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label(
101105 LabeledSpan::new(
102106 None,
103 start + 1, // +1 to skip '<'
107 start + preamble_offset + 1, // +1 to skip '<'
104108 open.tag_name_len,
105109 ),
106110 ),
......@@ -114,20 +118,20 @@ pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDia
114118 ))
115119 .with_label(LabeledSpan::new(
116120 None,
117 start + 2, // +2 to skip '</'
121 start + preamble_offset + 2, // +2 to skip '</'
118122 close.tag_name_len.unwrap_or(0),
119123 )),
120124 );
121125 } else {
122126 errors.push(
123127 OxcDiagnostic::error("Closing inline tag </> without matching open")
124 .with_label(LabeledSpan::new(None, start, 3)),
128 .with_label(LabeledSpan::new(None, start + preamble_offset, 3)),
125129 );
126130 }
127131 }
128132
129133 // Recurse into children
130134 for child in &node.children {
131 check_unmatched_markers(child, errors);
135 check_unmatched_markers(child, errors, preamble_offset);
132136 }
133137}
src/plugin/mod.rs+2
......@@ -6,6 +6,8 @@ pub mod statement;
66pub mod tags;
77pub mod template;
88
9pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
10
911use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};
1012use oxc_diagnostics::OxcDiagnostic;
1113
tests/fixtures.rs+15
......@@ -157,3 +157,18 @@ fn fixture_18_multiline_tag_with_value() {
157157fn fixture_19_extra_cases() {
158158 run_fixture("19-extra-cases");
159159}
160
161#[test]
162fn fixture_20_frontmatter_preamble() {
163 run_fixture("20-frontmatter-preamble");
164}
165
166#[test]
167fn fixture_21_frontmatter_blank_lines() {
168 run_fixture("21-frontmatter-blank-lines");
169}
170
171#[test]
172fn fixture_22_frontmatter_mixed_preamble() {
173 run_fixture("22-frontmatter-mixed-preamble");
174}
tests/fixtures/20-frontmatter-preamble.marko created+6
......@@ -0,0 +1,6 @@
1import Button from "./Button.marko"
2
3export const title = "Hello";
4
5<h1>${title}</h1>
6<Button>Click</Button>
tests/fixtures/20-frontmatter-preamble.mdo created+9
......@@ -0,0 +1,9 @@
1import Button from "./Button.marko"
2
3---
4title: Hello
5---
6
7# ${title}
8
9<Button>Click</Button>
tests/fixtures/21-frontmatter-blank-lines.marko created+4
......@@ -0,0 +1,4 @@
1
2export const count = 42;
3
4<p>The count is ${count}.</p>
tests/fixtures/21-frontmatter-blank-lines.mdo created+7
......@@ -0,0 +1,7 @@
1
2
3---
4count: 42
5---
6
7The count is ${count}.
tests/fixtures/22-frontmatter-mixed-preamble.marko created+10
......@@ -0,0 +1,10 @@
1
2import Foo from "./Foo.marko"
3
4import Bar from "./Bar.marko"
5import { helper } from "./utils"
6
7export const active = true;
8
9<Foo enabled=active/>
10<Bar/>
tests/fixtures/22-frontmatter-mixed-preamble.mdo created+12
......@@ -0,0 +1,12 @@
1
2import Foo from "./Foo.marko"
3
4import Bar from "./Bar.marko"
5import { helper } from "./utils"
6
7---
8active: true
9---
10
11<Foo enabled=active/>
12<Bar/>