krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

src/render.rs

1185 lines · 46033 bytes

   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                self.render_wrapped("<blockquote>\n", inner, "</blockquote>\n", out);
 454            }
 455            Element::CenterBlock(inner) => {
 456                self.render_wrapped("<div class=\"center\">\n", inner, "</div>\n", out);
 457            }
 458            // An `html` export block is verbatim output by definition; every other
 459            // backend is out of scope and drops (§"Not supported").
 460            Element::ExportBlock { backend, raw } => {
 461                if backend.eq_ignore_ascii_case("html") {
 462                    out.push_str(raw);
 463                    out.push('\n');
 464                }
 465            }
 466            Element::Figure {
 467                link,
 468                caption,
 469                attrs,
 470            } => self.render_figure(link, caption, attrs, out),
 471            Element::SpecialBlock { name, content } => {
 472                let open = format!("<div class=\"{}\">\n", escape_attr(name));
 473                self.render_wrapped(&open, content, "</div>\n", out);
 474            }
 475            Element::VerseBlock(lines) => self.render_verse(lines, out),
 476            Element::HorizontalRule => out.push_str("<hr>\n"),
 477            // Definitions are emitted in the footnotes section, not inline.
 478            Element::FootnoteDefinition { .. } => {}
 479            // Out of scope (generic drawers, stray keywords, comments): emitted as
 480            // nothing rather than crashing.
 481            Element::Drawer { .. } | Element::Keyword { .. } | Element::Comment(_) => {}
 482        }
 483    }
 484
 485    fn render_wrapped(&mut self, open: &str, inner: &[Element], close: &str, out: &mut String) {
 486        out.push_str(open);
 487        for el in inner {
 488            self.render_element(el, out);
 489        }
 490        out.push_str(close);
 491    }
 492
 493    fn render_figure(
 494        &mut self,
 495        link: &Link,
 496        caption: &[Object],
 497        attrs: &str,
 498        out: &mut String,
 499    ) {
 500        out.push_str("<figure>");
 501        out.push_str(&image_tag(link, attrs, &plain_text(caption)));
 502        if !caption.is_empty() {
 503            self.figures += 1;
 504            out.push_str(&format!(
 505                "<figcaption><span class=\"figure-number\">Figure {}: </span>",
 506                self.figures
 507            ));
 508            self.render_objects(caption, out);
 509            out.push_str("</figcaption>");
 510        }
 511        out.push_str("</figure>\n");
 512    }
 513
 514    fn render_verse(&mut self, lines: &[String], out: &mut String) {
 515        out.push_str("<p class=\"verse\">\n");
 516        for (i, line) in lines.iter().enumerate() {
 517            if i > 0 {
 518                out.push_str("<br>\n");
 519            }
 520            self.render_objects(&crate::parser::inline(line), out);
 521        }
 522        out.push_str("\n</p>\n");
 523    }
 524
 525    fn render_list(&mut self, list: &crate::model::List, out: &mut String) {
 526        if list.kind == ListKind::Description {
 527            self.render_description_list(list, out);
 528            return;
 529        }
 530        let tag = if list.kind == ListKind::Ordered {
 531            "ol"
 532        } else {
 533            "ul"
 534        };
 535        out.push_str(&format!("<{}>\n", tag));
 536        for item in &list.items {
 537            self.render_list_item(item, out);
 538        }
 539        out.push_str(&format!("</{}>\n", tag));
 540    }
 541
 542    fn render_description_list(&mut self, list: &crate::model::List, out: &mut String) {
 543        out.push_str("<dl>\n");
 544        for item in &list.items {
 545            out.push_str("<dt>");
 546            if let Some(term) = &item.term {
 547                self.render_objects(term, out);
 548            }
 549            out.push_str("</dt>\n<dd>");
 550            self.render_item_content(&item.content, out);
 551            out.push_str("</dd>\n");
 552        }
 553        out.push_str("</dl>\n");
 554    }
 555
 556    fn render_list_item(&mut self, item: &crate::model::ListItem, out: &mut String) {
 557        // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
 558        // partially done — that a disabled <input> cannot express. The state goes on
 559        // the item, where it can style the whole line.
 560        let class = item.checkbox.as_ref().map(|cb| match cb {
 561            Checkbox::On => "on",
 562            Checkbox::Off => "off",
 563            Checkbox::Trans => "trans",
 564        });
 565        out.push_str("<li");
 566        if let Some(class) = class {
 567            out.push_str(&format!(" class=\"{class}\""));
 568        }
 569        // `[@4]` restarts the numbering, and HTML says so with `value`.
 570        if let Some(n) = item.counter {
 571            out.push_str(&format!(" value=\"{n}\""));
 572        }
 573        out.push('>');
 574        if let Some(cb) = &item.checkbox {
 575            out.push_str(match cb {
 576                Checkbox::On => "<code>[X]</code> ",
 577                Checkbox::Off => "<code>[&nbsp;]</code> ",
 578                Checkbox::Trans => "<code>[-]</code> ",
 579            });
 580        }
 581        self.render_item_content(&item.content, out);
 582        out.push_str("</li>\n");
 583    }
 584
 585    /// A single-paragraph item renders its text bare — `<li>text<ul>…` rather than
 586    /// `<li><p>text</p><ul>…` — which is what org does and what makes a nested list read
 587    /// as a continuation of its parent item. An item holding *several* paragraphs wraps
 588    /// them all, so they do not run together.
 589    fn render_item_content(&mut self, content: &[Element], out: &mut String) {
 590        let lead_is_bare = matches!(content.first(), Some(Element::Paragraph(_)))
 591            && !content[1..]
 592                .iter()
 593                .any(|el| matches!(el, Element::Paragraph(_)));
 594        let mut rest = content;
 595        if lead_is_bare {
 596            if let Some((Element::Paragraph(objs), tail)) = content.split_first() {
 597                self.render_objects(objs, out);
 598                rest = tail;
 599            }
 600        }
 601        for el in rest {
 602            self.render_element(el, out);
 603        }
 604    }
 605
 606    /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
 607    fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
 608        let table = strip_special_column(table);
 609        let table = &table;
 610        let rule_at = table
 611            .rows
 612            .iter()
 613            .position(|r| matches!(r, TableRow::Rule));
 614        out.push_str("<table>\n");
 615        if !table.caption.is_empty() {
 616            self.render_table_caption(&table.caption, out);
 617        }
 618        let (head, body) = match rule_at {
 619            Some(r) => (&table.rows[..r], &table.rows[r + 1..]),
 620            None => (&table.rows[..0], &table.rows[..]),
 621        };
 622        self.render_band("thead", "th", head, out);
 623        self.render_band("tbody", "td", body, out);
 624        out.push_str("</table>\n");
 625    }
 626
 627    fn render_table_caption(&mut self, caption: &[Object], out: &mut String) {
 628        self.tables += 1;
 629        out.push_str(&format!(
 630            "<caption><span class=\"table-number\">Table {}: </span>",
 631            self.tables
 632        ));
 633        self.render_objects(caption, out);
 634        out.push_str("</caption>\n");
 635    }
 636
 637    /// One `<thead>` or `<tbody>` of `rows`, skipping rule rows. Nothing at all when
 638    /// there are no cell rows.
 639    fn render_band(&mut self, band: &str, cell_tag: &str, rows: &[TableRow], out: &mut String) {
 640        let mut opened = false;
 641        for row in rows {
 642            let TableRow::Cells(cells) = row else { continue };
 643            if !opened {
 644                out.push_str(&format!("<{band}>\n"));
 645                opened = true;
 646            }
 647            out.push_str("<tr>");
 648            for cell in cells {
 649                out.push_str(&format!("<{}>", cell_tag));
 650                self.render_objects(cell, out);
 651                out.push_str(&format!("</{}>", cell_tag));
 652            }
 653            out.push_str("</tr>\n");
 654        }
 655        if opened {
 656            out.push_str(&format!("</{band}>\n"));
 657        }
 658    }
 659
 660    /// Plain text as HTML: escaped, then org's export-time text conversions.
 661    ///
 662    /// Both run on the *escaped* string so their output tags survive, and both are
 663    /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
 664    /// different objects, which is what keeps `--verbose` in a shell transcript intact.
 665    fn text_html(&self, t: &str) -> String {
 666        let escaped = escape_html(t);
 667        let mut out = String::with_capacity(escaped.len());
 668        // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
 669        // superscript for us, and an em dash inside a formula is not what was meant.
 670        for (span, is_latex) in latex_split(&escaped) {
 671            if is_latex {
 672                out.push_str(span);
 673                continue;
 674            }
 675            let with_strings = if self.opts.special_strings {
 676                special_strings(span)
 677            } else {
 678                span.to_string()
 679            };
 680            out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
 681        }
 682        out
 683    }
 684
 685    fn render_objects(&mut self, objs: &[Object], out: &mut String) {
 686        for obj in objs {
 687            self.render_object(obj, out);
 688        }
 689    }
 690
 691    fn render_object(&mut self, obj: &Object, out: &mut String) {
 692        match obj {
 693            Object::Text(t) => out.push_str(&self.text_html(t)),
 694            // The table's HTML column is an entity reference, so it is emitted raw.
 695            Object::Entity(name) => match self.opts.entities {
 696                true => out.push_str(crate::entities::lookup(name).unwrap_or(name)),
 697                false => out.push_str(&escape_html(&format!("\\{name}"))),
 698            },
 699            Object::Bold(inner) => self.wrap(out, "strong", inner),
 700            Object::Italic(inner) => self.wrap(out, "em", inner),
 701            Object::Underline(inner) => self.wrap(out, "u", inner),
 702            Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
 703            Object::Verbatim(s) => {
 704                out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
 705            }
 706            Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
 707            // A description-less link to an image is the image itself, not a link to it.
 708            Object::Link(link) if link.description.is_none() && is_image_target(&link.target) => {
 709                out.push_str(&image_tag(link, "", ""));
 710            }
 711            Object::Link(link) => {
 712                let href = link_href(&link.target);
 713                out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
 714                match &link.description {
 715                    Some(desc) => self.render_objects(desc, out),
 716                    None => out.push_str(&escape_html(&link_text(&link.target))),
 717                }
 718                out.push_str("</a>");
 719            }
 720            Object::FootnoteRef { label, inline } => {
 721                let key = if label.is_empty() {
 722                    format!("__anon{}", self.order.len() + 1)
 723                } else {
 724                    label.clone()
 725                };
 726                if let Some(objs) = inline {
 727                    self.inline_defs.insert(key.clone(), objs.clone());
 728                }
 729                if !self.order.contains(&key) {
 730                    self.order.push(key.clone());
 731                }
 732                let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
 733                out.push_str(&format!(
 734                    "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
 735                    n = num
 736                ));
 737            }
 738            Object::LineBreak => out.push_str("<br>\n"),
 739            Object::Timestamp(ts) => out.push_str(&timestamp_html(ts)),
 740        }
 741    }
 742
 743    fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
 744        out.push_str(&format!("<{}>", tag));
 745        self.render_objects(inner, out);
 746        out.push_str(&format!("</{}>", tag));
 747    }
 748
 749    /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
 750    fn emit_footnotes(&mut self, out: &mut String) {
 751        if self.order.is_empty() {
 752            return;
 753        }
 754        let order = self.order.clone();
 755        let inline_defs = self.inline_defs.clone();
 756        let block_defs = self.block_defs.clone();
 757        // Named, because a `<hr>` is a picture of a section break rather than a section:
 758        // without the label this landmark is announced as "section" and the reader has to
 759        // guess what they have reached.
 760        out.push_str(
 761            "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n",
 762        );
 763        for (idx, label) in order.iter().enumerate() {
 764            let n = idx + 1;
 765            out.push_str(&format!("<li id=\"fn-{n}\">"));
 766            if let Some(objs) = inline_defs.get(label) {
 767                self.render_objects(objs, out);
 768            } else if let Some(els) = block_defs.get(label) {
 769                for el in els {
 770                    self.render_element(el, out);
 771                }
 772            }
 773            // The glyph is the whole visible link, so it is also the whole accessible
 774            // name: a screen reader would otherwise announce "left arrow with hook",
 775            // identically, once per note.
 776            out.push_str(&format!(
 777                " <a class=\"footnote-back\" href=\"#fnr-{n}\" \
 778                 aria-label=\"Back to reference {n}\">&#8617;</a></li>\n"
 779            ));
 780        }
 781        out.push_str("</ol>\n</section>\n");
 782    }
 783}
 784
 785fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
 786    for el in elements {
 787        if let Element::FootnoteDefinition { label, content } = el {
 788            defs.entry(label.clone()).or_insert_with(|| content.clone());
 789        }
 790    }
 791}
 792
 793/// An `<img>` for an image link, carrying any `#+ATTR_HTML:` attributes and falling back
 794/// to the caption for alt text — but only when the author did not write an `:alt` of
 795/// their own, since two `alt` attributes on one tag is invalid HTML.
 796fn image_tag(link: &Link, attrs: &str, alt: &str) -> String {
 797    let mut pairs = attr_html(attrs);
 798    if !pairs.iter().any(|(k, _)| k.eq_ignore_ascii_case("alt")) {
 799        pairs.insert(0, ("alt".to_string(), alt.to_string()));
 800    }
 801    let attributes: String = pairs
 802        .iter()
 803        .map(|(k, v)| format!(" {}=\"{}\"", escape_attr(k), escape_attr(v)))
 804        .collect();
 805    format!(
 806        "<img src=\"{}\"{}>",
 807        escape_attr(&link_href(&link.target)),
 808        attributes
 809    )
 810}
 811
 812/// `#+ATTR_HTML: :width 400 :class hero` → `[(width, 400), (class, hero)]`. Values run to
 813/// the next `:key` token and may be double-quoted to include spaces. A malformed spec
 814/// contributes nothing rather than emitting broken markup.
 815fn attr_html(spec: &str) -> Vec<(String, String)> {
 816    let mut parser = AttrParser::default();
 817    for token in spec.split_whitespace() {
 818        parser.token(token);
 819    }
 820    parser.finish()
 821}
 822
 823/// [`attr_html`]'s state between tokens.
 824#[derive(Default)]
 825struct AttrParser<'a> {
 826    out: Vec<(String, String)>,
 827    key: Option<&'a str>,
 828    value: String,
 829    /// A double-quoted value still waiting for its closing quote.
 830    quoted: Option<String>,
 831}
 832
 833impl<'a> AttrParser<'a> {
 834    fn token(&mut self, token: &'a str) {
 835        // Inside a quoted value, everything up to the closing quote is literal.
 836        if let Some(buf) = &mut self.quoted {
 837            buf.push(' ');
 838            buf.push_str(token.trim_end_matches('"'));
 839            if token.ends_with('"') {
 840                self.value = self.quoted.take().expect("quoted value in progress");
 841            }
 842            return;
 843        }
 844        if let Some(k) = token.strip_prefix(':') {
 845            self.flush();
 846            if !k.is_empty() {
 847                self.key = Some(k);
 848            }
 849            return;
 850        }
 851        if self.key.is_none() {
 852            return;
 853        }
 854        if let Some(rest) = token.strip_prefix('"') {
 855            if let Some(inner) = rest.strip_suffix('"') {
 856                self.value = inner.to_string();
 857            } else {
 858                self.quoted = Some(rest.to_string());
 859            }
 860            return;
 861        }
 862        if !self.value.is_empty() {
 863            self.value.push(' ');
 864        }
 865        self.value.push_str(token);
 866    }
 867
 868    fn flush(&mut self) {
 869        if let Some(k) = self.key.take() {
 870            self.out.push((k.to_string(), self.value.trim().to_string()));
 871        }
 872        self.value.clear();
 873    }
 874
 875    fn finish(mut self) -> Vec<(String, String)> {
 876        if let Some(buf) = self.quoted.take() {
 877            self.value = buf;
 878        }
 879        self.flush();
 880        self.out
 881    }
 882}
 883
 884/// `<time>` markup for a timestamp. A range emits both endpoints; a same-day range
 885/// abbreviates its end to just the time.
 886fn timestamp_html(ts: &crate::model::Timestamp) -> String {
 887    let class = if ts.active {
 888        "timestamp"
 889    } else {
 890        "timestamp inactive"
 891    };
 892    let one = |dt: &chrono::NaiveDateTime, text: String| {
 893        let attr = if ts.has_time {
 894            dt.format("%Y-%m-%dT%H:%M").to_string()
 895        } else {
 896            dt.format("%Y-%m-%d").to_string()
 897        };
 898        format!(
 899            "<time class=\"{class}\" datetime=\"{}\">{}</time>",
 900            escape_attr(&attr),
 901            escape_html(&text)
 902        )
 903    };
 904    let text_of = |dt: &chrono::NaiveDateTime| {
 905        if ts.has_time {
 906            dt.format("%Y-%m-%d %H:%M").to_string()
 907        } else {
 908            dt.format("%Y-%m-%d").to_string()
 909        }
 910    };
 911
 912    let mut out = one(&ts.start, text_of(&ts.start));
 913    if let Some(end) = &ts.end {
 914        out.push_str("&#8211;");
 915        let text = if end.date() == ts.start.date() && ts.has_time {
 916            end.format("%H:%M").to_string()
 917        } else {
 918            text_of(end)
 919        };
 920        out.push_str(&one(end, text));
 921    }
 922    out
 923}
 924
 925/// Best-effort URL for a link target. After RESOLVE, internal targets have been
 926/// rewritten to `External` with their final URL; anything still internal here is an
 927/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
 928fn link_href(target: &LinkTarget) -> String {
 929    match target {
 930        LinkTarget::External(s) => s.clone(),
 931        LinkTarget::CustomId(id) => format!("#{}", id),
 932        LinkTarget::Id(id) => format!("#{}", id),
 933        LinkTarget::Heading(text) => format!("#{}", slugify(text)),
 934        LinkTarget::File { path, .. } => path.to_string(),
 935    }
 936}
 937
 938fn link_text(target: &LinkTarget) -> String {
 939    match target {
 940        LinkTarget::External(s) => s.clone(),
 941        LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
 942        LinkTarget::Heading(text) => text.clone(),
 943        LinkTarget::File { path, .. } => path.to_string(),
 944    }
 945}
 946
 947/// Drop org's special column and its marker rows.
 948///
 949/// A table's first column may hold export markers rather than data — `/` marks a column
 950/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
 951/// instructions to org and never appear in the output; the column itself disappears when
 952/// *every* row uses it that way, which is what stops a table of formulas from publishing
 953/// with a stray column of hashes.
 954fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
 955    let first_cell = |row: &TableRow| -> Option<String> {
 956        match row {
 957            TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
 958            TableRow::Rule => None,
 959        }
 960    };
 961    let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
 962    let column_is_special = data_rows().count() > 0
 963        && data_rows().all(|row| {
 964            matches!(
 965                first_cell(row).as_deref(),
 966                Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
 967            )
 968        });
 969
 970    let rows = table
 971        .rows
 972        .iter()
 973        .filter(|row| {
 974            // A marker row is an instruction, not content.
 975            !matches!(
 976                first_cell(row).as_deref(),
 977                Some("/" | "!" | "^" | "_" | "$")
 978            )
 979        })
 980        .map(|row| match (row, column_is_special) {
 981            (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
 982            _ => row.clone(),
 983        })
 984        .collect();
 985    crate::model::Table {
 986        rows,
 987        caption: table.caption.clone(),
 988    }
 989}
 990
 991/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
 992///
 993/// orgo does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
 994/// but it must know where a fragment *is*, because the export-time text conversions would
 995/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
 996/// marked up, and `--` inside one is a minus sign twice.
 997fn latex_split(s: &str) -> Vec<(&str, bool)> {
 998    let bytes = s.as_bytes();
 999    let mut spans = Vec::new();
1000    let mut plain_from = 0;
1001    let mut i = 0;
1002    while i < s.len() {
1003        if !s.is_char_boundary(i) {
1004            i += 1;
1005            continue;
1006        }
1007        let end = match bytes[i] {
1008            b'\\' => latex_backslash_end(s, i),
1009            b'$' => latex_dollar_end(s, i),
1010            _ => None,
1011        };
1012        if let Some(end) = end {
1013            if plain_from < i {
1014                spans.push((&s[plain_from..i], false));
1015            }
1016            spans.push((&s[i..end], true));
1017            plain_from = end;
1018            i = end;
1019            continue;
1020        }
1021        i += 1;
1022    }
1023    if plain_from < s.len() {
1024        spans.push((&s[plain_from..], false));
1025    }
1026    spans
1027}
1028
1029/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
1030fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
1031    let rest = &s[i..];
1032    for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
1033        if let Some(body) = rest.strip_prefix(open) {
1034            return body.find(close).map(|k| i + open.len() + k + close.len());
1035        }
1036    }
1037    let after = rest.strip_prefix("\\begin{")?;
1038    let name_end = after.find('}')?;
1039    let end_tag = format!("\\end{{{}}}", &after[..name_end]);
1040    let k = rest.find(&end_tag)?;
1041    Some(i + k + end_tag.len())
1042}
1043
1044/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
1045///
1046/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
1047/// out — the same heuristic org uses, and the same one that means a sentence with two
1048/// unrelated dollar amounts and no space between them will be read as math.
1049fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
1050    let body = &s[i + 1..];
1051    let close = body.find('$')?;
1052    if close == 0 {
1053        return None;
1054    }
1055    let inner = &body[..close];
1056    if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
1057        return None;
1058    }
1059    if inner.contains('\n') {
1060        return None;
1061    }
1062    Some(i + 1 + close + 1)
1063}
1064
1065/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
1066/// and `\-` a soft hyphen.
1067///
1068/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
1069/// very end of a line, or the `-----` someone drew as a rule, is left alone.
1070fn special_strings(s: &str) -> String {
1071    let chars: Vec<char> = s.chars().collect();
1072    let mut out = String::with_capacity(s.len());
1073    let mut i = 0;
1074    while i < chars.len() {
1075        let rest = &chars[i..];
1076        let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
1077        if rest.starts_with(&['\\', '-']) {
1078            out.push('\u{00ad}');
1079            i += 2;
1080        } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
1081            out.push('\u{2014}');
1082            i += 3;
1083        } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
1084            out.push('\u{2013}');
1085            i += 2;
1086        } else if rest.starts_with(&['.', '.', '.']) {
1087            out.push('\u{2026}');
1088            i += 3;
1089        } else {
1090            out.push(chars[i]);
1091            i += 1;
1092        }
1093    }
1094    out
1095}
1096
1097/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
1098///
1099/// Both require a non-whitespace character before the marker, which is what separates a
1100/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
1101/// that form requires whitespace *before* the marker.
1102fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
1103    if mode == SubSuperscript::No || !s.contains(['_', '^']) {
1104        return s.to_string();
1105    }
1106    let chars: Vec<char> = s.chars().collect();
1107    let mut out = String::with_capacity(s.len());
1108    let mut i = 0;
1109    while i < chars.len() {
1110        let c = chars[i];
1111        let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
1112        if (c == '_' || c == '^') && prev_ok {
1113            if let Some((body, next)) = script_body(&chars, i + 1, mode) {
1114                let tag = if c == '_' { "sub" } else { "sup" };
1115                out.push_str(&format!("<{tag}>{body}</{tag}>"));
1116                i = next;
1117                continue;
1118            }
1119        }
1120        out.push(c);
1121        i += 1;
1122    }
1123    out
1124}
1125
1126/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
1127/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
1128fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
1129    if chars.get(start) == Some(&'{') {
1130        let mut depth = 1;
1131        let mut j = start + 1;
1132        while j < chars.len() {
1133            match chars[j] {
1134                '{' => depth += 1,
1135                '}' => {
1136                    depth -= 1;
1137                    if depth == 0 {
1138                        return Some((chars[start + 1..j].iter().collect(), j + 1));
1139                    }
1140                }
1141                _ => {}
1142            }
1143            j += 1;
1144        }
1145        return None;
1146    }
1147    if mode == SubSuperscript::Braces {
1148        return None;
1149    }
1150    let mut j = start;
1151    if matches!(chars.get(j), Some('+') | Some('-')) {
1152        j += 1;
1153    }
1154    let mut last_alnum = None;
1155    while let Some(&c) = chars.get(j) {
1156        if c.is_alphanumeric() {
1157            last_alnum = Some(j);
1158        } else if !matches!(c, '.' | ',' | '\\') {
1159            break;
1160        }
1161        j += 1;
1162    }
1163    let end = last_alnum? + 1;
1164    Some((chars[start..end].iter().collect(), end))
1165}
1166
1167fn escape_html(s: &str) -> String {
1168    let mut out = String::with_capacity(s.len());
1169    for c in s.chars() {
1170        match c {
1171            '&' => out.push_str("&amp;"),
1172            '<' => out.push_str("&lt;"),
1173            '>' => out.push_str("&gt;"),
1174            _ => out.push(c),
1175        }
1176    }
1177    out
1178}
1179
1180fn escape_attr(s: &str) -> String {
1181    let mut out = escape_html(s);
1182    out = out.replace('"', "&quot;");
1183    out
1184}
1185