krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 0e8d2cb26d

0e8d2cb26dc57902abf721d0a9a8ca01963b6326

parent: c8729def37

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-21 19:56 UTC

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.

Layout: unified · split

Cargo.lock +1 −1
@@ -675,7 +675,7 @@ dependencies = [
675675
676676[[package]]
677677name = "orgo"
678version = "0.21.0"
678version = "0.22.0"
679679dependencies = [
680680 "anyhow",
681681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "orgo"
3version = "0.21.0"
3version = "0.22.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "0BSD"
docs/guide/02-configuration.org +31 −7
@@ -30,6 +30,7 @@ expose_page_list = false
3030
3131[highlight]
3232theme = "InspiredGitHub"
33theme_dark = ""
3334syntaxes_dir = "syntaxes"
3435
3536[build]
@@ -92,13 +93,14 @@ and redefine the handful you care about:
9293}
9394#+END_SRC
9495
95Code /blocks/ are the one part a theme cannot make follow =prefers-color-scheme=:
96=syntax.css= is generated from a single syntect theme, so a theme's dark mode keeps its
97block surface light to stay readable against the default =InspiredGitHub=. Pair a dark
98=highlight.theme= with overrides of =--orgo-code-bg=, =--orgo-code-fg= and
99=--orgo-code-rule=. Those three colour blocks only — inline =~code~= follows the page's
100own scheme, so it stays legible whichever highlight theme you use. The documentation site
101does exactly this; its =style.css= is those three lines and nothing else.
96Code /blocks/ are the one part a built-in theme leaves light in dark mode: =syntax.css=
97is coloured by =highlight.theme=, and that default is a light theme. Two pieces make a
98block follow =prefers-color-scheme= — =highlight.theme_dark= for the tokens, and
99=--orgo-code-bg=, =--orgo-code-fg= and =--orgo-code-rule= for the surface under them.
100Set both, or neither and blocks stay light in both schemes. Those three properties
101colour blocks only — inline =~code~= follows the page's own scheme, so it stays legible
102whichever highlight theme you use. The documentation site keeps a dark surface in both
103schemes instead; its =style.css= is those three lines and nothing else.
102104
103105When you outgrow a theme, drop =theme= from the config and write =templates/base.html=
104106against your own CSS. Nothing else changes.
@@ -223,12 +225,34 @@ adding a post a one-page rebuild.
223225| Key | Default | Meaning |
224226|-----+---------+---------|
225227| =theme= | ="InspiredGitHub"= | A syntect theme name. |
228| =theme_dark= | =""= | A second theme for readers in dark mode. |
226229| =syntaxes_dir= | ="syntaxes"= | Extra =.sublime-syntax= files. |
227230
228231Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=,
229232=base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=.
230233An unknown name is an error listing the valid ones.
231234
235=theme_dark= is off by default, and one theme colours every reader. Name a second one and
236=syntax.css= carries both, each behind the =prefers-color-scheme= query it belongs to, so
237a reader's system picks the colours — one stylesheet, no JavaScript, nothing extra for a
238layout to link:
239
240#+BEGIN_SRC toml
241[highlight]
242theme = "InspiredGitHub"
243theme_dark = "base16-ocean.dark"
244#+END_SRC
245
246Only the token colours change with the scheme. The surface a block sits on is the page's,
247so give your dark mode a dark =pre= background — a dark theme's colours are chosen for
248one — with =--orgo-code-bg= under a built-in theme, or your own CSS.
249
250The two themes are separated rather than stacked because they name different scopes: a
251light theme's =.source.python .keyword= outranks a dark theme's =.keyword=, so appending
252one to the other would leave light colours on some tokens. The cost of the separation is
253that a browser too old to know =prefers-color-scheme= matches neither query and shows
254code unhighlighted. Leaving =theme_dark= empty keeps the unconditional rules of before.
255
232256Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet.
233257Each build writes =syntax.css= into the output and every page links it.
234258
docs/style.css +5 −4
@@ -4,10 +4,11 @@
44 * image or font in a source directory reaches the output. It is linked after theme.css,
55 * which is what lets these definitions win.
66 *
7 * Only the code surface is changed. syntax.css is generated from one syntect theme and
8 * cannot follow prefers-color-scheme, so a site pairing a dark `highlight.theme` with a
9 * theme has to say what colour code sits on — here, dark in both schemes, matching
10 * base16-ocean.dark. Everything else is the theme's. */
7 * Only the code surface is changed. syntax.css colours tokens, never the surface under
8 * them, so a site pairing a dark `highlight.theme` with a theme has to say what colour
9 * code sits on — here, dark in both schemes, matching base16-ocean.dark. A site wanting
10 * blocks to follow prefers-color-scheme sets `highlight.theme_dark` and gives these
11 * three a dark query too. Everything else is the theme's. */
1112
1213:root {
1314 --orgo-code-bg: #2b303b;
src/config.rs +9
@@ -419,6 +419,11 @@ pub struct Highlight {
419419 /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the
420420 /// generated `syntax.css` colours them with.
421421 pub theme: String,
422 /// A second syntect theme for readers whose system asks for dark mode. `syntax.css`
423 /// then carries both, each behind its own `prefers-color-scheme` query, so one
424 /// stylesheet serves both schemes — see [`crate::render::syntax_stylesheet`]. Empty,
425 /// the default, means `theme` colours every reader whatever their scheme.
426 pub theme_dark: String,
422427}
423428
424429impl Default for Highlight {
@@ -426,6 +431,7 @@ impl Default for Highlight {
426431 Highlight {
427432 syntaxes_dir: Utf8PathBuf::from("syntaxes"),
428433 theme: "InspiredGitHub".to_string(),
434 theme_dark: String::new(),
429435 }
430436 }
431437}
@@ -617,6 +623,9 @@ expose_page_list = false
617623# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
618624# base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
619625theme = "InspiredGitHub"
626# A second theme for readers in dark mode. Set it and syntax.css carries both themes,
627# each behind its own prefers-color-scheme query. Empty means `theme` colours everyone.
628theme_dark = ""
620629# Extra .sublime-syntax files for languages neither syntect nor orgo bundles.
621630syntaxes_dir = "syntaxes"
622631
src/main.rs +1 −7
@@ -294,13 +294,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
294294 let config = Config::load(dir)?;
295295 config.validate()?;
296296 let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?;
297 let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
298 anyhow::anyhow!(
299 "unknown highlight.theme {:?}. Available: {}",
300 config.highlight.theme,
301 render::available_themes().join(", ")
302 )
303 })?;
297 let css_text = render::syntax_stylesheet(&config.highlight)?;
304298
305299 let site = SiteContext {
306300 title: config.site.title.clone(),
src/render.rs +38
@@ -119,6 +119,44 @@ pub fn available_themes() -> Vec<&'static str> {
119119 theme_set().themes.keys().map(String::as_str).collect()
120120}
121121
122/// The whole stylesheet a build writes: `highlight.theme`'s rules, and — when
123/// `highlight.theme_dark` is set — a second theme's, each behind the
124/// `prefers-color-scheme` query it belongs to. One file, both schemes, no JavaScript and
125/// nothing extra for a layout to link.
126///
127/// The two themes are *separated* rather than stacked, because stacking does not work:
128/// syntect writes a rule per scope its theme names, and the themes name different ones.
129/// A light theme's language-specific selector (`.source.python .keyword`) outranks a
130/// dark theme's plain `.keyword`, so a dark theme appended after a light one would leave
131/// light colours on some tokens — the half-themed look this setting exists to avoid.
132/// The cost is that a browser too old to know `prefers-color-scheme` matches neither
133/// query and renders code unhighlighted; a site that sets one theme is untouched by this
134/// and keeps its unconditional rules.
135///
136/// Only token colours change with the scheme. A code block's *surface* is the page's,
137/// set by whatever stylesheet the layout links, so a dark theme needs a dark background
138/// there to sit on.
139pub fn syntax_stylesheet(cfg: &crate::config::Highlight) -> anyhow::Result<String> {
140 let light = theme_css_or_error(&cfg.theme, "highlight.theme")?;
141 if cfg.theme_dark.is_empty() {
142 return Ok(light);
143 }
144 let dark = theme_css_or_error(&cfg.theme_dark, "highlight.theme_dark")?;
145 Ok(format!(
146 "@media (prefers-color-scheme: light) {{\n{light}}}\n\n\
147 @media (prefers-color-scheme: dark) {{\n{dark}}}\n"
148 ))
149}
150
151fn theme_css_or_error(theme: &str, key: &str) -> anyhow::Result<String> {
152 syntax_css(theme).ok_or_else(|| {
153 anyhow::anyhow!(
154 "unknown {key} {theme:?}. Available: {}",
155 available_themes().join(", ")
156 )
157 })
158}
159
122160/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
123161/// whose language syntect does not know falls back to escaped `<pre><code>`.
124162pub struct SyntectHighlighter {
src/site.rs +1 −7
@@ -975,13 +975,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
975975
976976 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
977977 check_page_templates(&templater, &preps)?;
978 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
979 anyhow::anyhow!(
980 "unknown highlight.theme {:?}. Available: {}",
981 cfg.highlight.theme,
982 render::available_themes().join(", ")
983 )
984 })?;
978 let syntax_css = render::syntax_stylesheet(&cfg.highlight)?;
985979
986980 // The global hash classes (spec §4.1): a change in any invalidates the site. The
987981 // config hash is combined with a site-structure hash covering the global chrome each
tests/config.rs +59
@@ -381,6 +381,65 @@ fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
381381 );
382382}
383383
384/// Highlighting emits classes, so a dark reading of a page is a second set of colours
385/// for them — not a second stylesheet the layout has to know to link. Each theme is
386/// behind its own query: the two name different scopes, and a light theme's
387/// language-specific selectors would outrank a dark theme's plain ones if both applied.
388#[test]
389fn a_dark_highlight_theme_is_written_into_the_same_stylesheet() {
390 let root = tmpdir("theme-dark");
391 let src = root.join("src");
392 std::fs::create_dir_all(&src).unwrap();
393 write_site(&src);
394 std::fs::write(
395 src.join("orgo.toml"),
396 "[highlight]\ntheme = \"InspiredGitHub\"\ntheme_dark = \"base16-ocean.dark\"\n",
397 )
398 .unwrap();
399 let out = root.join("out");
400 build(&src, &out);
401
402 let css = std::fs::read_to_string(out.join("syntax.css")).expect("syntax.css");
403 let light = css
404 .find("@media (prefers-color-scheme: light)")
405 .expect("the light theme is behind a scheme query");
406 let dark = css
407 .find("@media (prefers-color-scheme: dark)")
408 .expect("the dark theme is behind a scheme query");
409 assert!(light < dark, "light first, dark second: {css:.200}");
410 assert!(
411 css[..dark].contains(".comment") && css[dark..].contains("Base16 Ocean Dark"),
412 "each theme's rules sit inside its own query"
413 );
414 assert!(
415 !css[dark..].contains("@media (prefers-color-scheme: light)"),
416 "the queries are siblings, not nested"
417 );
418}
419
420/// The same mystery as an unknown `theme`, and it must name the key that is wrong —
421/// the two differ only in which scheme they colour.
422#[test]
423fn an_unknown_dark_highlight_theme_is_rejected_by_name() {
424 let root = tmpdir("theme-dark-bad");
425 let src = root.join("src");
426 std::fs::create_dir_all(&src).unwrap();
427 write_site(&src);
428 std::fs::write(
429 src.join("orgo.toml"),
430 "[highlight]\ntheme_dark = \"nope\"\n",
431 )
432 .unwrap();
433
434 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
435 .expect_err("unknown dark theme must fail");
436 let message = format!("{err:#}");
437 assert!(
438 message.contains("highlight.theme_dark") && message.contains("nope"),
439 "names the key and the bad theme: {message}"
440 );
441}
442
384443// ---------------------------------------------------------------------------
385444// Discovery
386445// ---------------------------------------------------------------------------
themes/blog.css +4 −3
@@ -19,9 +19,10 @@
1919 --orgo-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
2020 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
2121 /* These three colour code *blocks* only; inline code follows the page. A block keeps
22 a light surface in both colour schemes because syntax.css is generated from one
23 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
24 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
22 a light surface in both colour schemes, because syntax.css is coloured by
23 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
24 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
25 three in a dark query of your own. */
2526 --orgo-todo: #b02a37;
2627 --orgo-done: #2c7a4b;
2728 --orgo-code-bg: #f5f2ec;
themes/docs.css +4 −3
@@ -18,9 +18,10 @@
1818 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1919 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
2020 /* These three colour code *blocks* only; inline code follows the page. A block keeps
21 a light surface in both colour schemes because syntax.css is generated from one
22 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
23 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
21 a light surface in both colour schemes, because syntax.css is coloured by
22 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
23 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
24 three in a dark query of your own. */
2425 --orgo-todo: #b02a37;
2526 --orgo-done: #2c7a4b;
2627 --orgo-code-bg: #f6f8fa;
themes/plain.css +4 −3
@@ -18,9 +18,10 @@
1818 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1919 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
2020 /* These three colour code *blocks* only; inline code follows the page. A block keeps
21 a light surface in both colour schemes because syntax.css is generated from one
22 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
23 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
21 a light surface in both colour schemes, because syntax.css is coloured by
22 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
23 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
24 three in a dark query of your own. */
2425 --orgo-todo: #b02a37;
2526 --orgo-done: #2c7a4b;
2627 --orgo-code-bg: #f6f8fa;
themes/wiki.css +4 −3
@@ -21,9 +21,10 @@
2121 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
2222 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
2323 /* These three colour code *blocks* only; inline code follows the page. A block keeps
24 a light surface in both colour schemes because syntax.css is generated from one
25 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
26 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
24 a light surface in both colour schemes, because syntax.css is coloured by
25 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
26 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
27 three in a dark query of your own. */
2728 --orgo-todo: #b02a37;
2829 --orgo-done: #2c7a4b;
2930 --orgo-code-bg: #f6f8fa;