Commit 183c977692
Verified · cmc
Layout: unified · split
README.md +12 −9
| @@ -327,17 +327,20 @@ is the only inherently global stage — it is where the link dependency graph is | ||
| 327 | 327 | |
| 328 | 328 | ## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit) |
| 329 | 329 | |
| 330 | **IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags; | |
| 331 | property drawers; plain lists (unordered/ordered/description, checkboxes, nesting); | |
| 332 | tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting; | |
| 333 | example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`, | |
| 334 | `id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup | |
| 335 | (bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges); | |
| 336 | paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`. | |
| 330 | **IN — v1 must handle:** headings with nesting, at levels relative to the document's | |
| 331 | shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists | |
| 332 | (unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule | |
| 333 | 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, | |
| 335 | internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` | |
| 336 | keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's | |
| 337 | export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps | |
| 338 | (active/inactive, ranges); paragraphs and horizontal rules; images with | |
| 339 | `#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. | |
| 337 | 340 | |
| 338 | 341 | **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / |
| 339 | `:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and | |
| 340 | macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda | |
| 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 | |
| 341 | 344 | semantics; non-HTML export blocks; the full Unicode entity set. |
| 342 | 345 | |
| 343 | 346 | **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test |
docs/guide/05-org-support.org +44 −5
| @@ -35,6 +35,33 @@ every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=, | ||
| 35 | 35 | Timestamps, active and inactive, with times and ranges, render as =<time>= with a |
| 36 | 36 | machine-readable =datetime=. |
| 37 | 37 | |
| 38 | *** Text conversions | |
| 39 | ||
| 40 | Org rewrites some prose on export, and so does org-ssg: | |
| 41 | ||
| 42 | | Written | Published | | |
| 43 | |---------+-----------| | |
| 44 | | =--= | – | | |
| 45 | | =---= | — | | |
| 46 | | =...= | … | | |
| 47 | | =x^2= | x superscript 2 | | |
| 48 | | =H_{2}O= | H subscript 2 O | | |
| 49 | ||
| 50 | 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. | |
| 52 | ||
| 53 | *Braceless subscripts catch people out.* Org's default converts =a_b=, so =snake_case= in | |
| 54 | prose publishes as snake with a subscript. That is what Emacs does with the same file. Turn | |
| 55 | it off per document with =#+OPTIONS: ^:nil=, restrict it to the braced form with =^:{}=, or | |
| 56 | set =[html] sub_superscript= for the site. =#+OPTIONS: -:nil= turns off the dashes and | |
| 57 | ellipsis. | |
| 58 | ||
| 59 | *** Heading levels are relative | |
| 60 | ||
| 61 | A file whose shallowest heading is =**= is a file of top-level sections that happen to be | |
| 62 | indented, not a file of subsections — org exports levels relative to the document, so that | |
| 63 | subtree exports the same whether it was cut from a larger file or written on its own. | |
| 64 | ||
| 38 | 65 | ** Lists |
| 39 | 66 | |
| 40 | 67 | Unordered, ordered and description lists, nested by indentation, with checkboxes and |
| @@ -44,18 +71,25 @@ multi-paragraph items: | ||
| 44 | 71 | - outer item |
| 45 | 72 | - inner item |
| 46 | 73 | - [X] a checked item |
| 74 | - [-] a partly-done item | |
| 47 | 75 | - term :: definition |
| 76 | 1. [@4] an item numbered from 4 | |
| 48 | 77 | #+END_SRC |
| 49 | 78 | |
| 79 | Checkboxes render as org writes them — =<code>[X]</code>= with the state as a class on the | |
| 80 | item — rather than as a disabled =<input>=, which has no way to say "partly done". | |
| 81 | ||
| 50 | 82 | ** Blocks |
| 51 | 83 | |
| 52 | =SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block | |
| 53 | inside a quote block works, because block ends match their own kind. | |
| 84 | =SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER=, =VERSE= and =EXPORT=. A source | |
| 85 | block inside a quote block works, because block ends match their own kind. | |
| 54 | 86 | |
| 55 | 87 | An =html= export block passes through verbatim; every other backend is dropped, because |
| 56 | 88 | emitting LaTeX into an HTML page is worse than emitting nothing. |
| 57 | 89 | |
| 58 | An unknown block type keeps its content as an example block rather than vanishing. | |
| 90 | *Any other name is a special block*: =#+BEGIN_NOTE= becomes =<div class="note">= holding | |
| 91 | *parsed org*, which is what makes the convention usable without org-ssg knowing the word | |
| 92 | "note". A =COMMENT= block is not published. | |
| 59 | 93 | |
| 60 | 94 | *** Which languages highlight |
| 61 | 95 | |
| @@ -89,7 +123,12 @@ in Emacs as much as here. If a code block seems to stop early, that is why. | ||
| 89 | 123 | |
| 90 | 124 | ** Tables and footnotes |
| 91 | 125 | |
| 92 | Pipe tables, with the rule row establishing a header band. Footnotes in all three forms — | |
| 126 | Pipe tables, with the rule row establishing a header band. Org's *special column* is | |
| 127 | honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is | |
| 128 | dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than | |
| 129 | content, so they never reach the page. | |
| 130 | ||
| 131 | Footnotes in all three forms — | |
| 93 | 132 | =[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a |
| 94 | 133 | numbered, back-linked notes section. |
| 95 | 134 | |
| @@ -120,7 +159,7 @@ broken. | ||
| 120 | 159 | | =#+DRAFT:= | Keeps the page out of the build. | |
| 121 | 160 | | =#+TEMPLATE:= | The layout this page renders through. | |
| 122 | 161 | | =#+OPTIONS:= | Per-file export switches. | |
| 123 | | =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. | | |
| 162 | | =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image *directly* below them — a blank line in between attaches to nothing, as in org. A captioned image is numbered =Figure N:=. | | |
| 124 | 163 | |
| 125 | 164 | Every other =#+KEYWORD:= is available to templates as |
| 126 | 165 | ={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches |
fixtures/blocks.org +13
| @@ -51,3 +51,16 @@ A quote containing a source block: | ||
| 51 | 51 | echo hi |
| 52 | 52 | #+END_SRC |
| 53 | 53 | #+END_QUOTE |
| 54 | ||
| 55 | * Verse | |
| 56 | ||
| 57 | #+BEGIN_VERSE | |
| 58 | Line breaks are the point | |
| 59 | and indentation survives. | |
| 60 | #+END_VERSE | |
| 61 | ||
| 62 | * A named special block | |
| 63 | ||
| 64 | #+BEGIN_NOTE | |
| 65 | Contents are *org*, not literal text. | |
| 66 | #+END_NOTE | |
fixtures/outofscope.org −7
| @@ -43,13 +43,6 @@ CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00] => 1:00 | ||
| 43 | 43 | Drawer contents are captured and dropped. |
| 44 | 44 | :END: |
| 45 | 45 | |
| 46 | * Verse | |
| 47 | ||
| 48 | #+BEGIN_VERSE | |
| 49 | An unmodelled block type | |
| 50 | keeps its content verbatim. | |
| 51 | #+END_VERSE | |
| 52 | ||
| 53 | 46 | * Entities |
| 54 | 47 | |
| 55 | 48 | The full entity set is out of scope, so \alpha stays literal. |
src/audit.rs +6 −1
| @@ -78,7 +78,12 @@ const KNOWN_KEYWORDS: &[&str] = &[ | ||
| 78 | 78 | "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO", |
| 79 | 79 | "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS", |
| 80 | 80 | ]; |
| 81 | const KNOWN_BLOCKS: &[&str] = &["SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT"]; | |
| 81 | /// Blocks with dedicated handling. Any *other* name renders as a special block — a div | |
| 82 | /// with that name holding parsed org — so an unlisted block is a note about what a corpus | |
| 83 | /// contains rather than a construct that will be lost. | |
| 84 | const KNOWN_BLOCKS: &[&str] = &[ | |
| 85 | "SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT", "VERSE", "COMMENT", | |
| 86 | ]; | |
| 82 | 87 | const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"]; |
| 83 | 88 | /// Keyword names conventional enough to be worth flagging when they lead a heading. |
| 84 | 89 | /// A custom sequence is only *real* if some `#+TODO:` declares it, which the census |
src/config.rs +47
| @@ -227,6 +227,44 @@ pub struct HtmlOutput { | ||
| 227 | 227 | /// do not want them, so the default is the taste rather than the inheritance; |
| 228 | 228 | /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back. |
| 229 | 229 | pub section_numbers: bool, |
| 230 | /// Convert org's special strings in prose: `--` to an en dash, `---` to an em dash, | |
| 231 | /// `...` to an ellipsis. | |
| 232 | /// | |
| 233 | /// On, as in Emacs. A document turns it off for itself with `#+OPTIONS: -:nil`. | |
| 234 | /// Never applied inside verbatim, code, or a source block. | |
| 235 | pub special_strings: bool, | |
| 236 | /// Whether `x^2` and `H_{2}O` become `<sup>`/`<sub>`. | |
| 237 | /// | |
| 238 | /// `"yes"` (the default, and Emacs') also treats the braceless `a_b` as a subscript, | |
| 239 | /// which is what makes `snake_case` in prose render as `snake<sub>case</sub>` — | |
| 240 | /// surprising, but what Emacs does with the same file. `"braces"` limits it to the | |
| 241 | /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself | |
| 242 | /// with `#+OPTIONS: ^:nil` or `^:{}`. | |
| 243 | pub sub_superscript: SubSuperscript, | |
| 244 | } | |
| 245 | ||
| 246 | /// How `_` and `^` are treated in prose. Mirrors org's `^:` export option. | |
| 247 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 248 | #[serde(rename_all = "kebab-case")] | |
| 249 | pub enum SubSuperscript { | |
| 250 | /// `a_b` and `a_{b}` both convert. | |
| 251 | #[default] | |
| 252 | Yes, | |
| 253 | /// Only the braced `a_{b}` converts. | |
| 254 | Braces, | |
| 255 | /// Neither converts. | |
| 256 | No, | |
| 257 | } | |
| 258 | ||
| 259 | impl SubSuperscript { | |
| 260 | /// Read org's `^:` option value: `nil` is off, `{}` is braces-only, anything else on. | |
| 261 | pub fn from_option(value: &str) -> SubSuperscript { | |
| 262 | match value.trim() { | |
| 263 | "nil" | "false" | "no" | "off" => SubSuperscript::No, | |
| 264 | "{}" => SubSuperscript::Braces, | |
| 265 | _ => SubSuperscript::Yes, | |
| 266 | } | |
| 267 | } | |
| 230 | 268 | } |
| 231 | 269 | |
| 232 | 270 | impl Default for HtmlOutput { |
| @@ -235,6 +273,8 @@ impl Default for HtmlOutput { | ||
| 235 | 273 | heading_offset: 1, |
| 236 | 274 | toc: true, |
| 237 | 275 | section_numbers: false, |
| 276 | special_strings: true, | |
| 277 | sub_superscript: SubSuperscript::Yes, | |
| 238 | 278 | } |
| 239 | 279 | } |
| 240 | 280 | } |
| @@ -530,6 +570,13 @@ toc = true | ||
| 530 | 570 | # Number headings (1., 1.1., …). Emacs defaults this on; most sites do not. |
| 531 | 571 | # A document overrides with `#+OPTIONS: num:t`. |
| 532 | 572 | section_numbers = false |
| 573 | # Convert `--` to an en dash, `---` to an em dash and `...` to an ellipsis in prose, as | |
| 574 | # Emacs does. Never inside code. A document overrides with `#+OPTIONS: -:nil`. | |
| 575 | special_strings = true | |
| 576 | # Whether `x^2` and `H_{2}O` become <sup>/<sub>: "yes" (as Emacs, and so `snake_case` | |
| 577 | # becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no". | |
| 578 | # A document overrides with `#+OPTIONS: ^:nil` or `^:{}`. | |
| 579 | sub_superscript = "yes" | |
| 533 | 580 | |
| 534 | 581 | # Generated listing pages: output files with no source .org behind them. Repeat the |
| 535 | 582 | # [[collections]] block for each one. A feed is the same thing with an XML template. |
src/incremental.rs +1 −1
| @@ -29,7 +29,7 @@ use crate::util::output_url; | ||
| 29 | 29 | /// Bump whenever the `Document` type, hashing scheme, or resolution rules change. |
| 30 | 30 | /// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major |
| 31 | 31 | /// version is folded in as the "hash-algo version" so a hash upgrade also busts. |
| 32 | pub const CACHE_FORMAT_VERSION: u32 = 5; | |
| 32 | pub const CACHE_FORMAT_VERSION: u32 = 6; | |
| 33 | 33 | |
| 34 | 34 | /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). |
| 35 | 35 | pub type Hash = ContentHash; |
src/model.rs +10
| @@ -112,6 +112,14 @@ pub enum Element { | ||
| 112 | 112 | }, |
| 113 | 113 | QuoteBlock(Vec<Element>), |
| 114 | 114 | CenterBlock(Vec<Element>), |
| 115 | /// `#+BEGIN_<name>` for any other name — `note`, `warning`, `aside`. Its contents are | |
| 116 | /// org, not literal text, and the name becomes a class so a stylesheet can reach it. | |
| 117 | SpecialBlock { | |
| 118 | name: String, | |
| 119 | content: Vec<Element>, | |
| 120 | }, | |
| 121 | /// `#+BEGIN_VERSE`: line breaks are significant. | |
| 122 | VerseBlock(Vec<String>), | |
| 115 | 123 | /// html passes through; others dropped at render (spec §1 OUT). |
| 116 | 124 | ExportBlock { |
| 117 | 125 | backend: String, |
| @@ -157,6 +165,8 @@ pub enum ListKind { | ||
| 157 | 165 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 158 | 166 | pub struct ListItem { |
| 159 | 167 | pub bullet: Bullet, |
| 168 | /// `[@4]` — an explicit number for this item, restarting the list's counting. | |
| 169 | pub counter: Option<u32>, | |
| 160 | 170 | pub checkbox: Option<Checkbox>, |
| 161 | 171 | /// Description-list term before `::`. |
| 162 | 172 | pub term: Option<Vec<Object>>, |
src/parser.rs +48 −6
| @@ -398,6 +398,11 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V | ||
| 398 | 398 | while i < lines.len() { |
| 399 | 399 | let line = lines[i]; |
| 400 | 400 | if line.trim().is_empty() { |
| 401 | // A blank line ends the association: an affiliated keyword belongs to the | |
| 402 | // element *immediately* below it. Someone who writes `#+CAPTION:` under their | |
| 403 | // image and then leaves a blank line has captioned nothing, and org agrees — | |
| 404 | // silently attaching it to whatever comes next would caption the wrong thing. | |
| 405 | affiliated.clear(); | |
| 401 | 406 | i += 1; |
| 402 | 407 | continue; |
| 403 | 408 | } |
| @@ -563,9 +568,17 @@ fn parse_block( | ||
| 563 | 568 | backend: after.split_whitespace().next().unwrap_or("").to_string(), |
| 564 | 569 | raw: inner.join("\n"), |
| 565 | 570 | }, |
| 566 | // Out-of-scope block types (verse, comment, ascii, custom) degrade to a verbatim | |
| 567 | // example block: content preserved, no crash. | |
| 568 | _ => Element::ExampleBlock(inner.join("\n")), | |
| 571 | // Verse keeps its line breaks; that is the whole point of it. | |
| 572 | "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()), | |
| 573 | // A comment block is not published, in org or here. | |
| 574 | "COMMENT" => Element::Comment(inner.join("\n")), | |
| 575 | // Any other name is a special block: a div with that class, holding org. Emacs | |
| 576 | // exports unknown block types this way, which is what makes `#+BEGIN_NOTE` a | |
| 577 | // usable convention without the exporter knowing the word "note". | |
| 578 | other => Element::SpecialBlock { | |
| 579 | name: other.to_ascii_lowercase(), | |
| 580 | content: parse_elements(&inner, base + start + 1, diags), | |
| 581 | }, | |
| 569 | 582 | }; |
| 570 | 583 | (element, next) |
| 571 | 584 | } |
| @@ -830,6 +843,8 @@ fn parse_list( | ||
| 830 | 843 | // Body = the text after the bullet, plus every following line indented past the |
| 831 | 844 | // bullet column (blank lines included, so an item can hold several paragraphs). |
| 832 | 845 | let rest = item_body(lines[j].trim_start(), &bullet); |
| 846 | // `[@4]` comes before the checkbox: `1. [@4] [X] done`. | |
| 847 | let (counter, rest) = split_counter(rest); | |
| 833 | 848 | let (checkbox, rest) = split_checkbox(rest); |
| 834 | 849 | let (term, rest) = match kind { |
| 835 | 850 | ListKind::Description => match split_term(rest) { |
| @@ -864,6 +879,7 @@ fn parse_list( | ||
| 864 | 879 | |
| 865 | 880 | items.push(ListItem { |
| 866 | 881 | bullet, |
| 882 | counter, | |
| 867 | 883 | checkbox, |
| 868 | 884 | term, |
| 869 | 885 | // The item body starts at the bullet line, so `base + j` is exact even after |
| @@ -949,6 +965,20 @@ fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str { | ||
| 949 | 965 | } |
| 950 | 966 | } |
| 951 | 967 | |
| 968 | /// Detect a leading `[@N]` counter on a list item, which sets its number explicitly. | |
| 969 | fn split_counter(text: &str) -> (Option<u32>, &str) { | |
| 970 | let Some(rest) = text.strip_prefix("[@") else { | |
| 971 | return (None, text); | |
| 972 | }; | |
| 973 | let Some(end) = rest.find(']') else { | |
| 974 | return (None, text); | |
| 975 | }; | |
| 976 | match rest[..end].parse::<u32>() { | |
| 977 | Ok(n) => (Some(n), rest[end + 1..].trim_start()), | |
| 978 | Err(_) => (None, text), | |
| 979 | } | |
| 980 | } | |
| 981 | ||
| 952 | 982 | /// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item. |
| 953 | 983 | fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) { |
| 954 | 984 | let bytes = text.as_bytes(); |
| @@ -1368,8 +1398,10 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | ||
| 1368 | 1398 | if i + 1 >= n { |
| 1369 | 1399 | return None; |
| 1370 | 1400 | } |
| 1371 | let after = chars[i + 1]; | |
| 1372 | if after.is_whitespace() || after == m { | |
| 1401 | // Org's body-character rule: the character after the opening marker may not be | |
| 1402 | // whitespace, a comma or a quote. It *may* be another marker, which is what makes | |
| 1403 | // `~~/.config/emacs~` verbatim for a path that starts with `~`. | |
| 1404 | if !body_char_ok(chars[i + 1]) { | |
| 1373 | 1405 | return None; |
| 1374 | 1406 | } |
| 1375 | 1407 | let mut j = i + 1; |
| @@ -1377,7 +1409,7 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | ||
| 1377 | 1409 | if chars[j] == m && j > i + 1 { |
| 1378 | 1410 | let before = chars[j - 1]; |
| 1379 | 1411 | let next = chars.get(j + 1).copied(); |
| 1380 | if !before.is_whitespace() && post_ok(next) { | |
| 1412 | if body_char_ok(before) && post_ok(next) { | |
| 1381 | 1413 | let inner = &chars[i + 1..j]; |
| 1382 | 1414 | let obj = match m { |
| 1383 | 1415 | '=' => Object::Verbatim(inner.iter().collect()), |
| @@ -1396,6 +1428,16 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | ||
| 1396 | 1428 | None |
| 1397 | 1429 | } |
| 1398 | 1430 | |
| 1431 | /// May this character sit directly inside an emphasis marker? | |
| 1432 | /// | |
| 1433 | /// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a | |
| 1434 | /// body, which is what makes `="proxied":false=` verbatim, and `=SPC m '=` may close on | |
| 1435 | /// an apostrophe. The marker character itself is allowed too, so `~~/.config/emacs~` is a | |
| 1436 | /// path that starts with a tilde. | |
| 1437 | fn body_char_ok(c: char) -> bool { | |
| 1438 | !c.is_whitespace() | |
| 1439 | } | |
| 1440 | ||
| 1399 | 1441 | fn boundary_before(chars: &[char], i: usize) -> bool { |
| 1400 | 1442 | if i == 0 { |
| 1401 | 1443 | return true; |
src/render.rs +348 −15
| @@ -23,10 +23,11 @@ use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGener | ||
| 23 | 23 | use syntect::parsing::SyntaxSet; |
| 24 | 24 | use syntect::util::LinesWithEndings; |
| 25 | 25 | |
| 26 | use crate::config::SubSuperscript; | |
| 26 | 27 | use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; |
| 27 | 28 | use crate::parser::is_image_target; |
| 28 | 29 | use crate::resolve::ResolvedDoc; |
| 29 | use crate::util::{heading_anchor, plain_text, slugify}; | |
| 30 | use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify}; | |
| 30 | 31 | |
| 31 | 32 | /// A rendered HTML fragment (content only — no page chrome; spec §2.4). |
| 32 | 33 | #[derive(Debug, Clone)] |
| @@ -211,6 +212,12 @@ struct Renderer<'a> { | ||
| 211 | 212 | order: Vec<String>, |
| 212 | 213 | /// Counter per heading depth, for section numbers. |
| 213 | 214 | counters: Vec<usize>, |
| 215 | /// Subtracted from every heading level so the document's shallowest heading renders | |
| 216 | /// as level 1. Org exports levels *relative* to a file's own outline, so a file | |
| 217 | /// written entirely under `**` is not a file of subsections. | |
| 218 | headline_offset: u8, | |
| 219 | /// Captioned figures seen so far, for `Figure N:`. | |
| 220 | figures: usize, | |
| 214 | 221 | } |
| 215 | 222 | |
| 216 | 223 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| @@ -224,6 +231,12 @@ pub struct RenderOptions { | ||
| 224 | 231 | /// Prefix headings with `1.`, `1.1.`, … See |
| 225 | 232 | /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers). |
| 226 | 233 | pub section_numbers: bool, |
| 234 | /// Convert `--`, `---` and `...` in prose. See | |
| 235 | /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings). | |
| 236 | pub special_strings: bool, | |
| 237 | /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See | |
| 238 | /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript). | |
| 239 | pub sub_superscript: SubSuperscript, | |
| 227 | 240 | } |
| 228 | 241 | |
| 229 | 242 | impl Default for RenderOptions { |
| @@ -232,10 +245,49 @@ impl Default for RenderOptions { | ||
| 232 | 245 | RenderOptions { |
| 233 | 246 | heading_offset: html.heading_offset, |
| 234 | 247 | section_numbers: html.section_numbers, |
| 248 | special_strings: html.special_strings, | |
| 249 | sub_superscript: html.sub_superscript, | |
| 235 | 250 | } |
| 236 | 251 | } |
| 237 | 252 | } |
| 238 | 253 | |
| 254 | /// The site's render options with the document's own `#+OPTIONS:` applied on top. | |
| 255 | /// | |
| 256 | /// Org's per-file switches are the author's override of a site-wide setting, and they | |
| 257 | /// belong here rather than at each call site — otherwise rendering one document two ways | |
| 258 | /// depends on which caller remembered to read its keywords. | |
| 259 | fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions { | |
| 260 | RenderOptions { | |
| 261 | heading_offset: opts.heading_offset, | |
| 262 | section_numbers: option_enabled(keywords, "num", opts.section_numbers), | |
| 263 | special_strings: option_enabled(keywords, "-", opts.special_strings), | |
| 264 | // `^:` has three values rather than two — `nil`, `{}` or on — so it is read | |
| 265 | // directly instead of through the boolean helper. | |
| 266 | sub_superscript: match export_options(keywords).get("^") { | |
| 267 | Some(value) => SubSuperscript::from_option(value), | |
| 268 | None => opts.sub_superscript, | |
| 269 | }, | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | /// How much to subtract from every heading level, so a document's shallowest heading | |
| 274 | /// renders as level 1. | |
| 275 | /// | |
| 276 | /// Org exports outline levels *relative to the file*: a document written entirely under | |
| 277 | /// `**` is a document of top-level sections that happen to be indented, not a document of | |
| 278 | /// subsections. Emacs computes this from the shallowest top-level headline, which is what | |
| 279 | /// makes the same subtree export identically whether it was cut from a larger file or | |
| 280 | /// written on its own. | |
| 281 | fn headline_offset(root: &Section) -> u8 { | |
| 282 | root.children | |
| 283 | .iter() | |
| 284 | .filter_map(|s| s.heading.as_ref()) | |
| 285 | .map(|h| h.level) | |
| 286 | .min() | |
| 287 | .map(|min| min.saturating_sub(1)) | |
| 288 | .unwrap_or(0) | |
| 289 | } | |
| 290 | ||
| 239 | 291 | /// Render a resolved document to an HTML fragment, with default options. |
| 240 | 292 | pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { |
| 241 | 293 | render_with(doc, highlighter, &RenderOptions::default()) |
| @@ -245,11 +297,13 @@ pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { | ||
| 245 | 297 | pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html { |
| 246 | 298 | let mut r = Renderer { |
| 247 | 299 | hl: highlighter, |
| 248 | opts: *opts, | |
| 300 | opts: document_options(&doc.document.keywords, opts), | |
| 249 | 301 | block_defs: HashMap::new(), |
| 250 | 302 | inline_defs: HashMap::new(), |
| 251 | 303 | order: Vec::new(), |
| 252 | 304 | counters: Vec::new(), |
| 305 | headline_offset: headline_offset(&doc.document.root), | |
| 306 | figures: 0, | |
| 253 | 307 | }; |
| 254 | 308 | r.collect_defs(&doc.document.root); |
| 255 | 309 | let mut out = String::new(); |
| @@ -269,7 +323,8 @@ impl Renderer<'_> { | ||
| 269 | 323 | |
| 270 | 324 | fn render_section(&mut self, section: &Section, out: &mut String) { |
| 271 | 325 | if let Some(h) = §ion.heading { |
| 272 | let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); | |
| 326 | let relative = h.level.saturating_sub(self.headline_offset).max(1); | |
| 327 | let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6); | |
| 273 | 328 | let anchor = heading_anchor(h); |
| 274 | 329 | // A heading with no title text has no meaningful slug; emit no `id` at all |
| 275 | 330 | // rather than a run of duplicate empty ones. |
| @@ -279,7 +334,7 @@ impl Renderer<'_> { | ||
| 279 | 334 | out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); |
| 280 | 335 | } |
| 281 | 336 | if self.opts.section_numbers { |
| 282 | let number = self.next_section_number(h.level); | |
| 337 | let number = self.next_section_number(relative); | |
| 283 | 338 | out.push_str(&format!( |
| 284 | 339 | "<span class=\"section-number-{}\">{number}</span> ", |
| 285 | 340 | level |
| @@ -377,12 +432,33 @@ impl Renderer<'_> { | ||
| 377 | 432 | out.push_str("<figure>"); |
| 378 | 433 | out.push_str(&image_tag(link, attrs, &plain_text(caption))); |
| 379 | 434 | if !caption.is_empty() { |
| 380 | out.push_str("<figcaption>"); | |
| 435 | self.figures += 1; | |
| 436 | out.push_str(&format!( | |
| 437 | "<figcaption><span class=\"figure-number\">Figure {}: </span>", | |
| 438 | self.figures | |
| 439 | )); | |
| 381 | 440 | self.render_objects(caption, out); |
| 382 | 441 | out.push_str("</figcaption>"); |
| 383 | 442 | } |
| 384 | 443 | out.push_str("</figure>\n"); |
| 385 | 444 | } |
| 445 | Element::SpecialBlock { name, content } => { | |
| 446 | out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name))); | |
| 447 | for child in content { | |
| 448 | self.render_element(child, out); | |
| 449 | } | |
| 450 | out.push_str("</div>\n"); | |
| 451 | } | |
| 452 | Element::VerseBlock(lines) => { | |
| 453 | out.push_str("<p class=\"verse\">\n"); | |
| 454 | for (i, line) in lines.iter().enumerate() { | |
| 455 | if i > 0 { | |
| 456 | out.push_str("<br>\n"); | |
| 457 | } | |
| 458 | self.render_objects(&crate::parser::inline(line), out); | |
| 459 | } | |
| 460 | out.push_str("\n</p>\n"); | |
| 461 | } | |
| 386 | 462 | Element::HorizontalRule => out.push_str("<hr>\n"), |
| 387 | 463 | // Definitions are emitted in the footnotes section, not inline. |
| 388 | 464 | Element::FootnoteDefinition { .. } => {} |
| @@ -414,16 +490,29 @@ impl Renderer<'_> { | ||
| 414 | 490 | }; |
| 415 | 491 | out.push_str(&format!("<{}>\n", tag)); |
| 416 | 492 | for item in &list.items { |
| 417 | out.push_str("<li>"); | |
| 493 | // Org writes a checkbox as literal text, which keeps the third state — `[-]`, | |
| 494 | // partially done — that a disabled <input> cannot express. The state goes on | |
| 495 | // the item, where it can style the whole line. | |
| 496 | let class = item.checkbox.as_ref().map(|cb| match cb { | |
| 497 | Checkbox::On => "on", | |
| 498 | Checkbox::Off => "off", | |
| 499 | Checkbox::Trans => "trans", | |
| 500 | }); | |
| 501 | out.push_str("<li"); | |
| 502 | if let Some(class) = class { | |
| 503 | out.push_str(&format!(" class=\"{class}\"")); | |
| 504 | } | |
| 505 | // `[@4]` restarts the numbering, and HTML says so with `value`. | |
| 506 | if let Some(n) = item.counter { | |
| 507 | out.push_str(&format!(" value=\"{n}\"")); | |
| 508 | } | |
| 509 | out.push('>'); | |
| 418 | 510 | if let Some(cb) = &item.checkbox { |
| 419 | out.push_str(&format!( | |
| 420 | "<input type=\"checkbox\" disabled{}> ", | |
| 421 | if matches!(cb, Checkbox::On) { | |
| 422 | " checked" | |
| 423 | } else { | |
| 424 | "" | |
| 425 | } | |
| 426 | )); | |
| 511 | out.push_str(match cb { | |
| 512 | Checkbox::On => "<code>[X]</code> ", | |
| 513 | Checkbox::Off => "<code>[ ]</code> ", | |
| 514 | Checkbox::Trans => "<code>[-]</code> ", | |
| 515 | }); | |
| 427 | 516 | } |
| 428 | 517 | self.render_item_content(&item.content, out); |
| 429 | 518 | out.push_str("</li>\n"); |
| @@ -454,6 +543,8 @@ impl Renderer<'_> { | ||
| 454 | 543 | |
| 455 | 544 | /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`. |
| 456 | 545 | fn render_table(&mut self, table: &crate::model::Table, out: &mut String) { |
| 546 | let table = strip_special_column(table); | |
| 547 | let table = &table; | |
| 457 | 548 | let rule_at = table |
| 458 | 549 | .rows |
| 459 | 550 | .iter() |
| @@ -502,6 +593,31 @@ impl Renderer<'_> { | ||
| 502 | 593 | out.push_str("</table>\n"); |
| 503 | 594 | } |
| 504 | 595 | |
| 596 | /// Plain text as HTML: escaped, then org's export-time text conversions. | |
| 597 | /// | |
| 598 | /// Both run on the *escaped* string so their output tags survive, and both are | |
| 599 | /// reachable only from [`Object::Text`] — verbatim, code and source blocks are | |
| 600 | /// different objects, which is what keeps `--verbose` in a shell transcript intact. | |
| 601 | fn text_html(&self, t: &str) -> String { | |
| 602 | let escaped = escape_html(t); | |
| 603 | let mut out = String::with_capacity(escaped.len()); | |
| 604 | // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a | |
| 605 | // superscript for us, and an em dash inside a formula is not what was meant. | |
| 606 | for (span, is_latex) in latex_split(&escaped) { | |
| 607 | if is_latex { | |
| 608 | out.push_str(span); | |
| 609 | continue; | |
| 610 | } | |
| 611 | let with_strings = if self.opts.special_strings { | |
| 612 | special_strings(span) | |
| 613 | } else { | |
| 614 | span.to_string() | |
| 615 | }; | |
| 616 | out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript)); | |
| 617 | } | |
| 618 | out | |
| 619 | } | |
| 620 | ||
| 505 | 621 | fn render_objects(&mut self, objs: &[Object], out: &mut String) { |
| 506 | 622 | for obj in objs { |
| 507 | 623 | self.render_object(obj, out); |
| @@ -510,7 +626,7 @@ impl Renderer<'_> { | ||
| 510 | 626 | |
| 511 | 627 | fn render_object(&mut self, obj: &Object, out: &mut String) { |
| 512 | 628 | match obj { |
| 513 | Object::Text(t) => out.push_str(&escape_html(t)), | |
| 629 | Object::Text(t) => out.push_str(&self.text_html(t)), | |
| 514 | 630 | Object::Bold(inner) => self.wrap(out, "strong", inner), |
| 515 | 631 | Object::Italic(inner) => self.wrap(out, "em", inner), |
| 516 | 632 | Object::Underline(inner) => self.wrap(out, "u", inner), |
| @@ -735,6 +851,223 @@ fn link_text(target: &LinkTarget) -> String { | ||
| 735 | 851 | } |
| 736 | 852 | } |
| 737 | 853 | |
| 854 | /// Drop org's special column and its marker rows. | |
| 855 | /// | |
| 856 | /// A table's first column may hold export markers rather than data — `/` marks a column | |
| 857 | /// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are | |
| 858 | /// instructions to org and never appear in the output; the column itself disappears when | |
| 859 | /// *every* row uses it that way, which is what stops a table of formulas from publishing | |
| 860 | /// with a stray column of hashes. | |
| 861 | fn strip_special_column(table: &crate::model::Table) -> crate::model::Table { | |
| 862 | let first_cell = |row: &TableRow| -> Option<String> { | |
| 863 | match row { | |
| 864 | TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()), | |
| 865 | TableRow::Rule => None, | |
| 866 | } | |
| 867 | }; | |
| 868 | let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_))); | |
| 869 | let column_is_special = data_rows().count() > 0 | |
| 870 | && data_rows().all(|row| { | |
| 871 | matches!( | |
| 872 | first_cell(row).as_deref(), | |
| 873 | Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*") | |
| 874 | ) | |
| 875 | }); | |
| 876 | ||
| 877 | let rows = table | |
| 878 | .rows | |
| 879 | .iter() | |
| 880 | .filter(|row| { | |
| 881 | // A marker row is an instruction, not content. | |
| 882 | !matches!( | |
| 883 | first_cell(row).as_deref(), | |
| 884 | Some("/" | "!" | "^" | "_" | "$") | |
| 885 | ) | |
| 886 | }) | |
| 887 | .map(|row| match (row, column_is_special) { | |
| 888 | (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()), | |
| 889 | _ => row.clone(), | |
| 890 | }) | |
| 891 | .collect(); | |
| 892 | crate::model::Table { rows } | |
| 893 | } | |
| 894 | ||
| 895 | /// Split text into alternating prose and LaTeX spans, `(text, is_latex)`. | |
| 896 | /// | |
| 897 | /// org-ssg does not typeset LaTeX — it passes it through for MathJax or a reader's eyes — | |
| 898 | /// but it must know where a fragment *is*, because the export-time text conversions would | |
| 899 | /// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be | |
| 900 | /// marked up, and `--` inside one is a minus sign twice. | |
| 901 | fn latex_split(s: &str) -> Vec<(&str, bool)> { | |
| 902 | let bytes = s.as_bytes(); | |
| 903 | let mut spans = Vec::new(); | |
| 904 | let mut plain_from = 0; | |
| 905 | let mut i = 0; | |
| 906 | while i < s.len() { | |
| 907 | if !s.is_char_boundary(i) { | |
| 908 | i += 1; | |
| 909 | continue; | |
| 910 | } | |
| 911 | let end = match bytes[i] { | |
| 912 | b'\\' => latex_backslash_end(s, i), | |
| 913 | b'$' => latex_dollar_end(s, i), | |
| 914 | _ => None, | |
| 915 | }; | |
| 916 | if let Some(end) = end { | |
| 917 | if plain_from < i { | |
| 918 | spans.push((&s[plain_from..i], false)); | |
| 919 | } | |
| 920 | spans.push((&s[i..end], true)); | |
| 921 | plain_from = end; | |
| 922 | i = end; | |
| 923 | continue; | |
| 924 | } | |
| 925 | i += 1; | |
| 926 | } | |
| 927 | if plain_from < s.len() { | |
| 928 | spans.push((&s[plain_from..], false)); | |
| 929 | } | |
| 930 | spans | |
| 931 | } | |
| 932 | ||
| 933 | /// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`. | |
| 934 | fn latex_backslash_end(s: &str, i: usize) -> Option<usize> { | |
| 935 | let rest = &s[i..]; | |
| 936 | for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] { | |
| 937 | if let Some(body) = rest.strip_prefix(open) { | |
| 938 | return body.find(close).map(|k| i + open.len() + k + close.len()); | |
| 939 | } | |
| 940 | } | |
| 941 | let after = rest.strip_prefix("\\begin{")?; | |
| 942 | let name_end = after.find('}')?; | |
| 943 | let end_tag = format!("\\end{{{}}}", &after[..name_end]); | |
| 944 | let k = rest.find(&end_tag)?; | |
| 945 | Some(i + k + end_tag.len()) | |
| 946 | } | |
| 947 | ||
| 948 | /// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign. | |
| 949 | /// | |
| 950 | /// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6" | |
| 951 | /// out — the same heuristic org uses, and the same one that means a sentence with two | |
| 952 | /// unrelated dollar amounts and no space between them will be read as math. | |
| 953 | fn latex_dollar_end(s: &str, i: usize) -> Option<usize> { | |
| 954 | let body = &s[i + 1..]; | |
| 955 | let close = body.find('$')?; | |
| 956 | if close == 0 { | |
| 957 | return None; | |
| 958 | } | |
| 959 | let inner = &body[..close]; | |
| 960 | if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) { | |
| 961 | return None; | |
| 962 | } | |
| 963 | if inner.contains('\n') { | |
| 964 | return None; | |
| 965 | } | |
| 966 | Some(i + 1 + close + 1) | |
| 967 | } | |
| 968 | ||
| 969 | /// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis, | |
| 970 | /// and `\-` a soft hyphen. | |
| 971 | /// | |
| 972 | /// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the | |
| 973 | /// very end of a line, or the `-----` someone drew as a rule, is left alone. | |
| 974 | fn special_strings(s: &str) -> String { | |
| 975 | let chars: Vec<char> = s.chars().collect(); | |
| 976 | let mut out = String::with_capacity(s.len()); | |
| 977 | let mut i = 0; | |
| 978 | while i < chars.len() { | |
| 979 | let rest = &chars[i..]; | |
| 980 | let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None); | |
| 981 | if rest.starts_with(&['\\', '-']) { | |
| 982 | out.push('\u{00ad}'); | |
| 983 | i += 2; | |
| 984 | } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) { | |
| 985 | out.push('\u{2014}'); | |
| 986 | i += 3; | |
| 987 | } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) { | |
| 988 | out.push('\u{2013}'); | |
| 989 | i += 2; | |
| 990 | } else if rest.starts_with(&['.', '.', '.']) { | |
| 991 | out.push('\u{2026}'); | |
| 992 | i += 3; | |
| 993 | } else { | |
| 994 | out.push(chars[i]); | |
| 995 | i += 1; | |
| 996 | } | |
| 997 | } | |
| 998 | out | |
| 999 | } | |
| 1000 | ||
| 1001 | /// Org's `_` and `^` conversions: `H_{2}O`, `x^2`. | |
| 1002 | /// | |
| 1003 | /// Both require a non-whitespace character before the marker, which is what separates a | |
| 1004 | /// subscript from `_underlined text_` — the parser has already taken the emphasis, since | |
| 1005 | /// that form requires whitespace *before* the marker. | |
| 1006 | fn sub_superscript(s: &str, mode: SubSuperscript) -> String { | |
| 1007 | if mode == SubSuperscript::No || !s.contains(['_', '^']) { | |
| 1008 | return s.to_string(); | |
| 1009 | } | |
| 1010 | let chars: Vec<char> = s.chars().collect(); | |
| 1011 | let mut out = String::with_capacity(s.len()); | |
| 1012 | let mut i = 0; | |
| 1013 | while i < chars.len() { | |
| 1014 | let c = chars[i]; | |
| 1015 | let prev_ok = i > 0 && !chars[i - 1].is_whitespace(); | |
| 1016 | if (c == '_' || c == '^') && prev_ok { | |
| 1017 | if let Some((body, next)) = script_body(&chars, i + 1, mode) { | |
| 1018 | let tag = if c == '_' { "sub" } else { "sup" }; | |
| 1019 | out.push_str(&format!("<{tag}>{body}</{tag}>")); | |
| 1020 | i = next; | |
| 1021 | continue; | |
| 1022 | } | |
| 1023 | } | |
| 1024 | out.push(c); | |
| 1025 | i += 1; | |
| 1026 | } | |
| 1027 | out | |
| 1028 | } | |
| 1029 | ||
| 1030 | /// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required — | |
| 1031 | /// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not. | |
| 1032 | fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> { | |
| 1033 | if chars.get(start) == Some(&'{') { | |
| 1034 | let mut depth = 1; | |
| 1035 | let mut j = start + 1; | |
| 1036 | while j < chars.len() { | |
| 1037 | match chars[j] { | |
| 1038 | '{' => depth += 1, | |
| 1039 | '}' => { | |
| 1040 | depth -= 1; | |
| 1041 | if depth == 0 { | |
| 1042 | return Some((chars[start + 1..j].iter().collect(), j + 1)); | |
| 1043 | } | |
| 1044 | } | |
| 1045 | _ => {} | |
| 1046 | } | |
| 1047 | j += 1; | |
| 1048 | } | |
| 1049 | return None; | |
| 1050 | } | |
| 1051 | if mode == SubSuperscript::Braces { | |
| 1052 | return None; | |
| 1053 | } | |
| 1054 | let mut j = start; | |
| 1055 | if matches!(chars.get(j), Some('+') | Some('-')) { | |
| 1056 | j += 1; | |
| 1057 | } | |
| 1058 | let mut last_alnum = None; | |
| 1059 | while let Some(&c) = chars.get(j) { | |
| 1060 | if c.is_alphanumeric() { | |
| 1061 | last_alnum = Some(j); | |
| 1062 | } else if !matches!(c, '.' | ',' | '\\') { | |
| 1063 | break; | |
| 1064 | } | |
| 1065 | j += 1; | |
| 1066 | } | |
| 1067 | let end = last_alnum? + 1; | |
| 1068 | Some((chars[start..end].iter().collect(), end)) | |
| 1069 | } | |
| 1070 | ||
| 738 | 1071 | fn escape_html(s: &str) -> String { |
| 739 | 1072 | let mut out = String::with_capacity(s.len()); |
| 740 | 1073 | for c in s.chars() { |
src/site.rs +21 −9
| @@ -33,7 +33,8 @@ use crate::template::{ | ||
| 33 | 33 | Templater, |
| 34 | 34 | }; |
| 35 | 35 | use crate::util::{ |
| 36 | document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url, | |
| 36 | document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled, | |
| 37 | output_path, output_url, | |
| 37 | 38 | relative_root, slugify, table_of_contents, |
| 38 | 39 | }; |
| 39 | 40 | |
| @@ -239,7 +240,18 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 239 | 240 | // Undated pages sort last in the final order regardless of direction: a |
| 240 | 241 | // draft with no date should not lead an archive. |
| 241 | 242 | SortKey::Date => entries.sort_by(|a, b| { |
| 242 | let key = |p: &PageContext| p.date_iso.clone(); | |
| 243 | // Date *and* time: org records when a note was written, and two notes | |
| 244 | // from the same day have an order that the day alone cannot express. | |
| 245 | let key = |p: &PageContext| { | |
| 246 | p.date_iso.as_ref().map(|d| { | |
| 247 | let time = p | |
| 248 | .date | |
| 249 | .as_deref() | |
| 250 | .and_then(iso_time) | |
| 251 | .unwrap_or_else(|| "00:00:00".to_string()); | |
| 252 | format!("{d}T{time}") | |
| 253 | }) | |
| 254 | }; | |
| 243 | 255 | match (key(a), key(b)) { |
| 244 | 256 | (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)), |
| 245 | 257 | (Some(_), None) => std::cmp::Ordering::Greater, |
| @@ -750,12 +762,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 750 | 762 | Ok((pages, broken)) |
| 751 | 763 | } |
| 752 | 764 | |
| 753 | /// Render options for one page: the site's settings, with the document's own | |
| 754 | /// `#+OPTIONS:` switches applied on top. | |
| 755 | fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions { | |
| 765 | /// The site's render options. A document's own `#+OPTIONS:` switches are applied by the | |
| 766 | /// renderer, so every caller gets them. | |
| 767 | fn render_options(config: &Config) -> RenderOptions { | |
| 756 | 768 | RenderOptions { |
| 757 | 769 | heading_offset: config.html.heading_offset, |
| 758 | section_numbers: option_enabled(keywords, "num", config.html.section_numbers), | |
| 770 | section_numbers: config.html.section_numbers, | |
| 771 | special_strings: config.html.special_strings, | |
| 772 | sub_superscript: config.html.sub_superscript, | |
| 759 | 773 | } |
| 760 | 774 | } |
| 761 | 775 | |
| @@ -787,9 +801,7 @@ fn render_page( | ||
| 787 | 801 | config: &Config, |
| 788 | 802 | p: &PagePrep, |
| 789 | 803 | ) -> Result<String> { |
| 790 | // Options are resolved per page, because `#+OPTIONS:` is a per-document override of | |
| 791 | // the site setting. | |
| 792 | let opts = render_options(config, &p.resolved.document.keywords); | |
| 804 | let opts = render_options(config); | |
| 793 | 805 | let Html(fragment) = render_with(&p.resolved, highlighter, &opts); |
| 794 | 806 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. |
| 795 | 807 | let root = relative_root(&p.output); |
src/util.rs +22
| @@ -342,6 +342,28 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf | ||
| 342 | 342 | /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as |
| 343 | 343 | /// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing |
| 344 | 344 | /// needs one key it can sort on. |
| 345 | /// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one. | |
| 346 | /// | |
| 347 | /// Two notes written on the same day are not written at the same moment, and org records | |
| 348 | /// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them | |
| 349 | /// in whatever order the filesystem happened to yield. | |
| 350 | pub fn iso_time(raw: &str) -> Option<String> { | |
| 351 | let bytes = raw.as_bytes(); | |
| 352 | for i in 0..bytes.len().saturating_sub(4) { | |
| 353 | let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit); | |
| 354 | if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) { | |
| 355 | continue; | |
| 356 | } | |
| 357 | if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') { | |
| 358 | continue; | |
| 359 | } | |
| 360 | let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8); | |
| 361 | let end = if with_seconds { i + 8 } else { i + 5 }; | |
| 362 | return Some(raw[i..end].to_string()); | |
| 363 | } | |
| 364 | None | |
| 365 | } | |
| 366 | ||
| 345 | 367 | pub fn iso_date(raw: &str) -> Option<String> { |
| 346 | 368 | let bytes = raw.as_bytes(); |
| 347 | 369 | for i in 0..bytes.len().saturating_sub(9) { |
tests/config.rs +46
| @@ -2164,3 +2164,49 @@ fn a_listing_can_group_its_entries_by_year() { | ||
| 2164 | 2164 | ); |
| 2165 | 2165 | assert!(html.contains("Undated"), "the undated post is still listed"); |
| 2166 | 2166 | } |
| 2167 | ||
| 2168 | /// Two notes written on the same day are not written at the same moment, and org records | |
| 2169 | /// which came first. Sorting on the date alone throws that away. | |
| 2170 | #[test] | |
| 2171 | fn same_day_entries_sort_by_time_of_day() { | |
| 2172 | let root = tmpdir("sorttime"); | |
| 2173 | let src = root.join("src"); | |
| 2174 | std::fs::create_dir_all(src.join("blog")).unwrap(); | |
| 2175 | std::fs::create_dir_all(src.join("templates")).unwrap(); | |
| 2176 | for (name, title, date) in [ | |
| 2177 | ("morning", "Morning", "[2026-02-21 Sat 09:15:00]"), | |
| 2178 | ("evening", "Evening", "[2026-02-21 Sat 21:40:00]"), | |
| 2179 | ("noon", "Noon", "[2026-02-21 Sat 12:30]"), | |
| 2180 | ] { | |
| 2181 | std::fs::write( | |
| 2182 | src.join(format!("blog/{name}.org")), | |
| 2183 | format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"), | |
| 2184 | ) | |
| 2185 | .unwrap(); | |
| 2186 | } | |
| 2187 | std::fs::write( | |
| 2188 | src.join("templates/list.html"), | |
| 2189 | "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>", | |
| 2190 | ) | |
| 2191 | .unwrap(); | |
| 2192 | std::fs::write( | |
| 2193 | src.join("org-ssg.toml"), | |
| 2194 | "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\ | |
| 2195 | template = \"list.html\"\ntitle = \"Blog\"\nsort = \"date\"\norder = \"desc\"\n", | |
| 2196 | ) | |
| 2197 | .unwrap(); | |
| 2198 | let out = root.join("out"); | |
| 2199 | build(&src, &out); | |
| 2200 | ||
| 2201 | let html = page(&out, "blog/index.html"); | |
| 2202 | let order: Vec<&str> = html | |
| 2203 | .split("<li>") | |
| 2204 | .skip(1) | |
| 2205 | .map(|s| s.split('<').next().unwrap()) | |
| 2206 | .collect(); | |
| 2207 | assert_eq!( | |
| 2208 | order, | |
| 2209 | vec!["Evening", "Noon", "Morning"], | |
| 2210 | "newest first, by the clock:\n{html}" | |
| 2211 | ); | |
| 2212 | } | |
tests/constructs.rs +185 −6
| @@ -141,7 +141,7 @@ fn preamble_keywords_do_not_disturb_the_content_around_them() { | ||
| 141 | 141 | "a keyword between two paragraphs must not merge them:\n{html}" |
| 142 | 142 | ); |
| 143 | 143 | assert!( |
| 144 | html.contains("<figcaption>shot</figcaption>"), | |
| 144 | html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"), | |
| 145 | 145 | "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}" |
| 146 | 146 | ); |
| 147 | 147 | } |
| @@ -265,13 +265,19 @@ fn non_html_export_blocks_are_dropped() { | ||
| 265 | 265 | ); |
| 266 | 266 | } |
| 267 | 267 | |
| 268 | /// An unmodelled block type keeps its content rather than vanishing. | |
| 268 | /// A verse block keeps its line breaks, and a block with an unrecognised name becomes a | |
| 269 | /// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER | |
| 270 | /// can carry content. | |
| 269 | 271 | #[test] |
| 270 | fn unknown_block_types_keep_their_content() { | |
| 271 | let html = render_fixture("outofscope.org"); | |
| 272 | fn verse_and_special_blocks_keep_their_content() { | |
| 273 | let html = render_fixture("blocks.org"); | |
| 272 | 274 | assert!( |
| 273 | html.contains("An unmodelled block type"), | |
| 274 | "a verse block degrades to a verbatim example block:\n{html}" | |
| 275 | html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"), | |
| 276 | "verse keeps its breaks:\n{html}" | |
| 277 | ); | |
| 278 | assert!( | |
| 279 | html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"), | |
| 280 | "a special block holds parsed org:\n{html}" | |
| 275 | 281 | ); |
| 276 | 282 | } |
| 277 | 283 | |
| @@ -502,3 +508,176 @@ fn an_ordinary_leading_comma_is_not_stripped() { | ||
| 502 | 508 | assert!(text.contains(", a list continuation"), "{html}"); |
| 503 | 509 | assert!(text.contains(",not an escape"), "{html}"); |
| 504 | 510 | } |
| 511 | ||
| 512 | // --------------------------------------------------------------------------- | |
| 513 | // Export-time text conversions | |
| 514 | // --------------------------------------------------------------------------- | |
| 515 | ||
| 516 | fn html_of(source: &str) -> String { | |
| 517 | let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse"); | |
| 518 | let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); | |
| 519 | html | |
| 520 | } | |
| 521 | ||
| 522 | /// Org converts dash runs and ellipses in prose. A reader of the published page should | |
| 523 | /// see the typography the author meant, not the ASCII they had to type. | |
| 524 | #[test] | |
| 525 | fn special_strings_become_real_punctuation() { | |
| 526 | let html = html_of("An em---dash, an en--dash, and an ellipsis...\n"); | |
| 527 | assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}"); | |
| 528 | assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}"); | |
| 529 | assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}"); | |
| 530 | } | |
| 531 | ||
| 532 | /// A shell transcript is not prose. `--verbose` inside code has to survive intact, or | |
| 533 | /// copying a command off the page produces one that does not run. | |
| 534 | #[test] | |
| 535 | fn special_strings_leave_code_alone() { | |
| 536 | let html = html_of( | |
| 537 | "Prose --dash and ~ls --all~ and =grep --color=.\n\n\ | |
| 538 | #+BEGIN_SRC sh\nls --all\n#+END_SRC\n", | |
| 539 | ); | |
| 540 | assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}"); | |
| 541 | assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}"); | |
| 542 | assert!(html.contains("--color"), "verbatim:\n{html}"); | |
| 543 | // The source block's `--all` is split across highlighting spans, so count the | |
| 544 | // conversion itself: exactly one en dash on the page, the one in the prose. | |
| 545 | assert_eq!( | |
| 546 | html.matches('\u{2013}').count(), | |
| 547 | 1, | |
| 548 | "nothing inside code converted:\n{html}" | |
| 549 | ); | |
| 550 | } | |
| 551 | ||
| 552 | /// `#+OPTIONS: -:nil` is how a document opts out, and org-ssg honours org's own switch | |
| 553 | /// rather than inventing one. | |
| 554 | #[test] | |
| 555 | fn a_document_can_turn_special_strings_off() { | |
| 556 | let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n"); | |
| 557 | assert!(html.contains("em---dash"), "left alone:\n{html}"); | |
| 558 | assert!(html.contains("ellipsis..."), "left alone:\n{html}"); | |
| 559 | } | |
| 560 | ||
| 561 | /// Sub- and superscripts, including the braceless form — which is what makes | |
| 562 | /// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file. | |
| 563 | #[test] | |
| 564 | fn sub_and_superscripts_convert() { | |
| 565 | let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n"); | |
| 566 | assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}"); | |
| 567 | assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}"); | |
| 568 | assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}"); | |
| 569 | } | |
| 570 | ||
| 571 | /// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes | |
| 572 | /// *before* the marker: emphasis follows whitespace, a script follows a word. | |
| 573 | #[test] | |
| 574 | fn underline_still_wins_where_org_says_it_does() { | |
| 575 | let html = html_of("Some _underlined_ text.\n"); | |
| 576 | assert!(html.contains("<u>underlined</u>"), "underline:\n{html}"); | |
| 577 | assert!(!html.contains("<sub>"), "not a subscript:\n{html}"); | |
| 578 | } | |
| 579 | ||
| 580 | /// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form. | |
| 581 | #[test] | |
| 582 | fn a_document_can_restrict_sub_and_superscripts() { | |
| 583 | let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n"); | |
| 584 | assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}"); | |
| 585 | ||
| 586 | let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n"); | |
| 587 | assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}"); | |
| 588 | assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}"); | |
| 589 | } | |
| 590 | ||
| 591 | /// LaTeX is passed through for a typesetter, so the text conversions must not reach | |
| 592 | /// inside it: `x^2` in `$…$` is mathematics, not markup. | |
| 593 | #[test] | |
| 594 | fn latex_fragments_are_left_intact() { | |
| 595 | let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n"); | |
| 596 | for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] { | |
| 597 | assert!(html.contains(literal), "`{literal}` survives:\n{html}"); | |
| 598 | } | |
| 599 | } | |
| 600 | ||
| 601 | /// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end | |
| 602 | /// with a space, which is what keeps prices out of the math. | |
| 603 | #[test] | |
| 604 | fn dollar_amounts_are_not_latex() { | |
| 605 | let html = html_of("It cost $5 or $6 --- a bargain.\n"); | |
| 606 | assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}"); | |
| 607 | } | |
| 608 | ||
| 609 | /// Org exports outline levels relative to the file's own shallowest heading, so a | |
| 610 | /// document written entirely under `**` is a document of top-level sections. | |
| 611 | #[test] | |
| 612 | fn heading_levels_are_relative_to_the_shallowest_heading() { | |
| 613 | let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n"); | |
| 614 | assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}"); | |
| 615 | assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}"); | |
| 616 | } | |
| 617 | ||
| 618 | /// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org. | |
| 619 | /// Rendering its contents as literal text loses the markup the author wrote. | |
| 620 | #[test] | |
| 621 | fn a_special_block_holds_org_not_text() { | |
| 622 | let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n"); | |
| 623 | assert!(html.contains("<div class=\"note\">"), "named div:\n{html}"); | |
| 624 | assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}"); | |
| 625 | } | |
| 626 | ||
| 627 | /// A path that starts with `~` inside `~…~` verbatim: the body may open with the same | |
| 628 | /// character as the marker, and org says so. | |
| 629 | #[test] | |
| 630 | fn verbatim_can_start_with_its_own_marker() { | |
| 631 | let html = html_of("Edit ~~/.config/doom/config.el~ now.\n"); | |
| 632 | assert!( | |
| 633 | html.contains("<code>~/.config/doom/config.el</code>"), | |
| 634 | "the leading ~ belongs to the path:\n{html}" | |
| 635 | ); | |
| 636 | } | |
| 637 | ||
| 638 | /// Org's special first column holds export markers, not data: `/` marks a column group, | |
| 639 | /// `#` a row to recalculate. Publishing them puts a column of punctuation on the page. | |
| 640 | #[test] | |
| 641 | fn a_tables_special_column_and_marker_rows_are_dropped() { | |
| 642 | let html = html_of( | |
| 643 | "| N | N^2 |\n\ | |
| 644 | | / | < |\n\ | |
| 645 | | 1 | 1 |\n", | |
| 646 | ); | |
| 647 | assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}"); | |
| 648 | assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}"); | |
| 649 | ||
| 650 | // Every row marked, so the column itself goes too. | |
| 651 | let all_marked = html_of( | |
| 652 | "| # | exp(x) | 1 |\n\ | |
| 653 | | # | exp(x) | 2 |\n", | |
| 654 | ); | |
| 655 | assert!( | |
| 656 | !all_marked.contains(">#<"), | |
| 657 | "a wholly-special column is dropped:\n{all_marked}" | |
| 658 | ); | |
| 659 | assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}"); | |
| 660 | } | |
| 661 | ||
| 662 | /// An affiliated keyword belongs to the element *immediately* below it. Someone who | |
| 663 | /// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next | |
| 664 | /// image instead would put the wrong words under the wrong picture. | |
| 665 | #[test] | |
| 666 | fn a_blank_line_ends_a_captions_association() { | |
| 667 | let html = html_of( | |
| 668 | "[[file:one.png]]\n\ | |
| 669 | #+CAPTION: stranded\n\ | |
| 670 | \n\ | |
| 671 | [[file:two.png]]\n", | |
| 672 | ); | |
| 673 | assert!( | |
| 674 | !html.contains("stranded"), | |
| 675 | "an orphaned caption attaches to nothing:\n{html}" | |
| 676 | ); | |
| 677 | ||
| 678 | let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n"); | |
| 679 | assert!( | |
| 680 | attached.contains("<figcaption>"), | |
| 681 | "a caption directly above its image still works:\n{attached}" | |
| 682 | ); | |
| 683 | } | |
tests/snapshots/constructs__blocks_html.snap +9
| @@ -25,3 +25,12 @@ expression: "render_fixture(\"blocks.org\")" | ||
| 25 | 25 | <p>A quote containing a source block:</p> |
| 26 | 26 | <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre> |
| 27 | 27 | </blockquote> |
| 28 | <h2 id="verse">Verse</h2> | |
| 29 | <p class="verse"> | |
| 30 | Line breaks are the point<br> | |
| 31 | and indentation survives. | |
| 32 | </p> | |
| 33 | <h2 id="a-named-special-block">A named special block</h2> | |
| 34 | <div class="note"> | |
| 35 | <p>Contents are <strong>org</strong>, not literal text.</p> | |
| 36 | </div> | |
tests/snapshots/constructs__images_html.snap +2 −2
| @@ -5,9 +5,9 @@ expression: "render_fixture(\"images.org\")" | ||
| 5 | 5 | <h2 id="bare-image">Bare image</h2> |
| 6 | 6 | <p><img src="diagram.png" alt=""></p> |
| 7 | 7 | <h2 id="captioned-figure">Captioned figure</h2> |
| 8 | <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> | |
| 8 | <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption><span class="figure-number">Figure 1: </span>The pipeline, end to end</figcaption></figure> | |
| 9 | 9 | <h2 id="caption-with-markup">Caption with markup</h2> |
| 10 | <figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> | |
| 10 | <figure><img src="chart.png" alt="A stylised chart"><figcaption><span class="figure-number">Figure 2: </span>A <em>stylised</em> chart</figcaption></figure> | |
| 11 | 11 | <h2 id="quoted-attribute-values">Quoted attribute values</h2> |
| 12 | 12 | <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> |
| 13 | 13 | <h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2> |
tests/snapshots/constructs__lists_element_tree.snap +18
| @@ -37,6 +37,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 37 | 37 | "items": [ |
| 38 | 38 | { |
| 39 | 39 | "bullet": "Dash", |
| 40 | "counter": null, | |
| 40 | 41 | "checkbox": null, |
| 41 | 42 | "term": null, |
| 42 | 43 | "content": [ |
| @@ -53,6 +54,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 53 | 54 | "items": [ |
| 54 | 55 | { |
| 55 | 56 | "bullet": "Dash", |
| 57 | "counter": null, | |
| 56 | 58 | "checkbox": null, |
| 57 | 59 | "term": null, |
| 58 | 60 | "content": [ |
| @@ -69,6 +71,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 69 | 71 | "items": [ |
| 70 | 72 | { |
| 71 | 73 | "bullet": "Dash", |
| 74 | "counter": null, | |
| 72 | 75 | "checkbox": null, |
| 73 | 76 | "term": null, |
| 74 | 77 | "content": [ |
| @@ -88,6 +91,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 88 | 91 | }, |
| 89 | 92 | { |
| 90 | 93 | "bullet": "Dash", |
| 94 | "counter": null, | |
| 91 | 95 | "checkbox": null, |
| 92 | 96 | "term": null, |
| 93 | 97 | "content": [ |
| @@ -107,6 +111,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 107 | 111 | }, |
| 108 | 112 | { |
| 109 | 113 | "bullet": "Dash", |
| 114 | "counter": null, | |
| 110 | 115 | "checkbox": null, |
| 111 | 116 | "term": null, |
| 112 | 117 | "content": [ |
| @@ -151,6 +156,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 151 | 156 | "bullet": { |
| 152 | 157 | "Ordered": 1 |
| 153 | 158 | }, |
| 159 | "counter": null, | |
| 154 | 160 | "checkbox": null, |
| 155 | 161 | "term": null, |
| 156 | 162 | "content": [ |
| @@ -167,6 +173,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 167 | 173 | "bullet": { |
| 168 | 174 | "Ordered": 2 |
| 169 | 175 | }, |
| 176 | "counter": null, | |
| 170 | 177 | "checkbox": null, |
| 171 | 178 | "term": null, |
| 172 | 179 | "content": [ |
| @@ -185,6 +192,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 185 | 192 | "bullet": { |
| 186 | 193 | "Ordered": 1 |
| 187 | 194 | }, |
| 195 | "counter": null, | |
| 188 | 196 | "checkbox": null, |
| 189 | 197 | "term": null, |
| 190 | 198 | "content": [ |
| @@ -201,6 +209,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 201 | 209 | "bullet": { |
| 202 | 210 | "Ordered": 2 |
| 203 | 211 | }, |
| 212 | "counter": null, | |
| 204 | 213 | "checkbox": null, |
| 205 | 214 | "term": null, |
| 206 | 215 | "content": [ |
| @@ -222,6 +231,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 222 | 231 | "bullet": { |
| 223 | 232 | "Ordered": 3 |
| 224 | 233 | }, |
| 234 | "counter": null, | |
| 225 | 235 | "checkbox": null, |
| 226 | 236 | "term": null, |
| 227 | 237 | "content": [ |
| @@ -264,6 +274,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 264 | 274 | "items": [ |
| 265 | 275 | { |
| 266 | 276 | "bullet": "Dash", |
| 277 | "counter": null, | |
| 267 | 278 | "checkbox": "Off", |
| 268 | 279 | "term": null, |
| 269 | 280 | "content": [ |
| @@ -278,6 +289,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 278 | 289 | }, |
| 279 | 290 | { |
| 280 | 291 | "bullet": "Dash", |
| 292 | "counter": null, | |
| 281 | 293 | "checkbox": "On", |
| 282 | 294 | "term": null, |
| 283 | 295 | "content": [ |
| @@ -292,6 +304,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 292 | 304 | }, |
| 293 | 305 | { |
| 294 | 306 | "bullet": "Dash", |
| 307 | "counter": null, | |
| 295 | 308 | "checkbox": "Trans", |
| 296 | 309 | "term": null, |
| 297 | 310 | "content": [ |
| @@ -334,6 +347,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 334 | 347 | "items": [ |
| 335 | 348 | { |
| 336 | 349 | "bullet": "Dash", |
| 350 | "counter": null, | |
| 337 | 351 | "checkbox": null, |
| 338 | 352 | "term": [ |
| 339 | 353 | { |
| @@ -352,6 +366,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 352 | 366 | }, |
| 353 | 367 | { |
| 354 | 368 | "bullet": "Dash", |
| 369 | "counter": null, | |
| 355 | 370 | "checkbox": null, |
| 356 | 371 | "term": [ |
| 357 | 372 | { |
| @@ -370,6 +385,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 370 | 385 | }, |
| 371 | 386 | { |
| 372 | 387 | "bullet": "Dash", |
| 388 | "counter": null, | |
| 373 | 389 | "checkbox": null, |
| 374 | 390 | "term": [ |
| 375 | 391 | { |
| @@ -423,6 +439,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 423 | 439 | "items": [ |
| 424 | 440 | { |
| 425 | 441 | "bullet": "Dash", |
| 442 | "counter": null, | |
| 426 | 443 | "checkbox": null, |
| 427 | 444 | "term": null, |
| 428 | 445 | "content": [ |
| @@ -444,6 +461,7 @@ expression: "parse_fixture(\"lists.org\").root" | ||
| 444 | 461 | }, |
| 445 | 462 | { |
| 446 | 463 | "bullet": "Dash", |
| 464 | "counter": null, | |
| 447 | 465 | "checkbox": null, |
| 448 | 466 | "term": null, |
| 449 | 467 | "content": [ |
tests/snapshots/constructs__lists_html.snap +3 −3
| @@ -26,9 +26,9 @@ expression: "render_fixture(\"lists.org\")" | ||
| 26 | 26 | </ol> |
| 27 | 27 | <h2 id="checkboxes">Checkboxes</h2> |
| 28 | 28 | <ul> |
| 29 | <li><input type="checkbox" disabled> not done</li> | |
| 30 | <li><input type="checkbox" disabled checked> done</li> | |
| 31 | <li><input type="checkbox" disabled> partially done</li> | |
| 29 | <li class="off"><code>[ ]</code> not done</li> | |
| 30 | <li class="on"><code>[X]</code> done</li> | |
| 31 | <li class="trans"><code>[-]</code> partially done</li> | |
| 32 | 32 | </ul> |
| 33 | 33 | <h2 id="description">Description</h2> |
| 34 | 34 | <dl> |
tests/snapshots/constructs__out_of_scope_html.snap −3
| @@ -21,8 +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="verse">Verse</h2> | |
| 25 | <pre>An unmodelled block type | |
| 26 | keeps its content verbatim.</pre> | |
| 27 | 24 | <h2 id="entities">Entities</h2> |
| 28 | 25 | <p>The full entity set is out of scope, so \alpha stays literal.</p> |
tests/snapshots/oracle__oracle_blocks.snap +22 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | source: tests/oracle.rs |
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | agreement: 51/59 skeleton lines (86.4%) | |
| 5 | agreement: 67/77 skeleton lines (87.0%) | |
| 6 | 6 | (- org-ssg, + emacs) |
| 7 | 7 | |
| 8 | 8 | <h2> |
| @@ -66,3 +66,24 @@ agreement: 51/59 skeleton lines (86.4%) | ||
| 66 | 66 | - </code> |
| 67 | 67 | </pre> |
| 68 | 68 | </blockquote> |
| 69 | <h2> | |
| 70 | "Verse" | |
| 71 | </h2> | |
| 72 | <p> | |
| 73 | "Line breaks are the point" | |
| 74 | <br> | |
| 75 | "and indentation survives." | |
| 76 | + <br> | |
| 77 | </p> | |
| 78 | <h2> | |
| 79 | "A named special block" | |
| 80 | </h2> | |
| 81 | <p> | |
| 82 | "Contents are" | |
| 83 | - <strong> | |
| 84 | + <b> | |
| 85 | "org" | |
| 86 | - </strong> | |
| 87 | + </b> | |
| 88 | ", not literal text." | |
| 89 | </p> | |
tests/snapshots/oracle__oracle_core.snap +7 −9
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | source: tests/oracle.rs |
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | agreement: 45/54 skeleton lines (83.3%) | |
| 5 | agreement: 51/58 skeleton lines (87.9%) | |
| 6 | 6 | (- org-ssg, + emacs) |
| 7 | 7 | |
| 8 | 8 | <p> |
| @@ -34,17 +34,15 @@ agreement: 45/54 skeleton lines (83.3%) | ||
| 34 | 34 | - </ol> |
| 35 | 35 | - <ul> |
| 36 | 36 | <li> |
| 37 | - <input> | |
| 38 | + <code> | |
| 39 | + "[ ]" | |
| 40 | + </code> | |
| 37 | <code> | |
| 38 | "[ ]" | |
| 39 | </code> | |
| 41 | 40 | "todo item" |
| 42 | 41 | </li> |
| 43 | 42 | <li> |
| 44 | - <input> | |
| 45 | + <code> | |
| 46 | + "[X]" | |
| 47 | + </code> | |
| 43 | <code> | |
| 44 | "[X]" | |
| 45 | </code> | |
| 48 | 46 | "done item" |
| 49 | 47 | </li> |
| 50 | 48 | - </ul> |
tests/snapshots/oracle__oracle_images.snap +6 −8
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | source: tests/oracle.rs |
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | agreement: 28/42 skeleton lines (66.7%) | |
| 5 | agreement: 30/42 skeleton lines (71.4%) | |
| 6 | 6 | (- org-ssg, + emacs) |
| 7 | 7 | |
| 8 | 8 | <h2> |
| @@ -18,12 +18,11 @@ agreement: 28/42 skeleton lines (66.7%) | ||
| 18 | 18 | + <p> |
| 19 | 19 | <img src="pipeline.svg"> |
| 20 | 20 | - <figcaption> |
| 21 | - "The pipeline, end to end" | |
| 22 | - </figcaption> | |
| 23 | - </figure> | |
| 24 | 21 | + </p> |
| 25 | 22 | + <p> |
| 26 | + "Figure 1: The pipeline, end to end" | |
| 23 | "Figure 1: The pipeline, end to end" | |
| 24 | - </figcaption> | |
| 25 | - </figure> | |
| 27 | 26 | + </p> |
| 28 | 27 | <h2> |
| 29 | 28 | "Caption with markup" |
| @@ -32,11 +31,10 @@ agreement: 28/42 skeleton lines (66.7%) | ||
| 32 | 31 | + <p> |
| 33 | 32 | <img src="chart.png"> |
| 34 | 33 | - <figcaption> |
| 35 | - "A" | |
| 36 | - <em> | |
| 37 | 34 | + </p> |
| 38 | 35 | + <p> |
| 39 | + "Figure 2: A" | |
| 36 | "Figure 2: A" | |
| 37 | - <em> | |
| 40 | 38 | + <i> |
| 41 | 39 | "stylised" |
| 42 | 40 | - </em> |
tests/snapshots/oracle__oracle_lists.snap +10 −13
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | source: tests/oracle.rs |
| 3 | 3 | expression: report |
| 4 | 4 | --- |
| 5 | agreement: 100/111 skeleton lines (90.1%) | |
| 5 | agreement: 109/111 skeleton lines (98.2%) | |
| 6 | 6 | (- org-ssg, + emacs) |
| 7 | 7 | |
| 8 | 8 | <h2> |
| @@ -56,24 +56,21 @@ agreement: 100/111 skeleton lines (90.1%) | ||
| 56 | 56 | </h2> |
| 57 | 57 | <ul> |
| 58 | 58 | <li> |
| 59 | - <input> | |
| 60 | + <code> | |
| 61 | + "[ ]" | |
| 62 | + </code> | |
| 59 | <code> | |
| 60 | "[ ]" | |
| 61 | </code> | |
| 63 | 62 | "not done" |
| 64 | 63 | </li> |
| 65 | 64 | <li> |
| 66 | - <input> | |
| 67 | + <code> | |
| 68 | + "[X]" | |
| 69 | + </code> | |
| 65 | <code> | |
| 66 | "[X]" | |
| 67 | </code> | |
| 70 | 68 | "done" |
| 71 | 69 | </li> |
| 72 | 70 | <li> |
| 73 | - <input> | |
| 74 | + <code> | |
| 75 | + "[-]" | |
| 76 | + </code> | |
| 71 | <code> | |
| 72 | "[-]" | |
| 73 | </code> | |
| 77 | 74 | "partially done" |
| 78 | 75 | </li> |
| 79 | 76 | </ul> |
tests/snapshots/pipeline__core_element_tree.snap +4
| @@ -63,6 +63,7 @@ expression: "parse_fixture(\"core.org\").root" | ||
| 63 | 63 | "bullet": { |
| 64 | 64 | "Ordered": 1 |
| 65 | 65 | }, |
| 66 | "counter": null, | |
| 66 | 67 | "checkbox": null, |
| 67 | 68 | "term": null, |
| 68 | 69 | "content": [ |
| @@ -79,6 +80,7 @@ expression: "parse_fixture(\"core.org\").root" | ||
| 79 | 80 | "bullet": { |
| 80 | 81 | "Ordered": 2 |
| 81 | 82 | }, |
| 83 | "counter": null, | |
| 82 | 84 | "checkbox": null, |
| 83 | 85 | "term": null, |
| 84 | 86 | "content": [ |
| @@ -107,6 +109,7 @@ expression: "parse_fixture(\"core.org\").root" | ||
| 107 | 109 | "items": [ |
| 108 | 110 | { |
| 109 | 111 | "bullet": "Dash", |
| 112 | "counter": null, | |
| 110 | 113 | "checkbox": "Off", |
| 111 | 114 | "term": null, |
| 112 | 115 | "content": [ |
| @@ -121,6 +124,7 @@ expression: "parse_fixture(\"core.org\").root" | ||
| 121 | 124 | }, |
| 122 | 125 | { |
| 123 | 126 | "bullet": "Dash", |
| 127 | "counter": null, | |
| 124 | 128 | "checkbox": "On", |
| 125 | 129 | "term": null, |
| 126 | 130 | "content": [ |
tests/snapshots/pipeline__core_html.snap +2 −2
| @@ -9,8 +9,8 @@ expression: "render_fixture(\"core.org\")" | ||
| 9 | 9 | <li>second item with <em>emphasis</em></li> |
| 10 | 10 | </ol> |
| 11 | 11 | <ul> |
| 12 | <li><input type="checkbox" disabled> todo item</li> | |
| 13 | <li><input type="checkbox" disabled checked> done item</li> | |
| 12 | <li class="off"><code>[ ]</code> todo item</li> | |
| 13 | <li class="on"><code>[X]</code> done item</li> | |
| 14 | 14 | </ul> |
| 15 | 15 | <h2 id="links-and-code">Links and code</h2> |
| 16 | 16 | <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> |
tests/snapshots/pipeline__minimal_element_tree.snap +2
| @@ -117,6 +117,7 @@ expression: "parse_fixture(\"minimal.org\").root" | ||
| 117 | 117 | "items": [ |
| 118 | 118 | { |
| 119 | 119 | "bullet": "Dash", |
| 120 | "counter": null, | |
| 120 | 121 | "checkbox": null, |
| 121 | 122 | "term": null, |
| 122 | 123 | "content": [ |
| @@ -131,6 +132,7 @@ expression: "parse_fixture(\"minimal.org\").root" | ||
| 131 | 132 | }, |
| 132 | 133 | { |
| 133 | 134 | "bullet": "Dash", |
| 135 | "counter": null, | |
| 134 | 136 | "checkbox": null, |
| 135 | 137 | "term": null, |
| 136 | 138 | "content": [ |