krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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(§ion.content, &mut self.block_defs);
81 for child in §ion.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) = §ion.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 §ion.content {
102 self.render_element(element, out);
103 }
104 for child in §ion.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}\">↩</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("&"),
328 '<' => out.push_str("<"),
329 '>' => out.push_str(">"),
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('"', """);
339 out
340}