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 +--- + +
+ + +
+

in the summer

+ +
+ +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 ' // 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}

+ 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} + + 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 +--- + + +