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: `_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}
27
28#[derive(Default, Debug)]
29struct Frame {
30  parts: Vec<String>,
31}
32
33impl NodeSink for MdxBodyEmitter {
34  fn enter(&mut self, node: &Node, _ctx: &WalkCtx) {
35    if self.in_table_depth > 0 {
36      return;
37    }
38    match node {
39      Node::Text(t) => self.push_part(Self::js_string(&t.value)),
40      Node::InlineCode(c) => {
41        let tag = self.jsx_tag_ref("code");
42        // No `__dmcRaw__` here -- that flag is reserved for fenced `<pre>`
43        // blocks (PrettyCode); putting it on inline `<code>` misclassifies
44        // it as block in consumer mappings.
45        self.push_part(format!("jsx({}, {{ children: {} }})", tag, Self::js_string(&c.value),));
46      },
47      Node::CodeBlock(cb) => {
48        let s = self.code_block_expr(cb);
49        self.push_part(s);
50      },
51      Node::Image(i) => {
52        let s = self.image_expr(i);
53        self.push_part(s);
54      },
55      Node::HorizontalRule(_) => {
56        let tag = self.jsx_tag_ref("hr");
57        self.push_part(format!("jsx({}, {{}})", tag));
58      },
59      Node::HardBreak(_) => {
60        let tag = self.jsx_tag_ref("br");
61        self.push_part(format!("jsx({}, {{}})", tag));
62      },
63      Node::SoftBreak(_) => self.push_part(Self::js_string("\n")),
64      Node::JsxSelfClosing(s) => {
65        let expr = self.jsx_self_closing_expr(s);
66        self.push_part(expr);
67      },
68      Node::JsxExpression(j) => self.push_part(j.value.trim().to_string()),
69
70      // Must be an explicit arm: `_ => open_frame` would push a frame
71      // but `is_container` returns false for `Html`, so `close_frame`
72      // would bail without popping -- silently dropping every sibling
73      // and ancestor expression that follows.
74      Node::Html(h) => {
75        let tag = self.jsx_tag_ref("div");
76        self.push_part(format!(
77          "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
78          tag,
79          Self::js_string(&h.value)
80        ));
81      },
82
83      Node::Table(t) => {
84        let expr = self.table_expr(t);
85        self.push_part(expr);
86        self.in_table_depth += 1;
87      },
88
89      Node::Frontmatter(_) => {},
90      Node::Import(i) => self.imports.push(i.raw.trim_end().to_string()),
91      Node::Export(x) => self.exports.push(x.raw.trim_end().to_string()),
92
93      _ => self.open_frame(node),
94    }
95  }
96
97  fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
98    if let Node::Table(_) = node {
99      self.in_table_depth = self.in_table_depth.saturating_sub(1);
100      return;
101    }
102    if self.in_table_depth > 0 {
103      return;
104    }
105    self.close_frame(node);
106  }
107}
108
109impl Default for MdxBodyEmitter {
110  fn default() -> Self {
111    Self::new()
112  }
113}
114
115impl MdxBodyEmitter {
116  pub fn new() -> Self {
117    Self {
118      stack: vec![Frame::default()],
119      imports: Vec::new(),
120      exports: Vec::new(),
121      diag_engine: DiagnosticEngine::new(),
122      in_table_depth: 0,
123      used_intrinsic: BTreeSet::new(),
124      used_components: BTreeSet::new(),
125    }
126  }
127
128  pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
129    let mut emitter = Self::new();
130    Walker::new(doc).walk(&mut [&mut emitter]);
131    emitter.into_parts()
132  }
133
134  pub fn into_parts(mut self) -> (String, DiagnosticEngine<Code>) {
135    let diag = std::mem::replace(&mut self.diag_engine, DiagnosticEngine::new());
136    let body_str = self.into_string();
137    (body_str, diag)
138  }
139
140  pub fn into_string(self) -> String {
141    let MdxBodyEmitter { stack, imports, exports, used_intrinsic, used_components, .. } = self;
142    let root_parts = stack.into_iter().next().map(|f| f.parts).unwrap_or_default();
143    let (root_callee, root_kids) = jsx_callee_and_children(&root_parts);
144    let body_expr = format!("{}(Fragment, {{ children: {} }})", root_callee, root_kids);
145
146    // Function-body output (consumed via `new Function(body)(runtime)`)
147    // can't legally contain `import`/`export`. Drop them; consumers that
148    // need ESM bindings declare them outside MDX (e.g. components map).
149    let _ = (&imports, &exports);
150    let prelude = String::new();
151
152    let defaults = if used_intrinsic.is_empty() {
153      "...props.components".to_string()
154    } else {
155      let entries: Vec<String> = used_intrinsic.iter().map(|tag| format!("{}: \"{}\"", obj_key(tag), tag)).collect();
156      format!("{}, ...props.components", entries.join(", "))
157    };
158
159    let (component_destructure, missing_checks, missing_fn) = if used_components.is_empty() {
160      (String::new(), String::new(), String::new())
161    } else {
162      let names: Vec<String> = used_components.iter().cloned().collect();
163      let destruct = format!("  const {{ {} }} = _components;\n", names.join(", "));
164      let mut checks = String::new();
165      for name in &names {
166        checks.push_str(&format!("  if (!{name}) _missingMdxReference(\"{name}\");\n"));
167      }
168      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();
169      (destruct, checks, f)
170    };
171
172    // Destructure runtime at module scope so `_createMdxContent` closes
173    // over it; inside the fn it would be shadowed by React's `props`.
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 to a JSX object literal.
298  /// `--custom` properties stay quoted; everything else is camel-cased.
299  fn style_attr_to_object(s: &str) -> String {
300    let mut entries = Vec::new();
301    for decl in s.split(';') {
302      let decl = decl.trim();
303      if decl.is_empty() {
304        continue;
305      }
306      let Some((raw_key, raw_val)) = decl.split_once(':') else {
307        continue;
308      };
309      let key = raw_key.trim();
310      let val = raw_val.trim();
311      if key.is_empty() {
312        continue;
313      }
314      let key_out = if key.starts_with("--") {
315        format!("\"{}\"", key)
316      } else {
317        let mut camel = String::with_capacity(key.len());
318        let mut upper = false;
319        for ch in key.chars() {
320          if ch == '-' {
321            upper = true;
322          } else if upper {
323            camel.push(ch.to_ascii_uppercase());
324            upper = false;
325          } else {
326            camel.push(ch.to_ascii_lowercase());
327          }
328        }
329        camel
330      };
331      entries.push(format!("{}: {}", key_out, Self::js_string(val)));
332    }
333    if entries.is_empty() { "{}".to_string() } else { format!("{{ {} }}", entries.join(", ")) }
334  }
335
336  /// Resolve a JSX tag name and record the ref for the prelude.
337  /// - lowercase -> `_components.<tag>`
338  /// - capitalized -> bare local binding (destructured in prelude)
339  /// - `Fragment` -> in-scope runtime symbol
340  /// - non-ident (`my-element`) -> `_components[...]`
341  fn jsx_tag_ref(&mut self, name: &str) -> String {
342    if name == "Fragment" {
343      return "Fragment".to_string();
344    }
345    let starts_upper = name.chars().next().is_some_and(|c| c.is_ascii_uppercase());
346    if starts_upper {
347      self.used_components.insert(name.to_string());
348      return name.to_string();
349    }
350    self.used_intrinsic.insert(name.to_string());
351    if is_js_ident(name) { format!("_components.{name}") } else { format!("_components[{}]", Self::js_string(name)) }
352  }
353
354  fn jsx_props(&mut self, attrs: &[JsxAttr]) -> String {
355    let mut parts = Vec::new();
356    for a in attrs {
357      let key = obj_key(&a.name);
358      if let JsxAttrValue::Spread(e) = &a.value {
359        parts.push(format!("...{}", e.trim()));
360        continue;
361      }
362      let v = match &a.value {
363        // React requires `style` as an object literal, not a string.
364        JsxAttrValue::String(s) if a.name == "style" => Self::style_attr_to_object(s),
365        JsxAttrValue::String(s) => Self::js_string(s),
366        JsxAttrValue::Expression(e) => Self::compile_attr_expression(self, e),
367        JsxAttrValue::Boolean => "true".to_string(),
368        JsxAttrValue::Spread(_) => unreachable!(),
369      };
370      parts.push(format!("{}: {}", key, v));
371    }
372    parts.join(", ")
373  }
374
375  /// Compile a `{...}` JSX attribute expression. Plain JS passes through;
376  /// embedded JSX (`{<Zap />}`) is re-parsed and routed through `inline_expr`.
377  fn compile_attr_expression(&mut self, e: &str) -> String {
378    let trimmed = e.trim();
379    if !trimmed.starts_with('<') {
380      return trimmed.to_string();
381    }
382    let nodes = dmc_parser::parse_inline_str(trimmed);
383    let pieces: Vec<String> = nodes
384      .iter()
385      .filter(|n| !matches!(n, Node::Text(t) if t.value.trim().is_empty()))
386      .map(|n| self.inline_expr(n))
387      .collect();
388    match pieces.len() {
389      0 => trimmed.to_string(),
390      1 => pieces.into_iter().next().unwrap(),
391      _ => format!("jsxs(Fragment, {{ children: [{}] }})", pieces.join(", ")),
392    }
393  }
394
395  /// Rows/cells aren't `Node` variants, so walk them recursively here;
396  /// `in_table_depth` suppresses the outer walker.
397  fn table_expr(&mut self, t: &Table) -> String {
398    let mut sections: Vec<String> = Vec::new();
399    let tr = self.jsx_tag_ref("tr");
400    let thead = self.jsx_tag_ref("thead");
401    let tbody = self.jsx_tag_ref("tbody");
402    let table = self.jsx_tag_ref("table");
403
404    if let Some(header) = t.children.first() {
405      let mut head_cells: Vec<String> = Vec::with_capacity(header.cells.len());
406      for (i, cell) in header.cells.iter().enumerate() {
407        let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
408        head_cells.push(self.table_cell_expr("th", cell, align));
409      }
410      let head_row = format!("jsxs({}, {{ children: [{}] }})", tr, head_cells.join(", "));
411      sections.push(format!("jsxs({}, {{ children: [{}] }})", thead, head_row));
412    }
413
414    if t.children.len() > 1 {
415      let mut body_rows: Vec<String> = Vec::with_capacity(t.children.len() - 1);
416      for row in &t.children[1..] {
417        let mut row_cells: Vec<String> = Vec::with_capacity(row.cells.len());
418        for (i, cell) in row.cells.iter().enumerate() {
419          let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
420          row_cells.push(self.table_cell_expr("td", cell, align));
421        }
422        body_rows.push(format!("jsxs({}, {{ children: [{}] }})", tr, row_cells.join(", ")));
423      }
424      sections.push(format!("jsxs({}, {{ children: [{}] }})", tbody, body_rows.join(", ")));
425    }
426
427    format!("jsxs({}, {{ children: [{}] }})", table, sections.join(", "))
428  }
429
430  fn table_cell_expr(&mut self, tag: &str, cell: &TableCell, align: TableAlign) -> String {
431    let kids: Vec<String> = cell.children.iter().map(|n| self.inline_expr(n)).collect();
432    let kids_arr = format!("[{}]", kids.join(", "));
433    let align_str = match align {
434      TableAlign::Left => Some("left"),
435      TableAlign::Right => Some("right"),
436      TableAlign::Center => Some("center"),
437      TableAlign::None => None,
438    };
439    let tag_ref = self.jsx_tag_ref(tag);
440    match align_str {
441      Some(a) => format!("jsxs({}, {{ align: {}, children: {} }})", tag_ref, Self::js_string(a), kids_arr),
442      None => format!("jsxs({}, {{ children: {} }})", tag_ref, kids_arr),
443    }
444  }
445
446  /// Self-recursive for cell content (walker suppressed via `in_table_depth`).
447  fn inline_expr(&mut self, node: &Node) -> String {
448    match node {
449      Node::Text(t) => Self::js_string(&t.value),
450      Node::InlineCode(c) => {
451        format!("jsx({}, {{ children: {} }})", self.jsx_tag_ref("code"), Self::js_string(&c.value))
452      },
453      Node::CodeBlock(cb) => self.code_block_expr(cb),
454      Node::Image(i) => self.image_expr(i),
455      Node::HorizontalRule(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("hr")),
456      Node::HardBreak(_) => format!("jsx({}, {{}})", self.jsx_tag_ref("br")),
457      Node::SoftBreak(_) => Self::js_string("\n"),
458      Node::JsxSelfClosing(s) => self.jsx_self_closing_expr(s),
459      Node::JsxExpression(j) => j.value.trim().to_string(),
460      Node::Bold(i) => self.wrap_jsx("strong", &i.children),
461      Node::Italic(i) => self.wrap_jsx("em", &i.children),
462      Node::Strikethrough(i) => self.wrap_jsx("del", &i.children),
463      Node::Paragraph(p) => self.wrap_jsx("p", &p.children),
464      Node::Blockquote(b) => self.wrap_jsx("blockquote", &b.children),
465      Node::List(l) => {
466        let tag = if l.ordered { "ol" } else { "ul" };
467        self.wrap_jsx(tag, &l.children)
468      },
469      Node::ListItem(li) => self.wrap_jsx("li", &li.children),
470      Node::TaskListItem(t) => self.wrap_jsx("li", &t.children),
471      Node::Heading(h) => {
472        let kids: Vec<String> = h.children.iter().map(|n| self.inline_expr(n)).collect();
473        let (callee, kids_expr) = jsx_callee_and_children(&kids);
474        let tag = format!("h{}", h.level);
475        format!(
476          "{}({}, {{ id: {}, children: {} }})",
477          callee,
478          self.jsx_tag_ref(&tag),
479          Self::js_string(&h.slug()),
480          kids_expr,
481        )
482      },
483      Node::Link(l) => {
484        let kids: Vec<String> = l.children.iter().map(|n| self.inline_expr(n)).collect();
485        let (callee, kids_expr) = jsx_callee_and_children(&kids);
486        let mut props = format!("href: {}", Self::js_string(&l.href));
487        if let Some(title) = &l.title {
488          props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
489        }
490        format!("{}({}, {{ {}, children: {} }})", callee, self.jsx_tag_ref("a"), props, kids_expr)
491      },
492      Node::JsxElement(e) => {
493        let kids: Vec<String> = e.children.iter().map(|n| self.inline_expr(n)).collect();
494        let (callee, kids_expr) = jsx_callee_and_children(&kids);
495        self.jsx_element_expr_with(e, callee, kids_expr)
496      },
497      Node::JsxFragment(f) => {
498        let kids: Vec<String> = f.children.iter().map(|n| self.inline_expr(n)).collect();
499        let (callee, kids_expr) = jsx_callee_and_children(&kids);
500        format!("{}(Fragment, {{ children: {} }})", callee, kids_expr)
501      },
502      Node::Table(t) => self.table_expr(t),
503      Node::Html(h) => format!(
504        "jsx({}, {{ dangerouslySetInnerHTML: {{ __html: {} }} }})",
505        self.jsx_tag_ref("div"),
506        Self::js_string(&h.value)
507      ),
508      Node::FootnoteRef(f) => format!(
509        "jsx({}, {{ children: jsx({}, {{ href: \"#fn-{}\", children: {} }}) }})",
510        self.jsx_tag_ref("sup"),
511        self.jsx_tag_ref("a"),
512        f.id,
513        Self::js_string(&f.id)
514      ),
515      Node::FootnoteDef(f) => self.wrap_jsx("p", &f.children),
516      Node::Frontmatter(_)
517      | Node::Import(_)
518      | Node::Export(_)
519      | Node::Document(_)
520      | Node::TableRow(_)
521      | Node::TableCell(_) => "null".to_string(),
522    }
523  }
524
525  fn wrap_jsx(&mut self, tag: &str, children: &[Node]) -> String {
526    let kids: Vec<String> = children.iter().map(|n| self.inline_expr(n)).collect();
527    let (callee, kids_expr) = jsx_callee_and_children(&kids);
528    format!("{}({}, {{ children: {} }})", callee, self.jsx_tag_ref(tag), kids_expr)
529  }
530
531  /// Quote `s` as a JS string literal; control chars below 0x20 via `\uXXXX`.
532  fn js_string(s: &str) -> String {
533    let mut out = String::with_capacity(s.len() + 2);
534    out.push('"');
535    for ch in s.chars() {
536      match ch {
537        '\\' => out.push_str("\\\\"),
538        '"' => out.push_str("\\\""),
539        '\n' => out.push_str("\\n"),
540        '\r' => out.push_str("\\r"),
541        '\t' => out.push_str("\\t"),
542        c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
543        c => out.push(c),
544      }
545    }
546    out.push('"');
547    out
548  }
549}
550
551pub fn render_mdx_body(doc: &Document) -> String {
552  MdxBodyEmitter::render(doc).0
553}
554
555/// `@mdx-js/mdx` callee rule: 0/1 child -> `jsx` unwrapped; many -> `jsxs([])`.
556/// Adjacent string literals are coalesced first -- React SSR emits a
557/// `<!-- -->` between sibling text nodes, so without merging, each
558/// per-word `Text` node leaks an HTML comment into the DOM.
559fn jsx_callee_and_children(parts: &[String]) -> (&'static str, String) {
560  let merged = coalesce_string_literals(parts);
561  match merged.len() {
562    0 => ("jsx", "[]".into()),
563    1 => ("jsx", merged.into_iter().next().unwrap()),
564    _ => ("jsxs", format!("[{}]", merged.join(", "))),
565  }
566}
567
568/// Fold runs of `"..."` literals into one. Non-string expressions act as breaks.
569fn coalesce_string_literals(parts: &[String]) -> Vec<String> {
570  let mut out: Vec<String> = Vec::with_capacity(parts.len());
571  for p in parts {
572    if is_js_string_literal(p)
573      && let Some(last) = out.last_mut()
574      && is_js_string_literal(last)
575    {
576      last.pop();
577      last.push_str(&p[1..]);
578      continue;
579    }
580    out.push(p.clone());
581  }
582  out
583}
584
585/// True when `s` is exactly one JS string literal from `js_string`
586/// (interior `"` and `\` properly escaped).
587fn is_js_string_literal(s: &str) -> bool {
588  let bytes = s.as_bytes();
589  if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
590    return false;
591  }
592  let mut i = 1;
593  let end = bytes.len() - 1;
594  while i < end {
595    match bytes[i] {
596      b'\\' => {
597        if i + 1 >= end {
598          return false;
599        }
600        i += 2;
601      },
602      b'"' => return false,
603      _ => i += 1,
604    }
605  }
606  true
607}
608
609/// Safe to emit unquoted as member access or object-literal key.
610fn is_js_ident(s: &str) -> bool {
611  let mut chars = s.chars();
612  match chars.next() {
613    Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {},
614    _ => return false,
615  }
616  chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
617}
618
619fn obj_key(key: &str) -> String {
620  if is_js_ident(key) { key.to_string() } else { format!("\"{}\"", key.replace('"', "\\\"")) }
621}