1//! Component transformations for replacing built-in elements with custom components.
2//!
3//! This module walks the AST and replaces heading, code block, link, image, and
4//! blockquote elements with calls to custom components specified in ComponentImports.
5
6use markdown_it::{Node, NodeValue, Renderer};
7
8use crate::marko_ast::OpenOwned;
9use crate::plugin::tags::{
10 MarkoBlockComplete, MarkoClose, MarkoCloseWithText, MarkoOpen, MarkoOpenWithText,
11};
12use crate::ComponentImports;
13
14/// Component name suffix to avoid collisions
15const SUFFIX: &str = "__markodown__";
16
17/// Heading component name
18fn heading_component() -> String {
19 format!("HeadingComponent{SUFFIX}")
20}
21
22/// Code block component name
23fn code_block_component() -> String {
24 format!("CodeBlockComponent{SUFFIX}")
25}
26
27/// Link component name
28fn link_component() -> String {
29 format!("LinkComponent{SUFFIX}")
30}
31
32/// Image component name
33fn image_component() -> String {
34 format!("ImageComponent{SUFFIX}")
35}
36
37/// Blockquote component name
38fn blockquote_component() -> String {
39 format!("BlockquoteComponent{SUFFIX}")
40}
41
42/// Generate import statements for all configured component imports
43pub fn generate_imports(imports: &ComponentImports) -> String {
44 let mut result = String::new();
45
46 if let Some(path) = &imports.heading {
47 result.push_str(&format!(
48 "import {} from \"{}\";\n",
49 heading_component(),
50 path
51 ));
52 }
53
54 if let Some(path) = &imports.code_block {
55 result.push_str(&format!(
56 "import {} from \"{}\";\n",
57 code_block_component(),
58 path
59 ));
60 }
61
62 if let Some(path) = &imports.link {
63 result.push_str(&format!("import {} from \"{}\";\n", link_component(), path));
64 }
65
66 if let Some(path) = &imports.image {
67 result.push_str(&format!(
68 "import {} from \"{}\";\n",
69 image_component(),
70 path
71 ));
72 }
73
74 if let Some(path) = &imports.blockquote {
75 result.push_str(&format!(
76 "import {} from \"{}\";\n",
77 blockquote_component(),
78 path
79 ));
80 }
81
82 result
83}
84
85/// Which markdown element types are present in the document.
86/// Used to emit only the necessary layout-component boilerplate.
87#[derive(Debug, Default, Clone, Copy)]
88pub struct UsedElements {
89 pub heading: bool,
90 pub code_block: bool,
91 pub link: bool,
92 pub image: bool,
93 pub blockquote: bool,
94}
95
96/// Scan the AST and return which element types are present.
97pub fn detect_used_elements(node: &Node) -> UsedElements {
98 let mut used = UsedElements::default();
99 detect_recursive(node, &mut used);
100 used
101}
102
103fn detect_recursive(node: &Node, used: &mut UsedElements) {
104 if node
105 .cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>()
106 .is_some()
107 || node
108 .cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
109 .is_some()
110 || node
111 .cast::<MarkoOpen>()
112 .is_some_and(|n| parse_heading_level(n.open.as_ref().tag_name()).is_some())
113 || node
114 .cast::<MarkoOpenWithText>()
115 .is_some_and(|n| parse_heading_level(n.open.as_ref().tag_name()).is_some())
116 || node
117 .cast::<MarkoBlockComplete>()
118 .is_some_and(|n| parse_heading_level(n.open.as_ref().tag_name()).is_some())
119 {
120 used.heading = true;
121 }
122 if node
123 .cast::<markdown_it::plugins::cmark::block::fence::CodeFence>()
124 .is_some()
125 || node
126 .cast::<markdown_it::plugins::cmark::block::code::CodeBlock>()
127 .is_some()
128 {
129 used.code_block = true;
130 }
131 if node
132 .cast::<markdown_it::plugins::cmark::inline::link::Link>()
133 .is_some()
134 {
135 used.link = true;
136 }
137 if node
138 .cast::<markdown_it::plugins::cmark::inline::image::Image>()
139 .is_some()
140 {
141 used.image = true;
142 }
143 if node
144 .cast::<markdown_it::plugins::cmark::block::blockquote::Blockquote>()
145 .is_some()
146 {
147 used.blockquote = true;
148 }
149 for child in &node.children {
150 detect_recursive(child, used);
151 }
152}
153
154/// Generate the Marko boilerplate that wires up layout-sourced components.
155///
156/// Always emits the heading fallback `<define>` + `<const>`.
157/// Conditionally emits entries for code block, link, image, and blockquote
158/// based on which element types are actually present in the document.
159///
160/// Fallbacks:
161/// - heading: a `<define>` that renders `<h{level}>` dynamically
162/// - code block: a `<define>` that renders `<pre><code>`
163/// - link: the string `'a'` (Marko resolves string dynamic tags to HTML elements)
164/// - image: the string `'img'`
165/// - blockquote: the string `'blockquote'`
166pub fn generate_layout_boilerplate(used: &UsedElements) -> String {
167 let mut out = String::new();
168
169 // Heading — only emitted when headings are present and not handled by componentImports
170 if used.heading {
171 out.push_str(concat!(
172 "<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>\n",
173 " <${'h' + level} ...attrs><${content} /></>\n",
174 "</>\n",
175 "<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />\n",
176 ));
177 }
178
179 // Code block — fallback renders <pre><code class="language-...">
180 if used.code_block {
181 out.push_str(concat!(
182 "<define/CodeBlockComponentFallback__markodown__|{ language, content }|>\n",
183 " <pre><code class=(language && 'language-' + language)>${content}</code></pre>\n",
184 "</>\n",
185 "<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />\n",
186 ));
187 }
188
189 // Link — fallback is the HTML element name string 'a'
190 if used.link {
191 out.push_str("<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />\n");
192 }
193
194 // Image — fallback is the HTML element name string 'img'
195 if used.image {
196 out.push_str("<const/ImageComponent__markodown__ = LayoutModule__markodown__.components?.image ?? 'img' />\n");
197 }
198
199 // Blockquote — fallback is the HTML element name string 'blockquote'
200 if used.blockquote {
201 out.push_str("<const/BlockquoteComponent__markodown__ = LayoutModule__markodown__.components?.blockquote ?? 'blockquote' />\n");
202 }
203
204 out
205}
206
207/// Transform elements present in `used` to use their layout-sourced components.
208/// Must be called after outline extraction so headings are still h1-h6 when collected.
209/// Only transforms element types not already handled by explicit `componentImports`.
210pub fn transform_layout_components(node: &mut Node, used: &UsedElements) {
211 for child in &mut node.children {
212 transform_layout_components(child, used);
213 }
214 if used.heading {
215 transform_heading(node);
216 }
217 if used.code_block {
218 transform_code_block(node);
219 }
220 if used.link {
221 transform_link(node);
222 }
223 if used.image {
224 transform_image(node);
225 }
226 if used.blockquote {
227 transform_blockquote(node);
228 }
229}
230
231/// Transform all elements according to the component imports configuration.
232pub fn transform_components(node: &mut Node, imports: &ComponentImports) {
233 // Process children first (bottom-up traversal)
234 for child in &mut node.children {
235 transform_components(child, imports);
236 }
237
238 // Transform this node if applicable
239 if imports.heading.is_some() {
240 transform_heading(node);
241 }
242
243 if imports.code_block.is_some() {
244 transform_code_block(node);
245 }
246
247 if imports.link.is_some() {
248 transform_link(node);
249 }
250
251 if imports.image.is_some() {
252 transform_image(node);
253 }
254
255 if imports.blockquote.is_some() {
256 transform_blockquote(node);
257 }
258}
259
260/// A code block component node that renders as `<CodeBlockComponent language="x" meta="y">content</>`
261#[derive(Debug)]
262struct CodeBlockComponentNode {
263 /// Language identifier (e.g., "ts", "rust")
264 language: Option<String>,
265 /// Additional meta info after language
266 meta: Option<String>,
267 /// The code content
268 content: String,
269}
270
271impl NodeValue for CodeBlockComponentNode {
272 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
273 fmt.cr();
274 fmt.text_raw(&format!("<{}", code_block_component()));
275
276 if let Some(lang) = &self.language {
277 fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang)));
278 }
279 if let Some(meta) = &self.meta {
280 fmt.text_raw(&format!(" {}", meta));
281 }
282 fmt.text_raw(&format!(
283 " content=\"{}\"",
284 escape_template_literal(&self.content)
285 ));
286 fmt.text_raw("/>\n");
287 }
288}
289
290/// Escape a string for use in an attribute value
291fn escape_attr(s: &str) -> String {
292 s.replace('\\', "\\\\")
293 .replace('"', "\\\"")
294 .replace('\n', "\\n")
295}
296
297/// Escape a string for use inside a Marko template literal.
298/// This escapes backslashes, quotes, newlines, and `${` sequences
299/// to prevent nested template expression interpretation.
300fn escape_template_literal(s: &str) -> String {
301 let mut result = String::with_capacity(s.len() + s.len() / 8);
302 let mut chars = s.chars().peekable();
303
304 while let Some(c) = chars.next() {
305 match c {
306 '\\' => result.push_str("\\\\"),
307 '"' => result.push_str("\\\""),
308 '\n' => result.push_str("\\n"),
309 '\r' => result.push_str("\\r"),
310 '\t' => result.push_str("\\t"),
311 '$' if chars.peek() == Some(&'{') => {
312 // Escape ${ to prevent template expression interpretation
313 result.push_str("\\${");
314 chars.next(); // consume the '{'
315 }
316 _ => result.push(c),
317 }
318 }
319
320 result
321}
322
323/// Transform a code block node to use the code block component.
324fn transform_code_block(node: &mut Node) {
325 // Handle fenced code blocks (``` or ~~~)
326 if let Some(fence) = node.cast::<markdown_it::plugins::cmark::block::fence::CodeFence>() {
327 let info = &fence.info;
328 let mut parts = info.split_whitespace();
329 let language = parts
330 .next()
331 .filter(|s| !s.is_empty())
332 .map(|s| s.to_string());
333 let meta = {
334 let rest: String = parts.collect::<Vec<_>>().join(" ");
335 if rest.is_empty() {
336 None
337 } else {
338 Some(rest)
339 }
340 };
341 let content = fence.content.clone();
342
343 let new_node = Node::new(CodeBlockComponentNode {
344 language,
345 meta,
346 content,
347 });
348
349 *node = new_node;
350 return;
351 }
352
353 // Handle indented code blocks (4 spaces)
354 if let Some(code) = node.cast::<markdown_it::plugins::cmark::block::code::CodeBlock>() {
355 let content = code.content.clone();
356
357 let new_node = Node::new(CodeBlockComponentNode {
358 language: None,
359 meta: None,
360 content,
361 });
362
363 *node = new_node;
364 }
365}
366
367/// Transform a heading node (h1-h6) to use the heading component.
368fn transform_heading(node: &mut Node) {
369 // Handle markdown ATX headings (# ## ### etc)
370 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
371 let level = heading.level;
372
373 // Create a new MarkoBlockComplete to replace this node
374 let mut open = OpenOwned::from_tag_name(&heading_component());
375 // Transfer id injected by outline extraction (via node.attrs) into the tag
376 if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") {
377 open.insert_id_attr(id);
378 }
379 open.insert_attr(&format!("level={level}"));
380
381 // We need to take ownership of children
382 let children = std::mem::take(&mut node.children);
383
384 // Create the new node
385 let mut new_node = Node::new(MarkoBlockComplete {
386 open,
387 close_tag: "</>".to_string(),
388 });
389 new_node.children = children;
390 new_node.srcmap = node.srcmap;
391
392 *node = new_node;
393 return;
394 }
395
396 // Handle setext headings (underline style)
397 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
398 {
399 let level = heading.level;
400
401 let mut open = OpenOwned::from_tag_name(&heading_component());
402 // Transfer id injected by outline extraction (via node.attrs) into the tag
403 if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") {
404 open.insert_id_attr(id);
405 }
406 open.insert_attr(&format!("level={level}"));
407
408 let children = std::mem::take(&mut node.children);
409
410 let mut new_node = Node::new(MarkoBlockComplete {
411 open,
412 close_tag: "</>".to_string(),
413 });
414 new_node.children = children;
415 new_node.srcmap = node.srcmap;
416
417 *node = new_node;
418 return;
419 }
420
421 // Handle Marko open tags (h1-h6) - these have separate close tags
422 if let Some(marko_open) = node.cast_mut::<MarkoOpen>() {
423 if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) {
424 marko_open.open.replace_tag_name(&heading_component());
425 marko_open.open.insert_attr(&format!("level={level}"));
426 }
427 return;
428 }
429
430 // Handle Marko open with text tags (h1-h6)
431 if let Some(marko_open) = node.cast_mut::<MarkoOpenWithText>() {
432 if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) {
433 marko_open.open.replace_tag_name(&heading_component());
434 marko_open.open.insert_attr(&format!("level={level}"));
435 }
436 return;
437 }
438
439 // Handle Marko block complete tags (h1-h6) - open and close on same line
440 if let Some(marko_block) = node.cast_mut::<MarkoBlockComplete>() {
441 if let Some(level) = parse_heading_level(marko_block.open.as_ref().tag_name()) {
442 marko_block.open.replace_tag_name(&heading_component());
443 marko_block.open.insert_attr(&format!("level={level}"));
444 // Also update close tag to generic </>
445 marko_block.close_tag = "</>".to_string();
446 }
447 return;
448 }
449
450 // Handle close tags - need to replace </h1> etc with </>
451 if let Some(marko_close) = node.cast_mut::<MarkoClose>() {
452 if let Some(tag_name) = &marko_close.tag_name {
453 if parse_heading_level(tag_name).is_some() {
454 // Replace with generic close tag
455 marko_close.content = "</>".to_string();
456 marko_close.tag_name = None;
457 marko_close.tag_name_len = None;
458 }
459 }
460 }
461
462 if let Some(marko_close) = node.cast_mut::<MarkoCloseWithText>() {
463 if let Some(tag_name) = &marko_close.tag_name {
464 if parse_heading_level(tag_name).is_some() {
465 // Replace with generic close tag
466 marko_close.content = "</>".to_string();
467 marko_close.tag_name = None;
468 marko_close.tag_name_len = None;
469 }
470 }
471 }
472}
473
474/// Parse h1-h6 tag names and return the level
475fn parse_heading_level(tag_name: &str) -> Option<u8> {
476 match tag_name {
477 "h1" => Some(1),
478 "h2" => Some(2),
479 "h3" => Some(3),
480 "h4" => Some(4),
481 "h5" => Some(5),
482 "h6" => Some(6),
483 _ => None,
484 }
485}
486
487/// A link component node that renders as `<LinkComponent href="..." title="...">content</>`
488#[derive(Debug)]
489struct LinkComponentNode {
490 href: String,
491 title: Option<String>,
492}
493
494impl NodeValue for LinkComponentNode {
495 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
496 fmt.text_raw(&format!(
497 "<{} href=\"{}\"",
498 link_component(),
499 escape_attr(&self.href)
500 ));
501 if let Some(title) = &self.title {
502 fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title)));
503 }
504 fmt.text_raw(">");
505 fmt.contents(&node.children);
506 fmt.text_raw("</>");
507 }
508}
509
510/// Transform a link node to use the link component.
511fn transform_link(node: &mut Node) {
512 if let Some(link) = node.cast::<markdown_it::plugins::cmark::inline::link::Link>() {
513 let href = link.url.clone();
514 let title = link.title.clone();
515 let children = std::mem::take(&mut node.children);
516
517 let mut new_node = Node::new(LinkComponentNode { href, title });
518 new_node.children = children;
519 new_node.srcmap = node.srcmap;
520
521 *node = new_node;
522 }
523}
524
525/// An image component node that renders as `<ImageComponent src="..." alt="..." title="..." />`
526#[derive(Debug)]
527struct ImageComponentNode {
528 src: String,
529 alt: String,
530 title: Option<String>,
531}
532
533impl NodeValue for ImageComponentNode {
534 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
535 fmt.text_raw(&format!(
536 "<{} src=\"{}\" alt=\"{}\"",
537 image_component(),
538 escape_attr(&self.src),
539 escape_attr(&self.alt)
540 ));
541 if let Some(title) = &self.title {
542 fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title)));
543 }
544 fmt.text_raw(" />");
545 }
546}
547
548/// Transform an image node to use the image component.
549fn transform_image(node: &mut Node) {
550 if let Some(image) = node.cast::<markdown_it::plugins::cmark::inline::image::Image>() {
551 let src = image.url.clone();
552 let alt = node.collect_text();
553 let title = image.title.clone();
554
555 let new_node = Node::new(ImageComponentNode { src, alt, title });
556 *node = new_node;
557 }
558}
559
560/// A blockquote component node that wraps content in `<BlockquoteComponent>content</>`
561#[derive(Debug)]
562struct BlockquoteComponentNode;
563
564impl NodeValue for BlockquoteComponentNode {
565 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
566 fmt.cr();
567 fmt.text_raw(&format!("<{}>", blockquote_component()));
568 fmt.cr();
569 fmt.contents(&node.children);
570 fmt.cr();
571 fmt.text_raw("</>");
572 fmt.cr();
573 }
574}
575
576/// Transform a blockquote node to use the blockquote component.
577fn transform_blockquote(node: &mut Node) {
578 if node
579 .cast::<markdown_it::plugins::cmark::block::blockquote::Blockquote>()
580 .is_some()
581 {
582 let children = std::mem::take(&mut node.children);
583
584 let mut new_node = Node::new(BlockquoteComponentNode);
585 new_node.children = children;
586 new_node.srcmap = node.srcmap;
587
588 *node = new_node;
589 }
590}
591
592/// A code fence node that escapes `${` for Marko output.
593/// Used when no custom code block component is configured but output is Marko.
594#[derive(Debug)]
595struct EscapedCodeFence {
596 language: Option<String>,
597 content: String,
598}
599
600impl NodeValue for EscapedCodeFence {
601 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
602 fmt.cr();
603 fmt.text_raw("<pre><code");
604 if let Some(lang) = &self.language {
605 fmt.text_raw(&format!(" class=\"language-{}\"", lang));
606 }
607 fmt.text_raw(">");
608 // Escape ${ sequences in the content
609 let escaped = escape_marko_in_html(&self.content);
610 fmt.text_raw(&escaped);
611 fmt.text_raw("</code></pre>\n");
612 }
613}
614
615/// An inline code node that escapes `${` for Marko output.
616#[derive(Debug)]
617struct EscapedCodeInline {
618 content: String,
619}
620
621impl NodeValue for EscapedCodeInline {
622 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
623 fmt.text_raw("<code>");
624 let escaped = escape_marko_in_html(&self.content);
625 fmt.text_raw(&escaped);
626 fmt.text_raw("</code>");
627 }
628}
629
630/// Escape `${` sequences for Marko output in HTML context.
631/// This escapes `${` to `\${` to prevent template expression interpretation.
632fn escape_marko_in_html(s: &str) -> String {
633 let mut result = String::with_capacity(s.len() + s.len() / 16);
634 let mut chars = s.chars().peekable();
635
636 while let Some(c) = chars.next() {
637 match c {
638 '<' => result.push_str("&lt;"),
639 '>' => result.push_str("&gt;"),
640 '&' => result.push_str("&amp;"),
641 '"' => result.push_str("&quot;"),
642 '$' if chars.peek() == Some(&'{') => {
643 // Escape ${ to prevent template expression interpretation
644 result.push_str("\\${");
645 chars.next(); // consume the '{'
646 }
647 _ => result.push(c),
648 }
649 }
650
651 result
652}
653
654/// Escape code blocks for Marko output.
655/// - `escape_block`: if true, escape fenced and indented code blocks
656/// - Inline code is always escaped since there's no custom component option for it
657pub fn escape_code_blocks_for_marko(node: &mut Node, escape_block: bool) {
658 // Process children first (bottom-up traversal)
659 for child in &mut node.children {
660 escape_code_blocks_for_marko(child, escape_block);
661 }
662
663 if escape_block {
664 // Transform fenced code blocks
665 if let Some(fence) = node.cast::<markdown_it::plugins::cmark::block::fence::CodeFence>() {
666 let language = fence
667 .info
668 .split_whitespace()
669 .next()
670 .filter(|s| !s.is_empty())
671 .map(|s| s.to_string());
672 let content = fence.content.clone();
673
674 *node = Node::new(EscapedCodeFence { language, content });
675 return;
676 }
677
678 // Transform indented code blocks
679 if let Some(code) = node.cast::<markdown_it::plugins::cmark::block::code::CodeBlock>() {
680 let content = code.content.clone();
681 *node = Node::new(EscapedCodeFence {
682 language: None,
683 content,
684 });
685 return;
686 }
687 }
688
689 // Transform inline code - content is in children as text nodes
690 // Always escaped since there's no custom component option for inline code
691 if node
692 .cast::<markdown_it::plugins::cmark::inline::backticks::CodeInline>()
693 .is_some()
694 {
695 let content = node.collect_text();
696 *node = Node::new(EscapedCodeInline { content });
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn test_generate_imports_heading() {
706 let imports = ComponentImports {
707 heading: Some("./heading.marko".to_string()),
708 ..Default::default()
709 };
710 let result = generate_imports(&imports);
711 assert_eq!(
712 result,
713 "import HeadingComponent__markodown__ from \"./heading.marko\";\n"
714 );
715 }
716
717 #[test]
718 fn test_generate_imports_empty() {
719 let imports = ComponentImports::default();
720 let result = generate_imports(&imports);
721 assert_eq!(result, "");
722 }
723}