Skip to main content

dmc_codegen/
html.rs

1use crate::{
2  NodeSink, WalkCtx, Walker,
3  escape::{escape_attr, escape_text},
4};
5use dmc_diagnostic::Code;
6use dmc_parser::ast::*;
7use duck_diagnostic::{Diagnostic, DiagnosticEngine};
8
9/// Emits static HTML by reacting to walker enter/leave events. Container
10/// nodes split into `open_tag` / `close_tag` halves; leaves write their
11/// markup once on enter. Tables are rendered up-front in `enter Table`
12/// (rows + cells aren't `Node` variants the walker can surface) and
13/// `in_table_depth` suppresses subsequent walker events on cell content.
14///
15/// Owns its own `DiagnosticEngine` during the walk; merge into the
16/// caller's engine via `into_parts` after the walk completes.
17pub struct HtmlEmitter {
18  out: String,
19  diag_engine: DiagnosticEngine<Code>,
20  in_table_depth: usize,
21}
22
23impl NodeSink for HtmlEmitter {
24  fn enter(&mut self, node: &Node, _ctx: &WalkCtx) {
25    if self.in_table_depth > 0 {
26      return;
27    }
28    match node {
29      Node::Text(t) => self.out.push_str(&escape_text(&t.value)),
30      Node::InlineCode(c) => {
31        self.out.push_str("<code>");
32        self.out.push_str(&escape_text(&c.value));
33        self.out.push_str("</code>");
34      },
35      Node::CodeBlock(cb) => self.code_block(cb),
36      Node::Image(i) => self.image(i),
37      Node::HorizontalRule(_) => self.out.push_str("<hr />"),
38      Node::HardBreak(_) => self.out.push_str("<br/>"),
39      Node::SoftBreak(_) => self.out.push('\n'),
40      Node::JsxSelfClosing(s) => self.jsx_self_closing(s),
41      Node::JsxExpression(e) => {
42        self.diag(Code::HtmlExpressionDropped, format!("html: raw `{{...}}` expression dropped: {}", e.value.trim()));
43      },
44      Node::Table(t) => {
45        self.in_table_depth += 1;
46        self.inline_table(t);
47      },
48      Node::Frontmatter(_) | Node::Import(_) | Node::Export(_) => {},
49      _ => self.open_tag(node),
50    }
51  }
52
53  fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
54    if let Node::Table(_) = node {
55      self.in_table_depth = self.in_table_depth.saturating_sub(1);
56      return;
57    }
58    if self.in_table_depth > 0 {
59      return;
60    }
61    self.close_tag(node);
62  }
63}
64
65impl Default for HtmlEmitter {
66  fn default() -> Self {
67    Self::new()
68  }
69}
70
71impl HtmlEmitter {
72  pub fn new() -> Self {
73    Self { out: String::new(), diag_engine: DiagnosticEngine::new(), in_table_depth: 0 }
74  }
75
76  pub fn into_string(self) -> String {
77    self.out
78  }
79
80  /// Take both buffers: the rendered HTML and the per-emitter diagnostic
81  /// engine. Caller merges the diags into a shared engine via
82  /// `outer.extend(diag)`.
83  pub fn into_parts(self) -> (String, DiagnosticEngine<Code>) {
84    (self.out, self.diag_engine)
85  }
86
87  /// Drive the walker; return `(html, diag)`. Use when no other sink
88  /// shares the walk.
89  pub fn render(doc: &Document) -> (String, DiagnosticEngine<Code>) {
90    let mut e = Self::new();
91    Walker::new(doc).walk(&mut [&mut e]);
92    e.into_parts()
93  }
94
95  fn diag(&mut self, code: Code, message: impl Into<String>) {
96    self.diag_engine.emit(Diagnostic::new(code, message.into()));
97  }
98
99  // --- container open / close (walker fills the children in between) ----
100
101  /// Write the opening tag for a container node.
102  fn open_tag(&mut self, node: &Node) {
103    match node {
104      Node::Heading(h) => self.out.push_str(&format!("<h{} id=\"{}\">", h.level, escape_attr(&h.slug()))),
105      Node::Paragraph(_) => self.out.push_str("<p>"),
106      Node::Bold(_) => self.out.push_str("<strong>"),
107      Node::Italic(_) => self.out.push_str("<em>"),
108      Node::Strikethrough(_) => self.out.push_str("<del>"),
109      Node::Blockquote(_) => self.out.push_str("<blockquote>"),
110      Node::List(l) => {
111        let tag = if l.ordered { "ol" } else { "ul" };
112        self.out.push('<');
113        self.out.push_str(tag);
114        if l.ordered
115          && let Some(s) = l.start
116          && s != 1
117        {
118          self.out.push_str(&format!(" start=\"{}\"", s));
119        }
120        self.out.push('>');
121      },
122      Node::ListItem(_) => self.out.push_str("<li>"),
123      Node::TaskListItem(t) => {
124        let checked = if t.checked { " checked" } else { "" };
125        self.out.push_str(&format!("<li class=\"task-list-item\"><input type=\"checkbox\" disabled{} />", checked));
126      },
127      Node::Link(l) => {
128        self.out.push_str(&format!("<a href=\"{}\"", escape_attr(&l.href)));
129        if let Some(title) = &l.title {
130          self.out.push_str(&format!(" title=\"{}\"", escape_attr(title)));
131        }
132        self.out.push('>');
133      },
134      Node::JsxElement(e) => {
135        if e.name.is_empty() {
136          self.diag(Code::MalformedJsxTagName, "html: JSX element has empty name; skipped".to_string());
137          return;
138        }
139        self.out.push('<');
140        self.out.push_str(&e.name);
141        for a in &e.attrs {
142          self.jsx_attr(a);
143        }
144        self.out.push('>');
145      },
146      Node::JsxFragment(_) => {},
147      _ => {},
148    }
149  }
150
151  /// Write the closing tag for a container node opened by `open_tag`.
152  fn close_tag(&mut self, node: &Node) {
153    match node {
154      Node::Heading(h) => self.out.push_str(&format!("</h{}>", h.level)),
155      Node::Paragraph(_) => self.out.push_str("</p>"),
156      Node::Bold(_) => self.out.push_str("</strong>"),
157      Node::Italic(_) => self.out.push_str("</em>"),
158      Node::Strikethrough(_) => self.out.push_str("</del>"),
159      Node::Blockquote(_) => self.out.push_str("</blockquote>"),
160      Node::List(l) => {
161        let tag = if l.ordered { "ol" } else { "ul" };
162        self.out.push_str(&format!("</{}>", tag));
163      },
164      Node::ListItem(_) | Node::TaskListItem(_) => self.out.push_str("</li>"),
165      Node::Link(_) => self.out.push_str("</a>"),
166      Node::JsxElement(e) if !e.name.is_empty() => {
167        self.out.push_str(&format!("</{}>", e.name));
168      },
169      Node::JsxFragment(_) => {},
170      _ => {},
171    }
172  }
173
174  // --- leaf-shaped emitters --------------------------------------------
175
176  fn code_block(&mut self, cb: &CodeBlock) {
177    self.out.push_str("<pre><code");
178    if let Some(lang) = &cb.lang {
179      self.out.push_str(&format!(" class=\"gentledmc-language-{}\"", escape_attr(lang)));
180    }
181    self.out.push('>');
182    self.out.push_str(&escape_text(&cb.value));
183    self.out.push_str("</code></pre>");
184  }
185
186  fn image(&mut self, i: &Image) {
187    self.out.push_str(&format!("<img src=\"{}\" alt=\"{}\"", escape_attr(&i.src), escape_attr(&i.alt)));
188    if let Some(title) = &i.title {
189      self.out.push_str(&format!(" title=\"{}\"", escape_attr(title)));
190    }
191    self.out.push_str(" />");
192  }
193
194  fn jsx_self_closing(&mut self, s: &JsxSelfClosing) {
195    if s.name.is_empty() {
196      self.diag(Code::MalformedJsxTagName, "html: self-closing JSX has empty name; skipped".to_string());
197      return;
198    }
199    match s.name.as_str() {
200      "MermaidSvg" => {
201        if let Some(attr) = s.attrs.iter().find(|a| a.name == "svg")
202          && let JsxAttrValue::String(svg) = &attr.value
203        {
204          self.out.push_str(svg);
205        }
206      },
207      "MathMl" => {
208        if let Some(attr) = s.attrs.iter().find(|a| a.name == "mathml")
209          && let JsxAttrValue::String(mathml) = &attr.value
210        {
211          // Reverse the JSX-attribute escape applied by Math::preprocess_source
212          // (`"` -> `&quot;`, `&` -> `&amp;`) before emitting raw HTML.
213          let unescaped = mathml.replace("&quot;", "\"").replace("&amp;", "&");
214          self.out.push_str(&unescaped);
215        }
216      },
217      "PackageManagerTabs" => {
218        self.out.push_str("<div class=\"gentledmc-pm-tabs\">");
219        for pm in ["npm", "yarn", "pnpm", "bun"] {
220          if let Some(attr) = s.attrs.iter().find(|a| a.name == pm)
221            && let JsxAttrValue::String(cmd) = &attr.value
222          {
223            self.out.push_str(&format!(
224              "<pre><code class=\"gentledmc-language-bash\" data-pm=\"{}\">{}</code></pre>",
225              pm,
226              escape_text(cmd)
227            ));
228          }
229        }
230        self.out.push_str("</div>");
231      },
232      _ => {
233        self.out.push('<');
234        self.out.push_str(&s.name);
235        for a in &s.attrs {
236          self.jsx_attr(a);
237        }
238        self.out.push_str(" />");
239      },
240    }
241  }
242
243  fn jsx_attr(&mut self, a: &JsxAttr) {
244    self.out.push(' ');
245    self.out.push_str(&a.name);
246    match &a.value {
247      JsxAttrValue::Boolean => {},
248      JsxAttrValue::String(s) => self.out.push_str(&format!("=\"{}\"", escape_attr(s))),
249      JsxAttrValue::Expression(e) => self.out.push_str(&format!("={{{}}}", e)),
250    }
251  }
252
253  // --- table inline path (walker can't surface row/cell events) --------
254
255  /// Render the entire `<table>...</table>` up-front. Cell content uses
256  /// `inline_node` recursion since the walker is suppressed inside.
257  fn inline_table(&mut self, t: &Table) {
258    self.out.push_str("<table>");
259    if let Some(header) = t.children.first() {
260      self.out.push_str("<thead><tr>");
261      for (i, cell) in header.cells.iter().enumerate() {
262        self.inline_cell("th", cell, t.align.get(i).copied().unwrap_or(TableAlign::None));
263      }
264      self.out.push_str("</tr></thead>");
265    }
266    if t.children.len() > 1 {
267      self.out.push_str("<tbody>");
268      for row in &t.children[1..] {
269        self.out.push_str("<tr>");
270        for (i, cell) in row.cells.iter().enumerate() {
271          self.inline_cell("td", cell, t.align.get(i).copied().unwrap_or(TableAlign::None));
272        }
273        self.out.push_str("</tr>");
274      }
275      self.out.push_str("</tbody>");
276    }
277    self.out.push_str("</table>");
278  }
279
280  fn inline_cell(&mut self, tag: &str, cell: &TableCell, align: TableAlign) {
281    self.out.push('<');
282    self.out.push_str(tag);
283    let align_str = match align {
284      TableAlign::Left => Some("left"),
285      TableAlign::Right => Some("right"),
286      TableAlign::Center => Some("center"),
287      TableAlign::None => None,
288    };
289    if let Some(a) = align_str {
290      self.out.push_str(&format!(" align=\"{}\"", a));
291    }
292    self.out.push('>');
293    for c in &cell.children {
294      self.inline_node(c);
295    }
296    self.out.push_str("</");
297    self.out.push_str(tag);
298    self.out.push('>');
299  }
300
301  /// Self-recursive render used only inside the table inline path. The
302  /// walker is suppressed via `in_table_depth`, so cell content doesn't
303  /// get a second pass.
304  fn inline_node(&mut self, node: &Node) {
305    match node {
306      Node::Text(t) => self.out.push_str(&escape_text(&t.value)),
307      Node::Bold(i) => self.wrap_tag("strong", &i.children),
308      Node::Italic(i) => self.wrap_tag("em", &i.children),
309      Node::Strikethrough(i) => self.wrap_tag("del", &i.children),
310      Node::InlineCode(c) => {
311        self.out.push_str("<code>");
312        self.out.push_str(&escape_text(&c.value));
313        self.out.push_str("</code>");
314      },
315      Node::Link(l) => {
316        self.out.push_str(&format!("<a href=\"{}\"", escape_attr(&l.href)));
317        if let Some(title) = &l.title {
318          self.out.push_str(&format!(" title=\"{}\"", escape_attr(title)));
319        }
320        self.out.push('>');
321        for c in &l.children {
322          self.inline_node(c);
323        }
324        self.out.push_str("</a>");
325      },
326      Node::Image(i) => self.image(i),
327      Node::HardBreak(_) => self.out.push_str("<br/>"),
328      Node::SoftBreak(_) => self.out.push('\n'),
329      Node::CodeBlock(cb) => self.code_block(cb),
330      _ => {
331        self.open_tag(node);
332        for kid in Node::children_of(node) {
333          self.inline_node(kid);
334        }
335        self.close_tag(node);
336      },
337    }
338  }
339
340  fn wrap_tag(&mut self, tag: &str, children: &[Node]) {
341    self.out.push('<');
342    self.out.push_str(tag);
343    self.out.push('>');
344    for c in children {
345      self.inline_node(c);
346    }
347    self.out.push_str("</");
348    self.out.push_str(tag);
349    self.out.push('>');
350  }
351}
352
353/// Convenience: render `doc` to HTML with a throwaway diagnostic engine.
354pub fn render_html(doc: &Document) -> String {
355  let mut e = HtmlEmitter::new();
356  Walker::new(doc).walk(&mut [&mut e]);
357  e.into_string()
358}