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