krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
183c97769244fe508f3e6621c7771a6b086b9869
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T19:57:37Z
README.md | 21 +- docs/guide/05-org-support.org | 49 ++- fixtures/blocks.org | 13 + fixtures/outofscope.org | 7 - src/audit.rs | 7 +- src/config.rs | 47 +++ src/incremental.rs | 2 +- src/model.rs | 10 + src/parser.rs | 54 ++- src/render.rs | 363 ++++++++++++++++++++- src/site.rs | 30 +- src/util.rs | 22 ++ tests/config.rs | 46 +++ tests/constructs.rs | 191 ++++++++++- tests/snapshots/constructs__blocks_html.snap | 9 + tests/snapshots/constructs__images_html.snap | 4 +- .../snapshots/constructs__lists_element_tree.snap | 18 + tests/snapshots/constructs__lists_html.snap | 6 +- tests/snapshots/constructs__out_of_scope_html.snap | 3 - tests/snapshots/oracle__oracle_blocks.snap | 23 +- tests/snapshots/oracle__oracle_core.snap | 16 +- tests/snapshots/oracle__oracle_images.snap | 14 +- tests/snapshots/oracle__oracle_lists.snap | 23 +- tests/snapshots/pipeline__core_element_tree.snap | 4 + tests/snapshots/pipeline__core_html.snap | 4 +- .../snapshots/pipeline__minimal_element_tree.snap | 2 + 26 files changed, 888 insertions(+), 100 deletions(-) @@ -327,17 +327,20 @@ is the only inherently global stage — it is where the link dependency graph is ## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit) -**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags; -property drawers; plain lists (unordered/ordered/description, checkboxes, nesting); -tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting; -example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`, -`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup -(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges); -paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`. +**IN — v1 must handle:** headings with nesting, at levels relative to the document's +shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists +(unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule +rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax +highlighting; example/quote/center/verse blocks and named special blocks; links (external, +internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` +keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's +export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`); timestamps +(active/inactive, ranges); paragraphs and horizontal rules; images with +`#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / -`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and -macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda +`:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past +the text conversions); `#+INCLUDE:`; citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda semantics; non-HTML export blocks; the full Unicode entity set. **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test @@ -35,6 +35,33 @@ every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=, Timestamps, active and inactive, with times and ranges, render as =<time>= with a machine-readable =datetime=. +*** Text conversions + +Org rewrites some prose on export, and so does org-ssg: + +| Written | Published | +|---------+-----------| +| =--= | – | +| =---= | — | +| =...= | … | +| =x^2= | x superscript 2 | +| =H_{2}O= | H subscript 2 O | + +Neither reaches inside verbatim, code, a source block or a LaTeX fragment — =--verbose= in +a shell transcript stays a flag, and =$x^2$= stays mathematics. + +*Braceless subscripts catch people out.* Org's default converts =a_b=, so =snake_case= in +prose publishes as snake with a subscript. That is what Emacs does with the same file. Turn +it off per document with =#+OPTIONS: ^:nil=, restrict it to the braced form with =^:{}=, or +set =[html] sub_superscript= for the site. =#+OPTIONS: -:nil= turns off the dashes and +ellipsis. + +*** Heading levels are relative + +A file whose shallowest heading is =**= is a file of top-level sections that happen to be +indented, not a file of subsections — org exports levels relative to the document, so that +subtree exports the same whether it was cut from a larger file or written on its own. + ** Lists Unordered, ordered and description lists, nested by indentation, with checkboxes and @@ -44,18 +71,25 @@ multi-paragraph items: - outer item - inner item - [X] a checked item +- [-] a partly-done item - term :: definition +1. [@4] an item numbered from 4 #+END_SRC +Checkboxes render as org writes them — =<code>[X]</code>= with the state as a class on the +item — rather than as a disabled =<input>=, which has no way to say "partly done". + ** Blocks -=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block -inside a quote block works, because block ends match their own kind. +=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER=, =VERSE= and =EXPORT=. A source +block inside a quote block works, because block ends match their own kind. An =html= export block passes through verbatim; every other backend is dropped, because emitting LaTeX into an HTML page is worse than emitting nothing. -An unknown block type keeps its content as an example block rather than vanishing. +*Any other name is a special block*: =#+BEGIN_NOTE= becomes =<div class="note">= holding +*parsed org*, which is what makes the convention usable without org-ssg knowing the word +"note". A =COMMENT= block is not published. *** Which languages highlight @@ -89,7 +123,12 @@ in Emacs as much as here. If a code block seems to stop early, that is why. ** Tables and footnotes -Pipe tables, with the rule row establishing a header band. Footnotes in all three forms — +Pipe tables, with the rule row establishing a header band. Org's *special column* is +honoured: a first column holding only export markers (=/=, =#=, =!=, =^=, =_=, =$=) is +dropped, and rows marked =/=, =!=, =^=, =_= or =$= are instructions to org rather than +content, so they never reach the page. + +Footnotes in all three forms — =[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a numbered, back-linked notes section. @@ -120,7 +159,7 @@ broken. | =#+DRAFT:= | Keeps the page out of the build. | | =#+TEMPLATE:= | The layout this page renders through. | | =#+OPTIONS:= | Per-file export switches. | -| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. | +| =#+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:=. | Every other =#+KEYWORD:= is available to templates as ={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches @@ -51,3 +51,16 @@ A quote containing a source block: echo hi #+END_SRC #+END_QUOTE + +* Verse + +#+BEGIN_VERSE +Line breaks are the point + and indentation survives. +#+END_VERSE + +* A named special block + +#+BEGIN_NOTE +Contents are *org*, not literal text. +#+END_NOTE @@ -43,13 +43,6 @@ CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00] => 1:00 Drawer contents are captured and dropped. :END: -* Verse - -#+BEGIN_VERSE -An unmodelled block type -keeps its content verbatim. -#+END_VERSE - * Entities The full entity set is out of scope, so \alpha stays literal. @@ -78,7 +78,12 @@ const KNOWN_KEYWORDS: &[&str] = &[ "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO", "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS", ]; -const KNOWN_BLOCKS: &[&str] = &["SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT"]; +/// Blocks with dedicated handling. Any *other* name renders as a special block — a div +/// with that name holding parsed org — so an unlisted block is a note about what a corpus +/// contains rather than a construct that will be lost. +const KNOWN_BLOCKS: &[&str] = &[ + "SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT", "VERSE", "COMMENT", +]; const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"]; /// Keyword names conventional enough to be worth flagging when they lead a heading. /// A custom sequence is only *real* if some `#+TODO:` declares it, which the census @@ -227,6 +227,44 @@ pub struct HtmlOutput { /// do not want them, so the default is the taste rather than the inheritance; /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back. pub section_numbers: bool, + /// Convert org's special strings in prose: `--` to an en dash, `---` to an em dash, + /// `...` to an ellipsis. + /// + /// On, as in Emacs. A document turns it off for itself with `#+OPTIONS: -:nil`. + /// Never applied inside verbatim, code, or a source block. + pub special_strings: bool, + /// Whether `x^2` and `H_{2}O` become `<sup>`/`<sub>`. + /// + /// `"yes"` (the default, and Emacs') also treats the braceless `a_b` as a subscript, + /// which is what makes `snake_case` in prose render as `snake<sub>case</sub>` — + /// surprising, but what Emacs does with the same file. `"braces"` limits it to the + /// explicit `a_{b}` form, and `"no"` leaves both alone. A document chooses for itself + /// with `#+OPTIONS: ^:nil` or `^:{}`. + pub sub_superscript: SubSuperscript, +} + +/// How `_` and `^` are treated in prose. Mirrors org's `^:` export option. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SubSuperscript { + /// `a_b` and `a_{b}` both convert. + #[default] + Yes, + /// Only the braced `a_{b}` converts. + Braces, + /// Neither converts. + No, +} + +impl SubSuperscript { + /// Read org's `^:` option value: `nil` is off, `{}` is braces-only, anything else on. + pub fn from_option(value: &str) -> SubSuperscript { + match value.trim() { + "nil" | "false" | "no" | "off" => SubSuperscript::No, + "{}" => SubSuperscript::Braces, + _ => SubSuperscript::Yes, + } + } } impl Default for HtmlOutput { @@ -235,6 +273,8 @@ impl Default for HtmlOutput { heading_offset: 1, toc: true, section_numbers: false, + special_strings: true, + sub_superscript: SubSuperscript::Yes, } } } @@ -530,6 +570,13 @@ toc = true # Number headings (1., 1.1., …). Emacs defaults this on; most sites do not. # A document overrides with `#+OPTIONS: num:t`. section_numbers = false +# Convert `--` to an en dash, `---` to an em dash and `...` to an ellipsis in prose, as +# Emacs does. Never inside code. A document overrides with `#+OPTIONS: -:nil`. +special_strings = true +# Whether `x^2` and `H_{2}O` become <sup>/<sub>: "yes" (as Emacs, and so `snake_case` +# becomes snake<sub>case</sub>), "braces" for the `a_{b}` form only, or "no". +# A document overrides with `#+OPTIONS: ^:nil` or `^:{}`. +sub_superscript = "yes" # Generated listing pages: output files with no source .org behind them. Repeat the # [[collections]] block for each one. A feed is the same thing with an XML template. @@ -29,7 +29,7 @@ use crate::util::output_url; /// Bump whenever the `Document` type, hashing scheme, or resolution rules change. /// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major /// version is folded in as the "hash-algo version" so a hash upgrade also busts. -pub const CACHE_FORMAT_VERSION: u32 = 5; +pub const CACHE_FORMAT_VERSION: u32 = 6; /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). pub type Hash = ContentHash; @@ -112,6 +112,14 @@ pub enum Element { }, QuoteBlock(Vec<Element>), CenterBlock(Vec<Element>), + /// `#+BEGIN_<name>` for any other name — `note`, `warning`, `aside`. Its contents are + /// org, not literal text, and the name becomes a class so a stylesheet can reach it. + SpecialBlock { + name: String, + content: Vec<Element>, + }, + /// `#+BEGIN_VERSE`: line breaks are significant. + VerseBlock(Vec<String>), /// html passes through; others dropped at render (spec §1 OUT). ExportBlock { backend: String, @@ -157,6 +165,8 @@ pub enum ListKind { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListItem { pub bullet: Bullet, + /// `[@4]` — an explicit number for this item, restarting the list's counting. + pub counter: Option<u32>, pub checkbox: Option<Checkbox>, /// Description-list term before `::`. pub term: Option<Vec<Object>>, @@ -398,6 +398,11 @@ fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> V while i < lines.len() { let line = lines[i]; if line.trim().is_empty() { + // A blank line ends the association: an affiliated keyword belongs to the + // element *immediately* below it. Someone who writes `#+CAPTION:` under their + // image and then leaves a blank line has captioned nothing, and org agrees — + // silently attaching it to whatever comes next would caption the wrong thing. + affiliated.clear(); i += 1; continue; } @@ -563,9 +568,17 @@ fn parse_block( backend: after.split_whitespace().next().unwrap_or("").to_string(), raw: inner.join("\n"), }, - // Out-of-scope block types (verse, comment, ascii, custom) degrade to a verbatim - // example block: content preserved, no crash. - _ => Element::ExampleBlock(inner.join("\n")), + // Verse keeps its line breaks; that is the whole point of it. + "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()), + // A comment block is not published, in org or here. + "COMMENT" => Element::Comment(inner.join("\n")), + // Any other name is a special block: a div with that class, holding org. Emacs + // exports unknown block types this way, which is what makes `#+BEGIN_NOTE` a + // usable convention without the exporter knowing the word "note". + other => Element::SpecialBlock { + name: other.to_ascii_lowercase(), + content: parse_elements(&inner, base + start + 1, diags), + }, }; (element, next) } @@ -830,6 +843,8 @@ fn parse_list( // Body = the text after the bullet, plus every following line indented past the // bullet column (blank lines included, so an item can hold several paragraphs). let rest = item_body(lines[j].trim_start(), &bullet); + // `[@4]` comes before the checkbox: `1. [@4] [X] done`. + let (counter, rest) = split_counter(rest); let (checkbox, rest) = split_checkbox(rest); let (term, rest) = match kind { ListKind::Description => match split_term(rest) { @@ -864,6 +879,7 @@ fn parse_list( items.push(ListItem { bullet, + counter, checkbox, term, // 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 { } } +/// Detect a leading `[@N]` counter on a list item, which sets its number explicitly. +fn split_counter(text: &str) -> (Option<u32>, &str) { + let Some(rest) = text.strip_prefix("[@") else { + return (None, text); + }; + let Some(end) = rest.find(']') else { + return (None, text); + }; + match rest[..end].parse::<u32>() { + Ok(n) => (Some(n), rest[end + 1..].trim_start()), + Err(_) => (None, text), + } +} + /// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item. fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) { let bytes = text.as_bytes(); @@ -1368,8 +1398,10 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { if i + 1 >= n { return None; } - let after = chars[i + 1]; - if after.is_whitespace() || after == m { + // Org's body-character rule: the character after the opening marker may not be + // whitespace, a comma or a quote. It *may* be another marker, which is what makes + // `~~/.config/emacs~` verbatim for a path that starts with `~`. + if !body_char_ok(chars[i + 1]) { return None; } let mut j = i + 1; @@ -1377,7 +1409,7 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { if chars[j] == m && j > i + 1 { let before = chars[j - 1]; let next = chars.get(j + 1).copied(); - if !before.is_whitespace() && post_ok(next) { + if body_char_ok(before) && post_ok(next) { let inner = &chars[i + 1..j]; let obj = match m { '=' => Object::Verbatim(inner.iter().collect()), @@ -1396,6 +1428,16 @@ fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { None } +/// May this character sit directly inside an emphasis marker? +/// +/// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a +/// body, which is what makes `="proxied":false=` verbatim, and `=SPC m '=` may close on +/// an apostrophe. The marker character itself is allowed too, so `~~/.config/emacs~` is a +/// path that starts with a tilde. +fn body_char_ok(c: char) -> bool { + !c.is_whitespace() +} + fn boundary_before(chars: &[char], i: usize) -> bool { if i == 0 { return true; @@ -23,10 +23,11 @@ use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGener use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; +use crate::config::SubSuperscript; use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; use crate::parser::is_image_target; use crate::resolve::ResolvedDoc; -use crate::util::{heading_anchor, plain_text, slugify}; +use crate::util::{export_options, heading_anchor, option_enabled, plain_text, slugify}; /// A rendered HTML fragment (content only — no page chrome; spec §2.4). #[derive(Debug, Clone)] @@ -211,6 +212,12 @@ struct Renderer<'a> { order: Vec<String>, /// Counter per heading depth, for section numbers. counters: Vec<usize>, + /// Subtracted from every heading level so the document's shallowest heading renders + /// as level 1. Org exports levels *relative* to a file's own outline, so a file + /// written entirely under `**` is not a file of subsections. + headline_offset: u8, + /// Captioned figures seen so far, for `Figure N:`. + figures: usize, } /// Options affecting how the tree becomes HTML. Presentation choices that belong to the @@ -224,6 +231,12 @@ pub struct RenderOptions { /// Prefix headings with `1.`, `1.1.`, … See /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers). pub section_numbers: bool, + /// Convert `--`, `---` and `...` in prose. See + /// [`HtmlOutput::special_strings`](crate::config::HtmlOutput::special_strings). + pub special_strings: bool, + /// Whether `x^2` and `a_{b}` become `<sup>`/`<sub>`. See + /// [`HtmlOutput::sub_superscript`](crate::config::HtmlOutput::sub_superscript). + pub sub_superscript: SubSuperscript, } impl Default for RenderOptions { @@ -232,10 +245,49 @@ impl Default for RenderOptions { RenderOptions { heading_offset: html.heading_offset, section_numbers: html.section_numbers, + special_strings: html.special_strings, + sub_superscript: html.sub_superscript, } } } +/// The site's render options with the document's own `#+OPTIONS:` applied on top. +/// +/// Org's per-file switches are the author's override of a site-wide setting, and they +/// belong here rather than at each call site — otherwise rendering one document two ways +/// depends on which caller remembered to read its keywords. +fn document_options(keywords: &crate::model::Keywords, opts: &RenderOptions) -> RenderOptions { + RenderOptions { + heading_offset: opts.heading_offset, + section_numbers: option_enabled(keywords, "num", opts.section_numbers), + special_strings: option_enabled(keywords, "-", opts.special_strings), + // `^:` has three values rather than two — `nil`, `{}` or on — so it is read + // directly instead of through the boolean helper. + sub_superscript: match export_options(keywords).get("^") { + Some(value) => SubSuperscript::from_option(value), + None => opts.sub_superscript, + }, + } +} + +/// How much to subtract from every heading level, so a document's shallowest heading +/// renders as level 1. +/// +/// Org exports outline levels *relative to the file*: a document written entirely under +/// `**` is a document of top-level sections that happen to be indented, not a document of +/// subsections. Emacs computes this from the shallowest top-level headline, which is what +/// makes the same subtree export identically whether it was cut from a larger file or +/// written on its own. +fn headline_offset(root: &Section) -> u8 { + root.children + .iter() + .filter_map(|s| s.heading.as_ref()) + .map(|h| h.level) + .min() + .map(|min| min.saturating_sub(1)) + .unwrap_or(0) +} + /// Render a resolved document to an HTML fragment, with default options. pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { render_with(doc, highlighter, &RenderOptions::default()) @@ -245,11 +297,13 @@ pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html { let mut r = Renderer { hl: highlighter, - opts: *opts, + opts: document_options(&doc.document.keywords, opts), block_defs: HashMap::new(), inline_defs: HashMap::new(), order: Vec::new(), counters: Vec::new(), + headline_offset: headline_offset(&doc.document.root), + figures: 0, }; r.collect_defs(&doc.document.root); let mut out = String::new(); @@ -269,7 +323,8 @@ impl Renderer<'_> { fn render_section(&mut self, section: &Section, out: &mut String) { if let Some(h) = §ion.heading { - let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); + let relative = h.level.saturating_sub(self.headline_offset).max(1); + let level = relative.saturating_add(self.opts.heading_offset).clamp(1, 6); let anchor = heading_anchor(h); // A heading with no title text has no meaningful slug; emit no `id` at all // rather than a run of duplicate empty ones. @@ -279,7 +334,7 @@ impl Renderer<'_> { out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); } if self.opts.section_numbers { - let number = self.next_section_number(h.level); + let number = self.next_section_number(relative); out.push_str(&format!( "<span class=\"section-number-{}\">{number}</span> ", level @@ -377,12 +432,33 @@ impl Renderer<'_> { out.push_str("<figure>"); out.push_str(&image_tag(link, attrs, &plain_text(caption))); if !caption.is_empty() { - out.push_str("<figcaption>"); + self.figures += 1; + out.push_str(&format!( + "<figcaption><span class=\"figure-number\">Figure {}: </span>", + self.figures + )); self.render_objects(caption, out); out.push_str("</figcaption>"); } out.push_str("</figure>\n"); } + Element::SpecialBlock { name, content } => { + out.push_str(&format!("<div class=\"{}\">\n", escape_attr(name))); + for child in content { + self.render_element(child, out); + } + out.push_str("</div>\n"); + } + Element::VerseBlock(lines) => { + out.push_str("<p class=\"verse\">\n"); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + out.push_str("<br>\n"); + } + self.render_objects(&crate::parser::inline(line), out); + } + out.push_str("\n</p>\n"); + } Element::HorizontalRule => out.push_str("<hr>\n"), // Definitions are emitted in the footnotes section, not inline. Element::FootnoteDefinition { .. } => {} @@ -414,16 +490,29 @@ impl Renderer<'_> { }; out.push_str(&format!("<{}>\n", tag)); for item in &list.items { - out.push_str("<li>"); + // Org writes a checkbox as literal text, which keeps the third state — `[-]`, + // partially done — that a disabled <input> cannot express. The state goes on + // the item, where it can style the whole line. + let class = item.checkbox.as_ref().map(|cb| match cb { + Checkbox::On => "on", + Checkbox::Off => "off", + Checkbox::Trans => "trans", + }); + out.push_str("<li"); + if let Some(class) = class { + out.push_str(&format!(" class=\"{class}\"")); + } + // `[@4]` restarts the numbering, and HTML says so with `value`. + if let Some(n) = item.counter { + out.push_str(&format!(" value=\"{n}\"")); + } + out.push('>'); if let Some(cb) = &item.checkbox { - out.push_str(&format!( - "<input type=\"checkbox\" disabled{}> ", - if matches!(cb, Checkbox::On) { - " checked" - } else { - "" - } - )); + out.push_str(match cb { + Checkbox::On => "<code>[X]</code> ", + Checkbox::Off => "<code>[ ]</code> ", + Checkbox::Trans => "<code>[-]</code> ", + }); } self.render_item_content(&item.content, out); out.push_str("</li>\n"); @@ -454,6 +543,8 @@ impl Renderer<'_> { /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`. fn render_table(&mut self, table: &crate::model::Table, out: &mut String) { + let table = strip_special_column(table); + let table = &table; let rule_at = table .rows .iter() @@ -502,6 +593,31 @@ impl Renderer<'_> { out.push_str("</table>\n"); } + /// Plain text as HTML: escaped, then org's export-time text conversions. + /// + /// Both run on the *escaped* string so their output tags survive, and both are + /// reachable only from [`Object::Text`] — verbatim, code and source blocks are + /// different objects, which is what keeps `--verbose` in a shell transcript intact. + fn text_html(&self, t: &str) -> String { + let escaped = escape_html(t); + let mut out = String::with_capacity(escaped.len()); + // LaTeX is passed through untouched — `$x^2$` is math for a typesetter, not a + // superscript for us, and an em dash inside a formula is not what was meant. + for (span, is_latex) in latex_split(&escaped) { + if is_latex { + out.push_str(span); + continue; + } + let with_strings = if self.opts.special_strings { + special_strings(span) + } else { + span.to_string() + }; + out.push_str(&sub_superscript(&with_strings, self.opts.sub_superscript)); + } + out + } + fn render_objects(&mut self, objs: &[Object], out: &mut String) { for obj in objs { self.render_object(obj, out); @@ -510,7 +626,7 @@ impl Renderer<'_> { fn render_object(&mut self, obj: &Object, out: &mut String) { match obj { - Object::Text(t) => out.push_str(&escape_html(t)), + Object::Text(t) => out.push_str(&self.text_html(t)), Object::Bold(inner) => self.wrap(out, "strong", inner), Object::Italic(inner) => self.wrap(out, "em", inner), Object::Underline(inner) => self.wrap(out, "u", inner), @@ -735,6 +851,223 @@ fn link_text(target: &LinkTarget) -> String { } } +/// Drop org's special column and its marker rows. +/// +/// A table's first column may hold export markers rather than data — `/` marks a column +/// group, `#` a row to recalculate, `!` a row of names. Rows marked `/ ! ^ _ $` are +/// instructions to org and never appear in the output; the column itself disappears when +/// *every* row uses it that way, which is what stops a table of formulas from publishing +/// with a stray column of hashes. +fn strip_special_column(table: &crate::model::Table) -> crate::model::Table { + let first_cell = |row: &TableRow| -> Option<String> { + match row { + TableRow::Cells(cells) => Some(plain_text(cells.first()?).trim().to_string()), + TableRow::Rule => None, + } + }; + let data_rows = || table.rows.iter().filter(|r| matches!(r, TableRow::Cells(_))); + let column_is_special = data_rows().count() > 0 + && data_rows().all(|row| { + matches!( + first_cell(row).as_deref(), + Some("" | "/" | "#" | "!" | "^" | "_" | "$" | "*") + ) + }); + + let rows = table + .rows + .iter() + .filter(|row| { + // A marker row is an instruction, not content. + !matches!( + first_cell(row).as_deref(), + Some("/" | "!" | "^" | "_" | "$") + ) + }) + .map(|row| match (row, column_is_special) { + (TableRow::Cells(cells), true) => TableRow::Cells(cells[1.min(cells.len())..].to_vec()), + _ => row.clone(), + }) + .collect(); + crate::model::Table { rows } +} + +/// Split text into alternating prose and LaTeX spans, `(text, is_latex)`. +/// +/// org-ssg does not typeset LaTeX — it passes it through for MathJax or a reader's eyes — +/// but it must know where a fragment *is*, because the export-time text conversions would +/// otherwise rewrite the mathematics: `x^2` inside `$…$` is not a superscript to be +/// marked up, and `--` inside one is a minus sign twice. +fn latex_split(s: &str) -> Vec<(&str, bool)> { + let bytes = s.as_bytes(); + let mut spans = Vec::new(); + let mut plain_from = 0; + let mut i = 0; + while i < s.len() { + if !s.is_char_boundary(i) { + i += 1; + continue; + } + let end = match bytes[i] { + b'\\' => latex_backslash_end(s, i), + b'$' => latex_dollar_end(s, i), + _ => None, + }; + if let Some(end) = end { + if plain_from < i { + spans.push((&s[plain_from..i], false)); + } + spans.push((&s[i..end], true)); + plain_from = end; + i = end; + continue; + } + i += 1; + } + if plain_from < s.len() { + spans.push((&s[plain_from..], false)); + } + spans +} + +/// End of a `\(…\)`, `\[…\]` or `\begin{env}…\end{env}` fragment starting at `i`. +fn latex_backslash_end(s: &str, i: usize) -> Option<usize> { + let rest = &s[i..]; + for (open, close) in [("\\(", "\\)"), ("\\[", "\\]")] { + if let Some(body) = rest.strip_prefix(open) { + return body.find(close).map(|k| i + open.len() + k + close.len()); + } + } + let after = rest.strip_prefix("\\begin{")?; + let name_end = after.find('}')?; + let end_tag = format!("\\end{{{}}}", &after[..name_end]); + let k = rest.find(&end_tag)?; + Some(i + k + end_tag.len()) +} + +/// End of a `$…$` fragment starting at `i`, or `None` when this `$` is just a dollar sign. +/// +/// The body may not begin or end with whitespace, which is what keeps "it cost $5 or $6" +/// out — the same heuristic org uses, and the same one that means a sentence with two +/// unrelated dollar amounts and no space between them will be read as math. +fn latex_dollar_end(s: &str, i: usize) -> Option<usize> { + let body = &s[i + 1..]; + let close = body.find('$')?; + if close == 0 { + return None; + } + let inner = &body[..close]; + if inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace) { + return None; + } + if inner.contains('\n') { + return None; + } + Some(i + 1 + close + 1) +} + +/// Org's special strings: `--` becomes an en dash, `---` an em dash, `...` an ellipsis, +/// and `\-` a soft hyphen. +/// +/// A dash run only converts when a non-dash follows it, matching Emacs — so a `---` at the +/// very end of a line, or the `-----` someone drew as a rule, is left alone. +fn special_strings(s: &str) -> String { + let chars: Vec<char> = s.chars().collect(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < chars.len() { + let rest = &chars[i..]; + let followed_by_dash = |n: usize| matches!(rest.get(n), Some('-') | None); + if rest.starts_with(&['\\', '-']) { + out.push('\u{00ad}'); + i += 2; + } else if rest.starts_with(&['-', '-', '-']) && !followed_by_dash(3) { + out.push('\u{2014}'); + i += 3; + } else if rest.starts_with(&['-', '-']) && !followed_by_dash(2) { + out.push('\u{2013}'); + i += 2; + } else if rest.starts_with(&['.', '.', '.']) { + out.push('\u{2026}'); + i += 3; + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +/// Org's `_` and `^` conversions: `H_{2}O`, `x^2`. +/// +/// Both require a non-whitespace character before the marker, which is what separates a +/// subscript from `_underlined text_` — the parser has already taken the emphasis, since +/// that form requires whitespace *before* the marker. +fn sub_superscript(s: &str, mode: SubSuperscript) -> String { + if mode == SubSuperscript::No || !s.contains(['_', '^']) { + return s.to_string(); + } + let chars: Vec<char> = s.chars().collect(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let prev_ok = i > 0 && !chars[i - 1].is_whitespace(); + if (c == '_' || c == '^') && prev_ok { + if let Some((body, next)) = script_body(&chars, i + 1, mode) { + let tag = if c == '_' { "sub" } else { "sup" }; + out.push_str(&format!("<{tag}>{body}</{tag}>")); + i = next; + continue; + } + } + out.push(c); + i += 1; + } + out +} + +/// The scripted text after a `_`/`^`: either `{...}`, or — unless braces are required — +/// a run of alphanumerics ending in one, so `x^2` and `a_b1` convert but `a_ ` does not. +fn script_body(chars: &[char], start: usize, mode: SubSuperscript) -> Option<(String, usize)> { + if chars.get(start) == Some(&'{') { + let mut depth = 1; + let mut j = start + 1; + while j < chars.len() { + match chars[j] { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some((chars[start + 1..j].iter().collect(), j + 1)); + } + } + _ => {} + } + j += 1; + } + return None; + } + if mode == SubSuperscript::Braces { + return None; + } + let mut j = start; + if matches!(chars.get(j), Some('+') | Some('-')) { + j += 1; + } + let mut last_alnum = None; + while let Some(&c) = chars.get(j) { + if c.is_alphanumeric() { + last_alnum = Some(j); + } else if !matches!(c, '.' | ',' | '\\') { + break; + } + j += 1; + } + let end = last_alnum? + 1; + Some((chars[start..end].iter().collect(), end)) +} + fn escape_html(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -33,7 +33,8 @@ use crate::template::{ Templater, }; use crate::util::{ - document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url, + document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled, + output_path, output_url, relative_root, slugify, table_of_contents, }; @@ -239,7 +240,18 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { // Undated pages sort last in the final order regardless of direction: a // draft with no date should not lead an archive. SortKey::Date => entries.sort_by(|a, b| { - let key = |p: &PageContext| p.date_iso.clone(); + // Date *and* time: org records when a note was written, and two notes + // from the same day have an order that the day alone cannot express. + let key = |p: &PageContext| { + p.date_iso.as_ref().map(|d| { + let time = p + .date + .as_deref() + .and_then(iso_time) + .unwrap_or_else(|| "00:00:00".to_string()); + format!("{d}T{time}") + }) + }; match (key(a), key(b)) { (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)), (Some(_), None) => std::cmp::Ordering::Greater, @@ -750,12 +762,14 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { Ok((pages, broken)) } -/// Render options for one page: the site's settings, with the document's own -/// `#+OPTIONS:` switches applied on top. -fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions { +/// The site's render options. A document's own `#+OPTIONS:` switches are applied by the +/// renderer, so every caller gets them. +fn render_options(config: &Config) -> RenderOptions { RenderOptions { heading_offset: config.html.heading_offset, - section_numbers: option_enabled(keywords, "num", config.html.section_numbers), + section_numbers: config.html.section_numbers, + special_strings: config.html.special_strings, + sub_superscript: config.html.sub_superscript, } } @@ -787,9 +801,7 @@ fn render_page( config: &Config, p: &PagePrep, ) -> Result<String> { - // Options are resolved per page, because `#+OPTIONS:` is a per-document override of - // the site setting. - let opts = render_options(config, &p.resolved.document.keywords); + let opts = render_options(config); let Html(fragment) = render_with(&p.resolved, highlighter, &opts); // Relative to the *output* path, since `#+SLUG:` can move a page between depths. let root = relative_root(&p.output); @@ -342,6 +342,28 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as /// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing /// needs one key it can sort on. +/// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one. +/// +/// Two notes written on the same day are not written at the same moment, and org records +/// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them +/// in whatever order the filesystem happened to yield. +pub fn iso_time(raw: &str) -> Option<String> { + let bytes = raw.as_bytes(); + for i in 0..bytes.len().saturating_sub(4) { + let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit); + if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) { + continue; + } + if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') { + continue; + } + let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8); + let end = if with_seconds { i + 8 } else { i + 5 }; + return Some(raw[i..end].to_string()); + } + None +} + pub fn iso_date(raw: &str) -> Option<String> { let bytes = raw.as_bytes(); for i in 0..bytes.len().saturating_sub(9) { @@ -2164,3 +2164,49 @@ fn a_listing_can_group_its_entries_by_year() { ); assert!(html.contains("Undated"), "the undated post is still listed"); } + +/// Two notes written on the same day are not written at the same moment, and org records +/// which came first. Sorting on the date alone throws that away. +#[test] +fn same_day_entries_sort_by_time_of_day() { + let root = tmpdir("sorttime"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("blog")).unwrap(); + std::fs::create_dir_all(src.join("templates")).unwrap(); + for (name, title, date) in [ + ("morning", "Morning", "[2026-02-21 Sat 09:15:00]"), + ("evening", "Evening", "[2026-02-21 Sat 21:40:00]"), + ("noon", "Noon", "[2026-02-21 Sat 12:30]"), + ] { + std::fs::write( + src.join(format!("blog/{name}.org")), + format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"), + ) + .unwrap(); + } + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\ + template = \"list.html\"\ntitle = \"Blog\"\nsort = \"date\"\norder = \"desc\"\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let html = page(&out, "blog/index.html"); + let order: Vec<&str> = html + .split("<li>") + .skip(1) + .map(|s| s.split('<').next().unwrap()) + .collect(); + assert_eq!( + order, + vec!["Evening", "Noon", "Morning"], + "newest first, by the clock:\n{html}" + ); +} @@ -141,7 +141,7 @@ fn preamble_keywords_do_not_disturb_the_content_around_them() { "a keyword between two paragraphs must not merge them:\n{html}" ); assert!( - html.contains("<figcaption>shot</figcaption>"), + html.contains("<figcaption><span class=\"figure-number\">Figure 1: </span>shot</figcaption>"), "a preamble `#+CAPTION:` must still attach to the image below it:\n{html}" ); } @@ -265,13 +265,19 @@ fn non_html_export_blocks_are_dropped() { ); } -/// An unmodelled block type keeps its content rather than vanishing. +/// A verse block keeps its line breaks, and a block with an unrecognised name becomes a +/// div holding parsed org — the two ways a `#+BEGIN_` other than SRC/EXAMPLE/QUOTE/CENTER +/// can carry content. #[test] -fn unknown_block_types_keep_their_content() { - let html = render_fixture("outofscope.org"); +fn verse_and_special_blocks_keep_their_content() { + let html = render_fixture("blocks.org"); assert!( - html.contains("An unmodelled block type"), - "a verse block degrades to a verbatim example block:\n{html}" + html.contains("<p class=\"verse\">") && html.contains("Line breaks are the point<br>"), + "verse keeps its breaks:\n{html}" + ); + assert!( + html.contains("<div class=\"note\">") && html.contains("<strong>org</strong>"), + "a special block holds parsed org:\n{html}" ); } @@ -502,3 +508,176 @@ fn an_ordinary_leading_comma_is_not_stripped() { assert!(text.contains(", a list continuation"), "{html}"); assert!(text.contains(",not an escape"), "{html}"); } + +// --------------------------------------------------------------------------- +// Export-time text conversions +// --------------------------------------------------------------------------- + +fn html_of(source: &str) -> String { + let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + html +} + +/// Org converts dash runs and ellipses in prose. A reader of the published page should +/// see the typography the author meant, not the ASCII they had to type. +#[test] +fn special_strings_become_real_punctuation() { + let html = html_of("An em---dash, an en--dash, and an ellipsis...\n"); + assert!(html.contains("em\u{2014}dash"), "em dash:\n{html}"); + assert!(html.contains("en\u{2013}dash"), "en dash:\n{html}"); + assert!(html.contains("ellipsis\u{2026}"), "ellipsis:\n{html}"); +} + +/// A shell transcript is not prose. `--verbose` inside code has to survive intact, or +/// copying a command off the page produces one that does not run. +#[test] +fn special_strings_leave_code_alone() { + let html = html_of( + "Prose --dash and ~ls --all~ and =grep --color=.\n\n\ + #+BEGIN_SRC sh\nls --all\n#+END_SRC\n", + ); + assert!(html.contains("Prose \u{2013}dash"), "prose converts:\n{html}"); + assert!(html.contains("<code>ls --all</code>"), "inline code:\n{html}"); + assert!(html.contains("--color"), "verbatim:\n{html}"); + // The source block's `--all` is split across highlighting spans, so count the + // conversion itself: exactly one en dash on the page, the one in the prose. + assert_eq!( + html.matches('\u{2013}').count(), + 1, + "nothing inside code converted:\n{html}" + ); +} + +/// `#+OPTIONS: -:nil` is how a document opts out, and org-ssg honours org's own switch +/// rather than inventing one. +#[test] +fn a_document_can_turn_special_strings_off() { + let html = html_of("#+OPTIONS: -:nil\n\nAn em---dash and an ellipsis...\n"); + assert!(html.contains("em---dash"), "left alone:\n{html}"); + assert!(html.contains("ellipsis..."), "left alone:\n{html}"); +} + +/// Sub- and superscripts, including the braceless form — which is what makes +/// `snake_case` in prose render as a subscript, exactly as Emacs does with the same file. +#[test] +fn sub_and_superscripts_convert() { + let html = html_of("Water is H_2O, x^2 is a square, and sshd_{config}.d is a path.\n"); + assert!(html.contains("H<sub>2O</sub>"), "braceless subscript:\n{html}"); + assert!(html.contains("x<sup>2</sup>"), "superscript:\n{html}"); + assert!(html.contains("sshd<sub>config</sub>.d"), "braced:\n{html}"); +} + +/// `_underlined_` is emphasis, not a subscript. The two are told apart by what comes +/// *before* the marker: emphasis follows whitespace, a script follows a word. +#[test] +fn underline_still_wins_where_org_says_it_does() { + let html = html_of("Some _underlined_ text.\n"); + assert!(html.contains("<u>underlined</u>"), "underline:\n{html}"); + assert!(!html.contains("<sub>"), "not a subscript:\n{html}"); +} + +/// `#+OPTIONS: ^:nil` turns them off, `^:{}` limits them to the braced form. +#[test] +fn a_document_can_restrict_sub_and_superscripts() { + let off = html_of("#+OPTIONS: ^:nil\n\nH_2O and x^2.\n"); + assert!(off.contains("H_2O") && off.contains("x^2"), "off:\n{off}"); + + let braces = html_of("#+OPTIONS: ^:{}\n\nH_2O and a_{b}.\n"); + assert!(braces.contains("H_2O"), "braceless left alone:\n{braces}"); + assert!(braces.contains("a<sub>b</sub>"), "braced converts:\n{braces}"); +} + +/// LaTeX is passed through for a typesetter, so the text conversions must not reach +/// inside it: `x^2` in `$…$` is mathematics, not markup. +#[test] +fn latex_fragments_are_left_intact() { + let html = html_of("Inline $x^2 + y^2$ and \\(a_1\\) and \\[E = mc^2\\] stay put.\n"); + for literal in ["$x^2 + y^2$", "\\(a_1\\)", "\\[E = mc^2\\]"] { + assert!(html.contains(literal), "`{literal}` survives:\n{html}"); + } +} + +/// A dollar amount is not a formula. The body of a `$…$` fragment may not begin or end +/// with a space, which is what keeps prices out of the math. +#[test] +fn dollar_amounts_are_not_latex() { + let html = html_of("It cost $5 or $6 --- a bargain.\n"); + assert!(html.contains("\u{2014}"), "the em dash still converts:\n{html}"); +} + +/// Org exports outline levels relative to the file's own shallowest heading, so a +/// document written entirely under `**` is a document of top-level sections. +#[test] +fn heading_levels_are_relative_to_the_shallowest_heading() { + let html = html_of("#+TITLE: T\n\n** First\n\nBody.\n\n*** Nested\n\nMore.\n"); + assert!(html.contains("<h2 id=\"first\">"), "** becomes h2:\n{html}"); + assert!(html.contains("<h3 id=\"nested\">"), "*** becomes h3:\n{html}"); +} + +/// An unknown `#+BEGIN_` block is a special block: a div with that name, holding org. +/// Rendering its contents as literal text loses the markup the author wrote. +#[test] +fn a_special_block_holds_org_not_text() { + let html = html_of("#+BEGIN_NOTE\n*Note:* read this.\n#+END_NOTE\n"); + assert!(html.contains("<div class=\"note\">"), "named div:\n{html}"); + assert!(html.contains("<strong>Note:</strong>"), "markup parsed:\n{html}"); +} + +/// A path that starts with `~` inside `~…~` verbatim: the body may open with the same +/// character as the marker, and org says so. +#[test] +fn verbatim_can_start_with_its_own_marker() { + let html = html_of("Edit ~~/.config/doom/config.el~ now.\n"); + assert!( + html.contains("<code>~/.config/doom/config.el</code>"), + "the leading ~ belongs to the path:\n{html}" + ); +} + +/// Org's special first column holds export markers, not data: `/` marks a column group, +/// `#` a row to recalculate. Publishing them puts a column of punctuation on the page. +#[test] +fn a_tables_special_column_and_marker_rows_are_dropped() { + let html = html_of( + "| N | N^2 |\n\ + | / | < |\n\ + | 1 | 1 |\n", + ); + assert!(!html.contains("<td>/</td>"), "the marker row is gone:\n{html}"); + assert!(html.contains("<td>1</td>"), "the data row stays:\n{html}"); + + // Every row marked, so the column itself goes too. + let all_marked = html_of( + "| # | exp(x) | 1 |\n\ + | # | exp(x) | 2 |\n", + ); + assert!( + !all_marked.contains(">#<"), + "a wholly-special column is dropped:\n{all_marked}" + ); + assert!(all_marked.contains("exp(x)"), "data survives:\n{all_marked}"); +} + +/// An affiliated keyword belongs to the element *immediately* below it. Someone who +/// writes `#+CAPTION:` under their image has captioned nothing — and captioning the next +/// image instead would put the wrong words under the wrong picture. +#[test] +fn a_blank_line_ends_a_captions_association() { + let html = html_of( + "[[file:one.png]]\n\ + #+CAPTION: stranded\n\ + \n\ + [[file:two.png]]\n", + ); + assert!( + !html.contains("stranded"), + "an orphaned caption attaches to nothing:\n{html}" + ); + + let attached = html_of("#+CAPTION: attached\n[[file:one.png]]\n"); + assert!( + attached.contains("<figcaption>"), + "a caption directly above its image still works:\n{attached}" + ); +} @@ -25,3 +25,12 @@ expression: "render_fixture(\"blocks.org\")" <p>A quote containing a source block:</p> <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> </blockquote> +<h2 id="verse">Verse</h2> +<p class="verse"> +Line breaks are the point<br> + and indentation survives. +</p> +<h2 id="a-named-special-block">A named special block</h2> +<div class="note"> +<p>Contents are <strong>org</strong>, not literal text.</p> +</div> @@ -5,9 +5,9 @@ expression: "render_fixture(\"images.org\")" <h2 id="bare-image">Bare image</h2> <p><img src="diagram.png" alt=""></p> <h2 id="captioned-figure">Captioned figure</h2> -<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> +<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> <h2 id="caption-with-markup">Caption with markup</h2> -<figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> +<figure><img src="chart.png" alt="A stylised chart"><figcaption><span class="figure-number">Figure 2: </span>A <em>stylised</em> chart</figcaption></figure> <h2 id="quoted-attribute-values">Quoted attribute values</h2> <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> <h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2> @@ -37,6 +37,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -53,6 +54,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -69,6 +71,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -88,6 +91,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -107,6 +111,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -151,6 +156,7 @@ expression: "parse_fixture(\"lists.org\").root" "bullet": { "Ordered": 1 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -167,6 +173,7 @@ expression: "parse_fixture(\"lists.org\").root" "bullet": { "Ordered": 2 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -185,6 +192,7 @@ expression: "parse_fixture(\"lists.org\").root" "bullet": { "Ordered": 1 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -201,6 +209,7 @@ expression: "parse_fixture(\"lists.org\").root" "bullet": { "Ordered": 2 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -222,6 +231,7 @@ expression: "parse_fixture(\"lists.org\").root" "bullet": { "Ordered": 3 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -264,6 +274,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": "Off", "term": null, "content": [ @@ -278,6 +289,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": "On", "term": null, "content": [ @@ -292,6 +304,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": "Trans", "term": null, "content": [ @@ -334,6 +347,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": [ { @@ -352,6 +366,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": [ { @@ -370,6 +385,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": [ { @@ -423,6 +439,7 @@ expression: "parse_fixture(\"lists.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -444,6 +461,7 @@ expression: "parse_fixture(\"lists.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -26,9 +26,9 @@ expression: "render_fixture(\"lists.org\")" </ol> <h2 id="checkboxes">Checkboxes</h2> <ul> -<li><input type="checkbox" disabled> not done</li> -<li><input type="checkbox" disabled checked> done</li> -<li><input type="checkbox" disabled> partially done</li> +<li class="off"><code>[ ]</code> not done</li> +<li class="on"><code>[X]</code> done</li> +<li class="trans"><code>[-]</code> partially done</li> </ul> <h2 id="description">Description</h2> <dl> @@ -21,8 +21,5 @@ expression: "render_fixture(\"outofscope.org\")" <h2 id="macros-and-radio-targets">Macros and radio targets</h2> <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> <h2 id="drawers">Drawers</h2> -<h2 id="verse">Verse</h2> -<pre>An unmodelled block type -keeps its content verbatim.</pre> <h2 id="entities">Entities</h2> <p>The full entity set is out of scope, so \alpha stays literal.</p> @@ -2,7 +2,7 @@ source: tests/oracle.rs expression: report --- -agreement: 51/59 skeleton lines (86.4%) +agreement: 67/77 skeleton lines (87.0%) (- org-ssg, + emacs) <h2> @@ -66,3 +66,24 @@ agreement: 51/59 skeleton lines (86.4%) - </code> </pre> </blockquote> + <h2> + "Verse" + </h2> + <p> + "Line breaks are the point" + <br> + "and indentation survives." ++ <br> + </p> + <h2> + "A named special block" + </h2> + <p> + "Contents are" +- <strong> ++ <b> + "org" +- </strong> ++ </b> + ", not literal text." + </p> @@ -2,7 +2,7 @@ source: tests/oracle.rs expression: report --- -agreement: 45/54 skeleton lines (83.3%) +agreement: 51/58 skeleton lines (87.9%) (- org-ssg, + emacs) <p> @@ -34,17 +34,15 @@ agreement: 45/54 skeleton lines (83.3%) - </ol> - <ul> <li> -- <input> -+ <code> -+ "[ ]" -+ </code> + <code> + "[ ]" + </code> "todo item" </li> <li> -- <input> -+ <code> -+ "[X]" -+ </code> + <code> + "[X]" + </code> "done item" </li> - </ul> @@ -2,7 +2,7 @@ source: tests/oracle.rs expression: report --- -agreement: 28/42 skeleton lines (66.7%) +agreement: 30/42 skeleton lines (71.4%) (- org-ssg, + emacs) <h2> @@ -18,12 +18,11 @@ agreement: 28/42 skeleton lines (66.7%) + <p> <img src="pipeline.svg"> - <figcaption> -- "The pipeline, end to end" -- </figcaption> -- </figure> + </p> + <p> -+ "Figure 1: The pipeline, end to end" + "Figure 1: The pipeline, end to end" +- </figcaption> +- </figure> + </p> <h2> "Caption with markup" @@ -32,11 +31,10 @@ agreement: 28/42 skeleton lines (66.7%) + <p> <img src="chart.png"> - <figcaption> -- "A" -- <em> + </p> + <p> -+ "Figure 2: A" + "Figure 2: A" +- <em> + <i> "stylised" - </em> @@ -2,7 +2,7 @@ source: tests/oracle.rs expression: report --- -agreement: 100/111 skeleton lines (90.1%) +agreement: 109/111 skeleton lines (98.2%) (- org-ssg, + emacs) <h2> @@ -56,24 +56,21 @@ agreement: 100/111 skeleton lines (90.1%) </h2> <ul> <li> -- <input> -+ <code> -+ "[ ]" -+ </code> + <code> + "[ ]" + </code> "not done" </li> <li> -- <input> -+ <code> -+ "[X]" -+ </code> + <code> + "[X]" + </code> "done" </li> <li> -- <input> -+ <code> -+ "[-]" -+ </code> + <code> + "[-]" + </code> "partially done" </li> </ul> @@ -63,6 +63,7 @@ expression: "parse_fixture(\"core.org\").root" "bullet": { "Ordered": 1 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -79,6 +80,7 @@ expression: "parse_fixture(\"core.org\").root" "bullet": { "Ordered": 2 }, + "counter": null, "checkbox": null, "term": null, "content": [ @@ -107,6 +109,7 @@ expression: "parse_fixture(\"core.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": "Off", "term": null, "content": [ @@ -121,6 +124,7 @@ expression: "parse_fixture(\"core.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": "On", "term": null, "content": [ @@ -9,8 +9,8 @@ expression: "render_fixture(\"core.org\")" <li>second item with <em>emphasis</em></li> </ol> <ul> -<li><input type="checkbox" disabled> todo item</li> -<li><input type="checkbox" disabled checked> done item</li> +<li class="off"><code>[ ]</code> todo item</li> +<li class="on"><code>[X]</code> done item</li> </ul> <h2 id="links-and-code">Links and code</h2> <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> @@ -117,6 +117,7 @@ expression: "parse_fixture(\"minimal.org\").root" "items": [ { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [ @@ -131,6 +132,7 @@ expression: "parse_fixture(\"minimal.org\").root" }, { "bullet": "Dash", + "counter": null, "checkbox": null, "term": null, "content": [