| author | |
| committer | |
| log | 2a3ab659fdddfe407940a926347ff4e79ec41e82 |
| tree | 6b37d0bcf4ec75e1749c66919da2ac202081cced |
| parent | a4577db2eb8de0f1edf734aa8d476a349b7e8182 |
| signature |
12 files changed, 269 insertions(+), 26 deletions(-)
examples/in-the-summer.mdo created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import "./in-the-summer.css"; | |
| 2 | --- | |
| 3 | meta: | |
| 4 | title: in the summer | |
| 5 | theme: | |
| 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 | ||
| 19 | this is a song about the summer, and related feelings. written in 2022, | |
| 20 | produced in 2025. i had a lot of fun playing with a much larger instrument | |
| 21 | selection, 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 | 19 | } |
| 20 | 20 | |
| 21 | 21 | pub 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 | 34 | let md = &mut markdown_it::MarkdownIt::new(); |
| 23 | 35 | |
| 24 | 36 | plugin::add_all(md); |
| 25 | 37 | markdown_it::plugins::cmark::add(md); |
| 26 | 38 | markdown_it::plugins::extra::add(md); |
| 27 | 39 | |
| 28 | let mut ast = md.parse(source); | |
| 40 | let mut ast = md.parse(remaining_source); | |
| 29 | 41 | |
| 30 | 42 | let mut errors = Vec::new(); |
| 31 | collect_errors(&mut ast, &mut errors); | |
| 43 | collect_errors(&mut ast, &mut errors, preamble_offset); | |
| 32 | 44 | if !errors.is_empty() { |
| 33 | 45 | return Err(errors); |
| 34 | 46 | } |
| 35 | 47 | |
| 36 | 48 | // Validate Marko tag open/close matching |
| 37 | validate_marko_tags(&ast, &mut errors); | |
| 49 | validate_marko_tags(&ast, &mut errors, preamble_offset); | |
| 38 | 50 | if !errors.is_empty() { |
| 39 | 51 | return Err(errors); |
| 40 | 52 | } |
| 41 | 53 | |
| 42 | 54 | // 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 | 56 | if !errors.is_empty() { |
| 45 | 57 | return Err(errors); |
| 46 | 58 | } |
| ... | ... | @@ -59,29 +71,41 @@ pub fn transform(source: &str, force: Option<OutputFormat>) -> Result<Output, Ve |
| 59 | 71 | )]); |
| 60 | 72 | } |
| 61 | 73 | |
| 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 }) | |
| 66 | 82 | } |
| 67 | 83 | |
| 68 | 84 | /// Extract all `ErrorBlock`s from an AST, offsetting the error positions. |
| 69 | fn collect_errors(node: &mut markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | |
| 85 | fn collect_errors( | |
| 86 | node: &mut markdown_it::Node, | |
| 87 | errors: &mut Vec<OxcDiagnostic>, | |
| 88 | preamble_offset: usize, | |
| 89 | ) { | |
| 70 | 90 | let (start, end) = node.srcmap.unwrap().get_byte_offsets(); |
| 71 | 91 | if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() { |
| 72 | 92 | errors.extend(error_block.errors.drain(0..).map(|mut err| { |
| 73 | 93 | if let Some(labels) = err.labels.as_mut() { |
| 74 | 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 | 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 | 104 | err |
| 81 | 105 | })); |
| 82 | 106 | } |
| 83 | 107 | for child in &mut node.children { |
| 84 | collect_errors(child, errors); | |
| 108 | collect_errors(child, errors, preamble_offset); | |
| 85 | 109 | } |
| 86 | 110 | } |
| 87 | 111 | |
| ... | ... | @@ -124,7 +148,11 @@ fn has_marko_features(node: &markdown_it::Node) -> bool { |
| 124 | 148 | } |
| 125 | 149 | |
| 126 | 150 | /// Validate that Marko open/close tags are properly matched |
| 127 | fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | |
| 151 | fn validate_marko_tags( | |
| 152 | node: &markdown_it::Node, | |
| 153 | errors: &mut Vec<OxcDiagnostic>, | |
| 154 | preamble_offset: usize, | |
| 155 | ) { | |
| 128 | 156 | #[derive(Debug)] |
| 129 | 157 | struct OpenTag { |
| 130 | 158 | name: String, |
| ... | ... | @@ -136,25 +164,30 @@ fn validate_marko_tags(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic> |
| 136 | 164 | |
| 137 | 165 | let mut stack: Vec<OpenTag> = vec![]; |
| 138 | 166 | |
| 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 | 173 | if let Some(open) = node.cast::<MarkoOpen>() { |
| 141 | 174 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); |
| 142 | 175 | stack.push(OpenTag { |
| 143 | 176 | name: open.tag_name.clone(), |
| 144 | name_start: start + 1, // +1 to skip '<' | |
| 177 | name_start: start + offset + 1, // +1 to skip '<' | |
| 145 | 178 | name_len: open.tag_name_len, |
| 146 | 179 | }); |
| 147 | 180 | } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() { |
| 148 | 181 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); |
| 149 | 182 | stack.push(OpenTag { |
| 150 | 183 | name: open.tag_name.clone(), |
| 151 | name_start: start + 1, // +1 to skip '<' | |
| 184 | name_start: start + offset + 1, // +1 to skip '<' | |
| 152 | 185 | name_len: open.tag_name_len, |
| 153 | 186 | }); |
| 154 | 187 | } else if let Some(close) = node.cast::<MarkoClose>() { |
| 155 | 188 | let (close_start, _) = node.srcmap.unwrap().get_byte_offsets(); |
| 156 | 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 | 191 | let close_name_len = close.tag_name_len.unwrap_or(0); |
| 159 | 192 | |
| 160 | 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 | 235 | // </> without any open tag - highlight the whole </> |
| 203 | 236 | errors.push( |
| 204 | 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 | 243 | |
| 211 | 244 | // Recurse into children |
| 212 | 245 | for child in &node.children { |
| 213 | walk(child, stack, errors); | |
| 246 | walk(child, stack, errors, offset); | |
| 214 | 247 | } |
| 215 | 248 | } |
| 216 | 249 | |
| 217 | walk(node, &mut stack, errors); | |
| 250 | walk(node, &mut stack, errors, preamble_offset); | |
| 218 | 251 | |
| 219 | 252 | // Check for unclosed tags |
| 220 | 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 | 65 | Ok(exports.join("\n")) |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | /// Result of extracting preamble (imports/blank lines) and frontmatter from source | |
| 69 | pub 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. | |
| 78 | pub 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 | ||
| 68 | 166 | impl BlockRule for Rule { |
| 69 | 167 | 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) | |
| 71 | 169 | if state.line != 0 || state.line >= state.line_max { |
| 72 | 170 | return None; |
| 73 | 171 | } |
src/plugin/inline_tags.rs+9-5| ... | ... | @@ -93,14 +93,18 @@ fn match_markers(node: &mut Node) { |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | /// Check for unmatched markers and convert them to errors |
| 96 | pub fn check_unmatched_markers(node: &markdown_it::Node, errors: &mut Vec<OxcDiagnostic>) { | |
| 96 | pub fn check_unmatched_markers( | |
| 97 | node: &markdown_it::Node, | |
| 98 | errors: &mut Vec<OxcDiagnostic>, | |
| 99 | preamble_offset: usize, | |
| 100 | ) { | |
| 97 | 101 | if let Some(open) = node.cast::<MarkoInlineOpenMarker>() { |
| 98 | 102 | let (start, _) = node.srcmap.unwrap().get_byte_offsets(); |
| 99 | 103 | errors.push( |
| 100 | 104 | OxcDiagnostic::error(format!("Unclosed inline tag <{}>", open.tag_name)).with_label( |
| 101 | 105 | LabeledSpan::new( |
| 102 | 106 | None, |
| 103 | start + 1, // +1 to skip '<' | |
| 107 | start + preamble_offset + 1, // +1 to skip '<' | |
| 104 | 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 | 118 | )) |
| 115 | 119 | .with_label(LabeledSpan::new( |
| 116 | 120 | None, |
| 117 | start + 2, // +2 to skip '</' | |
| 121 | start + preamble_offset + 2, // +2 to skip '</' | |
| 118 | 122 | close.tag_name_len.unwrap_or(0), |
| 119 | 123 | )), |
| 120 | 124 | ); |
| 121 | 125 | } else { |
| 122 | 126 | errors.push( |
| 123 | 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 | } |
| 128 | 132 | |
| 129 | 133 | // Recurse into children |
| 130 | 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 | pub mod tags; |
| 7 | 7 | pub mod template; |
| 8 | 8 | |
| 9 | pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; | |
| 10 | ||
| 9 | 11 | use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; |
| 10 | 12 | use oxc_diagnostics::OxcDiagnostic; |
| 11 | 13 |
tests/fixtures.rs+15| ... | ... | @@ -157,3 +157,18 @@ fn fixture_18_multiline_tag_with_value() { |
| 157 | 157 | fn fixture_19_extra_cases() { |
| 158 | 158 | run_fixture("19-extra-cases"); |
| 159 | 159 | } |
| 160 | ||
| 161 | #[test] | |
| 162 | fn fixture_20_frontmatter_preamble() { | |
| 163 | run_fixture("20-frontmatter-preamble"); | |
| 164 | } | |
| 165 | ||
| 166 | #[test] | |
| 167 | fn fixture_21_frontmatter_blank_lines() { | |
| 168 | run_fixture("21-frontmatter-blank-lines"); | |
| 169 | } | |
| 170 | ||
| 171 | #[test] | |
| 172 | fn 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 @@ |
| 1 | import Button from "./Button.marko" | |
| 2 | ||
| 3 | export const title = "Hello"; | |
| 4 | ||
| 5 | <h1>${title}</h1> | |
| 6 | <Button>Click</Button> |
tests/fixtures/20-frontmatter-preamble.mdo created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import Button from "./Button.marko" | |
| 2 | ||
| 3 | --- | |
| 4 | title: Hello | |
| 5 | --- | |
| 6 | ||
| 7 | # ${title} | |
| 8 | ||
| 9 | <Button>Click</Button> |
tests/fixtures/21-frontmatter-blank-lines.marko created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | ||
| 2 | export 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 | --- | |
| 4 | count: 42 | |
| 5 | --- | |
| 6 | ||
| 7 | The count is ${count}. |
tests/fixtures/22-frontmatter-mixed-preamble.marko created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | ||
| 2 | import Foo from "./Foo.marko" | |
| 3 | ||
| 4 | import Bar from "./Bar.marko" | |
| 5 | import { helper } from "./utils" | |
| 6 | ||
| 7 | export const active = true; | |
| 8 | ||
| 9 | <Foo enabled=active/> | |
| 10 | <Bar/> |
tests/fixtures/22-frontmatter-mixed-preamble.mdo created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | ||
| 2 | import Foo from "./Foo.marko" | |
| 3 | ||
| 4 | import Bar from "./Bar.marko" | |
| 5 | import { helper } from "./utils" | |
| 6 | ||
| 7 | --- | |
| 8 | active: true | |
| 9 | --- | |
| 10 | ||
| 11 | <Foo enabled=active/> | |
| 12 | <Bar/> |