Skip to main content

dmc_codegen/
mdx.rs

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/// Builds an MDX-runtime body - a `_createMdxContent(props)` function whose
9/// return value is a React tree of `jsx`, `jsxs`, and `Fragment`. Imports +
10/// exports hoist into a prelude; frontmatter is dropped.
11///
12/// Output shape follows `@mdx-js/mdx`'s function-body format:
13/// - `Fragment`/`jsx`/`jsxs` destructured from `arguments[0]` inside the fn
14/// - `const _components = { tag: "tag", ..., ...props.components }` merging
15///   default tag strings with consumer overrides; only intrinsics actually
16///   referenced get a default entry
17/// - capitalized JSX names destructured off `_components` and pre-validated
18///   with `_missingMdxReference` so missing components throw at render time
19/// - `jsx` for zero/one child, `jsxs` for multiple
20#[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        // Inline code: just `children`. `__dmcRaw__` is reserved for
46        // fenced `<pre>` blocks (see PrettyCode transformer) - putting it
47        // on inline `<code>` makes consumer mappings that key off it
48        // misclassify inline as block, breaking paragraph flow.
49        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      // Raw HTML node: emit via `dangerouslySetInnerHTML` (matches the
75      // inline-expr path). Without this explicit arm `Node::Html` would
76      // fall into the `_ => open_frame` default below, but `is_container`
77      // returns false for it -- so `leave`'s `close_frame` would bail
78      // out without popping, leaking the frame and silently dropping
79      // every sibling and ancestor expression that follows. The
80      // production symptom: an `<AccordionContent>` whose body has an
81      // inline `<code className="...">x</code>` (parsed as raw HTML
82      // span) dropped the entire enclosing `<Accordion>` from the
83      // emitted MDX body.
84      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  /// Drive the walker; return `(body, diag)`.
139  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  /// Take both buffers: rendered MDX body and per-emitter diagnostics.
146  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    // Function-body output (the only mode dmc emits today) is consumed
159    // via `new Function(body)(runtime)` - that scope cannot legally
160    // contain `import`/`export` statements. dmc parses top-level ESM
161    // anyway because the lexer can't always tell content inside JSX-
162    // wrapped fences from real top-level imports, so we drop them on
163    // the floor here. Consumers that need real ESM bindings should
164    // declare them outside MDX (e.g. in the components map).
165    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    // Pull `Fragment`/`jsx`/`jsxs` from the factory's `arguments[0]`
189    // (the jsx-runtime passed in by the consumer) at module scope so
190    // `_createMdxContent` closes over them. Putting the destructure
191    // inside the function would shadow it with React's `props` once the
192    // returned default export is rendered.
193    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  /// Convert a CSS-style attribute string (`"color:#fff;background-color:red"`)
317  /// into a JSX-ready object literal (`{ color: "#fff", backgroundColor: "red" }`).
318  /// `--custom` properties stay quoted; everything else is camel-cased.
319  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  /// Resolve a JSX tag name to the runtime expression and record the ref
357  /// for the prelude.
358  ///
359  /// - Lowercase tag -> `_components.<tag>`, with the tag's default string
360  ///   added to the `_components` literal at assemble time.
361  /// - Capitalized tag -> bare local binding (destructured in the prelude
362  ///   from `_components` and validated via `_missingMdxReference`).
363  /// - `Fragment` -> the jsx-runtime symbol already in scope.
364  /// - Non-identifier tag (`my-element`) -> bracket access on `_components`.
365  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      // Spread attributes have no key/value -- emit `...expr` directly
383      // and skip the standard key/value path.
384      if let JsxAttrValue::Spread(e) = &a.value {
385        parts.push(format!("...{}", e.trim()));
386        continue;
387      }
388      let v = match &a.value {
389        // React rejects `style="..."` strings -- must be an object literal.
390        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  /// Compile a `{...}` JSX attribute expression to JS.
402  ///
403  /// `<Callout icon={<Zap />}>` captures the inside-of-braces as raw text.
404  /// Plain JS (`{count + 1}`) passes through unchanged. JSX content
405  /// (`{<Zap />}`) is re-parsed and routed through `inline_expr` so it
406  /// becomes a valid runtime expression.
407  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  /// Build the full `jsxs(table, { children: [thead, tbody] })` expr.
426  /// Cell content is walked recursively here because rows/cells aren't
427  /// surfaced as walker `Node` variants; `in_table_depth` suppresses the
428  /// outer walker's events while we're inside.
429  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  /// Self-recursive expression builder for cell content (the walker is
479  /// suppressed inside tables via `in_table_depth`).
480  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      // Raw HTML block: passed through verbatim via dangerouslySetInnerHTML
537      // so the renderer can emit it without JSX-encoding.
538      Node::Html(h) => format!(
539        "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
540        self.jsx_tag_ref("div"),
541        Self::js_string(&h.value)
542      ),
543      // GFM footnotes: emit a superscript link to the def section. The
544      // def itself renders as a list-item paragraph.
545      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  /// Quote `s` as a JS string literal. Handles `\`, `"`, `\n`, `\r`, `\t`,
569  /// and any control char below 0x20 via `\uXXXX`.
570  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
589/// Render `doc` to an MDX body string with a throwaway diagnostic engine.
590pub fn render_mdx_body(doc: &Document) -> String {
591  MdxBodyEmitter::render(doc).0
592}
593
594/// Pick the right jsx-runtime callee for a child list, mirroring
595/// `@mdx-js/mdx`: zero/one child -> `jsx` with the child unwrapped (no
596/// array); multiple children -> `jsxs` with the `[a, b, c]` literal.
597fn 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
605/// True when `s` is a bare JS identifier (safe to emit unquoted as a
606/// member access or object-literal key).
607fn 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
616/// Quote `key` for an object-literal key: bare ident when it's a valid
617/// JS identifier, JSON string otherwise.
618fn obj_key(key: &str) -> String {
619  if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
620}