krz/orgo

Lightning fast org-mode static site generator.

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

183c97769244fe508f3e6621c7771a6b086b9869

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T19:57:37Z

Close the export gaps the corpus diff found

Nine differences between org-ssg's output and what Emacs produces from the same
179 files, each one a rule org has and this did not:

- Heading levels are relative to the document's shallowest heading. A file
  written entirely under `**` is a file of top-level sections that happen to be
  indented, and exports the same whether it was cut from a larger file or
  written alone. Seven posts rendered an outline level too deep.
- Export-time text conversions: `--` `---` `...` become dashes and an ellipsis,
  `x^2` and `a_{b}` become sup/sub. Both stop at verbatim, code, source blocks
  and LaTeX — `--verbose` in a shell transcript is a flag and `$x^2$` is
  mathematics. `#+OPTIONS: -:nil`, `^:nil` and `^:{}` all work, and per-document
  options now apply inside the renderer, so every caller gets them rather than
  whichever one remembered to read the keywords.
- Captioned figures are numbered `Figure N:`.
- A caption attaches to the element *directly* below it. A blank line in between
  attaches to nothing, which is what Emacs does with the two images in the Home
  Assistant post whose captions were written underneath them.
- Checkboxes render as org writes them, `<code>[X]</code>` with the state as a
  class. The disabled `<input>` could not express `[-]`, partly done.
- `[@4]` sets a list item's number.
- A table's special column is export markup, not data: `/` marks a column group,
  `#` a row to recalculate. Marker rows never reach the page, and the column
  goes when every row uses it that way.
- `#+BEGIN_NOTE` and any other unrecognised name is a special block — a div with
  that class holding parsed org, not a `<pre>` of literal text. Verse keeps its
  line breaks; a comment block is not published.
- Emphasis borders forbid whitespace and nothing else, as org's own regexp does.
  `="proxied":false=` is verbatim, `=SPC m '=` closes on an apostrophe, and
  `~~/.config/doom/config.el~` is a path that starts with a tilde.

Also: listings sort on the time of day when a timestamp carries one, so two
notes written the same day keep the order they were written in.

Measured against the live site, page by page: 70 of 182 pages differed in
content before, 11 after — and every one of those 11 is either a template
difference the site owner chose or a documented non-goal (citations, footnote
chrome). The Emacs oracle agrees more too: lists 90.1% to 98.2%, core 83.3% to
87.9%, images 66.7% to 71.4%.

Cache format bumped to 6: the same source now renders differently.
 README.md                                          |  21 +-
 docs/guide/05-org-support.org                      |  49 ++-
 fixtures/blocks.org                                |  13 +
 fixtures/outofscope.org                            |   7 -
 src/audit.rs                                       |   7 +-
 src/config.rs                                      |  47 +++
 src/incremental.rs                                 |   2 +-
 src/model.rs                                       |  10 +
 src/parser.rs                                      |  54 ++-
 src/render.rs                                      | 363 ++++++++++++++++++++-
 src/site.rs                                        |  30 +-
 src/util.rs                                        |  22 ++
 tests/config.rs                                    |  46 +++
 tests/constructs.rs                                | 191 ++++++++++-
 tests/snapshots/constructs__blocks_html.snap       |   9 +
 tests/snapshots/constructs__images_html.snap       |   4 +-
 .../snapshots/constructs__lists_element_tree.snap  |  18 +
 tests/snapshots/constructs__lists_html.snap        |   6 +-
 tests/snapshots/constructs__out_of_scope_html.snap |   3 -
 tests/snapshots/oracle__oracle_blocks.snap         |  23 +-
 tests/snapshots/oracle__oracle_core.snap           |  16 +-
 tests/snapshots/oracle__oracle_images.snap         |  14 +-
 tests/snapshots/oracle__oracle_lists.snap          |  23 +-
 tests/snapshots/pipeline__core_element_tree.snap   |   4 +
 tests/snapshots/pipeline__core_html.snap           |   4 +-
 .../snapshots/pipeline__minimal_element_tree.snap  |   2 +
 26 files changed, 888 insertions(+), 100 deletions(-)

diff --git a/README.md b/README.md
index b084636..c5e075f 100644
--- a/README.md
+++ b/README.md
@@ -327,17 +327,20 @@ is the only inherently global stage — it is where the link dependency graph is
 
 ## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit)
 
-**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags;
-property drawers; plain lists (unordered/ordered/description, checkboxes, nesting);
-tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting;
-example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`,
-`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup
-(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges);
-paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`.
+**IN — v1 must handle:** headings with nesting, at levels relative to the document's
+shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists
+(unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule
+rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax
+highlighting; example/quote/center/verse blocks and named special blocks; links (external,
+internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+`
+keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's
+export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps
+(active/inactive, ranges); paragraphs and horizontal rules; images with
+`#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`.
 
 **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
-`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and
-macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
+`:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past
+the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
 semantics; non-HTML export blocks; the full Unicode entity set.
 
 **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test
diff --git a/docs/guide/05-org-support.org b/docs/guide/05-org-support.org
index d82048d..c25855f 100644
--- a/docs/guide/05-org-support.org
+++ b/docs/guide/05-org-support.org
@@ -35,6 +35,33 @@ every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=,
 Timestamps, active and inactive, with times and ranges, render as =<time>= with a
 machine-readable =datetime=.
 
+*** Text conversions
+
+Org rewrites some prose on export, and so does org-ssg:
+
+| Written | Published |
+|---------+-----------|
+| =--= | – |
+| =---= | — |
+| =...= | … |
+| =x^2= | x superscript 2 |
+| =H_{2}O= | H subscript 2 O |
+
+Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in
+a shell transcript stays a flag, and =$x^2$= stays mathematics.
+
+*Braceless subscripts catch people out.* Org's default converts =a_b=, so =snake_case= in
+prose publishes as snake with a subscript. That is what Emacs does with the same file. Turn
+it off per document with =#+OPTIONS: ^:nil=, restrict it to the braced form with =^:{}=, or
+set =[html] sub_superscript= for the site. =#+OPTIONS: -:nil= turns off the dashes and
+ellipsis.
+
+*** Heading levels are relative
+
+A file whose shallowest heading is =**= is a file of top-level sections that happen to be
+indented, not a file of subsections — org exports levels relative to the document, so that
+subtree exports the same whether it was cut from a larger file or written on its own.
+
 ** Lists
 
 Unordered, ordered and description lists, nested by indentation, with checkboxes and
@@ -44,18 +71,25 @@ multi-paragraph items:
 - outer item
   - inner item
 - [X] a checked item
+- [-] a partly-done item
 - term :: definition
+1. [@4] an item numbered from 4
 #+END_SRC
 
+Checkboxes render as org writes them — =<code>[X]</code>= with the state as a class on the
+item — rather than as a disabled =<input>=, which has no way to say "partly done".
+
 ** Blocks
 
-=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block
-inside a quote block works, because block ends match their own kind.
+=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER=, =VERSE= and =EXPORT=. A source
+block inside a quote block works, because block ends match their own kind.
 
 An =html= export block passes through verbatim; every other backend is dropped, because
 emitting LaTeX into an HTML page is worse than emitting nothing.
 
-An unknown block type keeps its content as an example block rather than vanishing.
+*Any other name is a special block*: =#+BEGIN_NOTE= becomes =<div class="note">= holding
+*parsed org*, which is what makes the convention usable without org-ssg knowing the word
+"note". A =COMMENT= block is not published.
 
 *** Which languages highlight
 
@@ -89,7 +123,12 @@ in Emacs as much as here. If a code block seems to stop early, that is why.
 
 ** Tables and footnotes
 
-Pipe tables, with the rule row establishing a header band. Footnotes in all three forms —
+Pipe tables, with the rule row establishing a header band. Org's *special column* is
+honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is
+dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than
+content, so they never reach the page.
+
+Footnotes in all three forms —
 =[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a
 numbered, back-linked notes section.
 
@@ -120,7 +159,7 @@ broken.
 | =#+DRAFT:= | Keeps the page out of the build. |
 | =#+TEMPLATE:= | The layout this page renders through. |
 | =#+OPTIONS:= | Per-file export switches. |
-| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. |
+| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image *directly* below them — a blank line in between attaches to nothing, as in org. A captioned image is numbered =Figure N:=. |
 
 Every other =#+KEYWORD:= is available to templates as
 ={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches
diff --git a/fixtures/blocks.org b/fixtures/blocks.org
index f3f1b75..5c3e7bb 100644
--- a/fixtures/blocks.org
+++ b/fixtures/blocks.org
@@ -51,3 +51,16 @@ A quote containing a source block:
 echo hi
 #+END_SRC
 #+END_QUOTE
+
+* Verse
+
+#+BEGIN_VERSE
+Line breaks are the point
+  and indentation survives.
+#+END_VERSE
+
+* A named special block
+
+#+BEGIN_NOTE
+Contents are *org*, not literal text.
+#+END_NOTE
diff --git a/fixtures/outofscope.org b/fixtures/outofscope.org
index f6c75d7..a0e822d 100644
--- a/fixtures/outofscope.org
+++ b/fixtures/outofscope.org
@@ -43,13 +43,6 @@ CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00] =>  1:00
 Drawer contents are captured and dropped.
 :END:
 
-* Verse
-
-#+BEGIN_VERSE
-An unmodelled block type
-keeps its content verbatim.
-#+END_VERSE
-
 * Entities
 
 The full entity set is out of scope, so \alpha stays literal.
diff --git a/src/audit.rs b/src/audit.rs
index 50ef7ec..330eb02 100644
--- a/src/audit.rs
+++ b/src/audit.rs
@@ -78,7 +78,12 @@ const KNOWN_KEYWORDS: &[&str] = &[
     "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO",
     "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS",
 ];
-const KNOWN_BLOCKS: &[&str] = &["SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT"];
+/// Blocks with dedicated handling. Any *other* name renders as a special block — a div
+/// with that name holding parsed org — so an unlisted block is a note about what a corpus
+/// contains rather than a construct that will be lost.
+const KNOWN_BLOCKS: &[&str] = &[
+    "SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT", "VERSE", "COMMENT",
+];
 const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"];
 /// Keyword names conventional enough to be worth flagging when they lead a heading.
 /// A custom sequence is only *real* if some `#+TODO:` declares it, which the census
diff --git a/src/config.rs b/src/config.rs
index 747cb6d..11c4d86 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -227,6 +227,44 @@ pub struct HtmlOutput {
     /// do not want them, so the default is the taste rather than the inheritance;
     /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back.
     pub section_numbers: bool,
+    /// Convert org's special strings in prose: `--` to an en dash, `---` to an em dash,
+    /// `...` to an ellipsis.
+    ///
+    /// On, as in Emacs. A document turns it off for itself with `#+OPTIONS: -:nil`.
+    /// Never applied inside verbatim, code, or a source block.
+    pub special_strings: bool,
+    /// Whether `x^2` and `H_{2}O` become `<sup>`/`<sub>`.
+    ///
+    /// `"yes"` (the default, and Emacs') also treats the braceless `a_b` as a subscript,
+    /// which is what makes `snake_case` in prose render as `snake<sub>case</sub>` —
+    /// surprising, but what Emacs does with the same file. `"braces"` limits it to the
+    /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself
+    /// with `#+OPTIONS: ^:nil` or `^:{}`.
+    pub sub_superscript: SubSuperscript,
+}
+
+/// How `_` and `^` are treated in prose. Mirrors org's `^:` export option.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum SubSuperscript {
+    /// `a_b` and `a_{b}` both convert.
+    #[default]
+    Yes,
+    /// Only the braced `a_{b}` converts.
+    Braces,
+    /// Neither converts.
+    No,
+}
+
+impl SubSuperscript {
+    /// Read org's `^:` option value: `nil` is off, `{}` is braces-only, anything else on.
+    pub fn from_option(value: &str) -> SubSuperscript {
+        match value.trim() {
+            "nil" | "false" | "no" | "off" => SubSuperscript::No,
+            "{}" => SubSuperscript::Braces,
+            _ => SubSuperscript::Yes,
+        }
+    }
 }
 
 impl Default for HtmlOutput {
@@ -235,6 +273,8 @@ impl Default for HtmlOutput {
             heading_offset: 1,
             toc: true,
             section_numbers: false,
+            special_strings: true,
+            sub_superscript: SubSuperscript::Yes,
         }
     }
 }
@@ -530,6 +570,13 @@ toc = true
 # Number headings (1., 1.1., …). Emacs defaults this on; most sites do not.
 # A document overrides with `#+OPTIONS: num:t`.
 section_numbers = false
+# Convert `--` to an en dash, `---` to an em dash and `...` to an ellipsis in prose, as
+# Emacs does. Never inside code. A document overrides with `#+OPTIONS: -:nil`.
+special_strings = true
+# Whether `x^2` and `H_{2}O` become <sup>/<sub>: "yes" (as Emacs, and so `snake_case`
+# becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no".
+# A document overrides with `#+OPTIONS: ^:nil` or `^:{}`.
+sub_superscript = "yes"
 
 # Generated listing pages: output files with no source .org behind them. Repeat the
 # [[collections]] block for each one. A feed is the same thing with an XML template.
diff --git a/src/incremental.rs b/src/incremental.rs
index 905e89d..2a1f79f 100644
--- a/src/incremental.rs
+++ b/src/incremental.rs
@@ -29,7 +29,7 @@ use crate::util::output_url;
 /// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
 /// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
 /// version is folded in as the "hash-algo version" so a hash upgrade also busts.
-pub const CACHE_FORMAT_VERSION: u32 = 5;
+pub const CACHE_FORMAT_VERSION: u32 = 6;
 
 /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
 pub type Hash = ContentHash;
diff --git a/src/model.rs b/src/model.rs
index 36a74df..aa452bc 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -112,6 +112,14 @@ pub enum Element {
     },
     QuoteBlock(Vec<Element>),
     CenterBlock(Vec<Element>),
+    /// `#+BEGIN_<name>` for any other name — `note`, `warning`, `aside`. Its contents are
+    /// org, not literal text, and the name becomes a class so a stylesheet can reach it.
+    SpecialBlock {
+        name: String,
+        content: Vec<Element>,
+    },
+    /// `#+BEGIN_VERSE`: line breaks are significant.
+    VerseBlock(Vec<String>),
     /// html passes through; others dropped at render (spec §1 OUT).
     ExportBlock {
         backend: String,
@@ -157,6 +165,8 @@ pub enum ListKind {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct ListItem {
     pub bullet: Bullet,
+    /// `[@4]` — an explicit number for this item, restarting the list's counting.
+    pub counter: Option<u32>,
     pub checkbox: Option<Checkbox>,
     /// Description-list term before `::`.
     pub term: Option<Vec<Object>>,
diff --git a/src/parser.rs b/src/parser.rs
index 86b2eb7..2cd8c2b 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -398,6 +398,11 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V
     while i < lines.len() {
         let line = lines[i];
         if line.trim().is_empty() {
+            // A blank line ends the association: an affiliated keyword belongs to the
+            // element *immediately* below it. Someone who writes `#+CAPTION:` under their
+            // image and then leaves a blank line has captioned nothing, and org agrees —
+            // silently attaching it to whatever comes next would caption the wrong thing.
+            affiliated.clear();
             i += 1;
             continue;
         }
@@ -563,9 +568,17 @@ fn parse_block(
             backend: after.split_whitespace().next().unwrap_or("").to_string(),
             raw: inner.join("\n"),
         },
-        // Out-of-scope block types (verse, comment, ascii, custom) degrade to a verbatim
-        // example block: content preserved, no crash.
-        _ => Element::ExampleBlock(inner.join("\n")),
+        // Verse keeps its line breaks; that is the whole point of it.
+        "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()),
+        // A comment block is not published, in org or here.
+        "COMMENT" => Element::Comment(inner.join("\n")),
+        // Any other name is a special block: a div with that class, holding org. Emacs
+        // exports unknown block types this way, which is what makes `#+BEGIN_NOTE` a
+        // usable convention without the exporter knowing the word "note".
+        other => Element::SpecialBlock {
+            name: other.to_ascii_lowercase(),
+            content: parse_elements(&inner, base + start + 1, diags),
+        },
     };
     (element, next)
 }
@@ -830,6 +843,8 @@ fn parse_list(
         // Body = the text after the bullet, plus every following line indented past the
         // bullet column (blank lines included, so an item can hold several paragraphs).
         let rest = item_body(lines[j].trim_start(), &bullet);
+        // `[@4]` comes before the checkbox: `1. [@4] [X] done`.
+        let (counter, rest) = split_counter(rest);
         let (checkbox, rest) = split_checkbox(rest);
         let (term, rest) = match kind {
             ListKind::Description => match split_term(rest) {
@@ -864,6 +879,7 @@ fn parse_list(
 
         items.push(ListItem {
             bullet,
+            counter,
             checkbox,
             term,
             // The item body starts at the bullet line, so `base + j` is exact even after
@@ -949,6 +965,20 @@ fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
     }
 }
 
+/// Detect a leading `[@N]` counter on a list item, which sets its number explicitly.
+fn split_counter(text: &str) -> (Option<u32>, &str) {
+    let Some(rest) = text.strip_prefix("[@") else {
+        return (None, text);
+    };
+    let Some(end) = rest.find(']') else {
+        return (None, text);
+    };
+    match rest[..end].parse::<u32>() {
+        Ok(n) => (Some(n), rest[end + 1..].trim_start()),
+        Err(_) => (None, text),
+    }
+}
+
 /// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
 fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
     let bytes = text.as_bytes();
@@ -1368,8 +1398,10 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
     if i + 1 >= n {
         return None;
     }
-    let after = chars[i + 1];
-    if after.is_whitespace() || after == m {
+    // Org's body-character rule: the character after the opening marker may not be
+    // whitespace, a comma or a quote. It *may* be another marker, which is what makes
+    // `~~/.config/emacs~` verbatim for a path that starts with `~`.
+    if !body_char_ok(chars[i + 1]) {
         return None;
     }
     let mut j = i + 1;
@@ -1377,7 +1409,7 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
         if chars[j] == m && j > i + 1 {
             let before = chars[j - 1];
             let next = chars.get(j + 1).copied();
-            if !before.is_whitespace() && post_ok(next) {
+            if body_char_ok(before) && post_ok(next) {
                 let inner = &chars[i + 1..j];
                 let obj = match m {
                     '=' => Object::Verbatim(inner.iter().collect()),
@@ -1396,6 +1428,16 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
     None
 }
 
+/// May this character sit directly inside an emphasis marker?
+///
+/// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a
+/// body, which is what makes `="proxied":false=` verbatim, and `=SPC m '=` may close on
+/// an apostrophe. The marker character itself is allowed too, so `~~/.config/emacs~` is a
+/// path that starts with a tilde.
+fn body_char_ok(c: char) -> bool {
+    !c.is_whitespace()
+}
+
 fn boundary_before(chars: &[char], i: usize) -> bool {
     if i == 0 {
         return true;
diff --git a/src/render.rs b/src/render.rs
index 4d8976f..ee201b2 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -23,10 +23,11 @@ use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGener
 use syntect::parsing::SyntaxSet;
 use syntect::util::LinesWithEndings;
 
+use crate::config::SubSuperscript;
 use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
 use crate::parser::is_image_target;
 use crate::resolve::ResolvedDoc;
-use crate::util::{heading_anchor, plain_text, slugify};
+use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify};
 
 /// A rendered HTML fragment (content only — no page chrome; spec §2.4).
 #[derive(Debug, Clone)]
@@ -211,6 +212,12 @@ struct Renderer<'a> {
     order: Vec<String>,
     /// Counter per heading depth, for section numbers.
     counters: Vec<usize>,
+    /// Subtracted from every heading level so the document's shallowest heading renders
+    /// as level 1. Org exports levels *relative* to a file's own outline, so a file
+    /// written entirely under `**` is not a file of subsections.
+    headline_offset: u8,
+    /// Captioned figures seen so far, for `Figure N:`.
+    figures: usize,
 }
 
 /// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -224,6 +231,12 @@ pub struct RenderOptions {
     /// Prefix headings with `1.`, `1.1.`, … See
     /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
     pub section_numbers: bool,
+    /// Convert `--`, `---` and `...` in prose. See
+    /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings).
+    pub special_strings: bool,
+    /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See
+    /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript).
+    pub sub_superscript: SubSuperscript,
 }
 
 impl Default for RenderOptions {
@@ -232,10 +245,49 @@ impl Default for RenderOptions {
         RenderOptions {
             heading_offset: html.heading_offset,
             section_numbers: html.section_numbers,
+            special_strings: html.special_strings,
+            sub_superscript: html.sub_superscript,
         }
     }
 }
 
+/// The site's render options with the document's own `#+OPTIONS:` applied on top.
+///
+/// Org's per-file switches are the author's override of a site-wide setting, and they
+/// belong here rather than at each call site — otherwise rendering one document two ways
+/// depends on which caller remembered to read its keywords.
+fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions {
+    RenderOptions {
+        heading_offset: opts.heading_offset,
+        section_numbers: option_enabled(keywords, "num", opts.section_numbers),
+        special_strings: option_enabled(keywords, "-", opts.special_strings),
+        // `^:` has three values rather than two — `nil`, `{}` or on — so it is read
+        // directly instead of through the boolean helper.
+        sub_superscript: match export_options(keywords).get("^") {
+            Some(value) => SubSuperscript::from_option(value),
+            None => opts.sub_superscript,
+        },
+    }
+}
+
+/// How much to subtract from every heading level, so a document's shallowest heading
+/// renders as level 1.
+///
+/// Org exports outline levels *relative to the file*: a document written entirely under
+/// `**` is a document of top-level sections that happen to be indented, not a document of
+/// subsections. Emacs computes this from the shallowest top-level headline, which is what
+/// makes the same subtree export identically whether it was cut from a larger file or
+/// written on its own.
+fn headline_offset(root: &Section) -> u8 {
+    root.children
+        .iter()
+        .filter_map(|s| s.heading.as_ref())
+        .map(|h| h.level)
+        .min()
+        .map(|min| min.saturating_sub(1))
+        .unwrap_or(0)
+}
+
 /// Render a resolved document to an HTML fragment, with default options.
 pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
     render_with(doc, highlighter, &RenderOptions::default())
@@ -245,11 +297,13 @@ pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
 pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
     let mut r = Renderer {
         hl: highlighter,
-        opts: *opts,
+        opts: document_options(&doc.document.keywords, opts),
         block_defs: HashMap::new(),
         inline_defs: HashMap::new(),
         order: Vec::new(),
         counters: Vec::new(),
+        headline_offset: headline_offset(&doc.document.root),
+        figures: 0,
     };
     r.collect_defs(&doc.document.root);
     let mut out = String::new();
@@ -269,7 +323,8 @@ impl Renderer<'_> {
 
     fn render_section(&mut self, section: &Section, out: &mut String) {
         if let Some(h) = &section.heading {
-            let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
+            let relative = h.level.saturating_sub(self.headline_offset).max(1);
+            let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6);
             let anchor = heading_anchor(h);
             // A heading with no title text has no meaningful slug; emit no `id` at all
             // rather than a run of duplicate empty ones.
@@ -279,7 +334,7 @@ impl Renderer<'_> {
                 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
             }
             if self.opts.section_numbers {
-                let number = self.next_section_number(h.level);
+                let number = self.next_section_number(relative);
                 out.push_str(&format!(
                     "<span class=\"section-number-{}\">{number}</span> ",
                     level
@@ -377,12 +432,33 @@ impl Renderer<'_> {
                 out.push_str("<figure>");
                 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
                 if !caption.is_empty() {
-                    out.push_str("<figcaption>");
+                    self.figures += 1;
+                    out.push_str(&format!(
+                        "<figcaption><span class=\"figure-number\">Figure {}: </span>",
+                        self.figures
+                    ));
                     self.render_objects(caption, out);
                     out.push_str("</figcaption>");
                 }
                 out.push_str("</figure>\n");
             }
+            Element::SpecialBlock { name, content } => {
+                out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name)));
+                for child in content {
+                    self.render_element(child, out);
+                }
+                out.push_str("</div>\n");
+            }
+            Element::VerseBlock(lines) => {
+                out.push_str("<p class=\"verse\">\n");
+                for (i, line) in lines.iter().enumerate() {
+                    if i > 0 {
+                        out.push_str("<br>\n");
+                    }
+                    self.render_objects(&crate::parser::inline(line), out);
+                }
+                out.push_str("\n</p>\n");
+            }
             Element::HorizontalRule => out.push_str("<hr>\n"),
             // Definitions are emitted in the footnotes section, not inline.
             Element::FootnoteDefinition { .. } => {}
@@ -414,16 +490,29 @@ impl Renderer<'_> {
         };
         out.push_str(&format!("<{}>\n", tag));
         for item in &list.items {
-            out.push_str("<li>");
+            // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
+            // partially done — that a disabled <input> cannot express. The state goes on
+            // the item, where it can style the whole line.
+            let class = item.checkbox.as_ref().map(|cb| match cb {
+                Checkbox::On => "on",
+                Checkbox::Off => "off",
+                Checkbox::Trans => "trans",
+            });
+            out.push_str("<li");
+            if let Some(class) = class {
+                out.push_str(&format!(" class=\"{class}\""));
+            }
+            // `[@4]` restarts the numbering, and HTML says so with `value`.
+            if let Some(n) = item.counter {
+                out.push_str(&format!(" value=\"{n}\""));
+            }
+            out.push('>');
             if let Some(cb) = &item.checkbox {
-                out.push_str(&format!(
-                    "<input type=\"checkbox\" disabled{}> ",
-                    if matches!(cb, Checkbox::On) {
-                        " checked"
-                    } else {
-                        ""
-                    }
-                ));
+                out.push_str(match cb {
+                    Checkbox::On => "<code>[X]</code> ",
+                    Checkbox::Off => "<code>[&nbsp;]</code> ",
+                    Checkbox::Trans => "<code>[-]</code> ",
+                });
             }
             self.render_item_content(&item.content, out);
             out.push_str("</li>\n");
@@ -454,6 +543,8 @@ impl Renderer<'_> {
 
     /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
     fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
+        let table = strip_special_column(table);
+        let table = &table;
         let rule_at = table
             .rows
             .iter()
@@ -502,6 +593,31 @@ impl Renderer<'_> {
         out.push_str("</table>\n");
     }
 
+    /// Plain text as HTML: escaped, then org's export-time text conversions.
+    ///
+    /// Both run on the *escaped* string so their output tags survive, and both are
+    /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
+    /// different objects, which is what keeps `--verbose` in a shell transcript intact.
+    fn text_html(&self, t: &str) -> String {
+        let escaped = escape_html(t);
+        let mut out = String::with_capacity(escaped.len());
+        // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
+        // superscript for us, and an em dash inside a formula is not what was meant.
+        for (span, is_latex) in latex_split(&escaped) {
+            if is_latex {
+                out.push_str(span);
+                continue;
+            }
+            let with_strings = if self.opts.special_strings {
+                special_strings(span)
+            } else {
+                span.to_string()
+            };
+            out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
+        }
+        out
+    }
+
     fn render_objects(&mut self, objs: &[Object], out: &mut String) {
         for obj in objs {
             self.render_object(obj, out);
@@ -510,7 +626,7 @@ impl Renderer<'_> {
 
     fn render_object(&mut self, obj: &Object, out: &mut String) {
         match obj {
-            Object::Text(t) => out.push_str(&escape_html(t)),
+            Object::Text(t) => out.push_str(&self.text_html(t)),
             Object::Bold(inner) => self.wrap(out, "strong", inner),
             Object::Italic(inner) => self.wrap(out, "em", inner),
             Object::Underline(inner) => self.wrap(out, "u", inner),
@@ -735,6 +851,223 @@ fn link_text(target: &LinkTarget) -> String {
     }
 }
 
+/// Drop org's special column and its marker rows.
+///
+/// A table's first column may hold export markers rather than data — `/` marks a column
+/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
+/// instructions to org and never appear in the output; the column itself disappears when
+/// *every* row uses it that way, which is what stops a table of formulas from publishing
+/// with a stray column of hashes.
+fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
+    let first_cell = |row: &TableRow| -> Option<String> {
+        match row {
+            TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
+            TableRow::Rule => None,
+        }
+    };
+    let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
+    let column_is_special = data_rows().count() > 0
+        && data_rows().all(|row| {
+            matches!(
+                first_cell(row).as_deref(),
+                Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
+            )
+        });
+
+    let rows = table
+        .rows
+        .iter()
+        .filter(|row| {
+            // A marker row is an instruction, not content.
+            !matches!(
+                first_cell(row).as_deref(),
+                Some("/" | "!" | "^" | "_" | "$")
+            )
+        })
+        .map(|row| match (row, column_is_special) {
+            (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
+            _ => row.clone(),
+        })
+        .collect();
+    crate::model::Table { rows }
+}
+
+/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
+///
+/// org-ssg does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
+/// but it must know where a fragment *is*, because the export-time text conversions would
+/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
+/// marked up, and `--` inside one is a minus sign twice.
+fn latex_split(s: &str) -> Vec<(&str, bool)> {
+    let bytes = s.as_bytes();
+    let mut spans = Vec::new();
+    let mut plain_from = 0;
+    let mut i = 0;
+    while i < s.len() {
+        if !s.is_char_boundary(i) {
+            i += 1;
+            continue;
+        }
+        let end = match bytes[i] {
+            b'\\' => latex_backslash_end(s, i),
+            b'$' => latex_dollar_end(s, i),
+            _ => None,
+        };
+        if let Some(end) = end {
+            if plain_from < i {
+                spans.push((&s[plain_from..i], false));
+            }
+            spans.push((&s[i..end], true));
+            plain_from = end;
+            i = end;
+            continue;
+        }
+        i += 1;
+    }
+    if plain_from < s.len() {
+        spans.push((&s[plain_from..], false));
+    }
+    spans
+}
+
+/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
+fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
+    let rest = &s[i..];
+    for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
+        if let Some(body) = rest.strip_prefix(open) {
+            return body.find(close).map(|k| i + open.len() + k + close.len());
+        }
+    }
+    let after = rest.strip_prefix("\\begin{")?;
+    let name_end = after.find('}')?;
+    let end_tag = format!("\\end{{{}}}", &after[..name_end]);
+    let k = rest.find(&end_tag)?;
+    Some(i + k + end_tag.len())
+}
+
+/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
+///
+/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
+/// out — the same heuristic org uses, and the same one that means a sentence with two
+/// unrelated dollar amounts and no space between them will be read as math.
+fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
+    let body = &s[i + 1..];
+    let close = body.find('$')?;
+    if close == 0 {
+        return None;
+    }
+    let inner = &body[..close];
+    if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
+        return None;
+    }
+    if inner.contains('\n') {
+        return None;
+    }
+    Some(i + 1 + close + 1)
+}
+
+/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
+/// and `\-` a soft hyphen.
+///
+/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
+/// very end of a line, or the `-----` someone drew as a rule, is left alone.
+fn special_strings(s: &str) -> String {
+    let chars: Vec<char> = s.chars().collect();
+    let mut out = String::with_capacity(s.len());
+    let mut i = 0;
+    while i < chars.len() {
+        let rest = &chars[i..];
+        let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
+        if rest.starts_with(&['\\', '-']) {
+            out.push('\u{00ad}');
+            i += 2;
+        } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
+            out.push('\u{2014}');
+            i += 3;
+        } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
+            out.push('\u{2013}');
+            i += 2;
+        } else if rest.starts_with(&['.', '.', '.']) {
+            out.push('\u{2026}');
+            i += 3;
+        } else {
+            out.push(chars[i]);
+            i += 1;
+        }
+    }
+    out
+}
+
+/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
+///
+/// Both require a non-whitespace character before the marker, which is what separates a
+/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
+/// that form requires whitespace *before* the marker.
+fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
+    if mode == SubSuperscript::No || !s.contains(['_', '^']) {
+        return s.to_string();
+    }
+    let chars: Vec<char> = s.chars().collect();
+    let mut out = String::with_capacity(s.len());
+    let mut i = 0;
+    while i < chars.len() {
+        let c = chars[i];
+        let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
+        if (c == '_' || c == '^') && prev_ok {
+            if let Some((body, next)) = script_body(&chars, i + 1, mode) {
+                let tag = if c == '_' { "sub" } else { "sup" };
+                out.push_str(&format!("<{tag}>{body}</{tag}>"));
+                i = next;
+                continue;
+            }
+        }
+        out.push(c);
+        i += 1;
+    }
+    out
+}
+
+/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
+/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
+fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
+    if chars.get(start) == Some(&'{') {
+        let mut depth = 1;
+        let mut j = start + 1;
+        while j < chars.len() {
+            match chars[j] {
+                '{' => depth += 1,
+                '}' => {
+                    depth -= 1;
+                    if depth == 0 {
+                        return Some((chars[start + 1..j].iter().collect(), j + 1));
+                    }
+                }
+                _ => {}
+            }
+            j += 1;
+        }
+        return None;
+    }
+    if mode == SubSuperscript::Braces {
+        return None;
+    }
+    let mut j = start;
+    if matches!(chars.get(j), Some('+') | Some('-')) {
+        j += 1;
+    }
+    let mut last_alnum = None;
+    while let Some(&c) = chars.get(j) {
+        if c.is_alphanumeric() {
+            last_alnum = Some(j);
+        } else if !matches!(c, '.' | ',' | '\\') {
+            break;
+        }
+        j += 1;
+    }
+    let end = last_alnum? + 1;
+    Some((chars[start..end].iter().collect(), end))
+}
+
 fn escape_html(s: &str) -> String {
     let mut out = String::with_capacity(s.len());
     for c in s.chars() {
diff --git a/src/site.rs b/src/site.rs
index 514c5b4..2c2381c 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -33,7 +33,8 @@ use crate::template::{
     Templater,
 };
 use crate::util::{
-    document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url,
+    document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled,
+    output_path, output_url,
     relative_root, slugify, table_of_contents,
 };
 
@@ -239,7 +240,18 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
             // Undated pages sort last in the final order regardless of direction: a
             // draft with no date should not lead an archive.
             SortKey::Date => entries.sort_by(|a, b| {
-                let key = |p: &PageContext| p.date_iso.clone();
+                // Date *and* time: org records when a note was written, and two notes
+                // from the same day have an order that the day alone cannot express.
+                let key = |p: &PageContext| {
+                    p.date_iso.as_ref().map(|d| {
+                        let time = p
+                            .date
+                            .as_deref()
+                            .and_then(iso_time)
+                            .unwrap_or_else(|| "00:00:00".to_string());
+                        format!("{d}T{time}")
+                    })
+                };
                 match (key(a), key(b)) {
                     (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
                     (Some(_), None) => std::cmp::Ordering::Greater,
@@ -750,12 +762,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
     Ok((pages, broken))
 }
 
-/// Render options for one page: the site's settings, with the document's own
-/// `#+OPTIONS:` switches applied on top.
-fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions {
+/// The site's render options. A document's own `#+OPTIONS:` switches are applied by the
+/// renderer, so every caller gets them.
+fn render_options(config: &Config) -> RenderOptions {
     RenderOptions {
         heading_offset: config.html.heading_offset,
-        section_numbers: option_enabled(keywords, "num", config.html.section_numbers),
+        section_numbers: config.html.section_numbers,
+        special_strings: config.html.special_strings,
+        sub_superscript: config.html.sub_superscript,
     }
 }
 
@@ -787,9 +801,7 @@ fn render_page(
     config: &Config,
     p: &PagePrep,
 ) -> Result<String> {
-    // Options are resolved per page, because `#+OPTIONS:` is a per-document override of
-    // the site setting.
-    let opts = render_options(config, &p.resolved.document.keywords);
+    let opts = render_options(config);
     let Html(fragment) = render_with(&p.resolved, highlighter, &opts);
     // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
     let root = relative_root(&p.output);
diff --git a/src/util.rs b/src/util.rs
index e779932..fd7b4e4 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -342,6 +342,28 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf
 /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
 /// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
 /// needs one key it can sort on.
+/// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one.
+///
+/// Two notes written on the same day are not written at the same moment, and org records
+/// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them
+/// in whatever order the filesystem happened to yield.
+pub fn iso_time(raw: &str) -> Option<String> {
+    let bytes = raw.as_bytes();
+    for i in 0..bytes.len().saturating_sub(4) {
+        let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
+        if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) {
+            continue;
+        }
+        if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') {
+            continue;
+        }
+        let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8);
+        let end = if with_seconds { i + 8 } else { i + 5 };
+        return Some(raw[i..end].to_string());
+    }
+    None
+}
+
 pub fn iso_date(raw: &str) -> Option<String> {
     let bytes = raw.as_bytes();
     for i in 0..bytes.len().saturating_sub(9) {
diff --git a/tests/config.rs b/tests/config.rs
index 7fcfb8f..4794f04 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -2164,3 +2164,49 @@ fn a_listing_can_group_its_entries_by_year() {
     );
     assert!(html.contains("Undated"), "the undated post is still listed");
 }
+
+/// Two notes written on the same day are not written at the same moment, and org records
+/// which came first. Sorting on the date alone throws that away.
+#[test]
+fn same_day_entries_sort_by_time_of_day() {
+    let root = tmpdir("sorttime");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("blog")).unwrap();
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    for (name, title, date) in [
+        ("morning", "Morning", "[2026-02-21 Sat 09:15:00]"),
+        ("evening", "Evening", "[2026-02-21 Sat 21:40:00]"),
+        ("noon", "Noon", "[2026-02-21 Sat 12:30]"),
+    ] {
+        std::fs::write(
+            src.join(format!("blog/{name}.org")),
+            format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"),
+        )
+        .unwrap();
+    }
+    std::fs::write(
+        src.join("templates/list.html"),
+        "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
+         template = \"list.html\"\ntitle = \"Blog\"\nsort = \"date\"\norder = \"desc\"\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "blog/index.html");
+    let order: Vec<&str> = html
+        .split("<li>")
+        .skip(1)
+        .map(|s| s.split('<').next().unwrap())
+        .collect();
+    assert_eq!(
+        order,
+        vec!["Evening", "Noon", "Morning"],
+        "newest first, by the clock:\n{html}"
+    );
+}
diff --git a/tests/constructs.rs b/tests/constructs.rs
index 018b270..6bd0790 100644
--- a/tests/constructs.rs
+++ b/tests/constructs.rs
@@ -141,7 +141,7 @@ fn preamble_keywords_do_not_disturb_the_content_around_them() {
         "a keyword between two paragraphs must not merge them:\n{html}"
     );
     assert!(
-        html.contains("<figcaption>shot</figcaption>"),
+        html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"),
         "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}"
     );
 }
@@ -265,13 +265,19 @@ fn non_html_export_blocks_are_dropped() {
     );
 }
 
-/// An unmodelled block type keeps its content rather than vanishing.
+/// A verse block keeps its line breaks, and a block with an unrecognised name becomes a
+/// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER
+/// can carry content.
 #[test]
-fn unknown_block_types_keep_their_content() {
-    let html = render_fixture("outofscope.org");
+fn verse_and_special_blocks_keep_their_content() {
+    let html = render_fixture("blocks.org");
     assert!(
-        html.contains("An unmodelled block type"),
-        "a verse block degrades to a verbatim example block:\n{html}"
+        html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"),
+        "verse keeps its breaks:\n{html}"
+    );
+    assert!(
+        html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"),
+        "a special block holds parsed org:\n{html}"
     );
 }
 
@@ -502,3 +508,176 @@ fn an_ordinary_leading_comma_is_not_stripped() {
     assert!(text.contains(", a list continuation"), "{html}");
     assert!(text.contains(",not an escape"), "{html}");
 }
+
+// ---------------------------------------------------------------------------
+// Export-time text conversions
+// ---------------------------------------------------------------------------
+
+fn html_of(source: &str) -> String {
+    let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
+    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
+    html
+}
+
+/// Org converts dash runs and ellipses in prose. A reader of the published page should
+/// see the typography the author meant, not the ASCII they had to type.
+#[test]
+fn special_strings_become_real_punctuation() {
+    let html = html_of("An em---dash, an en--dash, and an ellipsis...\n");
+    assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}");
+    assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}");
+    assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}");
+}
+
+/// A shell transcript is not prose. `--verbose` inside code has to survive intact, or
+/// copying a command off the page produces one that does not run.
+#[test]
+fn special_strings_leave_code_alone() {
+    let html = html_of(
+        "Prose --dash and ~ls --all~ and =grep --color=.\n\n\
+         #+BEGIN_SRC sh\nls --all\n#+END_SRC\n",
+    );
+    assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}");
+    assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}");
+    assert!(html.contains("--color"), "verbatim:\n{html}");
+    // The source block's `--all` is split across highlighting spans, so count the
+    // conversion itself: exactly one en dash on the page, the one in the prose.
+    assert_eq!(
+        html.matches('\u{2013}').count(),
+        1,
+        "nothing inside code converted:\n{html}"
+    );
+}
+
+/// `#+OPTIONS: -:nil` is how a document opts out, and org-ssg honours org's own switch
+/// rather than inventing one.
+#[test]
+fn a_document_can_turn_special_strings_off() {
+    let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n");
+    assert!(html.contains("em---dash"), "left alone:\n{html}");
+    assert!(html.contains("ellipsis..."), "left alone:\n{html}");
+}
+
+/// Sub- and superscripts, including the braceless form — which is what makes
+/// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file.
+#[test]
+fn sub_and_superscripts_convert() {
+    let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n");
+    assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}");
+    assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}");
+    assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}");
+}
+
+/// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes
+/// *before* the marker: emphasis follows whitespace, a script follows a word.
+#[test]
+fn underline_still_wins_where_org_says_it_does() {
+    let html = html_of("Some _underlined_ text.\n");
+    assert!(html.contains("<u>underlined</u>"), "underline:\n{html}");
+    assert!(!html.contains("<sub>"), "not a subscript:\n{html}");
+}
+
+/// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form.
+#[test]
+fn a_document_can_restrict_sub_and_superscripts() {
+    let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n");
+    assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}");
+
+    let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n");
+    assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}");
+    assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}");
+}
+
+/// LaTeX is passed through for a typesetter, so the text conversions must not reach
+/// inside it: `x^2` in `$…$` is mathematics, not markup.
+#[test]
+fn latex_fragments_are_left_intact() {
+    let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n");
+    for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] {
+        assert!(html.contains(literal), "`{literal}` survives:\n{html}");
+    }
+}
+
+/// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end
+/// with a space, which is what keeps prices out of the math.
+#[test]
+fn dollar_amounts_are_not_latex() {
+    let html = html_of("It cost $5 or $6 --- a bargain.\n");
+    assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}");
+}
+
+/// Org exports outline levels relative to the file's own shallowest heading, so a
+/// document written entirely under `**` is a document of top-level sections.
+#[test]
+fn heading_levels_are_relative_to_the_shallowest_heading() {
+    let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n");
+    assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}");
+    assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}");
+}
+
+/// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org.
+/// Rendering its contents as literal text loses the markup the author wrote.
+#[test]
+fn a_special_block_holds_org_not_text() {
+    let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n");
+    assert!(html.contains("<div class=\"note\">"), "named div:\n{html}");
+    assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}");
+}
+
+/// A path that starts with `~` inside `~…~` verbatim: the body may open with the same
+/// character as the marker, and org says so.
+#[test]
+fn verbatim_can_start_with_its_own_marker() {
+    let html = html_of("Edit ~~/.config/doom/config.el~ now.\n");
+    assert!(
+        html.contains("<code>~/.config/doom/config.el</code>"),
+        "the leading ~ belongs to the path:\n{html}"
+    );
+}
+
+/// Org's special first column holds export markers, not data: `/` marks a column group,
+/// `#` a row to recalculate. Publishing them puts a column of punctuation on the page.
+#[test]
+fn a_tables_special_column_and_marker_rows_are_dropped() {
+    let html = html_of(
+        "| N | N^2 |\n\
+         | / |   < |\n\
+         | 1 |   1 |\n",
+    );
+    assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}");
+    assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}");
+
+    // Every row marked, so the column itself goes too.
+    let all_marked = html_of(
+        "| # | exp(x) | 1 |\n\
+         | # | exp(x) | 2 |\n",
+    );
+    assert!(
+        !all_marked.contains(">#<"),
+        "a wholly-special column is dropped:\n{all_marked}"
+    );
+    assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}");
+}
+
+/// An affiliated keyword belongs to the element *immediately* below it. Someone who
+/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next
+/// image instead would put the wrong words under the wrong picture.
+#[test]
+fn a_blank_line_ends_a_captions_association() {
+    let html = html_of(
+        "[[file:one.png]]\n\
+         #+CAPTION: stranded\n\
+         \n\
+         [[file:two.png]]\n",
+    );
+    assert!(
+        !html.contains("stranded"),
+        "an orphaned caption attaches to nothing:\n{html}"
+    );
+
+    let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n");
+    assert!(
+        attached.contains("<figcaption>"),
+        "a caption directly above its image still works:\n{attached}"
+    );
+}
diff --git a/tests/snapshots/constructs__blocks_html.snap b/tests/snapshots/constructs__blocks_html.snap
index 27e5768..e29d685 100644
--- a/tests/snapshots/constructs__blocks_html.snap
+++ b/tests/snapshots/constructs__blocks_html.snap
@@ -25,3 +25,12 @@ expression: "render_fixture(\"blocks.org\")"
 <p>A quote containing a source block:</p>
 <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre>
 </blockquote>
+<h2 id="verse">Verse</h2>
+<p class="verse">
+Line breaks are the point<br>
+  and indentation survives.
+</p>
+<h2 id="a-named-special-block">A named special block</h2>
+<div class="note">
+<p>Contents are <strong>org</strong>, not literal text.</p>
+</div>
diff --git a/tests/snapshots/constructs__images_html.snap b/tests/snapshots/constructs__images_html.snap
index 777b3ef..022782f 100644
--- a/tests/snapshots/constructs__images_html.snap
+++ b/tests/snapshots/constructs__images_html.snap
@@ -5,9 +5,9 @@ expression: "render_fixture(\"images.org\")"
 <h2 id="bare-image">Bare image</h2>
 <p><img src="diagram.png" alt=""></p>
 <h2 id="captioned-figure">Captioned figure</h2>
-<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure>
+<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption><span class="figure-number">Figure 1: </span>The pipeline, end to end</figcaption></figure>
 <h2 id="caption-with-markup">Caption with markup</h2>
-<figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure>
+<figure><img src="chart.png" alt="A stylised chart"><figcaption><span class="figure-number">Figure 2: </span>A <em>stylised</em> chart</figcaption></figure>
 <h2 id="quoted-attribute-values">Quoted attribute values</h2>
 <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure>
 <h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2>
diff --git a/tests/snapshots/constructs__lists_element_tree.snap b/tests/snapshots/constructs__lists_element_tree.snap
index 6c0fbce..8bfc18b 100644
--- a/tests/snapshots/constructs__lists_element_tree.snap
+++ b/tests/snapshots/constructs__lists_element_tree.snap
@@ -37,6 +37,7 @@ expression: "parse_fixture(\"lists.org\").root"
             "items": [
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -53,6 +54,7 @@ expression: "parse_fixture(\"lists.org\").root"
                       "items": [
                         {
                           "bullet": "Dash",
+                          "counter": null,
                           "checkbox": null,
                           "term": null,
                           "content": [
@@ -69,6 +71,7 @@ expression: "parse_fixture(\"lists.org\").root"
                                 "items": [
                                   {
                                     "bullet": "Dash",
+                                    "counter": null,
                                     "checkbox": null,
                                     "term": null,
                                     "content": [
@@ -88,6 +91,7 @@ expression: "parse_fixture(\"lists.org\").root"
                         },
                         {
                           "bullet": "Dash",
+                          "counter": null,
                           "checkbox": null,
                           "term": null,
                           "content": [
@@ -107,6 +111,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -151,6 +156,7 @@ expression: "parse_fixture(\"lists.org\").root"
                 "bullet": {
                   "Ordered": 1
                 },
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -167,6 +173,7 @@ expression: "parse_fixture(\"lists.org\").root"
                 "bullet": {
                   "Ordered": 2
                 },
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -185,6 +192,7 @@ expression: "parse_fixture(\"lists.org\").root"
                           "bullet": {
                             "Ordered": 1
                           },
+                          "counter": null,
                           "checkbox": null,
                           "term": null,
                           "content": [
@@ -201,6 +209,7 @@ expression: "parse_fixture(\"lists.org\").root"
                           "bullet": {
                             "Ordered": 2
                           },
+                          "counter": null,
                           "checkbox": null,
                           "term": null,
                           "content": [
@@ -222,6 +231,7 @@ expression: "parse_fixture(\"lists.org\").root"
                 "bullet": {
                   "Ordered": 3
                 },
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -264,6 +274,7 @@ expression: "parse_fixture(\"lists.org\").root"
             "items": [
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": "Off",
                 "term": null,
                 "content": [
@@ -278,6 +289,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": "On",
                 "term": null,
                 "content": [
@@ -292,6 +304,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": "Trans",
                 "term": null,
                 "content": [
@@ -334,6 +347,7 @@ expression: "parse_fixture(\"lists.org\").root"
             "items": [
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": [
                   {
@@ -352,6 +366,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": [
                   {
@@ -370,6 +385,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": [
                   {
@@ -423,6 +439,7 @@ expression: "parse_fixture(\"lists.org\").root"
             "items": [
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -444,6 +461,7 @@ expression: "parse_fixture(\"lists.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
diff --git a/tests/snapshots/constructs__lists_html.snap b/tests/snapshots/constructs__lists_html.snap
index 34af61c..c14de67 100644
--- a/tests/snapshots/constructs__lists_html.snap
+++ b/tests/snapshots/constructs__lists_html.snap
@@ -26,9 +26,9 @@ expression: "render_fixture(\"lists.org\")"
 </ol>
 <h2 id="checkboxes">Checkboxes</h2>
 <ul>
-<li><input type="checkbox" disabled> not done</li>
-<li><input type="checkbox" disabled checked> done</li>
-<li><input type="checkbox" disabled> partially done</li>
+<li class="off"><code>[&nbsp;]</code> not done</li>
+<li class="on"><code>[X]</code> done</li>
+<li class="trans"><code>[-]</code> partially done</li>
 </ul>
 <h2 id="description">Description</h2>
 <dl>
diff --git a/tests/snapshots/constructs__out_of_scope_html.snap b/tests/snapshots/constructs__out_of_scope_html.snap
index 3a9f4ed..e083a24 100644
--- a/tests/snapshots/constructs__out_of_scope_html.snap
+++ b/tests/snapshots/constructs__out_of_scope_html.snap
@@ -21,8 +21,5 @@ expression: "render_fixture(\"outofscope.org\")"
 <h2 id="macros-and-radio-targets">Macros and radio targets</h2>
 <p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
 <h2 id="drawers">Drawers</h2>
-<h2 id="verse">Verse</h2>
-<pre>An unmodelled block type
-keeps its content verbatim.</pre>
 <h2 id="entities">Entities</h2>
 <p>The full entity set is out of scope, so \alpha stays literal.</p>
diff --git a/tests/snapshots/oracle__oracle_blocks.snap b/tests/snapshots/oracle__oracle_blocks.snap
index 5209d8b..2c07729 100644
--- a/tests/snapshots/oracle__oracle_blocks.snap
+++ b/tests/snapshots/oracle__oracle_blocks.snap
@@ -2,7 +2,7 @@
 source: tests/oracle.rs
 expression: report
 ---
-agreement: 51/59 skeleton lines (86.4%)
+agreement: 67/77 skeleton lines (87.0%)
 (- org-ssg, + emacs)
 
   <h2>
@@ -66,3 +66,24 @@ agreement: 51/59 skeleton lines (86.4%)
 - </code>
   </pre>
   </blockquote>
+  <h2>
+  "Verse"
+  </h2>
+  <p>
+  "Line breaks are the point"
+  <br>
+  "and indentation survives."
++ <br>
+  </p>
+  <h2>
+  "A named special block"
+  </h2>
+  <p>
+  "Contents are"
+- <strong>
++ <b>
+  "org"
+- </strong>
++ </b>
+  ", not literal text."
+  </p>
diff --git a/tests/snapshots/oracle__oracle_core.snap b/tests/snapshots/oracle__oracle_core.snap
index 028e7b9..5dab635 100644
--- a/tests/snapshots/oracle__oracle_core.snap
+++ b/tests/snapshots/oracle__oracle_core.snap
@@ -2,7 +2,7 @@
 source: tests/oracle.rs
 expression: report
 ---
-agreement: 45/54 skeleton lines (83.3%)
+agreement: 51/58 skeleton lines (87.9%)
 (- org-ssg, + emacs)
 
   <p>
@@ -34,17 +34,15 @@ agreement: 45/54 skeleton lines (83.3%)
 - </ol>
 - <ul>
   <li>
-- <input>
-+ <code>
-+ "[ ]"
-+ </code>
+  <code>
+  "[ ]"
+  </code>
   "todo item"
   </li>
   <li>
-- <input>
-+ <code>
-+ "[X]"
-+ </code>
+  <code>
+  "[X]"
+  </code>
   "done item"
   </li>
 - </ul>
diff --git a/tests/snapshots/oracle__oracle_images.snap b/tests/snapshots/oracle__oracle_images.snap
index 9cbe7b4..9f1176a 100644
--- a/tests/snapshots/oracle__oracle_images.snap
+++ b/tests/snapshots/oracle__oracle_images.snap
@@ -2,7 +2,7 @@
 source: tests/oracle.rs
 expression: report
 ---
-agreement: 28/42 skeleton lines (66.7%)
+agreement: 30/42 skeleton lines (71.4%)
 (- org-ssg, + emacs)
 
   <h2>
@@ -18,12 +18,11 @@ agreement: 28/42 skeleton lines (66.7%)
 + <p>
   <img src="pipeline.svg">
 - <figcaption>
-- "The pipeline, end to end"
-- </figcaption>
-- </figure>
 + </p>
 + <p>
-+ "Figure 1: The pipeline, end to end"
+  "Figure 1: The pipeline, end to end"
+- </figcaption>
+- </figure>
 + </p>
   <h2>
   "Caption with markup"
@@ -32,11 +31,10 @@ agreement: 28/42 skeleton lines (66.7%)
 + <p>
   <img src="chart.png">
 - <figcaption>
-- "A"
-- <em>
 + </p>
 + <p>
-+ "Figure 2: A"
+  "Figure 2: A"
+- <em>
 + <i>
   "stylised"
 - </em>
diff --git a/tests/snapshots/oracle__oracle_lists.snap b/tests/snapshots/oracle__oracle_lists.snap
index b88b324..22311ea 100644
--- a/tests/snapshots/oracle__oracle_lists.snap
+++ b/tests/snapshots/oracle__oracle_lists.snap
@@ -2,7 +2,7 @@
 source: tests/oracle.rs
 expression: report
 ---
-agreement: 100/111 skeleton lines (90.1%)
+agreement: 109/111 skeleton lines (98.2%)
 (- org-ssg, + emacs)
 
   <h2>
@@ -56,24 +56,21 @@ agreement: 100/111 skeleton lines (90.1%)
   </h2>
   <ul>
   <li>
-- <input>
-+ <code>
-+ "[ ]"
-+ </code>
+  <code>
+  "[ ]"
+  </code>
   "not done"
   </li>
   <li>
-- <input>
-+ <code>
-+ "[X]"
-+ </code>
+  <code>
+  "[X]"
+  </code>
   "done"
   </li>
   <li>
-- <input>
-+ <code>
-+ "[-]"
-+ </code>
+  <code>
+  "[-]"
+  </code>
   "partially done"
   </li>
   </ul>
diff --git a/tests/snapshots/pipeline__core_element_tree.snap b/tests/snapshots/pipeline__core_element_tree.snap
index 4dd425b..ace83aa 100644
--- a/tests/snapshots/pipeline__core_element_tree.snap
+++ b/tests/snapshots/pipeline__core_element_tree.snap
@@ -63,6 +63,7 @@ expression: "parse_fixture(\"core.org\").root"
                 "bullet": {
                   "Ordered": 1
                 },
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -79,6 +80,7 @@ expression: "parse_fixture(\"core.org\").root"
                 "bullet": {
                   "Ordered": 2
                 },
+                "counter": null,
                 "checkbox": null,
                 "term": null,
                 "content": [
@@ -107,6 +109,7 @@ expression: "parse_fixture(\"core.org\").root"
             "items": [
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": "Off",
                 "term": null,
                 "content": [
@@ -121,6 +124,7 @@ expression: "parse_fixture(\"core.org\").root"
               },
               {
                 "bullet": "Dash",
+                "counter": null,
                 "checkbox": "On",
                 "term": null,
                 "content": [
diff --git a/tests/snapshots/pipeline__core_html.snap b/tests/snapshots/pipeline__core_html.snap
index 5e291e4..88a11e7 100644
--- a/tests/snapshots/pipeline__core_html.snap
+++ b/tests/snapshots/pipeline__core_html.snap
@@ -9,8 +9,8 @@ expression: "render_fixture(\"core.org\")"
 <li>second item with <em>emphasis</em></li>
 </ol>
 <ul>
-<li><input type="checkbox" disabled> todo item</li>
-<li><input type="checkbox" disabled checked> done item</li>
+<li class="off"><code>[&nbsp;]</code> todo item</li>
+<li class="on"><code>[X]</code> done item</li>
 </ul>
 <h2 id="links-and-code">Links and code</h2>
 <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
diff --git a/tests/snapshots/pipeline__minimal_element_tree.snap b/tests/snapshots/pipeline__minimal_element_tree.snap
index 8eacea7..cd06ed0 100644
--- a/tests/snapshots/pipeline__minimal_element_tree.snap
+++ b/tests/snapshots/pipeline__minimal_element_tree.snap
@@ -117,6 +117,7 @@ expression: "parse_fixture(\"minimal.org\").root"
                 "items": [
                   {
                     "bullet": "Dash",
+                    "counter": null,
                     "checkbox": null,
                     "term": null,
                     "content": [
@@ -131,6 +132,7 @@ expression: "parse_fixture(\"minimal.org\").root"
                   },
                   {
                     "bullet": "Dash",
+                    "counter": null,
                     "checkbox": null,
                     "term": null,
                     "content": [