krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.3.0: src/render.rs · raw

  1//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment.
  2//!
  3//! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4):
  4//! 1. Footnotes use a two-pass layout — definitions are collected up front, references
  5//!    are numbered in order of first appearance during the walk, and a back-linked
  6//!    notes section is emitted at page end.
  7//! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern
  8//!    and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline
  9//!    styles, so themes live in the stylesheet (spec §3.2).
 10//!
 11//! v0.2 renders: headings (always anchored, with tags), paragraphs, plain lists
 12//! (unordered/ordered + checkboxes), source/example blocks, horizontal rules, tables
 13//! (with header band from the rule row), footnotes, and inline markup. Real syntect
 14//! tokenizing remains a `<pre><code>` passthrough for now (see [`SyntectHighlighter`]).
 15
 16use std::collections::HashMap;
 17
 18use crate::model::{Checkbox, Element, LinkTarget, ListKind, Object, Section, TableRow};
 19use crate::resolve::ResolvedDoc;
 20use crate::util::{plain_text, slugify};
 21
 22/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
 23#[derive(Debug, Clone)]
 24pub struct Html(pub String);
 25
 26/// Pluggable highlighter so tree-sitter can replace syntect per-language later
 27/// without touching the renderer (spec §3.2, R6).
 28pub trait Highlighter {
 29    fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
 30}
 31
 32/// Default v1 highlighter. For now this is a plain `<pre><code>` passthrough that
 33/// escapes the code and tags it with a `language-*` class; real syntect tokenizing
 34/// to CSS-class spans is deferred (spec §3.2, §4.2).
 35pub struct SyntectHighlighter;
 36
 37impl Highlighter for SyntectHighlighter {
 38    fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
 39        let class = match lang {
 40            Some(l) => format!(" class=\"language-{}\"", escape_attr(l)),
 41            None => String::new(),
 42        };
 43        Html(format!(
 44            "<pre><code{}>{}</code></pre>\n",
 45            class,
 46            escape_html(code)
 47        ))
 48    }
 49}
 50
 51/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
 52struct Renderer<'a> {
 53    hl: &'a dyn Highlighter,
 54    /// Block footnote definitions, keyed by label (collected before the walk).
 55    block_defs: HashMap<String, Vec<Element>>,
 56    /// Inline footnote definitions discovered at reference sites.
 57    inline_defs: HashMap<String, Vec<Object>>,
 58    /// Reference keys in order of first appearance — drives numbering and note order.
 59    order: Vec<String>,
 60}
 61
 62/// Render a resolved document to an HTML fragment.
 63pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
 64    let mut r = Renderer {
 65        hl: highlighter,
 66        block_defs: HashMap::new(),
 67        inline_defs: HashMap::new(),
 68        order: Vec::new(),
 69    };
 70    r.collect_defs(&doc.document.root);
 71    let mut out = String::new();
 72    r.render_section(&doc.document.root, &mut out);
 73    r.emit_footnotes(&mut out);
 74    Html(out)
 75}
 76
 77impl Renderer<'_> {
 78    /// First footnote pass: gather every block definition in the tree by label.
 79    fn collect_defs(&mut self, section: &Section) {
 80        collect_defs_in(&section.content, &mut self.block_defs);
 81        for child in &section.children {
 82            self.collect_defs(child);
 83        }
 84    }
 85
 86    fn render_section(&mut self, section: &Section, out: &mut String) {
 87        if let Some(h) = &section.heading {
 88            let level = h.level.clamp(1, 6);
 89            let anchor = h
 90                .custom_id
 91                .clone()
 92                .or_else(|| h.id.clone())
 93                .unwrap_or_else(|| slugify(&plain_text(&h.title)));
 94            out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
 95            self.render_objects(&h.title, out);
 96            for tag in &h.tags {
 97                out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag)));
 98            }
 99            out.push_str(&format!("</h{}>\n", level));
100        }
101        for element in &section.content {
102            self.render_element(element, out);
103        }
104        for child in &section.children {
105            self.render_section(child, out);
106        }
107    }
108
109    fn render_element(&mut self, element: &Element, out: &mut String) {
110        match element {
111            Element::Paragraph(objs) => {
112                out.push_str("<p>");
113                self.render_objects(objs, out);
114                out.push_str("</p>\n");
115            }
116            Element::List(list) => {
117                let tag = match list.kind {
118                    ListKind::Ordered => "ol",
119                    _ => "ul",
120                };
121                out.push_str(&format!("<{}>\n", tag));
122                for item in &list.items {
123                    out.push_str("<li>");
124                    if let Some(cb) = &item.checkbox {
125                        let checked = matches!(cb, Checkbox::On);
126                        out.push_str(&format!(
127                            "<input type=\"checkbox\" disabled{}> ",
128                            if checked { " checked" } else { "" }
129                        ));
130                    }
131                    match item.content.as_slice() {
132                        [Element::Paragraph(objs)] => self.render_objects(objs, out),
133                        els => {
134                            for el in els {
135                                self.render_element(el, out);
136                            }
137                        }
138                    }
139                    out.push_str("</li>\n");
140                }
141                out.push_str(&format!("</{}>\n", tag));
142            }
143            Element::Table(table) => self.render_table(table, out),
144            Element::SrcBlock { lang, code, .. } => {
145                let Html(h) = self.hl.highlight(code, lang.as_deref());
146                out.push_str(&h);
147            }
148            Element::ExampleBlock(code) => {
149                out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
150            }
151            Element::HorizontalRule => out.push_str("<hr>\n"),
152            // Definitions are emitted in the footnotes section, not inline.
153            Element::FootnoteDefinition { .. } => {}
154            // Out of scope (non-HTML export, generic drawers, stray keywords, comments):
155            // emitted as nothing rather than crashing.
156            _ => {}
157        }
158    }
159
160    /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
161    fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
162        let rule_at = table
163            .rows
164            .iter()
165            .position(|r| matches!(r, TableRow::Rule));
166        out.push_str("<table>\n");
167        let mut wrote_body = false;
168        let mut in_body = rule_at.is_none();
169        for (idx, row) in table.rows.iter().enumerate() {
170            match row {
171                TableRow::Rule => {
172                    if Some(idx) == rule_at {
173                        in_body = true;
174                    }
175                    continue;
176                }
177                TableRow::Cells(cells) => {
178                    let (open, cell_tag) = if in_body {
179                        if !wrote_body {
180                            wrote_body = true;
181                            ("<tbody>\n<tr>", "td")
182                        } else {
183                            ("<tr>", "td")
184                        }
185                    } else {
186                        ("<thead>\n<tr>", "th")
187                    };
188                    out.push_str(open);
189                    for cell in cells {
190                        out.push_str(&format!("<{}>", cell_tag));
191                        self.render_objects(cell, out);
192                        out.push_str(&format!("</{}>", cell_tag));
193                    }
194                    out.push_str("</tr>\n");
195                    // Close the header band right after its last row.
196                    if !in_body
197                        && rule_at.map(|r| idx + 1 == r).unwrap_or(false)
198                    {
199                        out.push_str("</thead>\n");
200                    }
201                }
202            }
203        }
204        if wrote_body {
205            out.push_str("</tbody>\n");
206        }
207        out.push_str("</table>\n");
208    }
209
210    fn render_objects(&mut self, objs: &[Object], out: &mut String) {
211        for obj in objs {
212            self.render_object(obj, out);
213        }
214    }
215
216    fn render_object(&mut self, obj: &Object, out: &mut String) {
217        match obj {
218            Object::Text(t) => out.push_str(&escape_html(t)),
219            Object::Bold(inner) => self.wrap(out, "strong", inner),
220            Object::Italic(inner) => self.wrap(out, "em", inner),
221            Object::Underline(inner) => self.wrap(out, "u", inner),
222            Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
223            Object::Verbatim(s) => {
224                out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
225            }
226            Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
227            Object::Link(link) => {
228                let href = link_href(&link.target);
229                out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
230                match &link.description {
231                    Some(desc) => self.render_objects(desc, out),
232                    None => out.push_str(&escape_html(&link_text(&link.target))),
233                }
234                out.push_str("</a>");
235            }
236            Object::FootnoteRef { label, inline } => {
237                let key = if label.is_empty() {
238                    format!("__anon{}", self.order.len() + 1)
239                } else {
240                    label.clone()
241                };
242                if let Some(objs) = inline {
243                    self.inline_defs.insert(key.clone(), objs.clone());
244                }
245                if !self.order.contains(&key) {
246                    self.order.push(key.clone());
247                }
248                let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
249                out.push_str(&format!(
250                    "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
251                    n = num
252                ));
253            }
254            Object::LineBreak => out.push_str("<br>\n"),
255            // Timestamps, entities: out of scope for now.
256            _ => {}
257        }
258    }
259
260    fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
261        out.push_str(&format!("<{}>", tag));
262        self.render_objects(inner, out);
263        out.push_str(&format!("</{}>", tag));
264    }
265
266    /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
267    fn emit_footnotes(&mut self, out: &mut String) {
268        if self.order.is_empty() {
269            return;
270        }
271        let order = self.order.clone();
272        let inline_defs = self.inline_defs.clone();
273        let block_defs = self.block_defs.clone();
274        out.push_str("<section class=\"footnotes\">\n<hr>\n<ol>\n");
275        for (idx, label) in order.iter().enumerate() {
276            let n = idx + 1;
277            out.push_str(&format!("<li id=\"fn-{n}\">"));
278            if let Some(objs) = inline_defs.get(label) {
279                self.render_objects(objs, out);
280            } else if let Some(els) = block_defs.get(label) {
281                for el in els {
282                    self.render_element(el, out);
283                }
284            }
285            out.push_str(&format!(
286                " <a class=\"footnote-back\" href=\"#fnr-{n}\">&#8617;</a></li>\n"
287            ));
288        }
289        out.push_str("</ol>\n</section>\n");
290    }
291}
292
293fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
294    for el in elements {
295        if let Element::FootnoteDefinition { label, content } = el {
296            defs.entry(label.clone()).or_insert_with(|| content.clone());
297        }
298    }
299}
300
301/// Best-effort URL for a link target. After RESOLVE, internal targets have been
302/// rewritten to `External` with their final URL; anything still internal here is an
303/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
304fn link_href(target: &LinkTarget) -> String {
305    match target {
306        LinkTarget::External(s) => s.clone(),
307        LinkTarget::CustomId(id) => format!("#{}", id),
308        LinkTarget::Id(id) => format!("#{}", id),
309        LinkTarget::Heading(text) => format!("#{}", slugify(text)),
310        LinkTarget::File { path, .. } => path.to_string(),
311    }
312}
313
314fn link_text(target: &LinkTarget) -> String {
315    match target {
316        LinkTarget::External(s) => s.clone(),
317        LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
318        LinkTarget::Heading(text) => text.clone(),
319        LinkTarget::File { path, .. } => path.to_string(),
320    }
321}
322
323fn escape_html(s: &str) -> String {
324    let mut out = String::with_capacity(s.len());
325    for c in s.chars() {
326        match c {
327            '&' => out.push_str("&amp;"),
328            '<' => out.push_str("&lt;"),
329            '>' => out.push_str("&gt;"),
330            _ => out.push(c),
331        }
332    }
333    out
334}
335
336fn escape_attr(s: &str) -> String {
337    let mut out = escape_html(s);
338    out = out.replace('"', "&quot;");
339    out
340}