Commit f2f12bfb9c
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -675,7 +675,7 @@ dependencies = [ | |||
| 675 | 675 | ||
| 676 | [[package]] | 676 | [[package]] |
| 677 | name = "org-ssg" | 677 | name = "org-ssg" |
| 678 | version = "0.15.0" | 678 | version = "0.16.0" |
| 679 | dependencies = [ | 679 | dependencies = [ |
| 680 | "anyhow", | 680 | "anyhow", |
| 681 | "blake3", | 681 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.15.0" | 3 | version = "0.16.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 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 | highlighting; example/quote/center/verse blocks and named special blocks; links (external, | 334 | highlighting; example/quote/center/verse blocks and named special blocks; links (external, |
| 335 | internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` | 335 | internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` |
| 336 | keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's | 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 | (active/inactive, ranges); paragraphs and horizontal rules; images with | 338 | (active/inactive, ranges); paragraphs and horizontal rules; images with |
| 339 | `#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. | 339 | `#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. |
| 340 | 340 | ||
| 341 | **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / | 341 | **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / |
| 342 | `:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past | 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 | 343 | the text conversions); `#+INCLUDE:` (never expanded — reported as a diagnostic, so a page |
| 344 | semantics; non-HTML export blocks; the full Unicode entity set. | 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 | **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test | 348 | **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test |
| 347 | asserting it degrades predictably (ignored, no crash). The IN/OUT line is enforced by | 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 | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | | 379 | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | |
| 378 | | **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | | 380 | | **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | |
| 379 | | **17** | **Bundled TOML and Org syntaxes, a user syntax directory, and org's comma escape** | **done** | | 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 | ### v0.2 in / out | 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 | **The OUT line is now enforced, not just asserted.** `tests/constructs.rs` pins each | 472 | **The OUT line is now enforced, not just asserted.** `tests/constructs.rs` pins each |
| 466 | excluded construct to a specific degradation: babel is never executed *and* a checked-in | 473 | excluded construct to a specific degradation: babel is never executed *and* a checked-in |
| 467 | `#+RESULTS:` block is dropped rather than published as if it were verified output; | 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 | as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block | 476 | as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block |
| 470 | types keep their content verbatim. | 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 | | =x^2= | x superscript 2 | | 47 | | =x^2= | x superscript 2 | |
| 48 | | =H_{2}O= | H subscript 2 O | | 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 | Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in | 56 | Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in |
| 51 | a shell transcript stays a flag, and =$x^2$= stays mathematics. | 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 | ** Tables and footnotes | 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 | honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is | 134 | honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is |
| 128 | dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than | 135 | dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than |
| 129 | content, so they never reach the page. | 136 | content, so they never reach the page. |
| @@ -174,7 +181,7 @@ a build. | |||
| 174 | |-----------+--------------| | 181 | |-----------+--------------| |
| 175 | | Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. | | 182 | | Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. | |
| 176 | | =#+TBLFM:= | Inert. The table renders with the values as written. | | 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 | | LaTeX, MathJax | Survives as the literal text you typed. | | 185 | | LaTeX, MathJax | Survives as the literal text you typed. | |
| 179 | | Macros ={{{name}}}=, radio targets | Literal text. | | 186 | | Macros ={{{name}}}=, radio targets | Literal text. | |
| 180 | | Drawers other than =PROPERTIES= | Captured and dropped, including =LOGBOOK=. | | 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 | Drawer contents are captured and dropped. | 43 | Drawer contents are captured and dropped. |
| 44 | :END: | 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 | /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself | 241 | /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself |
| 242 | /// with `#+OPTIONS: ^:nil` or `^:{}`. | 242 | /// with `#+OPTIONS: ^:nil` or `^:{}`. |
| 243 | pub sub_superscript: SubSuperscript, | 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 | /// How `_` and `^` are treated in prose. Mirrors org's `^:` export option. | 251 | /// How `_` and `^` are treated in prose. Mirrors org's `^:` export option. |
| @@ -275,6 +280,7 @@ impl Default for HtmlOutput { | |||
| 275 | section_numbers: false, | 280 | section_numbers: false, |
| 276 | special_strings: true, | 281 | special_strings: true, |
| 277 | sub_superscript: SubSuperscript::Yes, | 282 | sub_superscript: SubSuperscript::Yes, |
| 283 | entities: true, | ||
| 278 | } | 284 | } |
| 279 | } | 285 | } |
| 280 | } | 286 | } |
| @@ -577,6 +583,9 @@ special_strings = true | |||
| 577 | # becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no". | 583 | # becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no". |
| 578 | # A document overrides with `#+OPTIONS: ^:nil` or `^:{}`. | 584 | # A document overrides with `#+OPTIONS: ^:nil` or `^:{}`. |
| 579 | sub_superscript = "yes" | 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 | # Generated listing pages: output files with no source .org behind them. Repeat the | 590 | # Generated listing pages: output files with no source .org behind them. Repeat the |
| 582 | # [[collections]] block for each one. A feed is the same thing with an XML template. | 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 | pub mod audit; | 11 | pub mod audit; |
| 12 | pub mod config; | 12 | pub mod config; |
| 13 | pub mod entities; | ||
| 13 | pub mod incremental; | 14 | pub mod incremental; |
| 14 | pub mod index; | 15 | pub mod index; |
| 15 | pub mod model; | 16 | pub mod model; |
src/model.rs +3
| @@ -194,6 +194,9 @@ pub enum Checkbox { | |||
| 194 | pub struct Table { | 194 | pub struct Table { |
| 195 | /// Rule rows preserved to locate the header band. | 195 | /// Rule rows preserved to locate the header band. |
| 196 | pub rows: Vec<TableRow>, | 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 | #[derive(Debug, Clone, Serialize, Deserialize)] | 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 | continue; | 407 | continue; |
| 408 | } | 408 | } |
| 409 | if let Some((key, value)) = keyword_kv(line) { | 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 | if key.eq_ignore_ascii_case("RESULTS") { | 424 | if key.eq_ignore_ascii_case("RESULTS") { |
| 411 | // Babel is never executed (README §OUT), so a checked-in `#+RESULTS:` | 425 | // Babel is never executed (README §OUT), so a checked-in `#+RESULTS:` |
| 412 | // block is output from someone else's Emacs session at some other time. | 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 | /// A paragraph holding nothing but an image link becomes a block-level figure when a | 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 | 689 | /// `#+CAPTION:`/`#+ATTR_HTML:` precedes it, and a table takes its caption. Affiliated |
| 676 | /// parsed and dropped (README §IN covers captions for images only). | 690 | /// keywords on anything else are parsed and dropped. |
| 677 | fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Element { | 691 | fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Element { |
| 678 | let value = |key: &str| { | 692 | let value = |key: &str| { |
| 679 | affiliated | 693 | affiliated |
| @@ -686,6 +700,12 @@ fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Ele | |||
| 686 | if caption.is_empty() && attrs.is_empty() { | 700 | if caption.is_empty() && attrs.is_empty() { |
| 687 | return element; | 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 | let Element::Paragraph(objs) = &element else { | 709 | let Element::Paragraph(objs) = &element else { |
| 690 | return element; | 710 | return element; |
| 691 | }; | 711 | }; |
| @@ -741,7 +761,13 @@ fn parse_table(lines: &[&str], start: usize) -> (Table, usize) { | |||
| 741 | } | 761 | } |
| 742 | i += 1; | 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 | /// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`. | 773 | /// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`. |
| @@ -1142,6 +1168,14 @@ fn parse_inline_run(chars: &[char]) -> Vec<Object> { | |||
| 1142 | continue; | 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 | if is_marker(c) { | 1179 | if is_marker(c) { |
| 1146 | if let Some((obj, next)) = try_emphasis(chars, i) { | 1180 | if let Some((obj, next)) = try_emphasis(chars, i) { |
| 1147 | flush(&mut buf, &mut out); | 1181 | flush(&mut buf, &mut out); |
| @@ -1428,6 +1462,30 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | |||
| 1428 | None | 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 | /// May this character sit directly inside an emphasis marker? | 1489 | /// May this character sit directly inside an emphasis marker? |
| 1432 | /// | 1490 | /// |
| 1433 | /// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a | 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 | headline_offset: u8, | 218 | headline_offset: u8, |
| 219 | /// Captioned figures seen so far, for `Figure N:`. | 219 | /// Captioned figures seen so far, for `Figure N:`. |
| 220 | figures: usize, | 220 | figures: usize, |
| 221 | /// Captioned tables seen so far, for `Table N:`. | ||
| 222 | tables: usize, | ||
| 221 | } | 223 | } |
| 222 | 224 | ||
| 223 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the | 225 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| @@ -237,6 +239,9 @@ pub struct RenderOptions { | |||
| 237 | /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See | 239 | /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See |
| 238 | /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript). | 240 | /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript). |
| 239 | pub sub_superscript: SubSuperscript, | 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 | impl Default for RenderOptions { | 247 | impl Default for RenderOptions { |
| @@ -247,6 +252,7 @@ impl Default for RenderOptions { | |||
| 247 | section_numbers: html.section_numbers, | 252 | section_numbers: html.section_numbers, |
| 248 | special_strings: html.special_strings, | 253 | special_strings: html.special_strings, |
| 249 | sub_superscript: html.sub_superscript, | 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 | Some(value) => SubSuperscript::from_option(value), | 273 | Some(value) => SubSuperscript::from_option(value), |
| 268 | None => opts.sub_superscript, | 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 | counters: Vec::new(), | 311 | counters: Vec::new(), |
| 305 | headline_offset: headline_offset(&doc.document.root), | 312 | headline_offset: headline_offset(&doc.document.root), |
| 306 | figures: 0, | 313 | figures: 0, |
| 314 | tables: 0, | ||
| 307 | }; | 315 | }; |
| 308 | r.collect_defs(&doc.document.root); | 316 | r.collect_defs(&doc.document.root); |
| 309 | let mut out = String::new(); | 317 | let mut out = String::new(); |
| @@ -550,6 +558,16 @@ impl Renderer<'_> { | |||
| 550 | .iter() | 558 | .iter() |
| 551 | .position(|r| matches!(r, TableRow::Rule)); | 559 | .position(|r| matches!(r, TableRow::Rule)); |
| 552 | out.push_str("<table>\n"); | 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 | let mut wrote_body = false; | 571 | let mut wrote_body = false; |
| 554 | let mut in_body = rule_at.is_none(); | 572 | let mut in_body = rule_at.is_none(); |
| 555 | for (idx, row) in table.rows.iter().enumerate() { | 573 | for (idx, row) in table.rows.iter().enumerate() { |
| @@ -627,6 +645,11 @@ impl Renderer<'_> { | |||
| 627 | fn render_object(&mut self, obj: &Object, out: &mut String) { | 645 | fn render_object(&mut self, obj: &Object, out: &mut String) { |
| 628 | match obj { | 646 | match obj { |
| 629 | Object::Text(t) => out.push_str(&self.text_html(t)), | 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 | Object::Bold(inner) => self.wrap(out, "strong", inner), | 653 | Object::Bold(inner) => self.wrap(out, "strong", inner), |
| 631 | Object::Italic(inner) => self.wrap(out, "em", inner), | 654 | Object::Italic(inner) => self.wrap(out, "em", inner), |
| 632 | Object::Underline(inner) => self.wrap(out, "u", inner), | 655 | Object::Underline(inner) => self.wrap(out, "u", inner), |
| @@ -668,7 +691,6 @@ impl Renderer<'_> { | |||
| 668 | } | 691 | } |
| 669 | Object::LineBreak => out.push_str("<br>\n"), | 692 | Object::LineBreak => out.push_str("<br>\n"), |
| 670 | Object::Timestamp(ts) => out.push_str(×tamp_html(ts)), | 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 | _ => row.clone(), | 911 | _ => row.clone(), |
| 890 | }) | 912 | }) |
| 891 | .collect(); | 913 | .collect(); |
| 892 | crate::model::Table { rows } | 914 | crate::model::Table { |
| 915 | rows, | ||
| 916 | caption: table.caption.clone(), | ||
| 917 | } | ||
| 893 | } | 918 | } |
| 894 | 919 | ||
| 895 | /// Split text into alternating prose and LaTeX spans, `(text, is_latex)`. | 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 | section_numbers: config.html.section_numbers, | 770 | section_numbers: config.html.section_numbers, |
| 771 | special_strings: config.html.special_strings, | 771 | special_strings: config.html.special_strings, |
| 772 | sub_superscript: config.html.sub_superscript, | 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 | #[test] | 227 | #[test] |
| 228 | fn latex_macros_and_radio_targets_stay_literal() { | 228 | fn latex_macros_and_radio_targets_stay_literal() { |
| 229 | let html = render_fixture("outofscope.org"); | 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 | assert!( | 231 | assert!( |
| 232 | html.contains(literal), | 232 | html.contains(literal), |
| 233 | "`{literal}` should survive as literal text:\n{html}" | 233 | "`{literal}` should survive as literal text:\n{html}" |
| @@ -387,7 +387,6 @@ fn well_formed_fixtures_produce_no_diagnostics() { | |||
| 387 | "blocks.org", | 387 | "blocks.org", |
| 388 | "timestamps.org", | 388 | "timestamps.org", |
| 389 | "images.org", | 389 | "images.org", |
| 390 | "outofscope.org", | ||
| 391 | ] { | 390 | ] { |
| 392 | let document = parse_fixture(name); | 391 | let document = parse_fixture(name); |
| 393 | assert!( | 392 | assert!( |
| @@ -396,6 +395,17 @@ fn well_formed_fixtures_produce_no_diagnostics() { | |||
| 396 | document.diagnostics | 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 | "a caption directly above its image still works:\n{attached}" | 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 | // Divergence report | 252 | // Divergence report |
| 253 | // --------------------------------------------------------------------------- | 253 | // --------------------------------------------------------------------------- |
| 254 | 254 | ||
| 255 | /// A unified diff of the two skeletons, via a longest-common-subsequence walk. `-` is | 255 | /// One divergence org-ssg makes on purpose, so the report can separate "we chose this" |
| 256 | /// org-ssg, `+` is Emacs. | 256 | /// from "we got this wrong". |
| 257 | fn divergence(ours: &[String], theirs: &[String]) -> String { | 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 | let (n, m) = (ours.len(), theirs.len()); | 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 | let mut lcs = vec![vec![0usize; m + 1]; n + 1]; | 466 | let mut lcs = vec![vec![0usize; m + 1]; n + 1]; |
| 261 | for i in (0..n).rev() { | 467 | for i in (0..n).rev() { |
| 262 | for j in (0..m).rev() { | 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 | let (mut i, mut j) = (0, 0); | 490 | let (mut i, mut j) = (0, 0); |
| 273 | let mut agreed = 0usize; | ||
| 274 | while i < n && j < m { | 491 | while i < n && j < m { |
| 275 | if ours[i] == theirs[j] { | 492 | if ours[i] == theirs[j] { |
| 276 | out.push_str(&format!(" {}\n", ours[i])); | 493 | out.push(Op::Same(ours[i].clone())); |
| 277 | agreed += 1; | ||
| 278 | i += 1; | 494 | i += 1; |
| 279 | j += 1; | 495 | j += 1; |
| 280 | } else if lcs[i + 1][j] >= lcs[i][j + 1] { | 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 | i += 1; | 498 | i += 1; |
| 283 | } else { | 499 | } else { |
| 284 | out.push_str(&format!("+ {}\n", theirs[j])); | 500 | push_diff(&mut out, None, Some(theirs[j].clone())); |
| 285 | j += 1; | 501 | j += 1; |
| 286 | } | 502 | } |
| 287 | } | 503 | } |
| 288 | for line in &ours[i..] { | 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 | for line in &theirs[j..] { | 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 | let pct = if total == 0 { | 556 | let pct = if total == 0 { |
| 297 | 100.0 | 557 | 100.0 |
| 298 | } else { | 558 | } else { |
| 299 | 100.0 * agreed as f64 / total as f64 | 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 | /// Snapshot the divergence between org-ssg and Emacs for one fixture. | 578 | /// Snapshot the divergence between org-ssg and Emacs for one fixture. |
| @@ -334,6 +608,48 @@ oracle_test!(oracle_timestamps, "timestamps.org"); | |||
| 334 | oracle_test!(oracle_images, "images.org"); | 608 | oracle_test!(oracle_images, "images.org"); |
| 335 | oracle_test!(oracle_elements, "elements.org"); | 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 | // Invariants that must hold against the oracle, not merely be snapshotted | 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 | <h2 id="macros-and-radio-targets">Macros and radio targets</h2> | 21 | <h2 id="macros-and-radio-targets">Macros and radio targets</h2> |
| 22 | <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> | 22 | <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> |
| 23 | <h2 id="drawers">Drawers</h2> | 23 | <h2 id="drawers">Drawers</h2> |
| 24 | <h2 id="entities">Entities</h2> | 24 | <h2 id="unknown-entities">Unknown entities</h2> |
| 25 | <p>The full entity set is out of scope, so \alpha stays literal.</p> | 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 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 67/77 skeleton lines (87.0%) | 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 | <h2> | 10 | <h2> |
| 9 | "Quote" | 11 | "Quote" |
| @@ -11,11 +13,9 @@ agreement: 67/77 skeleton lines (87.0%) | |||
| 11 | <blockquote> | 13 | <blockquote> |
| 12 | <p> | 14 | <p> |
| 13 | "A quoted paragraph with" | 15 | "A quoted paragraph with" |
| 14 | - <em> | 16 | ~ semantic-emphasis (1 line(s)) |
| 15 | + <i> | ||
| 16 | "markup" | 17 | "markup" |
| 17 | - </em> | 18 | ~ semantic-emphasis (1 line(s)) |
| 18 | + </i> | ||
| 19 | "." | 19 | "." |
| 20 | </p> | 20 | </p> |
| 21 | <p> | 21 | <p> |
| @@ -44,14 +44,14 @@ agreement: 67/77 skeleton lines (87.0%) | |||
| 44 | "Source" | 44 | "Source" |
| 45 | </h2> | 45 | </h2> |
| 46 | <pre> | 46 | <pre> |
| 47 | - <code> | 47 | ~ pre-code (1 line(s)) |
| 48 | "def greet(name): return f\"hello {name}\"" | 48 | "def greet(name): return f\"hello {name}\"" |
| 49 | - </code> | 49 | ~ pre-code (1 line(s)) |
| 50 | </pre> | 50 | </pre> |
| 51 | <pre> | 51 | <pre> |
| 52 | - <code> | 52 | ~ pre-code (1 line(s)) |
| 53 | "plain block, no language" | 53 | "plain block, no language" |
| 54 | - </code> | 54 | ~ pre-code (1 line(s)) |
| 55 | </pre> | 55 | </pre> |
| 56 | <h2> | 56 | <h2> |
| 57 | "Nested" | 57 | "Nested" |
| @@ -61,9 +61,9 @@ agreement: 67/77 skeleton lines (87.0%) | |||
| 61 | "A quote containing a source block:" | 61 | "A quote containing a source block:" |
| 62 | </p> | 62 | </p> |
| 63 | <pre> | 63 | <pre> |
| 64 | - <code> | 64 | ~ pre-code (1 line(s)) |
| 65 | "echo hi" | 65 | "echo hi" |
| 66 | - </code> | 66 | ~ pre-code (1 line(s)) |
| 67 | </pre> | 67 | </pre> |
| 68 | </blockquote> | 68 | </blockquote> |
| 69 | <h2> | 69 | <h2> |
| @@ -73,17 +73,15 @@ agreement: 67/77 skeleton lines (87.0%) | |||
| 73 | "Line breaks are the point" | 73 | "Line breaks are the point" |
| 74 | <br> | 74 | <br> |
| 75 | "and indentation survives." | 75 | "and indentation survives." |
| 76 | + <br> | 76 | ~ verse-trailing-break (1 line(s)) |
| 77 | </p> | 77 | </p> |
| 78 | <h2> | 78 | <h2> |
| 79 | "A named special block" | 79 | "A named special block" |
| 80 | </h2> | 80 | </h2> |
| 81 | <p> | 81 | <p> |
| 82 | "Contents are" | 82 | "Contents are" |
| 83 | - <strong> | 83 | ~ semantic-emphasis (1 line(s)) |
| 84 | + <b> | ||
| 85 | "org" | 84 | "org" |
| 86 | - </strong> | 85 | ~ semantic-emphasis (1 line(s)) |
| 87 | + </b> | ||
| 88 | ", not literal text." | 86 | ", not literal text." |
| 89 | </p> | 87 | </p> |
tests/snapshots/oracle__oracle_core.snap +9 −11
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 51/58 skeleton lines (87.9%) | 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 | <p> | 10 | <p> |
| 9 | "Intro paragraph with a bare URL" | 11 | "Intro paragraph with a bare URL" |
| @@ -25,14 +27,11 @@ agreement: 51/58 skeleton lines (87.9%) | |||
| 25 | </li> | 27 | </li> |
| 26 | <li> | 28 | <li> |
| 27 | "second item with" | 29 | "second item with" |
| 28 | - <em> | 30 | ~ semantic-emphasis (1 line(s)) |
| 29 | + <i> | ||
| 30 | "emphasis" | 31 | "emphasis" |
| 31 | - </em> | 32 | ~ semantic-emphasis (1 line(s)) |
| 32 | + </i> | ||
| 33 | </li> | 33 | </li> |
| 34 | - </ol> | 34 | ~ list-per-bullet-type (2 line(s)) |
| 35 | - <ul> | ||
| 36 | <li> | 35 | <li> |
| 37 | <code> | 36 | <code> |
| 38 | "[ ]" | 37 | "[ ]" |
| @@ -45,8 +44,7 @@ agreement: 51/58 skeleton lines (87.9%) | |||
| 45 | </code> | 44 | </code> |
| 46 | "done item" | 45 | "done item" |
| 47 | </li> | 46 | </li> |
| 48 | - </ul> | 47 | ~ list-per-bullet-type (1 line(s)) |
| 49 | + </ol> | ||
| 50 | <h2> | 48 | <h2> |
| 51 | "Links and code" | 49 | "Links and code" |
| 52 | </h2> | 50 | </h2> |
| @@ -62,7 +60,7 @@ agreement: 51/58 skeleton lines (87.9%) | |||
| 62 | "." | 60 | "." |
| 63 | </p> | 61 | </p> |
| 64 | <pre> | 62 | <pre> |
| 65 | - <code> | 63 | ~ pre-code (1 line(s)) |
| 66 | "fn main() { println!(\"hello\"); }" | 64 | "fn main() { println!(\"hello\"); }" |
| 67 | - </code> | 65 | ~ pre-code (1 line(s)) |
| 68 | </pre> | 66 | </pre> |
tests/snapshots/oracle__oracle_elements.snap +10 −33
| @@ -3,21 +3,20 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 64/82 skeleton lines (78.0%) | 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 | <h2> | 10 | <h2> |
| 9 | "Code and tables" | 11 | "Code and tables" |
| 10 | </h2> | 12 | </h2> |
| 11 | <pre> | 13 | <pre> |
| 12 | - <code> | 14 | ~ pre-code (1 line(s)) |
| 13 | "fn main() { println!(\"hello\"); }" | 15 | "fn main() { println!(\"hello\"); }" |
| 14 | - </code> | 16 | ~ pre-code (1 line(s)) |
| 15 | </pre> | 17 | </pre> |
| 16 | <table> | 18 | <table> |
| 17 | + <colgroup> | 19 | ~ no-colgroup (4 line(s)) |
| 18 | + <col> | ||
| 19 | + <col> | ||
| 20 | + </colgroup> | ||
| 21 | <thead> | 20 | <thead> |
| 22 | <tr> | 21 | <tr> |
| 23 | <th> | 22 | <th> |
| @@ -55,18 +54,12 @@ agreement: 64/82 skeleton lines (78.0%) | |||
| 55 | <a href="https://example.com"> | 54 | <a href="https://example.com"> |
| 56 | "Example" | 55 | "Example" |
| 57 | </a> | 56 | </a> |
| 58 | - "and an id link" | 57 | ~ id-link-resolution (5 line(s)) |
| 59 | - <a href="#abc-123"> | ||
| 60 | - "abc-123" | ||
| 61 | - </a> | ||
| 62 | - "." | ||
| 63 | + "and an id link ." | ||
| 64 | </p> | 58 | </p> |
| 65 | <p> | 59 | <p> |
| 66 | "Text with a footnote reference." | 60 | "Text with a footnote reference." |
| 67 | <sup> | 61 | <sup> |
| 68 | - <a href="#fn-1"> | 62 | ~ footnote-anchor-naming (1 line(s)) |
| 69 | + <a href="#fn.1"> | ||
| 70 | "1" | 63 | "1" |
| 71 | </a> | 64 | </a> |
| 72 | </sup> | 65 | </sup> |
| @@ -80,24 +73,8 @@ agreement: 64/82 skeleton lines (78.0%) | |||
| 80 | </p> | 73 | </p> |
| 81 | </blockquote> | 74 | </blockquote> |
| 82 | <hr> | 75 | <hr> |
| 83 | - <section> | 76 | ~ footnote-section-shape (8 line(s)) |
| 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> | ||
| 95 | <p> | 77 | <p> |
| 96 | "The footnote definition." | 78 | "The footnote definition." |
| 97 | </p> | 79 | </p> |
| 98 | - <a href="#fnr-1"> | 80 | ~ footnote-section-shape (6 line(s)) |
| 99 | - "↩" | ||
| 100 | - </a> | ||
| 101 | - </li> | ||
| 102 | - </ol> | ||
| 103 | - </section> | ||
tests/snapshots/oracle__oracle_footnote.snap +14 −47
| @@ -3,20 +3,20 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 30/53 skeleton lines (56.6%) | 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 | <p> | 10 | <p> |
| 9 | "Text with a reference." | 11 | "Text with a reference." |
| 10 | <sup> | 12 | <sup> |
| 11 | - <a href="#fn-1"> | 13 | ~ footnote-anchor-naming (1 line(s)) |
| 12 | + <a href="#fn.1"> | ||
| 13 | "1" | 14 | "1" |
| 14 | </a> | 15 | </a> |
| 15 | </sup> | 16 | </sup> |
| 16 | "And a second one." | 17 | "And a second one." |
| 17 | <sup> | 18 | <sup> |
| 18 | - <a href="#fn-2"> | 19 | ~ footnote-anchor-naming (1 line(s)) |
| 19 | + <a href="#fn.2"> | ||
| 20 | "2" | 20 | "2" |
| 21 | </a> | 21 | </a> |
| 22 | </sup> | 22 | </sup> |
| @@ -24,60 +24,27 @@ agreement: 30/53 skeleton lines (56.6%) | |||
| 24 | <p> | 24 | <p> |
| 25 | "An inline footnote." | 25 | "An inline footnote." |
| 26 | <sup> | 26 | <sup> |
| 27 | - <a href="#fn-3"> | 27 | ~ footnote-anchor-naming (1 line(s)) |
| 28 | + <a href="#fn.3"> | ||
| 29 | "3" | 28 | "3" |
| 30 | </a> | 29 | </a> |
| 31 | </sup> | 30 | </sup> |
| 32 | </p> | 31 | </p> |
| 33 | - <section> | 32 | ~ footnote-section-shape (8 line(s)) |
| 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> | ||
| 45 | <p> | 33 | <p> |
| 46 | "The first definition." | 34 | "The first definition." |
| 47 | </p> | 35 | </p> |
| 48 | - <a href="#fnr-1"> | 36 | ~ footnote-section-shape (3 line(s)) |
| 49 | - "↩" | ||
| 50 | + <sup> | ||
| 51 | + <a href="#fnr.2"> | ||
| 52 | + "2" | ||
| 53 | </a> | 37 | </a> |
| 54 | - </li> | 38 | ~ footnote-section-shape (2 line(s)) |
| 55 | - <li> | ||
| 56 | + </sup> | ||
| 57 | <p> | 39 | <p> |
| 58 | "The second definition, with" | 40 | "The second definition, with" |
| 59 | - <em> | 41 | ~ semantic-emphasis (1 line(s)) |
| 60 | + <i> | ||
| 61 | "emphasis" | 42 | "emphasis" |
| 62 | - </em> | 43 | ~ semantic-emphasis (1 line(s)) |
| 63 | + </i> | ||
| 64 | "." | 44 | "." |
| 65 | </p> | 45 | </p> |
| 66 | - <a href="#fnr-2"> | 46 | ~ footnote-section-shape (3 line(s)) |
| 67 | - "↩" | ||
| 68 | + <sup> | ||
| 69 | + <a href="#fnr.3"> | ||
| 70 | + "3" | ||
| 71 | </a> | 47 | </a> |
| 72 | - </li> | 48 | ~ footnote-section-shape (2 line(s)) |
| 73 | - <li> | ||
| 74 | + </sup> | ||
| 75 | + <p> | ||
| 76 | "defined right here" | 49 | "defined right here" |
| 77 | - <a href="#fnr-3"> | 50 | ~ footnote-section-shape (6 line(s)) |
| 78 | - "↩" | ||
| 79 | - </a> | ||
| 80 | - </li> | ||
| 81 | - </ol> | ||
| 82 | - </section> | ||
| 83 | + </p> | ||
tests/snapshots/oracle__oracle_headings.snap +5 −5
| @@ -3,11 +3,12 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 28/30 skeleton lines (93.3%) | 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 | <h2> | 10 | <h2> |
| 9 | - "TODO [#A] Write the parser work rust" | 11 | ~ semantic-time (1 line(s)) |
| 10 | + "TODO Write the parser work rust" | ||
| 11 | </h2> | 12 | </h2> |
| 12 | <p> | 13 | <p> |
| 13 | "A heading carrying a keyword, a priority, tags and a property drawer." | 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 | "Sub-headings nest by star count." | 20 | "Sub-headings nest by star count." |
| 20 | </p> | 21 | </p> |
| 21 | <h3> | 22 | <h3> |
| 22 | - "[#C] Priority without a keyword" | 23 | ~ semantic-time (1 line(s)) |
| 23 | + "Priority without a keyword" | ||
| 24 | </h3> | 24 | </h3> |
| 25 | <p> | 25 | <p> |
| 26 | "A priority cookie can stand alone." | 26 | "A priority cookie can stand alone." |
tests/snapshots/oracle__oracle_images.snap +13 −25
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 30/42 skeleton lines (71.4%) | 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 | <h2> | 10 | <h2> |
| 9 | "Bare image" | 11 | "Bare image" |
| @@ -14,43 +16,29 @@ agreement: 30/42 skeleton lines (71.4%) | |||
| 14 | <h2> | 16 | <h2> |
| 15 | "Captioned figure" | 17 | "Captioned figure" |
| 16 | </h2> | 18 | </h2> |
| 17 | - <figure> | 19 | ~ figure-element (1 line(s)) |
| 18 | + <p> | ||
| 19 | <img src="pipeline.svg"> | 20 | <img src="pipeline.svg"> |
| 20 | - <figcaption> | 21 | ~ figure-element (2 line(s)) |
| 21 | + </p> | ||
| 22 | + <p> | ||
| 23 | "Figure 1: The pipeline, end to end" | 22 | "Figure 1: The pipeline, end to end" |
| 24 | - </figcaption> | 23 | ~ figure-element (2 line(s)) |
| 25 | - </figure> | ||
| 26 | + </p> | ||
| 27 | <h2> | 24 | <h2> |
| 28 | "Caption with markup" | 25 | "Caption with markup" |
| 29 | </h2> | 26 | </h2> |
| 30 | - <figure> | 27 | ~ figure-element (1 line(s)) |
| 31 | + <p> | ||
| 32 | <img src="chart.png"> | 28 | <img src="chart.png"> |
| 33 | - <figcaption> | 29 | ~ figure-element (2 line(s)) |
| 34 | + </p> | ||
| 35 | + <p> | ||
| 36 | "Figure 2: A" | 30 | "Figure 2: A" |
| 37 | - <em> | 31 | ~ semantic-emphasis (1 line(s)) |
| 38 | + <i> | ||
| 39 | "stylised" | 32 | "stylised" |
| 40 | - </em> | 33 | ~ semantic-emphasis (1 line(s)) |
| 41 | + </i> | ||
| 42 | "chart" | 34 | "chart" |
| 43 | - </figcaption> | 35 | ~ figure-element (2 line(s)) |
| 44 | - </figure> | ||
| 45 | + </p> | ||
| 46 | <h2> | 36 | <h2> |
| 47 | "Quoted attribute values" | 37 | "Quoted attribute values" |
| 48 | </h2> | 38 | </h2> |
| 49 | - <figure> | 39 | ~ figure-element (1 line(s)) |
| 50 | + <p> | ||
| 51 | <img src="cat.jpg"> | 40 | <img src="cat.jpg"> |
| 52 | - </figure> | 41 | ~ figure-element (1 line(s)) |
| 53 | + </p> | ||
| 54 | <h2> | 42 | <h2> |
| 55 | "Image with a description is a link" | 43 | "Image with a description is a link" |
| 56 | </h2> | 44 | </h2> |
tests/snapshots/oracle__oracle_lists.snap +5 −5
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 109/111 skeleton lines (98.2%) | 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 | <h2> | 10 | <h2> |
| 9 | "Nesting" | 11 | "Nesting" |
| @@ -91,11 +93,9 @@ agreement: 109/111 skeleton lines (98.2%) | |||
| 91 | "the second definition, which is soft-wrapped across two lines" | 93 | "the second definition, which is soft-wrapped across two lines" |
| 92 | </dd> | 94 | </dd> |
| 93 | <dt> | 95 | <dt> |
| 94 | - <em> | 96 | ~ semantic-emphasis (1 line(s)) |
| 95 | + <i> | ||
| 96 | "marked up" | 97 | "marked up" |
| 97 | - </em> | 98 | ~ semantic-emphasis (1 line(s)) |
| 98 | + </i> | ||
| 99 | "term" | 99 | "term" |
| 100 | </dt> | 100 | </dt> |
| 101 | <dd> | 101 | <dd> |
tests/snapshots/oracle__oracle_minimal.snap +7 −9
| @@ -3,7 +3,9 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 38/42 skeleton lines (90.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 | <p> | 10 | <p> |
| 9 | "A single paragraph of preamble text before any heading." | 11 | "A single paragraph of preamble text before any heading." |
| @@ -13,17 +15,13 @@ agreement: 38/42 skeleton lines (90.5%) | |||
| 13 | </h2> | 15 | </h2> |
| 14 | <p> | 16 | <p> |
| 15 | "Some body text with" | 17 | "Some body text with" |
| 16 | - <strong> | 18 | ~ semantic-emphasis (1 line(s)) |
| 17 | + <b> | ||
| 18 | "bold" | 19 | "bold" |
| 19 | - </strong> | 20 | ~ semantic-emphasis (1 line(s)) |
| 20 | + </b> | ||
| 21 | "," | 21 | "," |
| 22 | - <em> | 22 | ~ semantic-emphasis (1 line(s)) |
| 23 | + <i> | ||
| 24 | "italic" | 23 | "italic" |
| 25 | - </em> | 24 | ~ semantic-emphasis (1 line(s)) |
| 26 | + </i> | ||
| 27 | ", and" | 25 | ", and" |
| 28 | <code> | 26 | <code> |
| 29 | "verbatim" | 27 | "verbatim" |
tests/snapshots/oracle__oracle_table.snap +4 −5
| @@ -3,13 +3,12 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 30/34 skeleton lines (88.2%) | 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 | <table> | 10 | <table> |
| 9 | + <colgroup> | 11 | ~ no-colgroup (4 line(s)) |
| 10 | + <col> | ||
| 11 | + <col> | ||
| 12 | + </colgroup> | ||
| 13 | <thead> | 12 | <thead> |
| 14 | <tr> | 13 | <tr> |
| 15 | <th> | 14 | <th> |
tests/snapshots/oracle__oracle_timestamps.snap +8 −43
| @@ -3,68 +3,33 @@ source: tests/oracle.rs | |||
| 3 | expression: report | 3 | expression: report |
| 4 | --- | 4 | --- |
| 5 | agreement: 25/62 skeleton lines (40.3%) | 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 | <h2> | 10 | <h2> |
| 9 | "Single" | 11 | "Single" |
| 10 | </h2> | 12 | </h2> |
| 11 | <p> | 13 | <p> |
| 12 | - "An active date" | 14 | ~ semantic-time (9 line(s)) |
| 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]." | ||
| 22 | </p> | 15 | </p> |
| 23 | <p> | 16 | <p> |
| 24 | - "With a time:" | 17 | ~ semantic-time (5 line(s)) |
| 25 | - <time> | ||
| 26 | - "2024-01-15 10:30" | ||
| 27 | - </time> | ||
| 28 | - "." | ||
| 29 | + "With a time: <2024-01-15 Mon 10:30>." | ||
| 30 | </p> | 18 | </p> |
| 31 | <h2> | 19 | <h2> |
| 32 | "Ranges" | 20 | "Ranges" |
| 33 | </h2> | 21 | </h2> |
| 34 | <p> | 22 | <p> |
| 35 | - "A same-day time range" | 23 | ~ semantic-time (9 line(s)) |
| 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>." | ||
| 45 | </p> | 24 | </p> |
| 46 | <p> | 25 | <p> |
| 47 | - "A multi-day range" | 26 | ~ semantic-time (9 line(s)) |
| 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>." | ||
| 57 | </p> | 27 | </p> |
| 58 | <h2> | 28 | <h2> |
| 59 | "Ignored decorations" | 29 | "Ignored decorations" |
| 60 | </h2> | 30 | </h2> |
| 61 | <p> | 31 | <p> |
| 62 | - "A repeater is dropped:" | 32 | ~ semantic-time (5 line(s)) |
| 63 | - <time> | ||
| 64 | - "2024-01-15" | ||
| 65 | - </time> | ||
| 66 | - "." | ||
| 67 | + "A repeater is dropped: <2024-01-15 Mon +1w>." | ||
| 68 | </p> | 33 | </p> |
| 69 | <h2> | 34 | <h2> |
| 70 | "Not timestamps" | 35 | "Not timestamps" |