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

v0.15: bundled TOML and Org syntaxes, and org's comma escape

The documentation site written last commit hit both gaps on its first page: syntect
bundles no TOML, and every org-ssg.toml example is TOML; syntect bundles no Org, and a
tool for org users gets written about in org. Both definitions are now written as
.sublime-syntax files under syntaxes/ and compiled into the binary with include_str!, so
they work with no setup like the rest of the zero-config path. 23 code blocks across the
docs went from uncoloured to highlighted.

[highlight] syntaxes_dir loads further .sublime-syntax files from a directory, so the
next missing language is a file rather than a release. A definition that fails to parse
is reported and skipped — one bad file should not stop a site building — while a
malformed *bundled* definition panics, because that is a bug in this crate.

Writing the Org syntax surfaced a real parser bug. Org escapes a line inside a block that
would otherwise look like structure with a leading comma — `,* heading`, `,#+KEYWORD:` —
and the exporter removes exactly one. org-ssg kept it, so documentation *about* org
displayed the escape characters its author had to type, to precisely the audience most
likely to notice. Emacs was consulted and strips them; now so do we.

Checking that against the oracle answered a second question worth recording: an
*unescaped* `*` at column zero inside a block ends the block in Emacs too. Our behaviour
already matched, and my test fixture was invalid org rather than the parser being wrong.
That is why the escape exists at all, and it is now documented.

CACHE_FORMAT_VERSION bumped to 5, because the comma fix changes output for existing
sources and a cache written by the previous binary would otherwise serve the old text.
Noticed only because a rebuild reported "0 rendered" when the output should have changed
— worth remembering that the render key covers inputs, not the binary.
 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(-)

diff --git a/Cargo.lock b/Cargo.lock
index 52e225c..96db404 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -675,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.14.0"
+version = "0.15.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 40d467a..40e6095 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index ce26b45..fc36189 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index a6fbcf0..c354143 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -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 |
diff --git a/docs/guide/05-org-support.org b/docs/guide/05-org-support.org
index be94681..1b4b0b1 100644
--- a/docs/guide/05-org-support.org
+++ b/docs/guide/05-org-support.org
@@ -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
 
diff --git a/src/config.rs b/src/config.rs
index e04ecf4..015dc85 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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
diff --git a/src/incremental.rs b/src/incremental.rs
index c4e6341..905e89d 100644
--- a/src/incremental.rs
+++ b/src/incremental.rs
@@ -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;
diff --git a/src/parser.rs b/src/parser.rs
index 81454a4..86b2eb7 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -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).
diff --git a/src/render.rs b/src/render.rs
index b36c681..4d8976f 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -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 {
diff --git a/src/site.rs b/src/site.rs
index 1017f54..f6d7e3a 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -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();
diff --git a/syntaxes/Org.sublime-syntax b/syntaxes/Org.sublime-syntax
new file mode 100644
index 0000000..38ea843
--- /dev/null
+++ b/syntaxes/Org.sublime-syntax
@@ -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
diff --git a/syntaxes/TOML.sublime-syntax b/syntaxes/TOML.sublime-syntax
new file mode 100644
index 0000000..424c9cf
--- /dev/null
+++ b/syntaxes/TOML.sublime-syntax
@@ -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
diff --git a/tests/constructs.rs b/tests/constructs.rs
index 722ffad..018b270 100644
--- a/tests/constructs.rs
+++ b/tests/constructs.rs
@@ -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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
+}
+
+/// 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}");
+}