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 {...@@ -19,28 +19,40 @@ pub enum OutputFormat {
19}19}
2020
21pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Vec<OxcDiagnostic>> {21pub 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
22 let md = &mut markdown_it::MarkdownIt::new();34 let md = &mut markdown_it::MarkdownIt::new();
2335
24 plugin::add_all(md);36 plugin::add_all(md);
25 markdown_it::plugins::cmark::add(md);37 markdown_it::plugins::cmark::add(md);
26 markdown_it::plugins::extra::add(md);38 markdown_it::plugins::extra::add(md);
2739
28 let mut ast = md.parse(source);40 let mut ast = md.parse(remaining_source);
2941
30 let mut errors = Vec::new();42 let mut errors = Vec::new();
31 collect_errors(&mut ast, &mut errors);43 collect_errors(&mut ast, &mut errors, preamble_offset);
32 if !errors.is_empty() {44 if !errors.is_empty() {
33 return Err(errors);45 return Err(errors);
34 }46 }
3547
36 // Validate Marko tag open/close matching48 // Validate Marko tag open/close matching
37 validate_marko_tags(&ast, &mut errors);49 validate_marko_tags(&ast, &mut errors, preamble_offset);
38 if !errors.is_empty() {50 if !errors.is_empty() {
39 return Err(errors);51 return Err(errors);
40 }52 }
4153
42 // Check for unmatched inline tag markers54 // 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);
44 if !errors.is_empty() {56 if !errors.is_empty() {
45 return Err(errors);57 return Err(errors);
46 }58 }
...@@ -59,29 +71,41 @@ pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Ve...@@ -59,29 +71,41 @@ pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Ve
59 )]);71 )]);
60 }72 }
6173
62 Ok(Output {74 let mut text = ast.render();
63 text: ast.render(),75
64 format,76 // Prepend preamble output if we extracted it
65 })77 if let Some(preamble) = preamble_output {
78 text = preamble + &text;
79 }
80
81 Ok(Output { text, format })
66}82}
6783
68/// Extract all `ErrorBlock`s from an AST, offsetting the error positions.84/// 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) {
70 let (start, end) = node.srcmap.unwrap().get_byte_offsets();90 let (start, end) = node.srcmap.unwrap().get_byte_offsets();
71 if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() {91 if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() {
72 errors.extend(error_block.errors.drain(0..).map(|mut err| {92 errors.extend(error_block.errors.drain(0..).map(|mut err| {
73 if let Some(labels) = err.labels.as_mut() {93 if let Some(labels) = err.labels.as_mut() {
74 for label in labels {94 for label in labels {
75 label.set_span_offset(label.offset() + start + 2);95 label.set_span_offset(label.offset() + start + preamble_offset + 2);
76 }96 }
77 } else {97 } 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 )])
79 }103 }
80 err104 err
81 }));105 }));
82 }106 }
83 for child in &mut node.children {107 for child in &mut node.children {
84 collect_errors(child, errors);108 collect_errors(child, errors, preamble_offset);
85 }109 }
86}110}
87111
...@@ -124,7 +148,11 @@ fn has_marko_features(node: &markdown_it::Node) -> bool {...@@ -124,7 +148,11 @@ fn has_marko_features(node: &markdown_it::Node) -> bool {
124}148}
125149
126/// Validate that Marko open/close tags are properly matched150/// 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) {
128 #[derive(Debug)]156 #[derive(Debug)]
129 struct OpenTag {157 struct OpenTag {
130 name: String,158 name: String,
...@@ -136,25 +164,30 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>...@@ -136,25 +164,30 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
136164
137 let mut stack: Vec<OpenTag> = vec![];165 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 ) {
140 if let Some(open) = node.cast::<MarkoOpen>() {173 if let Some(open) = node.cast::<MarkoOpen>() {
141 let (start, _) = node.srcmap.unwrap().get_byte_offsets();174 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
142 stack.push(OpenTag {175 stack.push(OpenTag {
143 name: open.tag_name.clone(),176 name: open.tag_name.clone(),
144 name_start: start + 1, // +1 to skip '<'177 name_start: start + offset + 1, // +1 to skip '<'
145 name_len: open.tag_name_len,178 name_len: open.tag_name_len,
146 });179 });
147 } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() {180 } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() {
148 let (start, _) = node.srcmap.unwrap().get_byte_offsets();181 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
149 stack.push(OpenTag {182 stack.push(OpenTag {
150 name: open.tag_name.clone(),183 name: open.tag_name.clone(),
151 name_start: start + 1, // +1 to skip '<'184 name_start: start + offset + 1, // +1 to skip '<'
152 name_len: open.tag_name_len,185 name_len: open.tag_name_len,
153 });186 });
154 } else if let Some(close) = node.cast::<MarkoClose>() {187 } else if let Some(close) = node.cast::<MarkoClose>() {
155 let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();188 let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();
156 // Close tag name starts after '</' (offset +2)189 // Close tag name starts after '</' (offset +2)
157 let close_name_start = close_start + 2;190 let close_name_start = close_start + offset + 2;
158 let close_name_len = close.tag_name_len.unwrap_or(0);191 let close_name_len = close.tag_name_len.unwrap_or(0);
159192
160 match (close.tag_name.as_ref(), stack.pop()) {193 match (close.tag_name.as_ref(), stack.pop()) {
...@@ -202,7 +235,7 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>...@@ -202,7 +235,7 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
202 // </> without any open tag - highlight the whole </>235 // </> without any open tag - highlight the whole </>
203 errors.push(236 errors.push(
204 OxcDiagnostic::error("Closing tag </> without matching open")237 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)),
206 );239 );
207 }240 }
208 }241 }
...@@ -210,11 +243,11 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>...@@ -210,11 +243,11 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>
210243
211 // Recurse into children244 // Recurse into children
212 for child in &node.children {245 for child in &node.children {
213 walk(child, stack, errors);246 walk(child, stack, errors, offset);
214 }247 }
215 }248 }
216249
217 walk(node, &mut stack, errors);250 walk(node, &mut stack, errors, preamble_offset);
218251
219 // Check for unclosed tags252 // Check for unclosed tags
220 for unclosed in stack {253 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> {...@@ -65,9 +65,107 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
65 Ok(exports.join("\n"))65 Ok(exports.join("\n"))
66}66}
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
68impl BlockRule for Rule {166impl BlockRule for Rule {
69 fn run(state: &mut BlockState) -> Option<(Node, usize)> {167 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
70 // Only at the start of the document168 // Only at the start of the document (no preamble case - handled by preprocessing)
71 if state.line != 0 || state.line >= state.line_max {169 if state.line != 0 || state.line >= state.line_max {
72 return None;170 return None;
73 }171 }
src/plugin/inline_tags.rs+9-5
...@@ -93,14 +93,18 @@ fn match_markers(node: &mut Node) {...@@ -93,14 +93,18 @@ fn match_markers(node: &mut Node) {
93}93}
9494
95/// Check for unmatched markers and convert them to errors95/// 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) {
97 if let Some(open) = node.cast::<MarkoInlineOpenMarker>() {101 if let Some(open) = node.cast::<MarkoInlineOpenMarker>() {
98 let (start, _) = node.srcmap.unwrap().get_byte_offsets();102 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
99 errors.push(103 errors.push(
100 OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label(104 OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label(
101 LabeledSpan::new(105 LabeledSpan::new(
102 None,106 None,
103 start + 1, // +1 to skip '<'107 start + preamble_offset + 1, // +1 to skip '<'
104 open.tag_name_len,108 open.tag_name_len,
105 ),109 ),
106 ),110 ),
...@@ -114,20 +118,20 @@ pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDia...@@ -114,20 +118,20 @@ pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDia
114 ))118 ))
115 .with_label(LabeledSpan::new(119 .with_label(LabeledSpan::new(
116 None,120 None,
117 start + 2, // +2 to skip '</'121 start + preamble_offset + 2, // +2 to skip '</'
118 close.tag_name_len.unwrap_or(0),122 close.tag_name_len.unwrap_or(0),
119 )),123 )),
120 );124 );
121 } else {125 } else {
122 errors.push(126 errors.push(
123 OxcDiagnostic::error("Closing inline tag </> without matching open")127 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)),
125 );129 );
126 }130 }
127 }131 }
128132
129 // Recurse into children133 // Recurse into children
130 for child in &node.children {134 for child in &node.children {
131 check_unmatched_markers(child, errors);135 check_unmatched_markers(child, errors, preamble_offset);
132 }136 }
133}137}
src/plugin/mod.rs+2
...@@ -6,6 +6,8 @@ pub mod statement;...@@ -6,6 +6,8 @@ pub mod statement;
6pub mod tags;6pub mod tags;
7pub mod template;7pub mod template;
88
9pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
10
9use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};11use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};
10use oxc_diagnostics::OxcDiagnostic;12use oxc_diagnostics::OxcDiagnostic;
1113
tests/fixtures.rs+15
...@@ -157,3 +157,18 @@ fn fixture_18_multiline_tag_with_value() {...@@ -157,3 +157,18 @@ fn fixture_18_multiline_tag_with_value() {
157fn fixture_19_extra_cases() {157fn fixture_19_extra_cases() {
158 run_fixture("19-extra-cases");158 run_fixture("19-extra-cases");
159}159}
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/>