krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
ba4152c75ebfc45fa02279a0d355613465d42a0a
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T04:20:59Z
README.md | 90 +++- fixtures/slugsite/2024-02-11-long-source-name.org | 11 + fixtures/slugsite/index.org | 3 + src/audit.rs | 620 ++++++++++++++++++++++ src/index.rs | 44 +- src/lib.rs | 1 + src/main.rs | 11 + src/resolve.rs | 8 +- src/site.rs | 31 +- src/util.rs | 64 ++- tests/oracle.el | 42 ++ tests/oracle.rs | 454 ++++++++++++++++ tests/site.rs | 83 +++ tests/snapshots/oracle__oracle_blocks.snap | 68 +++ tests/snapshots/oracle__oracle_core.snap | 70 +++ tests/snapshots/oracle__oracle_elements.snap | 103 ++++ tests/snapshots/oracle__oracle_footnote.snap | 83 +++ tests/snapshots/oracle__oracle_headings.snap | 39 ++ tests/snapshots/oracle__oracle_images.snap | 63 +++ tests/snapshots/oracle__oracle_lists.snap | 123 +++++ tests/snapshots/oracle__oracle_minimal.snap | 53 ++ tests/snapshots/oracle__oracle_table.snap | 41 ++ tests/snapshots/oracle__oracle_timestamps.snap | 74 +++ 23 files changed, 2133 insertions(+), 46 deletions(-) @@ -25,6 +25,7 @@ is the only inherently global stage — it is where the link dependency graph is | Stage | Module | Notes | |---|---|---| | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | +| audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | | INDEX | `src/index.rs` | Collect link targets into a symbol table. | | RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). | @@ -50,8 +51,9 @@ 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 asserting it degrades predictably (ignored, no crash). The IN/OUT line is enforced by `tests/constructs.rs`, defending against the project's #1 risk: scope creep back toward -all-of-org. The fixtures are hand-written today; deriving them from a real corpus is -Phase 0. +all-of-org. Phase 0 checked this line against a real 179-file corpus and found it sound +(99.9% of construct uses in scope) — but also found one thing missing from it entirely: +`#+SLUG:`. See [Phase 0](#phase-0-the-corpus-audit-and-the-emacs-oracle). ## Phase plan @@ -62,7 +64,7 @@ Phase 0. | **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build <src-dir> <out-dir>`, tables + footnotes** | **done** | | **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** | | **v0.4** | **MVP: the full v1 construct scope — heading metadata, nested/description lists, block types, timestamps, images, syntect highlighting — with the IN/OUT line under test** | **done** | -| 0 | Corpus audit + `emacs --batch` ground-truth oracle | todo | +| **0** | **Corpus audit + `emacs --batch` ground-truth oracle** | **done** | | 1 | Line lexer + heading/section skeleton | done | | 2 | Block elements — lists, source blocks, tables, footnote defs, blocks by type, drawers | done | | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done | @@ -162,11 +164,82 @@ excluded construct to a specific degradation: babel is never executed *and* a ch as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block types keep their content verbatim. -**Still out at v0.4:** the Phase 0 corpus audit and `emacs --batch` oracle (the fixtures are -hand-written, so "matches Emacs" is asserted by construction, not measured); rayon -parallelism; parse errors carrying source locations; `#+TODO:` per-file keyword sequences; -planning lines (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width -`: ` lines; and the `watch` fs-notify integration. +**Still out at v0.4:** rayon parallelism; parse errors carrying source locations; `#+TODO:` +per-file keyword sequences; planning lines (`SCHEDULED:`/`DEADLINE:`), which render as +ordinary paragraphs; fixed-width `: ` lines; and the `watch` fs-notify integration. + +## Phase 0: the corpus audit and the Emacs oracle + +The v1 scope was, by its own admission, *recommended* — a guess about which slice of org +matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks +what a real corpus actually uses, and an oracle that asks whether we render it the way +Emacs does. The corpus is the 179 files behind [cleberg.net](https://cleberg.net), which is +published today by weblorg — a wrapper around org's own HTML exporter. That makes it both +the workload and the incumbent. + +``` +cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? +cargo test --test oracle # how does our HTML differ from Emacs' own export? +``` + +### What the audit found + +**The scope guess was sound.** 99.9% of construct uses in the corpus are in scope. The +whole out-of-scope tail is 8 uses: four `#+TBLFM:` in a post *about* org-mode, three +`\name` entities, and one `#+BEGIN_NOTE`. + +**`#+SLUG:` was a hole big enough to sink the project.** 178 of 179 files set it, and the +published URL comes from it, not from the filename: `2018-11-28-aes-encryption.org` is +served at `blog/aes-encryption.html`. org-ssg derived output paths from source filenames, +so **169 of 179 pages would have been published at the wrong URL** — every inbound link and +every search result, broken, by a tool that reported a clean build. Output paths now come +from `#+SLUG:` when present ([`util::output_path`](src/util.rs)); slugs are sanitized so an +author-supplied `../../etc/x` cannot escape the output directory, and two pages claiming one +URL is a build error rather than a silently dropped page. Building the real corpus now +reproduces all 179 of the live site's URLs exactly. + +**Some machinery is speculative.** The corpus contains no `id:`, `#custom-id` or `*Heading` +links at all — its cross-page links are hand-written relative URLs. The INDEX/RESOLVE +symbol table that v0.2 was built around is, against this corpus, unexercised. + +**An audit can lie too.** The first run reported 23 uses of a custom TODO keyword sequence. +All 23 were false: the detector read the leading word of `* CSS Variables` as the keyword +`CSS`. The corpus defines no `#+TODO:` sequences at all, so the true count was zero. The +detector now matches conventional keyword names only — a tool that overstates a gap argues +for work nobody needs. + +### What the oracle found + +`tests/oracle.rs` exports each fixture with org's own exporter via `emacs --batch`, reduces +both sides to a semantic skeleton (element opens, closes and text, with layout `div`s, +inline `span`s and all attributes but `href`/`src` dropped), and **snapshots the +disagreement**. Snapshotting rather than asserting is deliberate: a checked-in divergence +report gets reviewed and shows up as a diff, where a permanently red test gets ignored. +Three invariants are asserted outright, and all three hold — heading structure, list +nesting, and source-block text match Emacs exactly. + +**No bugs in org-ssg.** Every remaining divergence is a deliberate choice to emit better +HTML than org does: + +| | org-ssg | Emacs | why | +|---|---|---|---| +| emphasis | `<em>`/`<strong>` | `<i>`/`<b>` | semantic, not presentational | +| captioned image | `<figure>`/`<figcaption>` | `<p>` + `"Figure 1: …"` | real figure semantics | +| timestamp | `<time datetime="…">` | literal `<2024-01-15 Mon>` | machine-readable | +| footnotes | `<section><ol>` | `<h2>Footnotes:</h2>` | a list of notes is a list | +| heading anchor | slug of the text | `org1a2b3c4` | stable, and what the live site serves | +| code | `<pre><code>` | `<pre>` | the HTML5 idiom | + +One genuine semantic difference: org treats a single blank line between a `1.` list and a +`-` list as *one* list and keeps the first item's bullet type, while we start a second list. +We keep ours, on measurement rather than taste — the pattern occurs **zero** times in the +corpus, so matching an org quirk would buy nothing and cost the more obvious reading. + +**The oracle's best catch was three bugs in itself.** Naive normalization reported code as +corrupted (it trimmed each of syntect's per-token text runs, turning `def greet` into +`defgreet`) and reported blocks at 36% agreement (syntect's spans flooded the diff). Both +were measurement artifacts. A differential harness is a piece of software like any other, +and the first divergences it reports are usually its own. **From v0.1 (core subset):** headings with nesting and anchors (every heading is now anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs; @@ -188,6 +261,7 @@ cargo build cargo test cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) +cargo run -- audit fixtures/site # corpus audit (Phase 0) cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild cargo run -- watch fixtures/site -o _site # poll + rebuild on change cargo run -- clean _site # remove output + cache new file mode 100644 @@ -0,0 +1,11 @@ +#+TITLE: The Post +#+SLUG: short-url + +The source filename carries a date; the published URL does not. + +* Setup +:PROPERTIES: +:CUSTOM_ID: setup +:END: + +Linking into this heading must land on the slugged page, not the source name. new file mode 100644 @@ -0,0 +1,3 @@ +#+TITLE: Home + +Read [[file:2024-02-11-long-source-name.org][the post]], or jump to its [[#setup][setup section]]. new file mode 100644 @@ -0,0 +1,620 @@ +//! Corpus audit (spec §5, Phase 0): measure which org constructs a real corpus actually +//! uses, and classify each against the v1 IN/OUT line. +//! +//! This exists because the v1 scope was, on the README's own admission, *recommended* +//! rather than measured — a guess about which slice of org matters. A guess about a +//! corpus is a hypothesis, and this is the experiment. It answers two questions: +//! +//! 1. **Coverage** — of the constructs this corpus uses, which do we handle? A construct +//! that is common here and out of scope is a scope bug, not a corpus quirk. +//! 2. **Blind spots** — which constructs are here that the implementation has no opinion +//! about at all? These are the dangerous ones: not "known unsupported" but unknown. +//! +//! The audit is deliberately a *separate, line-oriented scanner* rather than a reuse of +//! [`crate::parser`]. Auditing with the parser could only ever find constructs the parser +//! already knows about, which is precisely the wrong instrument for question 2 — it would +//! report a blind spot as clean. +//! +//! Nothing here reports document *text*. Counts, construct names, and `file:line` +//! locations only, so an audit of private notes stays publishable. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use walkdir::WalkDir; + +/// Where a construct sits relative to the v1 scope line (README §"v1 scope"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Scope { + /// v1 handles this. + In, + /// v1 deliberately excludes this; it degrades predictably. + Out, +} + +impl Scope { + fn label(self) -> &'static str { + match self { + Scope::In => "IN ", + Scope::Out => "OUT", + } + } +} + +/// One construct's tally across the corpus. +#[derive(Debug, Default, Clone)] +pub struct Tally { + pub occurrences: usize, + pub files: usize, + /// First `file:line` the construct was seen at, to make a finding actionable. + pub first_seen: Option<String>, + /// Set while scanning one file, to count each file once. + seen_in_current_file: bool, +} + +/// The audit result: the fixed construct catalog plus the dynamic name censuses. +#[derive(Debug, Default)] +pub struct Audit { + pub files: usize, + pub lines: usize, + /// Catalogued constructs → tally. + pub constructs: BTreeMap<(Scope, &'static str), Tally>, + /// Every distinct `#+KEYWORD:` seen, by name. + pub keywords: BTreeMap<String, Tally>, + /// Every distinct `#+BEGIN_<TYPE>` seen, by type. + pub blocks: BTreeMap<String, Tally>, + /// Every distinct `:DRAWER:` seen, by name. + pub drawers: BTreeMap<String, Tally>, + /// Every distinct link scheme seen (`https`, `file`, `id`, `denote`, ...). + pub link_schemes: BTreeMap<String, Tally>, +} + +/// Names the implementation understands, so the census can flag everything else. These +/// are the *recognized* sets, not the supported ones: `INCLUDE` is recognized (it is +/// deliberately inert) while an unlisted keyword is a genuine blind spot. +const KNOWN_KEYWORDS: &[&str] = &[ + "TITLE", "AUTHOR", "DATE", "EMAIL", "LANGUAGE", "OPTIONS", "FILETAGS", "DESCRIPTION", + "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"]; +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 +/// reports separately — this list keeps the heading-level signal honest. +const CONVENTIONAL_TODO_KEYWORDS: &[&str] = &[ + "NEXT", "WAITING", "HOLD", "CANCELLED", "CANCELED", "STARTED", "SOMEDAY", "PROJ", + "IN-PROGRESS", "BLOCKED", "REVIEW", +]; +const KNOWN_SCHEMES: &[&str] = &[ + "http", "https", "mailto", "ftp", "news", "tel", "file", "id", "custom-id", "heading", + "relative", +]; + +impl Audit { + /// Is this name one the implementation recognizes? + pub fn is_known(kind: Census, name: &str) -> bool { + let known = match kind { + Census::Keyword => KNOWN_KEYWORDS, + Census::Block => KNOWN_BLOCKS, + Census::Drawer => KNOWN_DRAWERS, + Census::Scheme => KNOWN_SCHEMES, + }; + known.iter().any(|k| k.eq_ignore_ascii_case(name)) + } +} + +/// Which dynamic census a name belongs to. +#[derive(Debug, Clone, Copy)] +pub enum Census { + Keyword, + Block, + Drawer, + Scheme, +} + +/// Walk `root`, auditing every `.org` file. +pub fn audit(root: &Utf8Path) -> Result<Audit> { + let mut audit = Audit::default(); + let mut paths: Vec<Utf8PathBuf> = Vec::new(); + + if root.is_file() { + paths.push(root.to_owned()); + } else { + for entry in WalkDir::new(root).sort_by_file_name() { + let entry = entry.with_context(|| format!("walking {root}"))?; + if !entry.file_type().is_file() { + continue; + } + let path = Utf8PathBuf::from_path_buf(entry.into_path()) + .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?; + if path.extension() == Some("org") { + paths.push(path); + } + } + } + + for path in &paths { + // A file that cannot be read is reported and skipped: an audit of 179 files + // should not be lost to one unreadable one. + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("warning: skipping {path}: {e}"); + continue; + } + }; + let rel = path.strip_prefix(root).unwrap_or(path).to_owned(); + audit.scan_file(&rel, &source); + audit.files += 1; + } + Ok(audit) +} + +impl Audit { + fn scan_file(&mut self, path: &Utf8Path, source: &str) { + // Reset the per-file flags so each construct counts this file at most once. + for tally in self.constructs.values_mut() { + tally.seen_in_current_file = false; + } + for map in [ + &mut self.keywords, + &mut self.blocks, + &mut self.drawers, + &mut self.link_schemes, + ] { + for tally in map.values_mut() { + tally.seen_in_current_file = false; + } + } + + let mut in_block: Option<String> = None; + for (idx, line) in source.lines().enumerate() { + self.lines += 1; + let at = format!("{path}:{}", idx + 1); + let trimmed = line.trim_start(); + + // Inside a verbatim block only the terminator matters — a `*` in a source + // block is not a heading, and counting it as one would corrupt the audit. + if let Some(kind) = &in_block { + if trimmed.to_ascii_uppercase().starts_with("#+END_") { + in_block = None; + } else if kind.eq_ignore_ascii_case("SRC") || kind.eq_ignore_ascii_case("EXAMPLE") { + continue; + } + continue; + } + if let Some(rest) = trimmed.to_ascii_uppercase().strip_prefix("#+BEGIN_") { + let kind = rest.split_whitespace().next().unwrap_or("").to_string(); + self.count_census(Census::Block, &kind, &at); + self.count(scope_of_block(&kind), block_construct(&kind), &at); + if trimmed.to_ascii_uppercase().contains(":RESULTS") { + self.count(Scope::Out, "babel header args (:results)", &at); + } + in_block = Some(kind); + continue; + } + + self.scan_line(line, trimmed, &at); + } + } + + fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) { + // --- headings and their metadata --- + if let Some(stars) = heading_stars(line) { + self.count(Scope::In, "heading", at); + let rest = line[stars..].trim(); + let word = rest.split_whitespace().next().unwrap_or(""); + if word == "TODO" || word == "DONE" { + self.count(Scope::In, "TODO keyword (default set)", at); + } else if CONVENTIONAL_TODO_KEYWORDS.contains(&word) { + // Only conventional keyword names count. "Any all-caps first word" is + // the tempting rule and it is wrong: it reads `* CSS Variables` as the + // keyword `CSS`, which on this corpus produced 23 false positives and + // zero true ones. An audit that overstates a gap is worse than no audit, + // because it argues for work nobody needs. + self.count(Scope::Out, "TODO keyword (custom sequence)", at); + } + if rest.contains("[#") { + self.count(Scope::In, "priority cookie", at); + } + if rest.trim_end().ends_with(':') && rest.trim_end().matches(':').count() >= 2 { + self.count(Scope::In, "heading tags", at); + } + if rest.contains("[/") || rest.contains("[%") { + self.count(Scope::Out, "statistics cookie", at); + } + return; + } + + // --- planning and clocking --- + for marker in ["SCHEDULED:", "DEADLINE:", "CLOSED:"] { + if trimmed.starts_with(marker) { + self.count(Scope::Out, "planning line", at); + } + } + if trimmed.starts_with("CLOCK:") { + self.count(Scope::Out, "clock entry", at); + } + + // --- keywords and drawers --- + if let Some(rest) = trimmed.strip_prefix("#+") { + if let Some(colon) = rest.find(':') { + let key = rest[..colon].trim().to_ascii_uppercase(); + if !key.is_empty() && !key.contains(char::is_whitespace) { + self.count_census(Census::Keyword, &key, at); + match key.as_str() { + "CAPTION" | "NAME" | "ATTR_HTML" => { + self.count(Scope::In, "affiliated keyword", at) + } + "TBLFM" => self.count(Scope::Out, "table formula (#+TBLFM:)", at), + "INCLUDE" => self.count(Scope::Out, "#+INCLUDE:", at), + "RESULTS" => self.count(Scope::Out, "babel results block", at), + "TODO" => self.count(Scope::Out, "#+TODO: keyword sequence", at), + "MACRO" => self.count(Scope::Out, "macro definition", at), + _ => self.count(Scope::In, "#+ keyword", at), + } + } + } + } else if is_drawer(trimmed) { + let name = trimmed[1..trimmed.len() - 1].to_ascii_uppercase(); + if name != "END" { + self.count_census(Census::Drawer, &name, at); + match name.as_str() { + "PROPERTIES" => self.count(Scope::In, "property drawer", at), + _ => self.count(Scope::Out, "non-PROPERTIES drawer", at), + } + } + } + + // --- lists, tables, rules --- + if let Some(bullet) = list_bullet(trimmed) { + self.count(Scope::In, "list item", at); + if bullet == Bullet::Ordered { + self.count(Scope::In, "ordered list", at); + } + let indent = line.len() - trimmed.len(); + if indent > 0 { + self.count(Scope::In, "nested list item", at); + } + if trimmed.contains(" :: ") { + self.count(Scope::In, "description list", at); + } + let after = trimmed.trim_start_matches(['-', '+', '*', ' ']); + if after.starts_with("[ ]") || after.starts_with("[X]") || after.starts_with("[-]") { + self.count(Scope::In, "checkbox", at); + } + } + if trimmed.starts_with('|') { + self.count(Scope::In, "table row", at); + } + if trimmed.starts_with(':') && !is_drawer(trimmed) && trimmed.starts_with(": ") { + self.count(Scope::Out, "fixed-width line", at); + } + + // --- footnotes --- + if trimmed.starts_with("[fn:") { + self.count(Scope::In, "footnote definition", at); + } else if line.contains("[fn:") { + self.count(Scope::In, "footnote reference", at); + } + + // --- inline objects --- + self.scan_inline(line, at); + } + + fn scan_inline(&mut self, line: &str, at: &str) { + // Links: count each `[[target]]`, censusing its scheme. + let mut rest = line; + while let Some(start) = rest.find("[[") { + let after = &rest[start + 2..]; + let Some(end) = after.find("]]") else { break }; + let inner = &after[..end]; + let target = inner.split("][").next().unwrap_or(inner); + self.count(Scope::In, "link", at); + self.count_census(Census::Scheme, &link_scheme(target), at); + rest = &after[end..]; + } + + if has_timestamp(line) { + self.count(Scope::In, "timestamp", at); + } + if line.contains("{{{") { + self.count(Scope::Out, "macro call", at); + } + if line.contains("<<<") { + self.count(Scope::Out, "radio target", at); + } else if line.contains("<<") && line.contains(">>") { + self.count(Scope::Out, "internal target", at); + } + if line.contains("\\begin{") || latex_inline(line) { + self.count(Scope::Out, "LaTeX fragment", at); + } + if entity_ref(line) { + self.count(Scope::Out, "entity (\\name)", at); + } + for (marker, name) in [ + ('*', "bold"), + ('/', "italic"), + ('_', "underline"), + ('+', "strike-through"), + ('=', "verbatim"), + ('~', "code"), + ] { + if emphasis_pair(line, marker) { + self.count(Scope::In, name, at); + } + } + } + + fn count(&mut self, scope: Scope, name: &'static str, at: &str) { + let tally = self.constructs.entry((scope, name)).or_default(); + bump(tally, at); + } + + fn count_census(&mut self, kind: Census, name: &str, at: &str) { + let map = match kind { + Census::Keyword => &mut self.keywords, + Census::Block => &mut self.blocks, + Census::Drawer => &mut self.drawers, + Census::Scheme => &mut self.link_schemes, + }; + let tally = map.entry(name.to_string()).or_default(); + bump(tally, at); + } +} + +fn bump(tally: &mut Tally, at: &str) { + tally.occurrences += 1; + if !tally.seen_in_current_file { + tally.seen_in_current_file = true; + tally.files += 1; + } + if tally.first_seen.is_none() { + tally.first_seen = Some(at.to_string()); + } +} + +// --------------------------------------------------------------------------- +// Line-level detectors. Deliberately independent of the parser (see module docs). +// --------------------------------------------------------------------------- + +fn heading_stars(line: &str) -> Option<usize> { + if !line.starts_with('*') { + return None; + } + let stars = line.chars().take_while(|c| *c == '*').count(); + let after = &line[stars..]; + (after.starts_with(' ') || after.is_empty()).then_some(stars) +} + +#[derive(PartialEq)] +enum Bullet { + Unordered, + Ordered, +} + +fn list_bullet(trimmed: &str) -> Option<Bullet> { + let bytes = trimmed.as_bytes(); + if bytes.is_empty() { + return None; + } + if (bytes[0] == b'-' || bytes[0] == b'+') && (bytes.len() == 1 || bytes[1] == b' ') { + return Some(Bullet::Unordered); + } + let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 { + let after = &trimmed[digits..]; + if (after.starts_with('.') || after.starts_with(')')) + && (after.len() == 1 || after.as_bytes()[1] == b' ') + { + return Some(Bullet::Ordered); + } + } + None +} + +fn is_drawer(trimmed: &str) -> bool { + let t = trimmed.trim_end(); + t.len() >= 3 + && t.starts_with(':') + && t.ends_with(':') + && t[1..t.len() - 1] + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + && t.len() > 2 +} + +fn scope_of_block(kind: &str) -> Scope { + if KNOWN_BLOCKS.iter().any(|k| k.eq_ignore_ascii_case(kind)) { + Scope::In + } else { + Scope::Out + } +} + +fn block_construct(kind: &str) -> &'static str { + match kind.to_ascii_uppercase().as_str() { + "SRC" => "source block", + "QUOTE" => "quote block", + "EXAMPLE" => "example block", + "CENTER" => "center block", + "EXPORT" => "export block", + _ => "unmodelled block type", + } +} + +/// The scheme of a link target, normalized into the census's vocabulary. +fn link_scheme(target: &str) -> String { + if let Some(rest) = target.split_once(':') { + let scheme = rest.0; + if !scheme.is_empty() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '+') + { + return scheme.to_ascii_lowercase(); + } + } + if target.starts_with('#') { + return "custom-id".to_string(); + } + if target.starts_with('*') { + return "heading".to_string(); + } + "relative".to_string() +} + +/// A `<...>`/`[...]` span opening with an ISO date is a timestamp. +fn has_timestamp(line: &str) -> bool { + let bytes = line.as_bytes(); + for (i, c) in line.char_indices() { + if c != '<' && c != '[' { + continue; + } + let rest = &bytes[i + 1..]; + if rest.len() >= 10 + && rest[..4].iter().all(u8::is_ascii_digit) + && rest[4] == b'-' + && rest[5..7].iter().all(u8::is_ascii_digit) + && rest[7] == b'-' + && rest[8..10].iter().all(u8::is_ascii_digit) + { + return true; + } + } + false +} + +/// `$x$` or `\(x\)` inline math. `$` alone (a price, a shell prompt) is not math. +fn latex_inline(line: &str) -> bool { + if line.contains("\\(") && line.contains("\\)") { + return true; + } + let dollars = line.matches('$').count(); + dollars >= 2 && line.contains("$\\") +} + +/// A `\name` entity reference such as `\alpha`, excluding LaTeX environment commands. +fn entity_ref(line: &str) -> bool { + for (i, c) in line.char_indices() { + if c != '\\' { + continue; + } + let rest = &line[i + 1..]; + let name: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + if name.len() >= 3 && !matches!(name.as_str(), "begin" | "end") { + return true; + } + } + false +} + +/// A plausible `*bold*`-style emphasis pair: two markers on one line with non-space +/// content between them. Approximate by design — the audit measures prevalence, and the +/// parser owns the exact pre/post-character rules. +fn emphasis_pair(line: &str, marker: char) -> bool { + let positions: Vec<usize> = line + .char_indices() + .filter(|(_, c)| *c == marker) + .map(|(i, _)| i) + .collect(); + if positions.len() < 2 { + return false; + } + // A leading `*` is a heading, and `-`/`+` at line start is a bullet. + let trimmed = line.trim_start(); + if trimmed.starts_with(marker) { + return false; + } + positions.windows(2).any(|w| w[1] > w[0] + 1) +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +/// Render the audit as a readable report. Names, counts and locations only — never +/// document text, so an audit of private notes is safe to paste into an issue. +pub fn report(audit: &Audit) -> String { + let mut out = String::new(); + out.push_str(&format!( + "corpus: {} file(s), {} line(s)\n", + audit.files, audit.lines + )); + + let mut rows: Vec<(&(Scope, &str), &Tally)> = audit.constructs.iter().collect(); + rows.sort_by(|a, b| { + b.1.occurrences + .cmp(&a.1.occurrences) + .then_with(|| a.0 .1.cmp(b.0 .1)) + }); + + out.push_str("\nCONSTRUCTS (by frequency)\n"); + out.push_str(&format!( + "{:<4} {:<32} {:>8} {:>7} {}\n", + "", "construct", "uses", "files", "first seen" + )); + for ((scope, name), tally) in &rows { + out.push_str(&format!( + "{:<4} {:<32} {:>8} {:>7} {}\n", + scope.label(), + name, + tally.occurrences, + tally.files, + tally.first_seen.as_deref().unwrap_or("") + )); + } + + let in_uses: usize = rows + .iter() + .filter(|((s, _), _)| *s == Scope::In) + .map(|(_, t)| t.occurrences) + .sum(); + let out_uses: usize = rows + .iter() + .filter(|((s, _), _)| *s == Scope::Out) + .map(|(_, t)| t.occurrences) + .sum(); + let total = in_uses + out_uses; + let pct = |n: usize| { + if total == 0 { + 0.0 + } else { + 100.0 * n as f64 / total as f64 + } + }; + out.push_str(&format!( + "\ncoverage: {in_uses} in-scope use(s) ({:.1}%), {out_uses} out-of-scope ({:.1}%)\n", + pct(in_uses), + pct(out_uses) + )); + + for (title, kind, map) in [ + ("KEYWORDS", Census::Keyword, &audit.keywords), + ("BLOCK TYPES", Census::Block, &audit.blocks), + ("DRAWERS", Census::Drawer, &audit.drawers), + ("LINK SCHEMES", Census::Scheme, &audit.link_schemes), + ] { + let mut names: Vec<(&String, &Tally)> = map.iter().collect(); + names.sort_by(|a, b| b.1.occurrences.cmp(&a.1.occurrences).then(a.0.cmp(b.0))); + out.push_str(&format!("\n{title}\n")); + for (name, tally) in names { + let flag = if Audit::is_known(kind, name) { + " " + } else { + "??? " + }; + out.push_str(&format!( + "{flag}{:<32} {:>8} {:>7} {}\n", + name, + tally.occurrences, + tally.files, + tally.first_seen.as_deref().unwrap_or("") + )); + } + } + out.push_str("\n`???` marks a name the implementation does not recognize at all.\n"); + out +} @@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use serde::{Deserialize, Serialize}; use crate::model::{Document, Section}; -use crate::util::{plain_text, slugify}; +use crate::util::{output_path, plain_text, slugify}; /// Identity of a link target. A target is owned by exactly one file (spec §4.3). /// @@ -51,6 +51,9 @@ impl TargetId { #[derive(Debug, Clone)] pub struct TargetLocation { pub source_path: Utf8PathBuf, + /// The page this target is emitted into. Recorded at INDEX time because it depends + /// on the defining document's `#+SLUG:`, which only that document knows. + pub output_path: Utf8PathBuf, /// Final URL fragment/anchor for the target, filled during resolution. pub anchor: Option<String>, } @@ -71,14 +74,16 @@ impl SymbolTable { /// the renderer emits for that target's heading. pub fn index_document(&mut self, doc: &Document) { let path = &doc.source_path; + let out = output_path(path, &doc.keywords); self.targets.insert( TargetId::File(path.clone()), TargetLocation { source_path: path.clone(), + output_path: out.clone(), anchor: None, }, ); - index_section(&doc.root, path, &mut self.targets); + index_section(&doc.root, path, &out, &mut self.targets); } } @@ -107,40 +112,37 @@ fn collect_targets(section: &Section, out: &mut Vec<TargetId>) { } } -fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) { +fn index_section( + section: &Section, + path: &Utf8Path, + out: &Utf8Path, + targets: &mut HashMap<TargetId, TargetLocation>, +) { if let Some(h) = §ion.heading { let anchor = h .custom_id .clone() .or_else(|| h.id.clone()) .unwrap_or_else(|| slugify(&plain_text(&h.title))); - if let Some(cid) = &h.custom_id { + let mut record = |id: TargetId, anchor: Option<String>| { targets.insert( - TargetId::CustomId(cid.clone()), + id, TargetLocation { source_path: path.to_owned(), - anchor: Some(cid.clone()), + output_path: out.to_owned(), + anchor, }, ); + }; + if let Some(cid) = &h.custom_id { + record(TargetId::CustomId(cid.clone()), Some(cid.clone())); } if let Some(id) = &h.id { - targets.insert( - TargetId::Id(id.clone()), - TargetLocation { - source_path: path.to_owned(), - anchor: Some(id.clone()), - }, - ); + record(TargetId::Id(id.clone()), Some(id.clone())); } - targets.insert( - TargetId::Heading(plain_text(&h.title)), - TargetLocation { - source_path: path.to_owned(), - anchor: Some(anchor), - }, - ); + record(TargetId::Heading(plain_text(&h.title)), Some(anchor)); } for child in §ion.children { - index_section(child, path, targets); + index_section(child, path, out, targets); } } @@ -8,6 +8,7 @@ //! [`render`] (RENDER) → [`template`] (TEMPLATE) → EMIT, with [`incremental`] //! deciding which pages actually need rewriting. +pub mod audit; pub mod incremental; pub mod index; pub mod model; @@ -49,6 +49,12 @@ enum Command { /// Output directory to remove. output: Utf8PathBuf, }, + /// Audit a corpus: report which org constructs it uses and how they land against + /// the v1 scope line. Reports names, counts and locations — never document text. + Audit { + /// Source directory (or single `.org` file) to audit. + input: Utf8PathBuf, + }, } fn main() -> Result<()> { @@ -86,6 +92,11 @@ fn main() -> Result<()> { // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds // incrementally whenever any source file's mtime advances. Command::Watch { input, output } => watch(&input, &output), + Command::Audit { input } => { + let result = org_ssg::audit::audit(&input)?; + print!("{}", org_ssg::audit::report(&result)); + Ok(()) + } Command::Clean { output } => { if output.exists() { fs::remove_dir_all(&output) @@ -15,7 +15,7 @@ use camino::Utf8Path; use crate::index::{SymbolTable, TargetId}; use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow}; -use crate::util::{normalize_link_path, output_url}; +use crate::util::{normalize_link_path, output_path, output_url}; /// A document whose links have been rewritten to concrete URLs. #[derive(Debug, Clone)] @@ -42,10 +42,13 @@ pub struct ResolveOutput { pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput { let mut document = doc.clone(); let from = doc.source_path.clone(); + // URLs are computed between *output* paths, which `#+SLUG:` can rename. + let from_out = output_path(&from, &doc.keywords); let mut used = Vec::new(); let mut broken = Vec::new(); let mut cx = Cx { from: &from, + from_out: &from_out, symbols, used: &mut used, broken: &mut broken, @@ -70,6 +73,7 @@ fn human_text(target: &LinkTarget) -> Option<String> { struct Cx<'a> { from: &'a Utf8Path, + from_out: &'a Utf8Path, symbols: &'a SymbolTable, used: &'a mut Vec<TargetId>, broken: &'a mut Vec<BrokenLink>, @@ -167,7 +171,7 @@ impl Cx<'_> { link.description = Some(vec![Object::Text(text)]); } } - let url = output_url(self.from, &loc.source_path, loc.anchor.as_deref()); + let url = output_url(self.from_out, &loc.output_path, loc.anchor.as_deref()); link.target = LinkTarget::External(url); } None => { @@ -26,7 +26,7 @@ use crate::parser::parse; use crate::render::{render, syntax_css, Html, SyntectHighlighter}; use crate::resolve::resolve; use crate::template::{template_sources, NavItem, Templater}; -use crate::util::{output_url, relative_root}; +use crate::util::{output_path, output_url, relative_root}; /// A fully built page: source and output paths (relative to their roots) and its /// final templated HTML. @@ -100,12 +100,27 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { symbols.index_document(doc); } - // Nav is global; titles come from #+TITLE (falling back to the file stem). + // Nav is global; titles come from #+TITLE (falling back to the file stem) and URLs + // from each page's output path, which `#+SLUG:` can rename. let entries: Vec<(Utf8PathBuf, String)> = docs .iter() - .map(|d| (d.source_path.clone(), page_title(d))) + .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d))) .collect(); + // Two sources emitting one page would silently drop a page — and with slugs, a + // collision is a typo away and invisible in the source filenames. + let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> = + std::collections::HashMap::new(); + for (doc, (out, _)) in docs.iter().zip(&entries) { + if let Some(other) = claimed.insert(out, &doc.source_path) { + anyhow::bail!( + "output collision: {} and {} both build to {out} (check their #+SLUG:)", + other, + doc.source_path + ); + } + } + let mut pages = Vec::new(); for doc in &docs { let out = resolve(doc, &symbols); @@ -113,18 +128,20 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect(); let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect(); + let output = output_path(&doc.source_path, &doc.keywords); + // Nav links are relative to *this* page (spec URL scheme, §8 Q3). let nav: Vec<NavItem> = entries .iter() .map(|(path, title)| NavItem { title: title.clone(), - url: output_url(&doc.source_path, path, None), + url: output_url(&output, path, None), }) .collect(); pages.push(PagePrep { source: doc.source_path.clone(), - output: doc.source_path.with_extension("html"), + output, title: page_title(doc), content_hash: doc.content_hash, resolved: out.resolved, @@ -190,9 +207,11 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result // chrome on every page — is built from every page's (path, title), so a title/path // change or a page add/remove must re-render every page (else stale nav on disk). let cfg = BuildConfig::default(); + // Keyed on the *output* path: a `#+SLUG:` change moves a page's URL, which changes + // the nav on every other page even though no source filename moved. let nav_entries: Vec<(String, String)> = preps .iter() - .map(|p| (p.source.to_string(), p.title.clone())) + .map(|p| (p.output.to_string(), p.title.clone())) .collect(); let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries)); let tmpl_hash = template_hash(template_sources()); @@ -4,7 +4,50 @@ use camino::{Utf8Path, Utf8PathBuf}; -use crate::model::Object; +use crate::model::{Keywords, Object}; + +/// The output path for a document, relative to the site root. +/// +/// Normally this is the source path with `.org` swapped for `.html`, but a `#+SLUG:` +/// keyword renames the file — which is how the target corpus works: 178 of its 179 files +/// set one, and `2018-11-28-aes-encryption.org` publishes as `aes-encryption.html`. The +/// slug names the *file*, never the directory, so the page stays where its source lives. +pub fn output_path(source: &Utf8Path, keywords: &Keywords) -> Utf8PathBuf { + let slug = keywords + .entries + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("SLUG")) + .map(|(_, v)| sanitize_slug(v)) + .filter(|s| !s.is_empty()); + + match slug { + Some(slug) => { + let dir = source.parent().unwrap_or_else(|| Utf8Path::new("")); + dir.join(format!("{slug}.html")) + } + None => source.with_extension("html"), + } +} + +/// Reduce a slug to a safe single filename component. +/// +/// A slug is author-controlled text that becomes a path we write to, so `../../etc/x` +/// has to be impossible by construction rather than by convention: separators and dots +/// are folded to `-`, which cannot traverse and cannot produce a hidden file. +fn sanitize_slug(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut prev_dash = false; + for c in raw.trim().chars() { + if c.is_ascii_alphanumeric() || c == '_' { + out.extend(c.to_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} /// Flatten inline objects to their plain-text content (markup stripped). Used to /// derive heading anchors and `[[*Heading]]` link identities (spec §4.3). @@ -49,16 +92,19 @@ pub fn slugify(text: &str) -> String { out.trim_matches('-').to_string() } -/// The output URL to reach `to_rel` (a source `.org` path relative to the site root) -/// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce -/// to a bare `#anchor` fragment; cross-file links become a relative `.html` path. -pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String { - let path = if from_rel == to_rel { +/// The URL to reach the page output at `to_out` from the page output at `from_out`, +/// honoring an optional `anchor`. Same-page links reduce to a bare `#anchor` fragment; +/// cross-page links become a relative path. +/// +/// Both arguments are *output* paths, not source paths, because `#+SLUG:` means the two +/// no longer correspond: deriving the URL here would reintroduce the filename assumption +/// that [`output_path`] exists to remove. +pub fn output_url(from_out: &Utf8Path, to_out: &Utf8Path, anchor: Option<&str>) -> String { + let path = if from_out == to_out { String::new() } else { - let to_html = to_rel.with_extension("html"); - let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new("")); - relative_path(from_dir, &to_html) + let from_dir = from_out.parent().unwrap_or_else(|| Utf8Path::new("")); + relative_path(from_dir, to_out) }; match anchor { Some(a) if !a.is_empty() => { new file mode 100644 @@ -0,0 +1,42 @@ +;;; oracle.el --- ground-truth HTML export for the org-ssg differential tests -*- lexical-binding: t -*- + +;; Exports the org file named by $ORG_ORACLE_INPUT to HTML on stdout, using org's own +;; exporter — the same one weblorg wraps to publish the corpus this project targets. +;; Run with: ORG_ORACLE_INPUT=x.org emacs -Q --batch -l tests/oracle.el +;; +;; `-Q' is deliberate: no user init, so the oracle is the stock org exporter and not +;; this machine's Emacs configuration. The path is passed by environment variable +;; rather than as an argument because batch Emacs would otherwise try to visit it. + +(require 'org) +(require 'ox-html) + +;; Presentation settings are normalized so the diff carries semantic divergences only. +;; Everything that affects *content* is left at its default, because the point is to +;; learn what stock org does — normalizing that away would be marking our own homework. +(setq org-export-with-toc nil ; we emit no table of contents + org-export-with-section-numbers nil ; we do not number headings + org-html-toplevel-hlevel 1 ; org defaults to h2 for a level-1 heading, + ; because a template supplies the page <h1>. + ; Aligning here keeps a global +1 offset from + ; drowning every real finding in the diff. + org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight + ; with syntect, so comparing code *text* is + ; the meaningful part + org-html-head-include-default-style nil + org-html-head-include-scripts nil + ;; Fixtures link to ids that live in org-ssg's own symbol table, not in an + ;; `org-id' database. Without this, org aborts the whole export on the first one. + org-export-with-broken-links t + make-backup-files nil) + +(let ((input (getenv "ORG_ORACLE_INPUT"))) + (unless input + (error "ORG_ORACLE_INPUT is not set")) + (with-temp-buffer + (insert-file-contents input) + (org-mode) + ;; BODY-ONLY: emit the content, not a full document with <head> chrome. + (princ (org-export-as 'html nil nil t nil)))) + +;;; oracle.el ends here new file mode 100644 @@ -0,0 +1,454 @@ +//! The `emacs --batch` ground-truth oracle (spec §5, Phase 0). +//! +//! Every other test in this suite checks org-ssg against org-ssg: a snapshot says our +//! output has not *changed*, never that it is *right*. Those two questions are different, +//! and only one of them matters to someone whose site is currently published by Emacs. +//! This file answers the second by exporting the same fixture with org's own HTML +//! exporter — the exporter weblorg wraps to publish the target corpus — and diffing the +//! two. +//! +//! **What is compared.** Byte equality is not a useful goal: org wraps every section in +//! `outline-container` divs keyed by generated ids, and no amount of agreement on +//! semantics would survive that. Both sides are reduced to a *semantic skeleton* — the +//! sequence of element opens, closes, and text runs, with `<div>`s and all attributes +//! except `href`/`src` dropped, whitespace collapsed, and entities decoded. What remains +//! is the question worth asking: does org think this is a `<blockquote><p>`, and do we? +//! +//! **What the result means.** These tests do not assert agreement — they *snapshot the +//! disagreement*. A divergence report that is checked in and reviewed is worth more than +//! a red test nobody can act on, and it makes any new divergence show up as a diff in +//! code review. A few invariants that must never break are asserted outright. +//! +//! The suite skips cleanly when Emacs is absent, so it never blocks a machine or CI +//! runner that has no Emacs. + +use std::process::Command; + +use camino::Utf8PathBuf; + +use org_ssg::parser::parse; +use org_ssg::render::{render, Html, SyntectHighlighter}; +use org_ssg::resolve::ResolvedDoc; + +fn manifest_dir() -> Utf8PathBuf { + Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Is a usable Emacs on PATH? The oracle is a development instrument, not a build +/// dependency, so its absence skips rather than fails. +fn emacs_available() -> bool { + Command::new("emacs") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Export a fixture with org's own HTML exporter. +fn org_export(fixture: &str) -> String { + let root = manifest_dir(); + let output = Command::new("emacs") + .args(["-Q", "--batch", "-l"]) + .arg(root.join("tests/oracle.el")) + .env("ORG_ORACLE_INPUT", root.join("fixtures").join(fixture)) + .current_dir(&root) + .output() + .expect("run emacs"); + assert!( + output.status.success(), + "emacs export of {fixture} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("emacs emits UTF-8") +} + +/// Render a fixture with org-ssg. +fn our_export(fixture: &str) -> String { + let path = manifest_dir().join("fixtures").join(fixture); + let source = std::fs::read_to_string(&path).expect("read fixture"); + let document = parse(Utf8PathBuf::from(fixture).as_path(), &source).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + html +} + +// --------------------------------------------------------------------------- +// HTML → semantic skeleton +// --------------------------------------------------------------------------- + +/// Elements dropped from the skeleton entirely, because once attributes are gone they +/// carry no meaning the two exporters could agree or disagree *about*. +/// +/// `div` is pure layout: org wraps every section in `outline-container`/`outline-text` +/// wrappers and we emit none. `span` is the same story at the inline level, and matters +/// far more than it looks: syntect emits one span per code token, so keeping them made a +/// source block contribute ~60 skeleton lines of pure noise and dragged the agreement on +/// `blocks.org` down to 36% — a number that said nothing about whether we render blocks +/// correctly. Text still carries the signal: a `<span class="todo">` shows up as its +/// text, `"TODO"`, which is the part worth comparing. +const IGNORED: &[&str] = &["div", "span"]; + +/// Attributes kept in the skeleton. Ids and classes are generated (`org6c28c1b`) or +/// cosmetic (`org-ul`); `href` and `src` are the content. +const KEPT_ATTRS: &[&str] = &["href", "src"]; + +/// HTML void elements, which never emit a close event. +const VOID: &[&str] = &[ + "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr", +]; + +/// Reduce an HTML fragment to its semantic skeleton: one line per element open, element +/// close, or text run. +fn skeleton(html: &str) -> Vec<String> { + let mut out = Vec::new(); + let chars: Vec<char> = html.chars().collect(); + let mut i = 0; + let mut text = String::new(); + + while i < chars.len() { + if chars[i] != '<' { + text.push(chars[i]); + i += 1; + continue; + } + + // Comments and doctypes carry nothing. + if chars[i..].starts_with(&['<', '!']) { + i += match find_from(&chars, i, ">") { + Some(end) => end - i + 1, + None => break, + }; + continue; + } + let Some(end) = find_from(&chars, i, ">") else { + break; + }; + let raw: String = chars[i + 1..end].iter().collect(); + i = end + 1; + + let raw = raw.trim().trim_end_matches('/').trim().to_string(); + // Text is flushed only when a tag is actually *emitted*. Text either side of an + // ignored tag therefore merges into one run, which is what makes a highlighted + // source block compare as the one string of code it is, rather than as a + // token-by-token sequence that has to line up exactly. + if let Some(name) = raw.strip_prefix('/') { + let name = name.trim().to_ascii_lowercase(); + if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) { + flush_text(&mut text, &mut out); + out.push(format!("</{name}>")); + } + continue; + } + let mut parts = raw.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or("").to_ascii_lowercase(); + if name.is_empty() || IGNORED.contains(&name.as_str()) { + continue; + } + let attrs = kept_attributes(parts.next().unwrap_or("")); + flush_text(&mut text, &mut out); + out.push(format!("<{name}{attrs}>")); + } + flush_text(&mut text, &mut out); + out +} + +fn flush_text(text: &mut String, out: &mut Vec<String>) { + let decoded = decode_entities(text); + let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" "); + if !collapsed.is_empty() { + out.push(format!("{collapsed:?}")); + } + text.clear(); +} + +fn find_from(chars: &[char], from: usize, needle: &str) -> Option<usize> { + let n: Vec<char> = needle.chars().collect(); + (from..chars.len()).find(|&k| chars[k..].starts_with(&n[..])) +} + +/// Keep only the content-bearing attributes, in a stable order. +fn kept_attributes(rest: &str) -> String { + let mut kept: Vec<(String, String)> = Vec::new(); + for attr in KEPT_ATTRS { + if let Some(value) = attribute_value(rest, attr) { + kept.push(((*attr).to_string(), value)); + } + } + kept.iter() + .map(|(k, v)| format!(" {k}=\"{}\"", decode_entities(v))) + .collect() +} + +fn attribute_value(rest: &str, name: &str) -> Option<String> { + let mut search = rest; + while let Some(pos) = search.find(name) { + let before_ok = pos == 0 + || search[..pos] + .chars() + .next_back() + .is_some_and(char::is_whitespace); + let after = &search[pos + name.len()..]; + let after_trimmed = after.trim_start(); + if before_ok && after_trimmed.starts_with('=') { + let value = after_trimmed[1..].trim_start(); + let quote = value.chars().next()?; + if quote == '"' || quote == '\'' { + let end = value[1..].find(quote)? + 1; + return Some(value[1..end].to_string()); + } + let end = value.find(char::is_whitespace).unwrap_or(value.len()); + return Some(value[..end].to_string()); + } + search = &search[pos + name.len()..]; + } + None +} + +/// Decode the entities either exporter is likely to emit, so an encoding difference is +/// never reported as a semantic one. +fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(amp) = rest.find('&') { + out.push_str(&rest[..amp]); + let tail = &rest[amp..]; + let Some(semi) = tail.find(';').filter(|s| *s <= 12) else { + out.push('&'); + rest = &tail[1..]; + continue; + }; + let entity = &tail[1..semi]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ => entity + .strip_prefix('#') + .and_then(|n| match n.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok(), + None => n.parse::<u32>().ok(), + }) + .and_then(char::from_u32), + }; + match decoded { + // A non-breaking space is a space for comparison purposes. + Some('\u{a0}') => out.push(' '), + Some(c) => out.push(c), + None => { + out.push('&'); + rest = &tail[1..]; + continue; + } + } + rest = &tail[semi + 1..]; + } + out.push_str(rest); + out +} + +// --------------------------------------------------------------------------- +// Divergence report +// --------------------------------------------------------------------------- + +/// A unified diff of the two skeletons, via a longest-common-subsequence walk. `-` is +/// org-ssg, `+` is Emacs. +fn divergence(ours: &[String], theirs: &[String]) -> String { + let (n, m) = (ours.len(), theirs.len()); + // lcs[i][j] = length of the longest common subsequence of ours[i..] and theirs[j..]. + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if ours[i] == theirs[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + + let mut out = String::new(); + let (mut i, mut j) = (0, 0); + let mut agreed = 0usize; + while i < n && j < m { + if ours[i] == theirs[j] { + out.push_str(&format!(" {}\n", ours[i])); + agreed += 1; + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + out.push_str(&format!("- {}\n", ours[i])); + i += 1; + } else { + out.push_str(&format!("+ {}\n", theirs[j])); + j += 1; + } + } + for line in &ours[i..] { + out.push_str(&format!("- {line}\n")); + } + for line in &theirs[j..] { + out.push_str(&format!("+ {line}\n")); + } + + let total = n.max(m); + let pct = if total == 0 { + 100.0 + } else { + 100.0 * agreed as f64 / total as f64 + }; + format!("agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n(- org-ssg, + emacs)\n\n{out}") +} + +/// Snapshot the divergence between org-ssg and Emacs for one fixture. +fn compare(fixture: &str) -> Option<String> { + if !emacs_available() { + eprintln!("skipping oracle comparison for {fixture}: no emacs on PATH"); + return None; + } + let ours = skeleton(&our_export(fixture)); + let theirs = skeleton(&org_export(fixture)); + Some(divergence(&ours, &theirs)) +} + +macro_rules! oracle_test { + ($name:ident, $fixture:literal) => { + #[test] + fn $name() { + if let Some(report) = compare($fixture) { + insta::assert_snapshot!(report); + } + } + }; +} + +oracle_test!(oracle_minimal, "minimal.org"); +oracle_test!(oracle_core, "core.org"); +oracle_test!(oracle_headings, "headings.org"); +oracle_test!(oracle_lists, "lists.org"); +oracle_test!(oracle_blocks, "blocks.org"); +oracle_test!(oracle_table, "table.org"); +oracle_test!(oracle_footnote, "footnote.org"); +oracle_test!(oracle_timestamps, "timestamps.org"); +oracle_test!(oracle_images, "images.org"); +oracle_test!(oracle_elements, "elements.org"); + +// --------------------------------------------------------------------------- +// Invariants that must hold against the oracle, not merely be snapshotted +// --------------------------------------------------------------------------- + +/// How many headings a document has and at what depth is the shape of the document. +/// Getting it wrong reorganizes someone's writing, so it is asserted rather than +/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy +/// difference and is left to the snapshots. +#[test] +fn heading_structure_matches_emacs() { + if !emacs_available() { + eprintln!("skipping: no emacs on PATH"); + return; + } + for fixture in ["minimal.org", "core.org", "headings.org", "lists.org"] { + let ours = heading_levels(&skeleton(&our_export(fixture))); + let theirs = heading_levels(&skeleton(&org_export(fixture))); + assert_eq!( + ours, theirs, + "heading structure diverges from Emacs in {fixture}" + ); + } +} + +/// The sequence of heading open tags, e.g. `["<h1>", "<h2>", "<h1>"]`. +fn heading_levels(skeleton: &[String]) -> Vec<String> { + skeleton + .iter() + .filter(|l| l.starts_with("<h") && l[2..].starts_with(|c: char| c.is_ascii_digit())) + .cloned() + .collect() +} + +/// A list is the construct where nesting is easiest to get subtly wrong, and where being +/// wrong changes the meaning of the document rather than its looks. +#[test] +fn list_nesting_matches_emacs() { + if !emacs_available() { + eprintln!("skipping: no emacs on PATH"); + return; + } + let ours = list_shape(&skeleton(&our_export("lists.org"))); + let theirs = list_shape(&skeleton(&org_export("lists.org"))); + assert_eq!(ours, theirs, "list nesting diverges from Emacs"); +} + +/// The sequence of list opens/closes, ignoring content — the shape of the nesting. +fn list_shape(skeleton: &[String]) -> Vec<String> { + skeleton + .iter() + .filter(|l| { + matches!( + l.as_str(), + "<ul>" | "</ul>" | "<ol>" | "</ol>" | "<li>" | "</li>" | "<dl>" | "</dl>" + | "<dt>" | "</dt>" | "<dd>" | "</dd>" + ) + }) + .cloned() + .collect() +} + +/// Code must survive verbatim. Highlighting markup differs by construction (syntect +/// spans vs htmlize), but if the *characters of the program* differ, we have corrupted +/// the author's content. +#[test] +fn source_block_text_matches_emacs() { + if !emacs_available() { + eprintln!("skipping: no emacs on PATH"); + return; + } + for fixture in ["blocks.org", "core.org", "elements.org"] { + let ours = code_text(&our_export(fixture)); + let theirs = code_text(&org_export(fixture)); + assert_eq!(ours, theirs, "source block text diverges from Emacs in {fixture}"); + } +} + +/// All text inside `<pre>` blocks, with tags stripped and whitespace collapsed. +fn code_text(html: &str) -> Vec<String> { + let mut out = Vec::new(); + let mut rest = html; + while let Some(start) = rest.find("<pre") { + let after = &rest[start..]; + let Some(open_end) = after.find('>') else { break }; + let Some(close) = after.find("</pre>") else { break }; + let inner = &after[open_end + 1..close]; + out.push(strip_tags(inner)); + rest = &after[close + 6..]; + } + out +} + +/// All text in a fragment with tags removed and entities decoded, then whitespace +/// collapsed once at the end. +/// +/// [`skeleton`] cannot do this job: it trims each text run individually, which is +/// invisible for prose (one run per paragraph) but destructive for highlighted code, +/// where syntect splits a line into one run per token and the spaces *between* tokens +/// live at the edges of those runs. Trimming each run turns `def greet` into `defgreet`. +fn strip_tags(html: &str) -> String { + let mut text = String::new(); + let mut rest = html; + while let Some(open) = rest.find('<') { + text.push_str(&rest[..open]); + match rest[open..].find('>') { + Some(close) => rest = &rest[open + close + 1..], + None => { + rest = ""; + break; + } + } + } + text.push_str(rest); + decode_entities(&text) + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} @@ -122,3 +122,86 @@ fn table_render() { fn footnote_render() { insta::assert_snapshot!(render_fragment("footnote.org")); } + +// --------------------------------------------------------------------------- +// `#+SLUG:` output paths (Phase 0 corpus-audit finding) +// --------------------------------------------------------------------------- + +/// The audit found `#+SLUG:` in 178 of the target corpus's 179 files, and the live site +/// derives every URL from it — `2018-11-28-aes-encryption.org` publishes as +/// `aes-encryption.html`. Deriving output paths from source filenames would therefore +/// have rewritten every URL on the site. +#[test] +fn slug_renames_the_output_page() { + let (pages, broken) = render_site(&fixtures().join("slugsite")).expect("build site"); + assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}"); + let post = pages + .iter() + .find(|p| p.source == "2024-02-11-long-source-name.org") + .expect("post page"); + assert_eq!( + post.output, "short-url.html", + "the slug names the output file, not the source stem" + ); +} + +/// A link's URL has to follow the target's slug. If resolution kept using source paths, +/// every cross-page link would point at a file that was never written. +#[test] +fn links_resolve_through_the_slug() { + let (pages, _) = render_site(&fixtures().join("slugsite")).expect("build site"); + let index = &page(&pages, "index.org").html; + assert!( + index.contains("href=\"short-url.html\""), + "a file: link must target the slugged page:\n{index}" + ); + assert!( + index.contains("href=\"short-url.html#setup\""), + "a custom-id link must target the slugged page plus the anchor:\n{index}" + ); + assert!( + !index.contains("long-source-name"), + "no URL may mention the source filename:\n{index}" + ); +} + +/// A slug is author-controlled text that becomes a path we write to, so traversal has to +/// be impossible by construction rather than by convention. +#[test] +fn slugs_cannot_escape_the_output_directory() { + use org_ssg::model::Keywords; + let source = Utf8PathBuf::from("blog/post.org"); + let slugged = |value: &str| { + let keywords = Keywords { + entries: vec![("SLUG".to_string(), value.to_string())], + }; + org_ssg::util::output_path(&source, &keywords).to_string() + }; + assert_eq!(slugged("../../etc/passwd"), "blog/etc-passwd.html"); + assert_eq!(slugged("/absolute"), "blog/absolute.html"); + assert_eq!(slugged(".hidden"), "blog/hidden.html"); + assert_eq!(slugged("Mixed Case Slug"), "blog/mixed-case-slug.html"); + // An empty or punctuation-only slug falls back to the source stem rather than + // producing `.html` with no name at all. + assert_eq!(slugged("///"), "blog/post.html"); +} + +/// Two pages claiming one URL silently drops a page. With slugs that is a typo away and +/// invisible in the source filenames, so the build refuses rather than picking a winner. +#[test] +fn colliding_slugs_are_a_build_error() { + let dir = std::env::temp_dir().join(format!("org-ssg-slug-{}", std::process::id())); + let dir = Utf8PathBuf::from_path_buf(dir).expect("utf-8 temp dir"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.org"), "#+TITLE: A\n#+SLUG: same\n").unwrap(); + std::fs::write(dir.join("b.org"), "#+TITLE: B\n#+SLUG: same\n").unwrap(); + + let err = render_site(&dir).expect_err("colliding slugs must fail the build"); + let message = format!("{err:#}"); + assert!( + message.contains("collision") && message.contains("same.html"), + "the error must name the collision: {message}" + ); + std::fs::remove_dir_all(&dir).unwrap(); +} new file mode 100644 @@ -0,0 +1,68 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 51/59 skeleton lines (86.4%) +(- org-ssg, + emacs) + + <h1> + "Quote" + </h1> + <blockquote> + <p> + "A quoted paragraph with" +- <em> ++ <i> + "markup" +- </em> ++ </i> + "." + </p> + <p> + "And a second paragraph." + </p> + </blockquote> + <h1> + "Center" + </h1> + <p> + "Centred text." + </p> + <h1> + "Example" + </h1> + <pre> + "Verbatim *not bold* text. Indentation preserved." + </pre> + <h1> + "Export" + </h1> + <aside> + "Raw HTML passes through." + </aside> + <h1> + "Source" + </h1> + <pre> +- <code> + "def greet(name): return f\"hello {name}\"" +- </code> + </pre> + <pre> +- <code> + "plain block, no language" +- </code> + </pre> + <h1> + "Nested" + </h1> + <blockquote> + <p> + "A quote containing a source block:" + </p> + <pre> +- <code> + "echo hi" +- </code> + </pre> + </blockquote> new file mode 100644 @@ -0,0 +1,70 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 45/54 skeleton lines (83.3%) +(- org-ssg, + emacs) + + <p> + "Intro paragraph with a bare URL" + <a href="https://example.com"> + "https://example.com" + </a> + "and some" + <code> + "inline code" + </code> + "." + </p> + <h1> + "Ordered and checked" + </h1> + <ol> + <li> + "first item" + </li> + <li> + "second item with" +- <em> ++ <i> + "emphasis" +- </em> ++ </i> + </li> +- </ol> +- <ul> + <li> +- <input> ++ <code> ++ "[ ]" ++ </code> + "todo item" + </li> + <li> +- <input> ++ <code> ++ "[X]" ++ </code> + "done item" + </li> +- </ul> ++ </ol> + <h1> + "Links and code" + </h1> + <p> + "An external" + <a href="https://example.org"> + "site" + </a> + "and a bare" + <a href="https://bare.example"> + "https://bare.example" + </a> + "." + </p> + <pre> +- <code> + "fn main() { println!(\"hello\"); }" +- </code> + </pre> new file mode 100644 @@ -0,0 +1,103 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 64/82 skeleton lines (78.0%) +(- org-ssg, + emacs) + + <h1> + "Code and tables" + </h1> + <pre> +- <code> + "fn main() { println!(\"hello\"); }" +- </code> + </pre> + <table> ++ <colgroup> ++ <col> ++ <col> ++ </colgroup> + <thead> + <tr> + <th> + "Name" + </th> + <th> + "Score" + </th> + </tr> + </thead> + <tbody> + <tr> + <td> + "alpha" + </td> + <td> + "10" + </td> + </tr> + <tr> + <td> + "beta" + </td> + <td> + "20" + </td> + </tr> + </tbody> + </table> + <h1> + "Links and footnotes" + </h1> + <p> + "An external link:" + <a href="https://example.com"> + "Example" + </a> +- "and an id link" +- <a href="#abc-123"> +- "abc-123" +- </a> +- "." ++ "and an id link ." + </p> + <p> + "Text with a footnote reference." + <sup> +- <a href="#fn-1"> ++ <a href="#fn.1"> + "1" + </a> + </sup> + </p> + <h1> + "Blocks" + </h1> + <blockquote> + <p> + "A quoted paragraph." + </p> + </blockquote> + <hr> +- <section> +- <hr> +- <ol> +- <li> ++ <h2> ++ "Footnotes:" ++ </h2> ++ <sup> ++ <a href="#fnr.1"> ++ "1" ++ </a> ++ </sup> + <p> + "The footnote definition." + </p> +- <a href="#fnr-1"> +- "↩" +- </a> +- </li> +- </ol> +- </section> new file mode 100644 @@ -0,0 +1,83 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 30/53 skeleton lines (56.6%) +(- org-ssg, + emacs) + + <p> + "Text with a reference." + <sup> +- <a href="#fn-1"> ++ <a href="#fn.1"> + "1" + </a> + </sup> + "And a second one." + <sup> +- <a href="#fn-2"> ++ <a href="#fn.2"> + "2" + </a> + </sup> + </p> + <p> + "An inline footnote." + <sup> +- <a href="#fn-3"> ++ <a href="#fn.3"> + "3" + </a> + </sup> + </p> +- <section> +- <hr> +- <ol> +- <li> ++ <h2> ++ "Footnotes:" ++ </h2> ++ <sup> ++ <a href="#fnr.1"> ++ "1" ++ </a> ++ </sup> + <p> + "The first definition." + </p> +- <a href="#fnr-1"> +- "↩" ++ <sup> ++ <a href="#fnr.2"> ++ "2" + </a> +- </li> +- <li> ++ </sup> + <p> + "The second definition, with" +- <em> ++ <i> + "emphasis" +- </em> ++ </i> + "." + </p> +- <a href="#fnr-2"> +- "↩" ++ <sup> ++ <a href="#fnr.3"> ++ "3" + </a> +- </li> +- <li> ++ </sup> ++ <p> + "defined right here" +- <a href="#fnr-3"> +- "↩" +- </a> +- </li> +- </ol> +- </section> ++ </p> new file mode 100644 @@ -0,0 +1,39 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 28/30 skeleton lines (93.3%) +(- org-ssg, + emacs) + + <h1> +- "TODO [#A] Write the parser work rust" ++ "TODO Write the parser work rust" + </h1> + <p> + "A heading carrying a keyword, a priority, tags and a property drawer." + </p> + <h2> + "DONE Nested and finished" + </h2> + <p> + "Sub-headings nest by star count." + </p> + <h2> +- "[#C] Priority without a keyword" ++ "Priority without a keyword" + </h2> + <p> + "A priority cookie can stand alone." + </p> + <h1> + "TODOs are not a keyword" + </h1> + <p> + "The word boundary matters: this heading has no TODO keyword." + </p> + <h1> + "DONE" + </h1> + <p> + "A keyword with no title at all." + </p> new file mode 100644 @@ -0,0 +1,63 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 28/42 skeleton lines (66.7%) +(- org-ssg, + emacs) + + <h1> + "Bare image" + </h1> + <p> + <img src="diagram.png"> + </p> + <h1> + "Captioned figure" + </h1> +- <figure> ++ <p> + <img src="pipeline.svg"> +- <figcaption> +- "The pipeline, end to end" +- </figcaption> +- </figure> ++ </p> ++ <p> ++ "Figure 1: The pipeline, end to end" ++ </p> + <h1> + "Caption with markup" + </h1> +- <figure> ++ <p> + <img src="chart.png"> +- <figcaption> +- "A" +- <em> ++ </p> ++ <p> ++ "Figure 2: A" ++ <i> + "stylised" +- </em> ++ </i> + "chart" +- </figcaption> +- </figure> ++ </p> + <h1> + "Quoted attribute values" + </h1> +- <figure> ++ <p> + <img src="cat.jpg"> +- </figure> ++ </p> + <h1> + "Image with a description is a link" + </h1> + <p> + <a href="diagram.png"> + "the diagram" + </a> + </p> new file mode 100644 @@ -0,0 +1,123 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 100/111 skeleton lines (90.1%) +(- org-ssg, + emacs) + + <h1> + "Nesting" + </h1> + <ul> + <li> + "outer item" + <ul> + <li> + "inner item" + <ul> + <li> + "deepest item" + </li> + </ul> + </li> + <li> + "second inner" + </li> + </ul> + </li> + <li> + "second outer" + </li> + </ul> + <h1> + "Ordered" + </h1> + <ol> + <li> + "first" + </li> + <li> + "second" + <ol> + <li> + "second point one" + </li> + <li> + "second point two" + </li> + </ol> + </li> + <li> + "third" + </li> + </ol> + <h1> + "Checkboxes" + </h1> + <ul> + <li> +- <input> ++ <code> ++ "[ ]" ++ </code> + "not done" + </li> + <li> +- <input> ++ <code> ++ "[X]" ++ </code> + "done" + </li> + <li> +- <input> ++ <code> ++ "[-]" ++ </code> + "partially done" + </li> + </ul> + <h1> + "Description" + </h1> + <dl> + <dt> + "term one" + </dt> + <dd> + "the first definition" + </dd> + <dt> + "term two" + </dt> + <dd> + "the second definition, which is soft-wrapped across two lines" + </dd> + <dt> +- <em> ++ <i> + "marked up" +- </em> ++ </i> + "term" + </dt> + <dd> + "definitions hold inline markup" + </dd> + </dl> + <h1> + "Multi-paragraph items" + </h1> + <ul> + <li> + <p> + "an item whose body has two paragraphs" + </p> + <p> + "the second paragraph, indented under the bullet" + </p> + </li> + <li> + "a plain sibling" + </li> + </ul> new file mode 100644 @@ -0,0 +1,53 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 38/42 skeleton lines (90.5%) +(- org-ssg, + emacs) + + <p> + "A single paragraph of preamble text before any heading." + </p> + <h1> + "First Heading" + </h1> + <p> + "Some body text with" +- <strong> ++ <b> + "bold" +- </strong> ++ </b> + "," +- <em> ++ <i> + "italic" +- </em> ++ </i> + ", and" + <code> + "verbatim" + </code> + "." + </p> + <h2> + "A Subheading tag1 tag2" + </h2> + <ul> + <li> + "an unordered item" + </li> + <li> + "another with a checkbox [ ]" + </li> + </ul> + <h1> + "Second Heading" + </h1> + <p> + "See" + <a href="#first"> + "the first heading" + </a> + "." + </p> new file mode 100644 @@ -0,0 +1,41 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 30/34 skeleton lines (88.2%) +(- org-ssg, + emacs) + + <table> ++ <colgroup> ++ <col> ++ <col> ++ </colgroup> + <thead> + <tr> + <th> + "Name" + </th> + <th> + "Score" + </th> + </tr> + </thead> + <tbody> + <tr> + <td> + "alpha" + </td> + <td> + "10" + </td> + </tr> + <tr> + <td> + "beta" + </td> + <td> + "20" + </td> + </tr> + </tbody> + </table> new file mode 100644 @@ -0,0 +1,74 @@ +--- +source: tests/oracle.rs +expression: report +--- +agreement: 25/62 skeleton lines (40.3%) +(- org-ssg, + emacs) + + <h1> + "Single" + </h1> + <p> +- "An active date" +- <time> +- "2024-01-15" +- </time> +- "and an inactive one" +- <time> +- "2024-01-15" +- </time> +- "." ++ "An active date <2024-01-15 Mon> and an inactive one [2024-01-15 Mon]." + </p> + <p> +- "With a time:" +- <time> +- "2024-01-15 10:30" +- </time> +- "." ++ "With a time: <2024-01-15 Mon 10:30>." + </p> + <h1> + "Ranges" + </h1> + <p> +- "A same-day time range" +- <time> +- "2024-01-15 10:00" +- </time> +- "–" +- <time> +- "11:45" +- </time> +- "." ++ "A same-day time range <2024-01-15 Mon 10:00-11:45>." + </p> + <p> +- "A multi-day range" +- <time> +- "2024-01-15" +- </time> +- "–" +- <time> +- "2024-01-20" +- </time> +- "." ++ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>." + </p> + <h1> + "Ignored decorations" + </h1> + <p> +- "A repeater is dropped:" +- <time> +- "2024-01-15" +- </time> +- "." ++ "A repeater is dropped: <2024-01-15 Mon +1w>." + </p> + <h1> + "Not timestamps" + </h1> + <p> + "Comparisons like 3 < 4 and [not a stamp] stay literal text." + </p>