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