Skip to main content

dmc_codegen/
mdx.rs

1use std::collections::BTreeSet;
2
3use crate::{NodeSink, RenderOptions, WalkCtx, Walker, escape::sanitize_url};
4use dmc_diagnostic::Code;
5use dmc_parser::ast::*;
6use duck_diagnostic::{DiagnosticEngine, diag};
7
8/// Builds an MDX-runtime body: `_createMdxContent(props)` returning a tree
9/// of `jsx`/`jsxs`/`Fragment`. Output shape follows `@mdx-js/mdx`'s
10/// function-body format:
11/// - `Fragment`/`jsx`/`jsxs` from `arguments[0]`
12/// - `_components = { tag: "tag", ..., ...props.components }` -- only
13///   referenced intrinsics get a default entry
14/// - capitalized JSX names destructured off `_components` and validated
15///   via `_missingMdxReference`
16/// - `jsx` for zero/one child, `jsxs` for multiple
17#[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  /// SEC-010: gates raw-HTML passthrough. When `false` (the default),
27  /// `Node::Html` arms do NOT emit `dangerouslySetInnerHTML` — inline
28  /// raw HTML is escaped to visible text and block-level raw HTML is
29  /// omitted, mirroring the `HtmlEmitter` safe mode. Set via
30  /// [`MdxBodyEmitter::new_with_options`] to opt back into verbatim
31  /// passthrough (the caller then owns the XSS risk).
32  options: RenderOptions,
33}
34
35#[derive(Default, Debug)]
36struct Frame {
37  parts: Vec<String>,
38}
39
40impl NodeSink for MdxBodyEmitter {
41  fn enter(&mut self, node: &Node, ctx: &WalkCtx) {
42    if self.in_table_depth > 0 {
43      return;
44    }
45    match node {
46      Node::Text(t) => self.push_part(Self::js_string(&t.value)),
47      Node::InlineCode(c) => {
48        let tag = self.jsx_tag_ref("code");
49        // No `__dmcRaw__` here -- that flag is reserved for fenced `<pre>`
50        // blocks (PrettyCode); putting it on inline `<code>` misclassifies
51        // it as block in consumer mappings.
52        self.push_part(format!("jsx({}, {{ children: {} }})", tag, Self::js_string(&c.value),));
53      },
54      Node::CodeBlock(cb) => {
55        let s = self.code_block_expr(cb);
56        self.push_part(s);
57      },
58      Node::Image(i) => {
59        let s = self.image_expr(i);
60        self.push_part(s);
61      },
62      Node::HorizontalRule(_) => {
63        let tag = self.jsx_tag_ref("hr");
64        self.push_part(format!("jsx({}, {{}})", tag));
65      },
66      Node::HardBreak(_) => {
67        let tag = self.jsx_tag_ref("br");
68        self.push_part(format!("jsx({}, {{}})", tag));
69      },
70      Node::SoftBreak(_) => self.push_part(Self::js_string("\n")),
71      Node::JsxSelfClosing(s) => {
72        let expr = self.jsx_self_closing_expr(s);
73        self.push_part(expr);
74      },
75      Node::JsxExpression(j) => self.push_part(j.value.trim().to_string()),
76
77      // Must be an explicit arm: `_ => open_frame` would push a frame
78      // but `is_container` returns false for `Html`, so `close_frame`
79      // would bail without popping -- silently dropping every sibling
80      // and ancestor expression that follows.
81      Node::Html(h) => {
82        // SEC-010: raw-HTML passthrough is gated behind
83        // `allow_dangerous_html`. In safe mode (default), inline raw HTML
84        // is escaped to visible text and block-level raw HTML is omitted
85        // — never emitted as a live `dangerouslySetInnerHTML`.
86        if !self.options.allow_dangerous_html {
87          let inline_context = matches!(ctx.parent, Some(Node::Paragraph(_)) | Some(Node::Heading(_)));
88          if inline_context {
89            self.push_part(Self::js_string(&h.value));
90          }
91          // Block-level raw HTML: omitted entirely in safe mode.
92          return;
93        }
94        let tag = self.jsx_tag_ref("div");
95        self.push_part(format!(
96          "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
97          tag,
98          Self::js_string(&h.value)
99        ));
100      },
101
102      Node::Table(t) => {
103        let expr = self.table_expr(t);
104        self.push_part(expr);
105        self.in_table_depth += 1;
106      },
107
108      Node::Frontmatter(_) => {},
109      Node::Import(i) => self.imports.push(i.raw.trim_end().to_string()),
110      Node::Export(x) => self.exports.push(x.raw.trim_end().to_string()),
111
112      _ => self.open_frame(node),
113    }
114  }
115
116  fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
117    if let Node::Table(_) = node {
118      self.in_table_depth = self.in_table_depth.saturating_sub(1);
119      return;
120    }
121    if self.in_table_depth > 0 {
122      return;
123    }
124    self.close_frame(node);
125  }
126}
127
128impl Default for MdxBodyEmitter {
129  fn default() -> Self {
130    Self::new()
131  }
132}
133
134impl MdxBodyEmitter {
135  pub fn new() -> Self {
136    Self::new_with_options(RenderOptions::default())
137  }
138
139  /// Construct with explicit [`RenderOptions`]. The only field
140  /// `MdxBodyEmitter` honors is `allow_dangerous_html`; when `false`
141  /// (the default via [`MdxBodyEmitter::new`]) raw HTML is rendered as
142  /// safe text rather than `dangerouslySetInnerHTML`.
143  pub fn new_with_options(options: RenderOptions) -> Self {
144    Self {
145      stack: vec![Frame::default()],
146      imports: Vec::new(),
147      exports: Vec::new(),
148      diag_engine: DiagnosticEngine::new(),
149      in_table_depth: 0,
150      used_intrinsic: BTreeSet::new(),
151      used_components: BTreeSet::new(),
152      options,
153    }
154  }
155
156  pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
157    let mut emitter = Self::new();
158    Walker::new(doc).walk(&mut [&mut emitter]);
159    emitter.into_parts()
160  }
161
162  /// Drive the walker with explicit [`RenderOptions`].
163  pub fn render_with(doc: &Document, options: RenderOptions) -> (String, DiagnosticEngine<Code>) {
164    let mut emitter = Self::new_with_options(options);
165    Walker::new(doc).walk(&mut [&mut emitter]);
166    emitter.into_parts()
167  }
168
169  pub fn into_parts(mut self) -> (String, DiagnosticEngine<Code>) {
170    let diag = std::mem::replace(&mut self.diag_engine, DiagnosticEngine::new());
171    let body_str = self.into_string();
172    (body_str, diag)
173  }
174
175  pub fn into_string(self) -> String {
176    let MdxBodyEmitter { stack, imports, exports, used_intrinsic, used_components, .. } = self;
177    let root_parts = stack.into_iter().next().map(|f| f.parts).unwrap_or_default();
178    let (root_callee, root_kids) = jsx_callee_and_children(&root_parts);
179    let body_expr = format!("{}(Fragment, {{ children: {} }})", root_callee, root_kids);
180
181    // Function-body output (consumed via `new Function(body)(runtime)`)
182    // can't legally contain `import`/`export`. Drop them; consumers that
183    // need ESM bindings declare them outside MDX (e.g. components map).
184    let _ = (&imports, &exports);
185    let prelude = String::new();
186
187    let defaults = if used_intrinsic.is_empty() {
188      "...props.components".to_string()
189    } else {
190      let entries: Vec<String> = used_intrinsic.iter().map(|tag| format!("{}: \"{}\"", obj_key(tag), tag)).collect();
191      format!("{}, ...props.components", entries.join(", "))
192    };
193
194    let (component_destructure, missing_checks, missing_fn) = if used_components.is_empty() {
195      (String::new(), String::new(), String::new())
196    } else {
197      let names: Vec<String> = used_components.iter().cloned().collect();
198      let destruct = format!("  const {{ {} }} = _components;\n", names.join(", "));
199      let mut checks = String::new();
200      for name in &names {
201        checks.push_str(&format!("  if (!{name}) _missingMdxReference(\"{name}\");\n"));
202      }
203      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();
204      (destruct, checks, f)
205    };
206
207    // Destructure runtime at module scope so `_createMdxContent` closes
208    // over it; inside the fn it would be shadowed by React's `props`.
209    format!(
210      "{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",
211    )
212  }
213
214  fn diag(&mut self, code: Code, message: impl Into<String>) {
215    self.diag_engine.emit(diag!(code, message.into()));
216  }
217
218  fn open_frame(&mut self, _node: &Node) {
219    self.stack.push(Frame::default());
220  }
221
222  fn close_frame(&mut self, node: &Node) {
223    if !Self::is_container(node) {
224      return;
225    }
226    let kid_parts = self.pop_kid_parts();
227    let (callee, kids) = jsx_callee_and_children(&kid_parts);
228    let expr = match node {
229      Node::Heading(h) => {
230        let tag = format!("h{}", h.level);
231        format!("{}({}, {{ id: {}, children: {} }})", callee, self.jsx_tag_ref(&tag), Self::js_string(&h.slug()), kids,)
232      },
233      Node::Paragraph(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("p"), kids),
234      Node::Bold(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("strong"), kids),
235      Node::Italic(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("em"), kids),
236      Node::Strikethrough(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("del"), kids),
237      Node::Blockquote(_) => format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("blockquote"), kids),
238      Node::List(l) => {
239        let tag = if l.ordered { "ol" } else { "ul" };
240        format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids)
241      },
242      Node::ListItem(_) | Node::TaskListItem(_) => {
243        format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref("li"), kids)
244      },
245      Node::Link(l) => {
246        let mut props = format!("href: {}", Self::js_string(&sanitize_url(&l.href)));
247        if let Some(title) = &l.title {
248          props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
249        }
250        format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids)
251      },
252      Node::JsxElement(e) => self.jsx_element_expr_with(e, callee, kids),
253      Node::JsxFragment(_) => format!("{}(Fragment, {{ children: {} }})", callee, kids),
254      _ => unreachable!("is_container guards every other variant"),
255    };
256    self.push_part(expr);
257  }
258
259  fn is_container(n: &Node) -> bool {
260    matches!(
261      n,
262      Node::Heading(_)
263        | Node::Paragraph(_)
264        | Node::Bold(_)
265        | Node::Italic(_)
266        | Node::Strikethrough(_)
267        | Node::Blockquote(_)
268        | Node::List(_)
269        | Node::ListItem(_)
270        | Node::TaskListItem(_)
271        | Node::Link(_)
272        | Node::JsxElement(_)
273        | Node::JsxFragment(_)
274    )
275  }
276
277  fn pop_kid_parts(&mut self) -> Vec<String> {
278    self.stack.pop().map(|f| f.parts).unwrap_or_default()
279  }
280
281  fn push_part(&mut self, expr: String) {
282    if let Some(frame) = self.stack.last_mut() {
283      frame.parts.push(expr);
284    }
285  }
286
287  fn code_block_expr(&mut self, cb: &CodeBlock) -> String {
288    let pre = self.jsx_tag_ref("pre");
289    let code = self.jsx_tag_ref("code");
290    match &cb.lang {
291      Some(lang) => format!(
292        "jsx({}, {{ children: jsx({}, {{ className: {}, children: {} }}) }})",
293        pre,
294        code,
295        Self::js_string(&format!("gentledmc-language-{}", lang)),
296        Self::js_string(&cb.value),
297      ),
298      None => format!("jsx({}, {{ children: jsx({}, {{ children: {} }}) }})", pre, code, Self::js_string(&cb.value),),
299    }
300  }
301
302  fn image_expr(&mut self, i: &Image) -> String {
303    format!(
304      "jsx({}, {{ src: {}, alt: {} }})",
305      self.jsx_tag_ref("img"),
306      Self::js_string(&sanitize_url(&i.src)),
307      Self::js_string(&i.alt)
308    )
309  }
310
311  fn jsx_element_expr_with(&mut self, e: &JsxElement, callee: &str, kids: String) -> String {
312    if e.name.is_empty() {
313      self.diag(Code::MalformedJsxTagName, "mdx-body: JSX element has empty name; rendered as Fragment".to_string());
314      return format!("{}(Fragment, {{ children: {} }})", callee, kids);
315    }
316    let mut props = self.jsx_props(&e.attrs);
317    if !props.is_empty() {
318      props.push_str(", ");
319    }
320    format!("{}({}, {{ {}children: {} }})", callee, self.jsx_tag_ref(&e.name), props, kids)
321  }
322
323  fn jsx_self_closing_expr(&mut self, s: &JsxSelfClosing) -> String {
324    if s.name.is_empty() {
325      self.diag(Code::MalformedJsxTagName, "mdx-body: self-closing JSX has empty name; emitted as null".to_string());
326      return "null".to_string();
327    }
328    let props = self.jsx_props(&s.attrs);
329    format!("jsx({}, {{ {} }})", self.jsx_tag_ref(&s.name), props)
330  }
331
332  /// Convert a CSS-style attribute string to a JSX object literal.
333  /// `--custom` properties stay quoted; everything else is camel-cased.
334  fn style_attr_to_object(s: &str) -> String {
335    let mut entries = Vec::new();
336    for decl in s.split(';') {
337      let decl = decl.trim();
338      if decl.is_empty() {
339        continue;
340      }
341      let Some((raw_key, raw_val)) = decl.split_once(':') else {
342        continue;
343      };
344      let key = raw_key.trim();
345      let val = raw_val.trim();
346      if key.is_empty() {
347        continue;
348      }
349      let key_out = if key.starts_with("--") {
350        format!("\"{}\"", key)
351      } else {
352        let mut camel = String::with_capacity(key.len());
353        let mut upper = false;
354        for ch in key.chars() {
355          if ch == '-' {
356            upper = true;
357          } else if upper {
358            camel.push(ch.to_ascii_uppercase());
359            upper = false;
360          } else {
361            camel.push(ch.to_ascii_lowercase());
362          }
363        }
364        camel
365      };
366      entries.push(format!("{}: {}", key_out, Self::js_string(val)));
367    }
368    if entries.is_empty() { "{}".to_string() } else { format!("{{ {} }}", entries.join(", ")) }
369  }
370
371  /// Resolve a JSX tag name and record the ref for the prelude.
372  /// - lowercase -> `_components.<tag>`
373  /// - capitalized -> bare local binding (destructured in prelude)
374  /// - `Fragment` -> in-scope runtime symbol
375  /// - non-ident (`my-element`) -> `_components[...]`
376  fn jsx_tag_ref(&mut self, name: &str) -> String {
377    if name == "Fragment" {
378      return "Fragment".to_string();
379    }
380    let starts_upper = name.chars().next().is_some_and(|c| c.is_ascii_uppercase());
381    if starts_upper {
382      self.used_components.insert(name.to_string());
383      return name.to_string();
384    }
385    self.used_intrinsic.insert(name.to_string());
386    if is_js_ident(name) { format!("_components.{name}") } else { format!("_components[{}]", Self::js_string(name)) }
387  }
388
389  fn jsx_props(&mut self, attrs: &[JsxAttr]) -> String {
390    let mut parts = Vec::new();
391    for a in attrs {
392      let key = obj_key(&a.name);
393      if let JsxAttrValue::Spread(e) = &a.value {
394        parts.push(format!("...{}", e.trim()));
395        continue;
396      }
397      let v = match &a.value {
398        // React requires `style` as an object literal, not a string.
399        JsxAttrValue::String(s) if a.name == "style" => Self::style_attr_to_object(s),
400        JsxAttrValue::String(s) => Self::js_string(s),
401        JsxAttrValue::Expression(e) => Self::compile_attr_expression(self, e),
402        JsxAttrValue::Boolean => "true".to_string(),
403        JsxAttrValue::Spread(_) => unreachable!(),
404      };
405      parts.push(format!("{}: {}", key, v));
406    }
407    parts.join(", ")
408  }
409
410  /// Compile a `{...}` JSX attribute expression. Plain JS passes through;
411  /// embedded JSX (`{<Zap />}`) is re-parsed and routed through `inline_expr`.
412  fn compile_attr_expression(&mut self, e: &str) -> String {
413    let trimmed = e.trim();
414    if !trimmed.starts_with('<') {
415      return trimmed.to_string();
416    }
417    let nodes = dmc_parser::parse_inline_str(trimmed);
418    let pieces: Vec<String> = nodes
419      .iter()
420      .filter(|n| !matches!(n, Node::Text(t) if t.value.trim().is_empty()))
421      .map(|n| self.inline_expr(n))
422      .collect();
423    match pieces.len() {
424      0 => trimmed.to_string(),
425      1 => pieces.into_iter().next().unwrap(),
426      _ => format!("jsxs(Fragment, {{ children: [{}] }})", pieces.join(", ")),
427    }
428  }
429
430  /// Rows/cells aren't `Node` variants, so walk them recursively here;
431  /// `in_table_depth` suppresses the outer walker.
432  fn table_expr(&mut self, t: &Table) -> String {
433    let mut sections: Vec<String> = Vec::new();
434    let tr = self.jsx_tag_ref("tr");
435    let thead = self.jsx_tag_ref("thead");
436    let tbody = self.jsx_tag_ref("tbody");
437    let table = self.jsx_tag_ref("table");
438
439    if let Some(header) = t.children.first() {
440      let mut head_cells: Vec<String> = Vec::with_capacity(header.cells.len());
441      for (i, cell) in header.cells.iter().enumerate() {
442        let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
443        head_cells.push(self.table_cell_expr("th", cell, align));
444      }
445      let head_row = format!("jsxs({}, {{ children: [{}] }})", tr, head_cells.join(", "));
446      sections.push(format!("jsxs({}, {{ children: [{}] }})", thead, head_row));
447    }
448
449    if t.children.len() > 1 {
450      let mut body_rows: Vec<String> = Vec::with_capacity(t.children.len() - 1);
451      for row in &t.children[1..] {
452        let mut row_cells: Vec<String> = Vec::with_capacity(row.cells.len());
453        for (i, cell) in row.cells.iter().enumerate() {
454          let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
455          row_cells.push(self.table_cell_expr("td", cell, align));
456        }
457        body_rows.push(format!("jsxs({}, {{ children: [{}] }})", tr, row_cells.join(", ")));
458      }
459      sections.push(format!("jsxs({}, {{ children: [{}] }})", tbody, body_rows.join(", ")));
460    }
461
462    format!("jsxs({}, {{ children: [{}] }})", table, sections.join(", "))
463  }
464
465  fn table_cell_expr(&mut self, tag: &str, cell: &TableCell, align: TableAlign) -> String {
466    let kids: Vec<String> = cell.children.iter().map(|n| self.inline_expr(n)).collect();
467    let kids_arr = format!("[{}]", kids.join(", "));
468    let align_str = match align {
469      TableAlign::Left => Some("left"),
470      TableAlign::Right => Some("right"),
471      TableAlign::Center => Some("center"),
472      TableAlign::None => None,
473    };
474    let tag_ref = self.jsx_tag_ref(tag);
475    match align_str {
476      Some(a) => format!("jsxs({}, {{ align: {}, children: {} }})", tag_ref, Self::js_string(a), kids_arr),
477      None => format!("jsxs({}, {{ children: {} }})", tag_ref, kids_arr),
478    }
479  }
480
481  /// Self-recursive for cell content (walker suppressed via `in_table_depth`).
482  fn inline_expr(&mut self, node: &Node) -> String {
483    match node {
484      Node::Text(t) => Self::js_string(&t.value),
485      Node::InlineCode(c) => {
486        format!("jsx({}, {{ children: {} }})", self.jsx_tag_ref("code"), Self::js_string(&c.value))
487      },
488      Node::CodeBlock(cb) => self.code_block_expr(cb),
489      Node::Image(i) => self.image_expr(i),
490      Node::HorizontalRule(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("hr")),
491      Node::HardBreak(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("br")),
492      Node::SoftBreak(_) => Self::js_string("\n"),
493      Node::JsxSelfClosing(s) => self.jsx_self_closing_expr(s),
494      Node::JsxExpression(j) => j.value.trim().to_string(),
495      Node::Bold(i) => self.wrap_jsx("strong", &i.children),
496      Node::Italic(i) => self.wrap_jsx("em", &i.children),
497      Node::Strikethrough(i) => self.wrap_jsx("del", &i.children),
498      Node::Paragraph(p) => self.wrap_jsx("p", &p.children),
499      Node::Blockquote(b) => self.wrap_jsx("blockquote", &b.children),
500      Node::List(l) => {
501        let tag = if l.ordered { "ol" } else { "ul" };
502        self.wrap_jsx(tag, &l.children)
503      },
504      Node::ListItem(li) => self.wrap_jsx("li", &li.children),
505      Node::TaskListItem(t) => self.wrap_jsx("li", &t.children),
506      Node::Heading(h) => {
507        let kids: Vec<String> = h.children.iter().map(|n| self.inline_expr(n)).collect();
508        let (callee, kids_expr) = jsx_callee_and_children(&kids);
509        let tag = format!("h{}", h.level);
510        format!(
511          "{}({}, {{ id: {}, children: {} }})",
512          callee,
513          self.jsx_tag_ref(&tag),
514          Self::js_string(&h.slug()),
515          kids_expr,
516        )
517      },
518      Node::Link(l) => {
519        let kids: Vec<String> = l.children.iter().map(|n| self.inline_expr(n)).collect();
520        let (callee, kids_expr) = jsx_callee_and_children(&kids);
521        let mut props = format!("href: {}", Self::js_string(&sanitize_url(&l.href)));
522        if let Some(title) = &l.title {
523          props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
524        }
525        format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids_expr)
526      },
527      Node::JsxElement(e) => {
528        let kids: Vec<String> = e.children.iter().map(|n| self.inline_expr(n)).collect();
529        let (callee, kids_expr) = jsx_callee_and_children(&kids);
530        self.jsx_element_expr_with(e, callee, kids_expr)
531      },
532      Node::JsxFragment(f) => {
533        let kids: Vec<String> = f.children.iter().map(|n| self.inline_expr(n)).collect();
534        let (callee, kids_expr) = jsx_callee_and_children(&kids);
535        format!("{}(Fragment, {{ children: {} }})", callee, kids_expr)
536      },
537      Node::Table(t) => self.table_expr(t),
538      // SEC-010: raw-HTML passthrough gated behind `allow_dangerous_html`.
539      // This path is the inline (table-cell) recursion, so in safe mode
540      // the raw HTML is escaped to a visible text node.
541      Node::Html(h) => {
542        if self.options.allow_dangerous_html {
543          format!(
544            "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
545            self.jsx_tag_ref("div"),
546            Self::js_string(&h.value)
547          )
548        } else {
549          Self::js_string(&h.value)
550        }
551      },
552      Node::FootnoteRef(f) => format!(
553        "jsx({}, {{ children: jsx({}, {{ href: \"#fn-{}\", children: {} }}) }})",
554        self.jsx_tag_ref("sup"),
555        self.jsx_tag_ref("a"),
556        f.id,
557        Self::js_string(&f.id)
558      ),
559      Node::FootnoteDef(f) => self.wrap_jsx("p", &f.children),
560      Node::Frontmatter(_)
561      | Node::Import(_)
562      | Node::Export(_)
563      | Node::Document(_)
564      | Node::TableRow(_)
565      | Node::TableCell(_) => "null".to_string(),
566    }
567  }
568
569  fn wrap_jsx(&mut self, tag: &str, children: &[Node]) -> String {
570    let kids: Vec<String> = children.iter().map(|n| self.inline_expr(n)).collect();
571    let (callee, kids_expr) = jsx_callee_and_children(&kids);
572    format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids_expr)
573  }
574
575  /// Quote `s` as a JS string literal; control chars below 0x20 via `\uXXXX`.
576  fn js_string(s: &str) -> String {
577    let mut out = String::with_capacity(s.len() + 2);
578    out.push('"');
579    for ch in s.chars() {
580      match ch {
581        '\\' => out.push_str("\\\\"),
582        '"' => out.push_str("\\\""),
583        '\n' => out.push_str("\\n"),
584        '\r' => out.push_str("\\r"),
585        '\t' => out.push_str("\\t"),
586        c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
587        c => out.push(c),
588      }
589    }
590    out.push('"');
591    out
592  }
593}
594
595pub fn render_mdx_body(doc: &Document) -> String {
596  MdxBodyEmitter::render(doc).0
597}
598
599/// `@mdx-js/mdx` callee rule: 0/1 child -> `jsx` unwrapped; many -> `jsxs([])`.
600/// Adjacent string literals are coalesced first -- React SSR emits a
601/// `<!-- -->` between sibling text nodes, so without merging, each
602/// per-word `Text` node leaks an HTML comment into the DOM.
603fn jsx_callee_and_children(parts: &[String]) -> (&'static str, String) {
604  let merged = coalesce_string_literals(parts);
605  match merged.len() {
606    0 => ("jsx", "[]".into()),
607    1 => ("jsx", merged.into_iter().next().unwrap()),
608    _ => ("jsxs", format!("[{}]", merged.join(", "))),
609  }
610}
611
612/// Fold runs of `"..."` literals into one. Non-string expressions act as breaks.
613fn coalesce_string_literals(parts: &[String]) -> Vec<String> {
614  let mut out: Vec<String> = Vec::with_capacity(parts.len());
615  for p in parts {
616    if is_js_string_literal(p)
617      && let Some(last) = out.last_mut()
618      && is_js_string_literal(last)
619    {
620      last.pop();
621      last.push_str(&p[1..]);
622      continue;
623    }
624    out.push(p.clone());
625  }
626  out
627}
628
629/// True when `s` is exactly one JS string literal from `js_string`
630/// (interior `"` and `\` properly escaped).
631fn is_js_string_literal(s: &str) -> bool {
632  let bytes = s.as_bytes();
633  if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
634    return false;
635  }
636  let mut i = 1;
637  let end = bytes.len() - 1;
638  while i < end {
639    match bytes[i] {
640      b'\\' => {
641        if i + 1 >= end {
642          return false;
643        }
644        i += 2;
645      },
646      b'"' => return false,
647      _ => i += 1,
648    }
649  }
650  true
651}
652
653/// Safe to emit unquoted as member access or object-literal key.
654fn is_js_ident(s: &str) -> bool {
655  let mut chars = s.chars();
656  match chars.next() {
657    Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {},
658    _ => return false,
659  }
660  chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
661}
662
663fn obj_key(key: &str) -> String {
664  if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
665}