1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;
3use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
4
5use crate::plugin::{get_line_raw, ErrorBlock, StatementBlock};
6
7/// Parse frontmatter (---) at document start
8pub(crate) struct Rule;
9
10/// Strip '//' comments from YAML content (only when // starts the line)
11fn strip_js_comments(yaml: &str) -> String {
12 yaml.lines()
13 .map(|line| {
14 if line.trim_start().starts_with("//") {
15 ""
16 } else {
17 line
18 }
19 })
20 .collect::<Vec<_>>()
21 .join("\n")
22}
23
24/// Convert a YAML value to a JavaScript literal
25fn yaml_value_to_js(val: &serde_yml::Value) -> String {
26 match val {
27 serde_yml::Value::String(s) => {
28 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
29 format!("\"{escaped}\"")
30 }
31 serde_yml::Value::Number(n) => n.to_string(),
32 serde_yml::Value::Bool(b) => b.to_string(),
33 serde_yml::Value::Null => "null".to_string(),
34 serde_yml::Value::Mapping(_) | serde_yml::Value::Sequence(_) => {
35 // For complex types, serialize as JSON
36 serde_json::to_string(val).unwrap_or_else(|_| "null".to_string())
37 }
38 serde_yml::Value::Tagged(_) => "null".to_string(),
39 }
40}
41
42/// Convert YAML mapping to JavaScript export statements.
43/// Keys listed in `skip_keys` are omitted from the output.
44fn yaml_to_exports(value: &serde_yml::Value, skip_keys: &[&str]) -> Result<String, String> {
45 let map = match value {
46 serde_yml::Value::Mapping(m) => m,
47 _ => {
48 return Ok(format!(
49 "export const frontmatter = {};",
50 yaml_value_to_js(value)
51 ))
52 }
53 };
54
55 let mut exports = Vec::new();
56 for (key, val) in map {
57 let key_str = match key {
58 serde_yml::Value::String(s) => s,
59 _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")),
60 };
61
62 if skip_keys.contains(&key_str.as_str()) {
63 continue;
64 }
65
66 if matches!(val, serde_yml::Value::Tagged(_)) {
67 return Err("YAML tags are not supported in markodown frontmatter".to_string());
68 }
69
70 exports.push(format!(
71 "export const {key_str} = {};",
72 yaml_value_to_js(val)
73 ));
74 }
75
76 Ok(exports.join("\n"))
77}
78
79/// Extract the `layout` string value from a YAML mapping, if present.
80fn extract_layout_key(value: &serde_yml::Value) -> Option<String> {
81 let map = value.as_mapping()?;
82 let key = serde_yml::Value::String("layout".to_string());
83 match map.get(&key)? {
84 serde_yml::Value::String(s) => Some(s.clone()),
85 _ => None,
86 }
87}
88
89/// Result of extracting preamble (imports/blank lines) and frontmatter from source
90pub struct PreambleResult {
91 /// The generated output (imports + exports from frontmatter)
92 pub output: String,
93 /// Number of bytes consumed from the source
94 pub bytes_consumed: usize,
95 /// The `layout` key from frontmatter, if present. This overrides the
96 /// `layoutImport` transform option for this document.
97 pub layout_import: Option<String>,
98}
99
100/// Extract leading blank lines, import statements, and frontmatter from source.
101/// Returns None if no frontmatter is found after the preamble.
102pub fn extract_preamble_and_frontmatter(
103 source: &str,
104) -> Result<Option<PreambleResult>, OxcDiagnostic> {
105 let lines: Vec<&str> = source.lines().collect();
106 let mut preamble_lines = Vec::new();
107 let mut fm_start = 0;
108
109 // Collect blank lines and import statements
110 while fm_start < lines.len() {
111 let line = lines[fm_start];
112 let trimmed = line.trim();
113 if trimmed.is_empty() || line.starts_with("import ") {
114 preamble_lines.push(line);
115 fm_start += 1;
116 } else {
117 break;
118 }
119 }
120
121 // Check for opening delimiter
122 if fm_start >= lines.len() || lines[fm_start].trim() != "---" {
123 return Ok(None);
124 }
125
126 // Collect frontmatter lines until closing delimiter
127 let mut end_line = fm_start + 1;
128 let mut yaml_lines = Vec::new();
129
130 while end_line < lines.len() {
131 let line = lines[end_line];
132 if line.trim() == "---" {
133 end_line += 1;
134 break;
135 }
136 yaml_lines.push(line);
137 end_line += 1;
138 }
139
140 // Parse YAML and convert to exports
141 let yaml = strip_js_comments(&yaml_lines.join("\n"));
142 let (exports, layout_import) = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
143 Ok(value) => {
144 // Extract the `layout` key before converting to exports
145 let layout = extract_layout_key(&value);
146 let exports = yaml_to_exports(&value, &["layout"]).map_err(OxcDiagnostic::error)?;
147 (exports, layout)
148 }
149 Err(e) => {
150 let offset = e
151 .location()
152 .map(|loc| {
153 yaml.lines()
154 .take(loc.line())
155 .map(|l| l.len() + 1)
156 .sum::<usize>()
157 + loc.column()
158 })
159 .unwrap_or(0);
160
161 return Err(OxcDiagnostic::error(format!("YAML syntax error: {e}"))
162 .and_label(LabeledSpan::new(None, offset, 1)));
163 }
164 };
165
166 // Build output: preamble + exports
167 let mut output = String::new();
168 for line in &preamble_lines {
169 output.push_str(line);
170 output.push('\n');
171 }
172 output.push_str(&exports);
173 if !exports.is_empty() {
174 output.push('\n');
175 }
176
177 // Calculate bytes consumed (including the trailing newline after closing ---)
178 let mut bytes_consumed = 0;
179 for line in lines.iter().take(end_line) {
180 bytes_consumed += line.len() + 1; // +1 for newline
181 }
182
183 // Consume one trailing blank line for separation if present
184 if end_line < lines.len() && lines[end_line].trim().is_empty() {
185 output.push('\n');
186 bytes_consumed += lines[end_line].len() + 1;
187 }
188
189 Ok(Some(PreambleResult {
190 output,
191 bytes_consumed,
192 layout_import,
193 }))
194}
195
196#[cfg(test)]
197mod tests {
198 use super::extract_preamble_and_frontmatter;
199 use crate::transform;
200
201 #[test]
202 fn test_frontmatter_layout_extracted_as_layout_import() {
203 let source = "---\nlayout: ./my-layout.marko\ntitle: Hello\n---\n\n# Hi";
204 let result = extract_preamble_and_frontmatter(source)
205 .expect("no error")
206 .expect("some result");
207 assert_eq!(
208 result.layout_import.as_deref(),
209 Some("./my-layout.marko"),
210 "layout key should be extracted"
211 );
212 // `layout` must not appear as an export
213 assert!(
214 !result.output.contains("export const layout"),
215 "layout should not be exported"
216 );
217 // other keys are still exported
218 assert!(
219 result.output.contains("export const title"),
220 "title should still be exported"
221 );
222 }
223
224 #[test]
225 fn test_frontmatter_without_layout_gives_none() {
226 let source = "---\ntitle: Hello\n---\n\n# Hi";
227 let result = extract_preamble_and_frontmatter(source)
228 .expect("no error")
229 .expect("some result");
230 assert!(
231 result.layout_import.is_none(),
232 "no layout_import when key absent"
233 );
234 }
235
236 #[test]
237 fn test_frontmatter_layout_overrides_option() {
238 let source = "---\nlayout: ./frontmatter-layout.marko\n---\n\n# Hello";
239 let output = transform(
240 source,
241 None,
242 Some("./option-layout.marko".to_string()),
243 None,
244 None,
245 false,
246 None,
247 )
248 .expect("transform succeeded");
249 // frontmatter layout wins over the option
250 assert!(
251 output.text.contains("frontmatter-layout.marko"),
252 "frontmatter layout should be used"
253 );
254 assert!(
255 !output.text.contains("option-layout.marko"),
256 "option layout should be overridden"
257 );
258 }
259
260 #[test]
261 fn test_frontmatter_layout_used_when_no_option() {
262 let source = "---\nlayout: ./my-layout.marko\n---\n\n# Hello";
263 let output =
264 transform(source, None, None, None, None, false, None).expect("transform succeeded");
265 assert!(
266 output.text.contains("my-layout.marko"),
267 "frontmatter layout should be applied"
268 );
269 assert!(
270 output.text.contains("Layout__markodown__"),
271 "layout wrapper should be emitted"
272 );
273 }
274
275 #[test]
276 fn test_option_layout_used_when_no_frontmatter_layout() {
277 let source = "---\ntitle: Hello\n---\n\n# Hello";
278 let output = transform(
279 source,
280 None,
281 Some("./option-layout.marko".to_string()),
282 None,
283 None,
284 false,
285 None,
286 )
287 .expect("transform succeeded");
288 assert!(
289 output.text.contains("option-layout.marko"),
290 "option layout should still work"
291 );
292 }
293}
294
295impl BlockRule for Rule {
296 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
297 // Only at the start of the document (no preamble case - handled by preprocessing)
298 if state.line != 0 || state.line >= state.line_max {
299 return None;
300 }
301
302 // Check for opening delimiter
303 let line = get_line_raw(state, state.line);
304 if line.trim() != "---" {
305 return None;
306 }
307
308 // Collect frontmatter lines until closing delimiter
309 let start_line = state.line;
310 let mut end_line = state.line + 1;
311 let mut lines = Vec::new();
312
313 while end_line < state.line_max {
314 let line = get_line_raw(state, end_line);
315 if line.trim() == "---" {
316 end_line += 1;
317 break;
318 }
319 lines.push(line);
320 end_line += 1;
321 }
322
323 // Parse YAML and convert to exports
324 let yaml = strip_js_comments(&lines.join("\n"));
325 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
326 Ok(value) => match yaml_to_exports(&value, &["layout"]) {
327 Ok(js) => js,
328 Err(msg) => {
329 return Some((
330 Node::new(ErrorBlock {
331 errors: vec![OxcDiagnostic::error(msg)],
332 }),
333 end_line - start_line,
334 ));
335 }
336 },
337 Err(e) => {
338 let offset = e
339 .location()
340 .map(|loc| {
341 yaml.lines()
342 .take(loc.line())
343 .map(|l| l.len() + 1)
344 .sum::<usize>()
345 + loc.column()
346 })
347 .unwrap_or(0);
348
349 let error = OxcDiagnostic::error(format!("YAML syntax error: {e}"))
350 .and_label(LabeledSpan::new(None, offset, 1));
351
352 return Some((
353 Node::new(ErrorBlock {
354 errors: vec![error],
355 }),
356 end_line - start_line,
357 ));
358 }
359 };
360
361 // Build output with optional trailing newlines
362 let mut content = exports;
363 if !content.is_empty() {
364 content.push('\n');
365 }
366
367 let mut lines_consumed = end_line - start_line;
368
369 // Consume trailing blank line for separation
370 if end_line < state.line_max && get_line_raw(state, end_line).trim().is_empty() {
371 content.push('\n');
372 lines_consumed += 1;
373 }
374
375 Some((Node::new(StatementBlock { content }), lines_consumed))
376 }
377}