krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
a50bdfe77502f0e48848a1e1bf873862fa2ce1c0
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-14T21:17:22Z
README.md | 5 +- docs/guide/02-configuration.org | 50 +++++++ docs/guide/04-templates.org | 14 ++ docs/guide/10-deploying.org | 4 +- docs/guide/11-versioning.org | 9 +- docs/orgo.toml | 5 + docs/quickstart.org | 2 +- docs/style.css | 136 ++---------------- docs/templates/base.html | 5 +- src/config.rs | 32 ++++- src/lib.rs | 1 + src/main.rs | 14 +- src/site.rs | 26 ++++ src/template.rs | 16 +++ src/theme.rs | 43 ++++++ tests/config.rs | 110 +++++++++++++++ themes/blog.css | 298 ++++++++++++++++++++++++++++++++++++++++ themes/docs.css | 247 +++++++++++++++++++++++++++++++++ themes/plain.css | 205 +++++++++++++++++++++++++++ themes/wiki.css | 275 ++++++++++++++++++++++++++++++++++++ 20 files changed, 1362 insertions(+), 135 deletions(-) @@ -48,7 +48,9 @@ Open <http://127.0.0.1:3000>. Edit `my-site/index.org`, save, and the page reloa own — that is the loop you will spend your time in. `init` writes a starter post, a page layout you can edit, and a config file with every -setting explained in comments. It never overwrites a file you already have. +setting explained in comments. It never overwrites a file you already have. It also picks +one of the four built-in themes — `plain`, `blog`, `wiki`, `docs` — so the site is styled +from the first build; change `theme` in `orgo.toml`, or empty it and write your own CSS. ## Org-Mode, Anywhere @@ -78,6 +80,7 @@ Each of these is a few lines of config, and each has a page in the guide: | Tag pages, and an index of tags | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | | An RSS feed | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | | Numbered pages when a list gets long | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) | +| A built-in theme for a blog, a wiki or a doc site | [Configuration](https://krazywarez.github.io/orgo/guide/02-configuration.html) | | Your own design, in ordinary HTML templates | [Templates](https://krazywarez.github.io/orgo/guide/04-templates.html) | | Drafts that stay unpublished until you say so | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | | A table of contents on long posts | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) | @@ -18,6 +18,7 @@ title = "orgo site" base_url = "" description = "" language = "en" +theme = "" [nav] mode = "top-level" @@ -52,6 +53,55 @@ Plus any number of =[[collections]]= blocks, documented in [[file:03-collections | =base_url= | ="" | Absolute origin, *no trailing slash*. | | =description= | ="" | Available as ={{ site.description }}=. | | =language= | ="en"= | Goes in =<html lang>= in the built-in layout. | +| =theme= | ="" | A built-in stylesheet, written to the output as =theme.css=. | + +** theme + +Four themes are compiled into the binary. Name one and each build writes it to the +output root as =theme.css=, which the built-in layout and the templates =orgo init= +writes both link. + +| Theme | Shape | For | +|-------+-------+-----| +| ="plain"= | Narrow, system fonts, hairline rules. | Readable defaults to build your own CSS on. | +| ="blog"= | Serif prose, a centred masthead, styled post lists. | Dated writing. | +| ="wiki"= | Wide and dense, contents in the margin, TODO states as badges. | Notes, a reference site. | +| ="docs"= | Narrow, a contents card, quote blocks as notes, =#+LEDE:=. | A guide read in order. | + +All four follow =prefers-color-scheme=, so a site gets a dark mode without a toggle, a +setting or a line of JavaScript — and all four reflow from a 320px phone up, with tables +and code blocks scrolling inside their own box rather than widening the page. + +The default is empty: no stylesheet is written and no page links one, so the output is +unstyled HTML. That is deliberate — a site that already ships CSS of its own should not +find a second stylesheet competing with it, and upgrading orgo should never restyle a +site. An unknown name is an error listing the four. + +A theme styles the markup orgo already emits — headings, tags, TODO keywords, checkbox +lists, footnotes, tables — plus the chrome the built-in layout puts around it. There is +no theme-specific HTML, so switching or removing one touches no template. + +Every colour is a custom property on =:root=, named =--orgo-*=. To adjust rather than +replace a theme, ship a stylesheet of your own as an asset, link it after =theme.css=, +and redefine the handful you care about: + +#+BEGIN_SRC css +:root { + --orgo-accent: #7a1fa2; + --orgo-measure: 46rem; +} +#+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. + +When you outgrow a theme, drop =theme= from the config and write =templates/base.html= +against your own CSS. Nothing else changes. ** base_url @@ -150,6 +150,20 @@ depth: URL of the generated =syntax.css=, relative to this page. Link it or code blocks are unstyled. +** theme + +URL of =theme.css=, relative to this page — the [[file:02-configuration.org][built-in theme]] named by +=site.theme=. Empty when there is none, which is the default, so guard it and link it +*before* =stylesheet= or the theme's code colours would override the highlighter's: + +#+BEGIN_SRC html +{% if theme %}<link rel="stylesheet" href="{{ theme }}">{% endif %} +{% if stylesheet %}<link rel="stylesheet" href="{{ stylesheet }}">{% endif %} +#+END_SRC + +A layout that ignores it is a layout with its own CSS, which is the point at which you +have outgrown the setting. + ** pages, group, groups, paginator Present on generated pages; see [[file:03-collections.org][Collections]]. =pages= is also @@ -14,8 +14,8 @@ Two differences from the build you run while writing: bad build fails rather than shipping. - No =--drafts=, so pages marked =#+DRAFT:= stay out. -Everything in =_site= is the site: HTML, the generated =syntax.css=, and every asset -copied from the source. There is no runtime, no server requirement and no build step +Everything in =_site= is the site: HTML, the generated =syntax.css=, =theme.css= if the +config names a [[file:02-configuration.org][theme]], and every asset copied from the source. There is no runtime, no server requirement and no build step downstream. * Set base_url for production @@ -12,7 +12,7 @@ Changing any of this incompatibly requires a major version. | Stable | What that covers | |--------+------------------| | =orgo.toml= keys | Their names, types and meaning. | -| Template context | =page=, =site=, =nav=, =root=, =pages=, =group=, =groups=, =paginator=, =stylesheet=, and the =absolute=, =rfc822= and =truncate= filters. | +| Template context | =page=, =site=, =nav=, =root=, =pages=, =group=, =groups=, =paginator=, =stylesheet=, =theme=, and the =absolute=, =rfc822= and =truncate= filters. | | The CLI | Command names, flags and exit codes. | | URLs | How a source path becomes an output path, =#+SLUG:= included. | @@ -42,6 +42,13 @@ The class names the documentation names are the ones to write CSS against: =post-list=, =post-list-item=, =figure-number=, =table-number=, =section-number-N=, =footnote-ref=, =verbatim=, and the =on=/=off=/=trans= classes on checkbox items. +** What a built-in theme looks like + +The names — =plain=, =blog=, =wiki=, =docs= — and the fact that the chosen one is written +to =theme.css= are stable. Its CSS is not: a theme is a starting point that improves +between releases, and a site that cannot afford that should copy the stylesheet it likes +into its own assets and stop naming a theme. + ** The Rust API The crate is on crates.io so the binary can be installed with =cargo install=. The library @@ -10,6 +10,9 @@ language = "en" # Left empty so the docs build with relative URLs and open from the filesystem. Set it to # your real origin to enable canonical links and feeds. base_url = "" +# The built-in theme written for exactly this: a guide read in order, with a contents +# block that matters. The site's own CSS is six lines of overrides on top of it. +theme = "docs" [nav] # Explicit, because the header already links home: listing index.org here as well would @@ -25,6 +28,8 @@ dir = "templates" # A dark theme, with code blocks styled dark in both colour schemes. syntax.css is # generated from a single syntect theme and cannot respond to prefers-color-scheme, so # the page CSS matches the theme rather than leaving code unreadable in one of them. +# That match is style.css, which overrides the site theme's --orgo-code-* properties and +# nothing else — the documented way to adjust a built-in theme instead of replacing it. theme = "base16-ocean.dark" [html] @@ -19,7 +19,7 @@ already has content is safe and additive. #+BEGIN_EXAMPLE my-site/ - orgo.toml every setting, at its default, commented + orgo.toml every setting, commented — all at their defaults but `theme` index.org the home page blog/first-post.org a post, to show the collection working templates/ @@ -1,130 +1,16 @@ -/* Documentation site styling. +/* Documentation site styling: the `docs` built-in theme, adjusted rather than replaced. * * A plain asset, copied through the build untouched — which is also how any other CSS, - * image or font in a source directory reaches the output. */ + * 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. */ :root { - --ink: #1c1f24; - --muted: #5b6472; - --rule: #dfe3e8; - --accent: #0b5fa5; - --surface: #f6f8fa; - --measure: 42rem; -} - -@media (prefers-color-scheme: dark) { - :root { - --ink: #dee3ea; - --muted: #9aa4b2; - --rule: #2b3138; - --accent: #79b8ff; - --surface: #171a1f; - } - body { background: #0f1216; } -} - -* { box-sizing: border-box; } - -body { - margin: 0; - color: var(--ink); - font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -} - -header.site { - border-bottom: 1px solid var(--rule); - padding: 1rem 1.5rem; - display: flex; - flex-wrap: wrap; - gap: 1rem 1.5rem; - align-items: baseline; + --orgo-code-bg: #2b303b; + --orgo-code-fg: #c0c5ce; + --orgo-code-rule: #1f232b; } - -header.site .site-title { - font-weight: 700; - font-size: 1.05rem; - color: var(--ink); - text-decoration: none; -} - -header.site nav { display: flex; gap: 1.25rem; flex-wrap: wrap; } -header.site nav a { color: var(--muted); text-decoration: none; } -header.site nav a:hover { color: var(--accent); } - -main { - max-width: var(--measure); - margin: 0 auto; - padding: 2.5rem 1.5rem 5rem; -} - -h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; letter-spacing: -0.02em; } -h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; letter-spacing: -0.01em; } -h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; } - -p.page-date, p.lede { color: var(--muted); } -p.lede { font-size: 1.1rem; margin-top: 0; } - -a { color: var(--accent); } - -code { - font: 0.875em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - background: var(--surface); - padding: .1em .35em; - border-radius: 3px; -} - -/* Code blocks are dark in both colour schemes, matching the syntect theme named in - orgo.toml. One generated stylesheet cannot follow prefers-color-scheme, so the page - commits to the theme's palette instead of leaving highlighted code unreadable in one - of the two. */ -pre { - background: #2b303b; - color: #c0c5ce; - border: 1px solid #1f232b; - border-radius: 6px; - padding: .9rem 1rem; - overflow-x: auto; -} - -pre code { background: none; padding: 0; } - -blockquote { - margin: 1.5rem 0; - padding: .25rem 0 .25rem 1rem; - border-left: 3px solid var(--rule); - color: var(--muted); -} - -table { border-collapse: collapse; width: 100%; margin: 1.25rem 0; display: block; overflow-x: auto; } -th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--rule); } -th { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } - -hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; } - -/* Table of contents, emitted from page.toc */ -nav.toc { - background: var(--surface); - border: 1px solid var(--rule); - border-radius: 6px; - padding: .75rem 1.25rem 1rem; - margin: 1.5rem 0 2.5rem; -} -nav.toc h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; margin: .25rem 0 .5rem; color: var(--muted); } -nav.toc ul { margin: 0; padding-left: 1.1rem; } -nav.toc li { margin: .15rem 0; } - -ul.post-list { list-style: none; padding: 0; } -ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--rule); } -ul.post-list a { font-weight: 600; font-size: 1.05rem; } -p.excerpt { margin: .35rem 0 .2rem; color: var(--muted); } -span.reading-time { font-size: .85rem; color: var(--muted); } - -footer.site { - border-top: 1px solid var(--rule); - padding: 1.5rem; - color: var(--muted); - font-size: .9rem; - text-align: center; -} - -.tag { font-size: .75rem; background: var(--surface); border: 1px solid var(--rule); border-radius: 999px; padding: .1em .6em; color: var(--muted); } @@ -8,10 +8,13 @@ <link rel="canonical" href="{{ page.url | absolute }}"> {%- endif %} <meta name="description" content="{{ page.excerpt | truncate(150) }}"> -<link rel="stylesheet" href="{{ root }}style.css"> +{%- if theme %} +<link rel="stylesheet" href="{{ theme }}"> +{%- endif %} {%- if stylesheet %} <link rel="stylesheet" href="{{ stylesheet }}"> {%- endif %} +<link rel="stylesheet" href="{{ root }}style.css"> </head> <body> <header class="site"> @@ -331,6 +331,14 @@ pub struct Site { pub description: String, /// `<html lang="…">` in the default layout. pub language: String, + /// A built-in theme name — see [`crate::theme::THEMES`] — written to the output root + /// as `theme.css` and linked by the built-in layout and the starter templates. + /// + /// Empty by default, which emits no stylesheet and leaves the HTML unstyled. A theme + /// is a convenience for a site that has not grown its own CSS yet, and defaulting + /// one on would restyle every existing site on upgrade and fight the stylesheets + /// people already ship as assets. + pub theme: String, } impl Default for Site { @@ -340,6 +348,7 @@ impl Default for Site { base_url: String::new(), description: String::new(), language: "en".to_string(), + theme: String::new(), } } } @@ -539,6 +548,14 @@ impl Config { ); } } + if !self.site.theme.is_empty() && crate::theme::theme_css(&self.site.theme).is_none() { + anyhow::bail!( + "unknown site.theme {:?}. Available: {} — or leave it empty for no \ + stylesheet", + self.site.theme, + crate::theme::available_themes().join(", ") + ); + } if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { anyhow::bail!( "site.base_url must not end with a slash (got {:?}) — URLs are joined \ @@ -551,9 +568,12 @@ impl Config { } /// The starter config written by `orgo init`, and the documentation of record for -/// what is configurable. Every value shown is the default, so deleting any line is safe. -pub const STARTER_CONFIG: &str = r#"# orgo configuration. Every setting here is optional and shown at its default, -# so you can delete any line you do not need — or the whole file. +/// what is configurable. Every value shown is the default — except `site.theme`, which +/// picks a stylesheet so a new site looks like something on its first build — so +/// deleting any line is safe. +pub const STARTER_CONFIG: &str = r#"# orgo configuration. Every setting here is optional and shown at its default — apart +# from `theme`, noted below — so you can delete any line you do not need, or the whole +# file. [site] title = "orgo site" @@ -562,6 +582,12 @@ title = "orgo site" base_url = "" description = "" language = "en" +# A built-in stylesheet, written to the output as theme.css: "plain" (readable defaults +# to build your own CSS on), "blog" (serif prose, masthead, styled post lists), "wiki" +# (wide and dense, contents in the margin, TODO states shown) or "docs" (a guide read in +# order). The one line here that is not a default: the default is "", which emits no +# stylesheet at all. Your own base.html can ignore theme.css and link whatever it likes. +theme = "blog" [nav] # Which pages appear in the shared navigation: @@ -20,5 +20,6 @@ pub mod resolve; pub mod serve; pub mod site; pub mod template; +pub mod theme; pub mod util; pub mod watch; @@ -10,7 +10,7 @@ use orgo::parser::parse; use orgo::config::{self, Config}; use orgo::render::{self, render, Html, SyntectHighlighter}; use orgo::resolve::ResolvedDoc; -use orgo::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; +use orgo::site::{build_site, BuildOptions, SYNTAX_STYLESHEET, THEME_STYLESHEET}; use orgo::template::{PageContext, RenderContext, SiteContext, Templater}; #[derive(Parser)] @@ -325,6 +325,13 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { }; let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); ctx.body = &fragment; + // The page and its stylesheets are written side by side here, so the link is a bare + // filename rather than a path back to a site root that does not exist. + ctx.theme = if config.site.theme.is_empty() { + "" + } else { + THEME_STYLESHEET + }; // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site // build put the same page through the same layout. let name = config::page_template( @@ -339,5 +346,10 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { let css = output.with_file_name(SYNTAX_STYLESHEET); fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?; + + if let Some(theme_css) = orgo::theme::theme_css(&config.site.theme) { + let path = output.with_file_name(THEME_STYLESHEET); + fs::write(&path, theme_css).with_context(|| format!("writing stylesheet {path}"))?; + } Ok(()) } @@ -32,6 +32,7 @@ use crate::template::{ GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, Templater, }; +use crate::theme; use crate::util::{ document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled, output_path, output_url, @@ -882,8 +883,10 @@ fn render_page( // Relative to the *output* path, since `#+SLUG:` can move a page between depths. let root = relative_root(&p.output); let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); + let theme = theme_href(config, &root); let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root); ctx.body = &fragment; + ctx.theme = &theme; ctx.pages = pages; templater .render(&p.template, &ctx) @@ -893,6 +896,20 @@ fn render_page( /// Site-root-relative name of the generated syntax stylesheet. Every page links to it. pub const SYNTAX_STYLESHEET: &str = "syntax.css"; +/// Site-root-relative name of the built-in theme's stylesheet, written only when +/// `site.theme` names one. +pub const THEME_STYLESHEET: &str = "theme.css"; + +/// Where a page at `root` finds `theme.css` — empty when the site has no theme, which +/// is how a template knows to link nothing at all. +fn theme_href(config: &Config, root: &str) -> String { + if config.site.theme.is_empty() { + String::new() + } else { + format!("{root}{THEME_STYLESHEET}") + } +} + /// Site-root-relative name of the generated sitemap. pub const SITEMAP: &str = "sitemap.xml"; @@ -1132,7 +1149,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result let with_content = listing .include_content .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg)); + let theme = theme_href(&cfg, &root); let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root); + ctx.theme = &theme; ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries)); ctx.group = listing.group.as_ref(); ctx.groups = &listing.groups; @@ -1167,6 +1186,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; + // The built-in theme, on the same terms: compiled in, written every build, and named + // in the config hash so switching themes re-renders the pages that link it. + if let Some(css) = theme::theme_css(&cfg.site.theme) { + fs::write(out.join(THEME_STYLESHEET), css) + .with_context(|| format!("writing {THEME_STYLESHEET} under {out}"))?; + } + // A sitemap covers every page the build emits, authored and generated alike, so it is // written here rather than declared as a collection: a collection lists the pages it // was pointed at, and this one has to know about all of them including itself. @@ -90,6 +90,9 @@ const BASE_TEMPLATE: &str = r##"<!DOCTYPE html> {%- if page.description %} <meta name="description" content="{{ page.description }}"> {%- endif %} +{%- if theme %} +<link rel="stylesheet" href="{{ theme }}"> +{%- endif %} {%- if stylesheet %} <link rel="stylesheet" href="{{ stylesheet }}"> {%- endif %} @@ -267,6 +270,7 @@ impl Templater { body => ctx.body, nav => ctx.nav, stylesheet => ctx.stylesheet, + theme => ctx.theme, root => ctx.root, pages => ctx.pages, group => ctx.group, @@ -338,6 +342,9 @@ pub struct RenderContext<'a> { pub nav: &'a [NavItem], /// URL of the syntax stylesheet, relative to this page. pub stylesheet: &'a str, + /// Path to the built-in theme's `theme.css`, relative to this page — empty when + /// `site.theme` names no theme, which is the default. + pub theme: &'a str, /// `../`-prefix back to the site root from this page. pub root: &'a str, /// The pages this listing shows, or every page when `expose_page_list` is on. @@ -366,6 +373,9 @@ impl<'a> RenderContext<'a> { body: "", nav, stylesheet, + // Assigned after construction, like `body`: most callers have no theme, and + // an empty one is exactly "link no theme stylesheet". + theme: "", root, pages: None, group: None, @@ -383,6 +393,9 @@ pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ page.title }} · {{ site.title }}</title> +{%- if theme %} +<link rel="stylesheet" href="{{ theme }}"> +{%- endif %} {%- if stylesheet %} <link rel="stylesheet" href="{{ stylesheet }}"> {%- endif %} @@ -418,6 +431,9 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ page.title }} · {{ site.title }}</title> +{%- if theme %} +<link rel="stylesheet" href="{{ theme }}"> +{%- endif %} {%- if stylesheet %} <link rel="stylesheet" href="{{ stylesheet }}"> {%- endif %} new file mode 100644 @@ -0,0 +1,43 @@ +//! Built-in site themes: whole stylesheets compiled into the binary. +//! +//! A theme is one CSS file and nothing else. It styles the markup the RENDER stage +//! emits — org's headings, tags, TODO keywords, checkbox lists, footnotes — plus the +//! chrome the built-in layout and the starter templates put around it. There is no +//! theme-specific HTML, so a theme can be switched, or removed, without touching a +//! template. +//! +//! Compiled in for the same reason the syntax definitions are: `cargo install orgo` +//! gives you one binary, and a site that needs a stylesheet fetched from somewhere else +//! before it looks like anything is not that. The chosen theme is written to the output +//! root as `theme.css` on every build, the way [`crate::render::syntax_css`] writes +//! `syntax.css`. +//! +//! Nothing here is a wrapper you have to work through: `site.theme` empty emits no +//! stylesheet at all, and a `base.html` of your own can ignore `theme.css` and link +//! whatever it likes. + +/// Every built-in theme, as `(name, stylesheet)`, in the order they are offered. +/// +/// - `plain` — readable defaults with no design opinion, to build your own CSS on. +/// - `blog` — dated writing: serif prose, a masthead, styled listing pages. +/// - `wiki` — a dense reference site: wide, sidebar contents, tables and TODO states. +/// - `docs` — a guide read in order: prominent contents, code-forward, `#+LEDE:`. +pub const THEMES: &[(&str, &str)] = &[ + ("plain", include_str!("../themes/plain.css")), + ("blog", include_str!("../themes/blog.css")), + ("wiki", include_str!("../themes/wiki.css")), + ("docs", include_str!("../themes/docs.css")), +]; + +/// The stylesheet for a built-in theme, or `None` if no theme goes by that name. +pub fn theme_css(name: &str) -> Option<&'static str> { + THEMES + .iter() + .find(|(theme, _)| *theme == name) + .map(|(_, css)| *css) +} + +/// Every theme name [`theme_css`] accepts, for error messages and documentation. +pub fn available_themes() -> Vec<&'static str> { + THEMES.iter().map(|(name, _)| *name).collect() +} @@ -2499,3 +2499,113 @@ fn well_known_is_published_but_other_dot_entries_are_not() { assert!(!out.join(".git/config").exists(), ".git stays out"); assert!(!out.join(".env").exists(), ".env stays out"); } + +// --------------------------------------------------------------------------- +// Built-in themes +// --------------------------------------------------------------------------- + +/// A theme is one compiled-in stylesheet: named in the config, written to the output +/// root, linked from every page at whatever depth that page sits. +#[test] +fn a_built_in_theme_is_written_once_and_linked_from_every_depth() { + let root = tmpdir("theme-site"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"wiki\"\n").unwrap(); + let out = root.join("out"); + build(&src, &out); + + let css = std::fs::read_to_string(out.join("theme.css")).expect("theme.css is written"); + assert_eq!( + css, + orgo::theme::theme_css("wiki").unwrap(), + "verbatim, not a rebuilt approximation of it" + ); + + assert!( + page(&out, "index.html").contains("<link rel=\"stylesheet\" href=\"theme.css\">"), + "a root page links it directly" + ); + assert!( + page(&out, "blog/post.html").contains("<link rel=\"stylesheet\" href=\"../theme.css\">"), + "a nested page reaches back up to it" + ); +} + +/// The theme has to come before `syntax.css`, or a theme's `pre code` colour would +/// override the highlighter's and code blocks would render in one flat colour. +#[test] +fn the_theme_is_linked_ahead_of_the_syntax_stylesheet() { + let root = tmpdir("theme-order"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"docs\"\n").unwrap(); + let out = root.join("out"); + build(&src, &out); + + let home = page(&out, "index.html"); + let theme = home.find("theme.css").expect("theme link"); + let syntax = home.find("syntax.css").expect("syntax link"); + assert!(theme < syntax, "theme first, highlighting on top of it: {home}"); +} + +/// No theme is the default. An existing site upgrading must not find itself restyled, +/// and a site with a stylesheet of its own must not have a second one competing with it. +#[test] +fn no_theme_is_the_default_and_emits_no_stylesheet() { + let root = tmpdir("theme-none"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + let out = root.join("out"); + build(&src, &out); + + assert!(!out.join("theme.css").exists(), "nothing to write"); + assert!( + !page(&out, "index.html").contains("theme.css"), + "and nothing to link" + ); +} + +/// A misspelled theme name would otherwise emit an unstyled site with no complaint, +/// which looks exactly like the theme setting doing nothing. +#[test] +fn an_unknown_site_theme_is_rejected_with_the_available_ones() { + let root = tmpdir("theme-unknown"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"blogg\"\n").unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("unknown theme must fail"); + let message = format!("{err:#}"); + assert!(message.contains("blogg"), "names the bad theme: {message}"); + for name in orgo::theme::available_themes() { + assert!(message.contains(name), "lists {name}: {message}"); + } +} + +/// Switching themes changes every page's `<head>`, so every page has to be re-rendered. +/// The theme name lives in the config hash, which is what makes that happen. +#[test] +fn switching_the_theme_re_renders_the_pages_that_link_it() { + let root = tmpdir("theme-switch"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"plain\"\n").unwrap(); + let out = root.join("out"); + build(&src, &out); + + std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"blog\"\n").unwrap(); + let report = build(&src, &out); + assert!(report.skipped.is_empty(), "no page may keep the old head"); + assert_eq!( + std::fs::read_to_string(out.join("theme.css")).unwrap(), + orgo::theme::theme_css("blog").unwrap(), + "and the stylesheet on disk is the new one" + ); +} new file mode 100644 @@ -0,0 +1,298 @@ +/* orgo built-in theme: blog + * + * A personal site of dated writing. Serif prose at a narrow measure, a masthead rather + * than a toolbar, and listing pages — the blog index, a tag page, a feed's HTML + * counterpart — treated as first-class rather than as an afterthought. + * + * Every colour is a custom property on :root, so a stylesheet of your own loaded after + * this one can retheme the site by redefining a handful of values. */ + +:root { + --orgo-ink: #23201d; + --orgo-muted: #6b645d; + --orgo-rule: #e5e0d8; + --orgo-accent: #9a3412; + --orgo-surface: #faf7f2; + --orgo-bg: #fdfcfa; + --orgo-measure: 36rem; + --orgo-font: Iowan Old Style, Charter, Palatino, Georgia, "Times New Roman", serif; + --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. */ + --orgo-todo: #b02a37; + --orgo-done: #2c7a4b; + --orgo-code-bg: #f5f2ec; + --orgo-code-fg: #323232; + --orgo-code-rule: #e5e0d8; +} + +@media (prefers-color-scheme: dark) { + :root { + --orgo-ink: #e6e1da; + --orgo-muted: #a49c93; + --orgo-rule: #322e2a; + --orgo-accent: #f0a066; + --orgo-surface: #1b1917; + --orgo-todo: #f0909a; + --orgo-done: #74c795; + --orgo-bg: #141312; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--orgo-bg); + color: var(--orgo-ink); + font: 18px/1.7 var(--orgo-font); + -webkit-font-smoothing: antialiased; + /* A bare URL or a long identifier must wrap, not widen the page on a phone. */ + overflow-wrap: break-word; +} + +/* Masthead ----------------------------------------------------------------- */ + +body > header { + max-width: var(--orgo-measure); + margin: 0 auto; + padding: 3rem 1.5rem 1.5rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: .75rem; +} + +.site-title { + font-size: 1.5rem; + font-weight: 400; + letter-spacing: .02em; + color: var(--orgo-ink); + text-decoration: none; +} + +body > header nav { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; + font: .8rem/1 var(--orgo-ui); + text-transform: uppercase; + letter-spacing: .1em; +} +body > header nav a { color: var(--orgo-muted); text-decoration: none; } +body > header nav a:hover { color: var(--orgo-accent); } + +/* Page body ---------------------------------------------------------------- */ + +main { + max-width: var(--orgo-measure); + margin: 0 auto; + padding: 2rem 1.5rem 5rem; +} + +h1 { + font-size: 2.1rem; + font-weight: 400; + line-height: 1.25; + letter-spacing: -0.01em; + margin: 0 0 .5rem; +} +h2 { font-size: 1.4rem; font-weight: 600; margin: 2.5rem 0 .75rem; } +h3 { font-size: 1.15rem; font-weight: 600; margin: 2rem 0 .5rem; } +h4, h5, h6 { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 .5rem; } + +a { color: var(--orgo-accent); text-underline-offset: .15em; } + +/* The byline the built-in layout prints under the title. */ +p.page-date { + font: .8rem/1 var(--orgo-ui); + text-transform: uppercase; + letter-spacing: .1em; + color: var(--orgo-muted); + margin: 0 0 2.5rem; +} + +img, video { max-width: 100%; height: auto; } + +figure { margin: 2rem 0; } +figcaption { + font: .85rem/1.5 var(--orgo-ui); + color: var(--orgo-muted); + margin-top: .5rem; + text-align: center; +} +.figure-number, .table-number { font-weight: 600; } + +blockquote { + margin: 2rem 0; + padding: 0 1.5rem; + border-left: 2px solid var(--orgo-accent); + font-style: italic; + color: var(--orgo-muted); +} + +hr { + border: 0; + margin: 3rem auto; + width: 6rem; + border-top: 1px solid var(--orgo-rule); +} + +.center { text-align: center; } +.verse { font-family: var(--orgo-mono); font-size: .95rem; white-space: pre-wrap; } + +dt { font-weight: 600; margin-top: .75rem; } +dd { margin: 0 0 0 1.5rem; } + +/* Code --------------------------------------------------------------------- */ + +/* Inline code is prose furniture, so it follows the page rather than the code blocks. */ +code { + font: .85em/1.5 var(--orgo-mono); + background: var(--orgo-surface); + color: inherit; + padding: .1em .35em; + border-radius: 3px; +} + +pre { + background: var(--orgo-code-bg); + color: var(--orgo-code-fg); + border: 1px solid var(--orgo-code-rule); + border-radius: 4px; + padding: 1rem 1.1rem; + font-size: .9rem; + line-height: 1.55; + overflow-x: auto; +} + +pre code { background: none; color: inherit; padding: 0; } + +/* Tables ------------------------------------------------------------------- */ + +table { + border-collapse: collapse; + margin: 2rem 0; + font: .95rem/1.5 var(--orgo-ui); + display: block; + overflow-x: auto; +} +caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .5rem; } +th, td { text-align: left; padding: .5rem .85rem; border-bottom: 1px solid var(--orgo-rule); } +th { + font-size: .75rem; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--orgo-muted); +} + +/* Org-specific markup ------------------------------------------------------ */ + +.tag { + font: .7rem/1.6 var(--orgo-ui); + text-transform: uppercase; + letter-spacing: .08em; + color: var(--orgo-muted); + background: var(--orgo-surface); + border: 1px solid var(--orgo-rule); + border-radius: 999px; + padding: .1em .7em; + vertical-align: middle; +} + +.todo, .done { font: .7em/1 var(--orgo-mono); letter-spacing: .05em; } +.todo { color: var(--orgo-todo); } +.done { color: var(--orgo-done); } +.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; } + +time.timestamp { font: .9em/1 var(--orgo-ui); color: var(--orgo-muted); } + +li.on, li.trans { color: var(--orgo-muted); } +li.on { text-decoration: line-through; } + +.footnotes { + margin-top: 4rem; + font-size: .9rem; + color: var(--orgo-muted); +} +.footnote-ref a { text-decoration: none; } + +/* Table of contents, from page.toc ----------------------------------------- */ + +nav.toc { + border-top: 1px solid var(--orgo-rule); + border-bottom: 1px solid var(--orgo-rule); + padding: 1rem 0; + margin: 2rem 0 3rem; + font: .95rem/1.6 var(--orgo-ui); +} +nav.toc h2 { + font-size: .7rem; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--orgo-muted); + margin: 0 0 .5rem; +} +nav.toc ul { margin: 0; padding-left: 1.1rem; } +nav.toc li { margin: .15rem 0; } + +/* Listing pages ------------------------------------------------------------ */ + +ul.post-list { list-style: none; padding: 0; margin: 2rem 0 0; } +ul.post-list > li { padding: 1.75rem 0; border-bottom: 1px solid var(--orgo-rule); } +ul.post-list > li:first-child { padding-top: 0; } +ul.post-list time { + display: block; + font: .75rem/1 var(--orgo-ui); + text-transform: uppercase; + letter-spacing: .1em; + color: var(--orgo-muted); + margin-bottom: .4rem; +} +ul.post-list a { + font-size: 1.3rem; + line-height: 1.3; + color: var(--orgo-ink); + text-decoration: none; +} +ul.post-list a:hover { color: var(--orgo-accent); } +p.excerpt { margin: .5rem 0 .35rem; color: var(--orgo-muted); font-size: .95rem; } +span.reading-time { font: .75rem/1 var(--orgo-ui); letter-spacing: .06em; color: var(--orgo-muted); } + +ul.tag-list { + list-style: none; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: .75rem 1.25rem; + font: .95rem/1.6 var(--orgo-ui); +} + +nav.pagination { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-top: 3rem; + font: .85rem/1 var(--orgo-ui); + text-transform: uppercase; + letter-spacing: .08em; + color: var(--orgo-muted); +} + +/* Footer ------------------------------------------------------------------- */ + +body > footer { + max-width: var(--orgo-measure); + margin: 0 auto; + border-top: 1px solid var(--orgo-rule); + padding: 1.5rem; + font: .85rem/1.6 var(--orgo-ui); + color: var(--orgo-muted); + text-align: center; +} new file mode 100644 @@ -0,0 +1,247 @@ +/* orgo built-in theme: docs + * + * A documentation site: a guide read in order, with a contents block that matters, code + * blocks that carry as much of the meaning as the prose, and a `#+LEDE:` line under the + * title. Cooler and more technical than `blog`, narrower and more designed than `wiki`. + * + * Every colour is a custom property on :root, so a stylesheet of your own loaded after + * this one can retheme the site by redefining a handful of values. */ + +:root { + --orgo-ink: #1c1f24; + --orgo-muted: #5b6472; + --orgo-rule: #dfe3e8; + --orgo-accent: #0b5fa5; + --orgo-surface: #f6f8fa; + --orgo-bg: #ffffff; + --orgo-measure: 42rem; + --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. */ + --orgo-todo: #b02a37; + --orgo-done: #2c7a4b; + --orgo-code-bg: #f6f8fa; + --orgo-code-fg: #323232; + --orgo-code-rule: #e3e7ec; +} + +@media (prefers-color-scheme: dark) { + :root { + --orgo-ink: #dee3ea; + --orgo-muted: #9aa4b2; + --orgo-rule: #2b3138; + --orgo-accent: #79b8ff; + --orgo-surface: #171a1f; + --orgo-todo: #f0909a; + --orgo-done: #74c795; + --orgo-bg: #0f1216; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--orgo-bg); + color: var(--orgo-ink); + font: 16px/1.65 var(--orgo-font); + /* A bare URL or a long identifier must wrap, not widen the page on a phone. */ + overflow-wrap: break-word; +} + +/* Header ------------------------------------------------------------------- */ + +body > header { + position: sticky; + top: 0; + z-index: 1; + background: var(--orgo-bg); + border-bottom: 1px solid var(--orgo-rule); + padding: 1rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: .5rem 1.5rem; + align-items: baseline; +} + +.site-title { + font-weight: 700; + font-size: 1.05rem; + color: var(--orgo-ink); + text-decoration: none; +} + +body > header nav { display: flex; flex-wrap: wrap; gap: 1.25rem; } +body > header nav a { + color: var(--orgo-muted); + text-decoration: none; + padding-bottom: .15rem; + border-bottom: 2px solid transparent; +} +body > header nav a:hover { color: var(--orgo-ink); border-bottom-color: var(--orgo-accent); } + +/* Page body ---------------------------------------------------------------- */ + +main { + max-width: var(--orgo-measure); + margin: 0 auto; + padding: 2.5rem 1.5rem 5rem; +} + +h1 { font-size: 2rem; line-height: 1.2; letter-spacing: -0.02em; margin: 0 0 .5rem; } +h2 { font-size: 1.35rem; letter-spacing: -0.01em; margin: 2.75rem 0 .75rem; } +h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; } +h4, h5, h6 { font-size: 1rem; margin: 1.5rem 0 .5rem; } + +[class^="section-number-"] { color: var(--orgo-muted); font-weight: 400; } + +a { color: var(--orgo-accent); } + +/* `#+LEDE:` reaches the layout as page.keywords.lede; page.date as the byline. */ +p.lede { font-size: 1.1rem; color: var(--orgo-muted); margin-top: 0; } +p.page-date { color: var(--orgo-muted); font-size: .9rem; } + +img, video { max-width: 100%; height: auto; } + +figure { margin: 1.75rem 0; } +figcaption { color: var(--orgo-muted); font-size: .9rem; margin-top: .4rem; } +.figure-number, .table-number { font-weight: 600; } + +/* A quote block reads as a note or a caution in a documentation site. */ +blockquote { + margin: 1.5rem 0; + padding: .75rem 1rem; + background: var(--orgo-surface); + border-left: 3px solid var(--orgo-accent); + border-radius: 0 4px 4px 0; + color: var(--orgo-muted); +} +blockquote > :first-child { margin-top: 0; } +blockquote > :last-child { margin-bottom: 0; } + +hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 2.5rem 0; } + +.center { text-align: center; } +.verse { font-family: var(--orgo-mono); white-space: pre-wrap; } + +dt { font-weight: 600; margin-top: .75rem; font-family: var(--orgo-mono); font-size: .95rem; } +dd { margin: 0 0 0 1.5rem; } + +/* Code --------------------------------------------------------------------- */ + +/* Inline code is prose furniture, so it follows the page rather than the code blocks. */ +code { + font: .875em/1.5 var(--orgo-mono); + background: var(--orgo-surface); + color: inherit; + padding: .1em .35em; + border-radius: 3px; +} + +pre { + background: var(--orgo-code-bg); + color: var(--orgo-code-fg); + border: 1px solid var(--orgo-code-rule); + border-radius: 6px; + padding: .9rem 1.1rem; + font-size: .9rem; + line-height: 1.55; + overflow-x: auto; +} + +pre code { background: none; color: inherit; padding: 0; } + +/* Tables ------------------------------------------------------------------- */ + +table { + border-collapse: collapse; + width: 100%; + margin: 1.5rem 0; + display: block; + overflow-x: auto; +} +caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .4rem; } +th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--orgo-rule); } +th { + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--orgo-muted); +} + +/* Org-specific markup ------------------------------------------------------ */ + +.tag { + font: .72rem/1.6 var(--orgo-mono); + color: var(--orgo-muted); + background: var(--orgo-surface); + border: 1px solid var(--orgo-rule); + border-radius: 999px; + padding: .05em .6em; + vertical-align: middle; +} + +.todo, .done { font: .72rem/1.6 var(--orgo-mono); letter-spacing: .04em; } +.todo { color: var(--orgo-todo); } +.done { color: var(--orgo-done); } +.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; } + +time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; } + +li.on, li.trans { color: var(--orgo-muted); } +li.on { text-decoration: line-through; } + +.footnotes { margin-top: 3rem; font-size: .9rem; color: var(--orgo-muted); } +.footnote-ref a { text-decoration: none; } + +/* Table of contents: a card, because in a guide it is navigation ----------- */ + +nav.toc { + background: var(--orgo-surface); + border: 1px solid var(--orgo-rule); + border-radius: 6px; + padding: .75rem 1.25rem 1rem; + margin: 1.75rem 0 2.5rem; +} +nav.toc h2 { + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--orgo-muted); + margin: .25rem 0 .5rem; +} +nav.toc ul { margin: 0; padding-left: 1.1rem; } +nav.toc li { margin: .15rem 0; } + +/* Listing pages: a guide's contents page, a tag index ---------------------- */ + +ul.post-list { list-style: none; padding: 0; } +ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--orgo-rule); } +ul.post-list a { font-weight: 600; font-size: 1.05rem; } +ul.post-list time { color: var(--orgo-muted); font-size: .9rem; } +p.excerpt { margin: .35rem 0 .2rem; color: var(--orgo-muted); } +span.reading-time { font-size: .85rem; color: var(--orgo-muted); } + +ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .6rem 1.25rem; } + +nav.pagination { + display: flex; + gap: 1rem; + align-items: baseline; + margin-top: 2.5rem; + color: var(--orgo-muted); + font-size: .9rem; +} + +/* Footer ------------------------------------------------------------------- */ + +body > footer { + border-top: 1px solid var(--orgo-rule); + padding: 1.5rem; + color: var(--orgo-muted); + font-size: .9rem; + text-align: center; +} new file mode 100644 @@ -0,0 +1,205 @@ +/* orgo built-in theme: plain + * + * Readable defaults and nothing else. System fonts, one measure, hairline rules — the + * styling a page needs to be read comfortably, with no design opinion for a site to + * grow out of. Start here when you intend to write your own CSS eventually. + * + * Every colour is a custom property on :root, so a stylesheet of your own loaded after + * this one can retheme the site by redefining a handful of values. */ + +:root { + --orgo-ink: #1c1f24; + --orgo-muted: #5b6472; + --orgo-rule: #dfe3e8; + --orgo-accent: #0b5fa5; + --orgo-surface: #f6f8fa; + --orgo-bg: #ffffff; + --orgo-measure: 40rem; + --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. */ + --orgo-todo: #b02a37; + --orgo-done: #2c7a4b; + --orgo-code-bg: #f6f8fa; + --orgo-code-fg: #323232; + --orgo-code-rule: #e3e7ec; +} + +@media (prefers-color-scheme: dark) { + :root { + --orgo-ink: #dee3ea; + --orgo-muted: #9aa4b2; + --orgo-rule: #2b3138; + --orgo-accent: #79b8ff; + --orgo-surface: #171a1f; + --orgo-todo: #f0909a; + --orgo-done: #74c795; + --orgo-bg: #0f1216; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--orgo-bg); + color: var(--orgo-ink); + font: 16px/1.65 var(--orgo-font); + /* A bare URL or a long identifier must wrap, not widen the page on a phone. */ + overflow-wrap: break-word; +} + +/* Header and navigation ---------------------------------------------------- */ + +body > header { + border-bottom: 1px solid var(--orgo-rule); + padding: 1rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: .5rem 1.5rem; + align-items: baseline; +} + +.site-title { font-weight: 700; color: var(--orgo-ink); text-decoration: none; } + +body > header nav { display: flex; flex-wrap: wrap; gap: 1.25rem; } +body > header nav a { color: var(--orgo-muted); text-decoration: none; } +body > header nav a:hover { color: var(--orgo-accent); text-decoration: underline; } + +/* Page body ---------------------------------------------------------------- */ + +main { + max-width: var(--orgo-measure); + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +h1 { font-size: 1.9rem; line-height: 1.2; margin: 0 0 1rem; } +h2 { font-size: 1.35rem; margin: 2.25rem 0 .75rem; } +h3 { font-size: 1.1rem; margin: 1.75rem 0 .5rem; } +h4, h5, h6 { font-size: 1rem; margin: 1.5rem 0 .5rem; } + +a { color: var(--orgo-accent); } + +p.page-date { color: var(--orgo-muted); margin-top: -.5rem; } + +img, video { max-width: 100%; height: auto; } + +figure { margin: 1.5rem 0; } +figcaption { color: var(--orgo-muted); font-size: .9rem; margin-top: .4rem; } +.figure-number, .table-number { font-weight: 600; } + +blockquote { + margin: 1.5rem 0; + padding-left: 1rem; + border-left: 3px solid var(--orgo-rule); + color: var(--orgo-muted); +} + +hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 2rem 0; } + +.center { text-align: center; } +.verse { font-family: var(--orgo-mono); white-space: pre-wrap; } + +dt { font-weight: 600; margin-top: .75rem; } +dd { margin: 0 0 0 1.5rem; } + +/* Code --------------------------------------------------------------------- */ + +/* Inline code is prose furniture, so it follows the page rather than the code blocks. */ +code { + font: .875em/1.5 var(--orgo-mono); + background: var(--orgo-surface); + color: inherit; + padding: .1em .35em; + border-radius: 3px; +} + +pre { + background: var(--orgo-code-bg); + color: var(--orgo-code-fg); + border: 1px solid var(--orgo-code-rule); + border-radius: 4px; + padding: .9rem 1rem; + overflow-x: auto; +} + +pre code { background: none; color: inherit; padding: 0; } + +/* Tables ------------------------------------------------------------------- */ + +table { border-collapse: collapse; margin: 1.25rem 0; display: block; overflow-x: auto; } +caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .4rem; } +th, td { text-align: left; padding: .45rem .75rem; border-bottom: 1px solid var(--orgo-rule); } +th { font-weight: 600; } + +/* Org-specific markup ------------------------------------------------------ */ + +.tag { + font-size: .75rem; + font-family: var(--orgo-mono); + color: var(--orgo-muted); + border: 1px solid var(--orgo-rule); + border-radius: 999px; + padding: .05em .55em; + vertical-align: middle; +} + +.todo, .done { font-size: .75em; font-family: var(--orgo-mono); letter-spacing: .04em; } +.todo { color: var(--orgo-todo); } +.done { color: var(--orgo-done); } +.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; } + +time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; } + +li.on, li.trans { color: var(--orgo-muted); } +li.on { text-decoration: line-through; } + +.footnotes { margin-top: 3rem; font-size: .9rem; color: var(--orgo-muted); } +.footnote-ref a { text-decoration: none; } + +/* Table of contents, from page.toc ----------------------------------------- */ + +nav.toc { margin: 1.5rem 0 2.5rem; } +nav.toc h2 { + font-size: .8rem; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--orgo-muted); + margin: 0 0 .35rem; +} +nav.toc ul { margin: 0; padding-left: 1.1rem; } +nav.toc li { margin: .1rem 0; } + +/* Listing pages: collections, tag indexes, pagination ---------------------- */ + +ul.post-list { list-style: none; padding: 0; } +ul.post-list > li { padding: .9rem 0; border-bottom: 1px solid var(--orgo-rule); } +ul.post-list a { font-weight: 600; } +ul.post-list time { color: var(--orgo-muted); font-size: .9rem; } +p.excerpt { margin: .3rem 0 .2rem; color: var(--orgo-muted); } +span.reading-time { font-size: .85rem; color: var(--orgo-muted); } + +ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem 1rem; } + +nav.pagination { + display: flex; + gap: 1rem; + align-items: baseline; + margin-top: 2rem; + color: var(--orgo-muted); + font-size: .9rem; +} + +/* Footer ------------------------------------------------------------------- */ + +body > footer { + border-top: 1px solid var(--orgo-rule); + padding: 1.5rem; + color: var(--orgo-muted); + font-size: .9rem; + text-align: center; +} new file mode 100644 @@ -0,0 +1,275 @@ +/* orgo built-in theme: wiki + * + * A dense reference site: notes, a personal knowledge base, a manual. Wide and + * information-first rather than roomy — a table of contents parked in the margin on a + * large screen, compact headings with visible structure, tables and task lists styled + * because a wiki is mostly made of them, and org's TODO keywords shown as badges since + * in a notes site they are content rather than decoration. + * + * Every colour is a custom property on :root, so a stylesheet of your own loaded after + * this one can retheme the site by redefining a handful of values. */ + +:root { + --orgo-ink: #1f2328; + --orgo-muted: #656d76; + --orgo-rule: #d8dee4; + --orgo-accent: #0969da; + --orgo-surface: #f6f8fa; + --orgo-bg: #ffffff; + --orgo-measure: 46rem; + --orgo-wide: 74rem; + --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. */ + --orgo-todo: #b02a37; + --orgo-done: #2c7a4b; + --orgo-code-bg: #f6f8fa; + --orgo-code-fg: #323232; + --orgo-code-rule: #d8dee4; +} + +@media (prefers-color-scheme: dark) { + :root { + --orgo-ink: #e6edf3; + --orgo-muted: #8d96a0; + --orgo-rule: #30363d; + --orgo-accent: #6cb6ff; + --orgo-surface: #161b22; + --orgo-bg: #0d1117; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--orgo-bg); + color: var(--orgo-ink); + font: 15px/1.6 var(--orgo-font); + /* A bare URL or a long identifier must wrap, not widen the page on a phone. */ + overflow-wrap: break-word; +} + +/* Header ------------------------------------------------------------------- */ + +body > header { + background: var(--orgo-surface); + border-bottom: 1px solid var(--orgo-rule); + padding: .7rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: .5rem 1.5rem; + align-items: baseline; +} + +.site-title { + font-weight: 700; + font-size: 1rem; + color: var(--orgo-ink); + text-decoration: none; +} + +body > header nav { display: flex; flex-wrap: wrap; gap: 1.1rem; font-size: .9rem; } +body > header nav a { color: var(--orgo-muted); text-decoration: none; } +body > header nav a:hover { color: var(--orgo-accent); text-decoration: underline; } + +/* Page body ---------------------------------------------------------------- */ + +main { + max-width: var(--orgo-measure); + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +h1 { + font-size: 1.8rem; + line-height: 1.25; + margin: 0 0 1rem; + padding-bottom: .4rem; + border-bottom: 1px solid var(--orgo-rule); +} +h2 { + font-size: 1.3rem; + margin: 2rem 0 .6rem; + padding-bottom: .25rem; + border-bottom: 1px solid var(--orgo-rule); +} +h3 { font-size: 1.05rem; margin: 1.5rem 0 .4rem; } +h4, h5, h6 { font-size: .95rem; margin: 1.25rem 0 .4rem; } + +/* Section numbers, when html.section_numbers is on. */ +[class^="section-number-"] { color: var(--orgo-muted); font-weight: 400; } + +a { color: var(--orgo-accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +p.page-date { color: var(--orgo-muted); font-size: .9rem; margin-top: -.5rem; } + +p, ul, ol { margin: .6rem 0; } +li { margin: .15rem 0; } + +img, video { max-width: 100%; height: auto; } + +figure { margin: 1.25rem 0; } +figcaption { color: var(--orgo-muted); font-size: .85rem; margin-top: .35rem; } +.figure-number, .table-number { font-weight: 600; } + +blockquote { + margin: 1rem 0; + padding: .1rem 0 .1rem 1rem; + border-left: 3px solid var(--orgo-rule); + color: var(--orgo-muted); +} + +hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 1.75rem 0; } + +.center { text-align: center; } +.verse { font-family: var(--orgo-mono); white-space: pre-wrap; } + +dt { font-weight: 600; margin-top: .5rem; } +dd { margin: 0 0 0 1.5rem; color: var(--orgo-muted); } + +/* Code --------------------------------------------------------------------- */ + +/* Inline code is prose furniture, so it follows the page rather than the code blocks. */ +code { + font: .875em/1.5 var(--orgo-mono); + background: var(--orgo-surface); + color: inherit; + border: 1px solid var(--orgo-rule); + padding: .05em .3em; + border-radius: 3px; +} + +pre { + background: var(--orgo-code-bg); + color: var(--orgo-code-fg); + border: 1px solid var(--orgo-code-rule); + border-radius: 4px; + padding: .8rem 1rem; + font-size: .875rem; + overflow-x: auto; +} + +pre code { background: none; color: inherit; border: 0; padding: 0; } + +/* Tables — a wiki lives in them, so they get zebra rows and a sticky-looking head. */ + +table { + border-collapse: collapse; + margin: 1rem 0; + font-size: .9rem; + display: block; + overflow-x: auto; +} +caption { text-align: left; color: var(--orgo-muted); font-size: .85rem; padding-bottom: .35rem; } +th, td { text-align: left; padding: .35rem .7rem; border: 1px solid var(--orgo-rule); } +th { background: var(--orgo-surface); font-weight: 600; } +tbody tr:nth-child(even) td { background: var(--orgo-surface); } + +/* Org-specific markup ------------------------------------------------------ */ + +.tag { + font: .7rem/1.6 var(--orgo-mono); + color: var(--orgo-muted); + background: var(--orgo-surface); + border: 1px solid var(--orgo-rule); + border-radius: 3px; + padding: .05em .45em; + vertical-align: middle; +} + +/* TODO state is content in a notes site, not decoration. */ +.todo, .done { + font: .7rem/1.6 var(--orgo-mono); + letter-spacing: .04em; + border-radius: 3px; + padding: .05em .45em; + vertical-align: middle; + color: #ffffff; +} +.todo { background: var(--orgo-todo); } +.done { background: var(--orgo-done); } +.priority { color: var(--orgo-todo); font-family: var(--orgo-mono); font-size: .8em; } + +time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; } +time.inactive { opacity: .75; } + +/* Checkbox lists: the three org states, including `[-]`, which HTML has no input for. + The `[X]` marker is the bullet, so the list's own is dropped. */ +li.on, li.off, li.trans { list-style: none; } +li.on { color: var(--orgo-muted); text-decoration: line-through; } +li.trans { color: var(--orgo-muted); } + +.footnotes { margin-top: 2.5rem; font-size: .9rem; color: var(--orgo-muted); } +.footnote-ref a { text-decoration: none; } + +/* Table of contents: in the margin once there is a margin to put it in ------ */ + +nav.toc { + background: var(--orgo-surface); + border: 1px solid var(--orgo-rule); + border-radius: 4px; + padding: .6rem 1rem .8rem; + margin: 0 0 1.5rem; + font-size: .875rem; +} +nav.toc h2 { + font-size: .7rem; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--orgo-muted); + margin: 0 0 .35rem; + padding: 0; + border: 0; +} +nav.toc ul { margin: 0; padding-left: 1rem; } +nav.toc li { margin: .1rem 0; } + +@media (min-width: 62rem) { + main { max-width: var(--orgo-wide); } + /* Floated rather than gridded: the layout puts the contents block wherever it likes + in the document, and a float lands in the margin from any of those positions. */ + nav.toc { + float: right; + width: 16rem; + margin: 0 0 1.5rem 2.5rem; + } + /* Prose keeps its measure even though the page is wide; the things that actually + want the extra room — code, tables, figures — are let out of it. */ + main > * { max-width: var(--orgo-measure); } + main > pre, main > table, main > figure, main > nav.toc { max-width: none; } +} + +/* Listing pages ------------------------------------------------------------ */ + +ul.post-list { list-style: none; padding: 0; } +ul.post-list > li { padding: .6rem 0; border-bottom: 1px solid var(--orgo-rule); } +ul.post-list a { font-weight: 600; } +ul.post-list time { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .85rem; } +p.excerpt { margin: .25rem 0; color: var(--orgo-muted); font-size: .9rem; } +span.reading-time { font-size: .8rem; color: var(--orgo-muted); } + +ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; } + +nav.pagination { + display: flex; + gap: 1rem; + align-items: baseline; + margin-top: 1.5rem; + font-size: .9rem; + color: var(--orgo-muted); +} + +/* Footer ------------------------------------------------------------------- */ + +body > footer { + border-top: 1px solid var(--orgo-rule); + padding: 1.25rem 1.5rem; + color: var(--orgo-muted); + font-size: .85rem; + text-align: center; +}