diff --git a/examples/in-the-summer.mdo b/examples/in-the-summer.mdo
new file mode 100644
index 0000000000000000000000000000000000000000..86aa97f69bf49445588fe5b019fdd3dcc8ae2288
--- /dev/null
+++ b/examples/in-the-summer.mdo
@@ -0,0 +1,43 @@
+import "./in-the-summer.css";
+---
+meta:
+ title: in the summer
+theme:
+ bg: #7DCC8C
+ fg: #000
+ primary: #9b3706
+---
+
+
+[back to home](/)
+
+
+in the summer
+(2025-08-01)
+
+
+this is a song about the summer, and related feelings. written in 2022,
+produced in 2025. i had a lot of fun playing with a much larger instrument
+selection, and new 3d workflows. enjoy!
+
+
+
+ <@header>**music video**: in the summer>
+>
+
+
+## downloads
+
+- [in the summer.mp3](/file/2025/in%20the%20summer/in%20the%20summer.mp4?view=dl) (AV1, requires modern player)
+- [in the summer.mp4](/file/2025/in%20the%20summer/in%20the%20summer.mp3?view=dl) (Music)
+- [fragments](/file/2025/in%20the%20summer/fragments) (behind the scenes)
+
+## mentions on the q&a
+
+
+
+
+
diff --git a/src/lib.rs b/src/lib.rs
index 4630e702fffdff2fc963ab208bc37eb94a1ad3b2..7180a818389288b49ced80be488b5ba75464666f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -19,28 +19,40 @@ pub enum OutputFormat {
}
pub fn transform(source: &str, force: Option) -> Result> {
+ // Pre-process to extract preamble (blank lines, imports) and frontmatter
+ // This is needed because markdown-it skips blank lines before running block rules
+ let (preamble_output, remaining_source, preamble_offset) =
+ match plugin::extract_preamble_and_frontmatter(source) {
+ Ok(Some(result)) => {
+ let offset = result.bytes_consumed;
+ (Some(result.output), &source[offset..], offset)
+ }
+ Ok(None) => (None, source, 0),
+ Err(e) => return Err(vec![e]),
+ };
+
let md = &mut markdown_it::MarkdownIt::new();
plugin::add_all(md);
markdown_it::plugins::cmark::add(md);
markdown_it::plugins::extra::add(md);
- let mut ast = md.parse(source);
+ let mut ast = md.parse(remaining_source);
let mut errors = Vec::new();
- collect_errors(&mut ast, &mut errors);
+ collect_errors(&mut ast, &mut errors, preamble_offset);
if !errors.is_empty() {
return Err(errors);
}
// Validate Marko tag open/close matching
- validate_marko_tags(&ast, &mut errors);
+ validate_marko_tags(&ast, &mut errors, preamble_offset);
if !errors.is_empty() {
return Err(errors);
}
// Check for unmatched inline tag markers
- plugin::inline_tags::check_unmatched_markers(&ast, &mut errors);
+ plugin::inline_tags::check_unmatched_markers(&ast, &mut errors, preamble_offset);
if !errors.is_empty() {
return Err(errors);
}
@@ -59,29 +71,41 @@ pub fn transform(source: &str, force: Option) -> Result) {
+fn collect_errors(
+ node: &mut markdown_it::Node,
+ errors: &mut Vec,
+ preamble_offset: usize,
+) {
let (start, end) = node.srcmap.unwrap().get_byte_offsets();
if let Some(error_block) = node.cast_mut::() {
errors.extend(error_block.errors.drain(0..).map(|mut err| {
if let Some(labels) = err.labels.as_mut() {
for label in labels {
- label.set_span_offset(label.offset() + start + 2);
+ label.set_span_offset(label.offset() + start + preamble_offset + 2);
}
} else {
- err.labels = Some(vec![LabeledSpan::new(None, start, end - start)])
+ err.labels = Some(vec![LabeledSpan::new(
+ None,
+ start + preamble_offset,
+ end - start,
+ )])
}
err
}));
}
for child in &mut node.children {
- collect_errors(child, errors);
+ collect_errors(child, errors, preamble_offset);
}
}
@@ -124,7 +148,11 @@ fn has_marko_features(node: &markdown_it::Node) -> bool {
}
/// Validate that Marko open/close tags are properly matched
-fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec) {
+fn validate_marko_tags(
+ node: &markdown_it::Node,
+ errors: &mut Vec,
+ preamble_offset: usize,
+) {
#[derive(Debug)]
struct OpenTag {
name: String,
@@ -136,25 +164,30 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec
let mut stack: Vec = vec![];
- fn walk(node: &markdown_it::Node, stack: &mut Vec, errors: &mut Vec) {
+ fn walk(
+ node: &markdown_it::Node,
+ stack: &mut Vec,
+ errors: &mut Vec,
+ offset: usize,
+ ) {
if let Some(open) = node.cast::() {
let (start, _) = node.srcmap.unwrap().get_byte_offsets();
stack.push(OpenTag {
name: open.tag_name.clone(),
- name_start: start + 1, // +1 to skip '<'
+ name_start: start + offset + 1, // +1 to skip '<'
name_len: open.tag_name_len,
});
} else if let Some(open) = node.cast::() {
let (start, _) = node.srcmap.unwrap().get_byte_offsets();
stack.push(OpenTag {
name: open.tag_name.clone(),
- name_start: start + 1, // +1 to skip '<'
+ name_start: start + offset + 1, // +1 to skip '<'
name_len: open.tag_name_len,
});
} else if let Some(close) = node.cast::() {
let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();
// Close tag name starts after '' (offset +2)
- let close_name_start = close_start + 2;
+ let close_name_start = close_start + offset + 2;
let close_name_len = close.tag_name_len.unwrap_or(0);
match (close.tag_name.as_ref(), stack.pop()) {
@@ -202,7 +235,7 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec
// > without any open tag - highlight the whole >
errors.push(
OxcDiagnostic::error("Closing tag > without matching open")
- .with_label(LabeledSpan::new(None, close_start, 3)),
+ .with_label(LabeledSpan::new(None, close_start + offset, 3)),
);
}
}
@@ -210,11 +243,11 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec
// Recurse into children
for child in &node.children {
- walk(child, stack, errors);
+ walk(child, stack, errors, offset);
}
}
- walk(node, &mut stack, errors);
+ walk(node, &mut stack, errors, preamble_offset);
// Check for unclosed tags
for unclosed in stack {
diff --git a/src/plugin/frontmatter.rs b/src/plugin/frontmatter.rs
index 730c94c8d703f2ae3afce969d555cb23aaf702b4..72979a0766a565c4585225cceeff60c739796d1e 100644
--- a/src/plugin/frontmatter.rs
+++ b/src/plugin/frontmatter.rs
@@ -65,9 +65,107 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result {
Ok(exports.join("\n"))
}
+/// Result of extracting preamble (imports/blank lines) and frontmatter from source
+pub struct PreambleResult {
+ /// The generated output (imports + exports from frontmatter)
+ pub output: String,
+ /// Number of bytes consumed from the source
+ pub bytes_consumed: usize,
+}
+
+/// Extract leading blank lines, import statements, and frontmatter from source.
+/// Returns None if no frontmatter is found after the preamble.
+pub fn extract_preamble_and_frontmatter(
+ source: &str,
+) -> Result, OxcDiagnostic> {
+ let lines: Vec<&str> = source.lines().collect();
+ let mut preamble_lines = Vec::new();
+ let mut fm_start = 0;
+
+ // Collect blank lines and import statements
+ while fm_start < lines.len() {
+ let line = lines[fm_start];
+ let trimmed = line.trim();
+ if trimmed.is_empty() || line.starts_with("import ") {
+ preamble_lines.push(line);
+ fm_start += 1;
+ } else {
+ break;
+ }
+ }
+
+ // Check for opening delimiter
+ if fm_start >= lines.len() || lines[fm_start].trim() != "---" {
+ return Ok(None);
+ }
+
+ // Collect frontmatter lines until closing delimiter
+ let mut end_line = fm_start + 1;
+ let mut yaml_lines = Vec::new();
+
+ while end_line < lines.len() {
+ let line = lines[end_line];
+ if line.trim() == "---" {
+ end_line += 1;
+ break;
+ }
+ yaml_lines.push(line);
+ end_line += 1;
+ }
+
+ // Parse YAML and convert to exports
+ let yaml = strip_js_comments(&yaml_lines.join("\n"));
+ let exports = match serde_yml::from_str::(&yaml) {
+ Ok(value) => yaml_to_exports(&value).map_err(OxcDiagnostic::error)?,
+ Err(e) => {
+ let offset = e
+ .location()
+ .map(|loc| {
+ yaml.lines()
+ .take(loc.line())
+ .map(|l| l.len() + 1)
+ .sum::()
+ + loc.column()
+ })
+ .unwrap_or(0);
+
+ return Err(OxcDiagnostic::error(format!("YAML syntax error: {e}"))
+ .and_label(LabeledSpan::new(None, offset, 1)));
+ }
+ };
+
+ // Build output: preamble + exports
+ let mut output = String::new();
+ for line in &preamble_lines {
+ output.push_str(line);
+ output.push('\n');
+ }
+ output.push_str(&exports);
+ if !exports.is_empty() {
+ output.push('\n');
+ }
+
+ // Calculate bytes consumed (including the trailing newline after closing ---)
+ let mut bytes_consumed = 0;
+ for i in 0..end_line {
+ bytes_consumed += lines[i].len() + 1; // +1 for newline
+ }
+
+ // Consume one trailing blank line for separation if present
+ if end_line < lines.len() && lines[end_line].trim().is_empty() {
+ output.push('\n');
+ bytes_consumed += lines[end_line].len() + 1;
+ }
+
+ Ok(Some(PreambleResult {
+ output,
+ bytes_consumed,
+ }))
+}
+
impl BlockRule for Rule {
fn run(state: &mut BlockState) -> Option<(Node, usize)> {
- // Only at the start of the document
+ // Only at the start of the document (no preamble case - handled by preprocessing)
if state.line != 0 || state.line >= state.line_max {
return None;
}
diff --git a/src/plugin/inline_tags.rs b/src/plugin/inline_tags.rs
index dedd49f92ec63935224a0674f09292bfdc7af100..8f0bd2f3f1d57efcb83f671e3c8dcb0b6736aac5 100644
--- a/src/plugin/inline_tags.rs
+++ b/src/plugin/inline_tags.rs
@@ -93,14 +93,18 @@ fn match_markers(node: &mut Node) {
}
/// Check for unmatched markers and convert them to errors
-pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec) {
+pub fn check_unmatched_markers(
+ node: &markdown_it::Node,
+ errors: &mut Vec,
+ preamble_offset: usize,
+) {
if let Some(open) = node.cast::() {
let (start, _) = node.srcmap.unwrap().get_byte_offsets();
errors.push(
OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label(
LabeledSpan::new(
None,
- start + 1, // +1 to skip '<'
+ start + preamble_offset + 1, // +1 to skip '<'
open.tag_name_len,
),
),
@@ -114,20 +118,20 @@ pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec without matching open")
- .with_label(LabeledSpan::new(None, start, 3)),
+ .with_label(LabeledSpan::new(None, start + preamble_offset, 3)),
);
}
}
// Recurse into children
for child in &node.children {
- check_unmatched_markers(child, errors);
+ check_unmatched_markers(child, errors, preamble_offset);
}
}
diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs
index 4237c6bfacfbca6228dc0a7a3cee3807a67abdea..2276f329ac713e5a45e947e6f0c4a69677fc05a9 100644
--- a/src/plugin/mod.rs
+++ b/src/plugin/mod.rs
@@ -6,6 +6,8 @@ pub mod statement;
pub mod tags;
pub mod template;
+pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
+
use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};
use oxc_diagnostics::OxcDiagnostic;
diff --git a/tests/fixtures.rs b/tests/fixtures.rs
index f53200711423139318deb8cd1de84926b70e41c3..923e5663a752fded0acd61c6fcf897f02e668c6e 100644
--- a/tests/fixtures.rs
+++ b/tests/fixtures.rs
@@ -157,3 +157,18 @@ fn fixture_18_multiline_tag_with_value() {
fn fixture_19_extra_cases() {
run_fixture("19-extra-cases");
}
+
+#[test]
+fn fixture_20_frontmatter_preamble() {
+ run_fixture("20-frontmatter-preamble");
+}
+
+#[test]
+fn fixture_21_frontmatter_blank_lines() {
+ run_fixture("21-frontmatter-blank-lines");
+}
+
+#[test]
+fn fixture_22_frontmatter_mixed_preamble() {
+ run_fixture("22-frontmatter-mixed-preamble");
+}
diff --git a/tests/fixtures/20-frontmatter-preamble.marko b/tests/fixtures/20-frontmatter-preamble.marko
new file mode 100644
index 0000000000000000000000000000000000000000..ae4bd21edf227fa69a0f8a3fdd3b40396d801f5f
--- /dev/null
+++ b/tests/fixtures/20-frontmatter-preamble.marko
@@ -0,0 +1,6 @@
+import Button from "./Button.marko"
+
+export const title = "Hello";
+
+${title}
+Click
diff --git a/tests/fixtures/20-frontmatter-preamble.mdo b/tests/fixtures/20-frontmatter-preamble.mdo
new file mode 100644
index 0000000000000000000000000000000000000000..7ae7d45bf923424da9a72d704f88ee0da9b34bff
--- /dev/null
+++ b/tests/fixtures/20-frontmatter-preamble.mdo
@@ -0,0 +1,9 @@
+import Button from "./Button.marko"
+
+---
+title: Hello
+---
+
+# ${title}
+
+Click
diff --git a/tests/fixtures/21-frontmatter-blank-lines.marko b/tests/fixtures/21-frontmatter-blank-lines.marko
new file mode 100644
index 0000000000000000000000000000000000000000..ce68549e5da52e7a7d9d79b36bb5c0c8189b4087
--- /dev/null
+++ b/tests/fixtures/21-frontmatter-blank-lines.marko
@@ -0,0 +1,4 @@
+
+export const count = 42;
+
+The count is ${count}.
diff --git a/tests/fixtures/21-frontmatter-blank-lines.mdo b/tests/fixtures/21-frontmatter-blank-lines.mdo
new file mode 100644
index 0000000000000000000000000000000000000000..e43090bc1392b7bc3cff06b32b85c9953b5cb4bf
--- /dev/null
+++ b/tests/fixtures/21-frontmatter-blank-lines.mdo
@@ -0,0 +1,7 @@
+
+
+---
+count: 42
+---
+
+The count is ${count}.
diff --git a/tests/fixtures/22-frontmatter-mixed-preamble.marko b/tests/fixtures/22-frontmatter-mixed-preamble.marko
new file mode 100644
index 0000000000000000000000000000000000000000..af9c546cb0f422f68aaf272443dbbfd5a5869765
--- /dev/null
+++ b/tests/fixtures/22-frontmatter-mixed-preamble.marko
@@ -0,0 +1,10 @@
+
+import Foo from "./Foo.marko"
+
+import Bar from "./Bar.marko"
+import { helper } from "./utils"
+
+export const active = true;
+
+
+
diff --git a/tests/fixtures/22-frontmatter-mixed-preamble.mdo b/tests/fixtures/22-frontmatter-mixed-preamble.mdo
new file mode 100644
index 0000000000000000000000000000000000000000..d45185adf926ea76a86191b139d5d73b3444deab
--- /dev/null
+++ b/tests/fixtures/22-frontmatter-mixed-preamble.mdo
@@ -0,0 +1,12 @@
+
+import Foo from "./Foo.marko"
+
+import Bar from "./Bar.marko"
+import { helper } from "./utils"
+
+---
+active: true
+---
+
+
+