Skip to main content

dmc_codegen/
mdx.rs

1use dmc_diagnostic::Code;
2use dmc_parser::ast::*;
3use duck_diagnostic::{Diagnostic, DiagnosticEngine};
4
5use crate::{NodeSink, WalkCtx, Walker};
6
7/// Builds an MDX-runtime body - a `_createMdxContent(props)` function whose
8/// return value is a React tree of `jsx`, `jsxs`, and `Fragment`. Imports +
9/// exports hoist into a prelude; frontmatter is dropped.
10///
11/// Every container node is one expression with its kids inlined as a
12/// comma-joined array, so we can't interleave open/close text the way
13/// HTML does. Instead each container `open_frame` pushes a child Frame;
14/// kid expressions accumulate there as the walker descends; `close_frame`
15/// pops, builds the parent expression, and folds it into the grandparent
16/// frame.
17///
18/// Tables are emitted in one shot from `enter Table` (rows + cells aren't
19/// walker-visible Node variants); `in_table_depth` suppresses subsequent
20/// walker events on cell content.
21///
22/// Owns its own `DiagnosticEngine` during the walk; merge into the
23/// caller's engine via `into_parts` after the walk completes.
24#[derive(Debug)]
25pub struct MdxBodyEmitter {
26  stack: Vec<Frame>,
27  imports: Vec<String>,
28  exports: Vec<String>,
29  diag_engine: DiagnosticEngine<Code>,
30  in_table_depth: usize,
31}
32
33#[derive(Default, Debug)]
34struct Frame {
35  parts: Vec<String>,
36}
37
38impl NodeSink for MdxBodyEmitter {
39  fn enter(&mut self, node: &Node, _ctx: &WalkCtx) {
40    if self.in_table_depth > 0 {
41      return;
42    }
43    match node {
44      Node::Text(t) => self.push_part(Self::js_string(&t.value)),
45      Node::InlineCode(c) => {
46        self.push_part(format!("jsx(\"code\", {{ children: {} }})", Self::js_string(&c.value)));
47      },
48      Node::CodeBlock(cb) => self.push_part(self.code_block_expr(cb)),
49      Node::Image(i) => self.push_part(self.image_expr(i)),
50      Node::HorizontalRule(_) => self.push_part("jsx(\"hr\", {})".to_string()),
51      Node::HardBreak(_) => self.push_part("jsx(\"br\", {})".to_string()),
52      Node::SoftBreak(_) => self.push_part(Self::js_string("\n")),
53      Node::JsxSelfClosing(s) => {
54        let expr = self.jsx_self_closing_expr(s);
55        self.push_part(expr);
56      },
57      Node::JsxExpression(j) => self.push_part(j.value.trim().to_string()),
58
59      Node::Table(t) => {
60        let expr = self.table_expr(t);
61        self.push_part(expr);
62        self.in_table_depth += 1;
63      },
64
65      Node::Frontmatter(_) => {},
66      Node::Import(i) => self.imports.push(i.raw.trim_end().to_string()),
67      Node::Export(x) => self.exports.push(x.raw.trim_end().to_string()),
68
69      _ => self.open_frame(node),
70    }
71  }
72
73  fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
74    if let Node::Table(_) = node {
75      self.in_table_depth = self.in_table_depth.saturating_sub(1);
76      return;
77    }
78    if self.in_table_depth > 0 {
79      return;
80    }
81    self.close_frame(node);
82  }
83}
84
85impl Default for MdxBodyEmitter {
86  fn default() -> Self {
87    Self::new()
88  }
89}
90
91impl MdxBodyEmitter {
92  pub fn new() -> Self {
93    Self {
94      stack: vec![Frame::default()],
95      imports: Vec::new(),
96      exports: Vec::new(),
97      diag_engine: DiagnosticEngine::new(),
98      in_table_depth: 0,
99    }
100  }
101
102  /// Drive the walker; return `(body, diag)`. Use when no other sink
103  /// shares the walk.
104  pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
105    let mut emitter = Self::new();
106    Walker::new(doc).walk(&mut [&mut emitter]);
107    emitter.into_parts()
108  }
109
110  /// Take both buffers: the rendered MDX body and the per-emitter
111  /// diagnostic engine. Caller merges via `outer.extend(diag)`.
112  pub fn into_parts(self) -> (String, DiagnosticEngine<Code>) {
113    let diag = self.diag_engine;
114    let body_str = Self::assemble(self.stack, self.imports, self.exports);
115    (body_str, diag)
116  }
117
118  fn assemble(stack: Vec<Frame>, imports: Vec<String>, exports: Vec<String>) -> String {
119    let root_parts = stack.into_iter().next().map(|f| f.parts).unwrap_or_default();
120    let body = format!("jsxs(Fragment, {{ children: [{}] }})", root_parts.join(", "));
121    let mut prelude = String::new();
122    for i in &imports {
123      prelude.push_str(i);
124      prelude.push('\n');
125    }
126    for e in &exports {
127      prelude.push_str(e);
128      prelude.push('\n');
129    }
130    format!(
131      "{prelude}function _createMdxContent(props) {{\n  const _components = (props && props.components) || {{}};\n  const {{ Fragment, jsx, jsxs }} = arguments[0];\n  return {body};\n}}\nreturn _createMdxContent(arguments[0]);\n",
132    )
133  }
134
135  /// Wrap the accumulated body in the `_createMdxContent` shell and
136  /// prepend the import / export prelude. Drops the diagnostic engine.
137  pub fn into_string(self) -> String {
138    Self::assemble(self.stack, self.imports, self.exports)
139  }
140
141  fn diag(&mut self, code: Code, message: impl Into<String>) {
142    self.diag_engine.emit(Diagnostic::new(code, message.into()));
143  }
144
145  // --- frame open / close (walker fills children between) ---------------
146
147  /// Push an empty child-frame; walker descent will populate it.
148  fn open_frame(&mut self, _node: &Node) {
149    self.stack.push(Frame::default());
150  }
151
152  /// Pop the current frame, build this node's expression, fold it into
153  /// the parent frame. Only container variants own a frame to pop.
154  fn close_frame(&mut self, node: &Node) {
155    if !Self::is_container(node) {
156      return;
157    }
158    let kids = self.pop_kids_array();
159    let expr = match node {
160      Node::Heading(h) => {
161        format!("jsxs(\"h{}\", {{ id: {}, children: {} }})", h.level, Self::js_string(&h.slug()), kids,)
162      },
163      Node::Paragraph(_) => format!("jsxs(\"p\", {{ children: {} }})", kids),
164      Node::Bold(_) => format!("jsxs(\"strong\", {{ children: {} }})", kids),
165      Node::Italic(_) => format!("jsxs(\"em\", {{ children: {} }})", kids),
166      Node::Strikethrough(_) => format!("jsxs(\"del\", {{ children: {} }})", kids),
167      Node::Blockquote(_) => format!("jsxs(\"blockquote\", {{ children: {} }})", kids),
168      Node::List(l) => {
169        let tag = if l.ordered { "ol" } else { "ul" };
170        format!("jsxs(\"{}\", {{ children: {} }})", tag, kids)
171      },
172      Node::ListItem(_) | Node::TaskListItem(_) => format!("jsxs(\"li\", {{ children: {} }})", kids),
173      Node::Link(l) => {
174        let mut props = format!("href: {}", Self::js_string(&l.href));
175        if let Some(title) = &l.title {
176          props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
177        }
178        format!("jsxs(\"a\", {{ {}, children: {} }})", props, kids)
179      },
180      Node::JsxElement(e) => self.jsx_element_expr(e, kids),
181      Node::JsxFragment(_) => format!("jsxs(Fragment, {{ children: {} }})", kids),
182      _ => unreachable!("is_container guards every other variant"),
183    };
184    self.push_part(expr);
185  }
186
187  /// True when `enter` pushed a frame for this node.
188  fn is_container(n: &Node) -> bool {
189    matches!(
190      n,
191      Node::Heading(_)
192        | Node::Paragraph(_)
193        | Node::Bold(_)
194        | Node::Italic(_)
195        | Node::Strikethrough(_)
196        | Node::Blockquote(_)
197        | Node::List(_)
198        | Node::ListItem(_)
199        | Node::TaskListItem(_)
200        | Node::Link(_)
201        | Node::JsxElement(_)
202        | Node::JsxFragment(_)
203    )
204  }
205
206  /// Pop the top frame and render its parts as a `[a, b, c]` JS array.
207  fn pop_kids_array(&mut self) -> String {
208    let parts = self.stack.pop().map(|f| f.parts).unwrap_or_default();
209    format!("[{}]", parts.join(", "))
210  }
211
212  /// Append one expression to the current top-of-stack frame.
213  fn push_part(&mut self, expr: String) {
214    if let Some(frame) = self.stack.last_mut() {
215      frame.parts.push(expr);
216    }
217  }
218
219  // --- expression builders for leaves + cell-recursive descent ---------
220
221  fn code_block_expr(&self, cb: &CodeBlock) -> String {
222    match &cb.lang {
223      Some(lang) => format!(
224        "jsx(\"pre\", {{ children: jsx(\"code\", {{ className: {}, children: {} }}) }})",
225        Self::js_string(&format!("gentledmc-language-{}", lang)),
226        Self::js_string(&cb.value),
227      ),
228      None => format!("jsx(\"pre\", {{ children: jsx(\"code\", {{ children: {} }}) }})", Self::js_string(&cb.value),),
229    }
230  }
231
232  fn image_expr(&self, i: &Image) -> String {
233    format!("jsx(\"img\", {{ src: {}, alt: {} }})", Self::js_string(&i.src), Self::js_string(&i.alt))
234  }
235
236  fn jsx_element_expr(&mut self, e: &JsxElement, kids: String) -> String {
237    if e.name.is_empty() {
238      self.diag(Code::MalformedJsxTagName, "mdx-body: JSX element has empty name; rendered as Fragment".to_string());
239      return format!("jsxs(Fragment, {{ children: {} }})", kids);
240    }
241    let mut props = self.jsx_props(&e.attrs);
242    if !props.is_empty() {
243      props.push_str(", ");
244    }
245    format!("jsxs({}, {{ {}children: {} }})", e.name, props, kids)
246  }
247
248  fn jsx_self_closing_expr(&mut self, s: &JsxSelfClosing) -> String {
249    if s.name.is_empty() {
250      self.diag(Code::MalformedJsxTagName, "mdx-body: self-closing JSX has empty name; emitted as null".to_string());
251      return "null".to_string();
252    }
253    let props = self.jsx_props(&s.attrs);
254    format!("jsx({}, {{ {} }})", s.name, props)
255  }
256
257  fn jsx_props(&self, attrs: &[JsxAttr]) -> String {
258    let mut parts = Vec::new();
259    for a in attrs {
260      let key = format!("\"{}\"", a.name);
261      let v = match &a.value {
262        JsxAttrValue::String(s) => Self::js_string(s),
263        JsxAttrValue::Expression(e) => e.trim().to_string(),
264        JsxAttrValue::Boolean => "true".to_string(),
265      };
266      parts.push(format!("{}: {}", key, v));
267    }
268    parts.join(", ")
269  }
270
271  // --- table inline path (walker can't surface row/cell events) --------
272
273  /// Build the full `jsxs("table", { children: [thead, tbody] })` expr.
274  fn table_expr(&mut self, t: &Table) -> String {
275    let mut sections: Vec<String> = Vec::new();
276
277    if let Some(header) = t.children.first() {
278      let mut head_cells: Vec<String> = Vec::with_capacity(header.cells.len());
279      for (i, cell) in header.cells.iter().enumerate() {
280        let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
281        head_cells.push(self.table_cell_expr("th", cell, align));
282      }
283      let head_row = format!("jsxs(\"tr\", {{ children: [{}] }})", head_cells.join(", "));
284      sections.push(format!("jsxs(\"thead\", {{ children: [{}] }})", head_row));
285    }
286
287    if t.children.len() > 1 {
288      let mut body_rows: Vec<String> = Vec::with_capacity(t.children.len() - 1);
289      for row in &t.children[1..] {
290        let mut row_cells: Vec<String> = Vec::with_capacity(row.cells.len());
291        for (i, cell) in row.cells.iter().enumerate() {
292          let align = t.align.get(i).copied().unwrap_or(TableAlign::None);
293          row_cells.push(self.table_cell_expr("td", cell, align));
294        }
295        body_rows.push(format!("jsxs(\"tr\", {{ children: [{}] }})", row_cells.join(", ")));
296      }
297      sections.push(format!("jsxs(\"tbody\", {{ children: [{}] }})", body_rows.join(", ")));
298    }
299
300    format!("jsxs(\"table\", {{ children: [{}] }})", sections.join(", "))
301  }
302
303  fn table_cell_expr(&mut self, tag: &str, cell: &TableCell, align: TableAlign) -> String {
304    let kids: Vec<String> = cell.children.iter().map(|n| self.inline_expr(n)).collect();
305    let kids_arr = format!("[{}]", kids.join(", "));
306    let align_str = match align {
307      TableAlign::Left => Some("left"),
308      TableAlign::Right => Some("right"),
309      TableAlign::Center => Some("center"),
310      TableAlign::None => None,
311    };
312    match align_str {
313      Some(a) => format!("jsxs(\"{}\", {{ align: {}, children: {} }})", tag, Self::js_string(a), kids_arr,),
314      None => format!("jsxs(\"{}\", {{ children: {} }})", tag, kids_arr),
315    }
316  }
317
318  /// Self-recursive expression builder for cell content. Walker is
319  /// suppressed via `in_table_depth` while we're inside a table.
320  fn inline_expr(&mut self, node: &Node) -> String {
321    match node {
322      Node::Text(t) => Self::js_string(&t.value),
323      Node::InlineCode(c) => format!("jsx(\"code\", {{ children: {} }})", Self::js_string(&c.value)),
324      Node::CodeBlock(cb) => self.code_block_expr(cb),
325      Node::Image(i) => self.image_expr(i),
326      Node::HorizontalRule(_) => "jsx(\"hr\", {})".to_string(),
327      Node::HardBreak(_) => "jsx(\"br\", {})".to_string(),
328      Node::SoftBreak(_) => Self::js_string("\n"),
329      Node::JsxSelfClosing(s) => self.jsx_self_closing_expr(s),
330      Node::JsxExpression(j) => j.value.trim().to_string(),
331      Node::Bold(i) => self.wrap_jsxs("strong", &i.children),
332      Node::Italic(i) => self.wrap_jsxs("em", &i.children),
333      Node::Strikethrough(i) => self.wrap_jsxs("del", &i.children),
334      Node::Paragraph(p) => self.wrap_jsxs("p", &p.children),
335      Node::Blockquote(b) => self.wrap_jsxs("blockquote", &b.children),
336      Node::List(l) => {
337        let tag = if l.ordered { "ol" } else { "ul" };
338        self.wrap_jsxs(tag, &l.children)
339      },
340      Node::ListItem(li) => self.wrap_jsxs("li", &li.children),
341      Node::TaskListItem(t) => self.wrap_jsxs("li", &t.children),
342      Node::Heading(h) => {
343        let kids: Vec<String> = h.children.iter().map(|n| self.inline_expr(n)).collect();
344        format!("jsxs(\"h{}\", {{ id: {}, children: [{}] }})", h.level, Self::js_string(&h.slug()), kids.join(", "),)
345      },
346      Node::Link(l) => {
347        let kids: Vec<String> = l.children.iter().map(|n| self.inline_expr(n)).collect();
348        let mut props = format!("href: {}", Self::js_string(&l.href));
349        if let Some(title) = &l.title {
350          props.push_str(&format!(", \"aria-label\": {}", Self::js_string(title)));
351        }
352        format!("jsxs(\"a\", {{ {}, children: [{}] }})", props, kids.join(", "))
353      },
354      Node::JsxElement(e) => {
355        let kids: Vec<String> = e.children.iter().map(|n| self.inline_expr(n)).collect();
356        let kids_arr = format!("[{}]", kids.join(", "));
357        self.jsx_element_expr(e, kids_arr)
358      },
359      Node::JsxFragment(f) => {
360        let kids: Vec<String> = f.children.iter().map(|n| self.inline_expr(n)).collect();
361        format!("jsxs(Fragment, {{ children: [{}] }})", kids.join(", "))
362      },
363      Node::Table(t) => self.table_expr(t),
364      Node::Frontmatter(_)
365      | Node::Import(_)
366      | Node::Export(_)
367      | Node::Document(_)
368      | Node::TableRow(_)
369      | Node::TableCell(_) => "null".to_string(),
370    }
371  }
372
373  fn wrap_jsxs(&mut self, tag: &str, children: &[Node]) -> String {
374    let kids: Vec<String> = children.iter().map(|n| self.inline_expr(n)).collect();
375    format!("jsxs(\"{}\", {{ children: [{}] }})", tag, kids.join(", "))
376  }
377
378  // --- string literal helper -------------------------------------------
379
380  /// Quote `s` as a JS string literal. Handles `\`, `"`, `\n`, `\r`, `\t`,
381  /// and any control char below 0x20 via `\uXXXX`.
382  fn js_string(s: &str) -> String {
383    let mut out = String::with_capacity(s.len() + 2);
384    out.push('"');
385    for ch in s.chars() {
386      match ch {
387        '\\' => out.push_str("\\\\"),
388        '"' => out.push_str("\\\""),
389        '\n' => out.push_str("\\n"),
390        '\r' => out.push_str("\\r"),
391        '\t' => out.push_str("\\t"),
392        c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
393        c => out.push(c),
394      }
395    }
396    out.push('"');
397    out
398  }
399}
400
401/// Convenience: render `doc` to an MDX body string with a throwaway
402/// diagnostic engine.
403pub fn render_mdx_body(doc: &Document) -> String {
404  MdxBodyEmitter::render(doc).0
405}