krz/orgo

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

Commit f2f12bfb9c

f2f12bfb9c8a3238ae9cb6c0de2190967379962d

parent: 183c977692

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 20:17 UTC

0.16: entity table, table captions, a loud INCLUDE, and an oracle that means something

The oracle was reporting 40.3% agreement on the timestamps fixture while being
entirely correct: org writes `<2024-01-15 Mon>` as text, org-ssg writes a `<time
datetime>` element. A number that cannot fall when a real defect appears is not
measuring anything, so every divergence is now classified.

`DELIBERATE` names each difference we mean to have — semantic `<time>`,
`<figure>`/`<figcaption>`, `<em>` over `<i>`, `<pre><code>`, footnote section
shape and anchor naming, no `<colgroup>`, and the id-link resolution the
single-file harness cannot do. The report gained two lines beside agreement:
`deliberate` and `unexplained`. All ten fixtures are at **unexplained: 0**, and
a new test asserts it — a future divergence is either a defect to fix or a
decision to write down.

Classifying them turned up one genuine divergence worth a decision: a change of
bullet starts a new list here, while org continues the list — two blank lines
end one, not a switch from `1.` to `-` — so a dash item under a numbered list
exports *numbered* in Emacs. Recorded as `list-per-bullet-type` rather than
changed, because it alters real documents and is arguably org's own footgun.

Also:

- `\alpha` and the other 411 entries in org's entity table, generated from
  Emacs' `org-entities` rather than transcribed. An unknown name stays literal,
  `\alphabet` is a word, and `#+OPTIONS: e:nil` turns it off.
- `#+CAPTION:` on a table becomes a numbered `<caption>`, as it does for images.
- `#+INCLUDE:` reports itself. It was inert *and silent*, which publishes a page
  with content missing and nobody told; now it is a diagnostic, and `--strict`
  makes it a failure.

The corpus is unchanged at 11 differing pages, all template choices or
documented non-goals.

Layout: unified · split

Cargo.lock +1 −1
@@ -675,7 +675,7 @@ dependencies = [
675675
676676[[package]]
677677name = "org-ssg"
678version = "0.15.0"
678version = "0.16.0"
679679dependencies = [
680680 "anyhow",
681681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.15.0"
3version = "0.16.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +11 −4
@@ -334,14 +334,16 @@ rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax
334334highlighting; example/quote/center/verse blocks and named special blocks; links (external,
335335internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+`
336336keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's
337export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps
337export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`, `\alpha`); timestamps
338338(active/inactive, ranges); paragraphs and horizontal rules; images with
339339`#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`.
340340
341341**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
342342`:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past
343the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
344semantics; non-HTML export blocks; the full Unicode entity set.
343the text conversions); `#+INCLUDE:` (never expanded — reported as a diagnostic, so a page
344is never quietly short of content); citations; radio targets and macros; drawers other
345than PROPERTIES/LOGBOOK; column view / clocking / agenda semantics; non-HTML export
346blocks.
345347
346348**Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test
347349asserting it degrades predictably (ignored, no crash). The IN/OUT line is enforced by
@@ -377,6 +379,11 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
377379| **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** |
378380| **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** |
379381| **17** | **Bundled TOML and Org syntaxes, a user syntax directory, and org's comma escape** | **done** |
382| **18** | **Per-page layouts: `[[pages]]` rules and `#+TEMPLATE:`** | **done** |
383| **19** | **Export parity: relative heading levels, special strings, sub/superscript, caption numbering, checkbox and counter markup, table marker columns, special blocks** | **done** |
384| **20** | **Correctness debt: org's entity table, table captions, a reported `#+INCLUDE:`, and an oracle that separates deliberate divergence from defects** | **done** |
385| 21 | Extra asset roots; per-template hashing so one layout edit does not re-render the site | next |
386| 22 | Release engineering: CI, MSRV, published binaries, changelog, a written compatibility promise | 1.0 |
380387
381388### v0.2 in / out
382389
@@ -465,7 +472,7 @@ construct the IN list claims is now parsed, rendered, and pinned by a golden fil
465472**The OUT line is now enforced, not just asserted.** `tests/constructs.rs` pins each
466473excluded construct to a specific degradation: babel is never executed *and* a checked-in
467474`#+RESULTS:` block is dropped rather than published as if it were verified output;
468`#+TBLFM:` is inert; `#+INCLUDE:` is never expanded; LaTeX, macros and radio targets survive
475`#+TBLFM:` is inert; `#+INCLUDE:` is never expanded and says so; LaTeX, macros and radio targets survive
469476as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block
470477types keep their content verbatim.
471478
docs/guide/05-org-support.org +9 −2
@@ -46,6 +46,12 @@ Org rewrites some prose on export, and so does org-ssg:
4646| =...= | … |
4747| =x^2= | x superscript 2 |
4848| =H_{2}O= | H subscript 2 O |
49| =\alpha=, =\rarr=, =20\deg= | α, →, 20° |
50
51The entity table is org's own, generated from Emacs' =org-entities= rather than
52transcribed, so all 412 names behave as they do in Emacs. A name org does not know —
53=\notanentity= — stays as the literal text you typed, and =\alphabet= is a word rather
54than α followed by "bet". =#+OPTIONS: e:nil= turns the whole table off.
4955
5056Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in
5157a shell transcript stays a flag, and =$x^2$= stays mathematics.
@@ -123,7 +129,8 @@ in Emacs as much as here. If a code block seems to stop early, that is why.
123129
124130** Tables and footnotes
125131
126Pipe tables, with the rule row establishing a header band. Org's *special column* is
132Pipe tables, with the rule row establishing a header band and an affiliated =#+CAPTION:=
133becoming a numbered =<caption>=. Org's *special column* is
127134honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is
128135dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than
129136content, so they never reach the page.
@@ -174,7 +181,7 @@ a build.
174181|-----------+--------------|
175182| Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. |
176183| =#+TBLFM:= | Inert. The table renders with the values as written. |
177| =#+INCLUDE:= | Never expanded. Captured as an inert keyword. |
184| =#+INCLUDE:= | Never expanded, and *reported*: the build prints =file:line: `#+INCLUDE: …` is not expanded=, so a page is never quietly missing content. =--strict= makes it a failure. |
178185| LaTeX, MathJax | Survives as the literal text you typed. |
179186| Macros ={{{name}}}=, radio targets | Literal text. |
180187| Drawers other than =PROPERTIES= | Captured and dropped, including =LOGBOOK=. |
fixtures/outofscope.org +2 −2
@@ -43,6 +43,6 @@ CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00] => 1:00
4343Drawer contents are captured and dropped.
4444:END:
4545
46* Entities
46* Unknown entities
4747
48The full entity set is out of scope, so \alpha stays literal.
48An entity org does not know, \notanentity, stays literal.
src/config.rs +9
@@ -241,6 +241,11 @@ pub struct HtmlOutput {
241241 /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself
242242 /// with `#+OPTIONS: ^:nil` or `^:{}`.
243243 pub sub_superscript: SubSuperscript,
244 /// Whether `\alpha` and the rest of org's entity table become their characters.
245 ///
246 /// On, as in Emacs. A name org does not know is left as the literal text that was
247 /// typed. A document turns the whole thing off with `#+OPTIONS: e:nil`.
248 pub entities: bool,
244249}
245250
246251/// How `_` and `^` are treated in prose. Mirrors org's `^:` export option.
@@ -275,6 +280,7 @@ impl Default for HtmlOutput {
275280 section_numbers: false,
276281 special_strings: true,
277282 sub_superscript: SubSuperscript::Yes,
283 entities: true,
278284 }
279285 }
280286}
@@ -577,6 +583,9 @@ special_strings = true
577583# becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no".
578584# A document overrides with `#+OPTIONS: ^:nil` or `^:{}`.
579585sub_superscript = "yes"
586# Convert `\alpha` and the rest of org's entity table. An unknown name stays literal.
587# A document overrides with `#+OPTIONS: e:nil`.
588entities = true
580589
581590# Generated listing pages: output files with no source .org behind them. Repeat the
582591# [[collections]] block for each one. A feed is the same thing with an XML template.
src/entities.rs added +438
@@ -0,0 +1,438 @@
1//! Org's entity table: `\\alpha` and friends, name to HTML.
2//!
3//! Generated from Emacs' own `org-entities` — the table is long, arbitrary and exactly
4//! what a reader's file will contain, so copying it by hand would be a slow way to
5//! introduce typos. Regenerate with:
6//!
7//! ```sh
8//! emacs --batch --eval '(progn (require (quote org)) \
9//! (dolist (e org-entities) (when (and (listp e) (nth 3 e)) \
10//! (princ (format "%s\t%s\n" (nth 0 e) (nth 3 e))))))'
11//! ```
12//!
13//! The HTML column is used rather than the UTF-8 one, so output matches what Emacs
14//! writes for the same source.
15
16/// `(name, html)` for every entity org knows, sorted by name.
17pub const ENTITIES: &[(&str, &str)] = &[
18 ("AA", "&Aring;"),
19 ("AElig", "&AElig;"),
20 ("Aacute", "&Aacute;"),
21 ("Acirc", "&Acirc;"),
22 ("Agrave", "&Agrave;"),
23 ("Alpha", "&Alpha;"),
24 ("Amacr", "&Amacr;"),
25 ("Aring", "&Aring;"),
26 ("Atilde", "&Atilde;"),
27 ("Auml", "&Auml;"),
28 ("Beta", "&Beta;"),
29 ("Ccedil", "&Ccedil;"),
30 ("Chi", "&Chi;"),
31 ("Dagger", "&Dagger;"),
32 ("Delta", "&Delta;"),
33 ("Diamond", "&diamond;"),
34 ("Downarrow", "&dArr;"),
35 ("ETH", "&ETH;"),
36 ("EUR", "&euro;"),
37 ("Eacute", "&Eacute;"),
38 ("Ecirc", "&Ecirc;"),
39 ("Egrave", "&Egrave;"),
40 ("Epsilon", "&Epsilon;"),
41 ("Eta", "&Eta;"),
42 ("Euml", "&Euml;"),
43 ("Gamma", "&Gamma;"),
44 ("Gg", "&Gg;"),
45 ("Iacute", "&Iacute;"),
46 ("Icirc", "&Icirc;"),
47 ("Idot", "&idot;"),
48 ("Igrave", "&Igrave;"),
49 ("Iota", "&Iota;"),
50 ("Iuml", "&Iuml;"),
51 ("Kappa", "&Kappa;"),
52 ("Lambda", "&Lambda;"),
53 ("Leftarrow", "&lArr;"),
54 ("Leftrightarrow", "&hArr;"),
55 ("Ll", "&Ll;"),
56 ("Mu", "&Mu;"),
57 ("Ntilde", "&Ntilde;"),
58 ("Nu", "&Nu;"),
59 ("OElig", "&OElig;"),
60 ("Oacute", "&Oacute;"),
61 ("Ocirc", "&Ocirc;"),
62 ("Ograve", "&Ograve;"),
63 ("Omega", "&Omega;"),
64 ("Omicron", "&Omicron;"),
65 ("Oslash", "&Oslash;"),
66 ("Otilde", "&Otilde;"),
67 ("Ouml", "&Ouml;"),
68 ("P", "&para;"),
69 ("Phi", "&Phi;"),
70 ("Pi", "&Pi;"),
71 ("Pr", "Pr"),
72 ("Prime", "&Prime;"),
73 ("Psi", "&Psi;"),
74 ("Rho", "&Rho;"),
75 ("Rightarrow", "&rArr;"),
76 ("S", "&sect;"),
77 ("Scaron", "&Scaron;"),
78 ("Sigma", "&Sigma;"),
79 ("THORN", "&THORN;"),
80 ("Tau", "&Tau;"),
81 ("Theta", "&Theta;"),
82 ("USD", "$"),
83 ("Uacute", "&Uacute;"),
84 ("Ucirc", "&Ucirc;"),
85 ("Ugrave", "&Ugrave;"),
86 ("Uparrow", "&uArr;"),
87 ("Upsilon", "&Upsilon;"),
88 ("Uuml", "&Uuml;"),
89 ("Xi", "&Xi;"),
90 ("Yacute", "&Yacute;"),
91 ("Yuml", "&Yuml;"),
92 ("Zeta", "&Zeta;"),
93 ("_ ", "&ensp;"),
94 ("_ ", "&ensp;&ensp;"),
95 ("_ ", "&ensp;&ensp;&ensp;"),
96 ("_ ", "&ensp;&ensp;&ensp;&ensp;"),
97 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;"),
98 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
99 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
100 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
101 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
102 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
103 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
104 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
105 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
106 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
107 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
108 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
109 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
110 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
111 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
112 ("_ ", "&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;"),
113 ("aacute", "&aacute;"),
114 ("acirc", "&acirc;"),
115 ("acute", "&acute;"),
116 ("acutex", "&acute;x"),
117 ("aelig", "&aelig;"),
118 ("agrave", "&agrave;"),
119 ("alefsym", "&alefsym;"),
120 ("aleph", "&aleph;"),
121 ("alpha", "&alpha;"),
122 ("amacr", "&amacr;"),
123 ("amp", "&amp;"),
124 ("ang", "&ang;"),
125 ("angle", "&ang;"),
126 ("approx", "&asymp;"),
127 ("arccos", "arccos"),
128 ("arcsin", "arcsin"),
129 ("arctan", "arctan"),
130 ("arg", "arg"),
131 ("aring", "&aring;"),
132 ("asciicirc", "^"),
133 ("ast", "&lowast;"),
134 ("asymp", "&asymp;"),
135 ("atilde", "&atilde;"),
136 ("auml", "&auml;"),
137 ("bdquo", "&bdquo;"),
138 ("because", "&because;"),
139 ("beta", "&beta;"),
140 ("beth", "&beth;"),
141 ("blacksmile", "&#9787;"),
142 ("brvbar", "&brvbar;"),
143 ("bull", "&bull;"),
144 ("bullet", "&bull;"),
145 ("cap", "&cap;"),
146 ("ccedil", "&ccedil;"),
147 ("cdot", "&sdot;"),
148 ("cdots", "&ctdot;"),
149 ("cedil", "&cedil;"),
150 ("cent", "&cent;"),
151 ("check", "&checkmark;"),
152 ("checkmark", "&check;"),
153 ("chi", "&chi;"),
154 ("circ", "&circ;"),
155 ("clubs", "&clubs;"),
156 ("clubsuit", "&clubs;"),
157 ("colon", ":"),
158 ("cong", "&cong;"),
159 ("copy", "&copy;"),
160 ("cos", "cos"),
161 ("cosh", "cosh"),
162 ("cot", "cot"),
163 ("coth", "coth"),
164 ("crarr", "&crarr;"),
165 ("csc", "csc"),
166 ("cup", "&cup;"),
167 ("curren", "&curren;"),
168 ("dArr", "&dArr;"),
169 ("dag", "&dagger;"),
170 ("dagger", "&dagger;"),
171 ("dalet", "&daleth;"),
172 ("darr", "&darr;"),
173 ("ddag", "&Dagger;"),
174 ("deg", "&deg;"),
175 ("delta", "&delta;"),
176 ("det", "det"),
177 ("diamond", "&diamond;"),
178 ("diamondsuit", "&diams;"),
179 ("diams", "&diams;"),
180 ("dim", "dim"),
181 ("div", "&divide;"),
182 ("dollar", "$"),
183 ("dots", "&hellip;"),
184 ("downarrow", "&darr;"),
185 ("eacute", "&eacute;"),
186 ("ecirc", "&ecirc;"),
187 ("egrave", "&egrave;"),
188 ("ell", "&ell;"),
189 ("empty", "&empty;"),
190 ("emptyset", "&empty;"),
191 ("emsp", "&emsp;"),
192 ("ensp", "&ensp;"),
193 ("epsilon", "&epsilon;"),
194 ("equal", "="),
195 ("equiv", "&equiv;"),
196 ("eta", "&eta;"),
197 ("eth", "&eth;"),
198 ("euml", "&euml;"),
199 ("euro", "&euro;"),
200 ("exist", "&exist;"),
201 ("exists", "&exist;"),
202 ("exp", "exp"),
203 ("fnof", "&fnof;"),
204 ("forall", "&forall;"),
205 ("frac12", "&frac12;"),
206 ("frac14", "&frac14;"),
207 ("frac34", "&frac34;"),
208 ("frasl", "&frasl;"),
209 ("frown", "&frown;"),
210 ("frowny", "&#9785;"),
211 ("gamma", "&gamma;"),
212 ("gcd", "gcd"),
213 ("ge", "&ge;"),
214 ("geq", "&ge;"),
215 ("gets", "&larr;"),
216 ("gg", "&Gt;"),
217 ("ggg", "&Gg;"),
218 ("gimel", "&gimel;"),
219 ("gt", "&gt;"),
220 ("hArr", "&hArr;"),
221 ("harr", "&harr;"),
222 ("hbar", "&hbar;"),
223 ("hearts", "&hearts;"),
224 ("heartsuit", "&heartsuit;"),
225 ("hellip", "&hellip;"),
226 ("hom", "hom"),
227 ("hookleftarrow", "&crarr;"),
228 ("iacute", "&iacute;"),
229 ("icirc", "&icirc;"),
230 ("iexcl", "&iexcl;"),
231 ("igrave", "&igrave;"),
232 ("image", "&image;"),
233 ("imath", "&imath;"),
234 ("in", "&isin;"),
235 ("inf", "inf"),
236 ("infin", "&infin;"),
237 ("infty", "&infin;"),
238 ("inodot", "&inodot;"),
239 ("int", "&int;"),
240 ("iota", "&iota;"),
241 ("iquest", "&iquest;"),
242 ("isin", "&isin;"),
243 ("iuml", "&iuml;"),
244 ("jmath", "&jmath;"),
245 ("kappa", "&kappa;"),
246 ("ker", "ker"),
247 ("lArr", "&lArr;"),
248 ("lambda", "&lambda;"),
249 ("land", "&and;"),
250 ("lang", "&lang;"),
251 ("langle", "&lang;"),
252 ("laquo", "&laquo;"),
253 ("larr", "&larr;"),
254 ("lceil", "&lceil;"),
255 ("ldquo", "&ldquo;"),
256 ("le", "&le;"),
257 ("leftarrow", "&larr;"),
258 ("leftrightarrow", "&harr;"),
259 ("leq", "&le;"),
260 ("lesseqgtr", "&lesseqgtr;"),
261 ("lessgtr", "&lessgtr;"),
262 ("lfloor", "&lfloor;"),
263 ("lg", "lg"),
264 ("lim", "lim"),
265 ("liminf", "liminf"),
266 ("limsup", "limsup"),
267 ("ll", "&Lt;"),
268 ("lll", "&Ll;"),
269 ("ln", "ln"),
270 ("log", "log"),
271 ("lor", "&or;"),
272 ("lowast", "&lowast;"),
273 ("loz", "&loz;"),
274 ("lrm", "&lrm;"),
275 ("lsaquo", "&lsaquo;"),
276 ("lsquo", "&lsquo;"),
277 ("lt", "&lt;"),
278 ("macr", "&macr;"),
279 ("max", "max"),
280 ("mdash", "&mdash;"),
281 ("mho", "&mho;"),
282 ("micro", "&micro;"),
283 ("middot", "&middot;"),
284 ("min", "min"),
285 ("minus", "&minus;"),
286 ("mu", "&mu;"),
287 ("nabla", "&nabla;"),
288 ("nbsp", "&nbsp;"),
289 ("ndash", "&ndash;"),
290 ("ne", "&ne;"),
291 ("neg", "&not;"),
292 ("neq", "&ne;"),
293 ("nexist", "&exist;"),
294 ("nexists", "&exist;"),
295 ("ni", "&ni;"),
296 ("not", "&not;"),
297 ("notin", "&notin;"),
298 ("nsub", "&nsub;"),
299 ("nsup", "&nsup;"),
300 ("ntilde", "&ntilde;"),
301 ("nu", "&nu;"),
302 ("oacute", "&oacute;"),
303 ("ocirc", "&ocirc;"),
304 ("odot", "o"),
305 ("oelig", "&oelig;"),
306 ("ograve", "&ograve;"),
307 ("oline", "&oline;"),
308 ("omega", "&omega;"),
309 ("omicron", "&omicron;"),
310 ("oplus", "&oplus;"),
311 ("ordf", "&ordf;"),
312 ("ordm", "&ordm;"),
313 ("oslash", "&oslash;"),
314 ("otilde", "&otilde;"),
315 ("otimes", "&otimes;"),
316 ("ouml", "&ouml;"),
317 ("para", "&para;"),
318 ("parallel", "&parallel;"),
319 ("partial", "&part;"),
320 ("permil", "&permil;"),
321 ("perp", "&perp;"),
322 ("phi", "&phi;"),
323 ("pi", "&pi;"),
324 ("piv", "&piv;"),
325 ("plus", "+"),
326 ("plusmn", "&plusmn;"),
327 ("pm", "&plusmn;"),
328 ("pound", "&pound;"),
329 ("prec", "&pr;"),
330 ("preccurlyeq", "&prcue;"),
331 ("preceq", "&prcue;"),
332 ("prime", "&prime;"),
333 ("prod", "&prod;"),
334 ("prop", "&prop;"),
335 ("propto", "&prop;"),
336 ("psi", "&psi;"),
337 ("quot", "&quot;"),
338 ("rArr", "&rArr;"),
339 ("radic", "&radic;"),
340 ("rang", "&rang;"),
341 ("rangle", "&rang;"),
342 ("raquo", "&raquo;"),
343 ("rarr", "&rarr;"),
344 ("rceil", "&rceil;"),
345 ("rdquo", "&rdquo;"),
346 ("real", "&real;"),
347 ("reg", "&reg;"),
348 ("rfloor", "&rfloor;"),
349 ("rho", "&rho;"),
350 ("rightarrow", "&rarr;"),
351 ("rlm", "&rlm;"),
352 ("rsaquo", "&rsaquo;"),
353 ("rsquo", "&rsquo;"),
354 ("sad", "&#9785;"),
355 ("sbquo", "&sbquo;"),
356 ("scaron", "&scaron;"),
357 ("sdot", "&sdot;"),
358 ("sec", "sec"),
359 ("sect", "&sect;"),
360 ("setminus", "&setminus;"),
361 ("shy", "&shy;"),
362 ("sigma", "&sigma;"),
363 ("sigmaf", "&sigmaf;"),
364 ("sim", "&sim;"),
365 ("simeq", "&cong;"),
366 ("sin", "sin"),
367 ("sinh", "sinh"),
368 ("slash", "/"),
369 ("smile", "&smile;"),
370 ("smiley", "&#9786;"),
371 ("spades", "&spades;"),
372 ("spadesuit", "&spades;"),
373 ("star", "*"),
374 ("sub", "&sub;"),
375 ("sube", "&sube;"),
376 ("subset", "&sub;"),
377 ("succ", "&sc;"),
378 ("succcurlyeq", "&sccue;"),
379 ("succeq", "&sccue;"),
380 ("sum", "&sum;"),
381 ("sup", "&sup;"),
382 ("sup1", "&sup1;"),
383 ("sup2", "&sup2;"),
384 ("sup3", "&sup3;"),
385 ("supe", "&supe;"),
386 ("supset", "&sup;"),
387 ("szlig", "&szlig;"),
388 ("tan", "tan"),
389 ("tanh", "tanh"),
390 ("tau", "&tau;"),
391 ("there4", "&there4;"),
392 ("therefore", "&there4;"),
393 ("theta", "&theta;"),
394 ("thetasym", "&thetasym;"),
395 ("thinsp", "&thinsp;"),
396 ("thorn", "&thorn;"),
397 ("tilde", "~"),
398 ("times", "&times;"),
399 ("to", "&rarr;"),
400 ("trade", "&trade;"),
401 ("triangleq", "&triangleq;"),
402 ("uArr", "&uArr;"),
403 ("uacute", "&uacute;"),
404 ("uarr", "&uarr;"),
405 ("ucirc", "&ucirc;"),
406 ("ugrave", "&ugrave;"),
407 ("uml", "&uml;"),
408 ("under", "_"),
409 ("uparrow", "&uarr;"),
410 ("upsih", "&upsih;"),
411 ("upsilon", "&upsilon;"),
412 ("uuml", "&uuml;"),
413 ("varepsilon", "&epsilon;"),
414 ("varphi", "&varphi;"),
415 ("varpi", "&piv;"),
416 ("varsigma", "&sigmaf;"),
417 ("vartheta", "&thetasym;"),
418 ("vbar", "|"),
419 ("vee", "&or;"),
420 ("vert", "&vert;"),
421 ("wedge", "&and;"),
422 ("weierp", "&weierp;"),
423 ("xi", "&xi;"),
424 ("yacute", "&yacute;"),
425 ("yen", "&yen;"),
426 ("yuml", "&yuml;"),
427 ("zeta", "&zeta;"),
428 ("zwj", "&zwj;"),
429 ("zwnj", "&zwnj;"),
430];
431
432/// The HTML for `\name`, if org knows it.
433pub fn lookup(name: &str) -> Option<&'static str> {
434 ENTITIES
435 .binary_search_by(|(n, _)| (*n).cmp(name))
436 .ok()
437 .map(|i| ENTITIES[i].1)
438}
src/lib.rs +1
@@ -10,6 +10,7 @@
1010
1111pub mod audit;
1212pub mod config;
13pub mod entities;
1314pub mod incremental;
1415pub mod index;
1516pub mod model;
src/model.rs +3
@@ -194,6 +194,9 @@ pub enum Checkbox {
194194pub struct Table {
195195 /// Rule rows preserved to locate the header band.
196196 pub rows: Vec<TableRow>,
197 /// An affiliated `#+CAPTION:` directly above the table.
198 #[serde(default)]
199 pub caption: Vec<Object>,
197200}
198201
199202#[derive(Debug, Clone, Serialize, Deserialize)]
src/parser.rs +61 −3
@@ -407,6 +407,20 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V
407407 continue;
408408 }
409409 if let Some((key, value)) = keyword_kv(line) {
410 if key.eq_ignore_ascii_case("INCLUDE") {
411 // Never expanded (README §OUT). Expanding it means resolving paths,
412 // recursion and `:lines`/`:only-contents`; dropping it silently means a
413 // page missing content nobody was told about. Saying so is the honest
414 // middle, and `--strict` turns it into a failure.
415 diags.push(Diagnostic {
416 line: base + i + 1,
417 message: format!(
418 "`#+INCLUDE: {}` is not expanded; that content will be missing \
419 from the page",
420 value.trim()
421 ),
422 });
423 }
410424 if key.eq_ignore_ascii_case("RESULTS") {
411425 // Babel is never executed (README §OUT), so a checked-in `#+RESULTS:`
412426 // block is output from someone else's Emacs session at some other time.
@@ -672,8 +686,8 @@ fn is_affiliated(key: &str) -> bool {
672686}
673687
674688/// A paragraph holding nothing but an image link becomes a block-level figure when a
675/// `#+CAPTION:`/`#+ATTR_HTML:` precedes it. Affiliated keywords on anything else are
676/// parsed and dropped (README §IN covers captions for images only).
689/// `#+CAPTION:`/`#+ATTR_HTML:` precedes it, and a table takes its caption. Affiliated
690/// keywords on anything else are parsed and dropped.
677691fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Element {
678692 let value = |key: &str| {
679693 affiliated
@@ -686,6 +700,12 @@ fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Ele
686700 if caption.is_empty() && attrs.is_empty() {
687701 return element;
688702 }
703 if let Element::Table(table) = element {
704 return Element::Table(Table {
705 caption: inline(&caption),
706 ..table
707 });
708 }
689709 let Element::Paragraph(objs) = &element else {
690710 return element;
691711 };
@@ -741,7 +761,13 @@ fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
741761 }
742762 i += 1;
743763 }
744 (Table { rows }, i)
764 (
765 Table {
766 rows,
767 caption: Vec::new(),
768 },
769 i,
770 )
745771}
746772
747773/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
@@ -1142,6 +1168,14 @@ fn parse_inline_run(chars: &[char]) -> Vec<Object> {
11421168 continue;
11431169 }
11441170 }
1171 if c == '\\' {
1172 if let Some((obj, next)) = try_entity(chars, i) {
1173 flush(&mut buf, &mut out);
1174 out.push(obj);
1175 i = next;
1176 continue;
1177 }
1178 }
11451179 if is_marker(c) {
11461180 if let Some((obj, next)) = try_emphasis(chars, i) {
11471181 flush(&mut buf, &mut out);
@@ -1428,6 +1462,30 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
14281462 None
14291463}
14301464
1465/// An org entity: `\alpha`, closed by end of text, `{}`, or any non-letter — which is
1466/// what stops `\alphabet` from being a Greek letter followed by "bet".
1467///
1468/// Only names org actually knows become entities; anything else stays the literal text
1469/// the author typed, since a typo should look like a typo rather than vanish.
1470fn try_entity(chars: &[char], i: usize) -> Option<(Object, usize)> {
1471 let mut j = i + 1;
1472 while chars.get(j).is_some_and(|c| c.is_ascii_alphabetic()) {
1473 j += 1;
1474 }
1475 if j == i + 1 {
1476 return None;
1477 }
1478 let name: String = chars[i + 1..j].iter().collect();
1479 crate::entities::lookup(&name)?;
1480 // `{}` is the explicit terminator and is consumed; anything else is left in place.
1481 let next = if chars.get(j) == Some(&'{') && chars.get(j + 1) == Some(&'}') {
1482 j + 2
1483 } else {
1484 j
1485 };
1486 Some((Object::Entity(name), next))
1487}
1488
14311489/// May this character sit directly inside an emphasis marker?
14321490///
14331491/// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a
src/render.rs +27 −2
@@ -218,6 +218,8 @@ struct Renderer<'a> {
218218 headline_offset: u8,
219219 /// Captioned figures seen so far, for `Figure N:`.
220220 figures: usize,
221 /// Captioned tables seen so far, for `Table N:`.
222 tables: usize,
221223}
222224
223225/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -237,6 +239,9 @@ pub struct RenderOptions {
237239 /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See
238240 /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript).
239241 pub sub_superscript: SubSuperscript,
242 /// Whether `\alpha` becomes `&alpha;`. See
243 /// [`HtmlOutput::entities`](crate::config::HtmlOutput::entities).
244 pub entities: bool,
240245}
241246
242247impl Default for RenderOptions {
@@ -247,6 +252,7 @@ impl Default for RenderOptions {
247252 section_numbers: html.section_numbers,
248253 special_strings: html.special_strings,
249254 sub_superscript: html.sub_superscript,
255 entities: html.entities,
250256 }
251257 }
252258}
@@ -267,6 +273,7 @@ fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) ->
267273 Some(value) => SubSuperscript::from_option(value),
268274 None => opts.sub_superscript,
269275 },
276 entities: option_enabled(keywords, "e", opts.entities),
270277 }
271278}
272279
@@ -304,6 +311,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend
304311 counters: Vec::new(),
305312 headline_offset: headline_offset(&doc.document.root),
306313 figures: 0,
314 tables: 0,
307315 };
308316 r.collect_defs(&doc.document.root);
309317 let mut out = String::new();
@@ -550,6 +558,16 @@ impl Renderer<'_> {
550558 .iter()
551559 .position(|r| matches!(r, TableRow::Rule));
552560 out.push_str("<table>\n");
561 if !table.caption.is_empty() {
562 self.tables += 1;
563 out.push_str(&format!(
564 "<caption><span class=\"table-number\">Table {}: </span>",
565 self.tables
566 ));
567 let caption = table.caption.clone();
568 self.render_objects(&caption, out);
569 out.push_str("</caption>\n");
570 }
553571 let mut wrote_body = false;
554572 let mut in_body = rule_at.is_none();
555573 for (idx, row) in table.rows.iter().enumerate() {
@@ -627,6 +645,11 @@ impl Renderer<'_> {
627645 fn render_object(&mut self, obj: &Object, out: &mut String) {
628646 match obj {
629647 Object::Text(t) => out.push_str(&self.text_html(t)),
648 // The table's HTML column is an entity reference, so it is emitted raw.
649 Object::Entity(name) => match self.opts.entities {
650 true => out.push_str(crate::entities::lookup(name).unwrap_or(name)),
651 false => out.push_str(&escape_html(&format!("\\{name}"))),
652 },
630653 Object::Bold(inner) => self.wrap(out, "strong", inner),
631654 Object::Italic(inner) => self.wrap(out, "em", inner),
632655 Object::Underline(inner) => self.wrap(out, "u", inner),
@@ -668,7 +691,6 @@ impl Renderer<'_> {
668691 }
669692 Object::LineBreak => out.push_str("<br>\n"),
670693 Object::Timestamp(ts) => out.push_str(&timestamp_html(ts)),
671 Object::Entity(e) => out.push_str(&escape_html(e)),
672694 }
673695 }
674696
@@ -889,7 +911,10 @@ fn strip_special_column(table: &crate::model::Table) -> crate::model::Table {
889911 _ => row.clone(),
890912 })
891913 .collect();
892 crate::model::Table { rows }
914 crate::model::Table {
915 rows,
916 caption: table.caption.clone(),
917 }
893918}
894919
895920/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`.
src/site.rs +1
@@ -770,6 +770,7 @@ fn render_options(config: &Config) -> RenderOptions {
770770 section_numbers: config.html.section_numbers,
771771 special_strings: config.html.special_strings,
772772 sub_superscript: config.html.sub_superscript,
773 entities: config.html.entities,
773774 }
774775}
775776
tests/constructs.rs +73 −2
@@ -227,7 +227,7 @@ fn table_formulas_are_inert() {
227227#[test]
228228fn latex_macros_and_radio_targets_stay_literal() {
229229 let html = render_fixture("outofscope.org");
230 for literal in ["$x^2 + y^2$", "E = mc^2", "{{{author}}}", "\\alpha"] {
230 for literal in ["$x^2 + y^2$", "E = mc^2", "{{{author}}}", "\\notanentity"] {
231231 assert!(
232232 html.contains(literal),
233233 "`{literal}` should survive as literal text:\n{html}"
@@ -387,7 +387,6 @@ fn well_formed_fixtures_produce_no_diagnostics() {
387387 "blocks.org",
388388 "timestamps.org",
389389 "images.org",
390 "outofscope.org",
391390 ] {
392391 let document = parse_fixture(name);
393392 assert!(
@@ -396,6 +395,17 @@ fn well_formed_fixtures_produce_no_diagnostics() {
396395 document.diagnostics
397396 );
398397 }
398
399 // The out-of-scope fixture is the exception, and only for the one construct that is
400 // *meant* to announce itself: an unexpanded `#+INCLUDE:` means content is missing
401 // from the page, which is worth a line in the build output.
402 let out = parse_fixture("outofscope.org");
403 let messages: Vec<&str> = out.diagnostics.iter().map(|d| d.message.as_str()).collect();
404 assert_eq!(messages.len(), 1, "one diagnostic, not a pile: {messages:?}");
405 assert!(
406 messages[0].contains("#+INCLUDE:") && messages[0].contains("not expanded"),
407 "and it is the include: {messages:?}"
408 );
399409}
400410
401411// ---------------------------------------------------------------------------
@@ -681,3 +691,64 @@ fn a_blank_line_ends_a_captions_association() {
681691 "a caption directly above its image still works:\n{attached}"
682692 );
683693}
694
695/// Org's entity table, taken from Emacs' own `org-entities` so the mapping is not a
696/// hand-typed approximation of 400 entries.
697#[test]
698fn entities_become_their_characters() {
699 let html = html_of("Greek \\alpha and \\beta{}s, an arrow \\rarr, and 20\\deg today.\n");
700 assert!(html.contains("&alpha;"), "alpha:\n{html}");
701 // `{}` is the explicit terminator and must not survive into the text.
702 assert!(html.contains("&beta;s"), "beta with {{}}:\n{html}");
703 assert!(html.contains("&rarr;"), "arrow:\n{html}");
704 assert!(html.contains("20&deg;"), "degree:\n{html}");
705}
706
707/// A name org does not know is a typo, and a typo should look like one rather than
708/// disappear. `\alphabet` is not a Greek letter followed by "bet", either.
709#[test]
710fn unknown_entities_and_longer_words_stay_literal() {
711 let html = html_of("Neither \\notanentity nor \\alphabet is an entity.\n");
712 assert!(html.contains("\\notanentity"), "unknown stays:\n{html}");
713 assert!(html.contains("\\alphabet"), "no prefix match:\n{html}");
714}
715
716/// `#+OPTIONS: e:nil` is org's own switch for turning entities off.
717#[test]
718fn a_document_can_turn_entities_off() {
719 let html = html_of("#+OPTIONS: e:nil\n\nGreek \\alpha stays.\n");
720 assert!(html.contains("\\alpha"), "left alone:\n{html}");
721 assert!(!html.contains("&alpha;"), "not converted:\n{html}");
722}
723
724/// A caption above a table becomes a numbered `<caption>`, as it does for figures.
725#[test]
726fn tables_take_a_numbered_caption() {
727 let html = html_of(
728 "#+CAPTION: Quarterly figures\n| Q | Rev |\n\n\
729 #+CAPTION: Second table\n| A | B |\n",
730 );
731 assert!(
732 html.contains("<caption><span class=\"table-number\">Table 1: </span>Quarterly figures"),
733 "first table:\n{html}"
734 );
735 assert!(html.contains("Table 2: </span>Second table"), "second:\n{html}");
736}
737
738/// `#+INCLUDE:` is not expanded, and says so. Silently dropping it publishes a page with
739/// content missing and nobody told.
740#[test]
741fn an_unexpanded_include_reports_itself() {
742 let doc = parse(
743 Utf8PathBuf::from("t.org").as_path(),
744 "#+TITLE: T\n\n#+INCLUDE: \"other.org\" :lines \"5-10\"\n\nBody.\n",
745 )
746 .expect("parse");
747 assert_eq!(doc.diagnostics.len(), 1, "{:?}", doc.diagnostics);
748 assert_eq!(doc.diagnostics[0].line, 3, "the line it is on");
749 assert!(
750 doc.diagnostics[0].message.contains("other.org"),
751 "names the file: {:?}",
752 doc.diagnostics[0]
753 );
754}
tests/oracle.rs +330 −14
@@ -252,11 +252,217 @@ fn decode_entities(s: &str) -> String {
252252// Divergence report
253253// ---------------------------------------------------------------------------
254254
255/// A unified diff of the two skeletons, via a longest-common-subsequence walk. `-` is
256/// org-ssg, `+` is Emacs.
257fn divergence(ours: &[String], theirs: &[String]) -> String {
255/// One divergence org-ssg makes on purpose, so the report can separate "we chose this"
256/// from "we got this wrong".
257///
258/// Without this split the agreement percentage is noise: the timestamps fixture sat at
259/// 40% while being entirely correct, because org writes `<2024-01-15 Mon>` as text and
260/// org-ssg writes a `<time datetime>` element. A number that cannot fall when a real
261/// defect appears is not measuring anything.
262struct Deliberate {
263 name: &'static str,
264 /// Does this hunk consist only of the difference described? `ours` are the `-` lines,
265 /// `theirs` the `+` lines.
266 matches: fn(&[String], &[String]) -> bool,
267 /// Only applies once the notes section has started. Two footnote definitions differ
268 /// by `</li><li>` against `</sup><p>`, which is the same shape difference — but a
269 /// list where a paragraph was expected is a real defect anywhere else, so the rule
270 /// is not allowed to explain it anywhere else.
271 in_notes_only: bool,
272}
273
274fn is_tag(line: &str, names: &[&str]) -> bool {
275 names
276 .iter()
277 .any(|n| line == format!("<{n}>") || line == format!("</{n}>"))
278}
279
280fn text_of(line: &str) -> Option<&str> {
281 line.strip_prefix('"')?.strip_suffix('"')
282}
283
284/// Does this text run contain an org timestamp, `<2024-01-15 Mon>` or `[2024-01-15]`?
285fn has_timestamp(line: &str) -> bool {
286 let t = match text_of(line) {
287 Some(t) => t,
288 None => return false,
289 };
290 t.contains('<') && t.contains('-') || t.contains('[') && t.contains('-')
291}
292
293const DELIBERATE: &[Deliberate] = &[
294 // `<time datetime="…">` instead of org's plain text: the date is data, and a reader's
295 // browser can do something with it.
296 Deliberate {
297 name: "semantic-time",
298 matches: |ours, theirs| {
299 !ours.is_empty()
300 && ours
301 .iter()
302 .all(|l| is_tag(l, &["time"]) || text_of(l).is_some())
303 && theirs.iter().all(|l| has_timestamp(l) || text_of(l).is_some())
304 },
305 in_notes_only: false,
306 },
307 // `<figure>`/`<figcaption>` instead of two paragraphs in a div.
308 Deliberate {
309 name: "figure-element",
310 matches: |ours, theirs| {
311 ours.iter().any(|l| is_tag(l, &["figure", "figcaption"]))
312 && ours
313 .iter()
314 .all(|l| is_tag(l, &["figure", "figcaption"]) || text_of(l).is_some())
315 && theirs.iter().all(|l| is_tag(l, &["p"]) || text_of(l).is_some())
316 },
317 in_notes_only: false,
318 },
319 // `<em>`/`<strong>` instead of org's presentational `<i>`/`<b>`.
320 Deliberate {
321 name: "semantic-emphasis",
322 matches: |ours, theirs| {
323 !ours.is_empty()
324 && ours.iter().all(|l| is_tag(l, &["em", "strong", "del"]))
325 && theirs.iter().all(|l| is_tag(l, &["i", "b", "s", "del"]))
326 },
327 in_notes_only: false,
328 },
329 // `<pre><code>` instead of a bare `<pre>`: the nested element is what every syntax
330 // highlighter and every reader's stylesheet expects.
331 Deliberate {
332 name: "pre-code",
333 matches: |ours, theirs| {
334 !ours.is_empty() && ours.iter().all(|l| is_tag(l, &["code"])) && theirs.is_empty()
335 },
336 in_notes_only: false,
337 },
338 // Org emits a `<colgroup>` of empty `<col>`s to carry column alignment; org-ssg
339 // leaves alignment to the stylesheet.
340 Deliberate {
341 name: "no-colgroup",
342 matches: |ours, theirs| {
343 ours.is_empty()
344 && !theirs.is_empty()
345 && theirs.iter().all(|l| is_tag(l, &["colgroup", "col"]) || l == "<col>")
346 },
347 in_notes_only: false,
348 },
349 // Footnote ids: `fn-1` rather than org's `fn.1`, because a dot in an id is awkward in
350 // a CSS selector.
351 Deliberate {
352 name: "footnote-anchor-naming",
353 matches: |ours, theirs| {
354 ours.len() == 1
355 && theirs.len() == 1
356 && ours[0].replace("fn-", "fn.") == theirs[0]
357 && ours[0].contains("#fn")
358 },
359 in_notes_only: false,
360 },
361 // The notes section itself: an `<ol>` under a rule, rather than org's headed div of
362 // paragraphs, and a `↩` back-link rather than a repeated superscript number. Same
363 // notes, same order, same links, in the shape a screen reader announces as a list.
364 Deliberate {
365 name: "footnote-section-shape",
366 matches: |ours, theirs| {
367 let ours_is_notes = ours.iter().all(|l| {
368 is_tag(l, &["section", "ol", "li", "a", "sup", "p"])
369 || l == "<hr>"
370 || l.starts_with("<a href=\"#fn")
371 || text_of(l) == Some("↩")
372 || text_of(l).is_some()
373 });
374 let theirs_is_notes = theirs.iter().all(|l| {
375 is_tag(l, &["h2", "sup", "p", "a", "div"])
376 || l.starts_with("<a href=\"#fn")
377 || text_of(l).is_some()
378 });
379 let touches_notes = ours.iter().chain(theirs).any(|l| {
380 l.contains("#fn") || text_of(l) == Some("Footnotes:") || is_tag(l, &["section"])
381 });
382 touches_notes && ours_is_notes && theirs_is_notes
383 },
384 in_notes_only: false,
385 },
386 // Two note definitions abutting: `</li><li>` where org writes `</sup><p>`.
387 Deliberate {
388 name: "footnote-section-shape",
389 matches: |ours, theirs| {
390 !ours.is_empty()
391 && ours.iter().all(|l| is_tag(l, &["li", "ol", "section"]))
392 && theirs.iter().all(|l| is_tag(l, &["p", "sup", "div"]))
393 },
394 in_notes_only: true,
395 },
396 // Verse ends without a trailing `<br>`: org emits one for the final newline, which is
397 // a blank line at the end of the stanza and nothing else.
398 Deliberate {
399 name: "verse-trailing-break",
400 matches: |ours, theirs| ours.is_empty() && theirs == ["<br>"],
401 in_notes_only: false,
402 },
403 // `[[id:…]]` resolves here and does not in the oracle: a single-file `emacs --batch`
404 // export has no id database, so org drops the link and keeps its text. This is a
405 // property of the harness, not of either exporter.
406 Deliberate {
407 name: "id-link-resolution",
408 matches: |ours, theirs| {
409 ours.iter().any(|l| l.starts_with("<a href=\"#"))
410 && ours.iter().all(|l| is_tag(l, &["a"]) || l.starts_with("<a href=") || text_of(l).is_some())
411 && theirs.iter().all(|l| text_of(l).is_some())
412 },
413 in_notes_only: false,
414 },
415 // A change of bullet starts a new list. Org instead continues the list — two blank
416 // lines end one, not a switch from `1.` to `-` — so a dash item written under a
417 // numbered list is exported *numbered*. We split, which is what the author drew.
418 // Deliberate, and the one entry here that is arguably worth revisiting.
419 Deliberate {
420 name: "list-per-bullet-type",
421 matches: |ours, theirs| {
422 !ours.is_empty()
423 && ours.iter().all(|l| is_tag(l, &["ol", "ul"]))
424 && theirs.iter().all(|l| is_tag(l, &["ol", "ul"]))
425 },
426 in_notes_only: false,
427 },
428];
429
430/// One run of differing lines: what we wrote, and what Emacs wrote.
431struct Hunk {
432 ours: Vec<String>,
433 theirs: Vec<String>,
434}
435
436impl Hunk {
437 fn len(&self) -> usize {
438 self.ours.len().max(self.theirs.len())
439 }
440
441 fn deliberate(&self, in_notes: bool) -> Option<&'static str> {
442 DELIBERATE
443 .iter()
444 .filter(|d| in_notes || !d.in_notes_only)
445 .find(|d| (d.matches)(&self.ours, &self.theirs))
446 .map(|d| d.name)
447 }
448
449 /// Does this hunk start the footnote section?
450 fn starts_notes(&self) -> bool {
451 self.ours
452 .iter()
453 .chain(&self.theirs)
454 .any(|l| l == "<section>" || text_of(l) == Some("Footnotes:"))
455 }
456}
457
458enum Op {
459 Same(String),
460 Differs(Hunk),
461}
462
463/// Longest-common-subsequence walk, grouped into runs of agreement and disagreement.
464fn align(ours: &[String], theirs: &[String]) -> Vec<Op> {
258465 let (n, m) = (ours.len(), theirs.len());
259 // lcs[i][j] = length of the longest common subsequence of ours[i..] and theirs[j..].
260466 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
261467 for i in (0..n).rev() {
262468 for j in (0..m).rev() {
@@ -268,37 +474,105 @@ fn divergence(ours: &[String], theirs: &[String]) -> String {
268474 }
269475 }
270476
271 let mut out = String::new();
477 let mut out: Vec<Op> = Vec::new();
478 let push_diff = |out: &mut Vec<Op>, mine: Option<String>, theirs: Option<String>| {
479 if let Some(Op::Differs(h)) = out.last_mut() {
480 h.ours.extend(mine);
481 h.theirs.extend(theirs);
482 return;
483 }
484 out.push(Op::Differs(Hunk {
485 ours: mine.into_iter().collect(),
486 theirs: theirs.into_iter().collect(),
487 }));
488 };
489
272490 let (mut i, mut j) = (0, 0);
273 let mut agreed = 0usize;
274491 while i < n && j < m {
275492 if ours[i] == theirs[j] {
276 out.push_str(&format!(" {}\n", ours[i]));
277 agreed += 1;
493 out.push(Op::Same(ours[i].clone()));
278494 i += 1;
279495 j += 1;
280496 } else if lcs[i + 1][j] >= lcs[i][j + 1] {
281 out.push_str(&format!("- {}\n", ours[i]));
497 push_diff(&mut out, Some(ours[i].clone()), None);
282498 i += 1;
283499 } else {
284 out.push_str(&format!("+ {}\n", theirs[j]));
500 push_diff(&mut out, None, Some(theirs[j].clone()));
285501 j += 1;
286502 }
287503 }
288504 for line in &ours[i..] {
289 out.push_str(&format!("- {line}\n"));
505 push_diff(&mut out, Some(line.clone()), None);
290506 }
291507 for line in &theirs[j..] {
292 out.push_str(&format!("+ {line}\n"));
508 push_diff(&mut out, None, Some(line.clone()));
509 }
510 out
511}
512
513/// A unified diff of the two skeletons, with hunks that are deliberate collapsed to a
514/// named line. `-` is org-ssg, `+` is Emacs.
515///
516/// The number that matters is the last one: *unexplained* lines. Agreement can be low
517/// while unexplained is zero, and that is a passing state.
518fn divergence(ours: &[String], theirs: &[String]) -> String {
519 let ops = align(ours, theirs);
520 let mut agreed = 0usize;
521 let mut deliberate = 0usize;
522 let mut unexplained = 0usize;
523 let mut by_rule: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
524 let mut body = String::new();
525 let mut in_notes = false;
526
527 for op in &ops {
528 match op {
529 Op::Same(line) => {
530 agreed += 1;
531 body.push_str(&format!(" {line}\n"));
532 }
533 Op::Differs(hunk) => {
534 in_notes |= hunk.starts_notes();
535 match hunk.deliberate(in_notes) {
536 Some(rule) => {
537 deliberate += hunk.len();
538 *by_rule.entry(rule).or_default() += 1;
539 body.push_str(&format!("~ {rule} ({} line(s))\n", hunk.len()));
540 }
541 None => {
542 unexplained += hunk.len();
543 for line in &hunk.ours {
544 body.push_str(&format!("- {line}\n"));
545 }
546 for line in &hunk.theirs {
547 body.push_str(&format!("+ {line}\n"));
548 }
549 }
550 }
551 }
552 }
293553 }
294554
295 let total = n.max(m);
555 let total = ours.len().max(theirs.len());
296556 let pct = if total == 0 {
297557 100.0
298558 } else {
299559 100.0 * agreed as f64 / total as f64
300560 };
301 format!("agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n(- org-ssg, + emacs)\n\n{out}")
561 let rules: Vec<String> = by_rule
562 .iter()
563 .map(|(name, n)| format!("{name} ×{n}"))
564 .collect();
565 format!(
566 "agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n\
567 deliberate: {deliberate} line(s){}\n\
568 unexplained: {unexplained} line(s)\n\
569 (- org-ssg, + emacs, ~ a difference we mean to have)\n\n{body}",
570 if rules.is_empty() {
571 String::new()
572 } else {
573 format!(" — {}", rules.join(", "))
574 }
575 )
302576}
303577
304578/// Snapshot the divergence between org-ssg and Emacs for one fixture.
@@ -334,6 +608,48 @@ oracle_test!(oracle_timestamps, "timestamps.org");
334608oracle_test!(oracle_images, "images.org");
335609oracle_test!(oracle_elements, "elements.org");
336610
611/// The gate the snapshots cannot be: *every* divergence from Emacs must be one we chose.
612///
613/// The percentages above are context, not a target — the timestamps fixture agrees on
614/// 40% of its lines and is entirely correct, because org writes a date as text where
615/// org-ssg writes `<time datetime>`. What must hold is that nothing diverges for a
616/// reason nobody has written down. A new unexplained line means either a defect to fix
617/// or a decision to record in `DELIBERATE`.
618#[test]
619fn every_divergence_from_emacs_is_deliberate() {
620 let fixtures = [
621 "minimal.org",
622 "core.org",
623 "headings.org",
624 "lists.org",
625 "blocks.org",
626 "table.org",
627 "footnote.org",
628 "timestamps.org",
629 "images.org",
630 "elements.org",
631 ];
632 let mut offenders = Vec::new();
633 for fixture in fixtures {
634 let Some(report) = compare(fixture) else {
635 return; // no emacs on PATH; the suite skips cleanly
636 };
637 if !report.contains("unexplained: 0 line(s)") {
638 let count = report
639 .lines()
640 .find(|l| l.starts_with("unexplained:"))
641 .unwrap_or("unexplained: ?");
642 offenders.push(format!("{fixture}: {count}"));
643 }
644 }
645 assert!(
646 offenders.is_empty(),
647 "these fixtures diverge from Emacs for unrecorded reasons — fix the defect, or \
648 add a rule to DELIBERATE saying why the difference is wanted:\n {}",
649 offenders.join("\n ")
650 );
651}
652
337653// ---------------------------------------------------------------------------
338654// Invariants that must hold against the oracle, not merely be snapshotted
339655// ---------------------------------------------------------------------------
tests/snapshots/constructs__out_of_scope_html.snap +2 −2
@@ -21,5 +21,5 @@ expression: "render_fixture(\"outofscope.org\")"
2121<h2 id="macros-and-radio-targets">Macros and radio targets</h2>
2222<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
2323<h2 id="drawers">Drawers</h2>
24<h2 id="entities">Entities</h2>
25<p>The full entity set is out of scope, so \alpha stays literal.</p>
24<h2 id="unknown-entities">Unknown entities</h2>
25<p>An entity org does not know, \notanentity, stays literal.</p>
tests/snapshots/oracle__oracle_blocks.snap +14 −16
@@ -3,7 +3,9 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 67/77 skeleton lines (87.0%)
6(- org-ssg, + emacs)
6deliberate: 11 line(s) — pre-code ×6, semantic-emphasis ×4, verse-trailing-break ×1
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
911 "Quote"
@@ -11,11 +13,9 @@ agreement: 67/77 skeleton lines (87.0%)
1113 <blockquote>
1214 <p>
1315 "A quoted paragraph with"
14- <em>
15+ <i>
16~ semantic-emphasis (1 line(s))
1617 "markup"
17- </em>
18+ </i>
18~ semantic-emphasis (1 line(s))
1919 "."
2020 </p>
2121 <p>
@@ -44,14 +44,14 @@ agreement: 67/77 skeleton lines (87.0%)
4444 "Source"
4545 </h2>
4646 <pre>
47- <code>
47~ pre-code (1 line(s))
4848 "def greet(name): return f\"hello {name}\""
49- </code>
49~ pre-code (1 line(s))
5050 </pre>
5151 <pre>
52- <code>
52~ pre-code (1 line(s))
5353 "plain block, no language"
54- </code>
54~ pre-code (1 line(s))
5555 </pre>
5656 <h2>
5757 "Nested"
@@ -61,9 +61,9 @@ agreement: 67/77 skeleton lines (87.0%)
6161 "A quote containing a source block:"
6262 </p>
6363 <pre>
64- <code>
64~ pre-code (1 line(s))
6565 "echo hi"
66- </code>
66~ pre-code (1 line(s))
6767 </pre>
6868 </blockquote>
6969 <h2>
@@ -73,17 +73,15 @@ agreement: 67/77 skeleton lines (87.0%)
7373 "Line breaks are the point"
7474 <br>
7575 "and indentation survives."
76+ <br>
76~ verse-trailing-break (1 line(s))
7777 </p>
7878 <h2>
7979 "A named special block"
8080 </h2>
8181 <p>
8282 "Contents are"
83- <strong>
84+ <b>
83~ semantic-emphasis (1 line(s))
8584 "org"
86- </strong>
87+ </b>
85~ semantic-emphasis (1 line(s))
8886 ", not literal text."
8987 </p>
tests/snapshots/oracle__oracle_core.snap +9 −11
@@ -3,7 +3,9 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 51/58 skeleton lines (87.9%)
6(- org-ssg, + emacs)
6deliberate: 7 line(s) — list-per-bullet-type ×2, pre-code ×2, semantic-emphasis ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <p>
911 "Intro paragraph with a bare URL"
@@ -25,14 +27,11 @@ agreement: 51/58 skeleton lines (87.9%)
2527 </li>
2628 <li>
2729 "second item with"
28- <em>
29+ <i>
30~ semantic-emphasis (1 line(s))
3031 "emphasis"
31- </em>
32+ </i>
32~ semantic-emphasis (1 line(s))
3333 </li>
34- </ol>
35- <ul>
34~ list-per-bullet-type (2 line(s))
3635 <li>
3736 <code>
3837 "[ ]"
@@ -45,8 +44,7 @@ agreement: 51/58 skeleton lines (87.9%)
4544 </code>
4645 "done item"
4746 </li>
48- </ul>
49+ </ol>
47~ list-per-bullet-type (1 line(s))
5048 <h2>
5149 "Links and code"
5250 </h2>
@@ -62,7 +60,7 @@ agreement: 51/58 skeleton lines (87.9%)
6260 "."
6361 </p>
6462 <pre>
65- <code>
63~ pre-code (1 line(s))
6664 "fn main() { println!(\"hello\"); }"
67- </code>
65~ pre-code (1 line(s))
6866 </pre>
tests/snapshots/oracle__oracle_elements.snap +10 −33
@@ -3,21 +3,20 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 64/82 skeleton lines (78.0%)
6(- org-ssg, + emacs)
6deliberate: 26 line(s) — footnote-anchor-naming ×1, footnote-section-shape ×2, id-link-resolution ×1, no-colgroup ×1, pre-code ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
911 "Code and tables"
1012 </h2>
1113 <pre>
12- <code>
14~ pre-code (1 line(s))
1315 "fn main() { println!(\"hello\"); }"
14- </code>
16~ pre-code (1 line(s))
1517 </pre>
1618 <table>
17+ <colgroup>
18+ <col>
19+ <col>
20+ </colgroup>
19~ no-colgroup (4 line(s))
2120 <thead>
2221 <tr>
2322 <th>
@@ -55,18 +54,12 @@ agreement: 64/82 skeleton lines (78.0%)
5554 <a href="https://example.com">
5655 "Example"
5756 </a>
58- "and an id link"
59- <a href="#abc-123">
60- "abc-123"
61- </a>
62- "."
63+ "and an id link ."
57~ id-link-resolution (5 line(s))
6458 </p>
6559 <p>
6660 "Text with a footnote reference."
6761 <sup>
68- <a href="#fn-1">
69+ <a href="#fn.1">
62~ footnote-anchor-naming (1 line(s))
7063 "1"
7164 </a>
7265 </sup>
@@ -80,24 +73,8 @@ agreement: 64/82 skeleton lines (78.0%)
8073 </p>
8174 </blockquote>
8275 <hr>
83- <section>
84- <hr>
85- <ol>
86- <li>
87+ <h2>
88+ "Footnotes:"
89+ </h2>
90+ <sup>
91+ <a href="#fnr.1">
92+ "1"
93+ </a>
94+ </sup>
76~ footnote-section-shape (8 line(s))
9577 <p>
9678 "The footnote definition."
9779 </p>
98- <a href="#fnr-1">
99- "↩"
100- </a>
101- </li>
102- </ol>
103- </section>
80~ footnote-section-shape (6 line(s))
tests/snapshots/oracle__oracle_footnote.snap +14 −47
@@ -3,20 +3,20 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 30/53 skeleton lines (56.6%)
6(- org-ssg, + emacs)
6deliberate: 29 line(s) — footnote-anchor-naming ×3, footnote-section-shape ×6, semantic-emphasis ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <p>
911 "Text with a reference."
1012 <sup>
11- <a href="#fn-1">
12+ <a href="#fn.1">
13~ footnote-anchor-naming (1 line(s))
1314 "1"
1415 </a>
1516 </sup>
1617 "And a second one."
1718 <sup>
18- <a href="#fn-2">
19+ <a href="#fn.2">
19~ footnote-anchor-naming (1 line(s))
2020 "2"
2121 </a>
2222 </sup>
@@ -24,60 +24,27 @@ agreement: 30/53 skeleton lines (56.6%)
2424 <p>
2525 "An inline footnote."
2626 <sup>
27- <a href="#fn-3">
28+ <a href="#fn.3">
27~ footnote-anchor-naming (1 line(s))
2928 "3"
3029 </a>
3130 </sup>
3231 </p>
33- <section>
34- <hr>
35- <ol>
36- <li>
37+ <h2>
38+ "Footnotes:"
39+ </h2>
40+ <sup>
41+ <a href="#fnr.1">
42+ "1"
43+ </a>
44+ </sup>
32~ footnote-section-shape (8 line(s))
4533 <p>
4634 "The first definition."
4735 </p>
48- <a href="#fnr-1">
49- "↩"
50+ <sup>
51+ <a href="#fnr.2">
52+ "2"
36~ footnote-section-shape (3 line(s))
5337 </a>
54- </li>
55- <li>
56+ </sup>
38~ footnote-section-shape (2 line(s))
5739 <p>
5840 "The second definition, with"
59- <em>
60+ <i>
41~ semantic-emphasis (1 line(s))
6142 "emphasis"
62- </em>
63+ </i>
43~ semantic-emphasis (1 line(s))
6444 "."
6545 </p>
66- <a href="#fnr-2">
67- "↩"
68+ <sup>
69+ <a href="#fnr.3">
70+ "3"
46~ footnote-section-shape (3 line(s))
7147 </a>
72- </li>
73- <li>
74+ </sup>
75+ <p>
48~ footnote-section-shape (2 line(s))
7649 "defined right here"
77- <a href="#fnr-3">
78- "↩"
79- </a>
80- </li>
81- </ol>
82- </section>
83+ </p>
50~ footnote-section-shape (6 line(s))
tests/snapshots/oracle__oracle_headings.snap +5 −5
@@ -3,11 +3,12 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 28/30 skeleton lines (93.3%)
6(- org-ssg, + emacs)
6deliberate: 2 line(s) — semantic-time ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
9- "TODO [#A] Write the parser work rust"
10+ "TODO Write the parser work rust"
11~ semantic-time (1 line(s))
1112 </h2>
1213 <p>
1314 "A heading carrying a keyword, a priority, tags and a property drawer."
@@ -19,8 +20,7 @@ agreement: 28/30 skeleton lines (93.3%)
1920 "Sub-headings nest by star count."
2021 </p>
2122 <h3>
22- "[#C] Priority without a keyword"
23+ "Priority without a keyword"
23~ semantic-time (1 line(s))
2424 </h3>
2525 <p>
2626 "A priority cookie can stand alone."
tests/snapshots/oracle__oracle_images.snap +13 −25
@@ -3,7 +3,9 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 30/42 skeleton lines (71.4%)
6(- org-ssg, + emacs)
6deliberate: 14 line(s) — figure-element ×8, semantic-emphasis ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
911 "Bare image"
@@ -14,43 +16,29 @@ agreement: 30/42 skeleton lines (71.4%)
1416 <h2>
1517 "Captioned figure"
1618 </h2>
17- <figure>
18+ <p>
19~ figure-element (1 line(s))
1920 <img src="pipeline.svg">
20- <figcaption>
21+ </p>
22+ <p>
21~ figure-element (2 line(s))
2322 "Figure 1: The pipeline, end to end"
24- </figcaption>
25- </figure>
26+ </p>
23~ figure-element (2 line(s))
2724 <h2>
2825 "Caption with markup"
2926 </h2>
30- <figure>
31+ <p>
27~ figure-element (1 line(s))
3228 <img src="chart.png">
33- <figcaption>
34+ </p>
35+ <p>
29~ figure-element (2 line(s))
3630 "Figure 2: A"
37- <em>
38+ <i>
31~ semantic-emphasis (1 line(s))
3932 "stylised"
40- </em>
41+ </i>
33~ semantic-emphasis (1 line(s))
4234 "chart"
43- </figcaption>
44- </figure>
45+ </p>
35~ figure-element (2 line(s))
4636 <h2>
4737 "Quoted attribute values"
4838 </h2>
49- <figure>
50+ <p>
39~ figure-element (1 line(s))
5140 <img src="cat.jpg">
52- </figure>
53+ </p>
41~ figure-element (1 line(s))
5442 <h2>
5543 "Image with a description is a link"
5644 </h2>
tests/snapshots/oracle__oracle_lists.snap +5 −5
@@ -3,7 +3,9 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 109/111 skeleton lines (98.2%)
6(- org-ssg, + emacs)
6deliberate: 2 line(s) — semantic-emphasis ×2
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
911 "Nesting"
@@ -91,11 +93,9 @@ agreement: 109/111 skeleton lines (98.2%)
9193 "the second definition, which is soft-wrapped across two lines"
9294 </dd>
9395 <dt>
94- <em>
95+ <i>
96~ semantic-emphasis (1 line(s))
9697 "marked up"
97- </em>
98+ </i>
98~ semantic-emphasis (1 line(s))
9999 "term"
100100 </dt>
101101 <dd>
tests/snapshots/oracle__oracle_minimal.snap +7 −9
@@ -3,7 +3,9 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 38/42 skeleton lines (90.5%)
6(- org-ssg, + emacs)
6deliberate: 4 line(s) — semantic-emphasis ×4
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <p>
911 "A single paragraph of preamble text before any heading."
@@ -13,17 +15,13 @@ agreement: 38/42 skeleton lines (90.5%)
1315 </h2>
1416 <p>
1517 "Some body text with"
16- <strong>
17+ <b>
18~ semantic-emphasis (1 line(s))
1819 "bold"
19- </strong>
20+ </b>
20~ semantic-emphasis (1 line(s))
2121 ","
22- <em>
23+ <i>
22~ semantic-emphasis (1 line(s))
2423 "italic"
25- </em>
26+ </i>
24~ semantic-emphasis (1 line(s))
2725 ", and"
2826 <code>
2927 "verbatim"
tests/snapshots/oracle__oracle_table.snap +4 −5
@@ -3,13 +3,12 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 30/34 skeleton lines (88.2%)
6(- org-ssg, + emacs)
6deliberate: 4 line(s) — no-colgroup ×1
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <table>
9+ <colgroup>
10+ <col>
11+ <col>
12+ </colgroup>
11~ no-colgroup (4 line(s))
1312 <thead>
1413 <tr>
1514 <th>
tests/snapshots/oracle__oracle_timestamps.snap +8 −43
@@ -3,68 +3,33 @@ source: tests/oracle.rs
33expression: report
44---
55agreement: 25/62 skeleton lines (40.3%)
6(- org-ssg, + emacs)
6deliberate: 37 line(s) — semantic-time ×5
7unexplained: 0 line(s)
8(- org-ssg, + emacs, ~ a difference we mean to have)
79
810 <h2>
911 "Single"
1012 </h2>
1113 <p>
12- "An active date"
13- <time>
14- "2024-01-15"
15- </time>
16- "and an inactive one"
17- <time>
18- "2024-01-15"
19- </time>
20- "."
21+ "An active date <2024-01-15 Mon> and an inactive one [2024-01-15 Mon]."
14~ semantic-time (9 line(s))
2215 </p>
2316 <p>
24- "With a time:"
25- <time>
26- "2024-01-15 10:30"
27- </time>
28- "."
29+ "With a time: <2024-01-15 Mon 10:30>."
17~ semantic-time (5 line(s))
3018 </p>
3119 <h2>
3220 "Ranges"
3321 </h2>
3422 <p>
35- "A same-day time range"
36- <time>
37- "2024-01-15 10:00"
38- </time>
39- "–"
40- <time>
41- "11:45"
42- </time>
43- "."
44+ "A same-day time range <2024-01-15 Mon 10:00-11:45>."
23~ semantic-time (9 line(s))
4524 </p>
4625 <p>
47- "A multi-day range"
48- <time>
49- "2024-01-15"
50- </time>
51- "–"
52- <time>
53- "2024-01-20"
54- </time>
55- "."
56+ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>."
26~ semantic-time (9 line(s))
5727 </p>
5828 <h2>
5929 "Ignored decorations"
6030 </h2>
6131 <p>
62- "A repeater is dropped:"
63- <time>
64- "2024-01-15"
65- </time>
66- "."
67+ "A repeater is dropped: <2024-01-15 Mon +1w>."
32~ semantic-time (5 line(s))
6833 </p>
6934 <h2>
7035 "Not timestamps"