krz/orgo

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

Commit 183c977692

183c97769244fe508f3e6621c7771a6b086b9869

parent: 8bd684becd

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 19:57 UTC

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.

Layout: unified · split

README.md +12 −9
@@ -327,17 +327,20 @@ is the only inherently global stage — it is where the link dependency graph is
327327
328328## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit)
329329
330**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags;
331property drawers; plain lists (unordered/ordered/description, checkboxes, nesting);
332tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting;
333example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`,
334`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup
335(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges);
336paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`.
330**IN — v1 must handle:** headings with nesting, at levels relative to the document's
331shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists
332(unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule
333rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax
334highlighting; example/quote/center/verse blocks and named special blocks; links (external,
335internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+`
336keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's
337export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps
338(active/inactive, ranges); paragraphs and horizontal rules; images with
339`#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`.
337340
338341**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
339`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and
340macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
342`:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past
343the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
341344semantics; non-HTML export blocks; the full Unicode entity set.
342345
343346**Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test
docs/guide/05-org-support.org +44 −5
@@ -35,6 +35,33 @@ every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=,
3535Timestamps, active and inactive, with times and ranges, render as =<time>= with a
3636machine-readable =datetime=.
3737
38*** Text conversions
39
40Org rewrites some prose on export, and so does org-ssg:
41
42| Written | Published |
43|---------+-----------|
44| =--= | – |
45| =---= | — |
46| =...= | … |
47| =x^2= | x superscript 2 |
48| =H_{2}O= | H subscript 2 O |
49
50Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in
51a shell transcript stays a flag, and =$x^2$= stays mathematics.
52
53*Braceless subscripts catch people out.* Org's default converts =a_b=, so =snake_case= in
54prose publishes as snake with a subscript. That is what Emacs does with the same file. Turn
55it off per document with =#+OPTIONS: ^:nil=, restrict it to the braced form with =^:{}=, or
56set =[html] sub_superscript= for the site. =#+OPTIONS: -:nil= turns off the dashes and
57ellipsis.
58
59*** Heading levels are relative
60
61A file whose shallowest heading is =**= is a file of top-level sections that happen to be
62indented, not a file of subsections — org exports levels relative to the document, so that
63subtree exports the same whether it was cut from a larger file or written on its own.
64
3865** Lists
3966
4067Unordered, ordered and description lists, nested by indentation, with checkboxes and
@@ -44,18 +71,25 @@ multi-paragraph items:
4471- outer item
4572 - inner item
4673- [X] a checked item
74- [-] a partly-done item
4775- term :: definition
761. [@4] an item numbered from 4
4877#+END_SRC
4978
79Checkboxes render as org writes them — =<code>[X]</code>= with the state as a class on the
80item — rather than as a disabled =<input>=, which has no way to say "partly done".
81
5082** Blocks
5183
52=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block
53inside a quote block works, because block ends match their own kind.
84=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER=, =VERSE= and =EXPORT=. A source
85block inside a quote block works, because block ends match their own kind.
5486
5587An =html= export block passes through verbatim; every other backend is dropped, because
5688emitting LaTeX into an HTML page is worse than emitting nothing.
5789
58An unknown block type keeps its content as an example block rather than vanishing.
90*Any other name is a special block*: =#+BEGIN_NOTE= becomes =<div class="note">= holding
91*parsed org*, which is what makes the convention usable without org-ssg knowing the word
92"note". A =COMMENT= block is not published.
5993
6094*** Which languages highlight
6195
@@ -89,7 +123,12 @@ in Emacs as much as here. If a code block seems to stop early, that is why.
89123
90124** Tables and footnotes
91125
92Pipe tables, with the rule row establishing a header band. Footnotes in all three forms —
126Pipe tables, with the rule row establishing a header band. Org's *special column* is
127honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is
128dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than
129content, so they never reach the page.
130
131Footnotes in all three forms —
93132=[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a
94133numbered, back-linked notes section.
95134
@@ -120,7 +159,7 @@ broken.
120159| =#+DRAFT:= | Keeps the page out of the build. |
121160| =#+TEMPLATE:= | The layout this page renders through. |
122161| =#+OPTIONS:= | Per-file export switches. |
123| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. |
162| =#+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:=. |
124163
125164Every other =#+KEYWORD:= is available to templates as
126165={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches
fixtures/blocks.org +13
@@ -51,3 +51,16 @@ A quote containing a source block:
5151echo hi
5252#+END_SRC
5353#+END_QUOTE
54
55* Verse
56
57#+BEGIN_VERSE
58Line breaks are the point
59 and indentation survives.
60#+END_VERSE
61
62* A named special block
63
64#+BEGIN_NOTE
65Contents are *org*, not literal text.
66#+END_NOTE
fixtures/outofscope.org −7
@@ -43,13 +43,6 @@ CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00] => 1:00
4343Drawer contents are captured and dropped.
4444:END:
4545
46* Verse
47
48#+BEGIN_VERSE
49An unmodelled block type
50keeps its content verbatim.
51#+END_VERSE
52
5346* Entities
5447
5548The full entity set is out of scope, so \alpha stays literal.
src/audit.rs +6 −1
@@ -78,7 +78,12 @@ const KNOWN_KEYWORDS: &[&str] = &[
7878 "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO",
7979 "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS",
8080];
81const KNOWN_BLOCKS: &[&str] = &["SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT"];
81/// Blocks with dedicated handling. Any *other* name renders as a special block — a div
82/// with that name holding parsed org — so an unlisted block is a note about what a corpus
83/// contains rather than a construct that will be lost.
84const KNOWN_BLOCKS: &[&str] = &[
85 "SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT", "VERSE", "COMMENT",
86];
8287const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"];
8388/// Keyword names conventional enough to be worth flagging when they lead a heading.
8489/// A custom sequence is only *real* if some `#+TODO:` declares it, which the census
src/config.rs +47
@@ -227,6 +227,44 @@ pub struct HtmlOutput {
227227 /// do not want them, so the default is the taste rather than the inheritance;
228228 /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back.
229229 pub section_numbers: bool,
230 /// Convert org's special strings in prose: `--` to an en dash, `---` to an em dash,
231 /// `...` to an ellipsis.
232 ///
233 /// On, as in Emacs. A document turns it off for itself with `#+OPTIONS: -:nil`.
234 /// Never applied inside verbatim, code, or a source block.
235 pub special_strings: bool,
236 /// Whether `x^2` and `H_{2}O` become `<sup>`/`<sub>`.
237 ///
238 /// `"yes"` (the default, and Emacs') also treats the braceless `a_b` as a subscript,
239 /// which is what makes `snake_case` in prose render as `snake<sub>case</sub>` —
240 /// surprising, but what Emacs does with the same file. `"braces"` limits it to the
241 /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself
242 /// with `#+OPTIONS: ^:nil` or `^:{}`.
243 pub sub_superscript: SubSuperscript,
244}
245
246/// How `_` and `^` are treated in prose. Mirrors org's `^:` export option.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "kebab-case")]
249pub enum SubSuperscript {
250 /// `a_b` and `a_{b}` both convert.
251 #[default]
252 Yes,
253 /// Only the braced `a_{b}` converts.
254 Braces,
255 /// Neither converts.
256 No,
257}
258
259impl SubSuperscript {
260 /// Read org's `^:` option value: `nil` is off, `{}` is braces-only, anything else on.
261 pub fn from_option(value: &str) -> SubSuperscript {
262 match value.trim() {
263 "nil" | "false" | "no" | "off" => SubSuperscript::No,
264 "{}" => SubSuperscript::Braces,
265 _ => SubSuperscript::Yes,
266 }
267 }
230268}
231269
232270impl Default for HtmlOutput {
@@ -235,6 +273,8 @@ impl Default for HtmlOutput {
235273 heading_offset: 1,
236274 toc: true,
237275 section_numbers: false,
276 special_strings: true,
277 sub_superscript: SubSuperscript::Yes,
238278 }
239279 }
240280}
@@ -530,6 +570,13 @@ toc = true
530570# Number headings (1., 1.1., …). Emacs defaults this on; most sites do not.
531571# A document overrides with `#+OPTIONS: num:t`.
532572section_numbers = false
573# Convert `--` to an en dash, `---` to an em dash and `...` to an ellipsis in prose, as
574# Emacs does. Never inside code. A document overrides with `#+OPTIONS: -:nil`.
575special_strings = true
576# Whether `x^2` and `H_{2}O` become <sup>/<sub>: "yes" (as Emacs, and so `snake_case`
577# becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no".
578# A document overrides with `#+OPTIONS: ^:nil` or `^:{}`.
579sub_superscript = "yes"
533580
534581# Generated listing pages: output files with no source .org behind them. Repeat the
535582# [[collections]] block for each one. A feed is the same thing with an XML template.
src/incremental.rs +1 −1
@@ -29,7 +29,7 @@ use crate::util::output_url;
2929/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
3030/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
3131/// version is folded in as the "hash-algo version" so a hash upgrade also busts.
32pub const CACHE_FORMAT_VERSION: u32 = 5;
32pub const CACHE_FORMAT_VERSION: u32 = 6;
3333
3434/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
3535pub type Hash = ContentHash;
src/model.rs +10
@@ -112,6 +112,14 @@ pub enum Element {
112112 },
113113 QuoteBlock(Vec<Element>),
114114 CenterBlock(Vec<Element>),
115 /// `#+BEGIN_<name>` for any other name — `note`, `warning`, `aside`. Its contents are
116 /// org, not literal text, and the name becomes a class so a stylesheet can reach it.
117 SpecialBlock {
118 name: String,
119 content: Vec<Element>,
120 },
121 /// `#+BEGIN_VERSE`: line breaks are significant.
122 VerseBlock(Vec<String>),
115123 /// html passes through; others dropped at render (spec §1 OUT).
116124 ExportBlock {
117125 backend: String,
@@ -157,6 +165,8 @@ pub enum ListKind {
157165#[derive(Debug, Clone, Serialize, Deserialize)]
158166pub struct ListItem {
159167 pub bullet: Bullet,
168 /// `[@4]` — an explicit number for this item, restarting the list's counting.
169 pub counter: Option<u32>,
160170 pub checkbox: Option<Checkbox>,
161171 /// Description-list term before `::`.
162172 pub term: Option<Vec<Object>>,
src/parser.rs +48 −6
@@ -398,6 +398,11 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V
398398 while i < lines.len() {
399399 let line = lines[i];
400400 if line.trim().is_empty() {
401 // A blank line ends the association: an affiliated keyword belongs to the
402 // element *immediately* below it. Someone who writes `#+CAPTION:` under their
403 // image and then leaves a blank line has captioned nothing, and org agrees —
404 // silently attaching it to whatever comes next would caption the wrong thing.
405 affiliated.clear();
401406 i += 1;
402407 continue;
403408 }
@@ -563,9 +568,17 @@ fn parse_block(
563568 backend: after.split_whitespace().next().unwrap_or("").to_string(),
564569 raw: inner.join("\n"),
565570 },
566 // Out-of-scope block types (verse, comment, ascii, custom) degrade to a verbatim
567 // example block: content preserved, no crash.
568 _ => Element::ExampleBlock(inner.join("\n")),
571 // Verse keeps its line breaks; that is the whole point of it.
572 "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()),
573 // A comment block is not published, in org or here.
574 "COMMENT" => Element::Comment(inner.join("\n")),
575 // Any other name is a special block: a div with that class, holding org. Emacs
576 // exports unknown block types this way, which is what makes `#+BEGIN_NOTE` a
577 // usable convention without the exporter knowing the word "note".
578 other => Element::SpecialBlock {
579 name: other.to_ascii_lowercase(),
580 content: parse_elements(&inner, base + start + 1, diags),
581 },
569582 };
570583 (element, next)
571584}
@@ -830,6 +843,8 @@ fn parse_list(
830843 // Body = the text after the bullet, plus every following line indented past the
831844 // bullet column (blank lines included, so an item can hold several paragraphs).
832845 let rest = item_body(lines[j].trim_start(), &bullet);
846 // `[@4]` comes before the checkbox: `1. [@4] [X] done`.
847 let (counter, rest) = split_counter(rest);
833848 let (checkbox, rest) = split_checkbox(rest);
834849 let (term, rest) = match kind {
835850 ListKind::Description => match split_term(rest) {
@@ -864,6 +879,7 @@ fn parse_list(
864879
865880 items.push(ListItem {
866881 bullet,
882 counter,
867883 checkbox,
868884 term,
869885 // 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 {
949965 }
950966}
951967
968/// Detect a leading `[@N]` counter on a list item, which sets its number explicitly.
969fn split_counter(text: &str) -> (Option<u32>, &str) {
970 let Some(rest) = text.strip_prefix("[@") else {
971 return (None, text);
972 };
973 let Some(end) = rest.find(']') else {
974 return (None, text);
975 };
976 match rest[..end].parse::<u32>() {
977 Ok(n) => (Some(n), rest[end + 1..].trim_start()),
978 Err(_) => (None, text),
979 }
980}
981
952982/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
953983fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
954984 let bytes = text.as_bytes();
@@ -1368,8 +1398,10 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
13681398 if i + 1 >= n {
13691399 return None;
13701400 }
1371 let after = chars[i + 1];
1372 if after.is_whitespace() || after == m {
1401 // Org's body-character rule: the character after the opening marker may not be
1402 // whitespace, a comma or a quote. It *may* be another marker, which is what makes
1403 // `~~/.config/emacs~` verbatim for a path that starts with `~`.
1404 if !body_char_ok(chars[i + 1]) {
13731405 return None;
13741406 }
13751407 let mut j = i + 1;
@@ -1377,7 +1409,7 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
13771409 if chars[j] == m && j > i + 1 {
13781410 let before = chars[j - 1];
13791411 let next = chars.get(j + 1).copied();
1380 if !before.is_whitespace() && post_ok(next) {
1412 if body_char_ok(before) && post_ok(next) {
13811413 let inner = &chars[i + 1..j];
13821414 let obj = match m {
13831415 '=' => Object::Verbatim(inner.iter().collect()),
@@ -1396,6 +1428,16 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
13961428 None
13971429}
13981430
1431/// May this character sit directly inside an emphasis marker?
1432///
1433/// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a
1434/// body, which is what makes `="proxied":false=` verbatim, and `=SPC m '=` may close on
1435/// an apostrophe. The marker character itself is allowed too, so `~~/.config/emacs~` is a
1436/// path that starts with a tilde.
1437fn body_char_ok(c: char) -> bool {
1438 !c.is_whitespace()
1439}
1440
13991441fn boundary_before(chars: &[char], i: usize) -> bool {
14001442 if i == 0 {
14011443 return true;
src/render.rs +348 −15
@@ -23,10 +23,11 @@ use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGener
2323use syntect::parsing::SyntaxSet;
2424use syntect::util::LinesWithEndings;
2525
26use crate::config::SubSuperscript;
2627use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
2728use crate::parser::is_image_target;
2829use crate::resolve::ResolvedDoc;
29use crate::util::{heading_anchor, plain_text, slugify};
30use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify};
3031
3132/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
3233#[derive(Debug, Clone)]
@@ -211,6 +212,12 @@ struct Renderer<'a> {
211212 order: Vec<String>,
212213 /// Counter per heading depth, for section numbers.
213214 counters: Vec<usize>,
215 /// Subtracted from every heading level so the document's shallowest heading renders
216 /// as level 1. Org exports levels *relative* to a file's own outline, so a file
217 /// written entirely under `**` is not a file of subsections.
218 headline_offset: u8,
219 /// Captioned figures seen so far, for `Figure N:`.
220 figures: usize,
214221}
215222
216223/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -224,6 +231,12 @@ pub struct RenderOptions {
224231 /// Prefix headings with `1.`, `1.1.`, … See
225232 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
226233 pub section_numbers: bool,
234 /// Convert `--`, `---` and `...` in prose. See
235 /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings).
236 pub special_strings: bool,
237 /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See
238 /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript).
239 pub sub_superscript: SubSuperscript,
227240}
228241
229242impl Default for RenderOptions {
@@ -232,10 +245,49 @@ impl Default for RenderOptions {
232245 RenderOptions {
233246 heading_offset: html.heading_offset,
234247 section_numbers: html.section_numbers,
248 special_strings: html.special_strings,
249 sub_superscript: html.sub_superscript,
235250 }
236251 }
237252}
238253
254/// The site's render options with the document's own `#+OPTIONS:` applied on top.
255///
256/// Org's per-file switches are the author's override of a site-wide setting, and they
257/// belong here rather than at each call site — otherwise rendering one document two ways
258/// depends on which caller remembered to read its keywords.
259fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions {
260 RenderOptions {
261 heading_offset: opts.heading_offset,
262 section_numbers: option_enabled(keywords, "num", opts.section_numbers),
263 special_strings: option_enabled(keywords, "-", opts.special_strings),
264 // `^:` has three values rather than two — `nil`, `{}` or on — so it is read
265 // directly instead of through the boolean helper.
266 sub_superscript: match export_options(keywords).get("^") {
267 Some(value) => SubSuperscript::from_option(value),
268 None => opts.sub_superscript,
269 },
270 }
271}
272
273/// How much to subtract from every heading level, so a document's shallowest heading
274/// renders as level 1.
275///
276/// Org exports outline levels *relative to the file*: a document written entirely under
277/// `**` is a document of top-level sections that happen to be indented, not a document of
278/// subsections. Emacs computes this from the shallowest top-level headline, which is what
279/// makes the same subtree export identically whether it was cut from a larger file or
280/// written on its own.
281fn headline_offset(root: &Section) -> u8 {
282 root.children
283 .iter()
284 .filter_map(|s| s.heading.as_ref())
285 .map(|h| h.level)
286 .min()
287 .map(|min| min.saturating_sub(1))
288 .unwrap_or(0)
289}
290
239291/// Render a resolved document to an HTML fragment, with default options.
240292pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
241293 render_with(doc, highlighter, &RenderOptions::default())
@@ -245,11 +297,13 @@ pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
245297pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
246298 let mut r = Renderer {
247299 hl: highlighter,
248 opts: *opts,
300 opts: document_options(&doc.document.keywords, opts),
249301 block_defs: HashMap::new(),
250302 inline_defs: HashMap::new(),
251303 order: Vec::new(),
252304 counters: Vec::new(),
305 headline_offset: headline_offset(&doc.document.root),
306 figures: 0,
253307 };
254308 r.collect_defs(&doc.document.root);
255309 let mut out = String::new();
@@ -269,7 +323,8 @@ impl Renderer<'_> {
269323
270324 fn render_section(&mut self, section: &Section, out: &mut String) {
271325 if let Some(h) = &section.heading {
272 let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
326 let relative = h.level.saturating_sub(self.headline_offset).max(1);
327 let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6);
273328 let anchor = heading_anchor(h);
274329 // A heading with no title text has no meaningful slug; emit no `id` at all
275330 // rather than a run of duplicate empty ones.
@@ -279,7 +334,7 @@ impl Renderer<'_> {
279334 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
280335 }
281336 if self.opts.section_numbers {
282 let number = self.next_section_number(h.level);
337 let number = self.next_section_number(relative);
283338 out.push_str(&format!(
284339 "<span class=\"section-number-{}\">{number}</span> ",
285340 level
@@ -377,12 +432,33 @@ impl Renderer<'_> {
377432 out.push_str("<figure>");
378433 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
379434 if !caption.is_empty() {
380 out.push_str("<figcaption>");
435 self.figures += 1;
436 out.push_str(&format!(
437 "<figcaption><span class=\"figure-number\">Figure {}: </span>",
438 self.figures
439 ));
381440 self.render_objects(caption, out);
382441 out.push_str("</figcaption>");
383442 }
384443 out.push_str("</figure>\n");
385444 }
445 Element::SpecialBlock { name, content } => {
446 out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name)));
447 for child in content {
448 self.render_element(child, out);
449 }
450 out.push_str("</div>\n");
451 }
452 Element::VerseBlock(lines) => {
453 out.push_str("<p class=\"verse\">\n");
454 for (i, line) in lines.iter().enumerate() {
455 if i > 0 {
456 out.push_str("<br>\n");
457 }
458 self.render_objects(&crate::parser::inline(line), out);
459 }
460 out.push_str("\n</p>\n");
461 }
386462 Element::HorizontalRule => out.push_str("<hr>\n"),
387463 // Definitions are emitted in the footnotes section, not inline.
388464 Element::FootnoteDefinition { .. } => {}
@@ -414,16 +490,29 @@ impl Renderer<'_> {
414490 };
415491 out.push_str(&format!("<{}>\n", tag));
416492 for item in &list.items {
417 out.push_str("<li>");
493 // Org writes a checkbox as literal text, which keeps the third state — `[-]`,
494 // partially done — that a disabled <input> cannot express. The state goes on
495 // the item, where it can style the whole line.
496 let class = item.checkbox.as_ref().map(|cb| match cb {
497 Checkbox::On => "on",
498 Checkbox::Off => "off",
499 Checkbox::Trans => "trans",
500 });
501 out.push_str("<li");
502 if let Some(class) = class {
503 out.push_str(&format!(" class=\"{class}\""));
504 }
505 // `[@4]` restarts the numbering, and HTML says so with `value`.
506 if let Some(n) = item.counter {
507 out.push_str(&format!(" value=\"{n}\""));
508 }
509 out.push('>');
418510 if let Some(cb) = &item.checkbox {
419 out.push_str(&format!(
420 "<input type=\"checkbox\" disabled{}> ",
421 if matches!(cb, Checkbox::On) {
422 " checked"
423 } else {
424 ""
425 }
426 ));
511 out.push_str(match cb {
512 Checkbox::On => "<code>[X]</code> ",
513 Checkbox::Off => "<code>[&nbsp;]</code> ",
514 Checkbox::Trans => "<code>[-]</code> ",
515 });
427516 }
428517 self.render_item_content(&item.content, out);
429518 out.push_str("</li>\n");
@@ -454,6 +543,8 @@ impl Renderer<'_> {
454543
455544 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
456545 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
546 let table = strip_special_column(table);
547 let table = &table;
457548 let rule_at = table
458549 .rows
459550 .iter()
@@ -502,6 +593,31 @@ impl Renderer<'_> {
502593 out.push_str("</table>\n");
503594 }
504595
596 /// Plain text as HTML: escaped, then org's export-time text conversions.
597 ///
598 /// Both run on the *escaped* string so their output tags survive, and both are
599 /// reachable only from [`Object::Text`] — verbatim, code and source blocks are
600 /// different objects, which is what keeps `--verbose` in a shell transcript intact.
601 fn text_html(&self, t: &str) -> String {
602 let escaped = escape_html(t);
603 let mut out = String::with_capacity(escaped.len());
604 // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a
605 // superscript for us, and an em dash inside a formula is not what was meant.
606 for (span, is_latex) in latex_split(&escaped) {
607 if is_latex {
608 out.push_str(span);
609 continue;
610 }
611 let with_strings = if self.opts.special_strings {
612 special_strings(span)
613 } else {
614 span.to_string()
615 };
616 out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript));
617 }
618 out
619 }
620
505621 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
506622 for obj in objs {
507623 self.render_object(obj, out);
@@ -510,7 +626,7 @@ impl Renderer<'_> {
510626
511627 fn render_object(&mut self, obj: &Object, out: &mut String) {
512628 match obj {
513 Object::Text(t) => out.push_str(&escape_html(t)),
629 Object::Text(t) => out.push_str(&self.text_html(t)),
514630 Object::Bold(inner) => self.wrap(out, "strong", inner),
515631 Object::Italic(inner) => self.wrap(out, "em", inner),
516632 Object::Underline(inner) => self.wrap(out, "u", inner),
@@ -735,6 +851,223 @@ fn link_text(target: &LinkTarget) -> String {
735851 }
736852}
737853
854/// Drop org's special column and its marker rows.
855///
856/// A table's first column may hold export markers rather than data — `/` marks a column
857/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are
858/// instructions to org and never appear in the output; the column itself disappears when
859/// *every* row uses it that way, which is what stops a table of formulas from publishing
860/// with a stray column of hashes.
861fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
862 let first_cell = |row: &TableRow| -> Option<String> {
863 match row {
864 TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()),
865 TableRow::Rule => None,
866 }
867 };
868 let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_)));
869 let column_is_special = data_rows().count() > 0
870 && data_rows().all(|row| {
871 matches!(
872 first_cell(row).as_deref(),
873 Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*")
874 )
875 });
876
877 let rows = table
878 .rows
879 .iter()
880 .filter(|row| {
881 // A marker row is an instruction, not content.
882 !matches!(
883 first_cell(row).as_deref(),
884 Some("/" | "!" | "^" | "_" | "$")
885 )
886 })
887 .map(|row| match (row, column_is_special) {
888 (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()),
889 _ => row.clone(),
890 })
891 .collect();
892 crate::model::Table { rows }
893}
894
895/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
896///
897/// org-ssg does not typeset LaTeX — it passes it through for MathJax or a reader's eyes —
898/// but it must know where a fragment *is*, because the export-time text conversions would
899/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be
900/// marked up, and `--` inside one is a minus sign twice.
901fn latex_split(s: &str) -> Vec<(&str, bool)> {
902 let bytes = s.as_bytes();
903 let mut spans = Vec::new();
904 let mut plain_from = 0;
905 let mut i = 0;
906 while i < s.len() {
907 if !s.is_char_boundary(i) {
908 i += 1;
909 continue;
910 }
911 let end = match bytes[i] {
912 b'\\' => latex_backslash_end(s, i),
913 b'$' => latex_dollar_end(s, i),
914 _ => None,
915 };
916 if let Some(end) = end {
917 if plain_from < i {
918 spans.push((&s[plain_from..i], false));
919 }
920 spans.push((&s[i..end], true));
921 plain_from = end;
922 i = end;
923 continue;
924 }
925 i += 1;
926 }
927 if plain_from < s.len() {
928 spans.push((&s[plain_from..], false));
929 }
930 spans
931}
932
933/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`.
934fn latex_backslash_end(s: &str, i: usize) -> Option<usize> {
935 let rest = &s[i..];
936 for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] {
937 if let Some(body) = rest.strip_prefix(open) {
938 return body.find(close).map(|k| i + open.len() + k + close.len());
939 }
940 }
941 let after = rest.strip_prefix("\\begin{")?;
942 let name_end = after.find('}')?;
943 let end_tag = format!("\\end{{{}}}", &after[..name_end]);
944 let k = rest.find(&end_tag)?;
945 Some(i + k + end_tag.len())
946}
947
948/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign.
949///
950/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6"
951/// out — the same heuristic org uses, and the same one that means a sentence with two
952/// unrelated dollar amounts and no space between them will be read as math.
953fn latex_dollar_end(s: &str, i: usize) -> Option<usize> {
954 let body = &s[i + 1..];
955 let close = body.find('$')?;
956 if close == 0 {
957 return None;
958 }
959 let inner = &body[..close];
960 if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) {
961 return None;
962 }
963 if inner.contains('\n') {
964 return None;
965 }
966 Some(i + 1 + close + 1)
967}
968
969/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis,
970/// and `\-` a soft hyphen.
971///
972/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the
973/// very end of a line, or the `-----` someone drew as a rule, is left alone.
974fn special_strings(s: &str) -> String {
975 let chars: Vec<char> = s.chars().collect();
976 let mut out = String::with_capacity(s.len());
977 let mut i = 0;
978 while i < chars.len() {
979 let rest = &chars[i..];
980 let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None);
981 if rest.starts_with(&['\\', '-']) {
982 out.push('\u{00ad}');
983 i += 2;
984 } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) {
985 out.push('\u{2014}');
986 i += 3;
987 } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) {
988 out.push('\u{2013}');
989 i += 2;
990 } else if rest.starts_with(&['.', '.', '.']) {
991 out.push('\u{2026}');
992 i += 3;
993 } else {
994 out.push(chars[i]);
995 i += 1;
996 }
997 }
998 out
999}
1000
1001/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`.
1002///
1003/// Both require a non-whitespace character before the marker, which is what separates a
1004/// subscript from `_underlined text_` — the parser has already taken the emphasis, since
1005/// that form requires whitespace *before* the marker.
1006fn sub_superscript(s: &str, mode: SubSuperscript) -> String {
1007 if mode == SubSuperscript::No || !s.contains(['_', '^']) {
1008 return s.to_string();
1009 }
1010 let chars: Vec<char> = s.chars().collect();
1011 let mut out = String::with_capacity(s.len());
1012 let mut i = 0;
1013 while i < chars.len() {
1014 let c = chars[i];
1015 let prev_ok = i > 0 && !chars[i - 1].is_whitespace();
1016 if (c == '_' || c == '^') && prev_ok {
1017 if let Some((body, next)) = script_body(&chars, i + 1, mode) {
1018 let tag = if c == '_' { "sub" } else { "sup" };
1019 out.push_str(&format!("<{tag}>{body}</{tag}>"));
1020 i = next;
1021 continue;
1022 }
1023 }
1024 out.push(c);
1025 i += 1;
1026 }
1027 out
1028}
1029
1030/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required —
1031/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not.
1032fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> {
1033 if chars.get(start) == Some(&'{') {
1034 let mut depth = 1;
1035 let mut j = start + 1;
1036 while j < chars.len() {
1037 match chars[j] {
1038 '{' => depth += 1,
1039 '}' => {
1040 depth -= 1;
1041 if depth == 0 {
1042 return Some((chars[start + 1..j].iter().collect(), j + 1));
1043 }
1044 }
1045 _ => {}
1046 }
1047 j += 1;
1048 }
1049 return None;
1050 }
1051 if mode == SubSuperscript::Braces {
1052 return None;
1053 }
1054 let mut j = start;
1055 if matches!(chars.get(j), Some('+') | Some('-')) {
1056 j += 1;
1057 }
1058 let mut last_alnum = None;
1059 while let Some(&c) = chars.get(j) {
1060 if c.is_alphanumeric() {
1061 last_alnum = Some(j);
1062 } else if !matches!(c, '.' | ',' | '\\') {
1063 break;
1064 }
1065 j += 1;
1066 }
1067 let end = last_alnum? + 1;
1068 Some((chars[start..end].iter().collect(), end))
1069}
1070
7381071fn escape_html(s: &str) -> String {
7391072 let mut out = String::with_capacity(s.len());
7401073 for c in s.chars() {
src/site.rs +21 −9
@@ -33,7 +33,8 @@ use crate::template::{
3333 Templater,
3434};
3535use crate::util::{
36 document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url,
36 document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled,
37 output_path, output_url,
3738 relative_root, slugify, table_of_contents,
3839};
3940
@@ -239,7 +240,18 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
239240 // Undated pages sort last in the final order regardless of direction: a
240241 // draft with no date should not lead an archive.
241242 SortKey::Date => entries.sort_by(|a, b| {
242 let key = |p: &PageContext| p.date_iso.clone();
243 // Date *and* time: org records when a note was written, and two notes
244 // from the same day have an order that the day alone cannot express.
245 let key = |p: &PageContext| {
246 p.date_iso.as_ref().map(|d| {
247 let time = p
248 .date
249 .as_deref()
250 .and_then(iso_time)
251 .unwrap_or_else(|| "00:00:00".to_string());
252 format!("{d}T{time}")
253 })
254 };
243255 match (key(a), key(b)) {
244256 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
245257 (Some(_), None) => std::cmp::Ordering::Greater,
@@ -750,12 +762,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
750762 Ok((pages, broken))
751763}
752764
753/// Render options for one page: the site's settings, with the document's own
754/// `#+OPTIONS:` switches applied on top.
755fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions {
765/// The site's render options. A document's own `#+OPTIONS:` switches are applied by the
766/// renderer, so every caller gets them.
767fn render_options(config: &Config) -> RenderOptions {
756768 RenderOptions {
757769 heading_offset: config.html.heading_offset,
758 section_numbers: option_enabled(keywords, "num", config.html.section_numbers),
770 section_numbers: config.html.section_numbers,
771 special_strings: config.html.special_strings,
772 sub_superscript: config.html.sub_superscript,
759773 }
760774}
761775
@@ -787,9 +801,7 @@ fn render_page(
787801 config: &Config,
788802 p: &PagePrep,
789803) -> Result<String> {
790 // Options are resolved per page, because `#+OPTIONS:` is a per-document override of
791 // the site setting.
792 let opts = render_options(config, &p.resolved.document.keywords);
804 let opts = render_options(config);
793805 let Html(fragment) = render_with(&p.resolved, highlighter, &opts);
794806 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
795807 let root = relative_root(&p.output);
src/util.rs +22
@@ -342,6 +342,28 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf
342342/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
343343/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
344344/// needs one key it can sort on.
345/// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one.
346///
347/// Two notes written on the same day are not written at the same moment, and org records
348/// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them
349/// in whatever order the filesystem happened to yield.
350pub fn iso_time(raw: &str) -> Option<String> {
351 let bytes = raw.as_bytes();
352 for i in 0..bytes.len().saturating_sub(4) {
353 let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
354 if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) {
355 continue;
356 }
357 if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') {
358 continue;
359 }
360 let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8);
361 let end = if with_seconds { i + 8 } else { i + 5 };
362 return Some(raw[i..end].to_string());
363 }
364 None
365}
366
345367pub fn iso_date(raw: &str) -> Option<String> {
346368 let bytes = raw.as_bytes();
347369 for i in 0..bytes.len().saturating_sub(9) {
tests/config.rs +46
@@ -2164,3 +2164,49 @@ fn a_listing_can_group_its_entries_by_year() {
21642164 );
21652165 assert!(html.contains("Undated"), "the undated post is still listed");
21662166}
2167
2168/// Two notes written on the same day are not written at the same moment, and org records
2169/// which came first. Sorting on the date alone throws that away.
2170#[test]
2171fn same_day_entries_sort_by_time_of_day() {
2172 let root = tmpdir("sorttime");
2173 let src = root.join("src");
2174 std::fs::create_dir_all(src.join("blog")).unwrap();
2175 std::fs::create_dir_all(src.join("templates")).unwrap();
2176 for (name, title, date) in [
2177 ("morning", "Morning", "[2026-02-21 Sat 09:15:00]"),
2178 ("evening", "Evening", "[2026-02-21 Sat 21:40:00]"),
2179 ("noon", "Noon", "[2026-02-21 Sat 12:30]"),
2180 ] {
2181 std::fs::write(
2182 src.join(format!("blog/{name}.org")),
2183 format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"),
2184 )
2185 .unwrap();
2186 }
2187 std::fs::write(
2188 src.join("templates/list.html"),
2189 "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>",
2190 )
2191 .unwrap();
2192 std::fs::write(
2193 src.join("org-ssg.toml"),
2194 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
2195 template = \"list.html\"\ntitle = \"Blog\"\nsort = \"date\"\norder = \"desc\"\n",
2196 )
2197 .unwrap();
2198 let out = root.join("out");
2199 build(&src, &out);
2200
2201 let html = page(&out, "blog/index.html");
2202 let order: Vec<&str> = html
2203 .split("<li>")
2204 .skip(1)
2205 .map(|s| s.split('<').next().unwrap())
2206 .collect();
2207 assert_eq!(
2208 order,
2209 vec!["Evening", "Noon", "Morning"],
2210 "newest first, by the clock:\n{html}"
2211 );
2212}
tests/constructs.rs +185 −6
@@ -141,7 +141,7 @@ fn preamble_keywords_do_not_disturb_the_content_around_them() {
141141 "a keyword between two paragraphs must not merge them:\n{html}"
142142 );
143143 assert!(
144 html.contains("<figcaption>shot</figcaption>"),
144 html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"),
145145 "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}"
146146 );
147147}
@@ -265,13 +265,19 @@ fn non_html_export_blocks_are_dropped() {
265265 );
266266}
267267
268/// An unmodelled block type keeps its content rather than vanishing.
268/// A verse block keeps its line breaks, and a block with an unrecognised name becomes a
269/// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER
270/// can carry content.
269271#[test]
270fn unknown_block_types_keep_their_content() {
271 let html = render_fixture("outofscope.org");
272fn verse_and_special_blocks_keep_their_content() {
273 let html = render_fixture("blocks.org");
272274 assert!(
273 html.contains("An unmodelled block type"),
274 "a verse block degrades to a verbatim example block:\n{html}"
275 html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"),
276 "verse keeps its breaks:\n{html}"
277 );
278 assert!(
279 html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"),
280 "a special block holds parsed org:\n{html}"
275281 );
276282}
277283
@@ -502,3 +508,176 @@ fn an_ordinary_leading_comma_is_not_stripped() {
502508 assert!(text.contains(", a list continuation"), "{html}");
503509 assert!(text.contains(",not an escape"), "{html}");
504510}
511
512// ---------------------------------------------------------------------------
513// Export-time text conversions
514// ---------------------------------------------------------------------------
515
516fn html_of(source: &str) -> String {
517 let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse");
518 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
519 html
520}
521
522/// Org converts dash runs and ellipses in prose. A reader of the published page should
523/// see the typography the author meant, not the ASCII they had to type.
524#[test]
525fn special_strings_become_real_punctuation() {
526 let html = html_of("An em---dash, an en--dash, and an ellipsis...\n");
527 assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}");
528 assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}");
529 assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}");
530}
531
532/// A shell transcript is not prose. `--verbose` inside code has to survive intact, or
533/// copying a command off the page produces one that does not run.
534#[test]
535fn special_strings_leave_code_alone() {
536 let html = html_of(
537 "Prose --dash and ~ls --all~ and =grep --color=.\n\n\
538 #+BEGIN_SRC sh\nls --all\n#+END_SRC\n",
539 );
540 assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}");
541 assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}");
542 assert!(html.contains("--color"), "verbatim:\n{html}");
543 // The source block's `--all` is split across highlighting spans, so count the
544 // conversion itself: exactly one en dash on the page, the one in the prose.
545 assert_eq!(
546 html.matches('\u{2013}').count(),
547 1,
548 "nothing inside code converted:\n{html}"
549 );
550}
551
552/// `#+OPTIONS: -:nil` is how a document opts out, and org-ssg honours org's own switch
553/// rather than inventing one.
554#[test]
555fn a_document_can_turn_special_strings_off() {
556 let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n");
557 assert!(html.contains("em---dash"), "left alone:\n{html}");
558 assert!(html.contains("ellipsis..."), "left alone:\n{html}");
559}
560
561/// Sub- and superscripts, including the braceless form — which is what makes
562/// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file.
563#[test]
564fn sub_and_superscripts_convert() {
565 let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n");
566 assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}");
567 assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}");
568 assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}");
569}
570
571/// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes
572/// *before* the marker: emphasis follows whitespace, a script follows a word.
573#[test]
574fn underline_still_wins_where_org_says_it_does() {
575 let html = html_of("Some _underlined_ text.\n");
576 assert!(html.contains("<u>underlined</u>"), "underline:\n{html}");
577 assert!(!html.contains("<sub>"), "not a subscript:\n{html}");
578}
579
580/// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form.
581#[test]
582fn a_document_can_restrict_sub_and_superscripts() {
583 let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n");
584 assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}");
585
586 let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n");
587 assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}");
588 assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}");
589}
590
591/// LaTeX is passed through for a typesetter, so the text conversions must not reach
592/// inside it: `x^2` in `$…$` is mathematics, not markup.
593#[test]
594fn latex_fragments_are_left_intact() {
595 let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n");
596 for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] {
597 assert!(html.contains(literal), "`{literal}` survives:\n{html}");
598 }
599}
600
601/// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end
602/// with a space, which is what keeps prices out of the math.
603#[test]
604fn dollar_amounts_are_not_latex() {
605 let html = html_of("It cost $5 or $6 --- a bargain.\n");
606 assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}");
607}
608
609/// Org exports outline levels relative to the file's own shallowest heading, so a
610/// document written entirely under `**` is a document of top-level sections.
611#[test]
612fn heading_levels_are_relative_to_the_shallowest_heading() {
613 let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n");
614 assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}");
615 assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}");
616}
617
618/// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org.
619/// Rendering its contents as literal text loses the markup the author wrote.
620#[test]
621fn a_special_block_holds_org_not_text() {
622 let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n");
623 assert!(html.contains("<div class=\"note\">"), "named div:\n{html}");
624 assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}");
625}
626
627/// A path that starts with `~` inside `~…~` verbatim: the body may open with the same
628/// character as the marker, and org says so.
629#[test]
630fn verbatim_can_start_with_its_own_marker() {
631 let html = html_of("Edit ~~/.config/doom/config.el~ now.\n");
632 assert!(
633 html.contains("<code>~/.config/doom/config.el</code>"),
634 "the leading ~ belongs to the path:\n{html}"
635 );
636}
637
638/// Org's special first column holds export markers, not data: `/` marks a column group,
639/// `#` a row to recalculate. Publishing them puts a column of punctuation on the page.
640#[test]
641fn a_tables_special_column_and_marker_rows_are_dropped() {
642 let html = html_of(
643 "| N | N^2 |\n\
644 | / | < |\n\
645 | 1 | 1 |\n",
646 );
647 assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}");
648 assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}");
649
650 // Every row marked, so the column itself goes too.
651 let all_marked = html_of(
652 "| # | exp(x) | 1 |\n\
653 | # | exp(x) | 2 |\n",
654 );
655 assert!(
656 !all_marked.contains(">#<"),
657 "a wholly-special column is dropped:\n{all_marked}"
658 );
659 assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}");
660}
661
662/// An affiliated keyword belongs to the element *immediately* below it. Someone who
663/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next
664/// image instead would put the wrong words under the wrong picture.
665#[test]
666fn a_blank_line_ends_a_captions_association() {
667 let html = html_of(
668 "[[file:one.png]]\n\
669 #+CAPTION: stranded\n\
670 \n\
671 [[file:two.png]]\n",
672 );
673 assert!(
674 !html.contains("stranded"),
675 "an orphaned caption attaches to nothing:\n{html}"
676 );
677
678 let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n");
679 assert!(
680 attached.contains("<figcaption>"),
681 "a caption directly above its image still works:\n{attached}"
682 );
683}
tests/snapshots/constructs__blocks_html.snap +9
@@ -25,3 +25,12 @@ expression: "render_fixture(\"blocks.org\")"
2525<p>A quote containing a source block:</p>
2626<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>
2727</blockquote>
28<h2 id="verse">Verse</h2>
29<p class="verse">
30Line breaks are the point<br>
31 and indentation survives.
32</p>
33<h2 id="a-named-special-block">A named special block</h2>
34<div class="note">
35<p>Contents are <strong>org</strong>, not literal text.</p>
36</div>
tests/snapshots/constructs__images_html.snap +2 −2
@@ -5,9 +5,9 @@ expression: "render_fixture(\"images.org\")"
55<h2 id="bare-image">Bare image</h2>
66<p><img src="diagram.png" alt=""></p>
77<h2 id="captioned-figure">Captioned figure</h2>
8<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure>
8<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>
99<h2 id="caption-with-markup">Caption with markup</h2>
10<figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure>
10<figure><img src="chart.png" alt="A stylised chart"><figcaption><span class="figure-number">Figure 2: </span>A <em>stylised</em> chart</figcaption></figure>
1111<h2 id="quoted-attribute-values">Quoted attribute values</h2>
1212<figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure>
1313<h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2>
tests/snapshots/constructs__lists_element_tree.snap +18
@@ -37,6 +37,7 @@ expression: "parse_fixture(\"lists.org\").root"
3737 "items": [
3838 {
3939 "bullet": "Dash",
40 "counter": null,
4041 "checkbox": null,
4142 "term": null,
4243 "content": [
@@ -53,6 +54,7 @@ expression: "parse_fixture(\"lists.org\").root"
5354 "items": [
5455 {
5556 "bullet": "Dash",
57 "counter": null,
5658 "checkbox": null,
5759 "term": null,
5860 "content": [
@@ -69,6 +71,7 @@ expression: "parse_fixture(\"lists.org\").root"
6971 "items": [
7072 {
7173 "bullet": "Dash",
74 "counter": null,
7275 "checkbox": null,
7376 "term": null,
7477 "content": [
@@ -88,6 +91,7 @@ expression: "parse_fixture(\"lists.org\").root"
8891 },
8992 {
9093 "bullet": "Dash",
94 "counter": null,
9195 "checkbox": null,
9296 "term": null,
9397 "content": [
@@ -107,6 +111,7 @@ expression: "parse_fixture(\"lists.org\").root"
107111 },
108112 {
109113 "bullet": "Dash",
114 "counter": null,
110115 "checkbox": null,
111116 "term": null,
112117 "content": [
@@ -151,6 +156,7 @@ expression: "parse_fixture(\"lists.org\").root"
151156 "bullet": {
152157 "Ordered": 1
153158 },
159 "counter": null,
154160 "checkbox": null,
155161 "term": null,
156162 "content": [
@@ -167,6 +173,7 @@ expression: "parse_fixture(\"lists.org\").root"
167173 "bullet": {
168174 "Ordered": 2
169175 },
176 "counter": null,
170177 "checkbox": null,
171178 "term": null,
172179 "content": [
@@ -185,6 +192,7 @@ expression: "parse_fixture(\"lists.org\").root"
185192 "bullet": {
186193 "Ordered": 1
187194 },
195 "counter": null,
188196 "checkbox": null,
189197 "term": null,
190198 "content": [
@@ -201,6 +209,7 @@ expression: "parse_fixture(\"lists.org\").root"
201209 "bullet": {
202210 "Ordered": 2
203211 },
212 "counter": null,
204213 "checkbox": null,
205214 "term": null,
206215 "content": [
@@ -222,6 +231,7 @@ expression: "parse_fixture(\"lists.org\").root"
222231 "bullet": {
223232 "Ordered": 3
224233 },
234 "counter": null,
225235 "checkbox": null,
226236 "term": null,
227237 "content": [
@@ -264,6 +274,7 @@ expression: "parse_fixture(\"lists.org\").root"
264274 "items": [
265275 {
266276 "bullet": "Dash",
277 "counter": null,
267278 "checkbox": "Off",
268279 "term": null,
269280 "content": [
@@ -278,6 +289,7 @@ expression: "parse_fixture(\"lists.org\").root"
278289 },
279290 {
280291 "bullet": "Dash",
292 "counter": null,
281293 "checkbox": "On",
282294 "term": null,
283295 "content": [
@@ -292,6 +304,7 @@ expression: "parse_fixture(\"lists.org\").root"
292304 },
293305 {
294306 "bullet": "Dash",
307 "counter": null,
295308 "checkbox": "Trans",
296309 "term": null,
297310 "content": [
@@ -334,6 +347,7 @@ expression: "parse_fixture(\"lists.org\").root"
334347 "items": [
335348 {
336349 "bullet": "Dash",
350 "counter": null,
337351 "checkbox": null,
338352 "term": [
339353 {
@@ -352,6 +366,7 @@ expression: "parse_fixture(\"lists.org\").root"
352366 },
353367 {
354368 "bullet": "Dash",
369 "counter": null,
355370 "checkbox": null,
356371 "term": [
357372 {
@@ -370,6 +385,7 @@ expression: "parse_fixture(\"lists.org\").root"
370385 },
371386 {
372387 "bullet": "Dash",
388 "counter": null,
373389 "checkbox": null,
374390 "term": [
375391 {
@@ -423,6 +439,7 @@ expression: "parse_fixture(\"lists.org\").root"
423439 "items": [
424440 {
425441 "bullet": "Dash",
442 "counter": null,
426443 "checkbox": null,
427444 "term": null,
428445 "content": [
@@ -444,6 +461,7 @@ expression: "parse_fixture(\"lists.org\").root"
444461 },
445462 {
446463 "bullet": "Dash",
464 "counter": null,
447465 "checkbox": null,
448466 "term": null,
449467 "content": [
tests/snapshots/constructs__lists_html.snap +3 −3
@@ -26,9 +26,9 @@ expression: "render_fixture(\"lists.org\")"
2626</ol>
2727<h2 id="checkboxes">Checkboxes</h2>
2828<ul>
29<li><input type="checkbox" disabled> not done</li>
30<li><input type="checkbox" disabled checked> done</li>
31<li><input type="checkbox" disabled> partially done</li>
29<li class="off"><code>[&nbsp;]</code> not done</li>
30<li class="on"><code>[X]</code> done</li>
31<li class="trans"><code>[-]</code> partially done</li>
3232</ul>
3333<h2 id="description">Description</h2>
3434<dl>
tests/snapshots/constructs__out_of_scope_html.snap −3
@@ -21,8 +21,5 @@ expression: "render_fixture(\"outofscope.org\")"
2121<h2 id="macros-and-radio-targets">Macros and radio targets</h2>
2222<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
2323<h2 id="drawers">Drawers</h2>
24<h2 id="verse">Verse</h2>
25<pre>An unmodelled block type
26keeps its content verbatim.</pre>
2724<h2 id="entities">Entities</h2>
2825<p>The full entity set is out of scope, so \alpha stays literal.</p>
tests/snapshots/oracle__oracle_blocks.snap +22 −1
@@ -2,7 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 51/59 skeleton lines (86.4%)
5agreement: 67/77 skeleton lines (87.0%)
66(- org-ssg, + emacs)
77
88 <h2>
@@ -66,3 +66,24 @@ agreement: 51/59 skeleton lines (86.4%)
6666- </code>
6767 </pre>
6868 </blockquote>
69 <h2>
70 "Verse"
71 </h2>
72 <p>
73 "Line breaks are the point"
74 <br>
75 "and indentation survives."
76+ <br>
77 </p>
78 <h2>
79 "A named special block"
80 </h2>
81 <p>
82 "Contents are"
83- <strong>
84+ <b>
85 "org"
86- </strong>
87+ </b>
88 ", not literal text."
89 </p>
tests/snapshots/oracle__oracle_core.snap +7 −9
@@ -2,7 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 45/54 skeleton lines (83.3%)
5agreement: 51/58 skeleton lines (87.9%)
66(- org-ssg, + emacs)
77
88 <p>
@@ -34,17 +34,15 @@ agreement: 45/54 skeleton lines (83.3%)
3434- </ol>
3535- <ul>
3636 <li>
37- <input>
38+ <code>
39+ "[ ]"
40+ </code>
37 <code>
38 "[ ]"
39 </code>
4140 "todo item"
4241 </li>
4342 <li>
44- <input>
45+ <code>
46+ "[X]"
47+ </code>
43 <code>
44 "[X]"
45 </code>
4846 "done item"
4947 </li>
5048- </ul>
tests/snapshots/oracle__oracle_images.snap +6 −8
@@ -2,7 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 28/42 skeleton lines (66.7%)
5agreement: 30/42 skeleton lines (71.4%)
66(- org-ssg, + emacs)
77
88 <h2>
@@ -18,12 +18,11 @@ agreement: 28/42 skeleton lines (66.7%)
1818+ <p>
1919 <img src="pipeline.svg">
2020- <figcaption>
21- "The pipeline, end to end"
22- </figcaption>
23- </figure>
2421+ </p>
2522+ <p>
26+ "Figure 1: The pipeline, end to end"
23 "Figure 1: The pipeline, end to end"
24- </figcaption>
25- </figure>
2726+ </p>
2827 <h2>
2928 "Caption with markup"
@@ -32,11 +31,10 @@ agreement: 28/42 skeleton lines (66.7%)
3231+ <p>
3332 <img src="chart.png">
3433- <figcaption>
35- "A"
36- <em>
3734+ </p>
3835+ <p>
39+ "Figure 2: A"
36 "Figure 2: A"
37- <em>
4038+ <i>
4139 "stylised"
4240- </em>
tests/snapshots/oracle__oracle_lists.snap +10 −13
@@ -2,7 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 100/111 skeleton lines (90.1%)
5agreement: 109/111 skeleton lines (98.2%)
66(- org-ssg, + emacs)
77
88 <h2>
@@ -56,24 +56,21 @@ agreement: 100/111 skeleton lines (90.1%)
5656 </h2>
5757 <ul>
5858 <li>
59- <input>
60+ <code>
61+ "[ ]"
62+ </code>
59 <code>
60 "[ ]"
61 </code>
6362 "not done"
6463 </li>
6564 <li>
66- <input>
67+ <code>
68+ "[X]"
69+ </code>
65 <code>
66 "[X]"
67 </code>
7068 "done"
7169 </li>
7270 <li>
73- <input>
74+ <code>
75+ "[-]"
76+ </code>
71 <code>
72 "[-]"
73 </code>
7774 "partially done"
7875 </li>
7976 </ul>
tests/snapshots/pipeline__core_element_tree.snap +4
@@ -63,6 +63,7 @@ expression: "parse_fixture(\"core.org\").root"
6363 "bullet": {
6464 "Ordered": 1
6565 },
66 "counter": null,
6667 "checkbox": null,
6768 "term": null,
6869 "content": [
@@ -79,6 +80,7 @@ expression: "parse_fixture(\"core.org\").root"
7980 "bullet": {
8081 "Ordered": 2
8182 },
83 "counter": null,
8284 "checkbox": null,
8385 "term": null,
8486 "content": [
@@ -107,6 +109,7 @@ expression: "parse_fixture(\"core.org\").root"
107109 "items": [
108110 {
109111 "bullet": "Dash",
112 "counter": null,
110113 "checkbox": "Off",
111114 "term": null,
112115 "content": [
@@ -121,6 +124,7 @@ expression: "parse_fixture(\"core.org\").root"
121124 },
122125 {
123126 "bullet": "Dash",
127 "counter": null,
124128 "checkbox": "On",
125129 "term": null,
126130 "content": [
tests/snapshots/pipeline__core_html.snap +2 −2
@@ -9,8 +9,8 @@ expression: "render_fixture(\"core.org\")"
99<li>second item with <em>emphasis</em></li>
1010</ol>
1111<ul>
12<li><input type="checkbox" disabled> todo item</li>
13<li><input type="checkbox" disabled checked> done item</li>
12<li class="off"><code>[&nbsp;]</code> todo item</li>
13<li class="on"><code>[X]</code> done item</li>
1414</ul>
1515<h2 id="links-and-code">Links and code</h2>
1616<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
tests/snapshots/pipeline__minimal_element_tree.snap +2
@@ -117,6 +117,7 @@ expression: "parse_fixture(\"minimal.org\").root"
117117 "items": [
118118 {
119119 "bullet": "Dash",
120 "counter": null,
120121 "checkbox": null,
121122 "term": null,
122123 "content": [
@@ -131,6 +132,7 @@ expression: "parse_fixture(\"minimal.org\").root"
131132 },
132133 {
133134 "bullet": "Dash",
135 "counter": null,
134136 "checkbox": null,
135137 "term": null,
136138 "content": [