1pub mod component_transforms;
2pub mod marko;
3pub mod marko_ast;
4pub mod outline;
5pub mod plugin;
6pub mod typescript;
7pub mod wasm;
8
9use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
10use oxc_span::Span;
11use plugin::tags::{MarkoClose, MarkoCloseWithText, MarkoOpen};
12use serde::{Deserialize, Serialize};
13use std::borrow::Cow;
14use wasm_bindgen::prelude::wasm_bindgen;
15
16/// Configuration for replacing built-in elements with custom components.
17/// Each field is an optional import path for the component.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct ComponentImports {
21 /// Replace heading elements (h1-h6) with a custom component.
22 /// The component receives `level=1-6` and all original attributes.
23 pub heading: Option<String>,
24 /// Replace code blocks (```) with a custom component.
25 /// The component receives `language` and `meta` attributes.
26 pub code_block: Option<String>,
27 /// Replace link elements (<a>) with a custom component.
28 pub link: Option<String>,
29 /// Replace image elements (<img>) with a custom component.
30 pub image: Option<String>,
31 /// Replace blockquote elements with a custom component.
32 pub blockquote: Option<String>,
33}
34
35/// Clover's question extensions for paperclover.net/q+a backlog.
36/// These emit custom HTML elements (not Marko components).
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct CloverExtensions {
40 /// Element name for question blocks. `q: text` -> `<element>text</element>`
41 pub question: String,
42 /// Element name for artifact refs. `@slug` -> `<element>slug</element>`
43 pub artifact_ref: String,
44 /// Element name for question refs. `#2602142011` -> `<element>2602142011</element>`
45 pub question_ref: String,
46 /// Element name for labelled redactions. `#name#` -> `<element>name</element>`
47 pub labelled_redaction: String,
48}
49
50pub struct Output {
51 pub text: String,
52 pub format: OutputFormat,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[wasm_bindgen]
57#[serde(rename_all = "camelCase")]
58pub enum OutputFormat {
59 Html,
60 Marko,
61}
62
63pub fn transform(
64 source: &str,
65 force: Option<OutputFormat>,
66 layout_import: Option<String>,
67 component_imports: Option<ComponentImports>,
68 self_import: Option<String>,
69 markdown_only: bool,
70 clover_extensions: Option<CloverExtensions>,
71) -> Result<Output, Vec<OxcDiagnostic>> {
72 // Pre-process to extract preamble (blank lines, imports) and frontmatter
73 // This is needed because markdown-it skips blank lines before running block rules
74 let (preamble_output, remaining_source, preamble_offset, frontmatter_layout) =
75 match plugin::extract_preamble_and_frontmatter(source) {
76 Ok(Some(result)) => {
77 let offset = result.bytes_consumed;
78 (
79 Some(result.output),
80 &source[offset..],
81 offset,
82 result.layout_import,
83 )
84 }
85 Ok(None) => (None, source, 0, None),
86 Err(e) => return Err(vec![e]),
87 };
88
89 // Frontmatter `layout` overrides the layout_import option
90 let layout_import = frontmatter_layout.or(layout_import);
91
92 let md = &mut markdown_it::MarkdownIt::new();
93
94 plugin::add_all(md, markdown_only, &clover_extensions);
95 markdown_it::plugins::cmark::add(md);
96 markdown_it::plugins::extra::add(md);
97
98 let mut ast = md.parse(remaining_source);
99
100 let mut errors = Vec::new();
101 collect_errors(&mut ast, &mut errors, preamble_offset);
102 if !errors.is_empty() {
103 return Err(errors);
104 }
105
106 // Validate Marko tag open/close matching
107 validate_marko_tags(&ast, &mut errors, preamble_offset);
108 if !errors.is_empty() {
109 return Err(errors);
110 }
111
112 // Check for unmatched inline tag markers
113 plugin::inline_tags::check_unmatched_markers(&ast, &mut errors, preamble_offset);
114 if !errors.is_empty() {
115 return Err(errors);
116 }
117
118 // Determine output format
119 // componentImports forces Marko format since we add imports
120 // markdown_only mode defaults to HTML since no Marko features are parsed
121 let format = force.unwrap_or_else(|| {
122 if markdown_only {
123 OutputFormat::Html
124 } else if component_imports.is_some() || has_marko_features(&ast) {
125 OutputFormat::Marko
126 } else {
127 OutputFormat::Html
128 }
129 });
130
131 if force.is_some() && format == OutputFormat::Html {
132 if has_marko_features(&ast) {
133 return Err(vec![OxcDiagnostic::error(
134 "Cannot output HTML: document contains Marko-specific features",
135 )]);
136 }
137 if component_imports.is_some() {
138 return Err(vec![OxcDiagnostic::error(
139 "Cannot output HTML: componentImports requires Marko output",
140 )]);
141 }
142 }
143
144 // Collect headings, inject IDs, and extract content if layout_import is provided
145 let outline_result = if layout_import.is_some() {
146 let result = outline::collect_and_extract(&mut ast, preamble_offset as u32);
147 if !result.errors.is_empty() {
148 return Err(result.errors);
149 }
150 Some(result)
151 } else {
152 None
153 };
154
155 // When a layout is active, source heading, code block, link, image, and blockquote
156 // components from the layout's `components` export (with built-in fallbacks).
157 // Detect which element types are actually present so we only emit necessary boilerplate.
158 // This must happen AFTER outline extraction so headings are still h1-h6 when collected.
159 let layout_used = if layout_import.is_some() {
160 let detected = component_transforms::detect_used_elements(&ast);
161 // Only take over types not already handled by an explicit componentImports entry
162 let used = component_transforms::UsedElements {
163 heading: detected.heading
164 && component_imports
165 .as_ref()
166 .map_or(true, |i| i.heading.is_none()),
167 code_block: detected.code_block
168 && component_imports
169 .as_ref()
170 .map_or(true, |i| i.code_block.is_none()),
171 link: detected.link
172 && component_imports
173 .as_ref()
174 .map_or(true, |i| i.link.is_none()),
175 image: detected.image
176 && component_imports
177 .as_ref()
178 .map_or(true, |i| i.image.is_none()),
179 blockquote: detected.blockquote
180 && component_imports
181 .as_ref()
182 .map_or(true, |i| i.blockquote.is_none()),
183 };
184 component_transforms::transform_layout_components(&mut ast, &used);
185 Some(used)
186 } else {
187 None
188 };
189
190 // Transform components if componentImports is configured
191 // This must happen AFTER outline extraction so headings are still h1-h6 when collected
192 if let Some(ref imports) = component_imports {
193 component_transforms::transform_components(&mut ast, imports);
194 }
195
196 // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions
197 // - Block code (fenced/indented): only escaped if no custom code_block component is configured
198 // and not already transformed by the layout component path
199 // - Inline code: always escaped since there's no custom component option for it
200 if format == OutputFormat::Marko {
201 let layout_took_code_block = layout_used.as_ref().map_or(false, |u| u.code_block);
202 let escape_block = !layout_took_code_block
203 && component_imports
204 .as_ref()
205 .map_or(true, |i| i.code_block.is_none());
206 component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block);
207 }
208
209 // Extract statements before rendering - they need to be hoisted above Layout
210 let extracted_statements = hoist_statements(&mut ast);
211
212 let mut text = ast.render();
213
214 // Build hoisted content: preamble + component imports + extracted statements + defines
215 let mut hoisted = String::new();
216 if let Some(preamble) = preamble_output {
217 hoisted.push_str(&preamble);
218 }
219 // Add component imports
220 if let Some(ref imports) = component_imports {
221 hoisted.push_str(&component_transforms::generate_imports(imports));
222 }
223 hoisted.push_str(&extracted_statements);
224
225 // Wrap with Layout component if layout_import is provided
226 // Statements and defines must come before the Layout tag
227 if let (Some(layout_path), Some(result), Some(used)) =
228 (layout_import, outline_result, layout_used)
229 {
230 let outline_array = outline::format_outline_array(&result.headings);
231
232 // Add heading defines after other hoisted content (statements etc.)
233 hoisted.push_str(&result.defines);
234
235 // Boilerplate for sourcing components from the layout's exports, with fallbacks.
236 let boilerplate = component_transforms::generate_layout_boilerplate(&used);
237
238 if let Some(self_path) = self_import {
239 text = format!(
240 "import Layout__markodown__ from \"{layout_path}\";\nexport * as layout from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{boilerplate}\n{hoisted}\n<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{text}</>"
241 );
242 } else {
243 text = format!(
244 "import Layout__markodown__ from \"{layout_path}\";\nexport * as layout from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\n{boilerplate}\n{hoisted}\n<Layout__markodown__ module=null outline={outline_array}>\n{text}</>"
245 );
246 }
247 } else {
248 // No Layout wrapping, just prepend hoisted statements
249 text = hoisted + &text;
250 }
251
252 Ok(Output { text, format })
253}
254
255/// Extract all `ErrorBlock`s from an AST, offsetting the error positions.
256fn collect_errors(
257 node: &mut markdown_it::Node,
258 errors: &mut Vec<OxcDiagnostic>,
259 preamble_offset: usize,
260) {
261 let (start, end) = node.srcmap.unwrap().get_byte_offsets();
262 if let Some(error_block) = node.cast_mut::<plugin::ErrorBlock>() {
263 errors.extend(error_block.errors.drain(0..).map(|mut err| {
264 if let Some(labels) = err.labels.as_mut() {
265 for label in labels {
266 label.set_span_offset(label.offset() + start + preamble_offset + 2);
267 }
268 } else {
269 err.labels = Some(vec![LabeledSpan::new(
270 None,
271 start + preamble_offset,
272 end - start,
273 )])
274 }
275 err
276 }));
277 }
278 for child in &mut node.children {
279 collect_errors(child, errors, preamble_offset);
280 }
281}
282
283/// Extract all `ErrorBlock`s from an AST, offsetting the error positions.
284pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic {
285 if let Some(labels) = err.labels.as_mut() {
286 for label in labels {
287 label.set_span_offset(label.offset().saturating_add_signed(offset));
288 }
289 }
290 err
291}
292
293/// Extract `StatementBlock` contents from the AST, replacing them with an empty
294/// string so they render as nothing. Returns the statements hoisted.
295fn hoist_statements(node: &mut markdown_it::Node) -> String {
296 let mut statements = String::new();
297
298 fn walk(node: &mut markdown_it::Node, statements: &mut String) {
299 if let Some(stmt) = node.cast_mut::<plugin::StatementBlock>() {
300 statements.push_str(&stmt.content);
301 stmt.content.clear();
302 }
303 for child in &mut node.children {
304 walk(child, statements);
305 }
306 }
307
308 walk(node, &mut statements);
309 statements
310}
311
312/// Check if the AST contains any Marko-specific features
313fn has_marko_features(node: &markdown_it::Node) -> bool {
314 node.cast::<plugin::RawBlock>().is_some()
315 || node.cast::<plugin::StatementBlock>().is_some()
316 || node.cast::<MarkoOpen>().is_some()
317 || node.cast::<MarkoClose>().is_some()
318 || node.cast::<MarkoCloseWithText>().is_some()
319 || node.cast::<plugin::tags::MarkoSelfClosing>().is_some()
320 || node.cast::<plugin::tags::MarkoOpenWithText>().is_some()
321 || node.cast::<plugin::tags::MarkoInlineTag>().is_some()
322 || node.cast::<plugin::tags::MarkoBlockComplete>().is_some()
323 || node.children.iter().any(has_marko_features)
324}
325
326/// Validate that Marko open/close tags are properly matched
327fn validate_marko_tags(
328 node: &markdown_it::Node,
329 errors: &mut Vec<OxcDiagnostic>,
330 preamble_offset: usize,
331) {
332 // Stack of (tag_name, absolute_span_of_name)
333 let mut stack: Vec<(String, Span)> = vec![];
334
335 fn walk(
336 node: &markdown_it::Node,
337 stack: &mut Vec<(String, Span)>,
338 errors: &mut Vec<OxcDiagnostic>,
339 offset: u32,
340 ) {
341 if let Some(open) = node.cast::<MarkoOpen>() {
342 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
343 let rel_span = open.open.as_ref().tag_name_span();
344 let abs_span = Span::new(
345 start as u32 + offset + rel_span.start,
346 start as u32 + offset + rel_span.end,
347 );
348 stack.push((open.open.as_ref().tag_name().to_owned(), abs_span));
349 } else if let Some(open) = node.cast::<plugin::tags::MarkoOpenWithText>() {
350 let (start, _) = node.srcmap.unwrap().get_byte_offsets();
351 let rel_span = open.open.as_ref().tag_name_span();
352 let abs_span = Span::new(
353 start as u32 + offset + rel_span.start,
354 start as u32 + offset + rel_span.end,
355 );
356 stack.push((open.open.as_ref().tag_name().to_owned(), abs_span));
357 } else if let Some(close) = node.cast::<MarkoClose>() {
358 let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();
359 let close_start = close_start as u32 + offset;
360 // Close tag name starts after '</' (offset +2)
361 let close_span = close
362 .tag_name
363 .as_ref()
364 .map(|name| Span::new(close_start + 2, close_start + 2 + name.len() as u32));
365
366 match (close.tag_name.as_ref(), stack.pop()) {
367 (Some(name), Some((top_name, _))) if name == &top_name => {
368 // valid
369 }
370 (None, Some(_)) => {
371 // </> closes anything - valid
372 }
373 (Some(name), Some((top_name, top_span))) => {
374 // Mismatched: expected </top_name>, got </name>
375 errors.push(
376 OxcDiagnostic::error(format!(
377 "Mismatched closing tag: expected </{top_name}>, found </{name}>"
378 ))
379 .with_labels(vec![
380 top_span.label("opened here"),
381 close_span.unwrap().label("closed here"),
382 ]),
383 );
384 }
385 (Some(name), None) => {
386 // Close without open
387 errors.push(
388 OxcDiagnostic::error(format!(
389 "Closing tag </{name}> without matching open"
390 ))
391 .with_label(close_span.unwrap()),
392 );
393 }
394 (None, None) => {
395 // </> without any open tag - highlight the whole </>
396 errors.push(
397 OxcDiagnostic::error("Closing tag </> without matching open")
398 .with_label(Span::new(close_start, close_start + 3)),
399 );
400 }
401 }
402 } else if let Some(close) = node.cast::<MarkoCloseWithText>() {
403 let (close_start, _) = node.srcmap.unwrap().get_byte_offsets();
404 let close_start = close_start as u32 + offset;
405 // Close tag name starts after '</' (offset +2)
406 let close_span = close
407 .tag_name
408 .as_ref()
409 .map(|name| Span::new(close_start + 2, close_start + 2 + name.len() as u32));
410
411 match (close.tag_name.as_ref(), stack.pop()) {
412 (Some(name), Some((top_name, _))) if name == &top_name => {
413 // valid
414 }
415 (None, Some(_)) => {
416 // </> closes anything - valid
417 }
418 (Some(name), Some((top_name, top_span))) => {
419 errors.push(
420 OxcDiagnostic::error(format!(
421 "Mismatched closing tag: expected </{top_name}>, found </{name}>"
422 ))
423 .with_labels(vec![
424 top_span.label("opened here"),
425 close_span.unwrap().label("closed here"),
426 ]),
427 );
428 }
429 (Some(name), None) => {
430 errors.push(
431 OxcDiagnostic::error(format!(
432 "Closing tag </{name}> without matching open"
433 ))
434 .with_label(close_span.unwrap()),
435 );
436 }
437 (None, None) => {
438 errors.push(
439 OxcDiagnostic::error("Closing tag </> without matching open")
440 .with_label(Span::new(close_start, close_start + 3)),
441 );
442 }
443 }
444 }
445
446 // Recurse into children
447 for child in &node.children {
448 walk(child, stack, errors, offset);
449 }
450 }
451
452 walk(node, &mut stack, errors, preamble_offset as u32);
453
454 // Check for unclosed tags
455 for (name, span) in stack {
456 errors.push(OxcDiagnostic::error(format!("Unclosed tag <{name}>")).with_label(span));
457 }
458}
459
460pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> OxcDiagnostic {
461 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))
462}
463
464/// Convert a byte offset into `src` to 1-indexed (line, column).
465/// Used by both the WASM layer and error fixture tests.
466pub fn offset_to_line_col(src: &str, offset: usize) -> (u32, u32) {
467 let mut line = 1u32;
468 let mut col = 1u32;
469 for (i, ch) in src.char_indices() {
470 if i >= offset {
471 break;
472 }
473 if ch == '\n' {
474 line += 1;
475 col = 1;
476 } else {
477 col += 1;
478 }
479 }
480 (line, col)
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 fn run(source: &str) -> String {
488 transform(source, None, None, None, None, false, None)
489 .expect("transform failed")
490 .text
491 }
492
493 fn run_with_layout(source: &str, layout: &str) -> String {
494 transform(
495 source,
496 None,
497 Some(layout.to_string()),
498 None,
499 None,
500 false,
501 None,
502 )
503 .expect("transform failed")
504 .text
505 }
506
507 fn run_with_layout_and_self(source: &str, layout: &str, self_path: &str) -> String {
508 transform(
509 source,
510 None,
511 Some(layout.to_string()),
512 None,
513 Some(self_path.to_string()),
514 false,
515 None,
516 )
517 .expect("transform failed")
518 .text
519 }
520
521 // -------------------------------------------------------------------------
522 // Heading component boilerplate (layout active)
523 // -------------------------------------------------------------------------
524
525 #[test]
526 fn layout_emits_module_import() {
527 let out = run_with_layout("# Hello", "./layout.marko");
528 assert!(
529 out.contains("import * as LayoutModule__markodown__ from \"./layout.marko\";"),
530 "missing LayoutModule import"
531 );
532 }
533
534 #[test]
535 fn layout_emits_heading_fallback_define() {
536 let out = run_with_layout("# Hello", "./layout.marko");
537 assert!(
538 out.contains("<define/HeadingComponentFallback__markodown__"),
539 "missing HeadingComponentFallback define"
540 );
541 // fallback renders a plain h{level} tag
542 assert!(
543 out.contains("<${'h' + level}"),
544 "missing dynamic tag in fallback"
545 );
546 }
547
548 #[test]
549 fn layout_emits_heading_component_const() {
550 let out = run_with_layout("# Hello", "./layout.marko");
551 assert!(
552 out.contains("<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />"),
553 "missing HeadingComponent const"
554 );
555 }
556
557 #[test]
558 fn layout_boilerplate_before_layout_tag() {
559 let out = run_with_layout("# Hello", "./layout.marko");
560 let boilerplate_pos = out
561 .find("HeadingComponentFallback__markodown__")
562 .expect("boilerplate not found");
563 let layout_tag_pos = out
564 .find("<Layout__markodown__")
565 .expect("Layout tag not found");
566 assert!(
567 boilerplate_pos < layout_tag_pos,
568 "boilerplate must appear before the Layout tag"
569 );
570 }
571
572 #[test]
573 fn layout_heading_uses_component_not_plain_html() {
574 let out = run_with_layout("# Hello\n\n## World", "./layout.marko");
575 assert!(
576 out.contains("<HeadingComponent__markodown__"),
577 "headings should use HeadingComponent"
578 );
579 // plain <h1> should not appear in the body (only inside the fallback define)
580 // the fallback define uses ${'h' + level}, not literal <h1>
581 assert!(
582 !out.contains("<h1>") && !out.contains("<h2"),
583 "plain h1/h2 tags should not appear in body"
584 );
585 }
586
587 #[test]
588 fn layout_no_headings_omits_heading_boilerplate() {
589 // When a document has no headings, the heading boilerplate is omitted
590 // since there is nothing to render through HeadingComponent.
591 // The layout module import is still present for other component lookups.
592 let out = run_with_layout("Just some prose.", "./layout.marko");
593 assert!(
594 out.contains("LayoutModule__markodown__"),
595 "LayoutModule import must appear even without headings"
596 );
597 assert!(
598 !out.contains("HeadingComponent__markodown__"),
599 "HeadingComponent boilerplate must not appear when document has no headings"
600 );
601 }
602
603 #[test]
604 fn layout_with_self_import_uses_self_module() {
605 let out = run_with_layout_and_self("# Hello", "./layout.marko", "./self.marko");
606 assert!(
607 out.contains("import * as self__markodown__ from \"./self.marko\";"),
608 "missing self import"
609 );
610 assert!(
611 out.contains("module=self__markodown__"),
612 "Layout tag should use self module"
613 );
614 assert!(
615 !out.contains("module=null"),
616 "should not have null module when selfImport is set"
617 );
618 }
619
620 // -------------------------------------------------------------------------
621 // Frontmatter layout field
622 // -------------------------------------------------------------------------
623
624 // Quoted id= attribute on MarkoBlockComplete (<tag>content</>)
625 #[test]
626 fn marko_block_complete_heading_single_quoted_id_decoded() {
627 let out = run_with_layout("<h2 id='real-world-pitfalls'>A heading</>", "./l.marko");
628 assert!(
629 out.contains("id: 'real-world-pitfalls'"),
630 "outline id must not contain the attribute quotes: {out}"
631 );
632 assert!(
633 !out.contains("''real-world-pitfalls''"),
634 "outline array must not contain raw single-quoted attribute value"
635 );
636 }
637
638 #[test]
639 fn marko_block_complete_heading_double_quoted_id_decoded() {
640 let out = run_with_layout("<h2 id=\"real-world-pitfalls\">A heading</>", "./l.marko");
641 assert!(
642 out.contains("id: 'real-world-pitfalls'"),
643 "outline id must not contain the attribute double quotes: {out}"
644 );
645 }
646
647 // Quoted id= attribute on MarkoOpen (<tag>\ncontent\n</>)
648 #[test]
649 fn marko_open_heading_single_quoted_id_decoded() {
650 let out = run_with_layout("<h2 id='real-world-pitfalls'>\nA heading\n</>", "./l.marko");
651 assert!(
652 out.contains("id: 'real-world-pitfalls'"),
653 "multiline MarkoOpen heading: outline id must not contain attribute quotes: {out}"
654 );
655 }
656
657 #[test]
658 fn marko_open_heading_double_quoted_id_decoded() {
659 let out = run_with_layout(
660 "<h2 id=\"real-world-pitfalls\">\nA heading\n</>",
661 "./l.marko",
662 );
663 assert!(
664 out.contains("id: 'real-world-pitfalls'"),
665 "multiline MarkoOpen heading: outline id must not contain attribute double quotes: {out}"
666 );
667 }
668
669 // Quoted id= attribute on MarkoOpenWithText (<tag> text on same line, close elsewhere)
670 #[test]
671 fn marko_open_with_text_heading_single_quoted_id_decoded() {
672 let out = run_with_layout("<h2 id='real-world-pitfalls'> A heading\n</>", "./l.marko");
673 assert!(
674 out.contains("id: 'real-world-pitfalls'"),
675 "MarkoOpenWithText heading: outline id must not contain attribute quotes: {out}"
676 );
677 }
678
679 #[test]
680 fn marko_open_with_text_heading_double_quoted_id_decoded() {
681 let out = run_with_layout(
682 "<h2 id=\"real-world-pitfalls\"> A heading\n</>",
683 "./l.marko",
684 );
685 assert!(
686 out.contains("id: 'real-world-pitfalls'"),
687 "MarkoOpenWithText heading: outline id must not contain attribute double quotes: {out}"
688 );
689 }
690
691 #[test]
692 fn frontmatter_layout_activates_layout_wrapping() {
693 let source = "---\nlayout: ./l.marko\n---\n\ntext";
694 let out = run(source);
695 assert!(
696 out.contains("import Layout__markodown__ from \"./l.marko\";"),
697 "Layout import missing"
698 );
699 assert!(out.contains("<Layout__markodown__"), "Layout tag missing");
700 }
701
702 #[test]
703 fn frontmatter_layout_activates_heading_boilerplate() {
704 let source = "---\nlayout: ./l.marko\n---\n\n# Hello";
705 let out = run(source);
706 assert!(
707 out.contains("LayoutModule__markodown__"),
708 "LayoutModule import must be present when frontmatter layout activates wrapping"
709 );
710 assert!(
711 out.contains("HeadingComponent__markodown__"),
712 "HeadingComponent const must be present"
713 );
714 }
715
716 #[test]
717 fn frontmatter_layout_overrides_layout_import_option() {
718 let source = "---\nlayout: ./frontmatter.marko\n---\n\ntext";
719 let out = transform(
720 source,
721 None,
722 Some("./option.marko".to_string()),
723 None,
724 None,
725 false,
726 None,
727 )
728 .unwrap()
729 .text;
730 assert!(
731 out.contains("frontmatter.marko"),
732 "frontmatter layout should win"
733 );
734 assert!(
735 !out.contains("option.marko"),
736 "option layout should be overridden"
737 );
738 }
739
740 #[test]
741 fn frontmatter_layout_with_preamble_imports() {
742 // Preamble imports should appear before the Layout tag (not before the layout module import)
743 let source = "import Foo from \"./Foo.marko\"\n---\nlayout: ./l.marko\n---\n\ntext";
744 let out = run(source);
745 let preamble_pos = out.find("import Foo").expect("preamble import missing");
746 let layout_tag_pos = out
747 .find("<Layout__markodown__")
748 .expect("Layout tag missing");
749 assert!(
750 preamble_pos < layout_tag_pos,
751 "preamble imports must come before the Layout tag"
752 );
753 }
754
755 // -------------------------------------------------------------------------
756 // Heading component in body output
757 // -------------------------------------------------------------------------
758
759 #[test]
760 fn atx_heading_wrapped_in_component_with_level() {
761 let out = run_with_layout("# Title\n\n## Subtitle", "./l.marko");
762 assert!(
763 out.contains("level=1"),
764 "h1 should get level=1 on component"
765 );
766 assert!(
767 out.contains("level=2"),
768 "h2 should get level=2 on component"
769 );
770 }
771
772 #[test]
773 fn atx_heading_id_injected_into_component_tag() {
774 let out = run_with_layout("## my section", "./l.marko");
775 assert!(
776 out.contains("#my-section"),
777 "id shorthand should be on the HeadingComponent tag"
778 );
779 }
780
781 #[test]
782 fn marko_heading_with_explicit_id_preserved() {
783 let out = run_with_layout("<h2#custom-id>Some heading</>", "./l.marko");
784 assert!(
785 out.contains("#custom-id"),
786 "explicit id should be preserved on HeadingComponent tag"
787 );
788 assert!(out.contains("level=2"), "level attr should be added");
789 }
790
791 #[test]
792 fn marko_heading_quoted_id_attr_decoded_in_outline() {
793 // id='real-world-pitfalls' (single-quoted attr) must be decoded to
794 // real-world-pitfalls (no quotes) before being placed in the outline array
795 let out = run_with_layout("<h2 id='real-world-pitfalls'>A heading</>", "./l.marko");
796 assert!(
797 out.contains("id: 'real-world-pitfalls'"),
798 "outline id must not contain the attribute quotes: {out}"
799 );
800 assert!(
801 !out.contains("''real-world-pitfalls''"),
802 "outline array must not contain raw single-quoted attribute value"
803 );
804 }
805
806 #[test]
807 fn marko_heading_double_quoted_id_attr_decoded_in_outline() {
808 let out = run_with_layout("<h2 id=\"real-world-pitfalls\">A heading</>", "./l.marko");
809 assert!(
810 out.contains("id: 'real-world-pitfalls'"),
811 "outline id must not contain the attribute quotes: {out}"
812 );
813 }
814
815 #[test]
816 fn heading_content_hoisted_into_define() {
817 let out = run_with_layout("# **bold** heading", "./l.marko");
818 assert!(
819 out.contains("<define/Heading_1__markodown__>"),
820 "heading content should be hoisted into a define"
821 );
822 assert!(
823 out.contains("<strong>bold</strong>"),
824 "rendered content should appear in the define block"
825 );
826 }
827
828 #[test]
829 fn heading_content_ref_used_in_outline_array() {
830 let out = run_with_layout("# Hello", "./l.marko");
831 assert!(
832 out.contains("content: Heading_1__markodown__"),
833 "outline array should reference the define component"
834 );
835 }
836
837 // -------------------------------------------------------------------------
838 // Setext headings (underline style) with layout
839 // -------------------------------------------------------------------------
840
841 #[test]
842 fn setext_h1_wrapped_in_component() {
843 // Setext h1: underlined with ===
844 let out = run_with_layout("Title\n=====", "./l.marko");
845 assert!(
846 out.contains("level=1"),
847 "setext h1 should get level=1 on HeadingComponent"
848 );
849 assert!(
850 out.contains("<HeadingComponent__markodown__"),
851 "setext h1 should use HeadingComponent"
852 );
853 }
854
855 #[test]
856 fn setext_h2_id_slug_from_text() {
857 // Setext h2: underlined with ---
858 let out = run_with_layout("My Section\n----------", "./l.marko");
859 assert!(
860 out.contains("#my-section"),
861 "setext h2 should have slug id on component tag"
862 );
863 assert!(out.contains("level=2"), "setext h2 should get level=2");
864 }
865
866 // -------------------------------------------------------------------------
867 // Marko <hN> tags trigger heading boilerplate
868 // -------------------------------------------------------------------------
869
870 #[test]
871 fn marko_heading_tag_triggers_heading_boilerplate() {
872 // A document with only a raw Marko <h2> (no markdown headings) should
873 // still emit heading boilerplate when a layout is active, because the
874 // detection pass covers Marko <hN> nodes in addition to markdown nodes.
875 let out = run_with_layout("<h2>Hello</h2>", "./l.marko");
876 assert!(
877 out.contains("<define/HeadingComponentFallback__markodown__"),
878 "Marko <h2> should trigger heading fallback define"
879 );
880 assert!(
881 out.contains("<const/HeadingComponent__markodown__"),
882 "Marko <h2> should trigger HeadingComponent const"
883 );
884 }
885
886 // -------------------------------------------------------------------------
887 // Multi-line Marko heading (<h2>\ncontent\n</h2>) with layout
888 // -------------------------------------------------------------------------
889
890 #[test]
891 fn multiline_marko_heading_with_layout() {
892 // <h3> with content on next line (MarkoOpen + MarkoClose in AST)
893 let out = run_with_layout("<h3>\nsome content\n</h3>", "./l.marko");
894 assert!(
895 out.contains("level=3"),
896 "multi-line <h3> should get level=3"
897 );
898 assert!(
899 out.contains("<HeadingComponent__markodown__"),
900 "multi-line <h3> should be renamed to HeadingComponent"
901 );
902 // The named close tag </h3> should become </> — not leave a stray </h3>
903 assert!(
904 !out.contains("</h3>"),
905 "named close tag </h3> should be converted to </>"
906 );
907 }
908
909 // -------------------------------------------------------------------------
910 // Duplicate heading IDs through layout
911 // -------------------------------------------------------------------------
912
913 #[test]
914 fn duplicate_heading_ids_deduplicated_in_outline() {
915 let out = run_with_layout("## Intro\n\n## Intro\n\n## Intro", "./l.marko");
916 assert!(
917 out.contains("id: 'intro'"),
918 "first occurrence uses base slug"
919 );
920 assert!(
921 out.contains("id: 'intro-1'"),
922 "second occurrence gets -1 suffix"
923 );
924 assert!(
925 out.contains("id: 'intro-2'"),
926 "third occurrence gets -2 suffix"
927 );
928 }
929
930 #[test]
931 fn duplicate_heading_ids_deduplicated_on_component_tags() {
932 let out = run_with_layout("## Intro\n\n## Intro", "./l.marko");
933 assert!(
934 out.contains("#intro ") || out.contains("#intro level"),
935 "first heading should have #intro"
936 );
937 assert!(
938 out.contains("#intro-1"),
939 "second heading should have #intro-1"
940 );
941 }
942
943 // -------------------------------------------------------------------------
944 // Non-string `layout` value is silently ignored
945 // -------------------------------------------------------------------------
946
947 #[test]
948 fn non_string_layout_value_does_not_activate_layout() {
949 // layout: 42 should be ignored (not a string path)
950 let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext";
951 let out = run(source);
952 assert!(
953 !out.contains("Layout__markodown__"),
954 "non-string layout value must not activate layout wrapping"
955 );
956 }
957
958 #[test]
959 fn non_string_layout_value_not_exported() {
960 // The `layout` key should be consumed and not appear as an export,
961 // even when it's a non-string type
962 let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext";
963 let out = run(source);
964 assert!(
965 !out.contains("export const layout"),
966 "layout key must never be exported regardless of its value type"
967 );
968 assert!(
969 out.contains("export const title"),
970 "other fields should still export"
971 );
972 }
973
974 // -------------------------------------------------------------------------
975 // Frontmatter with only the layout key (no other exports)
976 // -------------------------------------------------------------------------
977
978 #[test]
979 fn layout_only_frontmatter_produces_no_extra_exports() {
980 let source = "---\nlayout: ./l.marko\n---\n\ntext";
981 let out = run(source);
982 assert!(
983 !out.contains("export const"),
984 "layout-only frontmatter should produce no export statements"
985 );
986 assert!(
987 out.contains("Layout__markodown__"),
988 "layout wrapping should still activate"
989 );
990 }
991
992 // -------------------------------------------------------------------------
993 // layout + componentImports.heading — componentImports wins
994 // -------------------------------------------------------------------------
995
996 #[test]
997 fn layout_and_component_imports_heading_no_conflict() {
998 // When both layoutImport and componentImports.heading are set,
999 // componentImports.heading takes precedence: only the explicit import
1000 // is emitted; the layout boilerplate <const> is suppressed.
1001 let out = transform(
1002 "# Hello",
1003 None,
1004 Some("./l.marko".to_string()),
1005 Some(ComponentImports {
1006 heading: Some("./h.marko".to_string()),
1007 ..Default::default()
1008 }),
1009 None,
1010 false,
1011 None,
1012 )
1013 .unwrap()
1014 .text;
1015 assert!(
1016 out.contains("import HeadingComponent__markodown__ from \"./h.marko\""),
1017 "explicit heading import should appear"
1018 );
1019 assert!(
1020 !out.contains("<const/HeadingComponent__markodown__"),
1021 "layout boilerplate const must be suppressed when componentImports.heading is set"
1022 );
1023 }
1024
1025 // -------------------------------------------------------------------------
1026 // Layout code block component
1027 // -------------------------------------------------------------------------
1028
1029 #[test]
1030 fn layout_code_block_emits_fallback_define_and_const() {
1031 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
1032 assert!(
1033 out.contains("<define/CodeBlockComponentFallback__markodown__"),
1034 "code block fallback define must be emitted when code block is present"
1035 );
1036 assert!(
1037 out.contains("LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__"),
1038 "code block const must read from layout components"
1039 );
1040 }
1041
1042 #[test]
1043 fn layout_code_block_uses_component_in_body() {
1044 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
1045 assert!(
1046 out.contains("<CodeBlockComponent__markodown__ language=\"ts\""),
1047 "fenced code block should be replaced with CodeBlockComponent"
1048 );
1049 }
1050
1051 #[test]
1052 fn layout_code_block_without_language() {
1053 let out = run_with_layout("```\nsome code\n```", "./l.marko");
1054 assert!(
1055 out.contains("<CodeBlockComponent__markodown__"),
1056 "code block with no language should still use CodeBlockComponent (no language attr)"
1057 );
1058 }
1059
1060 #[test]
1061 fn layout_code_block_content_escaped_as_template_literal() {
1062 // Code content with ${...} must be escaped so Marko doesn't interpret it
1063 let out = run_with_layout("```\n${danger}\n```", "./l.marko");
1064 assert!(
1065 out.contains("\\${"),
1066 "template expression in code content must be escaped"
1067 );
1068 }
1069
1070 #[test]
1071 fn no_code_block_omits_code_block_boilerplate() {
1072 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1073 assert!(
1074 !out.contains("CodeBlockComponent"),
1075 "code block boilerplate must be absent when no code block in document"
1076 );
1077 }
1078
1079 // -------------------------------------------------------------------------
1080 // Layout link component
1081 // -------------------------------------------------------------------------
1082
1083 #[test]
1084 fn layout_link_emits_const_with_string_fallback() {
1085 let out = run_with_layout("[text](https://example.com)", "./l.marko");
1086 assert!(
1087 out.contains("LayoutModule__markodown__.components?.link ?? 'a'"),
1088 "link const must use 'a' as fallback"
1089 );
1090 // no <define> needed for link — the string 'a' is the fallback
1091 assert!(
1092 !out.contains("LinkComponentFallback"),
1093 "link must not emit a fallback define"
1094 );
1095 }
1096
1097 #[test]
1098 fn layout_link_uses_component_in_body() {
1099 let out = run_with_layout("[click here](https://example.com)", "./l.marko");
1100 assert!(
1101 out.contains("<LinkComponent__markodown__"),
1102 "link should be replaced with LinkComponent"
1103 );
1104 assert!(
1105 out.contains("href=\"https://example.com\""),
1106 "href attribute should be preserved"
1107 );
1108 }
1109
1110 #[test]
1111 fn layout_link_with_title() {
1112 let out = run_with_layout("[text](https://example.com \"My Title\")", "./l.marko");
1113 assert!(
1114 out.contains("title=\"My Title\""),
1115 "link title attribute should be present"
1116 );
1117 }
1118
1119 #[test]
1120 fn no_link_omits_link_boilerplate() {
1121 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1122 assert!(
1123 !out.contains("LinkComponent"),
1124 "link boilerplate must be absent when no links in document"
1125 );
1126 }
1127
1128 // -------------------------------------------------------------------------
1129 // Layout image component
1130 // -------------------------------------------------------------------------
1131
1132 #[test]
1133 fn layout_image_emits_const_with_string_fallback() {
1134 let out = run_with_layout("![alt](./img.png)", "./l.marko");
1135 assert!(
1136 out.contains("LayoutModule__markodown__.components?.image ?? 'img'"),
1137 "image const must use 'img' as fallback"
1138 );
1139 assert!(
1140 !out.contains("ImageComponentFallback"),
1141 "image must not emit a fallback define"
1142 );
1143 }
1144
1145 #[test]
1146 fn layout_image_uses_component_in_body() {
1147 let out = run_with_layout("![my alt](./photo.png)", "./l.marko");
1148 assert!(
1149 out.contains("<ImageComponent__markodown__"),
1150 "image should be replaced with ImageComponent"
1151 );
1152 assert!(
1153 out.contains("src=\"./photo.png\""),
1154 "src attribute should be preserved"
1155 );
1156 assert!(
1157 out.contains("alt=\"my alt\""),
1158 "alt attribute should be preserved"
1159 );
1160 }
1161
1162 #[test]
1163 fn layout_image_with_title() {
1164 let out = run_with_layout("![alt](./img.png \"caption\")", "./l.marko");
1165 assert!(
1166 out.contains("title=\"caption\""),
1167 "image title attribute should be present"
1168 );
1169 }
1170
1171 #[test]
1172 fn no_image_omits_image_boilerplate() {
1173 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1174 assert!(
1175 !out.contains("ImageComponent"),
1176 "image boilerplate must be absent when no images in document"
1177 );
1178 }
1179
1180 // -------------------------------------------------------------------------
1181 // Layout blockquote component
1182 // -------------------------------------------------------------------------
1183
1184 #[test]
1185 fn layout_blockquote_emits_const_with_string_fallback() {
1186 let out = run_with_layout("> some quote", "./l.marko");
1187 assert!(
1188 out.contains("LayoutModule__markodown__.components?.blockquote ?? 'blockquote'"),
1189 "blockquote const must use 'blockquote' as fallback"
1190 );
1191 assert!(
1192 !out.contains("BlockquoteComponentFallback"),
1193 "blockquote must not emit a fallback define"
1194 );
1195 }
1196
1197 #[test]
1198 fn layout_blockquote_uses_component_in_body() {
1199 let out = run_with_layout("> some quote", "./l.marko");
1200 assert!(
1201 out.contains("<BlockquoteComponent__markodown__"),
1202 "blockquote should be replaced with BlockquoteComponent"
1203 );
1204 }
1205
1206 #[test]
1207 fn no_blockquote_omits_blockquote_boilerplate() {
1208 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1209 assert!(
1210 !out.contains("BlockquoteComponent"),
1211 "blockquote boilerplate must be absent when no blockquotes in document"
1212 );
1213 }
1214
1215 // Regression: EmphPairScanner panicked (subtract-with-overflow) when an
1216 // emphasis span crossed multiple blockquote continuation lines because
1217 // `token_len` was derived from srcmap file-byte offsets (inclusive of
1218 // stripped `> ` bytes) instead of inline-text offsets.
1219 #[test]
1220 fn multiline_blockquote_emphasis_does_not_panic() {
1221 let src = "> *bottom row left: the past like 4 times i've gone with akira to that\n\
1222 > pizza place, besides last time in december, they've always seated us at that\n\
1223 > exact same table. it's really cute.*";
1224 let out = run(src);
1225 assert!(out.contains("<em>"), "emphasis must be rendered");
1226 assert!(out.contains("<blockquote>"), "blockquote must be rendered");
1227 }
1228
1229 // -------------------------------------------------------------------------
1230 // componentImports takes precedence over layout for the same element type
1231 // -------------------------------------------------------------------------
1232
1233 #[test]
1234 fn explicit_component_import_suppresses_layout_code_block() {
1235 let out = transform(
1236 "```ts\nconst x = 1;\n```",
1237 None,
1238 Some("./l.marko".to_string()),
1239 Some(ComponentImports {
1240 code_block: Some("./cb.marko".to_string()),
1241 ..Default::default()
1242 }),
1243 None,
1244 false,
1245 None,
1246 )
1247 .unwrap()
1248 .text;
1249 // explicit import wins — no layout const for code block
1250 assert!(
1251 !out.contains("LayoutModule__markodown__.components?.codeBlock"),
1252 "layout code block boilerplate should be suppressed by explicit componentImports.codeBlock"
1253 );
1254 assert!(
1255 out.contains("import CodeBlockComponent__markodown__ from \"./cb.marko\""),
1256 "explicit code block import should be present"
1257 );
1258 }
1259
1260 #[test]
1261 fn explicit_component_import_suppresses_layout_link() {
1262 let out = transform(
1263 "[text](https://example.com)",
1264 None,
1265 Some("./l.marko".to_string()),
1266 Some(ComponentImports {
1267 link: Some("./link.marko".to_string()),
1268 ..Default::default()
1269 }),
1270 None,
1271 false,
1272 None,
1273 )
1274 .unwrap()
1275 .text;
1276 assert!(
1277 !out.contains("LayoutModule__markodown__.components?.link"),
1278 "layout link boilerplate should be suppressed"
1279 );
1280 assert!(
1281 out.contains("import LinkComponent__markodown__ from \"./link.marko\""),
1282 "explicit link import should be present"
1283 );
1284 }
1285
1286 #[test]
1287 fn close_tag_with_same_line_text() {
1288 let out = run("<div>\n</div> trailing text\n");
1289 assert!(out.contains("</div>"), "close tag missing");
1290 assert!(
1291 out.contains("trailing text"),
1292 "same-line text after close tag missing"
1293 );
1294 }
1295
1296 #[test]
1297 fn close_tag_with_same_line_text_validated() {
1298 let result = transform(
1299 "<section>\ncontent\n</section> after",
1300 None,
1301 None,
1302 None,
1303 None,
1304 false,
1305 None,
1306 );
1307 assert!(
1308 result.is_ok(),
1309 "should not error on close tag with trailing text"
1310 );
1311 let out = result.unwrap().text;
1312 assert!(
1313 out.contains("after"),
1314 "trailing text should appear in output"
1315 );
1316 }
1317}