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
327 327
328## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit) 328## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit)
329 329
330**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags; 330**IN — v1 must handle:** headings with nesting, at levels relative to the document's
331property drawers; plain lists (unordered/ordered/description, checkboxes, nesting); 331shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists
332tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting; 332(unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule
333example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`, 333rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax
334`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup 334highlighting; example/quote/center/verse blocks and named special blocks; links (external,
335(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges); 335internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+`
336paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`. 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:`.
337 340
338**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / 341**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
339`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and 342`:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past
340macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda 343the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
341semantics; non-HTML export blocks; the full Unicode entity set. 344semantics; non-HTML export blocks; the full Unicode entity set.
342 345
343**Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test 346**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:...]]=,
35Timestamps, active and inactive, with times and ranges, render as =<time>= with a 35Timestamps, active and inactive, with times and ranges, render as =<time>= with a
36machine-readable =datetime=. 36machine-readable =datetime=.
37 37
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
38** Lists 65** Lists
39 66
40Unordered, ordered and description lists, nested by indentation, with checkboxes and 67Unordered, ordered and description lists, nested by indentation, with checkboxes and
@@ -44,18 +71,25 @@ multi-paragraph items:
44- outer item 71- outer item
45 - inner item 72 - inner item
46- [X] a checked item 73- [X] a checked item
74- [-] a partly-done item
47- term :: definition 75- term :: definition
761. [@4] an item numbered from 4
48#+END_SRC 77#+END_SRC
49 78
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
50** Blocks 82** Blocks
51 83
52=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block 84=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER=, =VERSE= and =EXPORT=. A source
53inside a quote block works, because block ends match their own kind. 85block inside a quote block works, because block ends match their own kind.
54 86
55An =html= export block passes through verbatim; every other backend is dropped, because 87An =html= export block passes through verbatim; every other backend is dropped, because
56emitting LaTeX into an HTML page is worse than emitting nothing. 88emitting LaTeX into an HTML page is worse than emitting nothing.
57 89
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.
59 93
60*** Which languages highlight 94*** Which languages highlight
61 95
@@ -89,7 +123,12 @@ in Emacs as much as here. If a code block seems to stop early, that is why.
89 123
90** Tables and footnotes 124** Tables and footnotes
91 125
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 —
93=[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a 132=[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a
94numbered, back-linked notes section. 133numbered, back-linked notes section.
95 134
@@ -120,7 +159,7 @@ broken.
120| =#+DRAFT:= | Keeps the page out of the build. | 159| =#+DRAFT:= | Keeps the page out of the build. |
121| =#+TEMPLATE:= | The layout this page renders through. | 160| =#+TEMPLATE:= | The layout this page renders through. |
122| =#+OPTIONS:= | Per-file export switches. | 161| =#+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:=. |
124 163
125Every other =#+KEYWORD:= is available to templates as 164Every other =#+KEYWORD:= is available to templates as
126={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches 165={{ 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:
51echo hi 51echo hi
52#+END_SRC 52#+END_SRC
53#+END_QUOTE 53#+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
43Drawer contents are captured and dropped. 43Drawer contents are captured and dropped.
44:END: 44:END:
45 45
46* Verse
47
48#+BEGIN_VERSE
49An unmodelled block type
50keeps its content verbatim.
51#+END_VERSE
52
53* Entities 46* Entities
54 47
55The full entity set is out of scope, so \alpha stays literal. 48The full entity set is out of scope, so \alpha stays literal.
src/audit.rs +6 −1
@@ -78,7 +78,12 @@ const KNOWN_KEYWORDS: &[&str] = &[
78 "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO", 78 "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO",
79 "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS", 79 "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS",
80]; 80];
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];
82const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"]; 87const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"];
83/// Keyword names conventional enough to be worth flagging when they lead a heading. 88/// Keyword names conventional enough to be worth flagging when they lead a heading.
84/// A custom sequence is only *real* if some `#+TODO:` declares it, which the census 89/// 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 {
227 /// do not want them, so the default is the taste rather than the inheritance; 227 /// do not want them, so the default is the taste rather than the inheritance;
228 /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back. 228 /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back.
229 pub section_numbers: bool, 229 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 }
230} 268}
231 269
232impl Default for HtmlOutput { 270impl Default for HtmlOutput {
@@ -235,6 +273,8 @@ impl Default for HtmlOutput {
235 heading_offset: 1, 273 heading_offset: 1,
236 toc: true, 274 toc: true,
237 section_numbers: false, 275 section_numbers: false,
276 special_strings: true,
277 sub_superscript: SubSuperscript::Yes,
238 } 278 }
239 } 279 }
240} 280}
@@ -530,6 +570,13 @@ toc = true
530# Number headings (1., 1.1., …). Emacs defaults this on; most sites do not. 570# Number headings (1., 1.1., …). Emacs defaults this on; most sites do not.
531# A document overrides with `#+OPTIONS: num:t`. 571# A document overrides with `#+OPTIONS: num:t`.
532section_numbers = false 572section_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"
533 580
534# Generated listing pages: output files with no source .org behind them. Repeat the 581# Generated listing pages: output files with no source .org behind them. Repeat the
535# [[collections]] block for each one. A feed is the same thing with an XML template. 582# [[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;
29/// Bump whenever the `Document` type, hashing scheme, or resolution rules change. 29/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
30/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major 30/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
31/// version is folded in as the "hash-algo version" so a hash upgrade also busts. 31/// 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;
33 33
34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). 34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
35pub type Hash = ContentHash; 35pub type Hash = ContentHash;
src/model.rs +10
@@ -112,6 +112,14 @@ pub enum Element {
112 }, 112 },
113 QuoteBlock(Vec<Element>), 113 QuoteBlock(Vec<Element>),
114 CenterBlock(Vec<Element>), 114 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>),
115 /// html passes through; others dropped at render (spec §1 OUT). 123 /// html passes through; others dropped at render (spec §1 OUT).
116 ExportBlock { 124 ExportBlock {
117 backend: String, 125 backend: String,
@@ -157,6 +165,8 @@ pub enum ListKind {
157#[derive(Debug, Clone, Serialize, Deserialize)] 165#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ListItem { 166pub struct ListItem {
159 pub bullet: Bullet, 167 pub bullet: Bullet,
168 /// `[@4]` — an explicit number for this item, restarting the list's counting.
169 pub counter: Option<u32>,
160 pub checkbox: Option<Checkbox>, 170 pub checkbox: Option<Checkbox>,
161 /// Description-list term before `::`. 171 /// Description-list term before `::`.
162 pub term: Option<Vec<Object>>, 172 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
398 while i < lines.len() { 398 while i < lines.len() {
399 let line = lines[i]; 399 let line = lines[i];
400 if line.trim().is_empty() { 400 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();
401 i += 1; 406 i += 1;
402 continue; 407 continue;
403 } 408 }
@@ -563,9 +568,17 @@ fn parse_block(
563 backend: after.split_whitespace().next().unwrap_or("").to_string(), 568 backend: after.split_whitespace().next().unwrap_or("").to_string(),
564 raw: inner.join("\n"), 569 raw: inner.join("\n"),
565 }, 570 },
566 // Out-of-scope block types (verse, comment, ascii, custom) degrade to a verbatim 571 // Verse keeps its line breaks; that is the whole point of it.
567 // example block: content preserved, no crash. 572 "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()),
568 _ => Element::ExampleBlock(inner.join("\n")), 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 },
569 }; 582 };
570 (element, next) 583 (element, next)
571} 584}
@@ -830,6 +843,8 @@ fn parse_list(
830 // Body = the text after the bullet, plus every following line indented past the 843 // Body = the text after the bullet, plus every following line indented past the
831 // bullet column (blank lines included, so an item can hold several paragraphs). 844 // bullet column (blank lines included, so an item can hold several paragraphs).
832 let rest = item_body(lines[j].trim_start(), &bullet); 845 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);
833 let (checkbox, rest) = split_checkbox(rest); 848 let (checkbox, rest) = split_checkbox(rest);
834 let (term, rest) = match kind { 849 let (term, rest) = match kind {
835 ListKind::Description => match split_term(rest) { 850 ListKind::Description => match split_term(rest) {
@@ -864,6 +879,7 @@ fn parse_list(
864 879
865 items.push(ListItem { 880 items.push(ListItem {
866 bullet, 881 bullet,
882 counter,
867 checkbox, 883 checkbox,
868 term, 884 term,
869 // The item body starts at the bullet line, so `base + j` is exact even after 885 // 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 {
949 } 965 }
950} 966}
951 967
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
952/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item. 982/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
953fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) { 983fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
954 let bytes = text.as_bytes(); 984 let bytes = text.as_bytes();
@@ -1368,8 +1398,10 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
1368 if i + 1 >= n { 1398 if i + 1 >= n {
1369 return None; 1399 return None;
1370 } 1400 }
1371 let after = chars[i + 1]; 1401 // Org's body-character rule: the character after the opening marker may not be
1372 if after.is_whitespace() || after == m { 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]) {
1373 return None; 1405 return None;
1374 } 1406 }
1375 let mut j = i + 1; 1407 let mut j = i + 1;
@@ -1377,7 +1409,7 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
1377 if chars[j] == m && j > i + 1 { 1409 if chars[j] == m && j > i + 1 {
1378 let before = chars[j - 1]; 1410 let before = chars[j - 1];
1379 let next = chars.get(j + 1).copied(); 1411 let next = chars.get(j + 1).copied();
1380 if !before.is_whitespace() && post_ok(next) { 1412 if body_char_ok(before) && post_ok(next) {
1381 let inner = &chars[i + 1..j]; 1413 let inner = &chars[i + 1..j];
1382 let obj = match m { 1414 let obj = match m {
1383 '=' => Object::Verbatim(inner.iter().collect()), 1415 '=' => Object::Verbatim(inner.iter().collect()),
@@ -1396,6 +1428,16 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
1396 None 1428 None
1397} 1429}
1398 1430
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
1399fn boundary_before(chars: &[char], i: usize) -> bool { 1441fn boundary_before(chars: &[char], i: usize) -> bool {
1400 if i == 0 { 1442 if i == 0 {
1401 return true; 1443 return true;
src/render.rs +348 −15
@@ -23,10 +23,11 @@ use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGener
23use syntect::parsing::SyntaxSet; 23use syntect::parsing::SyntaxSet;
24use syntect::util::LinesWithEndings; 24use syntect::util::LinesWithEndings;
25 25
26use crate::config::SubSuperscript;
26use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; 27use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
27use crate::parser::is_image_target; 28use crate::parser::is_image_target;
28use crate::resolve::ResolvedDoc; 29use crate::resolve::ResolvedDoc;
29use crate::util::{heading_anchor, plain_text, slugify}; 30use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify};
30 31
31/// A rendered HTML fragment (content only — no page chrome; spec §2.4). 32/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
32#[derive(Debug, Clone)] 33#[derive(Debug, Clone)]
@@ -211,6 +212,12 @@ struct Renderer<'a> {
211 order: Vec<String>, 212 order: Vec<String>,
212 /// Counter per heading depth, for section numbers. 213 /// Counter per heading depth, for section numbers.
213 counters: Vec<usize>, 214 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,
214} 221}
215 222
216/// Options affecting how the tree becomes HTML. Presentation choices that belong to the 223/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -224,6 +231,12 @@ pub struct RenderOptions {
224 /// Prefix headings with `1.`, `1.1.`, … See 231 /// Prefix headings with `1.`, `1.1.`, … See
225 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers). 232 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
226 pub section_numbers: bool, 233 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,
227} 240}
228 241
229impl Default for RenderOptions { 242impl Default for RenderOptions {
@@ -232,10 +245,49 @@ impl Default for RenderOptions {
232 RenderOptions { 245 RenderOptions {
233 heading_offset: html.heading_offset, 246 heading_offset: html.heading_offset,
234 section_numbers: html.section_numbers, 247 section_numbers: html.section_numbers,
248 special_strings: html.special_strings,
249 sub_superscript: html.sub_superscript,
235 } 250 }
236 } 251 }
237} 252}
238 253
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
239/// Render a resolved document to an HTML fragment, with default options. 291/// Render a resolved document to an HTML fragment, with default options.
240pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { 292pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
241 render_with(doc, highlighter, &RenderOptions::default()) 293 render_with(doc, highlighter, &RenderOptions::default())
@@ -245,11 +297,13 @@ pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
245pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html { 297pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
246 let mut r = Renderer { 298 let mut r = Renderer {
247 hl: highlighter, 299 hl: highlighter,
248 opts: *opts, 300 opts: document_options(&doc.document.keywords, opts),
249 block_defs: HashMap::new(), 301 block_defs: HashMap::new(),
250 inline_defs: HashMap::new(), 302 inline_defs: HashMap::new(),
251 order: Vec::new(), 303 order: Vec::new(),
252 counters: Vec::new(), 304 counters: Vec::new(),
305 headline_offset: headline_offset(&doc.document.root),
306 figures: 0,
253 }; 307 };
254 r.collect_defs(&doc.document.root); 308 r.collect_defs(&doc.document.root);
255 let mut out = String::new(); 309 let mut out = String::new();
@@ -269,7 +323,8 @@ impl Renderer<'_> {
269 323
270 fn render_section(&mut self, section: &Section, out: &mut String) { 324 fn render_section(&mut self, section: &Section, out: &mut String) {
271 if let Some(h) = &section.heading { 325 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);
273 let anchor = heading_anchor(h); 328 let anchor = heading_anchor(h);
274 // A heading with no title text has no meaningful slug; emit no `id` at all 329 // A heading with no title text has no meaningful slug; emit no `id` at all
275 // rather than a run of duplicate empty ones. 330 // rather than a run of duplicate empty ones.
@@ -279,7 +334,7 @@ impl Renderer<'_> {
279 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); 334 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
280 } 335 }
281 if self.opts.section_numbers { 336 if self.opts.section_numbers {
282 let number = self.next_section_number(h.level); 337 let number = self.next_section_number(relative);
283 out.push_str(&format!( 338 out.push_str(&format!(
284 "<span class=\"section-number-{}\">{number}</span> ", 339 "<span class=\"section-number-{}\">{number}</span> ",
285 level 340 level
@@ -377,12 +432,33 @@ impl Renderer<'_> {
377 out.push_str("<figure>"); 432 out.push_str("<figure>");
378 out.push_str(&image_tag(link, attrs, &plain_text(caption))); 433 out.push_str(&image_tag(link, attrs, &plain_text(caption)));
379 if !caption.is_empty() { 434 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 ));
381 self.render_objects(caption, out); 440 self.render_objects(caption, out);
382 out.push_str("</figcaption>"); 441 out.push_str("</figcaption>");
383 } 442 }
384 out.push_str("</figure>\n"); 443 out.push_str("</figure>\n");
385 } 444 }
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 }
386 Element::HorizontalRule => out.push_str("<hr>\n"), 462 Element::HorizontalRule => out.push_str("<hr>\n"),
387 // Definitions are emitted in the footnotes section, not inline. 463 // Definitions are emitted in the footnotes section, not inline.
388 Element::FootnoteDefinition { .. } => {} 464 Element::FootnoteDefinition { .. } => {}
@@ -414,16 +490,29 @@ impl Renderer<'_> {
414 }; 490 };
415 out.push_str(&format!("<{}>\n", tag)); 491 out.push_str(&format!("<{}>\n", tag));
416 for item in &list.items { 492 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('>');
418 if let Some(cb) = &item.checkbox { 510 if let Some(cb) = &item.checkbox {
419 out.push_str(&format!( 511 out.push_str(match cb {
420 "<input type=\"checkbox\" disabled{}> ", 512 Checkbox::On => "<code>[X]</code> ",
421 if matches!(cb, Checkbox::On) { 513 Checkbox::Off => "<code>[&nbsp;]</code> ",
422 " checked" 514 Checkbox::Trans => "<code>[-]</code> ",
423 } else { 515 });
424 ""
425 }
426 ));
427 } 516 }
428 self.render_item_content(&item.content, out); 517 self.render_item_content(&item.content, out);
429 out.push_str("</li>\n"); 518 out.push_str("</li>\n");
@@ -454,6 +543,8 @@ impl Renderer<'_> {
454 543
455 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`. 544 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
456 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) { 545 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
546 let table = strip_special_column(table);
547 let table = &table;
457 let rule_at = table 548 let rule_at = table
458 .rows 549 .rows
459 .iter() 550 .iter()
@@ -502,6 +593,31 @@ impl Renderer<'_> {
502 out.push_str("</table>\n"); 593 out.push_str("</table>\n");
503 } 594 }
504 595
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
505 fn render_objects(&mut self, objs: &[Object], out: &mut String) { 621 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
506 for obj in objs { 622 for obj in objs {
507 self.render_object(obj, out); 623 self.render_object(obj, out);
@@ -510,7 +626,7 @@ impl Renderer<'_> {
510 626
511 fn render_object(&mut self, obj: &Object, out: &mut String) { 627 fn render_object(&mut self, obj: &Object, out: &mut String) {
512 match obj { 628 match obj {
513 Object::Text(t) => out.push_str(&escape_html(t)), 629 Object::Text(t) => out.push_str(&self.text_html(t)),
514 Object::Bold(inner) => self.wrap(out, "strong", inner), 630 Object::Bold(inner) => self.wrap(out, "strong", inner),
515 Object::Italic(inner) => self.wrap(out, "em", inner), 631 Object::Italic(inner) => self.wrap(out, "em", inner),
516 Object::Underline(inner) => self.wrap(out, "u", inner), 632 Object::Underline(inner) => self.wrap(out, "u", inner),
@@ -735,6 +851,223 @@ fn link_text(target: &LinkTarget) -> String {
735 } 851 }
736} 852}
737 853
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
738fn escape_html(s: &str) -> String { 1071fn escape_html(s: &str) -> String {
739 let mut out = String::with_capacity(s.len()); 1072 let mut out = String::with_capacity(s.len());
740 for c in s.chars() { 1073 for c in s.chars() {
src/site.rs +21 −9
@@ -33,7 +33,8 @@ use crate::template::{
33 Templater, 33 Templater,
34}; 34};
35use crate::util::{ 35use 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,
37 relative_root, slugify, table_of_contents, 38 relative_root, slugify, table_of_contents,
38}; 39};
39 40
@@ -239,7 +240,18 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
239 // Undated pages sort last in the final order regardless of direction: a 240 // Undated pages sort last in the final order regardless of direction: a
240 // draft with no date should not lead an archive. 241 // draft with no date should not lead an archive.
241 SortKey::Date => entries.sort_by(|a, b| { 242 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 };
243 match (key(a), key(b)) { 255 match (key(a), key(b)) {
244 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)), 256 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
245 (Some(_), None) => std::cmp::Ordering::Greater, 257 (Some(_), None) => std::cmp::Ordering::Greater,
@@ -750,12 +762,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
750 Ok((pages, broken)) 762 Ok((pages, broken))
751} 763}
752 764
753/// Render options for one page: the site's settings, with the document's own 765/// The site's render options. A document's own `#+OPTIONS:` switches are applied by the
754/// `#+OPTIONS:` switches applied on top. 766/// renderer, so every caller gets them.
755fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions { 767fn render_options(config: &Config) -> RenderOptions {
756 RenderOptions { 768 RenderOptions {
757 heading_offset: config.html.heading_offset, 769 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,
759 } 773 }
760} 774}
761 775
@@ -787,9 +801,7 @@ fn render_page(
787 config: &Config, 801 config: &Config,
788 p: &PagePrep, 802 p: &PagePrep,
789) -> Result<String> { 803) -> Result<String> {
790 // Options are resolved per page, because `#+OPTIONS:` is a per-document override of 804 let opts = render_options(config);
791 // the site setting.
792 let opts = render_options(config, &p.resolved.document.keywords);
793 let Html(fragment) = render_with(&p.resolved, highlighter, &opts); 805 let Html(fragment) = render_with(&p.resolved, highlighter, &opts);
794 // Relative to the *output* path, since `#+SLUG:` can move a page between depths. 806 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
795 let root = relative_root(&p.output); 807 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
342/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as 342/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
343/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing 343/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
344/// needs one key it can sort on. 344/// 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
345pub fn iso_date(raw: &str) -> Option<String> { 367pub fn iso_date(raw: &str) -> Option<String> {
346 let bytes = raw.as_bytes(); 368 let bytes = raw.as_bytes();
347 for i in 0..bytes.len().saturating_sub(9) { 369 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() {
2164 ); 2164 );
2165 assert!(html.contains("Undated"), "the undated post is still listed"); 2165 assert!(html.contains("Undated"), "the undated post is still listed");
2166} 2166}
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() {
141 "a keyword between two paragraphs must not merge them:\n{html}" 141 "a keyword between two paragraphs must not merge them:\n{html}"
142 ); 142 );
143 assert!( 143 assert!(
144 html.contains("<figcaption>shot</figcaption>"), 144 html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"),
145 "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}" 145 "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}"
146 ); 146 );
147} 147}
@@ -265,13 +265,19 @@ fn non_html_export_blocks_are_dropped() {
265 ); 265 );
266} 266}
267 267
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.
269#[test] 271#[test]
270fn unknown_block_types_keep_their_content() { 272fn verse_and_special_blocks_keep_their_content() {
271 let html = render_fixture("outofscope.org"); 273 let html = render_fixture("blocks.org");
272 assert!( 274 assert!(
273 html.contains("An unmodelled block type"), 275 html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"),
274 "a verse block degrades to a verbatim example block:\n{html}" 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}"
275 ); 281 );
276} 282}
277 283
@@ -502,3 +508,176 @@ fn an_ordinary_leading_comma_is_not_stripped() {
502 assert!(text.contains(", a list continuation"), "{html}"); 508 assert!(text.contains(", a list continuation"), "{html}");
503 assert!(text.contains(",not an escape"), "{html}"); 509 assert!(text.contains(",not an escape"), "{html}");
504} 510}
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\")"
25<p>A quote containing a source block:</p> 25<p>A quote containing a source block:</p>
26<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> 26<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>
27</blockquote> 27</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\")"
5<h2 id="bare-image">Bare image</h2> 5<h2 id="bare-image">Bare image</h2>
6<p><img src="diagram.png" alt=""></p> 6<p><img src="diagram.png" alt=""></p>
7<h2 id="captioned-figure">Captioned figure</h2> 7<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>
9<h2 id="caption-with-markup">Caption with markup</h2> 9<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>
11<h2 id="quoted-attribute-values">Quoted attribute values</h2> 11<h2 id="quoted-attribute-values">Quoted attribute values</h2>
12<figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> 12<figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure>
13<h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2> 13<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"
37 "items": [ 37 "items": [
38 { 38 {
39 "bullet": "Dash", 39 "bullet": "Dash",
40 "counter": null,
40 "checkbox": null, 41 "checkbox": null,
41 "term": null, 42 "term": null,
42 "content": [ 43 "content": [
@@ -53,6 +54,7 @@ expression: "parse_fixture(\"lists.org\").root"
53 "items": [ 54 "items": [
54 { 55 {
55 "bullet": "Dash", 56 "bullet": "Dash",
57 "counter": null,
56 "checkbox": null, 58 "checkbox": null,
57 "term": null, 59 "term": null,
58 "content": [ 60 "content": [
@@ -69,6 +71,7 @@ expression: "parse_fixture(\"lists.org\").root"
69 "items": [ 71 "items": [
70 { 72 {
71 "bullet": "Dash", 73 "bullet": "Dash",
74 "counter": null,
72 "checkbox": null, 75 "checkbox": null,
73 "term": null, 76 "term": null,
74 "content": [ 77 "content": [
@@ -88,6 +91,7 @@ expression: "parse_fixture(\"lists.org\").root"
88 }, 91 },
89 { 92 {
90 "bullet": "Dash", 93 "bullet": "Dash",
94 "counter": null,
91 "checkbox": null, 95 "checkbox": null,
92 "term": null, 96 "term": null,
93 "content": [ 97 "content": [
@@ -107,6 +111,7 @@ expression: "parse_fixture(\"lists.org\").root"
107 }, 111 },
108 { 112 {
109 "bullet": "Dash", 113 "bullet": "Dash",
114 "counter": null,
110 "checkbox": null, 115 "checkbox": null,
111 "term": null, 116 "term": null,
112 "content": [ 117 "content": [
@@ -151,6 +156,7 @@ expression: "parse_fixture(\"lists.org\").root"
151 "bullet": { 156 "bullet": {
152 "Ordered": 1 157 "Ordered": 1
153 }, 158 },
159 "counter": null,
154 "checkbox": null, 160 "checkbox": null,
155 "term": null, 161 "term": null,
156 "content": [ 162 "content": [
@@ -167,6 +173,7 @@ expression: "parse_fixture(\"lists.org\").root"
167 "bullet": { 173 "bullet": {
168 "Ordered": 2 174 "Ordered": 2
169 }, 175 },
176 "counter": null,
170 "checkbox": null, 177 "checkbox": null,
171 "term": null, 178 "term": null,
172 "content": [ 179 "content": [
@@ -185,6 +192,7 @@ expression: "parse_fixture(\"lists.org\").root"
185 "bullet": { 192 "bullet": {
186 "Ordered": 1 193 "Ordered": 1
187 }, 194 },
195 "counter": null,
188 "checkbox": null, 196 "checkbox": null,
189 "term": null, 197 "term": null,
190 "content": [ 198 "content": [
@@ -201,6 +209,7 @@ expression: "parse_fixture(\"lists.org\").root"
201 "bullet": { 209 "bullet": {
202 "Ordered": 2 210 "Ordered": 2
203 }, 211 },
212 "counter": null,
204 "checkbox": null, 213 "checkbox": null,
205 "term": null, 214 "term": null,
206 "content": [ 215 "content": [
@@ -222,6 +231,7 @@ expression: "parse_fixture(\"lists.org\").root"
222 "bullet": { 231 "bullet": {
223 "Ordered": 3 232 "Ordered": 3
224 }, 233 },
234 "counter": null,
225 "checkbox": null, 235 "checkbox": null,
226 "term": null, 236 "term": null,
227 "content": [ 237 "content": [
@@ -264,6 +274,7 @@ expression: "parse_fixture(\"lists.org\").root"
264 "items": [ 274 "items": [
265 { 275 {
266 "bullet": "Dash", 276 "bullet": "Dash",
277 "counter": null,
267 "checkbox": "Off", 278 "checkbox": "Off",
268 "term": null, 279 "term": null,
269 "content": [ 280 "content": [
@@ -278,6 +289,7 @@ expression: "parse_fixture(\"lists.org\").root"
278 }, 289 },
279 { 290 {
280 "bullet": "Dash", 291 "bullet": "Dash",
292 "counter": null,
281 "checkbox": "On", 293 "checkbox": "On",
282 "term": null, 294 "term": null,
283 "content": [ 295 "content": [
@@ -292,6 +304,7 @@ expression: "parse_fixture(\"lists.org\").root"
292 }, 304 },
293 { 305 {
294 "bullet": "Dash", 306 "bullet": "Dash",
307 "counter": null,
295 "checkbox": "Trans", 308 "checkbox": "Trans",
296 "term": null, 309 "term": null,
297 "content": [ 310 "content": [
@@ -334,6 +347,7 @@ expression: "parse_fixture(\"lists.org\").root"
334 "items": [ 347 "items": [
335 { 348 {
336 "bullet": "Dash", 349 "bullet": "Dash",
350 "counter": null,
337 "checkbox": null, 351 "checkbox": null,
338 "term": [ 352 "term": [
339 { 353 {
@@ -352,6 +366,7 @@ expression: "parse_fixture(\"lists.org\").root"
352 }, 366 },
353 { 367 {
354 "bullet": "Dash", 368 "bullet": "Dash",
369 "counter": null,
355 "checkbox": null, 370 "checkbox": null,
356 "term": [ 371 "term": [
357 { 372 {
@@ -370,6 +385,7 @@ expression: "parse_fixture(\"lists.org\").root"
370 }, 385 },
371 { 386 {
372 "bullet": "Dash", 387 "bullet": "Dash",
388 "counter": null,
373 "checkbox": null, 389 "checkbox": null,
374 "term": [ 390 "term": [
375 { 391 {
@@ -423,6 +439,7 @@ expression: "parse_fixture(\"lists.org\").root"
423 "items": [ 439 "items": [
424 { 440 {
425 "bullet": "Dash", 441 "bullet": "Dash",
442 "counter": null,
426 "checkbox": null, 443 "checkbox": null,
427 "term": null, 444 "term": null,
428 "content": [ 445 "content": [
@@ -444,6 +461,7 @@ expression: "parse_fixture(\"lists.org\").root"
444 }, 461 },
445 { 462 {
446 "bullet": "Dash", 463 "bullet": "Dash",
464 "counter": null,
447 "checkbox": null, 465 "checkbox": null,
448 "term": null, 466 "term": null,
449 "content": [ 467 "content": [
tests/snapshots/constructs__lists_html.snap +3 −3
@@ -26,9 +26,9 @@ expression: "render_fixture(\"lists.org\")"
26</ol> 26</ol>
27<h2 id="checkboxes">Checkboxes</h2> 27<h2 id="checkboxes">Checkboxes</h2>
28<ul> 28<ul>
29<li><input type="checkbox" disabled> not done</li> 29<li class="off"><code>[&nbsp;]</code> not done</li>
30<li><input type="checkbox" disabled checked> done</li> 30<li class="on"><code>[X]</code> done</li>
31<li><input type="checkbox" disabled> partially done</li> 31<li class="trans"><code>[-]</code> partially done</li>
32</ul> 32</ul>
33<h2 id="description">Description</h2> 33<h2 id="description">Description</h2>
34<dl> 34<dl>
tests/snapshots/constructs__out_of_scope_html.snap −3
@@ -21,8 +21,5 @@ expression: "render_fixture(\"outofscope.org\")"
21<h2 id="macros-and-radio-targets">Macros and radio targets</h2> 21<h2 id="macros-and-radio-targets">Macros and radio targets</h2>
22<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p> 22<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
23<h2 id="drawers">Drawers</h2> 23<h2 id="drawers">Drawers</h2>
24<h2 id="verse">Verse</h2>
25<pre>An unmodelled block type
26keeps its content verbatim.</pre>
27<h2 id="entities">Entities</h2> 24<h2 id="entities">Entities</h2>
28<p>The full entity set is out of scope, so \alpha stays literal.</p> 25<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 @@
2source: tests/oracle.rs 2source: tests/oracle.rs
3expression: report 3expression: report
4--- 4---
5agreement: 51/59 skeleton lines (86.4%) 5agreement: 67/77 skeleton lines (87.0%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h2> 8 <h2>
@@ -66,3 +66,24 @@ agreement: 51/59 skeleton lines (86.4%)
66- </code> 66- </code>
67 </pre> 67 </pre>
68 </blockquote> 68 </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 @@
2source: tests/oracle.rs 2source: tests/oracle.rs
3expression: report 3expression: report
4--- 4---
5agreement: 45/54 skeleton lines (83.3%) 5agreement: 51/58 skeleton lines (87.9%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <p> 8 <p>
@@ -34,17 +34,15 @@ agreement: 45/54 skeleton lines (83.3%)
34- </ol> 34- </ol>
35- <ul> 35- <ul>
36 <li> 36 <li>
37- <input> 37 <code>
38+ <code> 38 "[ ]"
39+ "[ ]" 39 </code>
40+ </code>
41 "todo item" 40 "todo item"
42 </li> 41 </li>
43 <li> 42 <li>
44- <input> 43 <code>
45+ <code> 44 "[X]"
46+ "[X]" 45 </code>
47+ </code>
48 "done item" 46 "done item"
49 </li> 47 </li>
50- </ul> 48- </ul>
tests/snapshots/oracle__oracle_images.snap +6 −8
@@ -2,7 +2,7 @@
2source: tests/oracle.rs 2source: tests/oracle.rs
3expression: report 3expression: report
4--- 4---
5agreement: 28/42 skeleton lines (66.7%) 5agreement: 30/42 skeleton lines (71.4%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h2> 8 <h2>
@@ -18,12 +18,11 @@ agreement: 28/42 skeleton lines (66.7%)
18+ <p> 18+ <p>
19 <img src="pipeline.svg"> 19 <img src="pipeline.svg">
20- <figcaption> 20- <figcaption>
21- "The pipeline, end to end"
22- </figcaption>
23- </figure>
24+ </p> 21+ </p>
25+ <p> 22+ <p>
26+ "Figure 1: The pipeline, end to end" 23 "Figure 1: The pipeline, end to end"
24- </figcaption>
25- </figure>
27+ </p> 26+ </p>
28 <h2> 27 <h2>
29 "Caption with markup" 28 "Caption with markup"
@@ -32,11 +31,10 @@ agreement: 28/42 skeleton lines (66.7%)
32+ <p> 31+ <p>
33 <img src="chart.png"> 32 <img src="chart.png">
34- <figcaption> 33- <figcaption>
35- "A"
36- <em>
37+ </p> 34+ </p>
38+ <p> 35+ <p>
39+ "Figure 2: A" 36 "Figure 2: A"
37- <em>
40+ <i> 38+ <i>
41 "stylised" 39 "stylised"
42- </em> 40- </em>
tests/snapshots/oracle__oracle_lists.snap +10 −13
@@ -2,7 +2,7 @@
2source: tests/oracle.rs 2source: tests/oracle.rs
3expression: report 3expression: report
4--- 4---
5agreement: 100/111 skeleton lines (90.1%) 5agreement: 109/111 skeleton lines (98.2%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h2> 8 <h2>
@@ -56,24 +56,21 @@ agreement: 100/111 skeleton lines (90.1%)
56 </h2> 56 </h2>
57 <ul> 57 <ul>
58 <li> 58 <li>
59- <input> 59 <code>
60+ <code> 60 "[ ]"
61+ "[ ]" 61 </code>
62+ </code>
63 "not done" 62 "not done"
64 </li> 63 </li>
65 <li> 64 <li>
66- <input> 65 <code>
67+ <code> 66 "[X]"
68+ "[X]" 67 </code>
69+ </code>
70 "done" 68 "done"
71 </li> 69 </li>
72 <li> 70 <li>
73- <input> 71 <code>
74+ <code> 72 "[-]"
75+ "[-]" 73 </code>
76+ </code>
77 "partially done" 74 "partially done"
78 </li> 75 </li>
79 </ul> 76 </ul>
tests/snapshots/pipeline__core_element_tree.snap +4
@@ -63,6 +63,7 @@ expression: "parse_fixture(\"core.org\").root"
63 "bullet": { 63 "bullet": {
64 "Ordered": 1 64 "Ordered": 1
65 }, 65 },
66 "counter": null,
66 "checkbox": null, 67 "checkbox": null,
67 "term": null, 68 "term": null,
68 "content": [ 69 "content": [
@@ -79,6 +80,7 @@ expression: "parse_fixture(\"core.org\").root"
79 "bullet": { 80 "bullet": {
80 "Ordered": 2 81 "Ordered": 2
81 }, 82 },
83 "counter": null,
82 "checkbox": null, 84 "checkbox": null,
83 "term": null, 85 "term": null,
84 "content": [ 86 "content": [
@@ -107,6 +109,7 @@ expression: "parse_fixture(\"core.org\").root"
107 "items": [ 109 "items": [
108 { 110 {
109 "bullet": "Dash", 111 "bullet": "Dash",
112 "counter": null,
110 "checkbox": "Off", 113 "checkbox": "Off",
111 "term": null, 114 "term": null,
112 "content": [ 115 "content": [
@@ -121,6 +124,7 @@ expression: "parse_fixture(\"core.org\").root"
121 }, 124 },
122 { 125 {
123 "bullet": "Dash", 126 "bullet": "Dash",
127 "counter": null,
124 "checkbox": "On", 128 "checkbox": "On",
125 "term": null, 129 "term": null,
126 "content": [ 130 "content": [
tests/snapshots/pipeline__core_html.snap +2 −2
@@ -9,8 +9,8 @@ expression: "render_fixture(\"core.org\")"
9<li>second item with <em>emphasis</em></li> 9<li>second item with <em>emphasis</em></li>
10</ol> 10</ol>
11<ul> 11<ul>
12<li><input type="checkbox" disabled> todo item</li> 12<li class="off"><code>[&nbsp;]</code> todo item</li>
13<li><input type="checkbox" disabled checked> done item</li> 13<li class="on"><code>[X]</code> done item</li>
14</ul> 14</ul>
15<h2 id="links-and-code">Links and code</h2> 15<h2 id="links-and-code">Links and code</h2>
16<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> 16<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"
117 "items": [ 117 "items": [
118 { 118 {
119 "bullet": "Dash", 119 "bullet": "Dash",
120 "counter": null,
120 "checkbox": null, 121 "checkbox": null,
121 "term": null, 122 "term": null,
122 "content": [ 123 "content": [
@@ -131,6 +132,7 @@ expression: "parse_fixture(\"minimal.org\").root"
131 }, 132 },
132 { 133 {
133 "bullet": "Dash", 134 "bullet": "Dash",
135 "counter": null,
134 "checkbox": null, 136 "checkbox": null,
135 "term": null, 137 "term": null,
136 "content": [ 138 "content": [