krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

0e8d2cb26dc57902abf721d0a9a8ca01963b6326

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-21T19:56:09Z

0.22.0

A second syntax highlighting theme, `highlight.theme_dark`, for readers whose
system asks for dark mode. Set it and `syntax.css` carries both themes, each
behind its own `prefers-color-scheme` query — one stylesheet, no second link
tag, no JavaScript. A minor release because it adds a config key; a site that
leaves it empty gets exactly the unconditional rules 0.21.0 wrote.

The two themes are separated rather than stacked, which is the whole design
decision here. syntect emits a rule per scope its theme names, and two themes
name different ones: InspiredGitHub defines `.source.python .keyword` where
base16-ocean.dark defines only `.keyword`, so a dark theme appended after a
light one loses the cascade on some hundred selectors and leaves light colours
on tokens sitting over a dark block. Giving each theme its own query costs the
browsers too old to know `prefers-color-scheme` — they match neither and render
code unhighlighted — and that cost falls only on sites that opt in.

Only token colours follow the scheme. The surface under them is the page's, so
the built-in themes still leave `--orgo-code-*` to the site; their comments and
the configuration guide now say how the two settings pair instead of claiming a
code block cannot follow the reader's scheme at all.

A 199-page site builds byte-identical output under 0.21.0 and this version.
 Cargo.lock                      |  2 +-
 Cargo.toml                      |  2 +-
 docs/guide/02-configuration.org | 38 +++++++++++++++++++++-----
 docs/style.css                  |  9 ++++---
 src/config.rs                   |  9 +++++++
 src/main.rs                     |  8 +-----
 src/render.rs                   | 38 ++++++++++++++++++++++++++
 src/site.rs                     |  8 +-----
 tests/config.rs                 | 59 +++++++++++++++++++++++++++++++++++++++++
 themes/blog.css                 |  7 ++---
 themes/docs.css                 |  7 ++---
 themes/plain.css                |  7 ++---
 themes/wiki.css                 |  7 ++---
 13 files changed, 162 insertions(+), 39 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 862a8fd..662d894 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -675,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "orgo"
-version = "0.21.0"
+version = "0.22.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index b158c6d..837654f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "orgo"
-version = "0.21.0"
+version = "0.22.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "0BSD"
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index 4b94bc4..18edf4d 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -30,6 +30,7 @@ expose_page_list = false
 
 [highlight]
 theme = "InspiredGitHub"
+theme_dark = ""
 syntaxes_dir = "syntaxes"
 
 [build]
@@ -92,13 +93,14 @@ and redefine the handful you care about:
 }
 #+END_SRC
 
-Code /blocks/ are the one part a theme cannot make follow =prefers-color-scheme=:
-=syntax.css= is generated from a single syntect theme, so a theme's dark mode keeps its
-block surface light to stay readable against the default =InspiredGitHub=. Pair a dark
-=highlight.theme= with overrides of =--orgo-code-bg=, =--orgo-code-fg= and
-=--orgo-code-rule=. Those three colour blocks only — inline =~code~= follows the page's
-own scheme, so it stays legible whichever highlight theme you use. The documentation site
-does exactly this; its =style.css= is those three lines and nothing else.
+Code /blocks/ are the one part a built-in theme leaves light in dark mode: =syntax.css=
+is coloured by =highlight.theme=, and that default is a light theme. Two pieces make a
+block follow =prefers-color-scheme= — =highlight.theme_dark= for the tokens, and
+=--orgo-code-bg=, =--orgo-code-fg= and =--orgo-code-rule= for the surface under them.
+Set both, or neither and blocks stay light in both schemes. Those three properties
+colour blocks only — inline =~code~= follows the page's own scheme, so it stays legible
+whichever highlight theme you use. The documentation site keeps a dark surface in both
+schemes instead; its =style.css= is those three lines and nothing else.
 
 When you outgrow a theme, drop =theme= from the config and write =templates/base.html=
 against your own CSS. Nothing else changes.
@@ -223,12 +225,34 @@ adding a post a one-page rebuild.
 | Key | Default | Meaning |
 |-----+---------+---------|
 | =theme= | ="InspiredGitHub"= | A syntect theme name. |
+| =theme_dark= | =""= | A second theme for readers in dark mode. |
 | =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=.
 An unknown name is an error listing the valid ones.
 
+=theme_dark= is off by default, and one theme colours every reader. Name a second one and
+=syntax.css= carries both, each behind the =prefers-color-scheme= query it belongs to, so
+a reader's system picks the colours — one stylesheet, no JavaScript, nothing extra for a
+layout to link:
+
+#+BEGIN_SRC toml
+[highlight]
+theme = "InspiredGitHub"
+theme_dark = "base16-ocean.dark"
+#+END_SRC
+
+Only the token colours change with the scheme. The surface a block sits on is the page's,
+so give your dark mode a dark =pre= background — a dark theme's colours are chosen for
+one — with =--orgo-code-bg= under a built-in theme, or your own CSS.
+
+The two themes are separated rather than stacked because they name different scopes: a
+light theme's =.source.python .keyword= outranks a dark theme's =.keyword=, so appending
+one to the other would leave light colours on some tokens. The cost of the separation is
+that a browser too old to know =prefers-color-scheme= matches neither query and shows
+code unhighlighted. Leaving =theme_dark= empty keeps the unconditional rules of before.
+
 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.
 
diff --git a/docs/style.css b/docs/style.css
index c84f892..6da55a6 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -4,10 +4,11 @@
  * image or font in a source directory reaches the output. It is linked after theme.css,
  * which is what lets these definitions win.
  *
- * Only the code surface is changed. syntax.css is generated from one syntect theme and
- * cannot follow prefers-color-scheme, so a site pairing a dark `highlight.theme` with a
- * theme has to say what colour code sits on — here, dark in both schemes, matching
- * base16-ocean.dark. Everything else is the theme's. */
+ * Only the code surface is changed. syntax.css colours tokens, never the surface under
+ * them, so a site pairing a dark `highlight.theme` with a theme has to say what colour
+ * code sits on — here, dark in both schemes, matching base16-ocean.dark. A site wanting
+ * blocks to follow prefers-color-scheme sets `highlight.theme_dark` and gives these
+ * three a dark query too. Everything else is the theme's. */
 
 :root {
   --orgo-code-bg: #2b303b;
diff --git a/src/config.rs b/src/config.rs
index 5a83dd0..b49ad2f 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -419,6 +419,11 @@ pub struct Highlight {
     /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the
     /// generated `syntax.css` colours them with.
     pub theme: String,
+    /// A second syntect theme for readers whose system asks for dark mode. `syntax.css`
+    /// then carries both, each behind its own `prefers-color-scheme` query, so one
+    /// stylesheet serves both schemes — see [`crate::render::syntax_stylesheet`]. Empty,
+    /// the default, means `theme` colours every reader whatever their scheme.
+    pub theme_dark: String,
 }
 
 impl Default for Highlight {
@@ -426,6 +431,7 @@ impl Default for Highlight {
         Highlight {
             syntaxes_dir: Utf8PathBuf::from("syntaxes"),
             theme: "InspiredGitHub".to_string(),
+            theme_dark: String::new(),
         }
     }
 }
@@ -617,6 +623,9 @@ 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"
+# A second theme for readers in dark mode. Set it and syntax.css carries both themes,
+# each behind its own prefers-color-scheme query. Empty means `theme` colours everyone.
+theme_dark = ""
 # Extra .sublime-syntax files for languages neither syntect nor orgo bundles.
 syntaxes_dir = "syntaxes"
 
diff --git a/src/main.rs b/src/main.rs
index f813609..5c75981 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -294,13 +294,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
     let config = Config::load(dir)?;
     config.validate()?;
     let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?;
-    let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
-        anyhow::anyhow!(
-            "unknown highlight.theme {:?}. Available: {}",
-            config.highlight.theme,
-            render::available_themes().join(", ")
-        )
-    })?;
+    let css_text = render::syntax_stylesheet(&config.highlight)?;
 
     let site = SiteContext {
         title: config.site.title.clone(),
diff --git a/src/render.rs b/src/render.rs
index 4496862..e675ba7 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -119,6 +119,44 @@ pub fn available_themes() -> Vec<&'static str> {
     theme_set().themes.keys().map(String::as_str).collect()
 }
 
+/// The whole stylesheet a build writes: `highlight.theme`'s rules, and — when
+/// `highlight.theme_dark` is set — a second theme's, each behind the
+/// `prefers-color-scheme` query it belongs to. One file, both schemes, no JavaScript and
+/// nothing extra for a layout to link.
+///
+/// The two themes are *separated* rather than stacked, because stacking does not work:
+/// syntect writes a rule per scope its theme names, and the themes name different ones.
+/// A light theme's language-specific selector (`.source.python .keyword`) outranks a
+/// dark theme's plain `.keyword`, so a dark theme appended after a light one would leave
+/// light colours on some tokens — the half-themed look this setting exists to avoid.
+/// The cost is that a browser too old to know `prefers-color-scheme` matches neither
+/// query and renders code unhighlighted; a site that sets one theme is untouched by this
+/// and keeps its unconditional rules.
+///
+/// Only token colours change with the scheme. A code block's *surface* is the page's,
+/// set by whatever stylesheet the layout links, so a dark theme needs a dark background
+/// there to sit on.
+pub fn syntax_stylesheet(cfg: &crate::config::Highlight) -> anyhow::Result<String> {
+    let light = theme_css_or_error(&cfg.theme, "highlight.theme")?;
+    if cfg.theme_dark.is_empty() {
+        return Ok(light);
+    }
+    let dark = theme_css_or_error(&cfg.theme_dark, "highlight.theme_dark")?;
+    Ok(format!(
+        "@media (prefers-color-scheme: light) {{\n{light}}}\n\n\
+         @media (prefers-color-scheme: dark) {{\n{dark}}}\n"
+    ))
+}
+
+fn theme_css_or_error(theme: &str, key: &str) -> anyhow::Result<String> {
+    syntax_css(theme).ok_or_else(|| {
+        anyhow::anyhow!(
+            "unknown {key} {theme:?}. Available: {}",
+            available_themes().join(", ")
+        )
+    })
+}
+
 /// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
 /// whose language syntect does not know falls back to escaped `<pre><code>`.
 pub struct SyntectHighlighter {
diff --git a/src/site.rs b/src/site.rs
index 0dc835d..352f761 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -975,13 +975,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
 
     let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
     check_page_templates(&templater, &preps)?;
-    let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
-        anyhow::anyhow!(
-            "unknown highlight.theme {:?}. Available: {}",
-            cfg.highlight.theme,
-            render::available_themes().join(", ")
-        )
-    })?;
+    let syntax_css = render::syntax_stylesheet(&cfg.highlight)?;
 
     // The global hash classes (spec §4.1): a change in any invalidates the site. The
     // config hash is combined with a site-structure hash covering the global chrome each
diff --git a/tests/config.rs b/tests/config.rs
index dd86bed..788d71a 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -381,6 +381,65 @@ fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
     );
 }
 
+/// Highlighting emits classes, so a dark reading of a page is a second set of colours
+/// for them — not a second stylesheet the layout has to know to link. Each theme is
+/// behind its own query: the two name different scopes, and a light theme's
+/// language-specific selectors would outrank a dark theme's plain ones if both applied.
+#[test]
+fn a_dark_highlight_theme_is_written_into_the_same_stylesheet() {
+    let root = tmpdir("theme-dark");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("orgo.toml"),
+        "[highlight]\ntheme = \"InspiredGitHub\"\ntheme_dark = \"base16-ocean.dark\"\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let css = std::fs::read_to_string(out.join("syntax.css")).expect("syntax.css");
+    let light = css
+        .find("@media (prefers-color-scheme: light)")
+        .expect("the light theme is behind a scheme query");
+    let dark = css
+        .find("@media (prefers-color-scheme: dark)")
+        .expect("the dark theme is behind a scheme query");
+    assert!(light < dark, "light first, dark second: {css:.200}");
+    assert!(
+        css[..dark].contains(".comment") && css[dark..].contains("Base16 Ocean Dark"),
+        "each theme's rules sit inside its own query"
+    );
+    assert!(
+        !css[dark..].contains("@media (prefers-color-scheme: light)"),
+        "the queries are siblings, not nested"
+    );
+}
+
+/// The same mystery as an unknown `theme`, and it must name the key that is wrong —
+/// the two differ only in which scheme they colour.
+#[test]
+fn an_unknown_dark_highlight_theme_is_rejected_by_name() {
+    let root = tmpdir("theme-dark-bad");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("orgo.toml"),
+        "[highlight]\ntheme_dark = \"nope\"\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("unknown dark theme must fail");
+    let message = format!("{err:#}");
+    assert!(
+        message.contains("highlight.theme_dark") && message.contains("nope"),
+        "names the key and the bad theme: {message}"
+    );
+}
+
 // ---------------------------------------------------------------------------
 // Discovery
 // ---------------------------------------------------------------------------
diff --git a/themes/blog.css b/themes/blog.css
index a15dbdf..07aad98 100644
--- a/themes/blog.css
+++ b/themes/blog.css
@@ -19,9 +19,10 @@
   --orgo-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
   --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
   /* These three colour code *blocks* only; inline code follows the page. A block keeps
-     a light surface in both colour schemes because syntax.css is generated from one
-     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
-     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+     a light surface in both colour schemes, because syntax.css is coloured by
+     `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
+     prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
+     three in a dark query of your own. */
   --orgo-todo: #b02a37;
   --orgo-done: #2c7a4b;
   --orgo-code-bg: #f5f2ec;
diff --git a/themes/docs.css b/themes/docs.css
index d7964f1..0d57787 100644
--- a/themes/docs.css
+++ b/themes/docs.css
@@ -18,9 +18,10 @@
   --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
   --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
   /* These three colour code *blocks* only; inline code follows the page. A block keeps
-     a light surface in both colour schemes because syntax.css is generated from one
-     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
-     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+     a light surface in both colour schemes, because syntax.css is coloured by
+     `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
+     prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
+     three in a dark query of your own. */
   --orgo-todo: #b02a37;
   --orgo-done: #2c7a4b;
   --orgo-code-bg: #f6f8fa;
diff --git a/themes/plain.css b/themes/plain.css
index eecc711..b704a11 100644
--- a/themes/plain.css
+++ b/themes/plain.css
@@ -18,9 +18,10 @@
   --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
   --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
   /* These three colour code *blocks* only; inline code follows the page. A block keeps
-     a light surface in both colour schemes because syntax.css is generated from one
-     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
-     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+     a light surface in both colour schemes, because syntax.css is coloured by
+     `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
+     prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
+     three in a dark query of your own. */
   --orgo-todo: #b02a37;
   --orgo-done: #2c7a4b;
   --orgo-code-bg: #f6f8fa;
diff --git a/themes/wiki.css b/themes/wiki.css
index 4edaba3..595a238 100644
--- a/themes/wiki.css
+++ b/themes/wiki.css
@@ -21,9 +21,10 @@
   --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
   --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
   /* These three colour code *blocks* only; inline code follows the page. A block keeps
-     a light surface in both colour schemes because syntax.css is generated from one
-     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
-     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+     a light surface in both colour schemes, because syntax.css is coloured by
+     `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
+     prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
+     three in a dark query of your own. */
   --orgo-todo: #b02a37;
   --orgo-done: #2c7a4b;
   --orgo-code-bg: #f6f8fa;