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