1use std::collections::BTreeSet;
2
3use crate::{NodeSink, RenderOptions, WalkCtx, Walker, escape::sanitize_url};
4use dmc_diagnostic::Code;
5use dmc_parser::ast::*;
6use duck_diagnostic::{DiagnosticEngine, diag};
7
8#[derive(Debug)]
18pub struct MdxBodyEmitter {
19 stack: Vec<Frame>,
20 imports: Vec<String>,
21 exports: Vec<String>,
22 diag_engine: DiagnosticEngine<Code>,
23 in_table_depth: usize,
24 used_intrinsic: BTreeSet<String>,
25 used_components: BTreeSet<String>,
26 options: RenderOptions,
33}
34
35#[derive(Default, Debug)]
36struct Frame {
37 parts: Vec<String>,
38}
39
40impl NodeSink for MdxBodyEmitter {
41 fn enter(&mut self, node: &Node, ctx: &WalkCtx) {
42 if self.in_table_depth > 0 {
43 return;
44 }
45 match node {
46 Node::Text(t) => self.push_part(Self::js_string(&t.value)),
47 Node::InlineCode(c) => {
48 let tag = self.jsx_tag_ref("code");
49 self.push_part(format!("jsx({}, {{ children: {} }})", tag, Self::js_string(&c.value),));
53 },
54 Node::CodeBlock(cb) => {
55 let s = self.code_block_expr(cb);
56 self.push_part(s);
57 },
58 Node::Image(i) => {
59 let s = self.image_expr(i);
60 self.push_part(s);
61 },
62 Node::HorizontalRule(_) => {
63 let tag = self.jsx_tag_ref("hr");
64 self.push_part(format!("jsx({}, {{}})", tag));
65 },
66 Node::HardBreak(_) => {
67 let tag = self.jsx_tag_ref("br");
68 self.push_part(format!("jsx({}, {{}})", tag));
69 },
70 Node::SoftBreak(_) => self.push_part(Self::js_string("\n")),
71 Node::JsxSelfClosing(s) => {
72 let expr = self.jsx_self_closing_expr(s);
73 self.push_part(expr);
74 },
75 Node::JsxExpression(j) => self.push_part(j.value.trim().to_string()),
76
77 Node::Html(h) => {
82 if !self.options.allow_dangerous_html {
87 let inline_context = matches!(ctx.parent, Some(Node::Paragraph(_)) | Some(Node::Heading(_)));
88 if inline_context {
89 self.push_part(Self::js_string(&h.value));
90 }
91 return;
93 }
94 let tag = self.jsx_tag_ref("div");
95 self.push_part(format!(
96 "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
97 tag,
98 Self::js_string(&h.value)
99 ));
100 },
101
102 Node::Table(t) => {
103 let expr = self.table_expr(t);
104 self.push_part(expr);
105 self.in_table_depth += 1;
106 },
107
108 Node::Frontmatter(_) => {},
109 Node::Import(i) => self.imports.push(i.raw.trim_end().to_string()),
110 Node::Export(x) => self.exports.push(x.raw.trim_end().to_string()),
111
112 _ => self.open_frame(node),
113 }
114 }
115
116 fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
117 if let Node::Table(_) = node {
118 self.in_table_depth = self.in_table_depth.saturating_sub(1);
119 return;
120 }
121 if self.in_table_depth > 0 {
122 return;
123 }
124 self.close_frame(node);
125 }
126}
127
128impl Default for MdxBodyEmitter {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl MdxBodyEmitter {
135 pub fn new() -> Self {
136 Self::new_with_options(RenderOptions::default())
137 }
138
139 pub fn new_with_options(options: RenderOptions) -> Self {
144 Self {
145 stack: vec![Frame::default()],
146 imports: Vec::new(),
147 exports: Vec::new(),
148 diag_engine: DiagnosticEngine::new(),
149 in_table_depth: 0,
150 used_intrinsic: BTreeSet::new(),
151 used_components: BTreeSet::new(),
152 options,
153 }
154 }
155
156 pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
157 let mut emitter = Self::new();
158 Walker::new(doc).walk(&mut [&mut emitter]);
159 emitter.into_parts()
160 }
161
162 pub fn render_with(doc: &Document, options: RenderOptions) -> (String, DiagnosticEngine<Code>) {
164 let mut emitter = Self::new_with_options(options);
165 Walker::new(doc).walk(&mut [&mut emitter]);
166 emitter.into_parts()
167 }
168
169 pub fn into_parts(mut self) -> (String, DiagnosticEngine<Code>) {
170 let diag = std::mem::replace(&mut self.diag_engine, DiagnosticEngine::new());
171 let body_str = self.into_string();
172 (body_str, diag)
173 }
174
175 pub fn into_string(self) -> String {
176 let MdxBodyEmitter { stack, imports, exports, used_intrinsic, used_components, .. } = self;
177 let root_parts = stack.into_iter().next().map(|f| f.parts).unwrap_or_default();
178 let (root_callee, root_kids) = jsx_callee_and_children(&root_parts);
179 let body_expr = format!("{}(Fragment, {{ children: {} }})", root_callee, root_kids);
180
181 let _ = (&imports, &exports);
185 let prelude = String::new();
186
187 let defaults = if used_intrinsic.is_empty() {
188 "...props.components".to_string()
189 } else {
190 let entries: Vec<String> = used_intrinsic.iter().map(|tag| format!("{}: \"{}\"", obj_key(tag), tag)).collect();
191 format!("{}, ...props.components", entries.join(", "))
192 };
193
194 let (component_destructure, missing_checks, missing_fn) = if used_components.is_empty() {
195 (String::new(), String::new(), String::new())
196 } else {
197 let names: Vec<String> = used_components.iter().cloned().collect();
198 let destruct = format!(" const {{ {} }} = _components;\n", names.join(", "));
199 let mut checks = String::new();
200 for name in &names {
201 checks.push_str(&format!(" if (!{name}) _missingMdxReference(\"{name}\");\n"));
202 }
203 let f = "function _missingMdxReference(name) { throw new Error(\"Component <\" + name + \"> was not provided via the MDX components prop. Register it in your component map.\"); }\n".to_string();
204 (destruct, checks, f)
205 };
206
207 format!(
210 "{prelude}const {{ Fragment, jsx, jsxs }} = arguments[0];\n{missing_fn}function _createMdxContent(props) {{\n const _components = {{ {defaults} }};\n{component_destructure}{missing_checks} return {body_expr};\n}}\nreturn {{ default: _createMdxContent }};\n",
211 )
212 }
213
214 fn diag(&mut self, code: Code, message: impl Into<String>) {
215 self.diag_engine.emit(diag!(code, message.into()));
216 }
217
218 fn open_frame(&mut self, _node: &Node) {
219 self.stack.push(Frame::default());
220 }
221
222 fn close_frame(&mut self, node: &Node) {
223 if !Self::is_container(node) {
224 return;
225 }
226 let kid_parts = self.pop_kid_parts();
227 let (callee, kids) = jsx_callee_and_children(&kid_parts);
228 let expr = match node {
229 Node::Heading(h) => {
230 let tag = format!("h{}", h.level);
231 format!("{}({}, {{ id: {}, children: {} }})", callee, self.jsx_tag_ref(&tag), Self::js_string(&h.slug()), kids,)
232 },
233 Node::Paragraph(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("p"), kids),
234 Node::Bold(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("strong"), kids),
235 Node::Italic(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("em"), kids),
236 Node::Strikethrough(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("del"), kids),
237 Node::Blockquote(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("blockquote"), kids),
238 Node::List(l) => {
239 let tag = if l.ordered { "ol" } else { "ul" };
240 format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids)
241 },
242 Node::ListItem(_) | Node::TaskListItem(_) => {
243 format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("li"), kids)
244 },
245 Node::Link(l) => {
246 let mut props = format!("href: {}", Self::js_string(&sanitize_url(&l.href)));
247 if let Some(title) = &l.title {
248 props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
249 }
250 format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids)
251 },
252 Node::JsxElement(e) => self.jsx_element_expr_with(e, callee, kids),
253 Node::JsxFragment(_) => format!("{}(Fragment, {{ children: {} }})", callee, kids),
254 _ => unreachable!("is_container guards every other variant"),
255 };
256 self.push_part(expr);
257 }
258
259 fn is_container(n: &Node) -> bool {
260 matches!(
261 n,
262 Node::Heading(_)
263 | Node::Paragraph(_)
264 | Node::Bold(_)
265 | Node::Italic(_)
266 | Node::Strikethrough(_)
267 | Node::Blockquote(_)
268 | Node::List(_)
269 | Node::ListItem(_)
270 | Node::TaskListItem(_)
271 | Node::Link(_)
272 | Node::JsxElement(_)
273 | Node::JsxFragment(_)
274 )
275 }
276
277 fn pop_kid_parts(&mut self) -> Vec<String> {
278 self.stack.pop().map(|f| f.parts).unwrap_or_default()
279 }
280
281 fn push_part(&mut self, expr: String) {
282 if let Some(frame) = self.stack.last_mut() {
283 frame.parts.push(expr);
284 }
285 }
286
287 fn code_block_expr(&mut self, cb: &CodeBlock) -> String {
288 let pre = self.jsx_tag_ref("pre");
289 let code = self.jsx_tag_ref("code");
290 match &cb.lang {
291 Some(lang) => format!(
292 "jsx({}, {{ children: jsx({}, {{ className: {}, children: {} }}) }})",
293 pre,
294 code,
295 Self::js_string(&format!("gentledmc-language-{}", lang)),
296 Self::js_string(&cb.value),
297 ),
298 None => format!("jsx({}, {{ children: jsx({}, {{ children: {} }}) }})", pre, code, Self::js_string(&cb.value),),
299 }
300 }
301
302 fn image_expr(&mut self, i: &Image) -> String {
303 format!(
304 "jsx({}, {{ src: {}, alt: {} }})",
305 self.jsx_tag_ref("img"),
306 Self::js_string(&sanitize_url(&i.src)),
307 Self::js_string(&i.alt)
308 )
309 }
310
311 fn jsx_element_expr_with(&mut self, e: &JsxElement, callee: &str, kids: String) -> String {
312 if e.name.is_empty() {
313 self.diag(Code::MalformedJsxTagName, "mdx-body: JSX element has empty name; rendered as Fragment".to_string());
314 return format!("{}(Fragment, {{ children: {} }})", callee, kids);
315 }
316 let mut props = self.jsx_props(&e.attrs);
317 if !props.is_empty() {
318 props.push_str(", ");
319 }
320 format!("{}({}, {{ {}children: {} }})", callee, self.jsx_tag_ref(&e.name), props, kids)
321 }
322
323 fn jsx_self_closing_expr(&mut self, s: &JsxSelfClosing) -> String {
324 if s.name.is_empty() {
325 self.diag(Code::MalformedJsxTagName, "mdx-body: self-closing JSX has empty name; emitted as null".to_string());
326 return "null".to_string();
327 }
328 let props = self.jsx_props(&s.attrs);
329 format!("jsx({}, {{ {} }})", self.jsx_tag_ref(&s.name), props)
330 }
331
332 fn style_attr_to_object(s: &str) -> String {
335 let mut entries = Vec::new();
336 for decl in s.split(';') {
337 let decl = decl.trim();
338 if decl.is_empty() {
339 continue;
340 }
341 let Some((raw_key, raw_val)) = decl.split_once(':') else {
342 continue;
343 };
344 let key = raw_key.trim();
345 let val = raw_val.trim();
346 if key.is_empty() {
347 continue;
348 }
349 let key_out = if key.starts_with("--") {
350 format!("\"{}\"", key)
351 } else {
352 let mut camel = String::with_capacity(key.len());
353 let mut upper = false;
354 for ch in key.chars() {
355 if ch == '-' {
356 upper = true;
357 } else if upper {
358 camel.push(ch.to_ascii_uppercase());
359 upper = false;
360 } else {
361 camel.push(ch.to_ascii_lowercase());
362 }
363 }
364 camel
365 };
366 entries.push(format!("{}: {}", key_out, Self::js_string(val)));
367 }
368 if entries.is_empty() { "{}".to_string() } else { format!("{{ {} }}", entries.join(", ")) }
369 }
370
371 fn jsx_tag_ref(&mut self, name: &str) -> String {
377 if name == "Fragment" {
378 return "Fragment".to_string();
379 }
380 let starts_upper = name.chars().next().is_some_and(|c| c.is_ascii_uppercase());
381 if starts_upper {
382 self.used_components.insert(name.to_string());
383 return name.to_string();
384 }
385 self.used_intrinsic.insert(name.to_string());
386 if is_js_ident(name) { format!("_components.{name}") } else { format!("_components[{}]", Self::js_string(name)) }
387 }
388
389 fn jsx_props(&mut self, attrs: &[JsxAttr]) -> String {
390 let mut parts = Vec::new();
391 for a in attrs {
392 let key = obj_key(&a.name);
393 if let JsxAttrValue::Spread(e) = &a.value {
394 parts.push(format!("...{}", e.trim()));
395 continue;
396 }
397 let v = match &a.value {
398 JsxAttrValue::String(s) if a.name == "style" => Self::style_attr_to_object(s),
400 JsxAttrValue::String(s) => Self::js_string(s),
401 JsxAttrValue::Expression(e) => Self::compile_attr_expression(self, e),
402 JsxAttrValue::Boolean => "true".to_string(),
403 JsxAttrValue::Spread(_) => unreachable!(),
404 };
405 parts.push(format!("{}: {}", key, v));
406 }
407 parts.join(", ")
408 }
409
410 fn compile_attr_expression(&mut self, e: &str) -> String {
413 let trimmed = e.trim();
414 if !trimmed.starts_with('<') {
415 return trimmed.to_string();
416 }
417 let nodes = dmc_parser::parse_inline_str(trimmed);
418 let pieces: Vec<String> = nodes
419 .iter()
420 .filter(|n| !matches!(n, Node::Text(t) if t.value.trim().is_empty()))
421 .map(|n| self.inline_expr(n))
422 .collect();
423 match pieces.len() {
424 0 => trimmed.to_string(),
425 1 => pieces.into_iter().next().unwrap(),
426 _ => format!("jsxs(Fragment, {{ children: [{}] }})", pieces.join(", ")),
427 }
428 }
429
430 fn table_expr(&mut self, t: &Table) -> String {
433 let mut sections: Vec<String> = Vec::new();
434 let tr = self.jsx_tag_ref("tr");
435 let thead = self.jsx_tag_ref("thead");
436 let tbody = self.jsx_tag_ref("tbody");
437 let table = self.jsx_tag_ref("table");
438
439 if let Some(header) = t.children.first() {
440 let mut head_cells: Vec<String> = Vec::with_capacity(header.cells.len());
441 for (i, cell) in header.cells.iter().enumerate() {
442 let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
443 head_cells.push(self.table_cell_expr("th", cell, align));
444 }
445 let head_row = format!("jsxs({}, {{ children: [{}] }})", tr, head_cells.join(", "));
446 sections.push(format!("jsxs({}, {{ children: [{}] }})", thead, head_row));
447 }
448
449 if t.children.len() > 1 {
450 let mut body_rows: Vec<String> = Vec::with_capacity(t.children.len() - 1);
451 for row in &t.children[1..] {
452 let mut row_cells: Vec<String> = Vec::with_capacity(row.cells.len());
453 for (i, cell) in row.cells.iter().enumerate() {
454 let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
455 row_cells.push(self.table_cell_expr("td", cell, align));
456 }
457 body_rows.push(format!("jsxs({}, {{ children: [{}] }})", tr, row_cells.join(", ")));
458 }
459 sections.push(format!("jsxs({}, {{ children: [{}] }})", tbody, body_rows.join(", ")));
460 }
461
462 format!("jsxs({}, {{ children: [{}] }})", table, sections.join(", "))
463 }
464
465 fn table_cell_expr(&mut self, tag: &str, cell: &TableCell, align: TableAlign) -> String {
466 let kids: Vec<String> = cell.children.iter().map(|n| self.inline_expr(n)).collect();
467 let kids_arr = format!("[{}]", kids.join(", "));
468 let align_str = match align {
469 TableAlign::Left => Some("left"),
470 TableAlign::Right => Some("right"),
471 TableAlign::Center => Some("center"),
472 TableAlign::None => None,
473 };
474 let tag_ref = self.jsx_tag_ref(tag);
475 match align_str {
476 Some(a) => format!("jsxs({}, {{ align: {}, children: {} }})", tag_ref, Self::js_string(a), kids_arr),
477 None => format!("jsxs({}, {{ children: {} }})", tag_ref, kids_arr),
478 }
479 }
480
481 fn inline_expr(&mut self, node: &Node) -> String {
483 match node {
484 Node::Text(t) => Self::js_string(&t.value),
485 Node::InlineCode(c) => {
486 format!("jsx({}, {{ children: {} }})", self.jsx_tag_ref("code"), Self::js_string(&c.value))
487 },
488 Node::CodeBlock(cb) => self.code_block_expr(cb),
489 Node::Image(i) => self.image_expr(i),
490 Node::HorizontalRule(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("hr")),
491 Node::HardBreak(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("br")),
492 Node::SoftBreak(_) => Self::js_string("\n"),
493 Node::JsxSelfClosing(s) => self.jsx_self_closing_expr(s),
494 Node::JsxExpression(j) => j.value.trim().to_string(),
495 Node::Bold(i) => self.wrap_jsx("strong", &i.children),
496 Node::Italic(i) => self.wrap_jsx("em", &i.children),
497 Node::Strikethrough(i) => self.wrap_jsx("del", &i.children),
498 Node::Paragraph(p) => self.wrap_jsx("p", &p.children),
499 Node::Blockquote(b) => self.wrap_jsx("blockquote", &b.children),
500 Node::List(l) => {
501 let tag = if l.ordered { "ol" } else { "ul" };
502 self.wrap_jsx(tag, &l.children)
503 },
504 Node::ListItem(li) => self.wrap_jsx("li", &li.children),
505 Node::TaskListItem(t) => self.wrap_jsx("li", &t.children),
506 Node::Heading(h) => {
507 let kids: Vec<String> = h.children.iter().map(|n| self.inline_expr(n)).collect();
508 let (callee, kids_expr) = jsx_callee_and_children(&kids);
509 let tag = format!("h{}", h.level);
510 format!(
511 "{}({}, {{ id: {}, children: {} }})",
512 callee,
513 self.jsx_tag_ref(&tag),
514 Self::js_string(&h.slug()),
515 kids_expr,
516 )
517 },
518 Node::Link(l) => {
519 let kids: Vec<String> = l.children.iter().map(|n| self.inline_expr(n)).collect();
520 let (callee, kids_expr) = jsx_callee_and_children(&kids);
521 let mut props = format!("href: {}", Self::js_string(&sanitize_url(&l.href)));
522 if let Some(title) = &l.title {
523 props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
524 }
525 format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids_expr)
526 },
527 Node::JsxElement(e) => {
528 let kids: Vec<String> = e.children.iter().map(|n| self.inline_expr(n)).collect();
529 let (callee, kids_expr) = jsx_callee_and_children(&kids);
530 self.jsx_element_expr_with(e, callee, kids_expr)
531 },
532 Node::JsxFragment(f) => {
533 let kids: Vec<String> = f.children.iter().map(|n| self.inline_expr(n)).collect();
534 let (callee, kids_expr) = jsx_callee_and_children(&kids);
535 format!("{}(Fragment, {{ children: {} }})", callee, kids_expr)
536 },
537 Node::Table(t) => self.table_expr(t),
538 Node::Html(h) => {
542 if self.options.allow_dangerous_html {
543 format!(
544 "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
545 self.jsx_tag_ref("div"),
546 Self::js_string(&h.value)
547 )
548 } else {
549 Self::js_string(&h.value)
550 }
551 },
552 Node::FootnoteRef(f) => format!(
553 "jsx({}, {{ children: jsx({}, {{ href: \"#fn-{}\", children: {} }}) }})",
554 self.jsx_tag_ref("sup"),
555 self.jsx_tag_ref("a"),
556 f.id,
557 Self::js_string(&f.id)
558 ),
559 Node::FootnoteDef(f) => self.wrap_jsx("p", &f.children),
560 Node::Frontmatter(_)
561 | Node::Import(_)
562 | Node::Export(_)
563 | Node::Document(_)
564 | Node::TableRow(_)
565 | Node::TableCell(_) => "null".to_string(),
566 }
567 }
568
569 fn wrap_jsx(&mut self, tag: &str, children: &[Node]) -> String {
570 let kids: Vec<String> = children.iter().map(|n| self.inline_expr(n)).collect();
571 let (callee, kids_expr) = jsx_callee_and_children(&kids);
572 format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids_expr)
573 }
574
575 fn js_string(s: &str) -> String {
577 let mut out = String::with_capacity(s.len() + 2);
578 out.push('"');
579 for ch in s.chars() {
580 match ch {
581 '\\' => out.push_str("\\\\"),
582 '"' => out.push_str("\\\""),
583 '\n' => out.push_str("\\n"),
584 '\r' => out.push_str("\\r"),
585 '\t' => out.push_str("\\t"),
586 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
587 c => out.push(c),
588 }
589 }
590 out.push('"');
591 out
592 }
593}
594
595pub fn render_mdx_body(doc: &Document) -> String {
596 MdxBodyEmitter::render(doc).0
597}
598
599fn jsx_callee_and_children(parts: &[String]) -> (&'static str, String) {
604 let merged = coalesce_string_literals(parts);
605 match merged.len() {
606 0 => ("jsx", "[]".into()),
607 1 => ("jsx", merged.into_iter().next().unwrap()),
608 _ => ("jsxs", format!("[{}]", merged.join(", "))),
609 }
610}
611
612fn coalesce_string_literals(parts: &[String]) -> Vec<String> {
614 let mut out: Vec<String> = Vec::with_capacity(parts.len());
615 for p in parts {
616 if is_js_string_literal(p)
617 && let Some(last) = out.last_mut()
618 && is_js_string_literal(last)
619 {
620 last.pop();
621 last.push_str(&p[1..]);
622 continue;
623 }
624 out.push(p.clone());
625 }
626 out
627}
628
629fn is_js_string_literal(s: &str) -> bool {
632 let bytes = s.as_bytes();
633 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
634 return false;
635 }
636 let mut i = 1;
637 let end = bytes.len() - 1;
638 while i < end {
639 match bytes[i] {
640 b'\\' => {
641 if i + 1 >= end {
642 return false;
643 }
644 i += 2;
645 },
646 b'"' => return false,
647 _ => i += 1,
648 }
649 }
650 true
651}
652
653fn is_js_ident(s: &str) -> bool {
655 let mut chars = s.chars();
656 match chars.next() {
657 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {},
658 _ => return false,
659 }
660 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
661}
662
663fn obj_key(key: &str) -> String {
664 if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
665}