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 = [
675 675
676[[package]] 676[[package]]
677name = "orgo" 677name = "orgo"
678version = "0.21.0" 678version = "0.22.0"
679dependencies = [ 679dependencies = [
680 "anyhow", 680 "anyhow",
681 "blake3", 681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "orgo" 2name = "orgo"
3version = "0.21.0" 3version = "0.22.0"
4edition = "2021" 4edition = "2021"
5description = "Org-mode static site generator that renders the org element tree straight to HTML" 5description = "Org-mode static site generator that renders the org element tree straight to HTML"
6license = "0BSD" 6license = "0BSD"
docs/guide/02-configuration.org +31 −7
@@ -30,6 +30,7 @@ expose_page_list = false
30 30
31[highlight] 31[highlight]
32theme = "InspiredGitHub" 32theme = "InspiredGitHub"
33theme_dark = ""
33syntaxes_dir = "syntaxes" 34syntaxes_dir = "syntaxes"
34 35
35[build] 36[build]
@@ -92,13 +93,14 @@ and redefine the handful you care about:
92} 93}
93#+END_SRC 94#+END_SRC
94 95
95Code /blocks/ are the one part a theme cannot make follow =prefers-color-scheme=: 96Code /blocks/ are the one part a built-in theme leaves light in dark mode: =syntax.css=
96=syntax.css= is generated from a single syntect theme, so a theme's dark mode keeps its 97is coloured by =highlight.theme=, and that default is a light theme. Two pieces make a
97block surface light to stay readable against the default =InspiredGitHub=. Pair a dark 98block follow =prefers-color-scheme= — =highlight.theme_dark= for the tokens, and
98=highlight.theme= with overrides of =--orgo-code-bg=, =--orgo-code-fg= and 99=--orgo-code-bg=, =--orgo-code-fg= and =--orgo-code-rule= for the surface under them.
99=--orgo-code-rule=. Those three colour blocks only — inline =~code~= follows the page's 100Set both, or neither and blocks stay light in both schemes. Those three properties
100own scheme, so it stays legible whichever highlight theme you use. The documentation site 101colour blocks only — inline =~code~= follows the page's own scheme, so it stays legible
101does exactly this; its =style.css= is those three lines and nothing else. 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.
102 104
103When you outgrow a theme, drop =theme= from the config and write =templates/base.html= 105When you outgrow a theme, drop =theme= from the config and write =templates/base.html=
104against your own CSS. Nothing else changes. 106against your own CSS. Nothing else changes.
@@ -223,12 +225,34 @@ adding a post a one-page rebuild.
223| Key | Default | Meaning | 225| Key | Default | Meaning |
224|-----+---------+---------| 226|-----+---------+---------|
225| =theme= | ="InspiredGitHub"= | A syntect theme name. | 227| =theme= | ="InspiredGitHub"= | A syntect theme name. |
228| =theme_dark= | =""= | A second theme for readers in dark mode. |
226| =syntaxes_dir= | ="syntaxes"= | Extra =.sublime-syntax= files. | 229| =syntaxes_dir= | ="syntaxes"= | Extra =.sublime-syntax= files. |
227 230
228Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=, 231Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=,
229=base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=. 232=base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=.
230An unknown name is an error listing the valid ones. 233An unknown name is an error listing the valid ones.
231 234
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
232Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet. 256Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet.
233Each build writes =syntax.css= into the output and every page links it. 257Each build writes =syntax.css= into the output and every page links it.
234 258
docs/style.css +5 −4
@@ -4,10 +4,11 @@
4 * image or font in a source directory reaches the output. It is linked after theme.css, 4 * image or font in a source directory reaches the output. It is linked after theme.css,
5 * which is what lets these definitions win. 5 * which is what lets these definitions win.
6 * 6 *
7 * Only the code surface is changed. syntax.css is generated from one syntect theme and 7 * Only the code surface is changed. syntax.css colours tokens, never the surface under
8 * cannot follow prefers-color-scheme, so a site pairing a dark `highlight.theme` with a 8 * them, so a site pairing a dark `highlight.theme` with a theme has to say what colour
9 * theme has to say what colour code sits on — here, dark in both schemes, matching 9 * code sits on — here, dark in both schemes, matching base16-ocean.dark. A site wanting
10 * base16-ocean.dark. Everything else is the theme's. */ 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. */
11 12
12:root { 13:root {
13 --orgo-code-bg: #2b303b; 14 --orgo-code-bg: #2b303b;
src/config.rs +9
@@ -419,6 +419,11 @@ pub struct Highlight {
419 /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the 419 /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the
420 /// generated `syntax.css` colours them with. 420 /// generated `syntax.css` colours them with.
421 pub theme: String, 421 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,
422} 427}
423 428
424impl Default for Highlight { 429impl Default for Highlight {
@@ -426,6 +431,7 @@ impl Default for Highlight {
426 Highlight { 431 Highlight {
427 syntaxes_dir: Utf8PathBuf::from("syntaxes"), 432 syntaxes_dir: Utf8PathBuf::from("syntaxes"),
428 theme: "InspiredGitHub".to_string(), 433 theme: "InspiredGitHub".to_string(),
434 theme_dark: String::new(),
429 } 435 }
430 } 436 }
431} 437}
@@ -617,6 +623,9 @@ expose_page_list = false
617# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, 623# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
618# base16-eighties.dark, base16-mocha.dark, base16-ocean.light. 624# base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
619theme = "InspiredGitHub" 625theme = "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 = ""
620# Extra .sublime-syntax files for languages neither syntect nor orgo bundles. 629# Extra .sublime-syntax files for languages neither syntect nor orgo bundles.
621syntaxes_dir = "syntaxes" 630syntaxes_dir = "syntaxes"
622 631
src/main.rs +1 −7
@@ -294,13 +294,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
294 let config = Config::load(dir)?; 294 let config = Config::load(dir)?;
295 config.validate()?; 295 config.validate()?;
296 let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?; 296 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(|| { 297 let css_text = render::syntax_stylesheet(&config.highlight)?;
298 anyhow::anyhow!(
299 "unknown highlight.theme {:?}. Available: {}",
300 config.highlight.theme,
301 render::available_themes().join(", ")
302 )
303 })?;
304 298
305 let site = SiteContext { 299 let site = SiteContext {
306 title: config.site.title.clone(), 300 title: config.site.title.clone(),
src/render.rs +38
@@ -119,6 +119,44 @@ pub fn available_themes() -> Vec<&'static str> {
119 theme_set().themes.keys().map(String::as_str).collect() 119 theme_set().themes.keys().map(String::as_str).collect()
120} 120}
121 121
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
122/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block 160/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
123/// whose language syntect does not know falls back to escaped `<pre><code>`. 161/// whose language syntect does not know falls back to escaped `<pre><code>`.
124pub struct SyntectHighlighter { 162pub struct SyntectHighlighter {
src/site.rs +1 −7
@@ -975,13 +975,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
975 975
976 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?; 976 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
977 check_page_templates(&templater, &preps)?; 977 check_page_templates(&templater, &preps)?;
978 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { 978 let syntax_css = render::syntax_stylesheet(&cfg.highlight)?;
979 anyhow::anyhow!(
980 "unknown highlight.theme {:?}. Available: {}",
981 cfg.highlight.theme,
982 render::available_themes().join(", ")
983 )
984 })?;
985 979
986 // The global hash classes (spec §4.1): a change in any invalidates the site. The 980 // The global hash classes (spec §4.1): a change in any invalidates the site. The
987 // config hash is combined with a site-structure hash covering the global chrome each 981 // 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() {
381 ); 381 );
382} 382}
383 383
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
384// --------------------------------------------------------------------------- 443// ---------------------------------------------------------------------------
385// Discovery 444// Discovery
386// --------------------------------------------------------------------------- 445// ---------------------------------------------------------------------------
themes/blog.css +4 −3
@@ -19,9 +19,10 @@
19 --orgo-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 19 --orgo-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
20 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; 20 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
21 /* These three colour code *blocks* only; inline code follows the page. A block keeps 21 /* 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 22 a light surface in both colour schemes, because syntax.css is coloured by
23 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow 23 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
24 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */ 24 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
25 three in a dark query of your own. */
25 --orgo-todo: #b02a37; 26 --orgo-todo: #b02a37;
26 --orgo-done: #2c7a4b; 27 --orgo-done: #2c7a4b;
27 --orgo-code-bg: #f5f2ec; 28 --orgo-code-bg: #f5f2ec;
themes/docs.css +4 −3
@@ -18,9 +18,10 @@
18 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 18 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; 19 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
20 /* These three colour code *blocks* only; inline code follows the page. A block keeps 20 /* 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 21 a light surface in both colour schemes, because syntax.css is coloured by
22 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow 22 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
23 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */ 23 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
24 three in a dark query of your own. */
24 --orgo-todo: #b02a37; 25 --orgo-todo: #b02a37;
25 --orgo-done: #2c7a4b; 26 --orgo-done: #2c7a4b;
26 --orgo-code-bg: #f6f8fa; 27 --orgo-code-bg: #f6f8fa;
themes/plain.css +4 −3
@@ -18,9 +18,10 @@
18 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 18 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; 19 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
20 /* These three colour code *blocks* only; inline code follows the page. A block keeps 20 /* 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 21 a light surface in both colour schemes, because syntax.css is coloured by
22 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow 22 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
23 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */ 23 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
24 three in a dark query of your own. */
24 --orgo-todo: #b02a37; 25 --orgo-todo: #b02a37;
25 --orgo-done: #2c7a4b; 26 --orgo-done: #2c7a4b;
26 --orgo-code-bg: #f6f8fa; 27 --orgo-code-bg: #f6f8fa;
themes/wiki.css +4 −3
@@ -21,9 +21,10 @@
21 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 21 --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
22 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; 22 --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
23 /* These three colour code *blocks* only; inline code follows the page. A block keeps 23 /* 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 24 a light surface in both colour schemes, because syntax.css is coloured by
25 syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow 25 `highlight.theme` and that default (InspiredGitHub) is light. To let blocks follow
26 prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */ 26 prefers-color-scheme, set `highlight.theme_dark` to a dark theme and override these
27 three in a dark query of your own. */
27 --orgo-todo: #b02a37; 28 --orgo-todo: #b02a37;
28 --orgo-done: #2c7a4b; 29 --orgo-done: #2c7a4b;
29 --orgo-code-bg: #f6f8fa; 30 --orgo-code-bg: #f6f8fa;