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
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(-) @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "orgo" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -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" @@ -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. @@ -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; @@ -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" @@ -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(), @@ -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 { @@ -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 @@ -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 // --------------------------------------------------------------------------- @@ -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; @@ -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; @@ -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; @@ -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;