krz/orgo

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

Commit bb6ef333ca

bb6ef333ca9e4b42280a2b860e722e76ff9c222b

parent: 94205548ab

Unsigned ci/pages: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-18 20:22 UTC

Track org 9.8 in the Emacs oracle (!11)

`cargo test --locked --test oracle` failed four snapshots on Emacs 31.1 /
org 9.8.7, identically on `main`. Neither cause is in orgo.

**org 9.8 converged on `<pre><code>`.** `ox-html.el` now wraps source
blocks the way orgo already did, so the `pre-code` divergence stopped
firing: `core` 87.9% → 91.4%, `blocks` 87.0% → 93.6%, `elements` 78.0% →
80.5%. The rule stays, because org 9.7 and earlier write a bare `<pre>`.

**`ox-html` 9.8.7 emits a malformed entity.** The heading tag prefix is
written `&nbsp;&nbsp;&nbsp` — the last reference unterminated
(`ox-html.el:2915`). `decode_entities` required the semicolon and left it
literal, so a heading with tags reduced to `A Subheading &nbsptag1 tag2`
against orgo's `A Subheading tag1 tag2`. HTML5 decodes the legacy named
set without the semicolon and a browser renders three non-breaking
spaces; the skeleton now does the same. `minimal` is back to its
committed 38/42 (90.5%).

**The `semantic-time` rule was hiding both.** It accepted any hunk where
`ours` was tags-or-text and `theirs` was timestamp-or-text — and any text
line satisfies both, so every prose difference matched. The `&nbsp`
mismatch therefore arrived labelled as a deliberate rendering choice and
`every_divergence_from_emacs_is_deliberate` kept passing. Guarded with
`any` on both sides.

That guard exposed a second divergence the rule had also been absorbing:
orgo keeps heading priority cookies, org drops them
(`org-export-with-priority` defaults to nil). It is the policy the module
docs already claim — "heading decoration is a policy difference" — but no
rule recorded it. Added as `heading-priority-cookie`.

**Reports now name the Emacs and org version they ran against.** The
oracle is whatever Emacs is on the machine, so a snapshot without that is
not interpretable, and a version bump silently rewrites the agreement
percentage — which is how this sat on `main`. The cost is that the
snapshots churn on an Emacs upgrade; the diff then says `oracle: emacs
31.1, org 9.8.7` changed, which is the thing you need to know. Pinning
Emacs instead would make it a build dependency, which the suite
deliberately avoids (it skips when Emacs is absent, as CI does).

`cargo test --locked` green, 15/15 oracle tests, `unexplained: 0` on all
twelve fixtures. `cargo clippy --all-targets --locked -- -D warnings`
clean. New unit test `unterminated_named_references_decode_like_a_browser`
pins the entity behaviour without needing any particular Emacs.

Layout: unified · split

src/skeleton.rs +47 −27
@@ -31,6 +31,19 @@ const IGNORED: &[&str] = &["div", "span"];
3131/// cosmetic (`org-ul`); `href` and `src` are the content.
3232const KEPT_ATTRS: &[&str] = &["href", "src"];
3333
34/// The named references this reduction understands. HTML5 decodes a legacy set of them
35/// without the closing semicolon, and org relies on that: `ox-html` 9.8 writes the
36/// heading tag prefix as `&nbsp;&nbsp;&nbsp`. No name here is a prefix of another, so the
37/// longest match is the only match.
38const NAMED: &[(&str, char)] = &[
39 ("amp", '&'),
40 ("lt", '<'),
41 ("gt", '>'),
42 ("quot", '"'),
43 ("apos", '\''),
44 ("nbsp", '\u{a0}'),
45];
46
3447/// HTML void elements, which never emit a close event.
3548const VOID: &[&str] = &[
3649 "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
@@ -151,39 +164,46 @@ pub fn decode_entities(s: &str) -> String {
151164 while let Some(amp) = rest.find('&') {
152165 out.push_str(&rest[..amp]);
153166 let tail = &rest[amp..];
154 let Some(semi) = tail.find(';').filter(|s| *s <= 12) else {
155 out.push('&');
156 rest = &tail[1..];
157 continue;
158 };
159 let entity = &tail[1..semi];
160 let decoded = match entity {
161 "amp" => Some('&'),
162 "lt" => Some('<'),
163 "gt" => Some('>'),
164 "quot" => Some('"'),
165 "apos" => Some('\''),
166 "nbsp" => Some(' '),
167 _ => entity
168 .strip_prefix('#')
169 .and_then(|n| match n.strip_prefix(['x', 'X']) {
170 Some(hex) => u32::from_str_radix(hex, 16).ok(),
171 None => n.parse::<u32>().ok(),
172 })
173 .and_then(char::from_u32),
174 };
175 match decoded {
176 // A non-breaking space is a space for comparison purposes.
177 Some('\u{a0}') => out.push(' '),
178 Some(c) => out.push(c),
167 match entity_at(tail) {
168 Some((c, len)) => {
169 // A non-breaking space is a space for comparison purposes.
170 out.push(if c == '\u{a0}' { ' ' } else { c });
171 rest = &tail[len..];
172 }
179173 None => {
180174 out.push('&');
181175 rest = &tail[1..];
182 continue;
183176 }
184177 }
185 rest = &tail[semi + 1..];
186178 }
187179 out.push_str(rest);
188180 out
189181}
182
183/// The reference at the start of `tail`, which begins with `&`: the character it denotes
184/// and the bytes it occupies. The semicolon is optional for [`NAMED`], so `&nbsptag1`
185/// reads as a non-breaking space followed by `tag1`, the same way a browser reads it.
186fn entity_at(tail: &str) -> Option<(char, usize)> {
187 if let Some(semi) = tail.find(';').filter(|s| *s <= 12) {
188 if let Some(c) = named(&tail[1..semi]).or_else(|| numeric(&tail[1..semi])) {
189 return Some((c, semi + 1));
190 }
191 }
192 NAMED
193 .iter()
194 .find(|(name, _)| tail[1..].starts_with(name))
195 .map(|(name, c)| (*c, 1 + name.len()))
196}
197
198fn named(name: &str) -> Option<char> {
199 NAMED.iter().find(|(n, _)| *n == name).map(|(_, c)| *c)
200}
201
202fn numeric(name: &str) -> Option<char> {
203 name.strip_prefix('#')
204 .and_then(|n| match n.strip_prefix(['x', 'X']) {
205 Some(hex) => u32::from_str_radix(hex, 16).ok(),
206 None => n.parse::<u32>().ok(),
207 })
208 .and_then(char::from_u32)
209}
tests/oracle.rs +80 −4
@@ -45,6 +45,27 @@ fn emacs_available() -> bool {
4545 .unwrap_or(false)
4646}
4747
48/// Emacs and org, as the `+` side of every report below. The ground truth moves: org
49/// 9.8 began wrapping source blocks in `<pre><code>`, which orgo already did, so three
50/// of these snapshots gained several points without orgo changing. Recorded so that
51/// shows in the diff as what it is rather than reading as a regression.
52fn oracle_version() -> &'static str {
53 static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
54 VERSION.get_or_init(|| {
55 let output = Command::new("emacs")
56 .args([
57 "-Q",
58 "--batch",
59 "--eval",
60 "(progn (require 'org) \
61 (princ (format \"emacs %s, org %s\" emacs-version (org-version))))",
62 ])
63 .output()
64 .expect("run emacs");
65 String::from_utf8_lossy(&output.stdout).trim().to_string()
66 })
67}
68
4869/// Export a fixture with org's own HTML exporter.
4970fn org_export(fixture: &str) -> String {
5071 let root = manifest_dir();
@@ -105,6 +126,22 @@ fn text_of(line: &str) -> Option<&str> {
105126 line.strip_prefix('"')?.strip_suffix('"')
106127}
107128
129/// The one text run a hunk consists of, if that is all it is.
130fn one_text(lines: &[String]) -> Option<&str> {
131 match lines {
132 [line] => text_of(line),
133 _ => None,
134 }
135}
136
137/// `TODO [#A] Write the parser` → `TODO Write the parser`.
138fn without_priority(text: &str) -> Option<String> {
139 let open = text.find("[#")?;
140 let close = open + text[open..].find(']')?;
141 let joined = format!("{}{}", &text[..open], text[close + 1..].trim_start());
142 Some(joined.trim().to_string())
143}
144
108145/// Does this text run contain an org timestamp, `<2024-01-15 Mon>` or `[2024-01-15]`?
109146fn has_timestamp(line: &str) -> bool {
110147 let t = match text_of(line) {
@@ -120,14 +157,31 @@ const DELIBERATE: &[Deliberate] = &[
120157 Deliberate {
121158 name: "semantic-time",
122159 matches: |ours, theirs| {
123 !ours.is_empty()
160 // Both sides are allowed to carry text, so without the `any` guards this
161 // rule matches *any* hunk of differing prose and files it under a rendering
162 // choice nobody made. That is how org 9.8's malformed `&nbsp` in the heading
163 // tag prefix arrived here labelled `semantic-time`.
164 ours.iter().any(|l| is_tag(l, &["time"]))
124165 && ours
125166 .iter()
126167 .all(|l| is_tag(l, &["time"]) || text_of(l).is_some())
168 && theirs.iter().any(|l| has_timestamp(l))
127169 && theirs.iter().all(|l| has_timestamp(l) || text_of(l).is_some())
128170 },
129171 in_notes_only: false,
130172 },
173 // The priority cookie stays in the heading: the author wrote it, and a reader
174 // skimming a task list wants to see it. Org drops it, because
175 // `org-export-with-priority` defaults to nil. Heading decoration is a policy
176 // difference, not a structural one.
177 Deliberate {
178 name: "heading-priority-cookie",
179 matches: |ours, theirs| match (one_text(ours), one_text(theirs)) {
180 (Some(ours), Some(theirs)) => without_priority(ours).as_deref() == Some(theirs),
181 _ => false,
182 },
183 in_notes_only: false,
184 },
131185 // `<figure>`/`<figcaption>` instead of two paragraphs in a div.
132186 Deliberate {
133187 name: "figure-element",
@@ -150,8 +204,10 @@ const DELIBERATE: &[Deliberate] = &[
150204 },
151205 in_notes_only: false,
152206 },
153 // `<pre><code>` instead of a bare `<pre>`: the nested element is what every syntax
154 // highlighter and every reader's stylesheet expects.
207 // `<pre><code>`: the nested element is what every syntax highlighter and every
208 // reader's stylesheet expects. Org 9.8 emits it too, so this no longer fires — kept
209 // because org 9.7 and earlier write a bare `<pre>`, and the oracle is whatever Emacs
210 // is on the machine.
155211 Deliberate {
156212 name: "pre-code",
157213 matches: |ours, theirs| {
@@ -407,7 +463,11 @@ fn compare(fixture: &str) -> Option<String> {
407463 }
408464 let ours = skeleton(&our_export(fixture));
409465 let theirs = skeleton(&org_export(fixture));
410 Some(divergence(&ours, &theirs))
466 Some(format!(
467 "oracle: {}\n{}",
468 oracle_version(),
469 divergence(&ours, &theirs)
470 ))
411471}
412472
413473macro_rules! oracle_test {
@@ -480,6 +540,22 @@ fn every_divergence_from_emacs_is_deliberate() {
480540// Invariants that must hold against the oracle, not merely be snapshotted
481541// ---------------------------------------------------------------------------
482542
543/// org 9.8 writes the heading tag prefix as `&nbsp;&nbsp;&nbsp` — the last reference
544/// unterminated. HTML5 decodes the legacy named set without the semicolon, so a browser
545/// reads that as three non-breaking spaces; the skeleton has to as well, or a heading
546/// carrying tags reads as `A Subheading &nbsptag1 tag2` and the whole report shifts.
547/// Asserted here rather than left to the snapshots because it must hold whichever Emacs
548/// is installed, including none.
549#[test]
550fn unterminated_named_references_decode_like_a_browser() {
551 assert_eq!(
552 decode_entities("A Subheading&nbsp;&nbsp;&nbsptag1&nbsp;tag2"),
553 "A Subheading tag1 tag2"
554 );
555 assert_eq!(decode_entities("a &amp b"), "a & b");
556 assert_eq!(decode_entities("Q&A"), "Q&A");
557}
558
483559/// How many headings a document has and at what depth is the shape of the document.
484560/// Getting it wrong reorganizes someone's writing, so it is asserted rather than
485561/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy
tests/snapshots/oracle__oracle_blocks.snap +9 −8
@@ -2,8 +2,9 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 67/77 skeleton lines (87.0%)
6deliberate: 11 line(s) — pre-code ×6, semantic-emphasis ×4, verse-trailing-break ×1
5oracle: emacs 31.1, org 9.8.7
6agreement: 73/78 skeleton lines (93.6%)
7deliberate: 5 line(s) — semantic-emphasis ×4, verse-trailing-break ×1
78unexplained: 0 line(s)
89(- orgo, + emacs, ~ a difference we mean to have)
910
@@ -44,14 +45,14 @@ unexplained: 0 line(s)
4445 "Source"
4546 </h2>
4647 <pre>
47~ pre-code (1 line(s))
48 <code>
4849 "def greet(name): return f\"hello {name}\""
49~ pre-code (1 line(s))
50 </code>
5051 </pre>
5152 <pre>
52~ pre-code (1 line(s))
53 <code>
5354 "plain block, no language"
54~ pre-code (1 line(s))
55 </code>
5556 </pre>
5657 <h2>
5758 "Nested"
@@ -61,9 +62,9 @@ unexplained: 0 line(s)
6162 "A quote containing a source block:"
6263 </p>
6364 <pre>
64~ pre-code (1 line(s))
65 <code>
6566 "echo hi"
66~ pre-code (1 line(s))
67 </code>
6768 </pre>
6869 </blockquote>
6970 <h2>
tests/snapshots/oracle__oracle_core.snap +5 −4
@@ -2,8 +2,9 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 51/58 skeleton lines (87.9%)
6deliberate: 7 line(s) — list-per-bullet-type ×2, pre-code ×2, semantic-emphasis ×2
5oracle: emacs 31.1, org 9.8.7
6agreement: 53/58 skeleton lines (91.4%)
7deliberate: 5 line(s) — list-per-bullet-type ×2, semantic-emphasis ×2
78unexplained: 0 line(s)
89(- orgo, + emacs, ~ a difference we mean to have)
910
@@ -60,7 +61,7 @@ unexplained: 0 line(s)
6061 "."
6162 </p>
6263 <pre>
63~ pre-code (1 line(s))
64 <code>
6465 "fn main() { println!(\"hello\"); }"
65~ pre-code (1 line(s))
66 </code>
6667 </pre>
tests/snapshots/oracle__oracle_elements.snap +5 −4
@@ -2,8 +2,9 @@
22source: tests/oracle.rs
33expression: report
44---
5agreement: 64/82 skeleton lines (78.0%)
6deliberate: 26 line(s) — footnote-anchor-naming ×1, footnote-section-shape ×2, id-link-resolution ×1, no-colgroup ×1, pre-code ×2
5oracle: emacs 31.1, org 9.8.7
6agreement: 66/82 skeleton lines (80.5%)
7deliberate: 24 line(s) — footnote-anchor-naming ×1, footnote-section-shape ×2, id-link-resolution ×1, no-colgroup ×1
78unexplained: 0 line(s)
89(- orgo, + emacs, ~ a difference we mean to have)
910
@@ -11,9 +12,9 @@ unexplained: 0 line(s)
1112 "Code and tables"
1213 </h2>
1314 <pre>
14~ pre-code (1 line(s))
15 <code>
1516 "fn main() { println!(\"hello\"); }"
16~ pre-code (1 line(s))
17 </code>
1718 </pre>
1819 <table>
1920~ no-colgroup (4 line(s))
tests/snapshots/oracle__oracle_footnote.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 30/53 skeleton lines (56.6%)
67deliberate: 29 line(s) — footnote-anchor-naming ×3, footnote-section-shape ×6, semantic-emphasis ×2
78unexplained: 0 line(s)
tests/snapshots/oracle__oracle_headings.snap +4 −3
@@ -2,13 +2,14 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 28/30 skeleton lines (93.3%)
6deliberate: 2 line(s) — semantic-time ×2
7deliberate: 2 line(s) — heading-priority-cookie ×2
78unexplained: 0 line(s)
89(- orgo, + emacs, ~ a difference we mean to have)
910
1011 <h2>
11~ semantic-time (1 line(s))
12~ heading-priority-cookie (1 line(s))
1213 </h2>
1314 <p>
1415 "A heading carrying a keyword, a priority, tags and a property drawer."
@@ -20,7 +21,7 @@ unexplained: 0 line(s)
2021 "Sub-headings nest by star count."
2122 </p>
2223 <h3>
23~ semantic-time (1 line(s))
24~ heading-priority-cookie (1 line(s))
2425 </h3>
2526 <p>
2627 "A priority cookie can stand alone."
tests/snapshots/oracle__oracle_images.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 30/42 skeleton lines (71.4%)
67deliberate: 14 line(s) — figure-element ×8, semantic-emphasis ×2
78unexplained: 0 line(s)
tests/snapshots/oracle__oracle_lists.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 109/111 skeleton lines (98.2%)
67deliberate: 2 line(s) — semantic-emphasis ×2
78unexplained: 0 line(s)
tests/snapshots/oracle__oracle_minimal.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 38/42 skeleton lines (90.5%)
67deliberate: 4 line(s) — semantic-emphasis ×4
78unexplained: 0 line(s)
tests/snapshots/oracle__oracle_table.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 30/34 skeleton lines (88.2%)
67deliberate: 4 line(s) — no-colgroup ×1
78unexplained: 0 line(s)
tests/snapshots/oracle__oracle_timestamps.snap +1
@@ -2,6 +2,7 @@
22source: tests/oracle.rs
33expression: report
44---
5oracle: emacs 31.1, org 9.8.7
56agreement: 25/62 skeleton lines (40.3%)
67deliberate: 37 line(s) — semantic-time ×5
78unexplained: 0 line(s)