krz/orgo

Lightning fast org-mode static site generator.

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

v0.22.0: src/render.rs · raw

   1//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment.
   2//!
   3//! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4):
   4//! 1. Footnotes use a two-pass layout — definitions are collected up front, references
   5//!    are numbered in order of first appearance during the walk, and a back-linked
   6//!    notes section is emitted at page end.
   7//! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern
   8//!    and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline
   9//!    styles, so themes live in the stylesheet (spec §3.2).
  10//!
  11//! 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 `&alpha;`. 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(&section.content, &mut self.block_defs);
 365        for child in &section.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) = &section.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 &section.content {
 412            self.render_element(element, out);
 413        }
 414        for child in &section.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>[&nbsp;]</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(&timestamp_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}\">&#8617;</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("&#8211;");
 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("&amp;"),
1148            '<' => out.push_str("&lt;"),
1149            '>' => out.push_str("&gt;"),
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('"', "&quot;");
1159    out
1160}