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      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  /// Drive the walker; return `(body, diag)`.
120  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  /// Take both buffers: rendered MDX body and per-emitter diagnostics.
127  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    // Function-body output (the only mode dmc emits today) is consumed
140    // via `new Function(body)(runtime)` - that scope cannot legally
141    // contain `import`/`export` statements. dmc parses top-level ESM
142    // anyway because the lexer can't always tell content inside JSX-
143    // wrapped fences from real top-level imports, so we drop them on
144    // the floor here. Consumers that need real ESM bindings should
145    // declare them outside MDX (e.g. in the components map).
146    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    // Pull `Fragment`/`jsx`/`jsxs` from the factory's `arguments[0]`
170    // (the jsx-runtime passed in by the consumer) at module scope so
171    // `_createMdxContent` closes over them. Putting the destructure
172    // inside the function would shadow it with React's `props` once the
173    // returned default export is rendered.
174    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  /// Convert a CSS-style attribute string (`"color:#fff;background-color:red"`)
298  /// into a JSX-ready object literal (`{ color: "#fff", backgroundColor: "red" }`).
299  /// `--custom` properties stay quoted; everything else is camel-cased.
300  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  /// Resolve a JSX tag name to the runtime expression and record the ref
338  /// for the prelude.
339  ///
340  /// - Lowercase tag -> `_components.<tag>`, with the tag's default string
341  ///   added to the `_components` literal at assemble time.
342  /// - Capitalized tag -> bare local binding (destructured in the prelude
343  ///   from `_components` and validated via `_missingMdxReference`).
344  /// - `Fragment` -> the jsx-runtime symbol already in scope.
345  /// - Non-identifier tag (`my-element`) -> bracket access on `_components`.
346  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      // Spread attributes have no key/value -- emit `...expr` directly
364      // and skip the standard key/value path.
365      if let JsxAttrValue::Spread(e) = &a.value {
366        parts.push(format!("...{}", e.trim()));
367        continue;
368      }
369      let v = match &a.value {
370        // React rejects `style="..."` strings -- must be an object literal.
371        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  /// Compile a `{...}` JSX attribute expression to JS.
383  ///
384  /// `<Callout icon={<Zap />}>` captures the inside-of-braces as raw text.
385  /// Plain JS (`{count + 1}`) passes through unchanged. JSX content
386  /// (`{<Zap />}`) is re-parsed and routed through `inline_expr` so it
387  /// becomes a valid runtime expression.
388  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  /// Build the full `jsxs(table, { children: [thead, tbody] })` expr.
407  /// Cell content is walked recursively here because rows/cells aren't
408  /// surfaced as walker `Node` variants; `in_table_depth` suppresses the
409  /// outer walker's events while we're inside.
410  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  /// Self-recursive expression builder for cell content (the walker is
460  /// suppressed inside tables via `in_table_depth`).
461  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      // Raw HTML block: passed through verbatim via dangerouslySetInnerHTML
518      // so the renderer can emit it without JSX-encoding.
519      Node::Html(h) => format!(
520        "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
521        self.jsx_tag_ref("div"),
522        Self::js_string(&h.value)
523      ),
524      // GFM footnotes: emit a superscript link to the def section. The
525      // def itself renders as a list-item paragraph.
526      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  /// Quote `s` as a JS string literal. Handles `\`, `"`, `\n`, `\r`, `\t`,
550  /// and any control char below 0x20 via `\uXXXX`.
551  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
570/// Render `doc` to an MDX body string with a throwaway diagnostic engine.
571pub fn render_mdx_body(doc: &Document) -> String {
572  MdxBodyEmitter::render(doc).0
573}
574
575/// Pick the right jsx-runtime callee for a child list, mirroring
576/// `@mdx-js/mdx`: zero/one child -> `jsx` with the child unwrapped (no
577/// array); multiple children -> `jsxs` with the `[a, b, c]` literal.
578fn 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
586/// True when `s` is a bare JS identifier (safe to emit unquoted as a
587/// member access or object-literal key).
588fn 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
597/// Quote `key` for an object-literal key: bare ident when it's a valid
598/// JS identifier, JSON string otherwise.
599fn obj_key(key: &str) -> String {
600  if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
601}