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//! Renders the v1 IN set: headings (always anchored, with TODO keyword, priority and
12//! tags), paragraphs, plain lists (unordered/ordered/description, nested, with
13//! checkboxes), tables, source blocks (syntect-highlighted), example/quote/center
14//! blocks, HTML export blocks, horizontal rules, images and captioned figures,
15//! footnotes, timestamps, and inline markup. Out-of-scope elements (generic drawers,
16//! comments, stray keywords, non-HTML export blocks) render to nothing.
17
18use std::collections::HashMap;
19use std::sync::OnceLock;
20
21use syntect::highlighting::ThemeSet;
22use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator};
23use syntect::parsing::SyntaxSet;
24use syntect::util::LinesWithEndings;
25
26use crate::config::SubSuperscript;
27use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
28use crate::parser::is_image_target;
29use crate::resolve::ResolvedDoc;
30use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify};
31
32/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
33#[derive(Debug, Clone)]
34pub struct Html(pub String);
35
36/// Pluggable highlighter so tree-sitter can replace syntect per-language later
37/// without touching the renderer (spec §3.2, R6).
38pub trait Highlighter {
39 fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
40}
41
42/// The class style used for both the emitted spans and the generated stylesheet. The
43/// two must agree or the CSS will not match the markup.
44const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
45
46/// Syntax definitions syntect does not bundle, compiled into the binary.
47///
48/// Both are gaps this project hits on its own first page: every `orgo.toml` example is
49/// TOML, and a tool for org users is going to be written about in org. Embedding them
50/// rather than shipping files means they work with no setup, which is the same promise
51/// the rest of the zero-config path makes.
52const BUNDLED_SYNTAXES: &[(&str, &str)] = &[
53 ("TOML", include_str!("../syntaxes/TOML.sublime-syntax")),
54 ("Org", include_str!("../syntaxes/Org.sublime-syntax")),
55];
56
57/// Syntect's default syntax definitions plus [`BUNDLED_SYNTAXES`], loaded once per
58/// process (loading is far more expensive than highlighting, and a site build highlights
59/// many blocks).
60fn syntax_set() -> &'static SyntaxSet {
61 static SET: OnceLock<SyntaxSet> = OnceLock::new();
62 SET.get_or_init(|| build_syntax_set(None))
63}
64
65/// Build a syntax set: syntect's defaults, the bundled additions, and optionally a
66/// directory of user `.sublime-syntax` files.
67///
68/// A malformed bundled definition is a bug in this crate and panics. A malformed *user*
69/// definition is reported and skipped, because one bad file in a directory should not
70/// stop a site from building.
71fn build_syntax_set(user_dir: Option<&camino::Utf8Path>) -> SyntaxSet {
72 let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
73 for (name, source) in BUNDLED_SYNTAXES {
74 let definition =
75 syntect::parsing::SyntaxDefinition::load_from_str(source, true, Some(name))
76 .unwrap_or_else(|e| panic!("bundled {name} syntax is malformed: {e}"));
77 builder.add(definition);
78 }
79 if let Some(dir) = user_dir.filter(|d| d.is_dir()) {
80 if let Err(e) = builder.add_from_folder(dir, true) {
81 eprintln!("warning: ignoring syntax definitions in {dir}: {e}");
82 }
83 }
84 builder.build()
85}
86
87/// A syntax set including a user directory of `.sublime-syntax` files.
88///
89/// Cached per directory: a build highlights many blocks, and rebuilding the set for each
90/// would cost more than the highlighting.
91fn syntax_set_with(user_dir: &camino::Utf8Path) -> &'static SyntaxSet {
92 use std::collections::HashMap;
93 use std::sync::Mutex;
94 static SETS: OnceLock<Mutex<HashMap<camino::Utf8PathBuf, &'static SyntaxSet>>> =
95 OnceLock::new();
96 let sets = SETS.get_or_init(|| Mutex::new(HashMap::new()));
97 let mut sets = sets.lock().expect("syntax set cache");
98 sets.entry(user_dir.to_owned())
99 .or_insert_with(|| Box::leak(Box::new(build_syntax_set(Some(user_dir)))))
100}
101
102fn theme_set() -> &'static ThemeSet {
103 static THEMES: OnceLock<ThemeSet> = OnceLock::new();
104 THEMES.get_or_init(ThemeSet::load_defaults)
105}
106
107/// The stylesheet the emitted highlight classes refer to, for a named syntect theme.
108/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must
109/// also emit this. `None` means the theme name is not one syntect ships — the caller
110/// reports that rather than quietly emitting an empty stylesheet, which would look like
111/// highlighting is broken.
112pub fn syntax_css(theme: &str) -> Option<String> {
113 let theme = theme_set().themes.get(theme)?;
114 css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
115}
116
117/// Every theme name [`syntax_css`] accepts, for error messages and documentation.
118pub fn available_themes() -> Vec<&'static str> {
119 theme_set().themes.keys().map(String::as_str).collect()
120}
121
122/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
123/// whose language syntect does not know falls back to escaped `<pre><code>`.
124pub struct SyntectHighlighter {
125 syntaxes: &'static SyntaxSet,
126}
127
128impl SyntectHighlighter {
129 pub fn new() -> Self {
130 SyntectHighlighter {
131 syntaxes: syntax_set(),
132 }
133 }
134
135 /// A highlighter that also knows the `.sublime-syntax` files in `dir`, for languages
136 /// neither syntect nor this crate bundles.
137 pub fn with_syntaxes(dir: Option<&camino::Utf8Path>) -> Self {
138 match dir {
139 Some(dir) if dir.is_dir() => SyntectHighlighter {
140 syntaxes: syntax_set_with(dir),
141 },
142 _ => Self::new(),
143 }
144 }
145}
146
147/// Every language the highlighter recognises, for documentation and error messages.
148pub fn available_languages() -> Vec<&'static str> {
149 let mut names: Vec<&str> = syntax_set()
150 .syntaxes()
151 .iter()
152 .map(|s| s.name.as_str())
153 .collect();
154 names.sort_unstable();
155 names.dedup();
156 names
157}
158
159impl Default for SyntectHighlighter {
160 fn default() -> Self {
161 Self::new()
162 }
163}
164
165impl Highlighter for SyntectHighlighter {
166 fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
167 let Some(syntax) = lang.and_then(|l| self.syntaxes.find_syntax_by_token(l)) else {
168 return Html(plain_code(code, lang));
169 };
170 let mut generator =
171 ClassedHTMLGenerator::new_with_class_style(syntax, self.syntaxes, CLASS_STYLE);
172 for line in LinesWithEndings::from(code) {
173 if generator
174 .parse_html_for_line_which_includes_newline(line)
175 .is_err()
176 {
177 return Html(plain_code(code, lang));
178 }
179 }
180 Html(format!(
181 "<pre><code class=\"{} highlight\">{}</code></pre>\n",
182 language_class(lang),
183 generator.finalize()
184 ))
185 }
186}
187
188fn plain_code(code: &str, lang: Option<&str>) -> String {
189 format!(
190 "<pre><code class=\"{}\">{}</code></pre>\n",
191 language_class(lang),
192 escape_html(code)
193 )
194}
195
196fn language_class(lang: Option<&str>) -> String {
197 match lang {
198 Some(l) => format!("language-{}", escape_attr(l)),
199 None => "language-none".to_string(),
200 }
201}
202
203/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
204struct Renderer<'a> {
205 hl: &'a dyn Highlighter,
206 opts: RenderOptions,
207 /// Block footnote definitions, keyed by label (collected before the walk).
208 block_defs: HashMap<String, Vec<Element>>,
209 /// Inline footnote definitions discovered at reference sites.
210 inline_defs: HashMap<String, Vec<Object>>,
211 /// Reference keys in order of first appearance — drives numbering and note order.
212 order: Vec<String>,
213 /// Counter per heading depth, for section numbers.
214 counters: Vec<usize>,
215 /// Subtracted from every heading level so the document's shallowest heading renders
216 /// as level 1. Org exports levels *relative* to a file's own outline, so a file
217 /// written entirely under `**` is not a file of subsections.
218 headline_offset: u8,
219 /// Captioned figures seen so far, for `Figure N:`.
220 figures: usize,
221 /// Captioned tables seen so far, for `Table N:`.
222 tables: usize,
223}
224
225/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
226/// site rather than to the document.
227#[derive(Debug, Clone, Copy)]
228pub struct RenderOptions {
229 /// Added to every heading's level, so a level-1 org heading can render as `<h2>`
230 /// beneath a page title supplied by the layout. See
231 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
232 pub heading_offset: u8,
233 /// Prefix headings with `1.`, `1.1.`, … See
234 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
235 pub section_numbers: bool,
236 /// Convert `--`, `---` and `...` in prose. See
237 /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings).
238 pub special_strings: bool,
239 /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See
240 /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript).
241 pub sub_superscript: SubSuperscript,
242 /// Whether `\alpha` becomes `α`. See
243 /// [`HtmlOutput::entities`](crate::config::HtmlOutput::entities).
244 pub entities: bool,
245}
246
247impl Default for RenderOptions {
248 fn default() -> Self {
249 let html = crate::config::HtmlOutput::default();
250 RenderOptions {
251 heading_offset: html.heading_offset,
252 section_numbers: html.section_numbers,
253 special_strings: html.special_strings,
254 sub_superscript: html.sub_superscript,
255 entities: html.entities,
256 }
257 }
258}
259
260/// The site's render options with the document's own `#+OPTIONS:` applied on top.
261///
262/// Org's per-file switches are the author's override of a site-wide setting, and they
263/// belong here rather than at each call site — otherwise rendering one document two ways
264/// depends on which caller remembered to read its keywords.
265fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions {
266 RenderOptions {
267 heading_offset: opts.heading_offset,
268 section_numbers: option_enabled(keywords, "num", opts.section_numbers),
269 special_strings: option_enabled(keywords, "-", opts.special_strings),
270 // `^:` has three values rather than two — `nil`, `{}` or on — so it is read
271 // directly instead of through the boolean helper.
272 sub_superscript: match export_options(keywords).get("^") {
273 Some(value) => SubSuperscript::from_option(value),
274 None => opts.sub_superscript,
275 },
276 entities: option_enabled(keywords, "e", opts.entities),
277 }
278}
279
280/// How much to subtract from every heading level, so a document's shallowest heading
281/// renders as level 1.
282///
283/// Org exports outline levels *relative to the file*: a document written entirely under
284/// `**` is a document of top-level sections that happen to be indented, not a document of
285/// subsections. Emacs computes this from the shallowest top-level headline, which is what
286/// makes the same subtree export identically whether it was cut from a larger file or
287/// written on its own.
288fn headline_offset(root: &Section) -> u8 {
289 root.children
290 .iter()
291 .filter_map(|s| s.heading.as_ref())
292 .map(|h| h.level)
293 .min()
294 .map(|min| min.saturating_sub(1))
295 .unwrap_or(0)
296}
297
298/// Render a resolved document to an HTML fragment, with default options.
299pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
300 render_with(doc, highlighter, &RenderOptions::default())
301}
302
303/// Render a resolved document to an HTML fragment.
304pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
305 let mut r = Renderer {
306 hl: highlighter,
307 opts: document_options(&doc.document.keywords, opts),
308 block_defs: HashMap::new(),
309 inline_defs: HashMap::new(),
310 order: Vec::new(),
311 counters: Vec::new(),
312 headline_offset: headline_offset(&doc.document.root),
313 figures: 0,
314 tables: 0,
315 };
316 r.collect_defs(&doc.document.root);
317 let mut out = String::new();
318 r.render_section(&doc.document.root, &mut out);
319 r.emit_footnotes(&mut out);
320 Html(out)
321}
322
323impl Renderer<'_> {
324 /// First footnote pass: gather every block definition in the tree by label.
325 fn collect_defs(&mut self, section: &Section) {
326 collect_defs_in(§ion.content, &mut self.block_defs);
327 for child in §ion.children {
328 self.collect_defs(child);
329 }
330 }
331
332 fn render_section(&mut self, section: &Section, out: &mut String) {
333 if let Some(h) = §ion.heading {
334 let relative = h.level.saturating_sub(self.headline_offset).max(1);
335 let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6);
336 let anchor = heading_anchor(h);
337 // A heading with no title text has no meaningful slug; emit no `id` at all
338 // rather than a run of duplicate empty ones.
339 if anchor.is_empty() {
340 out.push_str(&format!("<h{}>", level));
341 } else {
342 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
343 }
344 if self.opts.section_numbers {
345 let number = self.next_section_number(relative);
346 out.push_str(&format!(
347 "<span class=\"section-number-{}\">{number}</span> ",
348 level
349 ));
350 }
351 // Keyword/priority markup mirrors Emacs' own HTML export classes, so output
352 // stays diffable against an `emacs --batch` oracle.
353 if let Some(todo) = &h.todo {
354 out.push_str(&format!(
355 "<span class=\"{} {}\">{}</span> ",
356 if todo.done { "done" } else { "todo" },
357 escape_attr(&todo.name),
358 escape_html(&todo.name)
359 ));
360 }
361 if let Some(priority) = h.priority {
362 out.push_str(&format!(
363 "<span class=\"priority\">[#{}]</span> ",
364 escape_html(&priority.to_string())
365 ));
366 }
367 self.render_objects(&h.title, out);
368 for tag in &h.tags {
369 out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag)));
370 }
371 out.push_str(&format!("</h{}>\n", level));
372 }
373 for element in §ion.content {
374 self.render_element(element, out);
375 }
376 for child in §ion.children {
377 self.render_section(child, out);
378 }
379 }
380
381 /// The next section number at `level`, e.g. `1.`, `1.1.`, `2.`.
382 ///
383 /// Deeper levels reset when a shallower one advances, and a document that skips a
384 /// level (a `***` under a `*`) simply starts the missing levels at 1 rather than
385 /// being treated as malformed.
386 fn next_section_number(&mut self, level: u8) -> String {
387 let depth = usize::from(level).max(1);
388 self.counters.truncate(depth);
389 while self.counters.len() < depth {
390 self.counters.push(0);
391 }
392 self.counters[depth - 1] += 1;
393 let parts: Vec<String> = self.counters.iter().map(usize::to_string).collect();
394 format!("{}.", parts.join("."))
395 }
396
397 fn render_element(&mut self, element: &Element, out: &mut String) {
398 match element {
399 Element::Paragraph(objs) => {
400 out.push_str("<p>");
401 self.render_objects(objs, out);
402 out.push_str("</p>\n");
403 }
404 Element::List(list) => self.render_list(list, out),
405 Element::Table(table) => self.render_table(table, out),
406 Element::SrcBlock { lang, code, .. } => {
407 let Html(h) = self.hl.highlight(code, lang.as_deref());
408 out.push_str(&h);
409 }
410 Element::ExampleBlock(code) => {
411 out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
412 }
413 Element::QuoteBlock(inner) => {
414 out.push_str("<blockquote>\n");
415 for el in inner {
416 self.render_element(el, out);
417 }
418 out.push_str("</blockquote>\n");
419 }
420 Element::CenterBlock(inner) => {
421 out.push_str("<div class=\"center\">\n");
422 for el in inner {
423 self.render_element(el, out);
424 }
425 out.push_str("</div>\n");
426 }
427 // An `html` export block is verbatim output by definition; every other
428 // backend is out of scope and drops (README §OUT).
429 Element::ExportBlock { backend, raw } => {
430 if backend.eq_ignore_ascii_case("html") {
431 out.push_str(raw);
432 out.push('\n');
433 }
434 }
435 Element::Figure {
436 link,
437 caption,
438 attrs,
439 } => {
440 out.push_str("<figure>");
441 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
442 if !caption.is_empty() {
443 self.figures += 1;
444 out.push_str(&format!(
445 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
446 self.figures
447 ));
448 self.render_objects(caption, out);
449 out.push_str("</figcaption>");
450 }
451 out.push_str("</figure>\n");
452 }
453 Element::SpecialBlock { name, content } => {
454 out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name)));
455 for child in content {
456 self.render_element(child, out);
457 }
458 out.push_str("</div>\n");
459 }
460 Element::VerseBlock(lines) => {
461 out.push_str("<p class=\"verse\">\n");
462 for (i, line) in lines.iter().enumerate() {
463 if i > 0 {
464 out.push_str("<br>\n");
465 }
466 self.render_objects(&crate::parser::inline(line), out);
467 }
468 out.push_str("\n</p>\n");
469 }
470 Element::HorizontalRule => out.push_str("<hr>\n"),
471 // Definitions are emitted in the footnotes section, not inline.
472 Element::FootnoteDefinition { .. } => {}
473 // Out of scope (generic drawers, stray keywords, comments): emitted as
474 // nothing rather than crashing.
475 Element::Drawer { .. } | Element::Keyword { .. } | Element::Comment(_) => {}
476 }
477 }
478
479 fn render_list(&mut self, list: &crate::model::List, out: &mut String) {
480 if list.kind == ListKind::Description {
481 out.push_str("<dl>\n");
482 for item in &list.items {
483 out.push_str("<dt>");
484 if let Some(term) = &item.term {
485 self.render_objects(term, out);
486 }
487 out.push_str("</dt>\n<dd>");
488 self.render_item_content(&item.content, out);
489 out.push_str("</dd>\n");
490 }
491 out.push_str("</dl>\n");
492 return;
493 }
494 let tag = if list.kind == ListKind::Ordered {
495 "ol"
496 } else {
497 "ul"
498 };
499 out.push_str(&format!("<{}>\n", tag));
500 for item in &list.items {
501 // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
502 // partially done — that a disabled <input> cannot express. The state goes on
503 // the item, where it can style the whole line.
504 let class = item.checkbox.as_ref().map(|cb| match cb {
505 Checkbox::On => "on",
506 Checkbox::Off => "off",
507 Checkbox::Trans => "trans",
508 });
509 out.push_str("<li");
510 if let Some(class) = class {
511 out.push_str(&format!(" class=\"{class}\""));
512 }
513 // `[@4]` restarts the numbering, and HTML says so with `value`.
514 if let Some(n) = item.counter {
515 out.push_str(&format!(" value=\"{n}\""));
516 }
517 out.push('>');
518 if let Some(cb) = &item.checkbox {
519 out.push_str(match cb {
520 Checkbox::On => "<code>[X]</code> ",
521 Checkbox::Off => "<code>[ ]</code> ",
522 Checkbox::Trans => "<code>[-]</code> ",
523 });
524 }
525 self.render_item_content(&item.content, out);
526 out.push_str("</li>\n");
527 }
528 out.push_str(&format!("</{}>\n", tag));
529 }
530
531 /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than
532 /// `<li><p>text</p><ul>…` — which is what org does and what makes a nested list read
533 /// as a continuation of its parent item. An item holding *several* paragraphs wraps
534 /// them all, so they do not run together.
535 fn render_item_content(&mut self, content: &[Element], out: &mut String) {
536 let lead_is_bare = matches!(content.first(), Some(Element::Paragraph(_)))
537 && !content[1..]
538 .iter()
539 .any(|el| matches!(el, Element::Paragraph(_)));
540 let mut rest = content;
541 if lead_is_bare {
542 if let Some((Element::Paragraph(objs), tail)) = content.split_first() {
543 self.render_objects(objs, out);
544 rest = tail;
545 }
546 }
547 for el in rest {
548 self.render_element(el, out);
549 }
550 }
551
552 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
553 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
554 let table = strip_special_column(table);
555 let table = &table;
556 let rule_at = table
557 .rows
558 .iter()
559 .position(|r| matches!(r, TableRow::Rule));
560 out.push_str("<table>\n");
561 if !table.caption.is_empty() {
562 self.tables += 1;
563 out.push_str(&format!(
564 "<caption><span class=\"table-number\">Table {}: </span>",
565 self.tables
566 ));
567 let caption = table.caption.clone();
568 self.render_objects(&caption, out);
569 out.push_str("</caption>\n");
570 }
571 let mut wrote_body = false;
572 let mut in_body = rule_at.is_none();
573 for (idx, row) in table.rows.iter().enumerate() {
574 match row {
575 TableRow::Rule => {
576 if Some(idx) == rule_at {
577 in_body = true;
578 }
579 continue;
580 }
581 TableRow::Cells(cells) => {
582 let (open, cell_tag) = if in_body {
583 if !wrote_body {
584 wrote_body = true;
585 ("<tbody>\n<tr>", "td")
586 } else {
587 ("<tr>", "td")
588 }
589 } else {
590 ("<thead>\n<tr>", "th")
591 };
592 out.push_str(open);
593 for cell in cells {
594 out.push_str(&format!("<{}>", cell_tag));
595 self.render_objects(cell, out);
596 out.push_str(&format!("</{}>", cell_tag));
597 }
598 out.push_str("</tr>\n");
599 // Close the header band right after its last row.
600 if !in_body
601 && rule_at.map(|r| idx + 1 == r).unwrap_or(false)
602 {
603 out.push_str("</thead>\n");
604 }
605 }
606 }
607 }
608 if wrote_body {
609 out.push_str("</tbody>\n");
610 }
611 out.push_str("</table>\n");
612 }
613
614 /// Plain text as HTML: escaped, then org's export-time text conversions.
615 ///
616 /// Both run on the *escaped* string so their output tags survive, and both are
617 /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
618 /// different objects, which is what keeps `--verbose` in a shell transcript intact.
619 fn text_html(&self, t: &str) -> String {
620 let escaped = escape_html(t);
621 let mut out = String::with_capacity(escaped.len());
622 // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
623 // superscript for us, and an em dash inside a formula is not what was meant.
624 for (span, is_latex) in latex_split(&escaped) {
625 if is_latex {
626 out.push_str(span);
627 continue;
628 }
629 let with_strings = if self.opts.special_strings {
630 special_strings(span)
631 } else {
632 span.to_string()
633 };
634 out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
635 }
636 out
637 }
638
639 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
640 for obj in objs {
641 self.render_object(obj, out);
642 }
643 }
644
645 fn render_object(&mut self, obj: &Object, out: &mut String) {
646 match obj {
647 Object::Text(t) => out.push_str(&self.text_html(t)),
648 // The table's HTML column is an entity reference, so it is emitted raw.
649 Object::Entity(name) => match self.opts.entities {
650 true => out.push_str(crate::entities::lookup(name).unwrap_or(name)),
651 false => out.push_str(&escape_html(&format!("\\{name}"))),
652 },
653 Object::Bold(inner) => self.wrap(out, "strong", inner),
654 Object::Italic(inner) => self.wrap(out, "em", inner),
655 Object::Underline(inner) => self.wrap(out, "u", inner),
656 Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
657 Object::Verbatim(s) => {
658 out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
659 }
660 Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
661 // A description-less link to an image is the image itself, not a link to it.
662 Object::Link(link) if link.description.is_none() && is_image_target(&link.target) => {
663 out.push_str(&image_tag(link, "", ""));
664 }
665 Object::Link(link) => {
666 let href = link_href(&link.target);
667 out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
668 match &link.description {
669 Some(desc) => self.render_objects(desc, out),
670 None => out.push_str(&escape_html(&link_text(&link.target))),
671 }
672 out.push_str("</a>");
673 }
674 Object::FootnoteRef { label, inline } => {
675 let key = if label.is_empty() {
676 format!("__anon{}", self.order.len() + 1)
677 } else {
678 label.clone()
679 };
680 if let Some(objs) = inline {
681 self.inline_defs.insert(key.clone(), objs.clone());
682 }
683 if !self.order.contains(&key) {
684 self.order.push(key.clone());
685 }
686 let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
687 out.push_str(&format!(
688 "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
689 n = num
690 ));
691 }
692 Object::LineBreak => out.push_str("<br>\n"),
693 Object::Timestamp(ts) => out.push_str(×tamp_html(ts)),
694 }
695 }
696
697 fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
698 out.push_str(&format!("<{}>", tag));
699 self.render_objects(inner, out);
700 out.push_str(&format!("</{}>", tag));
701 }
702
703 /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
704 fn emit_footnotes(&mut self, out: &mut String) {
705 if self.order.is_empty() {
706 return;
707 }
708 let order = self.order.clone();
709 let inline_defs = self.inline_defs.clone();
710 let block_defs = self.block_defs.clone();
711 // Named, because a `<hr>` is a picture of a section break rather than a section:
712 // without the label this landmark is announced as "section" and the reader has to
713 // guess what they have reached.
714 out.push_str(
715 "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n",
716 );
717 for (idx, label) in order.iter().enumerate() {
718 let n = idx + 1;
719 out.push_str(&format!("<li id=\"fn-{n}\">"));
720 if let Some(objs) = inline_defs.get(label) {
721 self.render_objects(objs, out);
722 } else if let Some(els) = block_defs.get(label) {
723 for el in els {
724 self.render_element(el, out);
725 }
726 }
727 // The glyph is the whole visible link, so it is also the whole accessible
728 // name: a screen reader would otherwise announce "left arrow with hook",
729 // identically, once per note.
730 out.push_str(&format!(
731 " <a class=\"footnote-back\" href=\"#fnr-{n}\" \
732 aria-label=\"Back to reference {n}\">↩</a></li>\n"
733 ));
734 }
735 out.push_str("</ol>\n</section>\n");
736 }
737}
738
739fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
740 for el in elements {
741 if let Element::FootnoteDefinition { label, content } = el {
742 defs.entry(label.clone()).or_insert_with(|| content.clone());
743 }
744 }
745}
746
747/// An `<img>` for an image link, carrying any `#+ATTR_HTML:` attributes and falling back
748/// to the caption for alt text — but only when the author did not write an `:alt` of
749/// their own, since two `alt` attributes on one tag is invalid HTML.
750fn image_tag(link: &Link, attrs: &str, alt: &str) -> String {
751 let mut pairs = attr_html(attrs);
752 if !pairs.iter().any(|(k, _)| k.eq_ignore_ascii_case("alt")) {
753 pairs.insert(0, ("alt".to_string(), alt.to_string()));
754 }
755 let attributes: String = pairs
756 .iter()
757 .map(|(k, v)| format!(" {}=\"{}\"", escape_attr(k), escape_attr(v)))
758 .collect();
759 format!(
760 "<img src=\"{}\"{}>",
761 escape_attr(&link_href(&link.target)),
762 attributes
763 )
764}
765
766/// `#+ATTR_HTML: :width 400 :class hero` → `[(width, 400), (class, hero)]`. Values run to
767/// the next `:key` token and may be double-quoted to include spaces. A malformed spec
768/// contributes nothing rather than emitting broken markup.
769fn attr_html(spec: &str) -> Vec<(String, String)> {
770 let mut out = Vec::new();
771 let mut key: Option<&str> = None;
772 let mut value = String::new();
773 let mut quoted: Option<String> = None;
774
775 let flush = |out: &mut Vec<(String, String)>, key: &mut Option<&str>, value: &mut String| {
776 if let Some(k) = key.take() {
777 out.push((k.to_string(), value.trim().to_string()));
778 }
779 value.clear();
780 };
781
782 for token in spec.split_whitespace() {
783 // Inside a quoted value, everything up to the closing quote is literal.
784 if let Some(buf) = &mut quoted {
785 buf.push(' ');
786 buf.push_str(token.trim_end_matches('"'));
787 if token.ends_with('"') {
788 value = quoted.take().expect("quoted value in progress");
789 }
790 continue;
791 }
792 if let Some(k) = token.strip_prefix(':') {
793 flush(&mut out, &mut key, &mut value);
794 if !k.is_empty() {
795 key = Some(k);
796 }
797 continue;
798 }
799 if key.is_none() {
800 continue;
801 }
802 if let Some(rest) = token.strip_prefix('"') {
803 if let Some(inner) = rest.strip_suffix('"') {
804 value = inner.to_string();
805 } else {
806 quoted = Some(rest.to_string());
807 }
808 continue;
809 }
810 if !value.is_empty() {
811 value.push(' ');
812 }
813 value.push_str(token);
814 }
815 if let Some(buf) = quoted {
816 value = buf;
817 }
818 flush(&mut out, &mut key, &mut value);
819 out
820}
821
822/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range
823/// abbreviates its end to just the time.
824fn timestamp_html(ts: &crate::model::Timestamp) -> String {
825 let class = if ts.active {
826 "timestamp"
827 } else {
828 "timestamp inactive"
829 };
830 let one = |dt: &chrono::NaiveDateTime, text: String| {
831 let attr = if ts.has_time {
832 dt.format("%Y-%m-%dT%H:%M").to_string()
833 } else {
834 dt.format("%Y-%m-%d").to_string()
835 };
836 format!(
837 "<time class=\"{class}\" datetime=\"{}\">{}</time>",
838 escape_attr(&attr),
839 escape_html(&text)
840 )
841 };
842 let text_of = |dt: &chrono::NaiveDateTime| {
843 if ts.has_time {
844 dt.format("%Y-%m-%d %H:%M").to_string()
845 } else {
846 dt.format("%Y-%m-%d").to_string()
847 }
848 };
849
850 let mut out = one(&ts.start, text_of(&ts.start));
851 if let Some(end) = &ts.end {
852 out.push_str("–");
853 let text = if end.date() == ts.start.date() && ts.has_time {
854 end.format("%H:%M").to_string()
855 } else {
856 text_of(end)
857 };
858 out.push_str(&one(end, text));
859 }
860 out
861}
862
863/// Best-effort URL for a link target. After RESOLVE, internal targets have been
864/// rewritten to `External` with their final URL; anything still internal here is an
865/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
866fn link_href(target: &LinkTarget) -> String {
867 match target {
868 LinkTarget::External(s) => s.clone(),
869 LinkTarget::CustomId(id) => format!("#{}", id),
870 LinkTarget::Id(id) => format!("#{}", id),
871 LinkTarget::Heading(text) => format!("#{}", slugify(text)),
872 LinkTarget::File { path, .. } => path.to_string(),
873 }
874}
875
876fn link_text(target: &LinkTarget) -> String {
877 match target {
878 LinkTarget::External(s) => s.clone(),
879 LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
880 LinkTarget::Heading(text) => text.clone(),
881 LinkTarget::File { path, .. } => path.to_string(),
882 }
883}
884
885/// Drop org's special column and its marker rows.
886///
887/// A table's first column may hold export markers rather than data — `/` marks a column
888/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
889/// instructions to org and never appear in the output; the column itself disappears when
890/// *every* row uses it that way, which is what stops a table of formulas from publishing
891/// with a stray column of hashes.
892fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
893 let first_cell = |row: &TableRow| -> Option<String> {
894 match row {
895 TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
896 TableRow::Rule => None,
897 }
898 };
899 let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
900 let column_is_special = data_rows().count() > 0
901 && data_rows().all(|row| {
902 matches!(
903 first_cell(row).as_deref(),
904 Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
905 )
906 });
907
908 let rows = table
909 .rows
910 .iter()
911 .filter(|row| {
912 // A marker row is an instruction, not content.
913 !matches!(
914 first_cell(row).as_deref(),
915 Some("/" | "!" | "^" | "_" | "$")
916 )
917 })
918 .map(|row| match (row, column_is_special) {
919 (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
920 _ => row.clone(),
921 })
922 .collect();
923 crate::model::Table {
924 rows,
925 caption: table.caption.clone(),
926 }
927}
928
929/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
930///
931/// orgo does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
932/// but it must know where a fragment *is*, because the export-time text conversions would
933/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
934/// marked up, and `--` inside one is a minus sign twice.
935fn latex_split(s: &str) -> Vec<(&str, bool)> {
936 let bytes = s.as_bytes();
937 let mut spans = Vec::new();
938 let mut plain_from = 0;
939 let mut i = 0;
940 while i < s.len() {
941 if !s.is_char_boundary(i) {
942 i += 1;
943 continue;
944 }
945 let end = match bytes[i] {
946 b'\\' => latex_backslash_end(s, i),
947 b'$' => latex_dollar_end(s, i),
948 _ => None,
949 };
950 if let Some(end) = end {
951 if plain_from < i {
952 spans.push((&s[plain_from..i], false));
953 }
954 spans.push((&s[i..end], true));
955 plain_from = end;
956 i = end;
957 continue;
958 }
959 i += 1;
960 }
961 if plain_from < s.len() {
962 spans.push((&s[plain_from..], false));
963 }
964 spans
965}
966
967/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
968fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
969 let rest = &s[i..];
970 for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
971 if let Some(body) = rest.strip_prefix(open) {
972 return body.find(close).map(|k| i + open.len() + k + close.len());
973 }
974 }
975 let after = rest.strip_prefix("\\begin{")?;
976 let name_end = after.find('}')?;
977 let end_tag = format!("\\end{{{}}}", &after[..name_end]);
978 let k = rest.find(&end_tag)?;
979 Some(i + k + end_tag.len())
980}
981
982/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
983///
984/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
985/// out — the same heuristic org uses, and the same one that means a sentence with two
986/// unrelated dollar amounts and no space between them will be read as math.
987fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
988 let body = &s[i + 1..];
989 let close = body.find('$')?;
990 if close == 0 {
991 return None;
992 }
993 let inner = &body[..close];
994 if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
995 return None;
996 }
997 if inner.contains('\n') {
998 return None;
999 }
1000 Some(i + 1 + close + 1)
1001}
1002
1003/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
1004/// and `\-` a soft hyphen.
1005///
1006/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
1007/// very end of a line, or the `-----` someone drew as a rule, is left alone.
1008fn special_strings(s: &str) -> String {
1009 let chars: Vec<char> = s.chars().collect();
1010 let mut out = String::with_capacity(s.len());
1011 let mut i = 0;
1012 while i < chars.len() {
1013 let rest = &chars[i..];
1014 let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
1015 if rest.starts_with(&['\\', '-']) {
1016 out.push('\u{00ad}');
1017 i += 2;
1018 } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
1019 out.push('\u{2014}');
1020 i += 3;
1021 } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
1022 out.push('\u{2013}');
1023 i += 2;
1024 } else if rest.starts_with(&['.', '.', '.']) {
1025 out.push('\u{2026}');
1026 i += 3;
1027 } else {
1028 out.push(chars[i]);
1029 i += 1;
1030 }
1031 }
1032 out
1033}
1034
1035/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
1036///
1037/// Both require a non-whitespace character before the marker, which is what separates a
1038/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
1039/// that form requires whitespace *before* the marker.
1040fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
1041 if mode == SubSuperscript::No || !s.contains(['_', '^']) {
1042 return s.to_string();
1043 }
1044 let chars: Vec<char> = s.chars().collect();
1045 let mut out = String::with_capacity(s.len());
1046 let mut i = 0;
1047 while i < chars.len() {
1048 let c = chars[i];
1049 let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
1050 if (c == '_' || c == '^') && prev_ok {
1051 if let Some((body, next)) = script_body(&chars, i + 1, mode) {
1052 let tag = if c == '_' { "sub" } else { "sup" };
1053 out.push_str(&format!("<{tag}>{body}</{tag}>"));
1054 i = next;
1055 continue;
1056 }
1057 }
1058 out.push(c);
1059 i += 1;
1060 }
1061 out
1062}
1063
1064/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
1065/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
1066fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
1067 if chars.get(start) == Some(&'{') {
1068 let mut depth = 1;
1069 let mut j = start + 1;
1070 while j < chars.len() {
1071 match chars[j] {
1072 '{' => depth += 1,
1073 '}' => {
1074 depth -= 1;
1075 if depth == 0 {
1076 return Some((chars[start + 1..j].iter().collect(), j + 1));
1077 }
1078 }
1079 _ => {}
1080 }
1081 j += 1;
1082 }
1083 return None;
1084 }
1085 if mode == SubSuperscript::Braces {
1086 return None;
1087 }
1088 let mut j = start;
1089 if matches!(chars.get(j), Some('+') | Some('-')) {
1090 j += 1;
1091 }
1092 let mut last_alnum = None;
1093 while let Some(&c) = chars.get(j) {
1094 if c.is_alphanumeric() {
1095 last_alnum = Some(j);
1096 } else if !matches!(c, '.' | ',' | '\\') {
1097 break;
1098 }
1099 j += 1;
1100 }
1101 let end = last_alnum? + 1;
1102 Some((chars[start..end].iter().collect(), end))
1103}
1104
1105fn escape_html(s: &str) -> String {
1106 let mut out = String::with_capacity(s.len());
1107 for c in s.chars() {
1108 match c {
1109 '&' => out.push_str("&"),
1110 '<' => out.push_str("<"),
1111 '>' => out.push_str(">"),
1112 _ => out.push(c),
1113 }
1114 }
1115 out
1116}
1117
1118fn escape_attr(s: &str) -> String {
1119 let mut out = escape_html(s);
1120 out = out.replace('"', """);
1121 out
1122}