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
9pub 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 pub fn into_parts(self) -> (String, DiagnosticEngine<Code>) {
84 (self.out, self.diag_engine)
85 }
86
87 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 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 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 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 let unescaped = mathml.replace(""", "\"").replace("&", "&");
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 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 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
353pub 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}