krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
0001ef1846a43f8b4cb5e92711a7695e29a6c3d7
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T06:48:25Z
Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 +- docs/guide/02-configuration.org | 13 ++++- docs/guide/05-org-support.org | 21 ++++++- src/config.rs | 9 +++ src/incremental.rs | 2 +- src/parser.rs | 23 +++++++- src/render.rs | 78 +++++++++++++++++++++++++- src/site.rs | 2 +- syntaxes/Org.sublime-syntax | 119 ++++++++++++++++++++++++++++++++++++++++ syntaxes/TOML.sublime-syntax | 105 +++++++++++++++++++++++++++++++++++ tests/constructs.rs | 111 +++++++++++++++++++++++++++++++++++++ 13 files changed, 474 insertions(+), 16 deletions(-) @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.14.0" +version = "0.15.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -364,6 +364,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | | **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | +| **17** | **Bundled TOML and Org syntaxes, a user syntax directory, and org's comma escape** | **done** | ### v0.2 in / out @@ -678,7 +679,7 @@ development server), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `an ``` cargo build -cargo test # 152 tests +cargo test # 156 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) @@ -29,6 +29,7 @@ expose_page_list = false [highlight] theme = "InspiredGitHub" +syntaxes_dir = "syntaxes" [build] drafts = false @@ -119,9 +120,10 @@ adding a post a one-page rebuild. * [highlight] -| Key | Default | -|-----+---------| -| =theme= | ="InspiredGitHub"= | +| Key | Default | Meaning | +|-----+---------+---------| +| =theme= | ="InspiredGitHub"= | A syntect theme name. | +| =syntaxes_dir= | ="syntaxes"= | Extra =.sublime-syntax= files. | Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=, =base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=. @@ -130,6 +132,11 @@ An unknown name is an error listing the valid ones. Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet. Each build writes =syntax.css= into the output and every page links it. +org-ssg bundles TOML and Org on top of syntect's built-in languages. Anything else +missing is a file away: put a =.sublime-syntax= definition in =syntaxes_dir= and it is +loaded. A definition that fails to parse is reported and skipped, because one bad file +should not stop a site from building. + * [build] | Key | Default | Meaning | @@ -68,9 +68,24 @@ Recognised, among others: =bash= / =sh=, =c=, =c++=, =css=, =clojure=, =diff=, = =makefile=, =markdown=, =matlab=, =objective-c=, =ocaml=, =perl=, =php=, =python=, =r=, =ruby=, =rust=, =scala=, =sql=, =tcl=, =xml=, =yaml=. -*Not* bundled, and worth knowing before you write a page full of them: *TOML*, *INI*, -*Org* and *Emacs Lisp*. The pages of this documentation are a live example — its -=#+BEGIN_SRC toml= blocks are readable but uncoloured. +org-ssg adds two syntect does not ship: *TOML* and *Org*. Both are what this project's +own documentation needed on its first page — every config example is TOML, and a tool for +org users gets written about in org — so they are compiled into the binary and work with +no setup. + +Still missing, and worth knowing before you write a page full of them: *INI* and *Emacs +Lisp*. For those, drop a =.sublime-syntax= file into the directory named by +=[highlight] syntaxes_dir= (default =syntaxes/=) and it is picked up. A file that fails +to parse is reported and skipped rather than failing the build. + +*** The comma escape + +A line inside a block that would otherwise look like document structure is written with a +leading comma — =,* heading=, =,#+KEYWORD:= — and org-ssg removes exactly one comma on +output, as Emacs does. Every org example in this documentation relies on it. + +The escape is not optional politeness: an unescaped =*= at column zero *ends the block*, +in Emacs as much as here. If a code block seems to stop early, that is why. ** Tables and footnotes @@ -259,6 +259,12 @@ impl Default for Templates { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Highlight { + /// Directory of extra `.sublime-syntax` files, relative to the source root. + /// + /// syntect bundles a long list of languages and this crate adds TOML and Org, but a + /// missing language should not need a new release — drop a definition here and it is + /// picked up. A file that fails to parse is reported and skipped. + pub syntaxes_dir: Utf8PathBuf, /// A syntect built-in theme name — `InspiredGitHub`, `Solarized (dark)`, /// `base16-ocean.dark`, `base16-eighties.dark`, `base16-mocha.dark`, /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the @@ -269,6 +275,7 @@ pub struct Highlight { impl Default for Highlight { fn default() -> Self { Highlight { + syntaxes_dir: Utf8PathBuf::from("syntaxes"), theme: "InspiredGitHub".to_string(), } } @@ -428,6 +435,8 @@ expose_page_list = false # A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, # base16-eighties.dark, base16-mocha.dark, base16-ocean.light. theme = "InspiredGitHub" +# Extra .sublime-syntax files for languages neither syntect nor org-ssg bundles. +syntaxes_dir = "syntaxes" [build] # Include pages marked `#+DRAFT:`. Off by default — the point of marking a draft is that @@ -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 = 4; +pub const CACHE_FORMAT_VERSION: u32 = 5; /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). pub type Hash = ContentHash; @@ -525,12 +525,13 @@ fn parse_block( base: usize, diags: &mut Vec<Diagnostic>, ) -> (Element, usize) { - let mut inner: Vec<&str> = Vec::new(); + let mut inner: Vec<String> = Vec::new(); let mut j = start + 1; while j < lines.len() && !is_block_end_of(lines[j], kind) { - inner.push(lines[j]); + inner.push(unescape_block_line(lines[j])); j += 1; } + let inner: Vec<&str> = inner.iter().map(String::as_str).collect(); if j >= lines.len() { // Everything to the end of input was swallowed by the block. This is the single // most destructive malformation in org: one missing line silently deletes the @@ -569,6 +570,24 @@ fn parse_block( (element, next) } +/// Undo org's comma escape on one line of block content. +/// +/// A line inside a block that would otherwise look like document structure is written +/// with a leading comma — `,* heading`, `,#+KEYWORD:` — and the exporter removes exactly +/// one comma. Without this, documentation *about* org shows the escape characters its +/// author had to type, which is precisely the audience most likely to notice. +fn unescape_block_line(line: &str) -> String { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix(',') else { + return line.to_string(); + }; + if !(rest.starts_with('*') || rest.starts_with("#+") || rest.starts_with(',')) { + return line.to_string(); + } + let indent = &line[..line.len() - trimmed.len()]; + format!("{indent}{rest}") +} + /// `:NAME:` … `:END:` at block level. A PROPERTIES drawer directly under a heading is /// consumed by [`parse_section_body`]; anything reaching here is a generic drawer, /// which the renderer drops (README §OUT). @@ -42,11 +42,60 @@ pub trait Highlighter { /// two must agree or the CSS will not match the markup. const CLASS_STYLE: ClassStyle = ClassStyle::Spaced; -/// Syntect's default syntax definitions, loaded once per process (loading is far more -/// expensive than highlighting, and a site build highlights many blocks). +/// Syntax definitions syntect does not bundle, compiled into the binary. +/// +/// Both are gaps this project hits on its own first page: every `org-ssg.toml` example is +/// TOML, and a tool for org users is going to be written about in org. Embedding them +/// rather than shipping files means they work with no setup, which is the same promise +/// the rest of the zero-config path makes. +const BUNDLED_SYNTAXES: &[(&str, &str)] = &[ + ("TOML", include_str!("../syntaxes/TOML.sublime-syntax")), + ("Org", include_str!("../syntaxes/Org.sublime-syntax")), +]; + +/// Syntect's default syntax definitions plus [`BUNDLED_SYNTAXES`], loaded once per +/// process (loading is far more expensive than highlighting, and a site build highlights +/// many blocks). fn syntax_set() -> &'static SyntaxSet { static SET: OnceLock<SyntaxSet> = OnceLock::new(); - SET.get_or_init(SyntaxSet::load_defaults_newlines) + SET.get_or_init(|| build_syntax_set(None)) +} + +/// Build a syntax set: syntect's defaults, the bundled additions, and optionally a +/// directory of user `.sublime-syntax` files. +/// +/// A malformed bundled definition is a bug in this crate and panics. A malformed *user* +/// definition is reported and skipped, because one bad file in a directory should not +/// stop a site from building. +fn build_syntax_set(user_dir: Option<&camino::Utf8Path>) -> SyntaxSet { + let mut builder = SyntaxSet::load_defaults_newlines().into_builder(); + for (name, source) in BUNDLED_SYNTAXES { + let definition = + syntect::parsing::SyntaxDefinition::load_from_str(source, true, Some(name)) + .unwrap_or_else(|e| panic!("bundled {name} syntax is malformed: {e}")); + builder.add(definition); + } + if let Some(dir) = user_dir.filter(|d| d.is_dir()) { + if let Err(e) = builder.add_from_folder(dir, true) { + eprintln!("warning: ignoring syntax definitions in {dir}: {e}"); + } + } + builder.build() +} + +/// A syntax set including a user directory of `.sublime-syntax` files. +/// +/// Cached per directory: a build highlights many blocks, and rebuilding the set for each +/// would cost more than the highlighting. +fn syntax_set_with(user_dir: &camino::Utf8Path) -> &'static SyntaxSet { + use std::collections::HashMap; + use std::sync::Mutex; + static SETS: OnceLock<Mutex<HashMap<camino::Utf8PathBuf, &'static SyntaxSet>>> = + OnceLock::new(); + let sets = SETS.get_or_init(|| Mutex::new(HashMap::new())); + let mut sets = sets.lock().expect("syntax set cache"); + sets.entry(user_dir.to_owned()) + .or_insert_with(|| Box::leak(Box::new(build_syntax_set(Some(user_dir))))) } fn theme_set() -> &'static ThemeSet { @@ -81,6 +130,29 @@ impl SyntectHighlighter { syntaxes: syntax_set(), } } + + /// A highlighter that also knows the `.sublime-syntax` files in `dir`, for languages + /// neither syntect nor this crate bundles. + pub fn with_syntaxes(dir: Option<&camino::Utf8Path>) -> Self { + match dir { + Some(dir) if dir.is_dir() => SyntectHighlighter { + syntaxes: syntax_set_with(dir), + }, + _ => Self::new(), + } + } +} + +/// Every language the highlighter recognises, for documentation and error messages. +pub fn available_languages() -> Vec<&'static str> { + let mut names: Vec<&str> = syntax_set() + .syntaxes() + .iter() + .map(|s| s.name.as_str()) + .collect(); + names.sort_unstable(); + names.dedup(); + names } impl Default for SyntectHighlighter { @@ -836,7 +836,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result } } - let highlighter = SyntectHighlighter::new(); + let highlighter = SyntectHighlighter::with_syntaxes(Some(&src.join(&cfg.highlight.syntaxes_dir))); let site = site_context(&cfg); let listing = page_listing(&cfg, &preps); let mut report = SiteReport::default(); new file mode 100644 @@ -0,0 +1,119 @@ +%YAML 1.2 +--- +# Org mode, for syntect. syntect bundles no Org definition, which is a conspicuous gap in +# a tool whose users write about org — every `#+BEGIN_SRC org` block in the documentation +# needs it. +# +# This highlights org as *source text you are reading about*, which is a different job +# from parsing it: org-ssg's own parser (src/parser.rs) is what turns org into a +# document. Where the two could disagree, this one stays conservative — a highlighter +# that colours something wrongly is a cosmetic bug, and one that swallows a line is not. +name: Org +file_extensions: [org] +scope: text.org + +contexts: + main: + - include: headings + - include: blocks + - include: keywords + - include: comments + - include: lists + - include: tables + - include: inline + + headings: + # The whole line is the heading; TODO keyword, priority and tags are picked out + # within it. + - match: '^(\*+)\s' + captures: + 1: punctuation.definition.heading.org + push: + - meta_scope: markup.heading.org + - match: $\n? + pop: true + - match: '\b(TODO|NEXT|WAITING|STARTED|DONE|CANCELLED|CANCELED)\b' + scope: keyword.other.todo.org + - match: '\[#[A-Z]\]' + scope: constant.other.priority.org + - match: '(:[A-Za-z0-9_@#%:]+:)\s*$' + scope: entity.name.tag.org + - include: inline + + blocks: + # A source block delegates nothing: the inner language is not embedded, because the + # point here is reading org, not rendering the code inside it. + - match: '^\s*(#\+(?i:BEGIN_SRC|BEGIN_EXAMPLE|BEGIN_QUOTE|BEGIN_CENTER|BEGIN_EXPORT|BEGIN_VERSE|BEGIN_COMMENT))(.*)$' + captures: + 1: keyword.control.block.begin.org + 2: variable.parameter.org + push: + - meta_scope: markup.raw.block.org + - match: '^\s*(#\+(?i:END_\w+))\s*$' + captures: + 1: keyword.control.block.end.org + pop: true + + keywords: + # #+TITLE:, #+DATE:, #+SLUG: and friends — the metadata at the top of a file. + - match: '^\s*(#\+[A-Za-z_]+:)(.*)$' + captures: + 1: keyword.other.keyword.org + 2: string.unquoted.org + + comments: + # `#` followed by a space. `#+` is a keyword and is matched above. + - match: '^\s*#\s.*$' + scope: comment.line.org + + lists: + - match: '^\s*([-+*]|\d+[.)])\s' + scope: punctuation.definition.list.org + - match: '\[[ xX-]\]' + scope: constant.language.checkbox.org + + tables: + - match: '^\s*\|.*$' + scope: markup.other.table.org + + inline: + - include: links + - include: timestamps + - match: '\[fn:[^\]]*\]' + scope: markup.other.footnote.org + # Verbatim and code carry no nested markup, so they come first. + - match: '=[^\s=][^=]*=' + scope: markup.raw.inline.org + - match: '~[^\s~][^~]*~' + scope: markup.raw.inline.org + - match: '\*[^\s*][^*]*\*' + scope: markup.bold.org + - match: '/[^\s/][^/]*/' + scope: markup.italic.org + - match: '_[^\s_][^_]*_' + scope: markup.underline.org + - match: '\+[^\s+][^+]*\+' + scope: markup.strikethrough.org + + links: + - match: '(\[\[)([^\]]*)(\])' + captures: + 1: punctuation.definition.link.org + 2: markup.underline.link.org + 3: punctuation.definition.link.org + push: + - match: '(\[)([^\]]*)(\]\])' + captures: + 1: punctuation.definition.link.org + 2: string.other.link.title.org + 3: punctuation.definition.link.org + pop: true + - match: '\]' + scope: punctuation.definition.link.org + pop: true + - match: '' + pop: true + + timestamps: + - match: '[<\[]\d{4}-\d{2}-\d{2}[^>\]]*[>\]]' + scope: constant.other.timestamp.org new file mode 100644 @@ -0,0 +1,105 @@ +%YAML 1.2 +--- +# TOML, for syntect. Not one of the definitions syntect bundles, and the first thing a +# config-heavy site needs — every org-ssg.toml example in the documentation is one. +# +# Scope names are the standard TextMate ones, so any syntect theme colours this without +# knowing it exists. +name: TOML +file_extensions: [toml, tml] +scope: source.toml + +contexts: + main: + - include: comments + - include: tables + - include: keys + - include: values + + comments: + - match: '#' + scope: punctuation.definition.comment.toml + push: + - meta_scope: comment.line.number-sign.toml + - match: $\n? + pop: true + + tables: + # [[array.of.tables]] before [table], so the doubled bracket wins. + - match: '^\s*(\[\[)([^\]]*)(\]\])' + captures: + 1: punctuation.definition.table.array.toml + 2: entity.name.section.toml + 3: punctuation.definition.table.array.toml + - match: '^\s*(\[)([^\]]*)(\])' + captures: + 1: punctuation.definition.table.toml + 2: entity.name.section.toml + 3: punctuation.definition.table.toml + + keys: + - match: '([A-Za-z0-9_\-]+|"[^"]*"|''[^'']*'')\s*(=)' + captures: + 1: variable.other.key.toml + 2: keyword.operator.assignment.toml + + values: + - include: strings + - match: '\b(true|false)\b' + scope: constant.language.toml + # Dates and times before numbers, or the year is read as an integer. + - match: '\b\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+\-]\d{2}:\d{2})?)?' + scope: constant.numeric.date.toml + - match: '\b\d{2}:\d{2}:\d{2}(\.\d+)?' + scope: constant.numeric.time.toml + - match: '\b0x[0-9A-Fa-f_]+\b' + scope: constant.numeric.hex.toml + - match: '\b0o[0-7_]+\b' + scope: constant.numeric.oct.toml + - match: '\b0b[01_]+\b' + scope: constant.numeric.bin.toml + - match: '[+\-]?\b\d[\d_]*(\.[\d_]+)?([eE][+\-]?\d+)?\b' + scope: constant.numeric.toml + - match: '\b(inf|nan)\b' + scope: constant.numeric.toml + + strings: + # Multi-line forms first: """ would otherwise match as an empty "" plus a stray ". + - match: '"""' + scope: punctuation.definition.string.begin.toml + push: + - meta_scope: string.quoted.double.block.toml + - match: '"""' + scope: punctuation.definition.string.end.toml + pop: true + - include: escapes + - match: "'''" + scope: punctuation.definition.string.begin.toml + push: + - meta_scope: string.quoted.single.block.toml + - match: "'''" + scope: punctuation.definition.string.end.toml + pop: true + - match: '"' + scope: punctuation.definition.string.begin.toml + push: + - meta_scope: string.quoted.double.toml + - match: '"' + scope: punctuation.definition.string.end.toml + pop: true + - match: $\n? + pop: true + - include: escapes + - match: "'" + scope: punctuation.definition.string.begin.toml + push: + - meta_scope: string.quoted.single.toml + - match: "'" + scope: punctuation.definition.string.end.toml + pop: true + - match: $\n? + pop: true + + escapes: + - match: '\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[btnfr"\\/]|\s*\n)' + scope: constant.character.escape.toml @@ -391,3 +391,114 @@ fn well_formed_fixtures_produce_no_diagnostics() { ); } } + +// --------------------------------------------------------------------------- +// Bundled syntax definitions, and org's comma escape +// --------------------------------------------------------------------------- + +/// syntect bundles neither TOML nor Org. Both are gaps this project hits on its own +/// first documentation page: every config example is TOML, and a tool for org users gets +/// written about in org. +#[test] +fn toml_and_org_blocks_are_highlighted() { + for (lang, code, expect_scope) in [ + ( + "toml", + "# comment\n[site]\ntitle = \"x\"\nport = 3000\nok = true\n", + "entity name section toml", + ), + ( + // The heading is comma-escaped, which org *requires* inside a block: an + // unescaped `*` at column 0 ends the block in Emacs too, verified against it. + "org", + ",#+TITLE: A page\n\n,* TODO [#A] Heading :tag:\n\nSome *bold* text.\n", + "markup heading org", + ), + ] { + let source = format!("#+BEGIN_SRC {lang}\n{code}#+END_SRC\n"); + let document = parse(Utf8PathBuf::from("t.org").as_path(), &source).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + + assert!( + html.contains(&format!("class=\"language-{lang} highlight\"")), + "{lang} should be highlighted, not fall back to plain code:\n{html}" + ); + assert!( + html.contains(expect_scope), + "{lang} should produce the scope {expect_scope:?}:\n{html}" + ); + } +} + +/// TOML's lexical corners: a table array is not a table, a date is not an integer, and a +/// comment is not a table header. +#[test] +fn the_toml_syntax_distinguishes_its_shapes() { + let code = "#+BEGIN_SRC toml\n# note\n[[collections]]\nwhen = 2026-08-11\nn = 12\ns = \"q\"\nb = false\n#+END_SRC\n"; + let document = parse(Utf8PathBuf::from("t.org").as_path(), code).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + + for scope in [ + "comment line number-sign toml", + "entity name section toml", + "constant numeric date toml", + "string quoted double toml", + "constant language toml", + ] { + assert!(html.contains(scope), "expected scope {scope:?}:\n{html}"); + } +} + +/// Org escapes a line inside a block that would look like structure by prefixing a +/// comma, and the exporter removes it. Without this, documentation *about* org shows the +/// escape characters its author had to type — to exactly the audience most likely to +/// notice. Verified against Emacs, which strips them. +#[test] +fn the_comma_escape_is_removed_from_block_content() { + let source = concat!( + "#+BEGIN_SRC org\n", + ",#+TITLE: A page\n", + ",* A heading\n", + ",,* not a heading, one comma removed\n", + "plain line\n", + "#+END_SRC\n", + ); + let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + // Highlighting splits the line across spans, so compare the text, not the markup. + let text = strip_tags(&html); + + assert!(text.contains("#+TITLE: A page"), "the comma is gone:\n{html}"); + assert!(!text.contains(",#+TITLE:"), "and not merely moved:\n{html}"); + assert!( + text.contains(",* not a heading"), + "a doubled comma loses exactly one:\n{html}" + ); + assert!(text.contains("plain line"), "other lines are untouched:\n{html}"); +} + +/// Text content of an HTML fragment, with tags removed and entities decoded. +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 => break, + } + } + text.push_str(rest); + text.replace("&", "&").replace("<", "<").replace(">", ">") +} + +/// A comma that is not an escape is content, and must survive. +#[test] +fn an_ordinary_leading_comma_is_not_stripped() { + let source = "#+BEGIN_SRC text\n, a list continuation\n,not an escape\n#+END_SRC\n"; + let document = parse(Utf8PathBuf::from("t.org").as_path(), source).expect("parse"); + let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new()); + let text = strip_tags(&html); + assert!(text.contains(", a list continuation"), "{html}"); + assert!(text.contains(",not an escape"), "{html}"); +}