Commit f2f12bfb9c
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -675,7 +675,7 @@ dependencies = [ | ||
| 675 | 675 | |
| 676 | 676 | [[package]] |
| 677 | 677 | name = "org-ssg" |
| 678 | version = "0.15.0" | |
| 678 | version = "0.16.0" | |
| 679 | 679 | dependencies = [ |
| 680 | 680 | "anyhow", |
| 681 | 681 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | [package] |
| 2 | 2 | name = "org-ssg" |
| 3 | version = "0.15.0" | |
| 3 | version = "0.16.0" | |
| 4 | 4 | edition = "2021" |
| 5 | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | 6 | license = "MIT" |
README.md +11 −4
| @@ -334,14 +334,16 @@ rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax | ||
| 334 | 334 | highlighting; example/quote/center/verse blocks and named special blocks; links (external, |
| 335 | 335 | internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` |
| 336 | 336 | keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's |
| 337 | export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps | |
| 337 | export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`, `\alpha`); timestamps | |
| 338 | 338 | (active/inactive, ranges); paragraphs and horizontal rules; images with |
| 339 | 339 | `#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. |
| 340 | 340 | |
| 341 | 341 | **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / |
| 342 | 342 | `:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past |
| 343 | the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda | |
| 344 | semantics; non-HTML export blocks; the full Unicode entity set. | |
| 343 | the text conversions); `#+INCLUDE:` (never expanded — reported as a diagnostic, so a page | |
| 344 | is never quietly short of content); citations; radio targets and macros; drawers other | |
| 345 | than PROPERTIES/LOGBOOK; column view / clocking / agenda semantics; non-HTML export | |
| 346 | blocks. | |
| 345 | 347 | |
| 346 | 348 | **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test |
| 347 | 349 | asserting 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 | ||
| 377 | 379 | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | |
| 378 | 380 | | **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | |
| 379 | 381 | | **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 | | |
| 380 | 387 | |
| 381 | 388 | ### v0.2 in / out |
| 382 | 389 | |
| @@ -465,7 +472,7 @@ construct the IN list claims is now parsed, rendered, and pinned by a golden fil | ||
| 465 | 472 | **The OUT line is now enforced, not just asserted.** `tests/constructs.rs` pins each |
| 466 | 473 | excluded construct to a specific degradation: babel is never executed *and* a checked-in |
| 467 | 474 | `#+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 | |
| 469 | 476 | as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block |
| 470 | 477 | types keep their content verbatim. |
| 471 | 478 | |
docs/guide/05-org-support.org +9 −2
| @@ -46,6 +46,12 @@ Org rewrites some prose on export, and so does org-ssg: | ||
| 46 | 46 | | =...= | … | |
| 47 | 47 | | =x^2= | x superscript 2 | |
| 48 | 48 | | =H_{2}O= | H subscript 2 O | |
| 49 | | =\alpha=, =\rarr=, =20\deg= | α, →, 20° | | |
| 50 | ||
| 51 | The entity table is org's own, generated from Emacs' =org-entities= rather than | |
| 52 | transcribed, 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 | |
| 54 | than α followed by "bet". =#+OPTIONS: e:nil= turns the whole table off. | |
| 49 | 55 | |
| 50 | 56 | Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in |
| 51 | 57 | a 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. | ||
| 123 | 129 | |
| 124 | 130 | ** Tables and footnotes |
| 125 | 131 | |
| 126 | Pipe tables, with the rule row establishing a header band. Org's *special column* is | |
| 132 | Pipe tables, with the rule row establishing a header band and an affiliated =#+CAPTION:= | |
| 133 | becoming a numbered =<caption>=. Org's *special column* is | |
| 127 | 134 | honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is |
| 128 | 135 | dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than |
| 129 | 136 | content, so they never reach the page. |
| @@ -174,7 +181,7 @@ a build. | ||
| 174 | 181 | |-----------+--------------| |
| 175 | 182 | | Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. | |
| 176 | 183 | | =#+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. | | |
| 178 | 185 | | LaTeX, MathJax | Survives as the literal text you typed. | |
| 179 | 186 | | Macros ={{{name}}}=, radio targets | Literal text. | |
| 180 | 187 | | 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 | ||
| 43 | 43 | Drawer contents are captured and dropped. |
| 44 | 44 | :END: |
| 45 | 45 | |
| 46 | * Entities | |
| 46 | * Unknown entities | |
| 47 | 47 | |
| 48 | The full entity set is out of scope, so \alpha stays literal. | |
| 48 | An entity org does not know, \notanentity, stays literal. | |
src/config.rs +9
| @@ -241,6 +241,11 @@ pub struct HtmlOutput { | ||
| 241 | 241 | /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself |
| 242 | 242 | /// with `#+OPTIONS: ^:nil` or `^:{}`. |
| 243 | 243 | 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, | |
| 244 | 249 | } |
| 245 | 250 | |
| 246 | 251 | /// How `_` and `^` are treated in prose. Mirrors org's `^:` export option. |
| @@ -275,6 +280,7 @@ impl Default for HtmlOutput { | ||
| 275 | 280 | section_numbers: false, |
| 276 | 281 | special_strings: true, |
| 277 | 282 | sub_superscript: SubSuperscript::Yes, |
| 283 | entities: true, | |
| 278 | 284 | } |
| 279 | 285 | } |
| 280 | 286 | } |
| @@ -577,6 +583,9 @@ special_strings = true | ||
| 577 | 583 | # becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no". |
| 578 | 584 | # A document overrides with `#+OPTIONS: ^:nil` or `^:{}`. |
| 579 | 585 | sub_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`. | |
| 588 | entities = true | |
| 580 | 589 | |
| 581 | 590 | # Generated listing pages: output files with no source .org behind them. Repeat the |
| 582 | 591 | # [[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. | |
| 17 | pub const ENTITIES: &[(&str, &str)] = &[ | |
| 18 | ("AA", "Å"), | |
| 19 | ("AElig", "Æ"), | |
| 20 | ("Aacute", "Á"), | |
| 21 | ("Acirc", "Â"), | |
| 22 | ("Agrave", "À"), | |
| 23 | ("Alpha", "Α"), | |
| 24 | ("Amacr", "Ā"), | |
| 25 | ("Aring", "Å"), | |
| 26 | ("Atilde", "Ã"), | |
| 27 | ("Auml", "Ä"), | |
| 28 | ("Beta", "Β"), | |
| 29 | ("Ccedil", "Ç"), | |
| 30 | ("Chi", "Χ"), | |
| 31 | ("Dagger", "‡"), | |
| 32 | ("Delta", "Δ"), | |
| 33 | ("Diamond", "⋄"), | |
| 34 | ("Downarrow", "⇓"), | |
| 35 | ("ETH", "Ð"), | |
| 36 | ("EUR", "€"), | |
| 37 | ("Eacute", "É"), | |
| 38 | ("Ecirc", "Ê"), | |
| 39 | ("Egrave", "È"), | |
| 40 | ("Epsilon", "Ε"), | |
| 41 | ("Eta", "Η"), | |
| 42 | ("Euml", "Ë"), | |
| 43 | ("Gamma", "Γ"), | |
| 44 | ("Gg", "⋙"), | |
| 45 | ("Iacute", "Í"), | |
| 46 | ("Icirc", "Î"), | |
| 47 | ("Idot", "&idot;"), | |
| 48 | ("Igrave", "Ì"), | |
| 49 | ("Iota", "Ι"), | |
| 50 | ("Iuml", "Ï"), | |
| 51 | ("Kappa", "Κ"), | |
| 52 | ("Lambda", "Λ"), | |
| 53 | ("Leftarrow", "⇐"), | |
| 54 | ("Leftrightarrow", "⇔"), | |
| 55 | ("Ll", "⋘"), | |
| 56 | ("Mu", "Μ"), | |
| 57 | ("Ntilde", "Ñ"), | |
| 58 | ("Nu", "Ν"), | |
| 59 | ("OElig", "Œ"), | |
| 60 | ("Oacute", "Ó"), | |
| 61 | ("Ocirc", "Ô"), | |
| 62 | ("Ograve", "Ò"), | |
| 63 | ("Omega", "Ω"), | |
| 64 | ("Omicron", "Ο"), | |
| 65 | ("Oslash", "Ø"), | |
| 66 | ("Otilde", "Õ"), | |
| 67 | ("Ouml", "Ö"), | |
| 68 | ("P", "¶"), | |
| 69 | ("Phi", "Φ"), | |
| 70 | ("Pi", "Π"), | |
| 71 | ("Pr", "Pr"), | |
| 72 | ("Prime", "″"), | |
| 73 | ("Psi", "Ψ"), | |
| 74 | ("Rho", "Ρ"), | |
| 75 | ("Rightarrow", "⇒"), | |
| 76 | ("S", "§"), | |
| 77 | ("Scaron", "Š"), | |
| 78 | ("Sigma", "Σ"), | |
| 79 | ("THORN", "Þ"), | |
| 80 | ("Tau", "Τ"), | |
| 81 | ("Theta", "Θ"), | |
| 82 | ("USD", "$"), | |
| 83 | ("Uacute", "Ú"), | |
| 84 | ("Ucirc", "Û"), | |
| 85 | ("Ugrave", "Ù"), | |
| 86 | ("Uparrow", "⇑"), | |
| 87 | ("Upsilon", "Υ"), | |
| 88 | ("Uuml", "Ü"), | |
| 89 | ("Xi", "Ξ"), | |
| 90 | ("Yacute", "Ý"), | |
| 91 | ("Yuml", "Ÿ"), | |
| 92 | ("Zeta", "Ζ"), | |
| 93 | ("_ ", " "), | |
| 94 | ("_ ", "  "), | |
| 95 | ("_ ", "   "), | |
| 96 | ("_ ", "    "), | |
| 97 | ("_ ", "     "), | |
| 98 | ("_ ", "      "), | |
| 99 | ("_ ", "       "), | |
| 100 | ("_ ", "        "), | |
| 101 | ("_ ", "         "), | |
| 102 | ("_ ", "          "), | |
| 103 | ("_ ", "           "), | |
| 104 | ("_ ", "            "), | |
| 105 | ("_ ", "             "), | |
| 106 | ("_ ", "              "), | |
| 107 | ("_ ", "               "), | |
| 108 | ("_ ", "                "), | |
| 109 | ("_ ", "                 "), | |
| 110 | ("_ ", "                  "), | |
| 111 | ("_ ", "                   "), | |
| 112 | ("_ ", "                    "), | |
| 113 | ("aacute", "á"), | |
| 114 | ("acirc", "â"), | |
| 115 | ("acute", "´"), | |
| 116 | ("acutex", "´x"), | |
| 117 | ("aelig", "æ"), | |
| 118 | ("agrave", "à"), | |
| 119 | ("alefsym", "ℵ"), | |
| 120 | ("aleph", "ℵ"), | |
| 121 | ("alpha", "α"), | |
| 122 | ("amacr", "ā"), | |
| 123 | ("amp", "&"), | |
| 124 | ("ang", "∠"), | |
| 125 | ("angle", "∠"), | |
| 126 | ("approx", "≈"), | |
| 127 | ("arccos", "arccos"), | |
| 128 | ("arcsin", "arcsin"), | |
| 129 | ("arctan", "arctan"), | |
| 130 | ("arg", "arg"), | |
| 131 | ("aring", "å"), | |
| 132 | ("asciicirc", "^"), | |
| 133 | ("ast", "∗"), | |
| 134 | ("asymp", "≈"), | |
| 135 | ("atilde", "ã"), | |
| 136 | ("auml", "ä"), | |
| 137 | ("bdquo", "„"), | |
| 138 | ("because", "∵"), | |
| 139 | ("beta", "β"), | |
| 140 | ("beth", "ℶ"), | |
| 141 | ("blacksmile", "☻"), | |
| 142 | ("brvbar", "¦"), | |
| 143 | ("bull", "•"), | |
| 144 | ("bullet", "•"), | |
| 145 | ("cap", "∩"), | |
| 146 | ("ccedil", "ç"), | |
| 147 | ("cdot", "⋅"), | |
| 148 | ("cdots", "⋯"), | |
| 149 | ("cedil", "¸"), | |
| 150 | ("cent", "¢"), | |
| 151 | ("check", "✓"), | |
| 152 | ("checkmark", "✓"), | |
| 153 | ("chi", "χ"), | |
| 154 | ("circ", "ˆ"), | |
| 155 | ("clubs", "♣"), | |
| 156 | ("clubsuit", "♣"), | |
| 157 | ("colon", ":"), | |
| 158 | ("cong", "≅"), | |
| 159 | ("copy", "©"), | |
| 160 | ("cos", "cos"), | |
| 161 | ("cosh", "cosh"), | |
| 162 | ("cot", "cot"), | |
| 163 | ("coth", "coth"), | |
| 164 | ("crarr", "↵"), | |
| 165 | ("csc", "csc"), | |
| 166 | ("cup", "∪"), | |
| 167 | ("curren", "¤"), | |
| 168 | ("dArr", "⇓"), | |
| 169 | ("dag", "†"), | |
| 170 | ("dagger", "†"), | |
| 171 | ("dalet", "ℸ"), | |
| 172 | ("darr", "↓"), | |
| 173 | ("ddag", "‡"), | |
| 174 | ("deg", "°"), | |
| 175 | ("delta", "δ"), | |
| 176 | ("det", "det"), | |
| 177 | ("diamond", "⋄"), | |
| 178 | ("diamondsuit", "♦"), | |
| 179 | ("diams", "♦"), | |
| 180 | ("dim", "dim"), | |
| 181 | ("div", "÷"), | |
| 182 | ("dollar", "$"), | |
| 183 | ("dots", "…"), | |
| 184 | ("downarrow", "↓"), | |
| 185 | ("eacute", "é"), | |
| 186 | ("ecirc", "ê"), | |
| 187 | ("egrave", "è"), | |
| 188 | ("ell", "ℓ"), | |
| 189 | ("empty", "∅"), | |
| 190 | ("emptyset", "∅"), | |
| 191 | ("emsp", " "), | |
| 192 | ("ensp", " "), | |
| 193 | ("epsilon", "ε"), | |
| 194 | ("equal", "="), | |
| 195 | ("equiv", "≡"), | |
| 196 | ("eta", "η"), | |
| 197 | ("eth", "ð"), | |
| 198 | ("euml", "ë"), | |
| 199 | ("euro", "€"), | |
| 200 | ("exist", "∃"), | |
| 201 | ("exists", "∃"), | |
| 202 | ("exp", "exp"), | |
| 203 | ("fnof", "ƒ"), | |
| 204 | ("forall", "∀"), | |
| 205 | ("frac12", "½"), | |
| 206 | ("frac14", "¼"), | |
| 207 | ("frac34", "¾"), | |
| 208 | ("frasl", "⁄"), | |
| 209 | ("frown", "⌢"), | |
| 210 | ("frowny", "☹"), | |
| 211 | ("gamma", "γ"), | |
| 212 | ("gcd", "gcd"), | |
| 213 | ("ge", "≥"), | |
| 214 | ("geq", "≥"), | |
| 215 | ("gets", "←"), | |
| 216 | ("gg", "≫"), | |
| 217 | ("ggg", "⋙"), | |
| 218 | ("gimel", "ℷ"), | |
| 219 | ("gt", ">"), | |
| 220 | ("hArr", "⇔"), | |
| 221 | ("harr", "↔"), | |
| 222 | ("hbar", "ℏ"), | |
| 223 | ("hearts", "♥"), | |
| 224 | ("heartsuit", "♥"), | |
| 225 | ("hellip", "…"), | |
| 226 | ("hom", "hom"), | |
| 227 | ("hookleftarrow", "↵"), | |
| 228 | ("iacute", "í"), | |
| 229 | ("icirc", "î"), | |
| 230 | ("iexcl", "¡"), | |
| 231 | ("igrave", "ì"), | |
| 232 | ("image", "ℑ"), | |
| 233 | ("imath", "ı"), | |
| 234 | ("in", "∈"), | |
| 235 | ("inf", "inf"), | |
| 236 | ("infin", "∞"), | |
| 237 | ("infty", "∞"), | |
| 238 | ("inodot", "ı"), | |
| 239 | ("int", "∫"), | |
| 240 | ("iota", "ι"), | |
| 241 | ("iquest", "¿"), | |
| 242 | ("isin", "∈"), | |
| 243 | ("iuml", "ï"), | |
| 244 | ("jmath", "ȷ"), | |
| 245 | ("kappa", "κ"), | |
| 246 | ("ker", "ker"), | |
| 247 | ("lArr", "⇐"), | |
| 248 | ("lambda", "λ"), | |
| 249 | ("land", "∧"), | |
| 250 | ("lang", "⟨"), | |
| 251 | ("langle", "⟨"), | |
| 252 | ("laquo", "«"), | |
| 253 | ("larr", "←"), | |
| 254 | ("lceil", "⌈"), | |
| 255 | ("ldquo", "“"), | |
| 256 | ("le", "≤"), | |
| 257 | ("leftarrow", "←"), | |
| 258 | ("leftrightarrow", "↔"), | |
| 259 | ("leq", "≤"), | |
| 260 | ("lesseqgtr", "⋚"), | |
| 261 | ("lessgtr", "≶"), | |
| 262 | ("lfloor", "⌊"), | |
| 263 | ("lg", "lg"), | |
| 264 | ("lim", "lim"), | |
| 265 | ("liminf", "liminf"), | |
| 266 | ("limsup", "limsup"), | |
| 267 | ("ll", "≪"), | |
| 268 | ("lll", "⋘"), | |
| 269 | ("ln", "ln"), | |
| 270 | ("log", "log"), | |
| 271 | ("lor", "∨"), | |
| 272 | ("lowast", "∗"), | |
| 273 | ("loz", "◊"), | |
| 274 | ("lrm", "‎"), | |
| 275 | ("lsaquo", "‹"), | |
| 276 | ("lsquo", "‘"), | |
| 277 | ("lt", "<"), | |
| 278 | ("macr", "¯"), | |
| 279 | ("max", "max"), | |
| 280 | ("mdash", "—"), | |
| 281 | ("mho", "℧"), | |
| 282 | ("micro", "µ"), | |
| 283 | ("middot", "·"), | |
| 284 | ("min", "min"), | |
| 285 | ("minus", "−"), | |
| 286 | ("mu", "μ"), | |
| 287 | ("nabla", "∇"), | |
| 288 | ("nbsp", " "), | |
| 289 | ("ndash", "–"), | |
| 290 | ("ne", "≠"), | |
| 291 | ("neg", "¬"), | |
| 292 | ("neq", "≠"), | |
| 293 | ("nexist", "∃"), | |
| 294 | ("nexists", "∃"), | |
| 295 | ("ni", "∋"), | |
| 296 | ("not", "¬"), | |
| 297 | ("notin", "∉"), | |
| 298 | ("nsub", "⊄"), | |
| 299 | ("nsup", "⊅"), | |
| 300 | ("ntilde", "ñ"), | |
| 301 | ("nu", "ν"), | |
| 302 | ("oacute", "ó"), | |
| 303 | ("ocirc", "ô"), | |
| 304 | ("odot", "o"), | |
| 305 | ("oelig", "œ"), | |
| 306 | ("ograve", "ò"), | |
| 307 | ("oline", "‾"), | |
| 308 | ("omega", "ω"), | |
| 309 | ("omicron", "ο"), | |
| 310 | ("oplus", "⊕"), | |
| 311 | ("ordf", "ª"), | |
| 312 | ("ordm", "º"), | |
| 313 | ("oslash", "ø"), | |
| 314 | ("otilde", "õ"), | |
| 315 | ("otimes", "⊗"), | |
| 316 | ("ouml", "ö"), | |
| 317 | ("para", "¶"), | |
| 318 | ("parallel", "∥"), | |
| 319 | ("partial", "∂"), | |
| 320 | ("permil", "‰"), | |
| 321 | ("perp", "⊥"), | |
| 322 | ("phi", "φ"), | |
| 323 | ("pi", "π"), | |
| 324 | ("piv", "ϖ"), | |
| 325 | ("plus", "+"), | |
| 326 | ("plusmn", "±"), | |
| 327 | ("pm", "±"), | |
| 328 | ("pound", "£"), | |
| 329 | ("prec", "≺"), | |
| 330 | ("preccurlyeq", "≼"), | |
| 331 | ("preceq", "≼"), | |
| 332 | ("prime", "′"), | |
| 333 | ("prod", "∏"), | |
| 334 | ("prop", "∝"), | |
| 335 | ("propto", "∝"), | |
| 336 | ("psi", "ψ"), | |
| 337 | ("quot", """), | |
| 338 | ("rArr", "⇒"), | |
| 339 | ("radic", "√"), | |
| 340 | ("rang", "⟩"), | |
| 341 | ("rangle", "⟩"), | |
| 342 | ("raquo", "»"), | |
| 343 | ("rarr", "→"), | |
| 344 | ("rceil", "⌉"), | |
| 345 | ("rdquo", "”"), | |
| 346 | ("real", "ℜ"), | |
| 347 | ("reg", "®"), | |
| 348 | ("rfloor", "⌋"), | |
| 349 | ("rho", "ρ"), | |
| 350 | ("rightarrow", "→"), | |
| 351 | ("rlm", "‏"), | |
| 352 | ("rsaquo", "›"), | |
| 353 | ("rsquo", "’"), | |
| 354 | ("sad", "☹"), | |
| 355 | ("sbquo", "‚"), | |
| 356 | ("scaron", "š"), | |
| 357 | ("sdot", "⋅"), | |
| 358 | ("sec", "sec"), | |
| 359 | ("sect", "§"), | |
| 360 | ("setminus", "∖"), | |
| 361 | ("shy", "­"), | |
| 362 | ("sigma", "σ"), | |
| 363 | ("sigmaf", "ς"), | |
| 364 | ("sim", "∼"), | |
| 365 | ("simeq", "≅"), | |
| 366 | ("sin", "sin"), | |
| 367 | ("sinh", "sinh"), | |
| 368 | ("slash", "/"), | |
| 369 | ("smile", "⌣"), | |
| 370 | ("smiley", "☺"), | |
| 371 | ("spades", "♠"), | |
| 372 | ("spadesuit", "♠"), | |
| 373 | ("star", "*"), | |
| 374 | ("sub", "⊂"), | |
| 375 | ("sube", "⊆"), | |
| 376 | ("subset", "⊂"), | |
| 377 | ("succ", "≻"), | |
| 378 | ("succcurlyeq", "≽"), | |
| 379 | ("succeq", "≽"), | |
| 380 | ("sum", "∑"), | |
| 381 | ("sup", "⊃"), | |
| 382 | ("sup1", "¹"), | |
| 383 | ("sup2", "²"), | |
| 384 | ("sup3", "³"), | |
| 385 | ("supe", "⊇"), | |
| 386 | ("supset", "⊃"), | |
| 387 | ("szlig", "ß"), | |
| 388 | ("tan", "tan"), | |
| 389 | ("tanh", "tanh"), | |
| 390 | ("tau", "τ"), | |
| 391 | ("there4", "∴"), | |
| 392 | ("therefore", "∴"), | |
| 393 | ("theta", "θ"), | |
| 394 | ("thetasym", "ϑ"), | |
| 395 | ("thinsp", " "), | |
| 396 | ("thorn", "þ"), | |
| 397 | ("tilde", "~"), | |
| 398 | ("times", "×"), | |
| 399 | ("to", "→"), | |
| 400 | ("trade", "™"), | |
| 401 | ("triangleq", "≜"), | |
| 402 | ("uArr", "⇑"), | |
| 403 | ("uacute", "ú"), | |
| 404 | ("uarr", "↑"), | |
| 405 | ("ucirc", "û"), | |
| 406 | ("ugrave", "ù"), | |
| 407 | ("uml", "¨"), | |
| 408 | ("under", "_"), | |
| 409 | ("uparrow", "↑"), | |
| 410 | ("upsih", "ϒ"), | |
| 411 | ("upsilon", "υ"), | |
| 412 | ("uuml", "ü"), | |
| 413 | ("varepsilon", "ε"), | |
| 414 | ("varphi", "ϕ"), | |
| 415 | ("varpi", "ϖ"), | |
| 416 | ("varsigma", "ς"), | |
| 417 | ("vartheta", "ϑ"), | |
| 418 | ("vbar", "|"), | |
| 419 | ("vee", "∨"), | |
| 420 | ("vert", "|"), | |
| 421 | ("wedge", "∧"), | |
| 422 | ("weierp", "℘"), | |
| 423 | ("xi", "ξ"), | |
| 424 | ("yacute", "ý"), | |
| 425 | ("yen", "¥"), | |
| 426 | ("yuml", "ÿ"), | |
| 427 | ("zeta", "ζ"), | |
| 428 | ("zwj", "‍"), | |
| 429 | ("zwnj", "‌"), | |
| 430 | ]; | |
| 431 | ||
| 432 | /// The HTML for `\name`, if org knows it. | |
| 433 | pub 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | pub mod audit; |
| 12 | 12 | pub mod config; |
| 13 | pub mod entities; | |
| 13 | 14 | pub mod incremental; |
| 14 | 15 | pub mod index; |
| 15 | 16 | pub mod model; |
src/model.rs +3
| @@ -194,6 +194,9 @@ pub enum Checkbox { | ||
| 194 | 194 | pub struct Table { |
| 195 | 195 | /// Rule rows preserved to locate the header band. |
| 196 | 196 | pub rows: Vec<TableRow>, |
| 197 | /// An affiliated `#+CAPTION:` directly above the table. | |
| 198 | #[serde(default)] | |
| 199 | pub caption: Vec<Object>, | |
| 197 | 200 | } |
| 198 | 201 | |
| 199 | 202 | #[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 | ||
| 407 | 407 | continue; |
| 408 | 408 | } |
| 409 | 409 | 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 | } | |
| 410 | 424 | if key.eq_ignore_ascii_case("RESULTS") { |
| 411 | 425 | // Babel is never executed (README §OUT), so a checked-in `#+RESULTS:` |
| 412 | 426 | // block is output from someone else's Emacs session at some other time. |
| @@ -672,8 +686,8 @@ fn is_affiliated(key: &str) -> bool { | ||
| 672 | 686 | } |
| 673 | 687 | |
| 674 | 688 | /// 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. | |
| 677 | 691 | fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Element { |
| 678 | 692 | let value = |key: &str| { |
| 679 | 693 | affiliated |
| @@ -686,6 +700,12 @@ fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Ele | ||
| 686 | 700 | if caption.is_empty() && attrs.is_empty() { |
| 687 | 701 | return element; |
| 688 | 702 | } |
| 703 | if let Element::Table(table) = element { | |
| 704 | return Element::Table(Table { | |
| 705 | caption: inline(&caption), | |
| 706 | ..table | |
| 707 | }); | |
| 708 | } | |
| 689 | 709 | let Element::Paragraph(objs) = &element else { |
| 690 | 710 | return element; |
| 691 | 711 | }; |
| @@ -741,7 +761,13 @@ fn parse_table(lines: &[&str], start: usize) -> (Table, usize) { | ||
| 741 | 761 | } |
| 742 | 762 | i += 1; |
| 743 | 763 | } |
| 744 | (Table { rows }, i) | |
| 764 | ( | |
| 765 | Table { | |
| 766 | rows, | |
| 767 | caption: Vec::new(), | |
| 768 | }, | |
| 769 | i, | |
| 770 | ) | |
| 745 | 771 | } |
| 746 | 772 | |
| 747 | 773 | /// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`. |
| @@ -1142,6 +1168,14 @@ fn parse_inline_run(chars: &[char]) -> Vec<Object> { | ||
| 1142 | 1168 | continue; |
| 1143 | 1169 | } |
| 1144 | 1170 | } |
| 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 | } | |
| 1145 | 1179 | if is_marker(c) { |
| 1146 | 1180 | if let Some((obj, next)) = try_emphasis(chars, i) { |
| 1147 | 1181 | flush(&mut buf, &mut out); |
| @@ -1428,6 +1462,30 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | ||
| 1428 | 1462 | None |
| 1429 | 1463 | } |
| 1430 | 1464 | |
| 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. | |
| 1470 | fn 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 | ||
| 1431 | 1489 | /// May this character sit directly inside an emphasis marker? |
| 1432 | 1490 | /// |
| 1433 | 1491 | /// 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> { | ||
| 218 | 218 | headline_offset: u8, |
| 219 | 219 | /// Captioned figures seen so far, for `Figure N:`. |
| 220 | 220 | figures: usize, |
| 221 | /// Captioned tables seen so far, for `Table N:`. | |
| 222 | tables: usize, | |
| 221 | 223 | } |
| 222 | 224 | |
| 223 | 225 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| @@ -237,6 +239,9 @@ pub struct RenderOptions { | ||
| 237 | 239 | /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See |
| 238 | 240 | /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript). |
| 239 | 241 | pub sub_superscript: SubSuperscript, |
| 242 | /// Whether `\alpha` becomes `α`. See | |
| 243 | /// [`HtmlOutput::entities`](crate::config::HtmlOutput::entities). | |
| 244 | pub entities: bool, | |
| 240 | 245 | } |
| 241 | 246 | |
| 242 | 247 | impl Default for RenderOptions { |
| @@ -247,6 +252,7 @@ impl Default for RenderOptions { | ||
| 247 | 252 | section_numbers: html.section_numbers, |
| 248 | 253 | special_strings: html.special_strings, |
| 249 | 254 | sub_superscript: html.sub_superscript, |
| 255 | entities: html.entities, | |
| 250 | 256 | } |
| 251 | 257 | } |
| 252 | 258 | } |
| @@ -267,6 +273,7 @@ fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> | ||
| 267 | 273 | Some(value) => SubSuperscript::from_option(value), |
| 268 | 274 | None => opts.sub_superscript, |
| 269 | 275 | }, |
| 276 | entities: option_enabled(keywords, "e", opts.entities), | |
| 270 | 277 | } |
| 271 | 278 | } |
| 272 | 279 | |
| @@ -304,6 +311,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend | ||
| 304 | 311 | counters: Vec::new(), |
| 305 | 312 | headline_offset: headline_offset(&doc.document.root), |
| 306 | 313 | figures: 0, |
| 314 | tables: 0, | |
| 307 | 315 | }; |
| 308 | 316 | r.collect_defs(&doc.document.root); |
| 309 | 317 | let mut out = String::new(); |
| @@ -550,6 +558,16 @@ impl Renderer<'_> { | ||
| 550 | 558 | .iter() |
| 551 | 559 | .position(|r| matches!(r, TableRow::Rule)); |
| 552 | 560 | 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 | } | |
| 553 | 571 | let mut wrote_body = false; |
| 554 | 572 | let mut in_body = rule_at.is_none(); |
| 555 | 573 | for (idx, row) in table.rows.iter().enumerate() { |
| @@ -627,6 +645,11 @@ impl Renderer<'_> { | ||
| 627 | 645 | fn render_object(&mut self, obj: &Object, out: &mut String) { |
| 628 | 646 | match obj { |
| 629 | 647 | 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 | }, | |
| 630 | 653 | Object::Bold(inner) => self.wrap(out, "strong", inner), |
| 631 | 654 | Object::Italic(inner) => self.wrap(out, "em", inner), |
| 632 | 655 | Object::Underline(inner) => self.wrap(out, "u", inner), |
| @@ -668,7 +691,6 @@ impl Renderer<'_> { | ||
| 668 | 691 | } |
| 669 | 692 | Object::LineBreak => out.push_str("<br>\n"), |
| 670 | 693 | Object::Timestamp(ts) => out.push_str(×tamp_html(ts)), |
| 671 | Object::Entity(e) => out.push_str(&escape_html(e)), | |
| 672 | 694 | } |
| 673 | 695 | } |
| 674 | 696 | |
| @@ -889,7 +911,10 @@ fn strip_special_column(table: &crate::model::Table) -> crate::model::Table { | ||
| 889 | 911 | _ => row.clone(), |
| 890 | 912 | }) |
| 891 | 913 | .collect(); |
| 892 | crate::model::Table { rows } | |
| 914 | crate::model::Table { | |
| 915 | rows, | |
| 916 | caption: table.caption.clone(), | |
| 917 | } | |
| 893 | 918 | } |
| 894 | 919 | |
| 895 | 920 | /// 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 { | ||
| 770 | 770 | section_numbers: config.html.section_numbers, |
| 771 | 771 | special_strings: config.html.special_strings, |
| 772 | 772 | sub_superscript: config.html.sub_superscript, |
| 773 | entities: config.html.entities, | |
| 773 | 774 | } |
| 774 | 775 | } |
| 775 | 776 | |
tests/constructs.rs +73 −2
| @@ -227,7 +227,7 @@ fn table_formulas_are_inert() { | ||
| 227 | 227 | #[test] |
| 228 | 228 | fn latex_macros_and_radio_targets_stay_literal() { |
| 229 | 229 | 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"] { | |
| 231 | 231 | assert!( |
| 232 | 232 | html.contains(literal), |
| 233 | 233 | "`{literal}` should survive as literal text:\n{html}" |
| @@ -387,7 +387,6 @@ fn well_formed_fixtures_produce_no_diagnostics() { | ||
| 387 | 387 | "blocks.org", |
| 388 | 388 | "timestamps.org", |
| 389 | 389 | "images.org", |
| 390 | "outofscope.org", | |
| 391 | 390 | ] { |
| 392 | 391 | let document = parse_fixture(name); |
| 393 | 392 | assert!( |
| @@ -396,6 +395,17 @@ fn well_formed_fixtures_produce_no_diagnostics() { | ||
| 396 | 395 | document.diagnostics |
| 397 | 396 | ); |
| 398 | 397 | } |
| 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 | ); | |
| 399 | 409 | } |
| 400 | 410 | |
| 401 | 411 | // --------------------------------------------------------------------------- |
| @@ -681,3 +691,64 @@ fn a_blank_line_ends_a_captions_association() { | ||
| 681 | 691 | "a caption directly above its image still works:\n{attached}" |
| 682 | 692 | ); |
| 683 | 693 | } |
| 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] | |
| 698 | fn 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:\n{html}"); | |
| 701 | // `{}` is the explicit terminator and must not survive into the text. | |
| 702 | assert!(html.contains("βs"), "beta with {{}}:\n{html}"); | |
| 703 | assert!(html.contains("→"), "arrow:\n{html}"); | |
| 704 | assert!(html.contains("20°"), "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] | |
| 710 | fn 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] | |
| 718 | fn 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("α"), "not converted:\n{html}"); | |
| 722 | } | |
| 723 | ||
| 724 | /// A caption above a table becomes a numbered `<caption>`, as it does for figures. | |
| 725 | #[test] | |
| 726 | fn 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] | |
| 741 | fn 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 { | ||
| 252 | 252 | // Divergence report |
| 253 | 253 | // --------------------------------------------------------------------------- |
| 254 | 254 | |
| 255 | /// A unified diff of the two skeletons, via a longest-common-subsequence walk. `-` is | |
| 256 | /// org-ssg, `+` is Emacs. | |
| 257 | fn 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. | |
| 262 | struct 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 | ||
| 274 | fn is_tag(line: &str, names: &[&str]) -> bool { | |
| 275 | names | |
| 276 | .iter() | |
| 277 | .any(|n| line == format!("<{n}>") || line == format!("</{n}>")) | |
| 278 | } | |
| 279 | ||
| 280 | fn 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]`? | |
| 285 | fn 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 | ||
| 293 | const 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. | |
| 431 | struct Hunk { | |
| 432 | ours: Vec<String>, | |
| 433 | theirs: Vec<String>, | |
| 434 | } | |
| 435 | ||
| 436 | impl 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 | ||
| 458 | enum Op { | |
| 459 | Same(String), | |
| 460 | Differs(Hunk), | |
| 461 | } | |
| 462 | ||
| 463 | /// Longest-common-subsequence walk, grouped into runs of agreement and disagreement. | |
| 464 | fn align(ours: &[String], theirs: &[String]) -> Vec<Op> { | |
| 258 | 465 | let (n, m) = (ours.len(), theirs.len()); |
| 259 | // lcs[i][j] = length of the longest common subsequence of ours[i..] and theirs[j..]. | |
| 260 | 466 | let mut lcs = vec![vec![0usize; m + 1]; n + 1]; |
| 261 | 467 | for i in (0..n).rev() { |
| 262 | 468 | for j in (0..m).rev() { |
| @@ -268,37 +474,105 @@ fn divergence(ours: &[String], theirs: &[String]) -> String { | ||
| 268 | 474 | } |
| 269 | 475 | } |
| 270 | 476 | |
| 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 | ||
| 272 | 490 | let (mut i, mut j) = (0, 0); |
| 273 | let mut agreed = 0usize; | |
| 274 | 491 | while i < n && j < m { |
| 275 | 492 | if ours[i] == theirs[j] { |
| 276 | out.push_str(&format!(" {}\n", ours[i])); | |
| 277 | agreed += 1; | |
| 493 | out.push(Op::Same(ours[i].clone())); | |
| 278 | 494 | i += 1; |
| 279 | 495 | j += 1; |
| 280 | 496 | } 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); | |
| 282 | 498 | i += 1; |
| 283 | 499 | } else { |
| 284 | out.push_str(&format!("+ {}\n", theirs[j])); | |
| 500 | push_diff(&mut out, None, Some(theirs[j].clone())); | |
| 285 | 501 | j += 1; |
| 286 | 502 | } |
| 287 | 503 | } |
| 288 | 504 | for line in &ours[i..] { |
| 289 | out.push_str(&format!("- {line}\n")); | |
| 505 | push_diff(&mut out, Some(line.clone()), None); | |
| 290 | 506 | } |
| 291 | 507 | 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. | |
| 518 | fn 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 | } | |
| 293 | 553 | } |
| 294 | 554 | |
| 295 | let total = n.max(m); | |
| 555 | let total = ours.len().max(theirs.len()); | |
| 296 | 556 | let pct = if total == 0 { |
| 297 | 557 | 100.0 |
| 298 | 558 | } else { |
| 299 | 559 | 100.0 * agreed as f64 / total as f64 |
| 300 | 560 | }; |
| 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 | ) | |
| 302 | 576 | } |
| 303 | 577 | |
| 304 | 578 | /// Snapshot the divergence between org-ssg and Emacs for one fixture. |
| @@ -334,6 +608,48 @@ oracle_test!(oracle_timestamps, "timestamps.org"); | ||
| 334 | 608 | oracle_test!(oracle_images, "images.org"); |
| 335 | 609 | oracle_test!(oracle_elements, "elements.org"); |
| 336 | 610 | |
| 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] | |
| 619 | fn 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 | ||
| 337 | 653 | // --------------------------------------------------------------------------- |
| 338 | 654 | // Invariants that must hold against the oracle, not merely be snapshotted |
| 339 | 655 | // --------------------------------------------------------------------------- |
tests/snapshots/constructs__out_of_scope_html.snap +2 −2
| @@ -21,5 +21,5 @@ expression: "render_fixture(\"outofscope.org\")" | ||
| 21 | 21 | <h2 id="macros-and-radio-targets">Macros and radio targets</h2> |
| 22 | 22 | <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> |
| 23 | 23 | <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 | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 67/77 skeleton lines (87.0%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 11 line(s) — pre-code ×6, semantic-emphasis ×4, verse-trailing-break ×1 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | 11 | "Quote" |
| @@ -11,11 +13,9 @@ agreement: 67/77 skeleton lines (87.0%) | ||
| 11 | 13 | <blockquote> |
| 12 | 14 | <p> |
| 13 | 15 | "A quoted paragraph with" |
| 14 | - <em> | |
| 15 | + <i> | |
| 16 | ~ semantic-emphasis (1 line(s)) | |
| 16 | 17 | "markup" |
| 17 | - </em> | |
| 18 | + </i> | |
| 18 | ~ semantic-emphasis (1 line(s)) | |
| 19 | 19 | "." |
| 20 | 20 | </p> |
| 21 | 21 | <p> |
| @@ -44,14 +44,14 @@ agreement: 67/77 skeleton lines (87.0%) | ||
| 44 | 44 | "Source" |
| 45 | 45 | </h2> |
| 46 | 46 | <pre> |
| 47 | - <code> | |
| 47 | ~ pre-code (1 line(s)) | |
| 48 | 48 | "def greet(name): return f\"hello {name}\"" |
| 49 | - </code> | |
| 49 | ~ pre-code (1 line(s)) | |
| 50 | 50 | </pre> |
| 51 | 51 | <pre> |
| 52 | - <code> | |
| 52 | ~ pre-code (1 line(s)) | |
| 53 | 53 | "plain block, no language" |
| 54 | - </code> | |
| 54 | ~ pre-code (1 line(s)) | |
| 55 | 55 | </pre> |
| 56 | 56 | <h2> |
| 57 | 57 | "Nested" |
| @@ -61,9 +61,9 @@ agreement: 67/77 skeleton lines (87.0%) | ||
| 61 | 61 | "A quote containing a source block:" |
| 62 | 62 | </p> |
| 63 | 63 | <pre> |
| 64 | - <code> | |
| 64 | ~ pre-code (1 line(s)) | |
| 65 | 65 | "echo hi" |
| 66 | - </code> | |
| 66 | ~ pre-code (1 line(s)) | |
| 67 | 67 | </pre> |
| 68 | 68 | </blockquote> |
| 69 | 69 | <h2> |
| @@ -73,17 +73,15 @@ agreement: 67/77 skeleton lines (87.0%) | ||
| 73 | 73 | "Line breaks are the point" |
| 74 | 74 | <br> |
| 75 | 75 | "and indentation survives." |
| 76 | + <br> | |
| 76 | ~ verse-trailing-break (1 line(s)) | |
| 77 | 77 | </p> |
| 78 | 78 | <h2> |
| 79 | 79 | "A named special block" |
| 80 | 80 | </h2> |
| 81 | 81 | <p> |
| 82 | 82 | "Contents are" |
| 83 | - <strong> | |
| 84 | + <b> | |
| 83 | ~ semantic-emphasis (1 line(s)) | |
| 85 | 84 | "org" |
| 86 | - </strong> | |
| 87 | + </b> | |
| 85 | ~ semantic-emphasis (1 line(s)) | |
| 88 | 86 | ", not literal text." |
| 89 | 87 | </p> |
tests/snapshots/oracle__oracle_core.snap +9 −11
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 51/58 skeleton lines (87.9%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 7 line(s) — list-per-bullet-type ×2, pre-code ×2, semantic-emphasis ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <p> |
| 9 | 11 | "Intro paragraph with a bare URL" |
| @@ -25,14 +27,11 @@ agreement: 51/58 skeleton lines (87.9%) | ||
| 25 | 27 | </li> |
| 26 | 28 | <li> |
| 27 | 29 | "second item with" |
| 28 | - <em> | |
| 29 | + <i> | |
| 30 | ~ semantic-emphasis (1 line(s)) | |
| 30 | 31 | "emphasis" |
| 31 | - </em> | |
| 32 | + </i> | |
| 32 | ~ semantic-emphasis (1 line(s)) | |
| 33 | 33 | </li> |
| 34 | - </ol> | |
| 35 | - <ul> | |
| 34 | ~ list-per-bullet-type (2 line(s)) | |
| 36 | 35 | <li> |
| 37 | 36 | <code> |
| 38 | 37 | "[ ]" |
| @@ -45,8 +44,7 @@ agreement: 51/58 skeleton lines (87.9%) | ||
| 45 | 44 | </code> |
| 46 | 45 | "done item" |
| 47 | 46 | </li> |
| 48 | - </ul> | |
| 49 | + </ol> | |
| 47 | ~ list-per-bullet-type (1 line(s)) | |
| 50 | 48 | <h2> |
| 51 | 49 | "Links and code" |
| 52 | 50 | </h2> |
| @@ -62,7 +60,7 @@ agreement: 51/58 skeleton lines (87.9%) | ||
| 62 | 60 | "." |
| 63 | 61 | </p> |
| 64 | 62 | <pre> |
| 65 | - <code> | |
| 63 | ~ pre-code (1 line(s)) | |
| 66 | 64 | "fn main() { println!(\"hello\"); }" |
| 67 | - </code> | |
| 65 | ~ pre-code (1 line(s)) | |
| 68 | 66 | </pre> |
tests/snapshots/oracle__oracle_elements.snap +10 −33
| @@ -3,21 +3,20 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 64/82 skeleton lines (78.0%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 26 line(s) — footnote-anchor-naming ×1, footnote-section-shape ×2, id-link-resolution ×1, no-colgroup ×1, pre-code ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | 11 | "Code and tables" |
| 10 | 12 | </h2> |
| 11 | 13 | <pre> |
| 12 | - <code> | |
| 14 | ~ pre-code (1 line(s)) | |
| 13 | 15 | "fn main() { println!(\"hello\"); }" |
| 14 | - </code> | |
| 16 | ~ pre-code (1 line(s)) | |
| 15 | 17 | </pre> |
| 16 | 18 | <table> |
| 17 | + <colgroup> | |
| 18 | + <col> | |
| 19 | + <col> | |
| 20 | + </colgroup> | |
| 19 | ~ no-colgroup (4 line(s)) | |
| 21 | 20 | <thead> |
| 22 | 21 | <tr> |
| 23 | 22 | <th> |
| @@ -55,18 +54,12 @@ agreement: 64/82 skeleton lines (78.0%) | ||
| 55 | 54 | <a href="https://example.com"> |
| 56 | 55 | "Example" |
| 57 | 56 | </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)) | |
| 64 | 58 | </p> |
| 65 | 59 | <p> |
| 66 | 60 | "Text with a footnote reference." |
| 67 | 61 | <sup> |
| 68 | - <a href="#fn-1"> | |
| 69 | + <a href="#fn.1"> | |
| 62 | ~ footnote-anchor-naming (1 line(s)) | |
| 70 | 63 | "1" |
| 71 | 64 | </a> |
| 72 | 65 | </sup> |
| @@ -80,24 +73,8 @@ agreement: 64/82 skeleton lines (78.0%) | ||
| 80 | 73 | </p> |
| 81 | 74 | </blockquote> |
| 82 | 75 | <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)) | |
| 95 | 77 | <p> |
| 96 | 78 | "The footnote definition." |
| 97 | 79 | </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 | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 30/53 skeleton lines (56.6%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 29 line(s) — footnote-anchor-naming ×3, footnote-section-shape ×6, semantic-emphasis ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <p> |
| 9 | 11 | "Text with a reference." |
| 10 | 12 | <sup> |
| 11 | - <a href="#fn-1"> | |
| 12 | + <a href="#fn.1"> | |
| 13 | ~ footnote-anchor-naming (1 line(s)) | |
| 13 | 14 | "1" |
| 14 | 15 | </a> |
| 15 | 16 | </sup> |
| 16 | 17 | "And a second one." |
| 17 | 18 | <sup> |
| 18 | - <a href="#fn-2"> | |
| 19 | + <a href="#fn.2"> | |
| 19 | ~ footnote-anchor-naming (1 line(s)) | |
| 20 | 20 | "2" |
| 21 | 21 | </a> |
| 22 | 22 | </sup> |
| @@ -24,60 +24,27 @@ agreement: 30/53 skeleton lines (56.6%) | ||
| 24 | 24 | <p> |
| 25 | 25 | "An inline footnote." |
| 26 | 26 | <sup> |
| 27 | - <a href="#fn-3"> | |
| 28 | + <a href="#fn.3"> | |
| 27 | ~ footnote-anchor-naming (1 line(s)) | |
| 29 | 28 | "3" |
| 30 | 29 | </a> |
| 31 | 30 | </sup> |
| 32 | 31 | </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)) | |
| 45 | 33 | <p> |
| 46 | 34 | "The first definition." |
| 47 | 35 | </p> |
| 48 | - <a href="#fnr-1"> | |
| 49 | - "↩" | |
| 50 | + <sup> | |
| 51 | + <a href="#fnr.2"> | |
| 52 | + "2" | |
| 36 | ~ footnote-section-shape (3 line(s)) | |
| 53 | 37 | </a> |
| 54 | - </li> | |
| 55 | - <li> | |
| 56 | + </sup> | |
| 38 | ~ footnote-section-shape (2 line(s)) | |
| 57 | 39 | <p> |
| 58 | 40 | "The second definition, with" |
| 59 | - <em> | |
| 60 | + <i> | |
| 41 | ~ semantic-emphasis (1 line(s)) | |
| 61 | 42 | "emphasis" |
| 62 | - </em> | |
| 63 | + </i> | |
| 43 | ~ semantic-emphasis (1 line(s)) | |
| 64 | 44 | "." |
| 65 | 45 | </p> |
| 66 | - <a href="#fnr-2"> | |
| 67 | - "↩" | |
| 68 | + <sup> | |
| 69 | + <a href="#fnr.3"> | |
| 70 | + "3" | |
| 46 | ~ footnote-section-shape (3 line(s)) | |
| 71 | 47 | </a> |
| 72 | - </li> | |
| 73 | - <li> | |
| 74 | + </sup> | |
| 75 | + <p> | |
| 48 | ~ footnote-section-shape (2 line(s)) | |
| 76 | 49 | "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 | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 28/30 skeleton lines (93.3%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 2 line(s) — semantic-time ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | - "TODO [#A] Write the parser work rust" | |
| 10 | + "TODO Write the parser work rust" | |
| 11 | ~ semantic-time (1 line(s)) | |
| 11 | 12 | </h2> |
| 12 | 13 | <p> |
| 13 | 14 | "A heading carrying a keyword, a priority, tags and a property drawer." |
| @@ -19,8 +20,7 @@ agreement: 28/30 skeleton lines (93.3%) | ||
| 19 | 20 | "Sub-headings nest by star count." |
| 20 | 21 | </p> |
| 21 | 22 | <h3> |
| 22 | - "[#C] Priority without a keyword" | |
| 23 | + "Priority without a keyword" | |
| 23 | ~ semantic-time (1 line(s)) | |
| 24 | 24 | </h3> |
| 25 | 25 | <p> |
| 26 | 26 | "A priority cookie can stand alone." |
tests/snapshots/oracle__oracle_images.snap +13 −25
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 30/42 skeleton lines (71.4%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 14 line(s) — figure-element ×8, semantic-emphasis ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | 11 | "Bare image" |
| @@ -14,43 +16,29 @@ agreement: 30/42 skeleton lines (71.4%) | ||
| 14 | 16 | <h2> |
| 15 | 17 | "Captioned figure" |
| 16 | 18 | </h2> |
| 17 | - <figure> | |
| 18 | + <p> | |
| 19 | ~ figure-element (1 line(s)) | |
| 19 | 20 | <img src="pipeline.svg"> |
| 20 | - <figcaption> | |
| 21 | + </p> | |
| 22 | + <p> | |
| 21 | ~ figure-element (2 line(s)) | |
| 23 | 22 | "Figure 1: The pipeline, end to end" |
| 24 | - </figcaption> | |
| 25 | - </figure> | |
| 26 | + </p> | |
| 23 | ~ figure-element (2 line(s)) | |
| 27 | 24 | <h2> |
| 28 | 25 | "Caption with markup" |
| 29 | 26 | </h2> |
| 30 | - <figure> | |
| 31 | + <p> | |
| 27 | ~ figure-element (1 line(s)) | |
| 32 | 28 | <img src="chart.png"> |
| 33 | - <figcaption> | |
| 34 | + </p> | |
| 35 | + <p> | |
| 29 | ~ figure-element (2 line(s)) | |
| 36 | 30 | "Figure 2: A" |
| 37 | - <em> | |
| 38 | + <i> | |
| 31 | ~ semantic-emphasis (1 line(s)) | |
| 39 | 32 | "stylised" |
| 40 | - </em> | |
| 41 | + </i> | |
| 33 | ~ semantic-emphasis (1 line(s)) | |
| 42 | 34 | "chart" |
| 43 | - </figcaption> | |
| 44 | - </figure> | |
| 45 | + </p> | |
| 35 | ~ figure-element (2 line(s)) | |
| 46 | 36 | <h2> |
| 47 | 37 | "Quoted attribute values" |
| 48 | 38 | </h2> |
| 49 | - <figure> | |
| 50 | + <p> | |
| 39 | ~ figure-element (1 line(s)) | |
| 51 | 40 | <img src="cat.jpg"> |
| 52 | - </figure> | |
| 53 | + </p> | |
| 41 | ~ figure-element (1 line(s)) | |
| 54 | 42 | <h2> |
| 55 | 43 | "Image with a description is a link" |
| 56 | 44 | </h2> |
tests/snapshots/oracle__oracle_lists.snap +5 −5
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 109/111 skeleton lines (98.2%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 2 line(s) — semantic-emphasis ×2 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | 11 | "Nesting" |
| @@ -91,11 +93,9 @@ agreement: 109/111 skeleton lines (98.2%) | ||
| 91 | 93 | "the second definition, which is soft-wrapped across two lines" |
| 92 | 94 | </dd> |
| 93 | 95 | <dt> |
| 94 | - <em> | |
| 95 | + <i> | |
| 96 | ~ semantic-emphasis (1 line(s)) | |
| 96 | 97 | "marked up" |
| 97 | - </em> | |
| 98 | + </i> | |
| 98 | ~ semantic-emphasis (1 line(s)) | |
| 99 | 99 | "term" |
| 100 | 100 | </dt> |
| 101 | 101 | <dd> |
tests/snapshots/oracle__oracle_minimal.snap +7 −9
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 38/42 skeleton lines (90.5%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 4 line(s) — semantic-emphasis ×4 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <p> |
| 9 | 11 | "A single paragraph of preamble text before any heading." |
| @@ -13,17 +15,13 @@ agreement: 38/42 skeleton lines (90.5%) | ||
| 13 | 15 | </h2> |
| 14 | 16 | <p> |
| 15 | 17 | "Some body text with" |
| 16 | - <strong> | |
| 17 | + <b> | |
| 18 | ~ semantic-emphasis (1 line(s)) | |
| 18 | 19 | "bold" |
| 19 | - </strong> | |
| 20 | + </b> | |
| 20 | ~ semantic-emphasis (1 line(s)) | |
| 21 | 21 | "," |
| 22 | - <em> | |
| 23 | + <i> | |
| 22 | ~ semantic-emphasis (1 line(s)) | |
| 24 | 23 | "italic" |
| 25 | - </em> | |
| 26 | + </i> | |
| 24 | ~ semantic-emphasis (1 line(s)) | |
| 27 | 25 | ", and" |
| 28 | 26 | <code> |
| 29 | 27 | "verbatim" |
tests/snapshots/oracle__oracle_table.snap +4 −5
| @@ -3,13 +3,12 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 30/34 skeleton lines (88.2%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 4 line(s) — no-colgroup ×1 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <table> |
| 9 | + <colgroup> | |
| 10 | + <col> | |
| 11 | + <col> | |
| 12 | + </colgroup> | |
| 11 | ~ no-colgroup (4 line(s)) | |
| 13 | 12 | <thead> |
| 14 | 13 | <tr> |
| 15 | 14 | <th> |
tests/snapshots/oracle__oracle_timestamps.snap +8 −43
| @@ -3,68 +3,33 @@ source: tests/oracle.rs | ||
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | 5 | agreement: 25/62 skeleton lines (40.3%) |
| 6 | (- org-ssg, + emacs) | |
| 6 | deliberate: 37 line(s) — semantic-time ×5 | |
| 7 | unexplained: 0 line(s) | |
| 8 | (- org-ssg, + emacs, ~ a difference we mean to have) | |
| 7 | 9 | |
| 8 | 10 | <h2> |
| 9 | 11 | "Single" |
| 10 | 12 | </h2> |
| 11 | 13 | <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)) | |
| 22 | 15 | </p> |
| 23 | 16 | <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)) | |
| 30 | 18 | </p> |
| 31 | 19 | <h2> |
| 32 | 20 | "Ranges" |
| 33 | 21 | </h2> |
| 34 | 22 | <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)) | |
| 45 | 24 | </p> |
| 46 | 25 | <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)) | |
| 57 | 27 | </p> |
| 58 | 28 | <h2> |
| 59 | 29 | "Ignored decorations" |
| 60 | 30 | </h2> |
| 61 | 31 | <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)) | |
| 68 | 33 | </p> |
| 69 | 34 | <h2> |
| 70 | 35 | "Not timestamps" |