krz/orgo

Lightning fast org-mode static site generator.

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

main: src/render.rs · raw

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