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::Table(t) => {
75 let expr = self.table_expr(t);
76 self.push_part(expr);
77 self.in_table_depth += 1;
78 },
79
80 Node::Frontmatter(_) => {},
81 Node::Import(i) => self.imports.push(i.raw.trim_end().to_string()),
82 Node::Export(x) => self.exports.push(x.raw.trim_end().to_string()),
83
84 _ => self.open_frame(node),
85 }
86 }
87
88 fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
89 if let Node::Table(_) = node {
90 self.in_table_depth = self.in_table_depth.saturating_sub(1);
91 return;
92 }
93 if self.in_table_depth > 0 {
94 return;
95 }
96 self.close_frame(node);
97 }
98}
99
100impl Default for MdxBodyEmitter {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl MdxBodyEmitter {
107 pub fn new() -> Self {
108 Self {
109 stack: vec![Frame::default()],
110 imports: Vec::new(),
111 exports: Vec::new(),
112 diag_engine: DiagnosticEngine::new(),
113 in_table_depth: 0,
114 used_intrinsic: BTreeSet::new(),
115 used_components: BTreeSet::new(),
116 }
117 }
118
119 pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
121 let mut emitter = Self::new();
122 Walker::new(doc).walk(&mut [&mut emitter]);
123 emitter.into_parts()
124 }
125
126 pub fn into_parts(mut self) -> (String, DiagnosticEngine<Code>) {
128 let diag = std::mem::replace(&mut self.diag_engine, DiagnosticEngine::new());
129 let body_str = self.into_string();
130 (body_str, diag)
131 }
132
133 pub fn into_string(self) -> String {
134 let MdxBodyEmitter { stack, imports, exports, used_intrinsic, used_components, .. } = self;
135 let root_parts = stack.into_iter().next().map(|f| f.parts).unwrap_or_default();
136 let (root_callee, root_kids) = jsx_callee_and_children(&root_parts);
137 let body_expr = format!("{}(Fragment, {{ children: {} }})", root_callee, root_kids);
138
139 let _ = (&imports, &exports);
147 let prelude = String::new();
148
149 let defaults = if used_intrinsic.is_empty() {
150 "...props.components".to_string()
151 } else {
152 let entries: Vec<String> = used_intrinsic.iter().map(|tag| format!("{}: \"{}\"", obj_key(tag), tag)).collect();
153 format!("{}, ...props.components", entries.join(", "))
154 };
155
156 let (component_destructure, missing_checks, missing_fn) = if used_components.is_empty() {
157 (String::new(), String::new(), String::new())
158 } else {
159 let names: Vec<String> = used_components.iter().cloned().collect();
160 let destruct = format!(" const {{ {} }} = _components;\n", names.join(", "));
161 let mut checks = String::new();
162 for name in &names {
163 checks.push_str(&format!(" if (!{name}) _missingMdxReference(\"{name}\");\n"));
164 }
165 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();
166 (destruct, checks, f)
167 };
168
169 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 {
301 let mut entries = Vec::new();
302 for decl in s.split(';') {
303 let decl = decl.trim();
304 if decl.is_empty() {
305 continue;
306 }
307 let Some((raw_key, raw_val)) = decl.split_once(':') else {
308 continue;
309 };
310 let key = raw_key.trim();
311 let val = raw_val.trim();
312 if key.is_empty() {
313 continue;
314 }
315 let key_out = if key.starts_with("--") {
316 format!("\"{}\"", key)
317 } else {
318 let mut camel = String::with_capacity(key.len());
319 let mut upper = false;
320 for ch in key.chars() {
321 if ch == '-' {
322 upper = true;
323 } else if upper {
324 camel.push(ch.to_ascii_uppercase());
325 upper = false;
326 } else {
327 camel.push(ch.to_ascii_lowercase());
328 }
329 }
330 camel
331 };
332 entries.push(format!("{}: {}", key_out, Self::js_string(val)));
333 }
334 if entries.is_empty() { "{}".to_string() } else { format!("{{ {} }}", entries.join(", ")) }
335 }
336
337 fn jsx_tag_ref(&mut self, name: &str) -> String {
347 if name == "Fragment" {
348 return "Fragment".to_string();
349 }
350 let starts_upper = name.chars().next().is_some_and(|c| c.is_ascii_uppercase());
351 if starts_upper {
352 self.used_components.insert(name.to_string());
353 return name.to_string();
354 }
355 self.used_intrinsic.insert(name.to_string());
356 if is_js_ident(name) { format!("_components.{name}") } else { format!("_components[{}]", Self::js_string(name)) }
357 }
358
359 fn jsx_props(&mut self, attrs: &[JsxAttr]) -> String {
360 let mut parts = Vec::new();
361 for a in attrs {
362 let key = obj_key(&a.name);
363 if let JsxAttrValue::Spread(e) = &a.value {
366 parts.push(format!("...{}", e.trim()));
367 continue;
368 }
369 let v = match &a.value {
370 JsxAttrValue::String(s) if a.name == "style" => Self::style_attr_to_object(s),
372 JsxAttrValue::String(s) => Self::js_string(s),
373 JsxAttrValue::Expression(e) => Self::compile_attr_expression(self, e),
374 JsxAttrValue::Boolean => "true".to_string(),
375 JsxAttrValue::Spread(_) => unreachable!(),
376 };
377 parts.push(format!("{}: {}", key, v));
378 }
379 parts.join(", ")
380 }
381
382 fn compile_attr_expression(&mut self, e: &str) -> String {
389 let trimmed = e.trim();
390 if !trimmed.starts_with('<') {
391 return trimmed.to_string();
392 }
393 let nodes = dmc_parser::parse_inline_str(trimmed);
394 let pieces: Vec<String> = nodes
395 .iter()
396 .filter(|n| !matches!(n, Node::Text(t) if t.value.trim().is_empty()))
397 .map(|n| self.inline_expr(n))
398 .collect();
399 match pieces.len() {
400 0 => trimmed.to_string(),
401 1 => pieces.into_iter().next().unwrap(),
402 _ => format!("jsxs(Fragment, {{ children: [{}] }})", pieces.join(", ")),
403 }
404 }
405
406 fn table_expr(&mut self, t: &Table) -> String {
411 let mut sections: Vec<String> = Vec::new();
412 let tr = self.jsx_tag_ref("tr");
413 let thead = self.jsx_tag_ref("thead");
414 let tbody = self.jsx_tag_ref("tbody");
415 let table = self.jsx_tag_ref("table");
416
417 if let Some(header) = t.children.first() {
418 let mut head_cells: Vec<String> = Vec::with_capacity(header.cells.len());
419 for (i, cell) in header.cells.iter().enumerate() {
420 let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
421 head_cells.push(self.table_cell_expr("th", cell, align));
422 }
423 let head_row = format!("jsxs({}, {{ children: [{}] }})", tr, head_cells.join(", "));
424 sections.push(format!("jsxs({}, {{ children: [{}] }})", thead, head_row));
425 }
426
427 if t.children.len() > 1 {
428 let mut body_rows: Vec<String> = Vec::with_capacity(t.children.len() - 1);
429 for row in &t.children[1..] {
430 let mut row_cells: Vec<String> = Vec::with_capacity(row.cells.len());
431 for (i, cell) in row.cells.iter().enumerate() {
432 let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
433 row_cells.push(self.table_cell_expr("td", cell, align));
434 }
435 body_rows.push(format!("jsxs({}, {{ children: [{}] }})", tr, row_cells.join(", ")));
436 }
437 sections.push(format!("jsxs({}, {{ children: [{}] }})", tbody, body_rows.join(", ")));
438 }
439
440 format!("jsxs({}, {{ children: [{}] }})", table, sections.join(", "))
441 }
442
443 fn table_cell_expr(&mut self, tag: &str, cell: &TableCell, align: TableAlign) -> String {
444 let kids: Vec<String> = cell.children.iter().map(|n| self.inline_expr(n)).collect();
445 let kids_arr = format!("[{}]", kids.join(", "));
446 let align_str = match align {
447 TableAlign::Left => Some("left"),
448 TableAlign::Right => Some("right"),
449 TableAlign::Center => Some("center"),
450 TableAlign::None => None,
451 };
452 let tag_ref = self.jsx_tag_ref(tag);
453 match align_str {
454 Some(a) => format!("jsxs({}, {{ align: {}, children: {} }})", tag_ref, Self::js_string(a), kids_arr),
455 None => format!("jsxs({}, {{ children: {} }})", tag_ref, kids_arr),
456 }
457 }
458
459 fn inline_expr(&mut self, node: &Node) -> String {
462 match node {
463 Node::Text(t) => Self::js_string(&t.value),
464 Node::InlineCode(c) => {
465 format!("jsx({}, {{ children: {} }})", self.jsx_tag_ref("code"), Self::js_string(&c.value))
466 },
467 Node::CodeBlock(cb) => self.code_block_expr(cb),
468 Node::Image(i) => self.image_expr(i),
469 Node::HorizontalRule(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("hr")),
470 Node::HardBreak(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("br")),
471 Node::SoftBreak(_) => Self::js_string("\n"),
472 Node::JsxSelfClosing(s) => self.jsx_self_closing_expr(s),
473 Node::JsxExpression(j) => j.value.trim().to_string(),
474 Node::Bold(i) => self.wrap_jsx("strong", &i.children),
475 Node::Italic(i) => self.wrap_jsx("em", &i.children),
476 Node::Strikethrough(i) => self.wrap_jsx("del", &i.children),
477 Node::Paragraph(p) => self.wrap_jsx("p", &p.children),
478 Node::Blockquote(b) => self.wrap_jsx("blockquote", &b.children),
479 Node::List(l) => {
480 let tag = if l.ordered { "ol" } else { "ul" };
481 self.wrap_jsx(tag, &l.children)
482 },
483 Node::ListItem(li) => self.wrap_jsx("li", &li.children),
484 Node::TaskListItem(t) => self.wrap_jsx("li", &t.children),
485 Node::Heading(h) => {
486 let kids: Vec<String> = h.children.iter().map(|n| self.inline_expr(n)).collect();
487 let (callee, kids_expr) = jsx_callee_and_children(&kids);
488 let tag = format!("h{}", h.level);
489 format!(
490 "{}({}, {{ id: {}, children: {} }})",
491 callee,
492 self.jsx_tag_ref(&tag),
493 Self::js_string(&h.slug()),
494 kids_expr,
495 )
496 },
497 Node::Link(l) => {
498 let kids: Vec<String> = l.children.iter().map(|n| self.inline_expr(n)).collect();
499 let (callee, kids_expr) = jsx_callee_and_children(&kids);
500 let mut props = format!("href: {}", Self::js_string(&l.href));
501 if let Some(title) = &l.title {
502 props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
503 }
504 format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids_expr)
505 },
506 Node::JsxElement(e) => {
507 let kids: Vec<String> = e.children.iter().map(|n| self.inline_expr(n)).collect();
508 let (callee, kids_expr) = jsx_callee_and_children(&kids);
509 self.jsx_element_expr_with(e, callee, kids_expr)
510 },
511 Node::JsxFragment(f) => {
512 let kids: Vec<String> = f.children.iter().map(|n| self.inline_expr(n)).collect();
513 let (callee, kids_expr) = jsx_callee_and_children(&kids);
514 format!("{}(Fragment, {{ children: {} }})", callee, kids_expr)
515 },
516 Node::Table(t) => self.table_expr(t),
517 Node::Html(h) => format!(
520 "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
521 self.jsx_tag_ref("div"),
522 Self::js_string(&h.value)
523 ),
524 Node::FootnoteRef(f) => format!(
527 "jsx({}, {{ children: jsx({}, {{ href: \"#fn-{}\", children: {} }}) }})",
528 self.jsx_tag_ref("sup"),
529 self.jsx_tag_ref("a"),
530 f.id,
531 Self::js_string(&f.id)
532 ),
533 Node::FootnoteDef(f) => self.wrap_jsx("p", &f.children),
534 Node::Frontmatter(_)
535 | Node::Import(_)
536 | Node::Export(_)
537 | Node::Document(_)
538 | Node::TableRow(_)
539 | Node::TableCell(_) => "null".to_string(),
540 }
541 }
542
543 fn wrap_jsx(&mut self, tag: &str, children: &[Node]) -> String {
544 let kids: Vec<String> = children.iter().map(|n| self.inline_expr(n)).collect();
545 let (callee, kids_expr) = jsx_callee_and_children(&kids);
546 format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids_expr)
547 }
548
549 fn js_string(s: &str) -> String {
552 let mut out = String::with_capacity(s.len() + 2);
553 out.push('"');
554 for ch in s.chars() {
555 match ch {
556 '\\' => out.push_str("\\\\"),
557 '"' => out.push_str("\\\""),
558 '\n' => out.push_str("\\n"),
559 '\r' => out.push_str("\\r"),
560 '\t' => out.push_str("\\t"),
561 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
562 c => out.push(c),
563 }
564 }
565 out.push('"');
566 out
567 }
568}
569
570pub fn render_mdx_body(doc: &Document) -> String {
572 MdxBodyEmitter::render(doc).0
573}
574
575fn jsx_callee_and_children(parts: &[String]) -> (&'static str, String) {
579 match parts.len() {
580 0 => ("jsx", "[]".into()),
581 1 => ("jsx", parts[0].clone()),
582 _ => ("jsxs", format!("[{}]", parts.join(", "))),
583 }
584}
585
586fn is_js_ident(s: &str) -> bool {
589 let mut chars = s.chars();
590 match chars.next() {
591 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {},
592 _ => return false,
593 }
594 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
595}
596
597fn obj_key(key: &str) -> String {
600 if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
601}