krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
34e27a50613a3f76cb1a9ad7cb096505f45f640a
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T04:51:13Z
Cargo.lock | 57 ++- Cargo.toml | 3 +- README.md | 101 ++++- src/config.rs | 239 +++++++++++ src/incremental.rs | 24 +- src/lib.rs | 1 + src/main.rs | 108 ++++- src/render.rs | 64 ++- src/site.rs | 306 +++++++++++--- src/template.rs | 186 +++++++-- tests/config.rs | 445 +++++++++++++++++++++ tests/constructs.rs | 4 +- tests/incremental.rs | 4 +- tests/oracle.el | 7 +- tests/snapshots/constructs__blocks_html.snap | 12 +- tests/snapshots/constructs__headings_html.snap | 10 +- tests/snapshots/constructs__images_html.snap | 10 +- tests/snapshots/constructs__lists_html.snap | 10 +- tests/snapshots/constructs__out_of_scope_html.snap | 14 +- tests/snapshots/constructs__timestamps_html.snap | 8 +- tests/snapshots/oracle__oracle_blocks.snap | 24 +- tests/snapshots/oracle__oracle_core.snap | 8 +- tests/snapshots/oracle__oracle_elements.snap | 12 +- tests/snapshots/oracle__oracle_headings.snap | 20 +- tests/snapshots/oracle__oracle_images.snap | 20 +- tests/snapshots/oracle__oracle_lists.snap | 20 +- tests/snapshots/oracle__oracle_minimal.snap | 12 +- tests/snapshots/oracle__oracle_timestamps.snap | 16 +- tests/snapshots/pipeline__core_html.snap | 4 +- tests/snapshots/pipeline__minimal_html.snap | 6 +- tests/snapshots/site__site_guide_html.snap | 11 +- tests/snapshots/site__site_index_html.snap | 9 +- 32 files changed, 1529 insertions(+), 246 deletions(-) @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "blake3", @@ -583,6 +583,7 @@ dependencies = [ "serde_json", "syntect", "thiserror", + "toml", "walkdir", ] @@ -747,6 +748,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -883,6 +893,45 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1027,6 +1076,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "yaml-rust" version = "0.4.5" @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.5.0" +version = "0.6.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -31,6 +31,7 @@ clap = { version = "4", features = ["derive"] } anyhow = "1" thiserror = "2" rayon = "1.12.0" +toml = "1.1.4" [dev-dependencies] insta = { version = "1", features = ["json"] } @@ -13,6 +13,86 @@ hashing**, treated as a first-class architectural concern from day one. The disc it imposes on the data model — pure, hashable, dependency-tracked units — is the real deliverable, even while the corpus is small enough that a full rebuild is instant. +## Quick start + +```bash +cargo run -- init my-site # config + an editable copy of the layout + a page +cargo run -- build my-site -o _site +``` + +Or skip the scaffolding entirely — point it at any directory of `.org` files: + +```bash +cargo run -- build ~/notes -o _site +``` + +**Zero configuration is a supported path, not a demo.** With no `org-ssg.toml`, no +templates and no org-ssg-specific markup in your files, you get a complete site: pages, +navigation, syntax-highlighted code and the stylesheet to colour it. Configuration +changes what you get; it is never what makes it work. + +Discovery skips what should not be published — dot-directories such as `.git`, the config +file, the templates directory, and the output directory when it sits inside the source, so +`org-ssg build . -o _site` does the obvious thing. + +## Configuration + +Everything is optional. `org-ssg init` writes a fully commented `org-ssg.toml`; every +value below is the default. + +```toml +[site] +title = "org-ssg site" +base_url = "" # absolute URL, no trailing slash; empty = relative URLs only +description = "" +language = "en" + +[nav] +mode = "top-level" # top-level | all | explicit | none +# pages = ["index.org", "about.org"] # for mode = "explicit"; order is preserved + +[templates] +dir = "templates" # base.html replaces the built-in layout +expose_page_list = false + +[highlight] +theme = "InspiredGitHub" + +[html] +heading_offset = 1 # a level-1 org heading becomes <h2>, beneath the layout's <h1> +``` + +### Templates + +Drop a `base.html` into the templates directory and it replaces the built-in layout +entirely. Any other `.html` file there is available to `{% include %}` and +`{% extends %}`. Templates are [minijinja](https://docs.rs/minijinja) (Jinja2 syntax) and +receive: + +| Variable | What it is | +|---|---| +| `body` | the rendered page HTML — use `{{ body \| safe }}` | +| `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` | +| `site` | `.title`, `.base_url`, `.description`, `.language` | +| `nav` | list of `{title, url}`, relative to this page | +| `root` | `../`-prefix back to the site root from this page | +| `stylesheet` | URL of the generated `syntax.css` | +| `pages` | every page's metadata — only when `expose_page_list = true` | + +`page.keywords` carries **every** `#+KEYWORD:` in the file under its lowercased name, so +your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is +`{{ page.keywords.custom_thing }}`. + +Editing a template re-renders the pages that use it — template sources are a hash input, +so a design change never leaves a site half-updated. + +### `#+SLUG:` + +A page's output filename comes from its `#+SLUG:` when it has one, so +`2018-11-28-aes-encryption.org` can publish as `aes-encryption.html`. Without one the +source filename is used. Slugs are sanitized to a single safe path component, and two +pages claiming one URL is a build error rather than a silently dropped page. + ## Pipeline ``` @@ -24,6 +104,7 @@ is the only inherently global stage — it is where the link dependency graph is | Stage | Module | Notes | |---|---|---| +| config | `src/config.rs` | `org-ssg.toml`: site metadata, nav mode, templates, theme. A hash input. | | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | | audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | @@ -72,6 +153,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | +| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | ### v0.2 in / out @@ -173,9 +255,12 @@ and the `watch` fs-notify integration. The v1 scope was, by its own admission, *recommended* — a guess about which slice of org matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks what a real corpus actually uses, and an oracle that asks whether we render it the way -Emacs does. The corpus is the 179 files behind [cleberg.net](https://cleberg.net), which is -published today by weblorg — a wrapper around org's own HTML exporter. That makes it both -the workload and the incumbent. +Emacs does. + +The audit runs against any corpus — point it at your own notes before trusting this tool +with them. The numbers below come from a 179-file site published today by weblorg, a +wrapper around org's own HTML exporter, which makes it both a realistic workload and a +directly comparable incumbent. ``` cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? @@ -309,10 +394,9 @@ blog post used to re-render the entire site; now it renders one page.** A top-le title still invalidates everything, correctly, since every page displays it. **Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely -root-level pages appear. cleberg.net keeps its landing pages at `content/salary/index.org` -and friends, so its nav comes out as a single `index.org` entry where the live site shows -four. Treating a directory's `index.org` as top-level too is a one-line change to -`is_top_level` if that is the behaviour you want. +root-level pages appear — a site keeping its landing pages at `salary/index.org` and friends +gets a one-entry nav. That is what `nav.mode = "explicit"` is for: list the pages you want, +in the order you want them. **From v0.1 (core subset):** headings with nesting and anchors (every heading is now anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs; @@ -332,7 +416,8 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro ``` cargo build -cargo test +cargo test # 86 tests +cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) cargo run -- audit fixtures/site # corpus audit (Phase 0) new file mode 100644 @@ -0,0 +1,239 @@ +//! User-facing build configuration (`org-ssg.toml`). +//! +//! Everything here was once a constant in the source: the page layout, the nav rule, the +//! highlighting theme. That made the generator produce exactly one kind of site — a +//! reasonable place to start from, and a dead end for anyone whose site is not that one. +//! +//! Two properties matter beyond the settings themselves: +//! +//! 1. **Absent config is a valid config.** Every field has a default, so a directory of +//! `.org` files with no `org-ssg.toml` still builds. Configuration is how you change +//! the output, never how you make it work at all. +//! 2. **Config is a hash input** (spec §4.1). [`Config`] serializes deterministically and +//! its hash is folded into every page's render key, so editing `org-ssg.toml` re-renders +//! exactly the pages it affects — which for most settings is all of them. + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; + +/// The config file's name, looked for in the source directory. +pub const CONFIG_FILE: &str = "org-ssg.toml"; + +/// Resolved build configuration. Serialized into the config hash, so field order and +/// defaults are part of the cache contract. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub site: Site, + pub nav: Nav, + pub templates: Templates, + pub highlight: Highlight, + pub html: HtmlOutput, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct HtmlOutput { + /// How far to push heading levels down: a level-1 org heading becomes + /// `<h{1 + heading_offset}>`. + /// + /// Defaults to 1, matching Emacs' own `org-html-toplevel-hlevel`, because the page + /// layout supplies the `<h1>` — the document's title — and section headings sit + /// beneath it. Set to 0 if your template renders no title of its own, so the + /// document does not start at `<h2>` with nothing above it. + pub heading_offset: u8, +} + +impl Default for HtmlOutput { + fn default() -> Self { + HtmlOutput { heading_offset: 1 } + } +} + +/// Site-wide metadata, exposed to templates as `site`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Site { + /// Shown in the default layout's header and available as `site.title`. + pub title: String, + /// Absolute base URL (no trailing slash), for feeds and canonical links. Empty means + /// the site is built with relative URLs only, which is the portable default. + pub base_url: String, + /// Free-form description, available as `site.description`. + pub description: String, + /// `<html lang="…">` in the default layout. + pub language: String, +} + +impl Default for Site { + fn default() -> Self { + Site { + title: "org-ssg site".to_string(), + base_url: String::new(), + description: String::new(), + language: "en".to_string(), + } + } +} + +/// Which pages appear in the shared navigation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NavMode { + /// Pages at the site root. A nav is a map of the top level, not an index of the + /// whole site, and this keeps nav size independent of how many pages exist. + #[default] + TopLevel, + /// Every page. Fine for a small site; note that it makes total output quadratic in + /// page count, since each of `n` pages then carries `n` nav links. + All, + /// Only the pages listed in `nav.pages`, in that order. + Explicit, + /// No navigation at all. + None, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Nav { + pub mode: NavMode, + /// Source paths (relative to the source root, e.g. `about.org`) used when + /// `mode = "explicit"`. Order is preserved, so this doubles as nav ordering. + pub pages: Vec<Utf8PathBuf>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Templates { + /// Directory of `.html` templates, relative to the source root. Each file is + /// registered under its stem, so `base.html` overrides the built-in layout and + /// anything else is available to `{% include %}`/`{% extends %}`. + pub dir: Utf8PathBuf, + /// Give templates a `pages` list of every page's metadata, so a template can build + /// an index or archive. + /// + /// Off by default because it is not free: if any page can read every page's + /// metadata, then adding one page can change any page's output, so the whole site + /// must re-render on every add, rename or retitle. Turning this on trades that + /// incremental precision for the ability to write listing pages. + pub expose_page_list: bool, +} + +impl Default for Templates { + fn default() -> Self { + Templates { + dir: Utf8PathBuf::from("templates"), + expose_page_list: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Highlight { + /// A syntect built-in theme name — `InspiredGitHub`, `Solarized (dark)`, + /// `base16-ocean.dark`, `base16-eighties.dark`, `base16-mocha.dark`, + /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the + /// generated `syntax.css` colours them with. + pub theme: String, +} + +impl Default for Highlight { + fn default() -> Self { + Highlight { + theme: "InspiredGitHub".to_string(), + } + } +} + +impl Config { + /// Load `org-ssg.toml` from `dir`, or return defaults if there is none. + /// + /// A *missing* config is normal and silent. A *malformed* one is an error: someone + /// who wrote a config meant it, and silently building the default site would hide + /// their typo behind plausible-looking output. + pub fn load(dir: &Utf8Path) -> Result<Config> { + Self::load_file(&dir.join(CONFIG_FILE)) + } + + /// Load a config from an explicit path. Missing is still fine; malformed is not. + pub fn load_file(path: &Utf8Path) -> Result<Config> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), + Err(e) => return Err(e).with_context(|| format!("reading {path}")), + }; + toml::from_str(&text).with_context(|| format!("parsing {path}")) + } + + /// Validate settings that only make sense in combination. Catching these up front + /// beats emitting a site with a silently empty nav. + pub fn validate(&self) -> Result<()> { + if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() { + anyhow::bail!( + "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \ + include, or use mode = \"top-level\"/\"all\"/\"none\"" + ); + } + if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() { + anyhow::bail!( + "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \ + mode = \"explicit\" to use it", + toml::to_string(&self.nav.mode) + .unwrap_or_default() + .trim() + .trim_matches('"') + ); + } + 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 \ + with an explicit separator", + self.site.base_url + ); + } + Ok(()) + } +} + +/// The starter config written by `org-ssg 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#"# org-ssg 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. + +[site] +title = "org-ssg site" +# Absolute base URL, no trailing slash. Leave empty to build with relative URLs only. +base_url = "" +description = "" +language = "en" + +[nav] +# Which pages appear in the shared navigation: +# "top-level" — pages at the site root (default; keeps nav size independent of site size) +# "all" — every page (fine when small; output grows quadratically with page count) +# "explicit" — only nav.pages, in the order listed +# "none" — no navigation +mode = "top-level" +# pages = ["index.org", "about.org"] + +[templates] +# Directory of .html templates, relative to this file. `base.html` replaces the built-in +# layout; any other file can be pulled in with {% include %} or {% extends %}. +dir = "templates" +# Give templates a `pages` list of every page's metadata, so you can build an index or +# archive. Costs incremental precision: with this on, adding a page re-renders the site. +expose_page_list = false + +[highlight] +# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, +# base16-eighties.dark, base16-mocha.dark, base16-ocean.light. +theme = "InspiredGitHub" + +[html] +# How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>. +# The default of 1 matches Emacs, and assumes your layout renders the page title as the +# <h1>. Set to 0 if your template renders no title of its own. +heading_offset = 1 +"#; @@ -34,24 +34,10 @@ pub const CACHE_FORMAT_VERSION: u32 = 4; /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). pub type Hash = ContentHash; -/// Resolved global build config. Its hash is a component of every page's render key -/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 — -/// there is no user-facing config yet — but structured so real knobs (base URL, TODO -/// keyword set, highlighter theme id, inline features) flow into the hash when added. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildConfig { - pub output_extension: String, - pub highlighter_theme: String, -} - -impl Default for BuildConfig { - fn default() -> Self { - BuildConfig { - output_extension: "html".to_string(), - highlighter_theme: crate::render::SYNTAX_THEME.to_string(), - } - } -} +/// The resolved global build config is [`crate::config::Config`]; its hash is a +/// component of every page's render key (spec §4.1), so editing `org-ssg.toml` +/// invalidates the pages it affects. +pub use crate::config::Config as BuildConfig; /// Compose bytes into a blake3 hash. The one place hashing happens for composite keys. fn hash_bytes(bytes: &[u8]) -> Hash { @@ -93,7 +79,7 @@ pub fn site_structure_hash(entries: &[(String, String)]) -> Hash { /// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over /// all templates; when partials land, split this per-template so a single-partial edit /// invalidates only its users. -pub fn template_hash(sources: &[(&str, &str)]) -> Hash { +pub fn template_hash(sources: &[(String, String)]) -> Hash { let mut hasher = blake3::Hasher::new(); for (name, src) in sources { hasher.update(name.as_bytes()); @@ -9,6 +9,7 @@ //! deciding which pages actually need rewriting. pub mod audit; +pub mod config; pub mod incremental; pub mod index; pub mod model; @@ -7,10 +7,11 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::{Parser, Subcommand}; use org_ssg::parser::parse; -use org_ssg::render::{render, syntax_css, Html, SyntectHighlighter}; +use org_ssg::config::Config; +use org_ssg::render::{self, render, Html, SyntectHighlighter}; use org_ssg::resolve::ResolvedDoc; use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; -use org_ssg::template::Templater; +use org_ssg::template::{PageContext, SiteContext, Templater}; #[derive(Parser)] #[command(name = "org-ssg", version, about = "Org-mode static site generator")] @@ -32,9 +33,12 @@ enum Command { /// Bypass the incremental cache and re-render every page (spec §4.5). #[arg(long)] no_cache: bool, - /// Treat broken internal links as errors (spec §4.3.4). + /// Treat broken links and parse diagnostics as errors (spec §4.3.4). #[arg(long)] strict: bool, + /// Config file to use, overriding `org-ssg.toml` in the source directory. + #[arg(long, value_name = "FILE")] + config: Option<Utf8PathBuf>, }, /// Watch a source directory and rebuild incrementally on change (simple poll loop). Watch { @@ -55,6 +59,13 @@ enum Command { /// Source directory (or single `.org` file) to audit. input: Utf8PathBuf, }, + /// Scaffold a new site: config, an editable copy of the default layout, and a page. + Init { + /// Directory to create the site in (created if missing; defaults to the + /// current directory). + #[arg(default_value = ".")] + directory: Utf8PathBuf, + }, } fn main() -> Result<()> { @@ -65,11 +76,16 @@ fn main() -> Result<()> { output, no_cache, strict, + config, } => { if input.is_dir() { let out = output .context("site build requires an output directory: build <src-dir> -o <out-dir>")?; - let opts = BuildOptions { no_cache, strict }; + let opts = BuildOptions { + no_cache, + strict, + config_path: config.clone(), + }; let report = build_site(&input, &out, &opts)?; println!( "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))", @@ -98,6 +114,7 @@ fn main() -> Result<()> { print!("{}", org_ssg::audit::report(&result)); Ok(()) } + Command::Init { directory } => init(&directory), Command::Clean { output } => { if output.exists() { fs::remove_dir_all(&output) @@ -111,6 +128,56 @@ fn main() -> Result<()> { } } +/// Scaffold a working site. Writes only files that do not already exist, so running it +/// in a directory that has content is safe and additive rather than destructive. +fn init(dir: &Utf8Path) -> Result<()> { + use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG}; + use org_ssg::template::starter_template; + + fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; + fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; + + let index = concat!( + "#+TITLE: Hello\n", + "#+DATE: today\n", + "\n", + "Welcome to your new site. Edit this file, then run the build again.\n", + "\n", + "* A heading\n", + "\n", + "Org markup works as you would expect: *bold*, /italic/, ~code~, and\n", + "[[https://orgmode.org][links]].\n", + "\n", + "#+BEGIN_SRC rust\n", + "fn main() {\n", + " println!(\"syntax highlighting is on by default\");\n", + "}\n", + "#+END_SRC\n", + ); + + let files: [(Utf8PathBuf, &str); 3] = [ + (dir.join(CONFIG_FILE), STARTER_CONFIG), + (dir.join("templates/base.html"), starter_template()), + (dir.join("index.org"), index), + ]; + + let mut created = Vec::new(); + for (path, contents) in &files { + if path.exists() { + println!("kept existing {path}"); + continue; + } + fs::write(path, contents).with_context(|| format!("writing {path}"))?; + created.push(path.clone()); + } + + for path in &created { + println!("created {path}"); + } + println!("\nNext: org-ssg build {dir} -o _site"); + Ok(()) +} + /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { @@ -187,13 +254,40 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { let highlighter = SyntectHighlighter::new(); let Html(fragment) = render(&resolved, &highlighter); - let templater = Templater::new(); + // A single-file build still honours a config beside the source, so `build one.org` + // and a whole-site build produce the same-looking page. + let dir = input.parent().unwrap_or_else(|| Utf8Path::new(".")); + let config = Config::load(dir)?; + config.validate()?; + let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?; + let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| { + anyhow::anyhow!( + "unknown highlight.theme {:?}. Available: {}", + config.highlight.theme, + render::available_themes().join(", ") + ) + })?; + + let site = SiteContext { + title: config.site.title.clone(), + base_url: config.site.base_url.clone(), + description: config.site.description.clone(), + language: config.site.language.clone(), + }; + let page_ctx = PageContext { + title: title.clone(), + url: output.file_name().unwrap_or("index.html").to_string(), + source: input.to_string(), + date: None, + tags: Vec::new(), + keywords: Default::default(), + }; let page = templater - .render_page(&title, &fragment, &[], SYNTAX_STYLESHEET) + .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None) .with_context(|| format!("templating {input}"))?; fs::write(output, page).with_context(|| format!("writing output file {output}"))?; let css = output.with_file_name(SYNTAX_STYLESHEET); - fs::write(&css, syntax_css()).with_context(|| format!("writing stylesheet {css}"))?; + fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?; Ok(()) } @@ -42,11 +42,6 @@ pub trait Highlighter { /// two must agree or the CSS will not match the markup. const CLASS_STYLE: ClassStyle = ClassStyle::Spaced; -/// The syntect theme whose colours become [`syntax_css`]. Mirrored in -/// [`BuildConfig::highlighter_theme`](crate::incremental::BuildConfig) so a theme change -/// flows into the config hash and invalidates every page. -pub const SYNTAX_THEME: &str = "InspiredGitHub"; - /// Syntect's default syntax definitions, loaded once per process (loading is far more /// expensive than highlighting, and a site build highlights many blocks). fn syntax_set() -> &'static SyntaxSet { @@ -54,18 +49,24 @@ fn syntax_set() -> &'static SyntaxSet { SET.get_or_init(SyntaxSet::load_defaults_newlines) } -/// The stylesheet the emitted highlight classes refer to. Highlighting emits CSS -/// classes rather than inline styles (spec §3.2), so a build must also emit this. -pub fn syntax_css() -> &'static str { - static CSS: OnceLock<String> = OnceLock::new(); - CSS.get_or_init(|| { - let themes = ThemeSet::load_defaults(); - themes - .themes - .get(SYNTAX_THEME) - .and_then(|theme| css_for_theme_with_class_style(theme, CLASS_STYLE).ok()) - .unwrap_or_default() - }) +fn theme_set() -> &'static ThemeSet { + static THEMES: OnceLock<ThemeSet> = OnceLock::new(); + THEMES.get_or_init(ThemeSet::load_defaults) +} + +/// The stylesheet the emitted highlight classes refer to, for a named syntect theme. +/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must +/// also emit this. `None` means the theme name is not one syntect ships — the caller +/// reports that rather than quietly emitting an empty stylesheet, which would look like +/// highlighting is broken. +pub fn syntax_css(theme: &str) -> Option<String> { + let theme = theme_set().themes.get(theme)?; + css_for_theme_with_class_style(theme, CLASS_STYLE).ok() +} + +/// Every theme name [`syntax_css`] accepts, for error messages and documentation. +pub fn available_themes() -> Vec<&'static str> { + theme_set().themes.keys().map(String::as_str).collect() } /// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block @@ -129,6 +130,7 @@ fn language_class(lang: Option<&str>) -> String { /// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4). struct Renderer<'a> { hl: &'a dyn Highlighter, + opts: RenderOptions, /// Block footnote definitions, keyed by label (collected before the walk). block_defs: HashMap<String, Vec<Element>>, /// Inline footnote definitions discovered at reference sites. @@ -137,10 +139,34 @@ struct Renderer<'a> { order: Vec<String>, } -/// Render a resolved document to an HTML fragment. +/// Options affecting how the tree becomes HTML. Presentation choices that belong to the +/// site rather than to the document. +#[derive(Debug, Clone, Copy)] +pub struct RenderOptions { + /// Added to every heading's level, so a level-1 org heading can render as `<h2>` + /// beneath a page title supplied by the layout. See + /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). + pub heading_offset: u8, +} + +impl Default for RenderOptions { + fn default() -> Self { + RenderOptions { + heading_offset: crate::config::HtmlOutput::default().heading_offset, + } + } +} + +/// Render a resolved document to an HTML fragment, with default options. pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { + render_with(doc, highlighter, &RenderOptions::default()) +} + +/// Render a resolved document to an HTML fragment. +pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html { let mut r = Renderer { hl: highlighter, + opts: *opts, block_defs: HashMap::new(), inline_defs: HashMap::new(), order: Vec::new(), @@ -163,7 +189,7 @@ impl Renderer<'_> { fn render_section(&mut self, section: &Section, out: &mut String) { if let Some(h) = §ion.heading { - let level = h.level.clamp(1, 6); + let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); let anchor = h .custom_id .clone() @@ -19,14 +19,15 @@ use walkdir::WalkDir; use crate::incremental::{ self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, - template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, + template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, }; use crate::index::{document_targets, SymbolTable, TargetId}; use crate::model::{ContentHash, Diagnostic, Document}; use crate::parser::parse; -use crate::render::{render, syntax_css, Html, SyntectHighlighter}; +use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; use crate::resolve::resolve; -use crate::template::{template_sources, NavItem, Templater}; +use crate::config::{self, Config, NavMode}; +use crate::template::{NavItem, PageContext, SiteContext, Templater}; use crate::util::{output_path, output_url, relative_root}; /// A fully built page: source and output paths (relative to their roots) and its @@ -49,6 +50,8 @@ pub struct BuildOptions { pub no_cache: bool, /// Treat broken internal links as a build error rather than a warning (spec §4.3.4). pub strict: bool, + /// Explicit config file, overriding `org-ssg.toml` in the source directory. + pub config_path: Option<Utf8PathBuf>, } /// Summary of a site build. @@ -98,14 +101,39 @@ struct PagePrep { broken: Vec<TargetId>, diagnostics: Vec<Diagnostic>, nav: Vec<NavItem>, + context: PageContext, +} + +/// Which pages the configured [`NavMode`] selects, in nav order. +fn nav_selection<'a>( + config: &Config, + pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)], +) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> { + match config.nav.mode { + NavMode::None => Vec::new(), + NavMode::All => pages.iter().collect(), + NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(), + // Configured order wins over discovery order — a hand-written nav is a designed + // sequence, not an alphabetical one. + NavMode::Explicit => config + .nav + .pages + .iter() + .filter_map(|want| pages.iter().find(|(source, _, _)| source == want)) + .collect(), + } } /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so /// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4). -fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { - let (org_rel, _assets) = discover(src)?; +fn prepare_pages( + src: &Utf8Path, + config: &Config, + out: Option<&Utf8Path>, +) -> Result<(Vec<PagePrep>, SymbolTable)> { + let (org_rel, _assets) = discover(src, config, out)?; // PARSE every file (relative paths keep snapshots and links machine-independent). // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the @@ -127,32 +155,46 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { symbols.index_document(doc); } - // Nav is global chrome; titles come from #+TITLE (falling back to the file stem) and - // URLs from each page's output path, which `#+SLUG:` can rename. - let all_pages: Vec<(Utf8PathBuf, String)> = docs - .iter() - .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d))) - .collect(); - let entries: Vec<(Utf8PathBuf, String)> = all_pages + // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to + // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename. + let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs .iter() - .filter(|(out, _)| is_top_level(out)) - .cloned() + .map(|d| { + ( + d.source_path.clone(), + output_path(&d.source_path, &d.keywords), + page_title(d), + ) + }) .collect(); // Two sources emitting one page would silently drop a page — and with slugs, a // collision is a typo away and invisible in the source filenames. let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> = std::collections::HashMap::new(); - for (doc, (out, _)) in docs.iter().zip(&all_pages) { - if let Some(other) = claimed.insert(out, &doc.source_path) { + for (source, out, _) in &all_pages { + if let Some(other) = claimed.insert(out, source) { anyhow::bail!( - "output collision: {} and {} both build to {out} (check their #+SLUG:)", - other, - doc.source_path + "output collision: {other} and {source} both build to {out} \ + (check their #+SLUG:)" ); } } + // An explicit nav naming a page that does not exist is a typo, and a silently + // shorter nav is a poor way to learn about it. + if config.nav.mode == NavMode::Explicit { + for want in &config.nav.pages { + if !all_pages.iter().any(|(source, _, _)| source == want) { + anyhow::bail!("nav.pages lists {want}, which is not a page in {src}"); + } + } + } + let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages) + .into_iter() + .map(|(_, out, title)| (out.clone(), title.clone())) + .collect(); + // RESOLVE reads the shared symbol table and writes only into its own page's output, // so it parallelizes for free once INDEX has finished building the table. let pages: Vec<PagePrep> = docs @@ -175,6 +217,7 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { .collect(); PagePrep { + context: page_context(doc, &output), source: doc.source_path.clone(), output, title: page_title(doc), @@ -195,9 +238,14 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { /// Parse + index + resolve + render + template a whole site *in memory*, without /// touching the output directory. Shared by the tests (full render, every page). pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { - let (preps, _symbols) = prepare_pages(src)?; + let config = Config::load(src)?; + config.validate()?; + let (preps, _symbols) = prepare_pages(src, &config, None)?; let highlighter = SyntectHighlighter::new(); - let templater = Templater::new(); + let templater = Templater::load(Some(&src.join(&config.templates.dir)))?; + let site = site_context(&config); + let listing = page_listing(&config, &preps); + let render_opts = render_options(&config); let mut pages = Vec::new(); let mut broken = Vec::new(); @@ -205,7 +253,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { for t in &p.broken { broken.push((p.source.clone(), t.clone())); } - let html = render_page(&templater, &highlighter, p)?; + let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; pages.push(BuiltPage { source: p.source.clone(), output: p.output.clone(), @@ -216,16 +264,46 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { Ok((pages, broken)) } +fn render_options(config: &Config) -> RenderOptions { + RenderOptions { + heading_offset: config.html.heading_offset, + } +} + +fn site_context(config: &Config) -> SiteContext { + SiteContext { + title: config.site.title.clone(), + base_url: config.site.base_url.clone(), + description: config.site.description.clone(), + language: config.site.language.clone(), + } +} + +/// The `pages` list templates see, when configured to see one (see +/// [`crate::config::Templates::expose_page_list`]). +fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> { + config + .templates + .expose_page_list + .then(|| preps.iter().map(|p| p.context.clone()).collect()) +} + /// RENDER + TEMPLATE one prepared page into its final HTML string. +#[allow(clippy::too_many_arguments)] fn render_page( templater: &Templater, highlighter: &SyntectHighlighter, + site: &SiteContext, + pages: Option<&[PageContext]>, + render_opts: &RenderOptions, p: &PagePrep, ) -> Result<String> { - let Html(fragment) = render(&p.resolved, highlighter); - let stylesheet = format!("{}{}", relative_root(&p.source), SYNTAX_STYLESHEET); + let Html(fragment) = render_with(&p.resolved, highlighter, render_opts); + // 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}"); templater - .render_page(&p.title, &fragment, &p.nav, &stylesheet) + .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages) .with_context(|| format!("templating {}", p.source)) } @@ -236,28 +314,55 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css"; /// `render_key` changed or that link into a changed file's targets; reuses the on-disk /// output of everything else; persists an updated cache manifest. pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { - let (_org_rel, assets) = discover(src)?; - let (preps, symbols) = prepare_pages(src)?; + let cfg = match &opts.config_path { + Some(path) => Config::load_file(path)?, + None => Config::load(src)?, + }; + cfg.validate()?; + + // Create the output directory up front so it can be recognised and excluded when it + // lives inside the source tree. + fs::create_dir_all(out).with_context(|| format!("creating {out}"))?; + let (_org_rel, assets) = discover(src, &cfg, Some(out))?; + let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; + + let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?; + let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { + anyhow::anyhow!( + "unknown highlight.theme {:?}. Available: {}", + cfg.highlight.theme, + render::available_themes().join(", ") + ) + })?; // The global hash classes (spec §4.1): a change in any invalidates the site. The - // config hash is combined with a site-structure hash because the nav bar — global - // chrome on every page — is built from every page's (path, title), so a title/path - // change or a page add/remove must re-render every page (else stale nav on disk). - let cfg = BuildConfig::default(); - // Only the pages that actually appear in the nav belong in the site-structure hash, - // because the nav is the only global chrome a page carries. Hashing *every* page - // here would mean adding one blog post re-rendered the entire site — correct, but - // needlessly: a nested page cannot change any other page's nav. + // config hash is combined with a site-structure hash covering the global chrome each + // page carries, so a change to that chrome re-renders the pages showing it. // - // Keyed on the *output* path, since a `#+SLUG:` change moves a page's URL — and so - // its nav link — even though no source filename moved. - let nav_entries: Vec<(String, String)> = preps + // Which pages belong in that hash depends on what a template can *see*. Normally it + // is the nav only — a nested page cannot change another page's nav, so adding a blog + // post should render one page, not the site. But `expose_page_list` hands every + // template every page's metadata, and then any page's output really can depend on + // any other page, so the hash has to widen to match. Keyed on output paths, since a + // `#+SLUG:` change moves a page's URL without moving its source. + let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps .iter() - .filter(|p| is_top_level(&p.output)) - .map(|p| (p.output.to_string(), p.title.clone())) + .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) .collect(); - let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries)); - let tmpl_hash = template_hash(template_sources()); + let structure: Vec<(String, String)> = if cfg.templates.expose_page_list { + all_pages + .iter() + .map(|(_, out, title)| (out.to_string(), title.clone())) + .collect() + } else { + // The same selection the nav itself is built from, so the two can never drift. + nav_selection(&cfg, &all_pages) + .into_iter() + .map(|(_, out, title)| (out.to_string(), title.clone())) + .collect() + }; + let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure)); + let tmpl_hash = template_hash(templater.sources()); // Compose each page's render key and record its dependency edges. let mut new_graph = DepGraph::default(); @@ -310,7 +415,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result } let highlighter = SyntectHighlighter::new(); - let templater = Templater::new(); + let site = site_context(&cfg); + let listing = page_listing(&cfg, &preps); + let render_opts = render_options(&cfg); let mut report = SiteReport::default(); // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes @@ -332,7 +439,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result if let Some(parent) = dest.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; } - let html = render_page(&templater, &highlighter, p)?; + let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; Ok(true) }) @@ -355,8 +462,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result // The syntax stylesheet the highlighter's CSS classes refer to. Written every build // (it is a few KB and depends only on the theme, which lives in the config hash). - fs::create_dir_all(out).with_context(|| format!("creating {out}"))?; - fs::write(out.join(SYNTAX_STYLESHEET), syntax_css()) + fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the @@ -475,10 +581,24 @@ fn compute_rebuild_set( /// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative /// to `src` and sorted for deterministic output. The cache manifest is not an asset. -fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { +fn discover( + src: &Utf8Path, + config: &Config, + out: Option<&Utf8Path>, +) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { + let skip_dirs = excluded_dirs(src, config, out); let mut org = Vec::new(); let mut assets = Vec::new(); - for entry in WalkDir::new(src).sort_by_file_name() { + + let walker = WalkDir::new(src).sort_by_file_name().into_iter(); + for entry in walker.filter_entry(|e| { + let Some(path) = Utf8Path::from_path(e.path()) else { + return false; + }; + let rel = path.strip_prefix(src).unwrap_or(path); + // The source root itself always passes; `filter_entry` prunes whole subtrees. + rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs) + }) { let entry = entry.with_context(|| format!("walking {src}"))?; if !entry.file_type().is_file() { continue; @@ -489,6 +609,9 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { .strip_prefix(src) .map(|p| p.to_owned()) .unwrap_or_else(|_| abs.clone()); + if rel == config::CONFIG_FILE { + continue; + } if rel.extension() == Some("org") { org.push(rel); } else { @@ -500,6 +623,62 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { Ok((org, assets)) } +/// Source-relative directories that DISCOVER must not descend into: the template +/// directory (build input, not content) and the output directory when it lives inside +/// the source. +/// +/// The output case is not a corner case — `org-ssg build . -o _site` is the obvious +/// thing to type, and without this the build copies its own output back into itself, +/// growing `_site/_site/_site/…` on every run. +fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec<Utf8PathBuf> { + let mut dirs = vec![config.templates.dir.clone()]; + if let Some(out) = out { + // Compare canonicalized paths so `.`, `./x` and an absolute path all agree. + // The output may not exist yet, in which case it cannot contain anything and + // the textual fallback is enough. + let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> { + std::fs::canonicalize(p) + .ok() + .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) + }; + match (canon(src), canon(out)) { + (Some(src_abs), Some(out_abs)) => { + if let Ok(rel) = out_abs.strip_prefix(&src_abs) { + if !rel.as_str().is_empty() { + dirs.push(rel.to_owned()); + } + } + } + _ => { + if let Ok(rel) = out.strip_prefix(src) { + if !rel.as_str().is_empty() { + dirs.push(rel.to_owned()); + } + } + } + } + } + dirs +} + +/// Is this source-relative path excluded from discovery? +/// +/// Dot-entries are skipped wholesale. That is the conventional rule for site generators, +/// and the reason is safety rather than tidiness: a source directory is very often a git +/// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire +/// history alongside its homepage. +fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool { + if rel + .components() + .any(|c| c.as_str().starts_with('.') && c.as_str() != "." && c.as_str() != "..") + { + return true; + } + skip_dirs + .iter() + .any(|dir| !dir.as_str().is_empty() && rel.starts_with(dir)) +} + /// Does this output path sit at the site root? /// /// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page @@ -511,6 +690,37 @@ fn is_top_level(output: &Utf8Path) -> bool { output.parent().is_none_or(|p| p.as_str().is_empty()) } +/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through +/// under its lowercased name, so a template can use metadata this crate has never heard +/// of without the crate needing a release to support it. +fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { + let keyword = |name: &str| { + doc.keywords + .entries + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) + }; + PageContext { + title: page_title(doc), + url: output.to_string(), + source: doc.source_path.to_string(), + date: keyword("DATE"), + tags: keyword("FILETAGS") + .unwrap_or_default() + .split(':') + .filter(|t| !t.trim().is_empty()) + .map(|t| t.trim().to_string()) + .collect(), + keywords: doc + .keywords + .entries + .iter() + .map(|(k, v)| (k.to_lowercase(), v.clone())) + .collect(), + } +} + fn page_title(doc: &Document) -> String { doc.keywords .entries @@ -1,10 +1,20 @@ //! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML. //! //! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile). -//! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit -//! invalidates every page that transitively uses it. Keep the fragment/template -//! boundary sharp so content HTML can be snapshot-tested independently of chrome. +//! +//! Templates come from the configured directory when it exists, and fall back to a +//! built-in layout when it does not. That fallback is what lets a bare directory of +//! `.org` files build into a real site with no setup, while `base.html` in the templates +//! directory replaces the layout entirely for anyone who wants their own. +//! +//! Template sources are a hashing input for incrementality (spec §4.1): editing a layout +//! invalidates the pages that use it, and that has to hold for user templates too, or a +//! design change would leave a site half-updated. +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use camino::Utf8Path; use minijinja::{context, Environment}; use serde::Serialize; @@ -15,36 +25,73 @@ pub struct NavItem { pub url: String, } -/// The base layout applied to every page: `<title>`, a nav bar, and the body. -/// Minimal but real — a single `base` template, no partials yet. +/// Site-wide values, exposed to templates as `site`. +#[derive(Debug, Clone, Serialize)] +pub struct SiteContext { + pub title: String, + pub base_url: String, + pub description: String, + pub language: String, +} + +/// One page's metadata, exposed to templates as `page` — and, when +/// `templates.expose_page_list` is on, as entries of `pages`. +#[derive(Debug, Clone, Serialize)] +pub struct PageContext { + pub title: String, + /// Output path relative to the site root, e.g. `blog/post.html`. + pub url: String, + /// Source path relative to the source root, e.g. `blog/post.org`. + pub source: String, + /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because + /// templates are better placed to decide how a date should read. + pub date: Option<String>, + /// `#+FILETAGS:` split on `:`. + pub tags: Vec<String>, + /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use + /// project-specific metadata this crate has never heard of. + pub keywords: BTreeMap<String, String>, +} + +/// The built-in layout, used when the templates directory has no `base.html`. +/// Deliberately plain: it should be a working starting point and an obvious thing to +/// replace, not a design anyone has to live with. const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> -<html lang="en"> +<html lang="{{ site.language }}"> <head> <meta charset="utf-8"> -<title>{{ title }}</title> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ page.title }} · {{ site.title }}</title> +{%- if page.description %} +<meta name="description" content="{{ page.description }}"> +{%- endif %} {%- if stylesheet %} <link rel="stylesheet" href="{{ stylesheet }}"> {%- endif %} </head> <body> +<header> +<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a> +{%- if nav %} <nav> {%- for item in nav %} <a href="{{ item.url }}">{{ item.title }}</a> {%- endfor %} </nav> +{%- endif %} +</header> <main> +<h1>{{ page.title }}</h1> +{%- if page.date %} +<p class="page-date">{{ page.date }}</p> +{%- endif %} {{ body | safe }}</main> </body> </html> "#; -/// The source text of every template that participates in the page layout. Hashed by -/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that -/// uses it. There is a single `base` template today; when partials arrive this returns -/// the transitive closure so a single-partial edit invalidates only its users. -pub fn template_sources() -> &'static [(&'static str, &'static str)] { - &[("base", BASE_TEMPLATE)] -} +/// The name a template must have to serve as the page layout. +pub const BASE_TEMPLATE_NAME: &str = "base"; #[derive(Debug, thiserror::Error)] pub enum TemplateError { @@ -55,37 +102,120 @@ pub enum TemplateError { /// Wraps a rendered fragment in its page template. pub struct Templater { env: Environment<'static>, + /// `(name, source)` for every registered template, for the template hash. Sorted by + /// name so the hash does not depend on directory iteration order. + sources: Vec<(String, String)>, } impl Templater { - pub fn new() -> Self { + /// Load templates from `dir`, falling back to the built-in layout. + /// + /// A missing directory is fine — that is the zero-config path. A directory that + /// exists but contains a template that does not compile is an error: it means + /// someone is actively editing their layout, and rendering the built-in default + /// instead would look like their edit silently did nothing. + pub fn load(dir: Option<&Utf8Path>) -> Result<Self> { + let mut sources: Vec<(String, String)> = Vec::new(); + + if let Some(dir) = dir.filter(|d| d.is_dir()) { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .with_context(|| format!("reading template directory {dir}"))? + .collect::<std::io::Result<Vec<_>>>() + .with_context(|| format!("reading template directory {dir}"))?; + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = Utf8Path::from_path(&entry.path()) + .map(Utf8Path::to_owned) + .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?; + if path.extension() != Some("html") || !path.is_file() { + continue; + } + let name = path + .file_stem() + .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))? + .to_string(); + let source = std::fs::read_to_string(&path) + .with_context(|| format!("reading template {path}"))?; + sources.push((name, source)); + } + } + + if !sources.iter().any(|(n, _)| n == BASE_TEMPLATE_NAME) { + sources.push((BASE_TEMPLATE_NAME.to_string(), BASE_TEMPLATE.to_string())); + } + sources.sort_by(|a, b| a.0.cmp(&b.0)); + let mut env = Environment::new(); - env.add_template("base", BASE_TEMPLATE) - .expect("base template compiles"); - Templater { env } + for (name, source) in &sources { + // `Environment<'static>` needs owned sources; leaking is bounded by the + // template count and lives as long as the build anyway. + let name: &'static str = Box::leak(name.clone().into_boxed_str()); + let source: &'static str = Box::leak(source.clone().into_boxed_str()); + env.add_template(name, source) + .with_context(|| format!("compiling template {name}"))?; + } + + Ok(Templater { env, sources }) + } + + /// `(name, source)` for every registered template — the template hash's input + /// (spec §4.1), covering user templates so editing one invalidates its pages. + pub fn sources(&self) -> &[(String, String)] { + &self.sources } - /// fragment + page metadata → full HTML page. `stylesheet` is the URL of the - /// syntax-highlighting stylesheet relative to *this* page (highlighting emits CSS - /// classes, so the sheet has to come with it). + /// fragment + page metadata → full HTML page. + /// + /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the + /// same at any directory depth. + #[allow(clippy::too_many_arguments)] pub fn render_page( &self, - title: &str, + site: &SiteContext, + page: &PageContext, body: &str, nav: &[NavItem], stylesheet: &str, + root: &str, + pages: Option<&[PageContext]>, ) -> Result<String, TemplateError> { let tmpl = self .env - .get_template("base") + .get_template(BASE_TEMPLATE_NAME) .map_err(|e| TemplateError::Render(e.to_string()))?; - tmpl.render(context! { title => title, body => body, nav => nav, stylesheet => stylesheet }) - .map_err(|e| TemplateError::Render(e.to_string())) + tmpl.render(context! { + site => site, + page => page, + body => body, + nav => nav, + stylesheet => stylesheet, + root => root, + pages => pages, + }) + .map_err(|e| TemplateError::Render(render_error_detail(e))) } } -impl Default for Templater { - fn default() -> Self { - Self::new() +/// minijinja's `Display` gives only the top-level message; the useful part (which +/// template, which line) is in the source and cause chain. +fn render_error_detail(error: minijinja::Error) -> String { + let mut out = error.to_string(); + if let Some(name) = error.template_source().map(|_| error.name().unwrap_or("?")) { + if let Some(line) = error.line() { + out = format!("{out} (in template {name}, line {line})"); + } } + let mut source = std::error::Error::source(&error); + while let Some(cause) = source { + out.push_str(&format!(": {cause}")); + source = cause.source(); + } + out +} + +/// The starter layout written by `org-ssg init`: the built-in template, on disk, ready +/// to edit. +pub fn starter_template() -> &'static str { + BASE_TEMPLATE } new file mode 100644 @@ -0,0 +1,445 @@ +//! Configuration, templating and discovery — the surface that decides whether this is a +//! generator for one site or for anyone's. +//! +//! The theme running through these tests is that **the zero-config path has to work**. +//! A directory of `.org` files with no `org-ssg.toml`, no templates and no knowledge of +//! this tool must build into a real site; configuration is how you change the output, +//! never how you make it work at all. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use camino::Utf8PathBuf; + +use org_ssg::config::{Config, NavMode}; +use org_ssg::site::{build_site, BuildOptions}; + +fn tmpdir(tag: &str) -> Utf8PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("utf-8 temp dir") + .join(format!("org-ssg-cfg-{}-{tag}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base +} + +/// A site with a root page, a second root page, and one nested page. +fn write_site(src: &Utf8PathBuf) { + std::fs::create_dir_all(src.join("blog")).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); + std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap(); + std::fs::write( + src.join("blog/post.org"), + "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n", + ) + .unwrap(); +} + +fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> org_ssg::site::SiteReport { + build_site(src, out, &BuildOptions::default()).expect("build") +} + +fn page(out: &Utf8PathBuf, rel: &str) -> String { + std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}")) +} + +// --------------------------------------------------------------------------- +// Zero config +// --------------------------------------------------------------------------- + +/// The headline promise: point it at a directory of org files and get a site. +#[test] +fn a_bare_directory_of_org_files_builds_with_no_config() { + let root = tmpdir("bare"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + let out = root.join("out"); + + let report = build(&src, &out); + assert_eq!(report.pages.len(), 3); + + let home = page(&out, "index.html"); + assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment"); + assert!(home.contains("Welcome."), "the content is there"); + assert!( + out.join("syntax.css").exists(), + "the stylesheet the highlighter needs is emitted too" + ); +} + +/// A missing config is normal. A *malformed* one is not: someone who wrote a config +/// meant it, and quietly building the default site would hide their typo behind +/// plausible-looking output. +#[test] +fn a_malformed_config_is_an_error_but_a_missing_one_is_not() { + let root = tmpdir("malformed"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + + assert_eq!(Config::load(&src).unwrap(), Config::default()); + + std::fs::write(src.join("org-ssg.toml"), "[site\ntitle = broken").unwrap(); + let err = Config::load(&src).expect_err("malformed config must fail"); + assert!(format!("{err:#}").contains("org-ssg.toml"), "names the file: {err:#}"); +} + +/// A misspelled key is a silent no-op in most config formats, which is exactly how +/// someone spends an afternoon wondering why a setting does nothing. +#[test] +fn an_unknown_config_key_is_rejected() { + let root = tmpdir("unknownkey"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("org-ssg.toml"), "[site]\ntittle = \"typo\"\n").unwrap(); + + let err = Config::load(&src).expect_err("unknown key must fail"); + assert!( + format!("{err:#}").contains("tittle"), + "the error names the offending key: {err:#}" + ); +} + +// --------------------------------------------------------------------------- +// Nav modes +// --------------------------------------------------------------------------- + +fn nav_of(html: &str) -> String { + html.split("<nav>") + .nth(1) + .and_then(|s| s.split("</nav>").next()) + .unwrap_or("") + .to_string() +} + +#[test] +fn nav_modes_select_different_pages() { + for (mode, expect_post, expect_about) in [ + ("top-level", false, true), + ("all", true, true), + ("none", false, false), + ] { + let root = tmpdir(&format!("nav-{mode}")); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + format!("[nav]\nmode = \"{mode}\"\n"), + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let nav = nav_of(&page(&out, "index.html")); + assert_eq!( + nav.contains("A Post"), + expect_post, + "mode {mode} nested page presence, nav was:\n{nav}" + ); + assert_eq!( + nav.contains("About"), + expect_about, + "mode {mode} root page presence, nav was:\n{nav}" + ); + } +} + +/// An explicit nav is a designed sequence, so configured order beats discovery order. +#[test] +fn explicit_nav_uses_the_configured_order() { + let root = tmpdir("navexplicit"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let nav = nav_of(&page(&out, "index.html")); + let post = nav.find("A Post").expect("post in nav"); + let home = nav.find("Home").expect("home in nav"); + assert!(post < home, "configured order wins:\n{nav}"); + assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}"); +} + +/// A nav entry naming a page that does not exist is a typo, and a silently shorter nav +/// is a poor way to find out. +#[test] +fn explicit_nav_rejects_a_page_that_does_not_exist() { + let root = tmpdir("navmissing"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n", + ) + .unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("missing nav page must fail"); + assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}"); +} + +/// `mode` and `pages` disagreeing means one of them is being ignored. +#[test] +fn contradictory_nav_settings_are_rejected() { + let mut config = Config::default(); + config.nav.pages = vec![Utf8PathBuf::from("index.org")]; + assert!(config.validate().is_err(), "pages without explicit mode"); + + let mut config = Config::default(); + config.nav.mode = NavMode::Explicit; + assert!(config.validate().is_err(), "explicit mode without pages"); +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +/// The single biggest blocker to general use: without this every site built with this +/// tool looks identical. +#[test] +fn a_user_template_replaces_the_built_in_layout() { + let root = tmpdir("template"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + std::fs::write( + src.join("templates/base.html"), + "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let home = page(&out, "index.html"); + assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}"); + assert!(!home.contains("<nav>"), "nothing of the default layout leaks in"); + assert!(home.contains("Welcome."), "content still renders"); +} + +/// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate, +/// a design change would leave a site half-updated — the worst kind of caching bug, +/// because it looks like it worked. +#[test] +fn editing_a_template_re_renders_every_page_that_uses_it() { + let root = tmpdir("templatehash"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + let tpl = src.join("templates/base.html"); + std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap(); + let out = root.join("out"); + + build(&src, &out); + std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap(); + let report = build(&src, &out); + + assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page"); + assert!(page(&out, "index.html").contains("v2"), "and the change lands"); +} + +/// A template that does not compile means someone is actively editing their layout. +/// Falling back to the built-in would look like their edit silently did nothing. +#[test] +fn a_broken_template_fails_the_build() { + let root = tmpdir("badtemplate"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("a broken template must fail the build"); + assert!( + format!("{err:#}").contains("base"), + "the error names the template: {err:#}" + ); +} + +/// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never +/// heard of — otherwise every new bit of metadata would need a release. +#[test] +fn templates_receive_page_metadata_including_unknown_keywords() { + let root = tmpdir("meta"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + std::fs::write( + src.join("blog/post.org"), + "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n", + ) + .unwrap(); + std::fs::write( + src.join("templates/base.html"), + "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \ + custom={{ page.keywords.custom_thing }} url={{ page.url }} \ + site={{ site.title }}{{ body | safe }}</body></html>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let post = page(&out, "blog/post.html"); + assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}"); + assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}"); + assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}"); + assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}"); +} + +/// Off by default, because it trades incremental precision for the ability to write +/// listing pages — and that trade should be a choice. +#[test] +fn the_page_list_is_opt_in_and_widens_invalidation() { + let root = tmpdir("pagelist"); + let src = root.join("src"); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + "[templates]\nexpose_page_list = true\n", + ) + .unwrap(); + std::fs::write( + src.join("templates/base.html"), + "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\ + {{ body | safe }}</body></html>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let home = page(&out, "index.html"); + for title in ["Home", "About", "A Post"] { + assert!(home.contains(title), "an index can list {title}:\n{home}"); + } + + // With every page visible to every template, adding one must re-render them all — + // the opposite of the default, and the documented cost of turning this on. + std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap(); + let report = build(&src, &out); + assert_eq!( + report.rendered.len(), + 4, + "with the page list exposed, adding a page re-renders the site" + ); +} + +// --------------------------------------------------------------------------- +// Output settings +// --------------------------------------------------------------------------- + +/// The default layout renders the page title as `<h1>`, so section headings belong +/// beneath it — which is also what Emacs does by default. +#[test] +fn heading_offset_shifts_content_headings_below_the_page_title() { + let root = tmpdir("hoffset"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap(); + let out = root.join("out"); + build(&src, &out); + assert!( + page(&out, "index.html").contains("<h2 id=\"section\">"), + "a level-1 org heading renders as <h2> by default" + ); + + std::fs::write(src.join("org-ssg.toml"), "[html]\nheading_offset = 0\n").unwrap(); + let out2 = root.join("out2"); + build(&src, &out2); + assert!( + page(&out2, "index.html").contains("<h1 id=\"section\">"), + "offset 0 leaves headings where the document put them" + ); +} + +/// An unknown theme silently produces an empty stylesheet, which looks exactly like +/// highlighting being broken. Naming the valid options turns a mystery into a typo. +#[test] +fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() { + let root = tmpdir("theme"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write(src.join("org-ssg.toml"), "[highlight]\ntheme = \"nope\"\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("nope"), "names the bad theme: {message}"); + assert!( + message.contains("InspiredGitHub"), + "lists what is available: {message}" + ); +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/// `org-ssg build . -o _site` is the obvious thing to type. Without excluding the output +/// directory, the build copies its own output back into itself, growing `_site/_site/…` +/// on every run. +#[test] +fn an_output_directory_inside_the_source_is_not_swallowed() { + let root = tmpdir("nested"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + let out = src.join("_site"); + + for _ in 0..3 { + build(&src, &out); + } + assert!(!out.join("_site").exists(), "output must not nest inside itself"); + + let report = build(&src, &out); + assert_eq!(report.pages.len(), 3, "still exactly the source pages"); + assert!( + report.assets.is_empty(), + "no output file is mistaken for an asset: {:?}", + report.assets + ); +} + +/// A source directory is very often a git repository. Publishing `.git` alongside the +/// homepage leaks a project's entire history. +#[test] +fn dot_directories_and_build_inputs_are_never_published() { + let root = tmpdir("dotfiles"); + let src = root.join("src"); + std::fs::create_dir_all(src.join(".git")).unwrap(); + std::fs::create_dir_all(src.join("templates")).unwrap(); + write_site(&src); + std::fs::write(src.join(".git/config"), "[remote]\nurl = private\n").unwrap(); + std::fs::write(src.join(".env"), "SECRET=hunter2\n").unwrap(); + std::fs::write(src.join("org-ssg.toml"), "[site]\ntitle = \"T\"\n").unwrap(); + std::fs::write(src.join("templates/base.html"), "<html>{{ body | safe }}</html>").unwrap(); + std::fs::write(src.join("style.css"), "body{}\n").unwrap(); + let out = root.join("out"); + + let report = build(&src, &out); + assert!(!out.join(".git").exists(), ".git must never be published"); + assert!(!out.join(".env").exists(), "dotfiles must never be published"); + assert!( + !out.join("org-ssg.toml").exists(), + "the config is a build input, not content" + ); + assert!( + !out.join("templates").exists(), + "templates are build inputs, not content" + ); + assert_eq!( + report.assets, + vec![Utf8PathBuf::from("style.css")], + "genuine assets still copy through" + ); +} @@ -160,7 +160,9 @@ fn highlighting_emits_classes_not_inline_styles() { "highlighting must not emit inline styles:\n{html}" ); assert!( - org_ssg::render::syntax_css().contains(".storage"), + org_ssg::render::syntax_css("InspiredGitHub") + .expect("a built-in theme") + .contains(".storage"), "the generated stylesheet must define the emitted classes" ); } @@ -85,7 +85,7 @@ fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() { &full, &BuildOptions { no_cache: true, - strict: false, + ..Default::default() }, ) .unwrap(); @@ -329,7 +329,7 @@ fn parallel_builds_are_deterministic_in_output_and_report_order() { out, &BuildOptions { no_cache: true, - strict: false, + ..Default::default() }, ) .unwrap() @@ -16,10 +16,9 @@ ;; learn what stock org does — normalizing that away would be marking our own homework. (setq org-export-with-toc nil ; we emit no table of contents org-export-with-section-numbers nil ; we do not number headings - org-html-toplevel-hlevel 1 ; org defaults to h2 for a level-1 heading, - ; because a template supplies the page <h1>. - ; Aligning here keeps a global +1 offset from - ; drowning every real finding in the diff. + ;; org-html-toplevel-hlevel is left at its default of 2. org-ssg's own default + ;; heading_offset is 1, which produces the same <h2>, so both sides now agree + ;; without the oracle being told to. org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight ; with syntect, so comparing code *text* is ; the meaningful part @@ -2,25 +2,25 @@ source: tests/constructs.rs expression: "render_fixture(\"blocks.org\")" --- -<h1 id="quote">Quote</h1> +<h2 id="quote">Quote</h2> <blockquote> <p>A quoted paragraph with <em>markup</em>.</p> <p>And a second paragraph.</p> </blockquote> -<h1 id="center">Center</h1> +<h2 id="center">Center</h2> <div class="center"> <p>Centred text.</p> </div> -<h1 id="example">Example</h1> +<h2 id="example">Example</h2> <pre>Verbatim *not bold* text. Indentation preserved.</pre> -<h1 id="export">Export</h1> +<h2 id="export">Export</h2> <aside class="raw">Raw HTML passes through.</aside> -<h1 id="source">Source</h1> +<h2 id="source">Source</h2> <pre><code class="language-python highlight"><span class="source python"><span class="meta function python"><span class="storage type function python">def</span> <span class="entity name function python"><span class="meta generic-name python">greet</span></span></span><span class="meta function parameters python"><span class="punctuation section parameters begin python">(</span></span><span class="meta function parameters python"><span class="variable parameter python">name</span><span class="punctuation section parameters end python">)</span></span><span class="meta function python"><span class="punctuation section function begin python">:</span></span> <span class="keyword control flow return python">return</span> <span class="storage type string python">f</span><span class="meta string interpolated python"><span class="string quoted double python"><span class="punctuation definition string begin python">"</span></span></span><span class="meta string interpolated python"><span class="string quoted double python">hello </span><span class="meta interpolation python"><span class="punctuation section interpolation begin python">{</span><span class="source python embedded"><span class="meta qualified-name python"><span class="meta generic-name python">name</span></span></span></span><span class="meta interpolation python"><span class="punctuation section interpolation end python">}</span></span><span class="string quoted double python"><span class="punctuation definition string end python">"</span></span></span></span></code></pre> <pre><code class="language-none">plain block, no language</code></pre> -<h1 id="nested">Nested</h1> +<h2 id="nested">Nested</h2> <blockquote> <p>A quote containing a source block:</p> <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre> @@ -2,13 +2,13 @@ source: tests/constructs.rs expression: "render_fixture(\"headings.org\")" --- -<h1 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h1> +<h2 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h2> <p>A heading carrying a keyword, a priority, tags and a property drawer.</p> -<h2 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h2> +<h3 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h3> <p>Sub-headings nest by star count.</p> -<h2 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h2> +<h3 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h3> <p>A priority cookie can stand alone.</p> -<h1 id="todos-are-not-a-keyword">TODOs are not a keyword</h1> +<h2 id="todos-are-not-a-keyword">TODOs are not a keyword</h2> <p>The word boundary matters: this heading has no TODO keyword.</p> -<h1><span class="done DONE">DONE</span> </h1> +<h2><span class="done DONE">DONE</span> </h2> <p>A keyword with no title at all.</p> @@ -2,13 +2,13 @@ source: tests/constructs.rs expression: "render_fixture(\"images.org\")" --- -<h1 id="bare-image">Bare image</h1> +<h2 id="bare-image">Bare image</h2> <p><img src="diagram.png" alt=""></p> -<h1 id="captioned-figure">Captioned figure</h1> +<h2 id="captioned-figure">Captioned figure</h2> <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> -<h1 id="caption-with-markup">Caption with markup</h1> +<h2 id="caption-with-markup">Caption with markup</h2> <figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> -<h1 id="quoted-attribute-values">Quoted attribute values</h1> +<h2 id="quoted-attribute-values">Quoted attribute values</h2> <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> -<h1 id="image-with-a-description-is-a-link">Image with a description is a link</h1> +<h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2> <p><a href="diagram.png">the diagram</a></p> @@ -2,7 +2,7 @@ source: tests/constructs.rs expression: "render_fixture(\"lists.org\")" --- -<h1 id="nesting">Nesting</h1> +<h2 id="nesting">Nesting</h2> <ul> <li>outer item<ul> <li>inner item<ul> @@ -14,7 +14,7 @@ expression: "render_fixture(\"lists.org\")" </li> <li>second outer</li> </ul> -<h1 id="ordered">Ordered</h1> +<h2 id="ordered">Ordered</h2> <ol> <li>first</li> <li>second<ol> @@ -24,13 +24,13 @@ expression: "render_fixture(\"lists.org\")" </li> <li>third</li> </ol> -<h1 id="checkboxes">Checkboxes</h1> +<h2 id="checkboxes">Checkboxes</h2> <ul> <li><input type="checkbox" disabled> not done</li> <li><input type="checkbox" disabled checked> done</li> <li><input type="checkbox" disabled> partially done</li> </ul> -<h1 id="description">Description</h1> +<h2 id="description">Description</h2> <dl> <dt>term one</dt> <dd>the first definition</dd> @@ -39,7 +39,7 @@ expression: "render_fixture(\"lists.org\")" <dt><em>marked up</em> term</dt> <dd>definitions hold inline markup</dd> </dl> -<h1 id="multi-paragraph-items">Multi-paragraph items</h1> +<h2 id="multi-paragraph-items">Multi-paragraph items</h2> <ul> <li><p>an item whose body has two paragraphs</p> <p>the second paragraph, indented under the bullet</p> @@ -3,9 +3,9 @@ source: tests/constructs.rs expression: "render_fixture(\"outofscope.org\")" --- <p>Every construct here is on the README's explicit OUT list. The contract is not that we handle them — it is that they degrade predictably and never crash the build.</p> -<h1 id="babel">Babel</h1> +<h2 id="babel">Babel</h2> <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> <span class="string quoted double shell"><span class="punctuation definition string begin shell">"</span>the block renders; :results is never executed<span class="punctuation definition string end shell">"</span></span></span></span></code></pre> -<h1 id="table-formulas">Table formulas</h1> +<h2 id="table-formulas">Table formulas</h2> <table> <thead> <tr><th>item</th><th>cost</th></tr> @@ -15,14 +15,14 @@ expression: "render_fixture(\"outofscope.org\")" <tr><td>b</td><td>2</td></tr> </tbody> </table> -<h1 id="latex">LaTeX</h1> +<h2 id="latex">LaTeX</h2> <p>Inline math $x^2 + y^2$ and a display block:</p> <p>\begin{equation} E = mc^2 \end{equation}</p> -<h1 id="macros-and-radio-targets">Macros and radio targets</h1> +<h2 id="macros-and-radio-targets">Macros and radio targets</h2> <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> -<h1 id="drawers">Drawers</h1> -<h1 id="verse">Verse</h1> +<h2 id="drawers">Drawers</h2> +<h2 id="verse">Verse</h2> <pre>An unmodelled block type keeps its content verbatim.</pre> -<h1 id="entities">Entities</h1> +<h2 id="entities">Entities</h2> <p>The full entity set is out of scope, so \alpha stays literal.</p> @@ -2,13 +2,13 @@ source: tests/constructs.rs expression: "render_fixture(\"timestamps.org\")" --- -<h1 id="single">Single</h1> +<h2 id="single">Single</h2> <p>An active date <time class="timestamp" datetime="2024-01-15">2024-01-15</time> and an inactive one <time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>.</p> <p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p> -<h1 id="ranges">Ranges</h1> +<h2 id="ranges">Ranges</h2> <p>A same-day time range <time class="timestamp" datetime="2024-01-15T10:00">2024-01-15 10:00</time>–<time class="timestamp" datetime="2024-01-15T11:45">11:45</time>.</p> <p>A multi-day range <time class="timestamp" datetime="2024-01-15">2024-01-15</time>–<time class="timestamp" datetime="2024-01-20">2024-01-20</time>.</p> -<h1 id="ignored-decorations">Ignored decorations</h1> +<h2 id="ignored-decorations">Ignored decorations</h2> <p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p> -<h1 id="not-timestamps">Not timestamps</h1> +<h2 id="not-timestamps">Not timestamps</h2> <p>Comparisons like 3 < 4 and [not a stamp] stay literal text.</p> @@ -5,9 +5,9 @@ expression: report agreement: 51/59 skeleton lines (86.4%) (- org-ssg, + emacs) - <h1> + <h2> "Quote" - </h1> + </h2> <blockquote> <p> "A quoted paragraph with" @@ -22,27 +22,27 @@ agreement: 51/59 skeleton lines (86.4%) "And a second paragraph." </p> </blockquote> - <h1> + <h2> "Center" - </h1> + </h2> <p> "Centred text." </p> - <h1> + <h2> "Example" - </h1> + </h2> <pre> "Verbatim *not bold* text. Indentation preserved." </pre> - <h1> + <h2> "Export" - </h1> + </h2> <aside> "Raw HTML passes through." </aside> - <h1> + <h2> "Source" - </h1> + </h2> <pre> - <code> "def greet(name): return f\"hello {name}\"" @@ -53,9 +53,9 @@ agreement: 51/59 skeleton lines (86.4%) "plain block, no language" - </code> </pre> - <h1> + <h2> "Nested" - </h1> + </h2> <blockquote> <p> "A quote containing a source block:" @@ -16,9 +16,9 @@ agreement: 45/54 skeleton lines (83.3%) </code> "." </p> - <h1> + <h2> "Ordered and checked" - </h1> + </h2> <ol> <li> "first item" @@ -49,9 +49,9 @@ agreement: 45/54 skeleton lines (83.3%) </li> - </ul> + </ol> - <h1> + <h2> "Links and code" - </h1> + </h2> <p> "An external" <a href="https://example.org"> @@ -5,9 +5,9 @@ expression: report agreement: 64/82 skeleton lines (78.0%) (- org-ssg, + emacs) - <h1> + <h2> "Code and tables" - </h1> + </h2> <pre> - <code> "fn main() { println!(\"hello\"); }" @@ -47,9 +47,9 @@ agreement: 64/82 skeleton lines (78.0%) </tr> </tbody> </table> - <h1> + <h2> "Links and footnotes" - </h1> + </h2> <p> "An external link:" <a href="https://example.com"> @@ -71,9 +71,9 @@ agreement: 64/82 skeleton lines (78.0%) </a> </sup> </p> - <h1> + <h2> "Blocks" - </h1> + </h2> <blockquote> <p> "A quoted paragraph." @@ -5,35 +5,35 @@ expression: report agreement: 28/30 skeleton lines (93.3%) (- org-ssg, + emacs) - <h1> + <h2> - "TODO [#A] Write the parser work rust" + "TODO Write the parser work rust" - </h1> + </h2> <p> "A heading carrying a keyword, a priority, tags and a property drawer." </p> - <h2> + <h3> "DONE Nested and finished" - </h2> + </h3> <p> "Sub-headings nest by star count." </p> - <h2> + <h3> - "[#C] Priority without a keyword" + "Priority without a keyword" - </h2> + </h3> <p> "A priority cookie can stand alone." </p> - <h1> + <h2> "TODOs are not a keyword" - </h1> + </h2> <p> "The word boundary matters: this heading has no TODO keyword." </p> - <h1> + <h2> "DONE" - </h1> + </h2> <p> "A keyword with no title at all." </p> @@ -5,15 +5,15 @@ expression: report agreement: 28/42 skeleton lines (66.7%) (- org-ssg, + emacs) - <h1> + <h2> "Bare image" - </h1> + </h2> <p> <img src="diagram.png"> </p> - <h1> + <h2> "Captioned figure" - </h1> + </h2> - <figure> + <p> <img src="pipeline.svg"> @@ -25,9 +25,9 @@ agreement: 28/42 skeleton lines (66.7%) + <p> + "Figure 1: The pipeline, end to end" + </p> - <h1> + <h2> "Caption with markup" - </h1> + </h2> - <figure> + <p> <img src="chart.png"> @@ -45,17 +45,17 @@ agreement: 28/42 skeleton lines (66.7%) - </figcaption> - </figure> + </p> - <h1> + <h2> "Quoted attribute values" - </h1> + </h2> - <figure> + <p> <img src="cat.jpg"> - </figure> + </p> - <h1> + <h2> "Image with a description is a link" - </h1> + </h2> <p> <a href="diagram.png"> "the diagram" @@ -5,9 +5,9 @@ expression: report agreement: 100/111 skeleton lines (90.1%) (- org-ssg, + emacs) - <h1> + <h2> "Nesting" - </h1> + </h2> <ul> <li> "outer item" @@ -29,9 +29,9 @@ agreement: 100/111 skeleton lines (90.1%) "second outer" </li> </ul> - <h1> + <h2> "Ordered" - </h1> + </h2> <ol> <li> "first" @@ -51,9 +51,9 @@ agreement: 100/111 skeleton lines (90.1%) "third" </li> </ol> - <h1> + <h2> "Checkboxes" - </h1> + </h2> <ul> <li> - <input> @@ -77,9 +77,9 @@ agreement: 100/111 skeleton lines (90.1%) "partially done" </li> </ul> - <h1> + <h2> "Description" - </h1> + </h2> <dl> <dt> "term one" @@ -105,9 +105,9 @@ agreement: 100/111 skeleton lines (90.1%) "definitions hold inline markup" </dd> </dl> - <h1> + <h2> "Multi-paragraph items" - </h1> + </h2> <ul> <li> <p> @@ -8,9 +8,9 @@ agreement: 38/42 skeleton lines (90.5%) <p> "A single paragraph of preamble text before any heading." </p> - <h1> + <h2> "First Heading" - </h1> + </h2> <p> "Some body text with" - <strong> @@ -30,9 +30,9 @@ agreement: 38/42 skeleton lines (90.5%) </code> "." </p> - <h2> + <h3> "A Subheading tag1 tag2" - </h2> + </h3> <ul> <li> "an unordered item" @@ -41,9 +41,9 @@ agreement: 38/42 skeleton lines (90.5%) "another with a checkbox [ ]" </li> </ul> - <h1> + <h2> "Second Heading" - </h1> + </h2> <p> "See" <a href="#first"> @@ -5,9 +5,9 @@ expression: report agreement: 25/62 skeleton lines (40.3%) (- org-ssg, + emacs) - <h1> + <h2> "Single" - </h1> + </h2> <p> - "An active date" - <time> @@ -28,9 +28,9 @@ agreement: 25/62 skeleton lines (40.3%) - "." + "With a time: <2024-01-15 Mon 10:30>." </p> - <h1> + <h2> "Ranges" - </h1> + </h2> <p> - "A same-day time range" - <time> @@ -55,9 +55,9 @@ agreement: 25/62 skeleton lines (40.3%) - "." + "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>." </p> - <h1> + <h2> "Ignored decorations" - </h1> + </h2> <p> - "A repeater is dropped:" - <time> @@ -66,9 +66,9 @@ agreement: 25/62 skeleton lines (40.3%) - "." + "A repeater is dropped: <2024-01-15 Mon +1w>." </p> - <h1> + <h2> "Not timestamps" - </h1> + </h2> <p> "Comparisons like 3 < 4 and [not a stamp] stay literal text." </p> @@ -3,7 +3,7 @@ source: tests/pipeline.rs expression: "render_fixture(\"core.org\")" --- <p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p> -<h1 id="ordered-and-checked">Ordered and checked</h1> +<h2 id="ordered-and-checked">Ordered and checked</h2> <ol> <li>first item</li> <li>second item with <em>emphasis</em></li> @@ -12,7 +12,7 @@ expression: "render_fixture(\"core.org\")" <li><input type="checkbox" disabled> todo item</li> <li><input type="checkbox" disabled checked> done item</li> </ul> -<h1 id="links-and-code">Links and code</h1> +<h2 id="links-and-code">Links and code</h2> <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> <pre><code class="language-rust highlight"><span class="source rust"><span class="meta function rust"><span class="meta function rust"><span class="storage type function rust">fn</span> </span><span class="entity name function rust">main</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters begin rust">(</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters end rust">)</span></span></span></span><span class="meta function rust"> </span><span class="meta function rust"><span class="meta block rust"><span class="punctuation section block begin rust">{</span> <span class="support macro rust">println!</span><span class="meta group rust"><span class="punctuation section group begin rust">(</span></span><span class="meta group rust"><span class="string quoted double rust"><span class="punctuation definition string begin rust">"</span>hello<span class="punctuation definition string end rust">"</span></span></span><span class="meta group rust"><span class="punctuation section group end rust">)</span></span><span class="punctuation terminator rust">;</span> @@ -3,12 +3,12 @@ source: tests/pipeline.rs expression: "render_fixture(\"minimal.org\")" --- <p>A single paragraph of preamble text before any heading.</p> -<h1 id="first">First Heading</h1> +<h2 id="first">First Heading</h2> <p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p> -<h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2> +<h3 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h3> <ul> <li>an unordered item</li> <li>another with a checkbox [ ]</li> </ul> -<h1 id="second-heading">Second Heading</h1> +<h2 id="second-heading">Second Heading</h2> <p>See <a href="#first">the first heading</a>.</p> @@ -6,19 +6,24 @@ expression: "page(&pages, \"guide.org\").html" <html lang="en"> <head> <meta charset="utf-8"> -<title>Guide</title> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Guide · org-ssg site</title> <link rel="stylesheet" href="syntax.css"> </head> <body> +<header> +<a class="site-title" href="index.html">org-ssg site</a> <nav> <a href="about.html">About</a> <a href="#">Guide</a> <a href="index.html">Home</a> </nav> +</header> <main> -<h1 id="setup">Setup</h1> +<h1>Guide</h1> +<h2 id="setup">Setup</h2> <p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p> -<h1 id="data">Data</h1> +<h2 id="data">Data</h2> <table> <thead> <tr><th>Name</th><th>Score</th></tr> @@ -6,19 +6,24 @@ expression: "page(&pages, \"index.org\").html" <html lang="en"> <head> <meta charset="utf-8"> -<title>Home</title> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Home · org-ssg site</title> <link rel="stylesheet" href="syntax.css"> </head> <body> +<header> +<a class="site-title" href="index.html">org-ssg site</a> <nav> <a href="about.html">About</a> <a href="guide.html">Guide</a> <a href="#">Home</a> </nav> +</header> <main> +<h1>Home</h1> <p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p> <p>Also see <a href="#overview">Overview</a> further down this page.</p> -<h1 id="overview">Overview</h1> +<h2 id="overview">Overview</h2> <p>The overview lives on the home page.</p> </main> </body>