Commit 34e27a5061
Verified · cmc
Layout: unified · split
Cargo.lock +56 −1
| @@ -569,7 +569,7 @@ dependencies = [ | |||
| 569 | 569 | ||
| 570 | [[package]] | 570 | [[package]] |
| 571 | name = "org-ssg" | 571 | name = "org-ssg" |
| 572 | version = "0.5.0" | 572 | version = "0.6.0" |
| 573 | dependencies = [ | 573 | dependencies = [ |
| 574 | "anyhow", | 574 | "anyhow", |
| 575 | "blake3", | 575 | "blake3", |
| @@ -583,6 +583,7 @@ dependencies = [ | |||
| 583 | "serde_json", | 583 | "serde_json", |
| 584 | "syntect", | 584 | "syntect", |
| 585 | "thiserror", | 585 | "thiserror", |
| 586 | "toml", | ||
| 586 | "walkdir", | 587 | "walkdir", |
| 587 | ] | 588 | ] |
| 588 | 589 | ||
| @@ -747,6 +748,15 @@ dependencies = [ | |||
| 747 | "zmij", | 748 | "zmij", |
| 748 | ] | 749 | ] |
| 749 | 750 | ||
| 751 | [[package]] | ||
| 752 | name = "serde_spanned" | ||
| 753 | version = "1.1.1" | ||
| 754 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 755 | checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" | ||
| 756 | dependencies = [ | ||
| 757 | "serde_core", | ||
| 758 | ] | ||
| 759 | |||
| 750 | [[package]] | 760 | [[package]] |
| 751 | name = "shlex" | 761 | name = "shlex" |
| 752 | version = "2.0.1" | 762 | version = "2.0.1" |
| @@ -883,6 +893,45 @@ dependencies = [ | |||
| 883 | "time-core", | 893 | "time-core", |
| 884 | ] | 894 | ] |
| 885 | 895 | ||
| 896 | [[package]] | ||
| 897 | name = "toml" | ||
| 898 | version = "1.1.4+spec-1.1.0" | ||
| 899 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 900 | checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" | ||
| 901 | dependencies = [ | ||
| 902 | "indexmap", | ||
| 903 | "serde_core", | ||
| 904 | "serde_spanned", | ||
| 905 | "toml_datetime", | ||
| 906 | "toml_parser", | ||
| 907 | "toml_writer", | ||
| 908 | "winnow", | ||
| 909 | ] | ||
| 910 | |||
| 911 | [[package]] | ||
| 912 | name = "toml_datetime" | ||
| 913 | version = "1.1.1+spec-1.1.0" | ||
| 914 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 915 | checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" | ||
| 916 | dependencies = [ | ||
| 917 | "serde_core", | ||
| 918 | ] | ||
| 919 | |||
| 920 | [[package]] | ||
| 921 | name = "toml_parser" | ||
| 922 | version = "1.1.3+spec-1.1.0" | ||
| 923 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 924 | checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" | ||
| 925 | dependencies = [ | ||
| 926 | "winnow", | ||
| 927 | ] | ||
| 928 | |||
| 929 | [[package]] | ||
| 930 | name = "toml_writer" | ||
| 931 | version = "1.1.2+spec-1.1.0" | ||
| 932 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 933 | checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" | ||
| 934 | |||
| 886 | [[package]] | 935 | [[package]] |
| 887 | name = "unicode-ident" | 936 | name = "unicode-ident" |
| 888 | version = "1.0.24" | 937 | version = "1.0.24" |
| @@ -1027,6 +1076,12 @@ dependencies = [ | |||
| 1027 | "windows-link", | 1076 | "windows-link", |
| 1028 | ] | 1077 | ] |
| 1029 | 1078 | ||
| 1079 | [[package]] | ||
| 1080 | name = "winnow" | ||
| 1081 | version = "1.0.4" | ||
| 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1083 | checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" | ||
| 1084 | |||
| 1030 | [[package]] | 1085 | [[package]] |
| 1031 | name = "yaml-rust" | 1086 | name = "yaml-rust" |
| 1032 | version = "0.4.5" | 1087 | version = "0.4.5" |
Cargo.toml +2 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.5.0" | 3 | version = "0.6.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 6 | license = "MIT" |
| @@ -31,6 +31,7 @@ clap = { version = "4", features = ["derive"] } | |||
| 31 | anyhow = "1" | 31 | anyhow = "1" |
| 32 | thiserror = "2" | 32 | thiserror = "2" |
| 33 | rayon = "1.12.0" | 33 | rayon = "1.12.0" |
| 34 | toml = "1.1.4" | ||
| 34 | 35 | ||
| 35 | [dev-dependencies] | 36 | [dev-dependencies] |
| 36 | insta = { version = "1", features = ["json"] } | 37 | insta = { version = "1", features = ["json"] } |
README.md +93 −8
| @@ -13,6 +13,86 @@ hashing**, treated as a first-class architectural concern from day one. The disc | |||
| 13 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real | 13 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real |
| 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. | 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. |
| 15 | 15 | ||
| 16 | ## Quick start | ||
| 17 | |||
| 18 | ```bash | ||
| 19 | cargo run -- init my-site # config + an editable copy of the layout + a page | ||
| 20 | cargo run -- build my-site -o _site | ||
| 21 | ``` | ||
| 22 | |||
| 23 | Or skip the scaffolding entirely — point it at any directory of `.org` files: | ||
| 24 | |||
| 25 | ```bash | ||
| 26 | cargo run -- build ~/notes -o _site | ||
| 27 | ``` | ||
| 28 | |||
| 29 | **Zero configuration is a supported path, not a demo.** With no `org-ssg.toml`, no | ||
| 30 | templates and no org-ssg-specific markup in your files, you get a complete site: pages, | ||
| 31 | navigation, syntax-highlighted code and the stylesheet to colour it. Configuration | ||
| 32 | changes what you get; it is never what makes it work. | ||
| 33 | |||
| 34 | Discovery skips what should not be published — dot-directories such as `.git`, the config | ||
| 35 | file, the templates directory, and the output directory when it sits inside the source, so | ||
| 36 | `org-ssg build . -o _site` does the obvious thing. | ||
| 37 | |||
| 38 | ## Configuration | ||
| 39 | |||
| 40 | Everything is optional. `org-ssg init` writes a fully commented `org-ssg.toml`; every | ||
| 41 | value below is the default. | ||
| 42 | |||
| 43 | ```toml | ||
| 44 | [site] | ||
| 45 | title = "org-ssg site" | ||
| 46 | base_url = "" # absolute URL, no trailing slash; empty = relative URLs only | ||
| 47 | description = "" | ||
| 48 | language = "en" | ||
| 49 | |||
| 50 | [nav] | ||
| 51 | mode = "top-level" # top-level | all | explicit | none | ||
| 52 | # pages = ["index.org", "about.org"] # for mode = "explicit"; order is preserved | ||
| 53 | |||
| 54 | [templates] | ||
| 55 | dir = "templates" # base.html replaces the built-in layout | ||
| 56 | expose_page_list = false | ||
| 57 | |||
| 58 | [highlight] | ||
| 59 | theme = "InspiredGitHub" | ||
| 60 | |||
| 61 | [html] | ||
| 62 | heading_offset = 1 # a level-1 org heading becomes <h2>, beneath the layout's <h1> | ||
| 63 | ``` | ||
| 64 | |||
| 65 | ### Templates | ||
| 66 | |||
| 67 | Drop a `base.html` into the templates directory and it replaces the built-in layout | ||
| 68 | entirely. Any other `.html` file there is available to `{% include %}` and | ||
| 69 | `{% extends %}`. Templates are [minijinja](https://docs.rs/minijinja) (Jinja2 syntax) and | ||
| 70 | receive: | ||
| 71 | |||
| 72 | | Variable | What it is | | ||
| 73 | |---|---| | ||
| 74 | | `body` | the rendered page HTML — use `{{ body \| safe }}` | | ||
| 75 | | `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` | | ||
| 76 | | `site` | `.title`, `.base_url`, `.description`, `.language` | | ||
| 77 | | `nav` | list of `{title, url}`, relative to this page | | ||
| 78 | | `root` | `../`-prefix back to the site root from this page | | ||
| 79 | | `stylesheet` | URL of the generated `syntax.css` | | ||
| 80 | | `pages` | every page's metadata — only when `expose_page_list = true` | | ||
| 81 | |||
| 82 | `page.keywords` carries **every** `#+KEYWORD:` in the file under its lowercased name, so | ||
| 83 | your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is | ||
| 84 | `{{ page.keywords.custom_thing }}`. | ||
| 85 | |||
| 86 | Editing a template re-renders the pages that use it — template sources are a hash input, | ||
| 87 | so a design change never leaves a site half-updated. | ||
| 88 | |||
| 89 | ### `#+SLUG:` | ||
| 90 | |||
| 91 | A page's output filename comes from its `#+SLUG:` when it has one, so | ||
| 92 | `2018-11-28-aes-encryption.org` can publish as `aes-encryption.html`. Without one the | ||
| 93 | source filename is used. Slugs are sanitized to a single safe path component, and two | ||
| 94 | pages claiming one URL is a build error rather than a silently dropped page. | ||
| 95 | |||
| 16 | ## Pipeline | 96 | ## Pipeline |
| 17 | 97 | ||
| 18 | ``` | 98 | ``` |
| @@ -24,6 +104,7 @@ is the only inherently global stage — it is where the link dependency graph is | |||
| 24 | 104 | ||
| 25 | | Stage | Module | Notes | | 105 | | Stage | Module | Notes | |
| 26 | |---|---|---| | 106 | |---|---|---| |
| 107 | | config | `src/config.rs` | `org-ssg.toml`: site metadata, nav mode, templates, theme. A hash input. | | ||
| 27 | | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | | 108 | | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | |
| 28 | | audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | | 109 | | audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | |
| 29 | | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | | 110 | | 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 | |||
| 72 | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | | 153 | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | |
| 73 | | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | | 154 | | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | |
| 74 | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | | 155 | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | |
| 156 | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | | ||
| 75 | 157 | ||
| 76 | ### v0.2 in / out | 158 | ### v0.2 in / out |
| 77 | 159 | ||
| @@ -173,9 +255,12 @@ and the `watch` fs-notify integration. | |||
| 173 | The v1 scope was, by its own admission, *recommended* — a guess about which slice of org | 255 | The v1 scope was, by its own admission, *recommended* — a guess about which slice of org |
| 174 | matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks | 256 | matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks |
| 175 | what a real corpus actually uses, and an oracle that asks whether we render it the way | 257 | what a real corpus actually uses, and an oracle that asks whether we render it the way |
| 176 | Emacs does. The corpus is the 179 files behind [cleberg.net](https://cleberg.net), which is | 258 | Emacs does. |
| 177 | published today by weblorg — a wrapper around org's own HTML exporter. That makes it both | 259 | |
| 178 | the workload and the incumbent. | 260 | The audit runs against any corpus — point it at your own notes before trusting this tool |
| 261 | with them. The numbers below come from a 179-file site published today by weblorg, a | ||
| 262 | wrapper around org's own HTML exporter, which makes it both a realistic workload and a | ||
| 263 | directly comparable incumbent. | ||
| 179 | 264 | ||
| 180 | ``` | 265 | ``` |
| 181 | cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? | 266 | 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 | |||
| 309 | title still invalidates everything, correctly, since every page displays it. | 394 | title still invalidates everything, correctly, since every page displays it. |
| 310 | 395 | ||
| 311 | **Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely | 396 | **Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely |
| 312 | root-level pages appear. cleberg.net keeps its landing pages at `content/salary/index.org` | 397 | root-level pages appear — a site keeping its landing pages at `salary/index.org` and friends |
| 313 | and friends, so its nav comes out as a single `index.org` entry where the live site shows | 398 | gets a one-entry nav. That is what `nav.mode = "explicit"` is for: list the pages you want, |
| 314 | four. Treating a directory's `index.org` as top-level too is a one-line change to | 399 | in the order you want them. |
| 315 | `is_top_level` if that is the behaviour you want. | ||
| 316 | 400 | ||
| 317 | **From v0.1 (core subset):** headings with nesting and anchors (every heading is now | 401 | **From v0.1 (core subset):** headings with nesting and anchors (every heading is now |
| 318 | anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs; | 402 | 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 | |||
| 332 | 416 | ||
| 333 | ``` | 417 | ``` |
| 334 | cargo build | 418 | cargo build |
| 335 | cargo test | 419 | cargo test # 86 tests |
| 420 | cargo run -- init my-site # scaffold a new site | ||
| 336 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 421 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 337 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 422 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
| 338 | cargo run -- audit fixtures/site # corpus audit (Phase 0) | 423 | cargo run -- audit fixtures/site # corpus audit (Phase 0) |
src/config.rs added +239
| @@ -0,0 +1,239 @@ | |||
| 1 | //! User-facing build configuration (`org-ssg.toml`). | ||
| 2 | //! | ||
| 3 | //! Everything here was once a constant in the source: the page layout, the nav rule, the | ||
| 4 | //! highlighting theme. That made the generator produce exactly one kind of site — a | ||
| 5 | //! reasonable place to start from, and a dead end for anyone whose site is not that one. | ||
| 6 | //! | ||
| 7 | //! Two properties matter beyond the settings themselves: | ||
| 8 | //! | ||
| 9 | //! 1. **Absent config is a valid config.** Every field has a default, so a directory of | ||
| 10 | //! `.org` files with no `org-ssg.toml` still builds. Configuration is how you change | ||
| 11 | //! the output, never how you make it work at all. | ||
| 12 | //! 2. **Config is a hash input** (spec §4.1). [`Config`] serializes deterministically and | ||
| 13 | //! its hash is folded into every page's render key, so editing `org-ssg.toml` re-renders | ||
| 14 | //! exactly the pages it affects — which for most settings is all of them. | ||
| 15 | |||
| 16 | use anyhow::{Context, Result}; | ||
| 17 | use camino::{Utf8Path, Utf8PathBuf}; | ||
| 18 | use serde::{Deserialize, Serialize}; | ||
| 19 | |||
| 20 | /// The config file's name, looked for in the source directory. | ||
| 21 | pub const CONFIG_FILE: &str = "org-ssg.toml"; | ||
| 22 | |||
| 23 | /// Resolved build configuration. Serialized into the config hash, so field order and | ||
| 24 | /// defaults are part of the cache contract. | ||
| 25 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] | ||
| 26 | #[serde(default, deny_unknown_fields)] | ||
| 27 | pub struct Config { | ||
| 28 | pub site: Site, | ||
| 29 | pub nav: Nav, | ||
| 30 | pub templates: Templates, | ||
| 31 | pub highlight: Highlight, | ||
| 32 | pub html: HtmlOutput, | ||
| 33 | } | ||
| 34 | |||
| 35 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 36 | #[serde(default, deny_unknown_fields)] | ||
| 37 | pub struct HtmlOutput { | ||
| 38 | /// How far to push heading levels down: a level-1 org heading becomes | ||
| 39 | /// `<h{1 + heading_offset}>`. | ||
| 40 | /// | ||
| 41 | /// Defaults to 1, matching Emacs' own `org-html-toplevel-hlevel`, because the page | ||
| 42 | /// layout supplies the `<h1>` — the document's title — and section headings sit | ||
| 43 | /// beneath it. Set to 0 if your template renders no title of its own, so the | ||
| 44 | /// document does not start at `<h2>` with nothing above it. | ||
| 45 | pub heading_offset: u8, | ||
| 46 | } | ||
| 47 | |||
| 48 | impl Default for HtmlOutput { | ||
| 49 | fn default() -> Self { | ||
| 50 | HtmlOutput { heading_offset: 1 } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Site-wide metadata, exposed to templates as `site`. | ||
| 55 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 56 | #[serde(default, deny_unknown_fields)] | ||
| 57 | pub struct Site { | ||
| 58 | /// Shown in the default layout's header and available as `site.title`. | ||
| 59 | pub title: String, | ||
| 60 | /// Absolute base URL (no trailing slash), for feeds and canonical links. Empty means | ||
| 61 | /// the site is built with relative URLs only, which is the portable default. | ||
| 62 | pub base_url: String, | ||
| 63 | /// Free-form description, available as `site.description`. | ||
| 64 | pub description: String, | ||
| 65 | /// `<html lang="…">` in the default layout. | ||
| 66 | pub language: String, | ||
| 67 | } | ||
| 68 | |||
| 69 | impl Default for Site { | ||
| 70 | fn default() -> Self { | ||
| 71 | Site { | ||
| 72 | title: "org-ssg site".to_string(), | ||
| 73 | base_url: String::new(), | ||
| 74 | description: String::new(), | ||
| 75 | language: "en".to_string(), | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Which pages appear in the shared navigation. | ||
| 81 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | ||
| 82 | #[serde(rename_all = "kebab-case")] | ||
| 83 | pub enum NavMode { | ||
| 84 | /// Pages at the site root. A nav is a map of the top level, not an index of the | ||
| 85 | /// whole site, and this keeps nav size independent of how many pages exist. | ||
| 86 | #[default] | ||
| 87 | TopLevel, | ||
| 88 | /// Every page. Fine for a small site; note that it makes total output quadratic in | ||
| 89 | /// page count, since each of `n` pages then carries `n` nav links. | ||
| 90 | All, | ||
| 91 | /// Only the pages listed in `nav.pages`, in that order. | ||
| 92 | Explicit, | ||
| 93 | /// No navigation at all. | ||
| 94 | None, | ||
| 95 | } | ||
| 96 | |||
| 97 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] | ||
| 98 | #[serde(default, deny_unknown_fields)] | ||
| 99 | pub struct Nav { | ||
| 100 | pub mode: NavMode, | ||
| 101 | /// Source paths (relative to the source root, e.g. `about.org`) used when | ||
| 102 | /// `mode = "explicit"`. Order is preserved, so this doubles as nav ordering. | ||
| 103 | pub pages: Vec<Utf8PathBuf>, | ||
| 104 | } | ||
| 105 | |||
| 106 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 107 | #[serde(default, deny_unknown_fields)] | ||
| 108 | pub struct Templates { | ||
| 109 | /// Directory of `.html` templates, relative to the source root. Each file is | ||
| 110 | /// registered under its stem, so `base.html` overrides the built-in layout and | ||
| 111 | /// anything else is available to `{% include %}`/`{% extends %}`. | ||
| 112 | pub dir: Utf8PathBuf, | ||
| 113 | /// Give templates a `pages` list of every page's metadata, so a template can build | ||
| 114 | /// an index or archive. | ||
| 115 | /// | ||
| 116 | /// Off by default because it is not free: if any page can read every page's | ||
| 117 | /// metadata, then adding one page can change any page's output, so the whole site | ||
| 118 | /// must re-render on every add, rename or retitle. Turning this on trades that | ||
| 119 | /// incremental precision for the ability to write listing pages. | ||
| 120 | pub expose_page_list: bool, | ||
| 121 | } | ||
| 122 | |||
| 123 | impl Default for Templates { | ||
| 124 | fn default() -> Self { | ||
| 125 | Templates { | ||
| 126 | dir: Utf8PathBuf::from("templates"), | ||
| 127 | expose_page_list: false, | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 133 | #[serde(default, deny_unknown_fields)] | ||
| 134 | pub struct Highlight { | ||
| 135 | /// A syntect built-in theme name — `InspiredGitHub`, `Solarized (dark)`, | ||
| 136 | /// `base16-ocean.dark`, `base16-eighties.dark`, `base16-mocha.dark`, | ||
| 137 | /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the | ||
| 138 | /// generated `syntax.css` colours them with. | ||
| 139 | pub theme: String, | ||
| 140 | } | ||
| 141 | |||
| 142 | impl Default for Highlight { | ||
| 143 | fn default() -> Self { | ||
| 144 | Highlight { | ||
| 145 | theme: "InspiredGitHub".to_string(), | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | impl Config { | ||
| 151 | /// Load `org-ssg.toml` from `dir`, or return defaults if there is none. | ||
| 152 | /// | ||
| 153 | /// A *missing* config is normal and silent. A *malformed* one is an error: someone | ||
| 154 | /// who wrote a config meant it, and silently building the default site would hide | ||
| 155 | /// their typo behind plausible-looking output. | ||
| 156 | pub fn load(dir: &Utf8Path) -> Result<Config> { | ||
| 157 | Self::load_file(&dir.join(CONFIG_FILE)) | ||
| 158 | } | ||
| 159 | |||
| 160 | /// Load a config from an explicit path. Missing is still fine; malformed is not. | ||
| 161 | pub fn load_file(path: &Utf8Path) -> Result<Config> { | ||
| 162 | let text = match std::fs::read_to_string(path) { | ||
| 163 | Ok(text) => text, | ||
| 164 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), | ||
| 165 | Err(e) => return Err(e).with_context(|| format!("reading {path}")), | ||
| 166 | }; | ||
| 167 | toml::from_str(&text).with_context(|| format!("parsing {path}")) | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Validate settings that only make sense in combination. Catching these up front | ||
| 171 | /// beats emitting a site with a silently empty nav. | ||
| 172 | pub fn validate(&self) -> Result<()> { | ||
| 173 | if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() { | ||
| 174 | anyhow::bail!( | ||
| 175 | "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \ | ||
| 176 | include, or use mode = \"top-level\"/\"all\"/\"none\"" | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() { | ||
| 180 | anyhow::bail!( | ||
| 181 | "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \ | ||
| 182 | mode = \"explicit\" to use it", | ||
| 183 | toml::to_string(&self.nav.mode) | ||
| 184 | .unwrap_or_default() | ||
| 185 | .trim() | ||
| 186 | .trim_matches('"') | ||
| 187 | ); | ||
| 188 | } | ||
| 189 | if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { | ||
| 190 | anyhow::bail!( | ||
| 191 | "site.base_url must not end with a slash (got {:?}) — URLs are joined \ | ||
| 192 | with an explicit separator", | ||
| 193 | self.site.base_url | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | Ok(()) | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | /// The starter config written by `org-ssg init`, and the documentation of record for | ||
| 201 | /// what is configurable. Every value shown is the default, so deleting any line is safe. | ||
| 202 | pub const STARTER_CONFIG: &str = r#"# org-ssg configuration. Every setting here is optional and shown at its default, | ||
| 203 | # so you can delete any line you do not need — or the whole file. | ||
| 204 | |||
| 205 | [site] | ||
| 206 | title = "org-ssg site" | ||
| 207 | # Absolute base URL, no trailing slash. Leave empty to build with relative URLs only. | ||
| 208 | base_url = "" | ||
| 209 | description = "" | ||
| 210 | language = "en" | ||
| 211 | |||
| 212 | [nav] | ||
| 213 | # Which pages appear in the shared navigation: | ||
| 214 | # "top-level" — pages at the site root (default; keeps nav size independent of site size) | ||
| 215 | # "all" — every page (fine when small; output grows quadratically with page count) | ||
| 216 | # "explicit" — only nav.pages, in the order listed | ||
| 217 | # "none" — no navigation | ||
| 218 | mode = "top-level" | ||
| 219 | # pages = ["index.org", "about.org"] | ||
| 220 | |||
| 221 | [templates] | ||
| 222 | # Directory of .html templates, relative to this file. `base.html` replaces the built-in | ||
| 223 | # layout; any other file can be pulled in with {% include %} or {% extends %}. | ||
| 224 | dir = "templates" | ||
| 225 | # Give templates a `pages` list of every page's metadata, so you can build an index or | ||
| 226 | # archive. Costs incremental precision: with this on, adding a page re-renders the site. | ||
| 227 | expose_page_list = false | ||
| 228 | |||
| 229 | [highlight] | ||
| 230 | # A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark, | ||
| 231 | # base16-eighties.dark, base16-mocha.dark, base16-ocean.light. | ||
| 232 | theme = "InspiredGitHub" | ||
| 233 | |||
| 234 | [html] | ||
| 235 | # How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>. | ||
| 236 | # The default of 1 matches Emacs, and assumes your layout renders the page title as the | ||
| 237 | # <h1>. Set to 0 if your template renders no title of its own. | ||
| 238 | heading_offset = 1 | ||
| 239 | "#; | ||
src/incremental.rs +5 −19
| @@ -34,24 +34,10 @@ pub const CACHE_FORMAT_VERSION: u32 = 4; | |||
| 34 | /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). | 34 | /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). |
| 35 | pub type Hash = ContentHash; | 35 | pub type Hash = ContentHash; |
| 36 | 36 | ||
| 37 | /// Resolved global build config. Its hash is a component of every page's render key | 37 | /// The resolved global build config is [`crate::config::Config`]; its hash is a |
| 38 | /// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 — | 38 | /// component of every page's render key (spec §4.1), so editing `org-ssg.toml` |
| 39 | /// there is no user-facing config yet — but structured so real knobs (base URL, TODO | 39 | /// invalidates the pages it affects. |
| 40 | /// keyword set, highlighter theme id, inline features) flow into the hash when added. | 40 | pub use crate::config::Config as BuildConfig; |
| 41 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 42 | pub struct BuildConfig { | ||
| 43 | pub output_extension: String, | ||
| 44 | pub highlighter_theme: String, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl Default for BuildConfig { | ||
| 48 | fn default() -> Self { | ||
| 49 | BuildConfig { | ||
| 50 | output_extension: "html".to_string(), | ||
| 51 | highlighter_theme: crate::render::SYNTAX_THEME.to_string(), | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | 41 | ||
| 56 | /// Compose bytes into a blake3 hash. The one place hashing happens for composite keys. | 42 | /// Compose bytes into a blake3 hash. The one place hashing happens for composite keys. |
| 57 | fn hash_bytes(bytes: &[u8]) -> Hash { | 43 | fn hash_bytes(bytes: &[u8]) -> Hash { |
| @@ -93,7 +79,7 @@ pub fn site_structure_hash(entries: &[(String, String)]) -> Hash { | |||
| 93 | /// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over | 79 | /// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over |
| 94 | /// all templates; when partials land, split this per-template so a single-partial edit | 80 | /// all templates; when partials land, split this per-template so a single-partial edit |
| 95 | /// invalidates only its users. | 81 | /// invalidates only its users. |
| 96 | pub fn template_hash(sources: &[(&str, &str)]) -> Hash { | 82 | pub fn template_hash(sources: &[(String, String)]) -> Hash { |
| 97 | let mut hasher = blake3::Hasher::new(); | 83 | let mut hasher = blake3::Hasher::new(); |
| 98 | for (name, src) in sources { | 84 | for (name, src) in sources { |
| 99 | hasher.update(name.as_bytes()); | 85 | hasher.update(name.as_bytes()); |
src/lib.rs +1
| @@ -9,6 +9,7 @@ | |||
| 9 | //! deciding which pages actually need rewriting. | 9 | //! deciding which pages actually need rewriting. |
| 10 | 10 | ||
| 11 | pub mod audit; | 11 | pub mod audit; |
| 12 | pub mod config; | ||
| 12 | pub mod incremental; | 13 | pub mod incremental; |
| 13 | pub mod index; | 14 | pub mod index; |
| 14 | pub mod model; | 15 | pub mod model; |
src/main.rs +101 −7
| @@ -7,10 +7,11 @@ use camino::{Utf8Path, Utf8PathBuf}; | |||
| 7 | use clap::{Parser, Subcommand}; | 7 | use clap::{Parser, Subcommand}; |
| 8 | 8 | ||
| 9 | use org_ssg::parser::parse; | 9 | use org_ssg::parser::parse; |
| 10 | use org_ssg::render::{render, syntax_css, Html, SyntectHighlighter}; | 10 | use org_ssg::config::Config; |
| 11 | use org_ssg::render::{self, render, Html, SyntectHighlighter}; | ||
| 11 | use org_ssg::resolve::ResolvedDoc; | 12 | use org_ssg::resolve::ResolvedDoc; |
| 12 | use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; | 13 | use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; |
| 13 | use org_ssg::template::Templater; | 14 | use org_ssg::template::{PageContext, SiteContext, Templater}; |
| 14 | 15 | ||
| 15 | #[derive(Parser)] | 16 | #[derive(Parser)] |
| 16 | #[command(name = "org-ssg", version, about = "Org-mode static site generator")] | 17 | #[command(name = "org-ssg", version, about = "Org-mode static site generator")] |
| @@ -32,9 +33,12 @@ enum Command { | |||
| 32 | /// Bypass the incremental cache and re-render every page (spec §4.5). | 33 | /// Bypass the incremental cache and re-render every page (spec §4.5). |
| 33 | #[arg(long)] | 34 | #[arg(long)] |
| 34 | no_cache: bool, | 35 | no_cache: bool, |
| 35 | /// Treat broken internal links as errors (spec §4.3.4). | 36 | /// Treat broken links and parse diagnostics as errors (spec §4.3.4). |
| 36 | #[arg(long)] | 37 | #[arg(long)] |
| 37 | strict: bool, | 38 | strict: bool, |
| 39 | /// Config file to use, overriding `org-ssg.toml` in the source directory. | ||
| 40 | #[arg(long, value_name = "FILE")] | ||
| 41 | config: Option<Utf8PathBuf>, | ||
| 38 | }, | 42 | }, |
| 39 | /// Watch a source directory and rebuild incrementally on change (simple poll loop). | 43 | /// Watch a source directory and rebuild incrementally on change (simple poll loop). |
| 40 | Watch { | 44 | Watch { |
| @@ -55,6 +59,13 @@ enum Command { | |||
| 55 | /// Source directory (or single `.org` file) to audit. | 59 | /// Source directory (or single `.org` file) to audit. |
| 56 | input: Utf8PathBuf, | 60 | input: Utf8PathBuf, |
| 57 | }, | 61 | }, |
| 62 | /// Scaffold a new site: config, an editable copy of the default layout, and a page. | ||
| 63 | Init { | ||
| 64 | /// Directory to create the site in (created if missing; defaults to the | ||
| 65 | /// current directory). | ||
| 66 | #[arg(default_value = ".")] | ||
| 67 | directory: Utf8PathBuf, | ||
| 68 | }, | ||
| 58 | } | 69 | } |
| 59 | 70 | ||
| 60 | fn main() -> Result<()> { | 71 | fn main() -> Result<()> { |
| @@ -65,11 +76,16 @@ fn main() -> Result<()> { | |||
| 65 | output, | 76 | output, |
| 66 | no_cache, | 77 | no_cache, |
| 67 | strict, | 78 | strict, |
| 79 | config, | ||
| 68 | } => { | 80 | } => { |
| 69 | if input.is_dir() { | 81 | if input.is_dir() { |
| 70 | let out = output | 82 | let out = output |
| 71 | .context("site build requires an output directory: build <src-dir> -o <out-dir>")?; | 83 | .context("site build requires an output directory: build <src-dir> -o <out-dir>")?; |
| 72 | let opts = BuildOptions { no_cache, strict }; | 84 | let opts = BuildOptions { |
| 85 | no_cache, | ||
| 86 | strict, | ||
| 87 | config_path: config.clone(), | ||
| 88 | }; | ||
| 73 | let report = build_site(&input, &out, &opts)?; | 89 | let report = build_site(&input, &out, &opts)?; |
| 74 | println!( | 90 | println!( |
| 75 | "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))", | 91 | "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))", |
| @@ -98,6 +114,7 @@ fn main() -> Result<()> { | |||
| 98 | print!("{}", org_ssg::audit::report(&result)); | 114 | print!("{}", org_ssg::audit::report(&result)); |
| 99 | Ok(()) | 115 | Ok(()) |
| 100 | } | 116 | } |
| 117 | Command::Init { directory } => init(&directory), | ||
| 101 | Command::Clean { output } => { | 118 | Command::Clean { output } => { |
| 102 | if output.exists() { | 119 | if output.exists() { |
| 103 | fs::remove_dir_all(&output) | 120 | fs::remove_dir_all(&output) |
| @@ -111,6 +128,56 @@ fn main() -> Result<()> { | |||
| 111 | } | 128 | } |
| 112 | } | 129 | } |
| 113 | 130 | ||
| 131 | /// Scaffold a working site. Writes only files that do not already exist, so running it | ||
| 132 | /// in a directory that has content is safe and additive rather than destructive. | ||
| 133 | fn init(dir: &Utf8Path) -> Result<()> { | ||
| 134 | use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG}; | ||
| 135 | use org_ssg::template::starter_template; | ||
| 136 | |||
| 137 | fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; | ||
| 138 | fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; | ||
| 139 | |||
| 140 | let index = concat!( | ||
| 141 | "#+TITLE: Hello\n", | ||
| 142 | "#+DATE: today\n", | ||
| 143 | "\n", | ||
| 144 | "Welcome to your new site. Edit this file, then run the build again.\n", | ||
| 145 | "\n", | ||
| 146 | "* A heading\n", | ||
| 147 | "\n", | ||
| 148 | "Org markup works as you would expect: *bold*, /italic/, ~code~, and\n", | ||
| 149 | "[[https://orgmode.org][links]].\n", | ||
| 150 | "\n", | ||
| 151 | "#+BEGIN_SRC rust\n", | ||
| 152 | "fn main() {\n", | ||
| 153 | " println!(\"syntax highlighting is on by default\");\n", | ||
| 154 | "}\n", | ||
| 155 | "#+END_SRC\n", | ||
| 156 | ); | ||
| 157 | |||
| 158 | let files: [(Utf8PathBuf, &str); 3] = [ | ||
| 159 | (dir.join(CONFIG_FILE), STARTER_CONFIG), | ||
| 160 | (dir.join("templates/base.html"), starter_template()), | ||
| 161 | (dir.join("index.org"), index), | ||
| 162 | ]; | ||
| 163 | |||
| 164 | let mut created = Vec::new(); | ||
| 165 | for (path, contents) in &files { | ||
| 166 | if path.exists() { | ||
| 167 | println!("kept existing {path}"); | ||
| 168 | continue; | ||
| 169 | } | ||
| 170 | fs::write(path, contents).with_context(|| format!("writing {path}"))?; | ||
| 171 | created.push(path.clone()); | ||
| 172 | } | ||
| 173 | |||
| 174 | for path in &created { | ||
| 175 | println!("created {path}"); | ||
| 176 | } | ||
| 177 | println!("\nNext: org-ssg build {dir} -o _site"); | ||
| 178 | Ok(()) | ||
| 179 | } | ||
| 180 | |||
| 114 | /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. | 181 | /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. |
| 115 | /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. | 182 | /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. |
| 116 | fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | 183 | fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { |
| @@ -187,13 +254,40 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | |||
| 187 | let highlighter = SyntectHighlighter::new(); | 254 | let highlighter = SyntectHighlighter::new(); |
| 188 | let Html(fragment) = render(&resolved, &highlighter); | 255 | let Html(fragment) = render(&resolved, &highlighter); |
| 189 | 256 | ||
| 190 | let templater = Templater::new(); | 257 | // A single-file build still honours a config beside the source, so `build one.org` |
| 258 | // and a whole-site build produce the same-looking page. | ||
| 259 | let dir = input.parent().unwrap_or_else(|| Utf8Path::new(".")); | ||
| 260 | let config = Config::load(dir)?; | ||
| 261 | config.validate()?; | ||
| 262 | let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?; | ||
| 263 | let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| { | ||
| 264 | anyhow::anyhow!( | ||
| 265 | "unknown highlight.theme {:?}. Available: {}", | ||
| 266 | config.highlight.theme, | ||
| 267 | render::available_themes().join(", ") | ||
| 268 | ) | ||
| 269 | })?; | ||
| 270 | |||
| 271 | let site = SiteContext { | ||
| 272 | title: config.site.title.clone(), | ||
| 273 | base_url: config.site.base_url.clone(), | ||
| 274 | description: config.site.description.clone(), | ||
| 275 | language: config.site.language.clone(), | ||
| 276 | }; | ||
| 277 | let page_ctx = PageContext { | ||
| 278 | title: title.clone(), | ||
| 279 | url: output.file_name().unwrap_or("index.html").to_string(), | ||
| 280 | source: input.to_string(), | ||
| 281 | date: None, | ||
| 282 | tags: Vec::new(), | ||
| 283 | keywords: Default::default(), | ||
| 284 | }; | ||
| 191 | let page = templater | 285 | let page = templater |
| 192 | .render_page(&title, &fragment, &[], SYNTAX_STYLESHEET) | 286 | .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None) |
| 193 | .with_context(|| format!("templating {input}"))?; | 287 | .with_context(|| format!("templating {input}"))?; |
| 194 | fs::write(output, page).with_context(|| format!("writing output file {output}"))?; | 288 | fs::write(output, page).with_context(|| format!("writing output file {output}"))?; |
| 195 | 289 | ||
| 196 | let css = output.with_file_name(SYNTAX_STYLESHEET); | 290 | let css = output.with_file_name(SYNTAX_STYLESHEET); |
| 197 | fs::write(&css, syntax_css()).with_context(|| format!("writing stylesheet {css}"))?; | 291 | fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?; |
| 198 | Ok(()) | 292 | Ok(()) |
| 199 | } | 293 | } |
src/render.rs +45 −19
| @@ -42,11 +42,6 @@ pub trait Highlighter { | |||
| 42 | /// two must agree or the CSS will not match the markup. | 42 | /// two must agree or the CSS will not match the markup. |
| 43 | const CLASS_STYLE: ClassStyle = ClassStyle::Spaced; | 43 | const CLASS_STYLE: ClassStyle = ClassStyle::Spaced; |
| 44 | 44 | ||
| 45 | /// The syntect theme whose colours become [`syntax_css`]. Mirrored in | ||
| 46 | /// [`BuildConfig::highlighter_theme`](crate::incremental::BuildConfig) so a theme change | ||
| 47 | /// flows into the config hash and invalidates every page. | ||
| 48 | pub const SYNTAX_THEME: &str = "InspiredGitHub"; | ||
| 49 | |||
| 50 | /// Syntect's default syntax definitions, loaded once per process (loading is far more | 45 | /// Syntect's default syntax definitions, loaded once per process (loading is far more |
| 51 | /// expensive than highlighting, and a site build highlights many blocks). | 46 | /// expensive than highlighting, and a site build highlights many blocks). |
| 52 | fn syntax_set() -> &'static SyntaxSet { | 47 | fn syntax_set() -> &'static SyntaxSet { |
| @@ -54,18 +49,24 @@ fn syntax_set() -> &'static SyntaxSet { | |||
| 54 | SET.get_or_init(SyntaxSet::load_defaults_newlines) | 49 | SET.get_or_init(SyntaxSet::load_defaults_newlines) |
| 55 | } | 50 | } |
| 56 | 51 | ||
| 57 | /// The stylesheet the emitted highlight classes refer to. Highlighting emits CSS | 52 | fn theme_set() -> &'static ThemeSet { |
| 58 | /// classes rather than inline styles (spec §3.2), so a build must also emit this. | 53 | static THEMES: OnceLock<ThemeSet> = OnceLock::new(); |
| 59 | pub fn syntax_css() -> &'static str { | 54 | THEMES.get_or_init(ThemeSet::load_defaults) |
| 60 | static CSS: OnceLock<String> = OnceLock::new(); | 55 | } |
| 61 | CSS.get_or_init(|| { | 56 | |
| 62 | let themes = ThemeSet::load_defaults(); | 57 | /// The stylesheet the emitted highlight classes refer to, for a named syntect theme. |
| 63 | themes | 58 | /// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must |
| 64 | .themes | 59 | /// also emit this. `None` means the theme name is not one syntect ships — the caller |
| 65 | .get(SYNTAX_THEME) | 60 | /// reports that rather than quietly emitting an empty stylesheet, which would look like |
| 66 | .and_then(|theme| css_for_theme_with_class_style(theme, CLASS_STYLE).ok()) | 61 | /// highlighting is broken. |
| 67 | .unwrap_or_default() | 62 | pub fn syntax_css(theme: &str) -> Option<String> { |
| 68 | }) | 63 | let theme = theme_set().themes.get(theme)?; |
| 64 | css_for_theme_with_class_style(theme, CLASS_STYLE).ok() | ||
| 65 | } | ||
| 66 | |||
| 67 | /// Every theme name [`syntax_css`] accepts, for error messages and documentation. | ||
| 68 | pub fn available_themes() -> Vec<&'static str> { | ||
| 69 | theme_set().themes.keys().map(String::as_str).collect() | ||
| 69 | } | 70 | } |
| 70 | 71 | ||
| 71 | /// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block | 72 | /// 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 { | |||
| 129 | /// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4). | 130 | /// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4). |
| 130 | struct Renderer<'a> { | 131 | struct Renderer<'a> { |
| 131 | hl: &'a dyn Highlighter, | 132 | hl: &'a dyn Highlighter, |
| 133 | opts: RenderOptions, | ||
| 132 | /// Block footnote definitions, keyed by label (collected before the walk). | 134 | /// Block footnote definitions, keyed by label (collected before the walk). |
| 133 | block_defs: HashMap<String, Vec<Element>>, | 135 | block_defs: HashMap<String, Vec<Element>>, |
| 134 | /// Inline footnote definitions discovered at reference sites. | 136 | /// Inline footnote definitions discovered at reference sites. |
| @@ -137,10 +139,34 @@ struct Renderer<'a> { | |||
| 137 | order: Vec<String>, | 139 | order: Vec<String>, |
| 138 | } | 140 | } |
| 139 | 141 | ||
| 140 | /// Render a resolved document to an HTML fragment. | 142 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| 143 | /// site rather than to the document. | ||
| 144 | #[derive(Debug, Clone, Copy)] | ||
| 145 | pub struct RenderOptions { | ||
| 146 | /// Added to every heading's level, so a level-1 org heading can render as `<h2>` | ||
| 147 | /// beneath a page title supplied by the layout. See | ||
| 148 | /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). | ||
| 149 | pub heading_offset: u8, | ||
| 150 | } | ||
| 151 | |||
| 152 | impl Default for RenderOptions { | ||
| 153 | fn default() -> Self { | ||
| 154 | RenderOptions { | ||
| 155 | heading_offset: crate::config::HtmlOutput::default().heading_offset, | ||
| 156 | } | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | /// Render a resolved document to an HTML fragment, with default options. | ||
| 141 | pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { | 161 | pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { |
| 162 | render_with(doc, highlighter, &RenderOptions::default()) | ||
| 163 | } | ||
| 164 | |||
| 165 | /// Render a resolved document to an HTML fragment. | ||
| 166 | pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html { | ||
| 142 | let mut r = Renderer { | 167 | let mut r = Renderer { |
| 143 | hl: highlighter, | 168 | hl: highlighter, |
| 169 | opts: *opts, | ||
| 144 | block_defs: HashMap::new(), | 170 | block_defs: HashMap::new(), |
| 145 | inline_defs: HashMap::new(), | 171 | inline_defs: HashMap::new(), |
| 146 | order: Vec::new(), | 172 | order: Vec::new(), |
| @@ -163,7 +189,7 @@ impl Renderer<'_> { | |||
| 163 | 189 | ||
| 164 | fn render_section(&mut self, section: &Section, out: &mut String) { | 190 | fn render_section(&mut self, section: &Section, out: &mut String) { |
| 165 | if let Some(h) = §ion.heading { | 191 | if let Some(h) = §ion.heading { |
| 166 | let level = h.level.clamp(1, 6); | 192 | let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); |
| 167 | let anchor = h | 193 | let anchor = h |
| 168 | .custom_id | 194 | .custom_id |
| 169 | .clone() | 195 | .clone() |
src/site.rs +258 −48
| @@ -19,14 +19,15 @@ use walkdir::WalkDir; | |||
| 19 | 19 | ||
| 20 | use crate::incremental::{ | 20 | use crate::incremental::{ |
| 21 | self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, | 21 | self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, |
| 22 | template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, | 22 | template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, |
| 23 | }; | 23 | }; |
| 24 | use crate::index::{document_targets, SymbolTable, TargetId}; | 24 | use crate::index::{document_targets, SymbolTable, TargetId}; |
| 25 | use crate::model::{ContentHash, Diagnostic, Document}; | 25 | use crate::model::{ContentHash, Diagnostic, Document}; |
| 26 | use crate::parser::parse; | 26 | use crate::parser::parse; |
| 27 | use crate::render::{render, syntax_css, Html, SyntectHighlighter}; | 27 | use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; |
| 28 | use crate::resolve::resolve; | 28 | use crate::resolve::resolve; |
| 29 | use crate::template::{template_sources, NavItem, Templater}; | 29 | use crate::config::{self, Config, NavMode}; |
| 30 | use crate::template::{NavItem, PageContext, SiteContext, Templater}; | ||
| 30 | use crate::util::{output_path, output_url, relative_root}; | 31 | use crate::util::{output_path, output_url, relative_root}; |
| 31 | 32 | ||
| 32 | /// A fully built page: source and output paths (relative to their roots) and its | 33 | /// A fully built page: source and output paths (relative to their roots) and its |
| @@ -49,6 +50,8 @@ pub struct BuildOptions { | |||
| 49 | pub no_cache: bool, | 50 | pub no_cache: bool, |
| 50 | /// Treat broken internal links as a build error rather than a warning (spec §4.3.4). | 51 | /// Treat broken internal links as a build error rather than a warning (spec §4.3.4). |
| 51 | pub strict: bool, | 52 | pub strict: bool, |
| 53 | /// Explicit config file, overriding `org-ssg.toml` in the source directory. | ||
| 54 | pub config_path: Option<Utf8PathBuf>, | ||
| 52 | } | 55 | } |
| 53 | 56 | ||
| 54 | /// Summary of a site build. | 57 | /// Summary of a site build. |
| @@ -98,14 +101,39 @@ struct PagePrep { | |||
| 98 | broken: Vec<TargetId>, | 101 | broken: Vec<TargetId>, |
| 99 | diagnostics: Vec<Diagnostic>, | 102 | diagnostics: Vec<Diagnostic>, |
| 100 | nav: Vec<NavItem>, | 103 | nav: Vec<NavItem>, |
| 104 | context: PageContext, | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Which pages the configured [`NavMode`] selects, in nav order. | ||
| 108 | fn nav_selection<'a>( | ||
| 109 | config: &Config, | ||
| 110 | pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)], | ||
| 111 | ) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> { | ||
| 112 | match config.nav.mode { | ||
| 113 | NavMode::None => Vec::new(), | ||
| 114 | NavMode::All => pages.iter().collect(), | ||
| 115 | NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(), | ||
| 116 | // Configured order wins over discovery order — a hand-written nav is a designed | ||
| 117 | // sequence, not an alphabetical one. | ||
| 118 | NavMode::Explicit => config | ||
| 119 | .nav | ||
| 120 | .pages | ||
| 121 | .iter() | ||
| 122 | .filter_map(|want| pages.iter().find(|(source, _, _)| source == want)) | ||
| 123 | .collect(), | ||
| 124 | } | ||
| 101 | } | 125 | } |
| 102 | 126 | ||
| 103 | /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the | 127 | /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the |
| 104 | /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental | 128 | /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental |
| 105 | /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so | 129 | /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so |
| 106 | /// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4). | 130 | /// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4). |
| 107 | fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { | 131 | fn prepare_pages( |
| 108 | let (org_rel, _assets) = discover(src)?; | 132 | src: &Utf8Path, |
| 133 | config: &Config, | ||
| 134 | out: Option<&Utf8Path>, | ||
| 135 | ) -> Result<(Vec<PagePrep>, SymbolTable)> { | ||
| 136 | let (org_rel, _assets) = discover(src, config, out)?; | ||
| 109 | 137 | ||
| 110 | // PARSE every file (relative paths keep snapshots and links machine-independent). | 138 | // PARSE every file (relative paths keep snapshots and links machine-independent). |
| 111 | // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the | 139 | // 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)> { | |||
| 127 | symbols.index_document(doc); | 155 | symbols.index_document(doc); |
| 128 | } | 156 | } |
| 129 | 157 | ||
| 130 | // Nav is global chrome; titles come from #+TITLE (falling back to the file stem) and | 158 | // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to |
| 131 | // URLs from each page's output path, which `#+SLUG:` can rename. | 159 | // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename. |
| 132 | let all_pages: Vec<(Utf8PathBuf, String)> = docs | 160 | let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs |
| 133 | .iter() | ||
| 134 | .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d))) | ||
| 135 | .collect(); | ||
| 136 | let entries: Vec<(Utf8PathBuf, String)> = all_pages | ||
| 137 | .iter() | 161 | .iter() |
| 138 | .filter(|(out, _)| is_top_level(out)) | 162 | .map(|d| { |
| 139 | .cloned() | 163 | ( |
| 164 | d.source_path.clone(), | ||
| 165 | output_path(&d.source_path, &d.keywords), | ||
| 166 | page_title(d), | ||
| 167 | ) | ||
| 168 | }) | ||
| 140 | .collect(); | 169 | .collect(); |
| 141 | 170 | ||
| 142 | // Two sources emitting one page would silently drop a page — and with slugs, a | 171 | // Two sources emitting one page would silently drop a page — and with slugs, a |
| 143 | // collision is a typo away and invisible in the source filenames. | 172 | // collision is a typo away and invisible in the source filenames. |
| 144 | let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> = | 173 | let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> = |
| 145 | std::collections::HashMap::new(); | 174 | std::collections::HashMap::new(); |
| 146 | for (doc, (out, _)) in docs.iter().zip(&all_pages) { | 175 | for (source, out, _) in &all_pages { |
| 147 | if let Some(other) = claimed.insert(out, &doc.source_path) { | 176 | if let Some(other) = claimed.insert(out, source) { |
| 148 | anyhow::bail!( | 177 | anyhow::bail!( |
| 149 | "output collision: {} and {} both build to {out} (check their #+SLUG:)", | 178 | "output collision: {other} and {source} both build to {out} \ |
| 150 | other, | 179 | (check their #+SLUG:)" |
| 151 | doc.source_path | ||
| 152 | ); | 180 | ); |
| 153 | } | 181 | } |
| 154 | } | 182 | } |
| 155 | 183 | ||
| 184 | // An explicit nav naming a page that does not exist is a typo, and a silently | ||
| 185 | // shorter nav is a poor way to learn about it. | ||
| 186 | if config.nav.mode == NavMode::Explicit { | ||
| 187 | for want in &config.nav.pages { | ||
| 188 | if !all_pages.iter().any(|(source, _, _)| source == want) { | ||
| 189 | anyhow::bail!("nav.pages lists {want}, which is not a page in {src}"); | ||
| 190 | } | ||
| 191 | } | ||
| 192 | } | ||
| 193 | let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages) | ||
| 194 | .into_iter() | ||
| 195 | .map(|(_, out, title)| (out.clone(), title.clone())) | ||
| 196 | .collect(); | ||
| 197 | |||
| 156 | // RESOLVE reads the shared symbol table and writes only into its own page's output, | 198 | // RESOLVE reads the shared symbol table and writes only into its own page's output, |
| 157 | // so it parallelizes for free once INDEX has finished building the table. | 199 | // so it parallelizes for free once INDEX has finished building the table. |
| 158 | let pages: Vec<PagePrep> = docs | 200 | let pages: Vec<PagePrep> = docs |
| @@ -175,6 +217,7 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { | |||
| 175 | .collect(); | 217 | .collect(); |
| 176 | 218 | ||
| 177 | PagePrep { | 219 | PagePrep { |
| 220 | context: page_context(doc, &output), | ||
| 178 | source: doc.source_path.clone(), | 221 | source: doc.source_path.clone(), |
| 179 | output, | 222 | output, |
| 180 | title: page_title(doc), | 223 | title: page_title(doc), |
| @@ -195,9 +238,14 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { | |||
| 195 | /// Parse + index + resolve + render + template a whole site *in memory*, without | 238 | /// Parse + index + resolve + render + template a whole site *in memory*, without |
| 196 | /// touching the output directory. Shared by the tests (full render, every page). | 239 | /// touching the output directory. Shared by the tests (full render, every page). |
| 197 | pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | 240 | pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { |
| 198 | let (preps, _symbols) = prepare_pages(src)?; | 241 | let config = Config::load(src)?; |
| 242 | config.validate()?; | ||
| 243 | let (preps, _symbols) = prepare_pages(src, &config, None)?; | ||
| 199 | let highlighter = SyntectHighlighter::new(); | 244 | let highlighter = SyntectHighlighter::new(); |
| 200 | let templater = Templater::new(); | 245 | let templater = Templater::load(Some(&src.join(&config.templates.dir)))?; |
| 246 | let site = site_context(&config); | ||
| 247 | let listing = page_listing(&config, &preps); | ||
| 248 | let render_opts = render_options(&config); | ||
| 201 | 249 | ||
| 202 | let mut pages = Vec::new(); | 250 | let mut pages = Vec::new(); |
| 203 | let mut broken = Vec::new(); | 251 | let mut broken = Vec::new(); |
| @@ -205,7 +253,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 205 | for t in &p.broken { | 253 | for t in &p.broken { |
| 206 | broken.push((p.source.clone(), t.clone())); | 254 | broken.push((p.source.clone(), t.clone())); |
| 207 | } | 255 | } |
| 208 | let html = render_page(&templater, &highlighter, p)?; | 256 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; |
| 209 | pages.push(BuiltPage { | 257 | pages.push(BuiltPage { |
| 210 | source: p.source.clone(), | 258 | source: p.source.clone(), |
| 211 | output: p.output.clone(), | 259 | output: p.output.clone(), |
| @@ -216,16 +264,46 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 216 | Ok((pages, broken)) | 264 | Ok((pages, broken)) |
| 217 | } | 265 | } |
| 218 | 266 | ||
| 267 | fn render_options(config: &Config) -> RenderOptions { | ||
| 268 | RenderOptions { | ||
| 269 | heading_offset: config.html.heading_offset, | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | fn site_context(config: &Config) -> SiteContext { | ||
| 274 | SiteContext { | ||
| 275 | title: config.site.title.clone(), | ||
| 276 | base_url: config.site.base_url.clone(), | ||
| 277 | description: config.site.description.clone(), | ||
| 278 | language: config.site.language.clone(), | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | /// The `pages` list templates see, when configured to see one (see | ||
| 283 | /// [`crate::config::Templates::expose_page_list`]). | ||
| 284 | fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> { | ||
| 285 | config | ||
| 286 | .templates | ||
| 287 | .expose_page_list | ||
| 288 | .then(|| preps.iter().map(|p| p.context.clone()).collect()) | ||
| 289 | } | ||
| 290 | |||
| 219 | /// RENDER + TEMPLATE one prepared page into its final HTML string. | 291 | /// RENDER + TEMPLATE one prepared page into its final HTML string. |
| 292 | #[allow(clippy::too_many_arguments)] | ||
| 220 | fn render_page( | 293 | fn render_page( |
| 221 | templater: &Templater, | 294 | templater: &Templater, |
| 222 | highlighter: &SyntectHighlighter, | 295 | highlighter: &SyntectHighlighter, |
| 296 | site: &SiteContext, | ||
| 297 | pages: Option<&[PageContext]>, | ||
| 298 | render_opts: &RenderOptions, | ||
| 223 | p: &PagePrep, | 299 | p: &PagePrep, |
| 224 | ) -> Result<String> { | 300 | ) -> Result<String> { |
| 225 | let Html(fragment) = render(&p.resolved, highlighter); | 301 | let Html(fragment) = render_with(&p.resolved, highlighter, render_opts); |
| 226 | let stylesheet = format!("{}{}", relative_root(&p.source), SYNTAX_STYLESHEET); | 302 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. |
| 303 | let root = relative_root(&p.output); | ||
| 304 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); | ||
| 227 | templater | 305 | templater |
| 228 | .render_page(&p.title, &fragment, &p.nav, &stylesheet) | 306 | .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages) |
| 229 | .with_context(|| format!("templating {}", p.source)) | 307 | .with_context(|| format!("templating {}", p.source)) |
| 230 | } | 308 | } |
| 231 | 309 | ||
| @@ -236,28 +314,55 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css"; | |||
| 236 | /// `render_key` changed or that link into a changed file's targets; reuses the on-disk | 314 | /// `render_key` changed or that link into a changed file's targets; reuses the on-disk |
| 237 | /// output of everything else; persists an updated cache manifest. | 315 | /// output of everything else; persists an updated cache manifest. |
| 238 | pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { | 316 | pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { |
| 239 | let (_org_rel, assets) = discover(src)?; | 317 | let cfg = match &opts.config_path { |
| 240 | let (preps, symbols) = prepare_pages(src)?; | 318 | Some(path) => Config::load_file(path)?, |
| 319 | None => Config::load(src)?, | ||
| 320 | }; | ||
| 321 | cfg.validate()?; | ||
| 322 | |||
| 323 | // Create the output directory up front so it can be recognised and excluded when it | ||
| 324 | // lives inside the source tree. | ||
| 325 | fs::create_dir_all(out).with_context(|| format!("creating {out}"))?; | ||
| 326 | let (_org_rel, assets) = discover(src, &cfg, Some(out))?; | ||
| 327 | let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; | ||
| 328 | |||
| 329 | let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?; | ||
| 330 | let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { | ||
| 331 | anyhow::anyhow!( | ||
| 332 | "unknown highlight.theme {:?}. Available: {}", | ||
| 333 | cfg.highlight.theme, | ||
| 334 | render::available_themes().join(", ") | ||
| 335 | ) | ||
| 336 | })?; | ||
| 241 | 337 | ||
| 242 | // The global hash classes (spec §4.1): a change in any invalidates the site. The | 338 | // The global hash classes (spec §4.1): a change in any invalidates the site. The |
| 243 | // config hash is combined with a site-structure hash because the nav bar — global | 339 | // config hash is combined with a site-structure hash covering the global chrome each |
| 244 | // chrome on every page — is built from every page's (path, title), so a title/path | 340 | // page carries, so a change to that chrome re-renders the pages showing it. |
| 245 | // change or a page add/remove must re-render every page (else stale nav on disk). | ||
| 246 | let cfg = BuildConfig::default(); | ||
| 247 | // Only the pages that actually appear in the nav belong in the site-structure hash, | ||
| 248 | // because the nav is the only global chrome a page carries. Hashing *every* page | ||
| 249 | // here would mean adding one blog post re-rendered the entire site — correct, but | ||
| 250 | // needlessly: a nested page cannot change any other page's nav. | ||
| 251 | // | 341 | // |
| 252 | // Keyed on the *output* path, since a `#+SLUG:` change moves a page's URL — and so | 342 | // Which pages belong in that hash depends on what a template can *see*. Normally it |
| 253 | // its nav link — even though no source filename moved. | 343 | // is the nav only — a nested page cannot change another page's nav, so adding a blog |
| 254 | let nav_entries: Vec<(String, String)> = preps | 344 | // post should render one page, not the site. But `expose_page_list` hands every |
| 345 | // template every page's metadata, and then any page's output really can depend on | ||
| 346 | // any other page, so the hash has to widen to match. Keyed on output paths, since a | ||
| 347 | // `#+SLUG:` change moves a page's URL without moving its source. | ||
| 348 | let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps | ||
| 255 | .iter() | 349 | .iter() |
| 256 | .filter(|p| is_top_level(&p.output)) | 350 | .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) |
| 257 | .map(|p| (p.output.to_string(), p.title.clone())) | ||
| 258 | .collect(); | 351 | .collect(); |
| 259 | let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries)); | 352 | let structure: Vec<(String, String)> = if cfg.templates.expose_page_list { |
| 260 | let tmpl_hash = template_hash(template_sources()); | 353 | all_pages |
| 354 | .iter() | ||
| 355 | .map(|(_, out, title)| (out.to_string(), title.clone())) | ||
| 356 | .collect() | ||
| 357 | } else { | ||
| 358 | // The same selection the nav itself is built from, so the two can never drift. | ||
| 359 | nav_selection(&cfg, &all_pages) | ||
| 360 | .into_iter() | ||
| 361 | .map(|(_, out, title)| (out.to_string(), title.clone())) | ||
| 362 | .collect() | ||
| 363 | }; | ||
| 364 | let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure)); | ||
| 365 | let tmpl_hash = template_hash(templater.sources()); | ||
| 261 | 366 | ||
| 262 | // Compose each page's render key and record its dependency edges. | 367 | // Compose each page's render key and record its dependency edges. |
| 263 | let mut new_graph = DepGraph::default(); | 368 | let mut new_graph = DepGraph::default(); |
| @@ -310,7 +415,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | |||
| 310 | } | 415 | } |
| 311 | 416 | ||
| 312 | let highlighter = SyntectHighlighter::new(); | 417 | let highlighter = SyntectHighlighter::new(); |
| 313 | let templater = Templater::new(); | 418 | let site = site_context(&cfg); |
| 419 | let listing = page_listing(&cfg, &preps); | ||
| 420 | let render_opts = render_options(&cfg); | ||
| 314 | let mut report = SiteReport::default(); | 421 | let mut report = SiteReport::default(); |
| 315 | 422 | ||
| 316 | // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes | 423 | // 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 | |||
| 332 | if let Some(parent) = dest.parent() { | 439 | if let Some(parent) = dest.parent() { |
| 333 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | 440 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; |
| 334 | } | 441 | } |
| 335 | let html = render_page(&templater, &highlighter, p)?; | 442 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; |
| 336 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; | 443 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; |
| 337 | Ok(true) | 444 | Ok(true) |
| 338 | }) | 445 | }) |
| @@ -355,8 +462,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | |||
| 355 | 462 | ||
| 356 | // The syntax stylesheet the highlighter's CSS classes refer to. Written every build | 463 | // The syntax stylesheet the highlighter's CSS classes refer to. Written every build |
| 357 | // (it is a few KB and depends only on the theme, which lives in the config hash). | 464 | // (it is a few KB and depends only on the theme, which lives in the config hash). |
| 358 | fs::create_dir_all(out).with_context(|| format!("creating {out}"))?; | 465 | fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) |
| 359 | fs::write(out.join(SYNTAX_STYLESHEET), syntax_css()) | ||
| 360 | .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; | 466 | .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; |
| 361 | 467 | ||
| 362 | // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the | 468 | // 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( | |||
| 475 | 581 | ||
| 476 | /// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative | 582 | /// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative |
| 477 | /// to `src` and sorted for deterministic output. The cache manifest is not an asset. | 583 | /// to `src` and sorted for deterministic output. The cache manifest is not an asset. |
| 478 | fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { | 584 | fn discover( |
| 585 | src: &Utf8Path, | ||
| 586 | config: &Config, | ||
| 587 | out: Option<&Utf8Path>, | ||
| 588 | ) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { | ||
| 589 | let skip_dirs = excluded_dirs(src, config, out); | ||
| 479 | let mut org = Vec::new(); | 590 | let mut org = Vec::new(); |
| 480 | let mut assets = Vec::new(); | 591 | let mut assets = Vec::new(); |
| 481 | for entry in WalkDir::new(src).sort_by_file_name() { | 592 | |
| 593 | let walker = WalkDir::new(src).sort_by_file_name().into_iter(); | ||
| 594 | for entry in walker.filter_entry(|e| { | ||
| 595 | let Some(path) = Utf8Path::from_path(e.path()) else { | ||
| 596 | return false; | ||
| 597 | }; | ||
| 598 | let rel = path.strip_prefix(src).unwrap_or(path); | ||
| 599 | // The source root itself always passes; `filter_entry` prunes whole subtrees. | ||
| 600 | rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs) | ||
| 601 | }) { | ||
| 482 | let entry = entry.with_context(|| format!("walking {src}"))?; | 602 | let entry = entry.with_context(|| format!("walking {src}"))?; |
| 483 | if !entry.file_type().is_file() { | 603 | if !entry.file_type().is_file() { |
| 484 | continue; | 604 | continue; |
| @@ -489,6 +609,9 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { | |||
| 489 | .strip_prefix(src) | 609 | .strip_prefix(src) |
| 490 | .map(|p| p.to_owned()) | 610 | .map(|p| p.to_owned()) |
| 491 | .unwrap_or_else(|_| abs.clone()); | 611 | .unwrap_or_else(|_| abs.clone()); |
| 612 | if rel == config::CONFIG_FILE { | ||
| 613 | continue; | ||
| 614 | } | ||
| 492 | if rel.extension() == Some("org") { | 615 | if rel.extension() == Some("org") { |
| 493 | org.push(rel); | 616 | org.push(rel); |
| 494 | } else { | 617 | } else { |
| @@ -500,6 +623,62 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { | |||
| 500 | Ok((org, assets)) | 623 | Ok((org, assets)) |
| 501 | } | 624 | } |
| 502 | 625 | ||
| 626 | /// Source-relative directories that DISCOVER must not descend into: the template | ||
| 627 | /// directory (build input, not content) and the output directory when it lives inside | ||
| 628 | /// the source. | ||
| 629 | /// | ||
| 630 | /// The output case is not a corner case — `org-ssg build . -o _site` is the obvious | ||
| 631 | /// thing to type, and without this the build copies its own output back into itself, | ||
| 632 | /// growing `_site/_site/_site/…` on every run. | ||
| 633 | fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec<Utf8PathBuf> { | ||
| 634 | let mut dirs = vec![config.templates.dir.clone()]; | ||
| 635 | if let Some(out) = out { | ||
| 636 | // Compare canonicalized paths so `.`, `./x` and an absolute path all agree. | ||
| 637 | // The output may not exist yet, in which case it cannot contain anything and | ||
| 638 | // the textual fallback is enough. | ||
| 639 | let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> { | ||
| 640 | std::fs::canonicalize(p) | ||
| 641 | .ok() | ||
| 642 | .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) | ||
| 643 | }; | ||
| 644 | match (canon(src), canon(out)) { | ||
| 645 | (Some(src_abs), Some(out_abs)) => { | ||
| 646 | if let Ok(rel) = out_abs.strip_prefix(&src_abs) { | ||
| 647 | if !rel.as_str().is_empty() { | ||
| 648 | dirs.push(rel.to_owned()); | ||
| 649 | } | ||
| 650 | } | ||
| 651 | } | ||
| 652 | _ => { | ||
| 653 | if let Ok(rel) = out.strip_prefix(src) { | ||
| 654 | if !rel.as_str().is_empty() { | ||
| 655 | dirs.push(rel.to_owned()); | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | } | ||
| 661 | dirs | ||
| 662 | } | ||
| 663 | |||
| 664 | /// Is this source-relative path excluded from discovery? | ||
| 665 | /// | ||
| 666 | /// Dot-entries are skipped wholesale. That is the conventional rule for site generators, | ||
| 667 | /// and the reason is safety rather than tidiness: a source directory is very often a git | ||
| 668 | /// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire | ||
| 669 | /// history alongside its homepage. | ||
| 670 | fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool { | ||
| 671 | if rel | ||
| 672 | .components() | ||
| 673 | .any(|c| c.as_str().starts_with('.') && c.as_str() != "." && c.as_str() != "..") | ||
| 674 | { | ||
| 675 | return true; | ||
| 676 | } | ||
| 677 | skip_dirs | ||
| 678 | .iter() | ||
| 679 | .any(|dir| !dir.as_str().is_empty() && rel.starts_with(dir)) | ||
| 680 | } | ||
| 681 | |||
| 503 | /// Does this output path sit at the site root? | 682 | /// Does this output path sit at the site root? |
| 504 | /// | 683 | /// |
| 505 | /// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page | 684 | /// 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 { | |||
| 511 | output.parent().is_none_or(|p| p.as_str().is_empty()) | 690 | output.parent().is_none_or(|p| p.as_str().is_empty()) |
| 512 | } | 691 | } |
| 513 | 692 | ||
| 693 | /// Everything a template can know about one page. Every `#+KEYWORD:` is passed through | ||
| 694 | /// under its lowercased name, so a template can use metadata this crate has never heard | ||
| 695 | /// of without the crate needing a release to support it. | ||
| 696 | fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { | ||
| 697 | let keyword = |name: &str| { | ||
| 698 | doc.keywords | ||
| 699 | .entries | ||
| 700 | .iter() | ||
| 701 | .find(|(k, _)| k.eq_ignore_ascii_case(name)) | ||
| 702 | .map(|(_, v)| v.clone()) | ||
| 703 | }; | ||
| 704 | PageContext { | ||
| 705 | title: page_title(doc), | ||
| 706 | url: output.to_string(), | ||
| 707 | source: doc.source_path.to_string(), | ||
| 708 | date: keyword("DATE"), | ||
| 709 | tags: keyword("FILETAGS") | ||
| 710 | .unwrap_or_default() | ||
| 711 | .split(':') | ||
| 712 | .filter(|t| !t.trim().is_empty()) | ||
| 713 | .map(|t| t.trim().to_string()) | ||
| 714 | .collect(), | ||
| 715 | keywords: doc | ||
| 716 | .keywords | ||
| 717 | .entries | ||
| 718 | .iter() | ||
| 719 | .map(|(k, v)| (k.to_lowercase(), v.clone())) | ||
| 720 | .collect(), | ||
| 721 | } | ||
| 722 | } | ||
| 723 | |||
| 514 | fn page_title(doc: &Document) -> String { | 724 | fn page_title(doc: &Document) -> String { |
| 515 | doc.keywords | 725 | doc.keywords |
| 516 | .entries | 726 | .entries |
src/template.rs +158 −28
| @@ -1,10 +1,20 @@ | |||
| 1 | //! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML. | 1 | //! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML. |
| 2 | //! | 2 | //! |
| 3 | //! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile). | 3 | //! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile). |
| 4 | //! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit | 4 | //! |
| 5 | //! invalidates every page that transitively uses it. Keep the fragment/template | 5 | //! Templates come from the configured directory when it exists, and fall back to a |
| 6 | //! boundary sharp so content HTML can be snapshot-tested independently of chrome. | 6 | //! built-in layout when it does not. That fallback is what lets a bare directory of |
| 7 | //! `.org` files build into a real site with no setup, while `base.html` in the templates | ||
| 8 | //! directory replaces the layout entirely for anyone who wants their own. | ||
| 9 | //! | ||
| 10 | //! Template sources are a hashing input for incrementality (spec §4.1): editing a layout | ||
| 11 | //! invalidates the pages that use it, and that has to hold for user templates too, or a | ||
| 12 | //! design change would leave a site half-updated. | ||
| 7 | 13 | ||
| 14 | use std::collections::BTreeMap; | ||
| 15 | |||
| 16 | use anyhow::{Context, Result}; | ||
| 17 | use camino::Utf8Path; | ||
| 8 | use minijinja::{context, Environment}; | 18 | use minijinja::{context, Environment}; |
| 9 | use serde::Serialize; | 19 | use serde::Serialize; |
| 10 | 20 | ||
| @@ -15,36 +25,73 @@ pub struct NavItem { | |||
| 15 | pub url: String, | 25 | pub url: String, |
| 16 | } | 26 | } |
| 17 | 27 | ||
| 18 | /// The base layout applied to every page: `<title>`, a nav bar, and the body. | 28 | /// Site-wide values, exposed to templates as `site`. |
| 19 | /// Minimal but real — a single `base` template, no partials yet. | 29 | #[derive(Debug, Clone, Serialize)] |
| 30 | pub struct SiteContext { | ||
| 31 | pub title: String, | ||
| 32 | pub base_url: String, | ||
| 33 | pub description: String, | ||
| 34 | pub language: String, | ||
| 35 | } | ||
| 36 | |||
| 37 | /// One page's metadata, exposed to templates as `page` — and, when | ||
| 38 | /// `templates.expose_page_list` is on, as entries of `pages`. | ||
| 39 | #[derive(Debug, Clone, Serialize)] | ||
| 40 | pub struct PageContext { | ||
| 41 | pub title: String, | ||
| 42 | /// Output path relative to the site root, e.g. `blog/post.html`. | ||
| 43 | pub url: String, | ||
| 44 | /// Source path relative to the source root, e.g. `blog/post.org`. | ||
| 45 | pub source: String, | ||
| 46 | /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because | ||
| 47 | /// templates are better placed to decide how a date should read. | ||
| 48 | pub date: Option<String>, | ||
| 49 | /// `#+FILETAGS:` split on `:`. | ||
| 50 | pub tags: Vec<String>, | ||
| 51 | /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use | ||
| 52 | /// project-specific metadata this crate has never heard of. | ||
| 53 | pub keywords: BTreeMap<String, String>, | ||
| 54 | } | ||
| 55 | |||
| 56 | /// The built-in layout, used when the templates directory has no `base.html`. | ||
| 57 | /// Deliberately plain: it should be a working starting point and an obvious thing to | ||
| 58 | /// replace, not a design anyone has to live with. | ||
| 20 | const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | 59 | const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> |
| 21 | <html lang="en"> | 60 | <html lang="{{ site.language }}"> |
| 22 | <head> | 61 | <head> |
| 23 | <meta charset="utf-8"> | 62 | <meta charset="utf-8"> |
| 24 | <title>{{ title }}</title> | 63 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 64 | <title>{{ page.title }} · {{ site.title }}</title> | ||
| 65 | {%- if page.description %} | ||
| 66 | <meta name="description" content="{{ page.description }}"> | ||
| 67 | {%- endif %} | ||
| 25 | {%- if stylesheet %} | 68 | {%- if stylesheet %} |
| 26 | <link rel="stylesheet" href="{{ stylesheet }}"> | 69 | <link rel="stylesheet" href="{{ stylesheet }}"> |
| 27 | {%- endif %} | 70 | {%- endif %} |
| 28 | </head> | 71 | </head> |
| 29 | <body> | 72 | <body> |
| 73 | <header> | ||
| 74 | <a class="site-title" href="{{ root }}index.html">{{ site.title }}</a> | ||
| 75 | {%- if nav %} | ||
| 30 | <nav> | 76 | <nav> |
| 31 | {%- for item in nav %} | 77 | {%- for item in nav %} |
| 32 | <a href="{{ item.url }}">{{ item.title }}</a> | 78 | <a href="{{ item.url }}">{{ item.title }}</a> |
| 33 | {%- endfor %} | 79 | {%- endfor %} |
| 34 | </nav> | 80 | </nav> |
| 81 | {%- endif %} | ||
| 82 | </header> | ||
| 35 | <main> | 83 | <main> |
| 84 | <h1>{{ page.title }}</h1> | ||
| 85 | {%- if page.date %} | ||
| 86 | <p class="page-date">{{ page.date }}</p> | ||
| 87 | {%- endif %} | ||
| 36 | {{ body | safe }}</main> | 88 | {{ body | safe }}</main> |
| 37 | </body> | 89 | </body> |
| 38 | </html> | 90 | </html> |
| 39 | "#; | 91 | "#; |
| 40 | 92 | ||
| 41 | /// The source text of every template that participates in the page layout. Hashed by | 93 | /// The name a template must have to serve as the page layout. |
| 42 | /// the incremental layer (spec §4.1): a base-layout edit invalidates every page that | 94 | pub const BASE_TEMPLATE_NAME: &str = "base"; |
| 43 | /// uses it. There is a single `base` template today; when partials arrive this returns | ||
| 44 | /// the transitive closure so a single-partial edit invalidates only its users. | ||
| 45 | pub fn template_sources() -> &'static [(&'static str, &'static str)] { | ||
| 46 | &[("base", BASE_TEMPLATE)] | ||
| 47 | } | ||
| 48 | 95 | ||
| 49 | #[derive(Debug, thiserror::Error)] | 96 | #[derive(Debug, thiserror::Error)] |
| 50 | pub enum TemplateError { | 97 | pub enum TemplateError { |
| @@ -55,37 +102,120 @@ pub enum TemplateError { | |||
| 55 | /// Wraps a rendered fragment in its page template. | 102 | /// Wraps a rendered fragment in its page template. |
| 56 | pub struct Templater { | 103 | pub struct Templater { |
| 57 | env: Environment<'static>, | 104 | env: Environment<'static>, |
| 105 | /// `(name, source)` for every registered template, for the template hash. Sorted by | ||
| 106 | /// name so the hash does not depend on directory iteration order. | ||
| 107 | sources: Vec<(String, String)>, | ||
| 58 | } | 108 | } |
| 59 | 109 | ||
| 60 | impl Templater { | 110 | impl Templater { |
| 61 | pub fn new() -> Self { | 111 | /// Load templates from `dir`, falling back to the built-in layout. |
| 112 | /// | ||
| 113 | /// A missing directory is fine — that is the zero-config path. A directory that | ||
| 114 | /// exists but contains a template that does not compile is an error: it means | ||
| 115 | /// someone is actively editing their layout, and rendering the built-in default | ||
| 116 | /// instead would look like their edit silently did nothing. | ||
| 117 | pub fn load(dir: Option<&Utf8Path>) -> Result<Self> { | ||
| 118 | let mut sources: Vec<(String, String)> = Vec::new(); | ||
| 119 | |||
| 120 | if let Some(dir) = dir.filter(|d| d.is_dir()) { | ||
| 121 | let mut entries: Vec<_> = std::fs::read_dir(dir) | ||
| 122 | .with_context(|| format!("reading template directory {dir}"))? | ||
| 123 | .collect::<std::io::Result<Vec<_>>>() | ||
| 124 | .with_context(|| format!("reading template directory {dir}"))?; | ||
| 125 | entries.sort_by_key(|e| e.file_name()); | ||
| 126 | |||
| 127 | for entry in entries { | ||
| 128 | let path = Utf8Path::from_path(&entry.path()) | ||
| 129 | .map(Utf8Path::to_owned) | ||
| 130 | .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?; | ||
| 131 | if path.extension() != Some("html") || !path.is_file() { | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | let name = path | ||
| 135 | .file_stem() | ||
| 136 | .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))? | ||
| 137 | .to_string(); | ||
| 138 | let source = std::fs::read_to_string(&path) | ||
| 139 | .with_context(|| format!("reading template {path}"))?; | ||
| 140 | sources.push((name, source)); | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | if !sources.iter().any(|(n, _)| n == BASE_TEMPLATE_NAME) { | ||
| 145 | sources.push((BASE_TEMPLATE_NAME.to_string(), BASE_TEMPLATE.to_string())); | ||
| 146 | } | ||
| 147 | sources.sort_by(|a, b| a.0.cmp(&b.0)); | ||
| 148 | |||
| 62 | let mut env = Environment::new(); | 149 | let mut env = Environment::new(); |
| 63 | env.add_template("base", BASE_TEMPLATE) | 150 | for (name, source) in &sources { |
| 64 | .expect("base template compiles"); | 151 | // `Environment<'static>` needs owned sources; leaking is bounded by the |
| 65 | Templater { env } | 152 | // template count and lives as long as the build anyway. |
| 153 | let name: &'static str = Box::leak(name.clone().into_boxed_str()); | ||
| 154 | let source: &'static str = Box::leak(source.clone().into_boxed_str()); | ||
| 155 | env.add_template(name, source) | ||
| 156 | .with_context(|| format!("compiling template {name}"))?; | ||
| 157 | } | ||
| 158 | |||
| 159 | Ok(Templater { env, sources }) | ||
| 160 | } | ||
| 161 | |||
| 162 | /// `(name, source)` for every registered template — the template hash's input | ||
| 163 | /// (spec §4.1), covering user templates so editing one invalidates its pages. | ||
| 164 | pub fn sources(&self) -> &[(String, String)] { | ||
| 165 | &self.sources | ||
| 66 | } | 166 | } |
| 67 | 167 | ||
| 68 | /// fragment + page metadata → full HTML page. `stylesheet` is the URL of the | 168 | /// fragment + page metadata → full HTML page. |
| 69 | /// syntax-highlighting stylesheet relative to *this* page (highlighting emits CSS | 169 | /// |
| 70 | /// classes, so the sheet has to come with it). | 170 | /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the |
| 171 | /// same at any directory depth. | ||
| 172 | #[allow(clippy::too_many_arguments)] | ||
| 71 | pub fn render_page( | 173 | pub fn render_page( |
| 72 | &self, | 174 | &self, |
| 73 | title: &str, | 175 | site: &SiteContext, |
| 176 | page: &PageContext, | ||
| 74 | body: &str, | 177 | body: &str, |
| 75 | nav: &[NavItem], | 178 | nav: &[NavItem], |
| 76 | stylesheet: &str, | 179 | stylesheet: &str, |
| 180 | root: &str, | ||
| 181 | pages: Option<&[PageContext]>, | ||
| 77 | ) -> Result<String, TemplateError> { | 182 | ) -> Result<String, TemplateError> { |
| 78 | let tmpl = self | 183 | let tmpl = self |
| 79 | .env | 184 | .env |
| 80 | .get_template("base") | 185 | .get_template(BASE_TEMPLATE_NAME) |
| 81 | .map_err(|e| TemplateError::Render(e.to_string()))?; | 186 | .map_err(|e| TemplateError::Render(e.to_string()))?; |
| 82 | tmpl.render(context! { title => title, body => body, nav => nav, stylesheet => stylesheet }) | 187 | tmpl.render(context! { |
| 83 | .map_err(|e| TemplateError::Render(e.to_string())) | 188 | site => site, |
| 189 | page => page, | ||
| 190 | body => body, | ||
| 191 | nav => nav, | ||
| 192 | stylesheet => stylesheet, | ||
| 193 | root => root, | ||
| 194 | pages => pages, | ||
| 195 | }) | ||
| 196 | .map_err(|e| TemplateError::Render(render_error_detail(e))) | ||
| 84 | } | 197 | } |
| 85 | } | 198 | } |
| 86 | 199 | ||
| 87 | impl Default for Templater { | 200 | /// minijinja's `Display` gives only the top-level message; the useful part (which |
| 88 | fn default() -> Self { | 201 | /// template, which line) is in the source and cause chain. |
| 89 | Self::new() | 202 | fn render_error_detail(error: minijinja::Error) -> String { |
| 203 | let mut out = error.to_string(); | ||
| 204 | if let Some(name) = error.template_source().map(|_| error.name().unwrap_or("?")) { | ||
| 205 | if let Some(line) = error.line() { | ||
| 206 | out = format!("{out} (in template {name}, line {line})"); | ||
| 207 | } | ||
| 90 | } | 208 | } |
| 209 | let mut source = std::error::Error::source(&error); | ||
| 210 | while let Some(cause) = source { | ||
| 211 | out.push_str(&format!(": {cause}")); | ||
| 212 | source = cause.source(); | ||
| 213 | } | ||
| 214 | out | ||
| 215 | } | ||
| 216 | |||
| 217 | /// The starter layout written by `org-ssg init`: the built-in template, on disk, ready | ||
| 218 | /// to edit. | ||
| 219 | pub fn starter_template() -> &'static str { | ||
| 220 | BASE_TEMPLATE | ||
| 91 | } | 221 | } |
tests/config.rs added +445
| @@ -0,0 +1,445 @@ | |||
| 1 | //! Configuration, templating and discovery — the surface that decides whether this is a | ||
| 2 | //! generator for one site or for anyone's. | ||
| 3 | //! | ||
| 4 | //! The theme running through these tests is that **the zero-config path has to work**. | ||
| 5 | //! A directory of `.org` files with no `org-ssg.toml`, no templates and no knowledge of | ||
| 6 | //! this tool must build into a real site; configuration is how you change the output, | ||
| 7 | //! never how you make it work at all. | ||
| 8 | |||
| 9 | use std::sync::atomic::{AtomicU32, Ordering}; | ||
| 10 | |||
| 11 | use camino::Utf8PathBuf; | ||
| 12 | |||
| 13 | use org_ssg::config::{Config, NavMode}; | ||
| 14 | use org_ssg::site::{build_site, BuildOptions}; | ||
| 15 | |||
| 16 | fn tmpdir(tag: &str) -> Utf8PathBuf { | ||
| 17 | static N: AtomicU32 = AtomicU32::new(0); | ||
| 18 | let n = N.fetch_add(1, Ordering::Relaxed); | ||
| 19 | let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) | ||
| 20 | .expect("utf-8 temp dir") | ||
| 21 | .join(format!("org-ssg-cfg-{}-{tag}-{n}", std::process::id())); | ||
| 22 | let _ = std::fs::remove_dir_all(&base); | ||
| 23 | std::fs::create_dir_all(&base).unwrap(); | ||
| 24 | base | ||
| 25 | } | ||
| 26 | |||
| 27 | /// A site with a root page, a second root page, and one nested page. | ||
| 28 | fn write_site(src: &Utf8PathBuf) { | ||
| 29 | std::fs::create_dir_all(src.join("blog")).unwrap(); | ||
| 30 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); | ||
| 31 | std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap(); | ||
| 32 | std::fs::write( | ||
| 33 | src.join("blog/post.org"), | ||
| 34 | "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n", | ||
| 35 | ) | ||
| 36 | .unwrap(); | ||
| 37 | } | ||
| 38 | |||
| 39 | fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> org_ssg::site::SiteReport { | ||
| 40 | build_site(src, out, &BuildOptions::default()).expect("build") | ||
| 41 | } | ||
| 42 | |||
| 43 | fn page(out: &Utf8PathBuf, rel: &str) -> String { | ||
| 44 | std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}")) | ||
| 45 | } | ||
| 46 | |||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | // Zero config | ||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | |||
| 51 | /// The headline promise: point it at a directory of org files and get a site. | ||
| 52 | #[test] | ||
| 53 | fn a_bare_directory_of_org_files_builds_with_no_config() { | ||
| 54 | let root = tmpdir("bare"); | ||
| 55 | let src = root.join("src"); | ||
| 56 | std::fs::create_dir_all(&src).unwrap(); | ||
| 57 | write_site(&src); | ||
| 58 | let out = root.join("out"); | ||
| 59 | |||
| 60 | let report = build(&src, &out); | ||
| 61 | assert_eq!(report.pages.len(), 3); | ||
| 62 | |||
| 63 | let home = page(&out, "index.html"); | ||
| 64 | assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment"); | ||
| 65 | assert!(home.contains("Welcome."), "the content is there"); | ||
| 66 | assert!( | ||
| 67 | out.join("syntax.css").exists(), | ||
| 68 | "the stylesheet the highlighter needs is emitted too" | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | /// A missing config is normal. A *malformed* one is not: someone who wrote a config | ||
| 73 | /// meant it, and quietly building the default site would hide their typo behind | ||
| 74 | /// plausible-looking output. | ||
| 75 | #[test] | ||
| 76 | fn a_malformed_config_is_an_error_but_a_missing_one_is_not() { | ||
| 77 | let root = tmpdir("malformed"); | ||
| 78 | let src = root.join("src"); | ||
| 79 | std::fs::create_dir_all(&src).unwrap(); | ||
| 80 | write_site(&src); | ||
| 81 | |||
| 82 | assert_eq!(Config::load(&src).unwrap(), Config::default()); | ||
| 83 | |||
| 84 | std::fs::write(src.join("org-ssg.toml"), "[site\ntitle = broken").unwrap(); | ||
| 85 | let err = Config::load(&src).expect_err("malformed config must fail"); | ||
| 86 | assert!(format!("{err:#}").contains("org-ssg.toml"), "names the file: {err:#}"); | ||
| 87 | } | ||
| 88 | |||
| 89 | /// A misspelled key is a silent no-op in most config formats, which is exactly how | ||
| 90 | /// someone spends an afternoon wondering why a setting does nothing. | ||
| 91 | #[test] | ||
| 92 | fn an_unknown_config_key_is_rejected() { | ||
| 93 | let root = tmpdir("unknownkey"); | ||
| 94 | let src = root.join("src"); | ||
| 95 | std::fs::create_dir_all(&src).unwrap(); | ||
| 96 | std::fs::write(src.join("org-ssg.toml"), "[site]\ntittle = \"typo\"\n").unwrap(); | ||
| 97 | |||
| 98 | let err = Config::load(&src).expect_err("unknown key must fail"); | ||
| 99 | assert!( | ||
| 100 | format!("{err:#}").contains("tittle"), | ||
| 101 | "the error names the offending key: {err:#}" | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | |||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | // Nav modes | ||
| 107 | // --------------------------------------------------------------------------- | ||
| 108 | |||
| 109 | fn nav_of(html: &str) -> String { | ||
| 110 | html.split("<nav>") | ||
| 111 | .nth(1) | ||
| 112 | .and_then(|s| s.split("</nav>").next()) | ||
| 113 | .unwrap_or("") | ||
| 114 | .to_string() | ||
| 115 | } | ||
| 116 | |||
| 117 | #[test] | ||
| 118 | fn nav_modes_select_different_pages() { | ||
| 119 | for (mode, expect_post, expect_about) in [ | ||
| 120 | ("top-level", false, true), | ||
| 121 | ("all", true, true), | ||
| 122 | ("none", false, false), | ||
| 123 | ] { | ||
| 124 | let root = tmpdir(&format!("nav-{mode}")); | ||
| 125 | let src = root.join("src"); | ||
| 126 | std::fs::create_dir_all(&src).unwrap(); | ||
| 127 | write_site(&src); | ||
| 128 | std::fs::write( | ||
| 129 | src.join("org-ssg.toml"), | ||
| 130 | format!("[nav]\nmode = \"{mode}\"\n"), | ||
| 131 | ) | ||
| 132 | .unwrap(); | ||
| 133 | let out = root.join("out"); | ||
| 134 | build(&src, &out); | ||
| 135 | |||
| 136 | let nav = nav_of(&page(&out, "index.html")); | ||
| 137 | assert_eq!( | ||
| 138 | nav.contains("A Post"), | ||
| 139 | expect_post, | ||
| 140 | "mode {mode} nested page presence, nav was:\n{nav}" | ||
| 141 | ); | ||
| 142 | assert_eq!( | ||
| 143 | nav.contains("About"), | ||
| 144 | expect_about, | ||
| 145 | "mode {mode} root page presence, nav was:\n{nav}" | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | /// An explicit nav is a designed sequence, so configured order beats discovery order. | ||
| 151 | #[test] | ||
| 152 | fn explicit_nav_uses_the_configured_order() { | ||
| 153 | let root = tmpdir("navexplicit"); | ||
| 154 | let src = root.join("src"); | ||
| 155 | std::fs::create_dir_all(&src).unwrap(); | ||
| 156 | write_site(&src); | ||
| 157 | std::fs::write( | ||
| 158 | src.join("org-ssg.toml"), | ||
| 159 | "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n", | ||
| 160 | ) | ||
| 161 | .unwrap(); | ||
| 162 | let out = root.join("out"); | ||
| 163 | build(&src, &out); | ||
| 164 | |||
| 165 | let nav = nav_of(&page(&out, "index.html")); | ||
| 166 | let post = nav.find("A Post").expect("post in nav"); | ||
| 167 | let home = nav.find("Home").expect("home in nav"); | ||
| 168 | assert!(post < home, "configured order wins:\n{nav}"); | ||
| 169 | assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}"); | ||
| 170 | } | ||
| 171 | |||
| 172 | /// A nav entry naming a page that does not exist is a typo, and a silently shorter nav | ||
| 173 | /// is a poor way to find out. | ||
| 174 | #[test] | ||
| 175 | fn explicit_nav_rejects_a_page_that_does_not_exist() { | ||
| 176 | let root = tmpdir("navmissing"); | ||
| 177 | let src = root.join("src"); | ||
| 178 | std::fs::create_dir_all(&src).unwrap(); | ||
| 179 | write_site(&src); | ||
| 180 | std::fs::write( | ||
| 181 | src.join("org-ssg.toml"), | ||
| 182 | "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n", | ||
| 183 | ) | ||
| 184 | .unwrap(); | ||
| 185 | |||
| 186 | let err = build_site(&src, &root.join("out"), &BuildOptions::default()) | ||
| 187 | .expect_err("missing nav page must fail"); | ||
| 188 | assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}"); | ||
| 189 | } | ||
| 190 | |||
| 191 | /// `mode` and `pages` disagreeing means one of them is being ignored. | ||
| 192 | #[test] | ||
| 193 | fn contradictory_nav_settings_are_rejected() { | ||
| 194 | let mut config = Config::default(); | ||
| 195 | config.nav.pages = vec![Utf8PathBuf::from("index.org")]; | ||
| 196 | assert!(config.validate().is_err(), "pages without explicit mode"); | ||
| 197 | |||
| 198 | let mut config = Config::default(); | ||
| 199 | config.nav.mode = NavMode::Explicit; | ||
| 200 | assert!(config.validate().is_err(), "explicit mode without pages"); | ||
| 201 | } | ||
| 202 | |||
| 203 | // --------------------------------------------------------------------------- | ||
| 204 | // Templates | ||
| 205 | // --------------------------------------------------------------------------- | ||
| 206 | |||
| 207 | /// The single biggest blocker to general use: without this every site built with this | ||
| 208 | /// tool looks identical. | ||
| 209 | #[test] | ||
| 210 | fn a_user_template_replaces_the_built_in_layout() { | ||
| 211 | let root = tmpdir("template"); | ||
| 212 | let src = root.join("src"); | ||
| 213 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 214 | write_site(&src); | ||
| 215 | std::fs::write( | ||
| 216 | src.join("templates/base.html"), | ||
| 217 | "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>", | ||
| 218 | ) | ||
| 219 | .unwrap(); | ||
| 220 | let out = root.join("out"); | ||
| 221 | build(&src, &out); | ||
| 222 | |||
| 223 | let home = page(&out, "index.html"); | ||
| 224 | assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}"); | ||
| 225 | assert!(!home.contains("<nav>"), "nothing of the default layout leaks in"); | ||
| 226 | assert!(home.contains("Welcome."), "content still renders"); | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate, | ||
| 230 | /// a design change would leave a site half-updated — the worst kind of caching bug, | ||
| 231 | /// because it looks like it worked. | ||
| 232 | #[test] | ||
| 233 | fn editing_a_template_re_renders_every_page_that_uses_it() { | ||
| 234 | let root = tmpdir("templatehash"); | ||
| 235 | let src = root.join("src"); | ||
| 236 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 237 | write_site(&src); | ||
| 238 | let tpl = src.join("templates/base.html"); | ||
| 239 | std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap(); | ||
| 240 | let out = root.join("out"); | ||
| 241 | |||
| 242 | build(&src, &out); | ||
| 243 | std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap(); | ||
| 244 | let report = build(&src, &out); | ||
| 245 | |||
| 246 | assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page"); | ||
| 247 | assert!(page(&out, "index.html").contains("v2"), "and the change lands"); | ||
| 248 | } | ||
| 249 | |||
| 250 | /// A template that does not compile means someone is actively editing their layout. | ||
| 251 | /// Falling back to the built-in would look like their edit silently did nothing. | ||
| 252 | #[test] | ||
| 253 | fn a_broken_template_fails_the_build() { | ||
| 254 | let root = tmpdir("badtemplate"); | ||
| 255 | let src = root.join("src"); | ||
| 256 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 257 | write_site(&src); | ||
| 258 | std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap(); | ||
| 259 | |||
| 260 | let err = build_site(&src, &root.join("out"), &BuildOptions::default()) | ||
| 261 | .expect_err("a broken template must fail the build"); | ||
| 262 | assert!( | ||
| 263 | format!("{err:#}").contains("base"), | ||
| 264 | "the error names the template: {err:#}" | ||
| 265 | ); | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never | ||
| 269 | /// heard of — otherwise every new bit of metadata would need a release. | ||
| 270 | #[test] | ||
| 271 | fn templates_receive_page_metadata_including_unknown_keywords() { | ||
| 272 | let root = tmpdir("meta"); | ||
| 273 | let src = root.join("src"); | ||
| 274 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 275 | write_site(&src); | ||
| 276 | std::fs::write( | ||
| 277 | src.join("blog/post.org"), | ||
| 278 | "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n", | ||
| 279 | ) | ||
| 280 | .unwrap(); | ||
| 281 | std::fs::write( | ||
| 282 | src.join("templates/base.html"), | ||
| 283 | "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \ | ||
| 284 | custom={{ page.keywords.custom_thing }} url={{ page.url }} \ | ||
| 285 | site={{ site.title }}{{ body | safe }}</body></html>", | ||
| 286 | ) | ||
| 287 | .unwrap(); | ||
| 288 | let out = root.join("out"); | ||
| 289 | build(&src, &out); | ||
| 290 | |||
| 291 | let post = page(&out, "blog/post.html"); | ||
| 292 | assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}"); | ||
| 293 | assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}"); | ||
| 294 | assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}"); | ||
| 295 | assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}"); | ||
| 296 | } | ||
| 297 | |||
| 298 | /// Off by default, because it trades incremental precision for the ability to write | ||
| 299 | /// listing pages — and that trade should be a choice. | ||
| 300 | #[test] | ||
| 301 | fn the_page_list_is_opt_in_and_widens_invalidation() { | ||
| 302 | let root = tmpdir("pagelist"); | ||
| 303 | let src = root.join("src"); | ||
| 304 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 305 | write_site(&src); | ||
| 306 | std::fs::write( | ||
| 307 | src.join("org-ssg.toml"), | ||
| 308 | "[templates]\nexpose_page_list = true\n", | ||
| 309 | ) | ||
| 310 | .unwrap(); | ||
| 311 | std::fs::write( | ||
| 312 | src.join("templates/base.html"), | ||
| 313 | "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\ | ||
| 314 | {{ body | safe }}</body></html>", | ||
| 315 | ) | ||
| 316 | .unwrap(); | ||
| 317 | let out = root.join("out"); | ||
| 318 | build(&src, &out); | ||
| 319 | |||
| 320 | let home = page(&out, "index.html"); | ||
| 321 | for title in ["Home", "About", "A Post"] { | ||
| 322 | assert!(home.contains(title), "an index can list {title}:\n{home}"); | ||
| 323 | } | ||
| 324 | |||
| 325 | // With every page visible to every template, adding one must re-render them all — | ||
| 326 | // the opposite of the default, and the documented cost of turning this on. | ||
| 327 | std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap(); | ||
| 328 | let report = build(&src, &out); | ||
| 329 | assert_eq!( | ||
| 330 | report.rendered.len(), | ||
| 331 | 4, | ||
| 332 | "with the page list exposed, adding a page re-renders the site" | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | |||
| 336 | // --------------------------------------------------------------------------- | ||
| 337 | // Output settings | ||
| 338 | // --------------------------------------------------------------------------- | ||
| 339 | |||
| 340 | /// The default layout renders the page title as `<h1>`, so section headings belong | ||
| 341 | /// beneath it — which is also what Emacs does by default. | ||
| 342 | #[test] | ||
| 343 | fn heading_offset_shifts_content_headings_below_the_page_title() { | ||
| 344 | let root = tmpdir("hoffset"); | ||
| 345 | let src = root.join("src"); | ||
| 346 | std::fs::create_dir_all(&src).unwrap(); | ||
| 347 | std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap(); | ||
| 348 | let out = root.join("out"); | ||
| 349 | build(&src, &out); | ||
| 350 | assert!( | ||
| 351 | page(&out, "index.html").contains("<h2 id=\"section\">"), | ||
| 352 | "a level-1 org heading renders as <h2> by default" | ||
| 353 | ); | ||
| 354 | |||
| 355 | std::fs::write(src.join("org-ssg.toml"), "[html]\nheading_offset = 0\n").unwrap(); | ||
| 356 | let out2 = root.join("out2"); | ||
| 357 | build(&src, &out2); | ||
| 358 | assert!( | ||
| 359 | page(&out2, "index.html").contains("<h1 id=\"section\">"), | ||
| 360 | "offset 0 leaves headings where the document put them" | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | |||
| 364 | /// An unknown theme silently produces an empty stylesheet, which looks exactly like | ||
| 365 | /// highlighting being broken. Naming the valid options turns a mystery into a typo. | ||
| 366 | #[test] | ||
| 367 | fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() { | ||
| 368 | let root = tmpdir("theme"); | ||
| 369 | let src = root.join("src"); | ||
| 370 | std::fs::create_dir_all(&src).unwrap(); | ||
| 371 | write_site(&src); | ||
| 372 | std::fs::write(src.join("org-ssg.toml"), "[highlight]\ntheme = \"nope\"\n").unwrap(); | ||
| 373 | |||
| 374 | let err = build_site(&src, &root.join("out"), &BuildOptions::default()) | ||
| 375 | .expect_err("unknown theme must fail"); | ||
| 376 | let message = format!("{err:#}"); | ||
| 377 | assert!(message.contains("nope"), "names the bad theme: {message}"); | ||
| 378 | assert!( | ||
| 379 | message.contains("InspiredGitHub"), | ||
| 380 | "lists what is available: {message}" | ||
| 381 | ); | ||
| 382 | } | ||
| 383 | |||
| 384 | // --------------------------------------------------------------------------- | ||
| 385 | // Discovery | ||
| 386 | // --------------------------------------------------------------------------- | ||
| 387 | |||
| 388 | /// `org-ssg build . -o _site` is the obvious thing to type. Without excluding the output | ||
| 389 | /// directory, the build copies its own output back into itself, growing `_site/_site/…` | ||
| 390 | /// on every run. | ||
| 391 | #[test] | ||
| 392 | fn an_output_directory_inside_the_source_is_not_swallowed() { | ||
| 393 | let root = tmpdir("nested"); | ||
| 394 | let src = root.join("src"); | ||
| 395 | std::fs::create_dir_all(&src).unwrap(); | ||
| 396 | write_site(&src); | ||
| 397 | let out = src.join("_site"); | ||
| 398 | |||
| 399 | for _ in 0..3 { | ||
| 400 | build(&src, &out); | ||
| 401 | } | ||
| 402 | assert!(!out.join("_site").exists(), "output must not nest inside itself"); | ||
| 403 | |||
| 404 | let report = build(&src, &out); | ||
| 405 | assert_eq!(report.pages.len(), 3, "still exactly the source pages"); | ||
| 406 | assert!( | ||
| 407 | report.assets.is_empty(), | ||
| 408 | "no output file is mistaken for an asset: {:?}", | ||
| 409 | report.assets | ||
| 410 | ); | ||
| 411 | } | ||
| 412 | |||
| 413 | /// A source directory is very often a git repository. Publishing `.git` alongside the | ||
| 414 | /// homepage leaks a project's entire history. | ||
| 415 | #[test] | ||
| 416 | fn dot_directories_and_build_inputs_are_never_published() { | ||
| 417 | let root = tmpdir("dotfiles"); | ||
| 418 | let src = root.join("src"); | ||
| 419 | std::fs::create_dir_all(src.join(".git")).unwrap(); | ||
| 420 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 421 | write_site(&src); | ||
| 422 | std::fs::write(src.join(".git/config"), "[remote]\nurl = private\n").unwrap(); | ||
| 423 | std::fs::write(src.join(".env"), "SECRET=hunter2\n").unwrap(); | ||
| 424 | std::fs::write(src.join("org-ssg.toml"), "[site]\ntitle = \"T\"\n").unwrap(); | ||
| 425 | std::fs::write(src.join("templates/base.html"), "<html>{{ body | safe }}</html>").unwrap(); | ||
| 426 | std::fs::write(src.join("style.css"), "body{}\n").unwrap(); | ||
| 427 | let out = root.join("out"); | ||
| 428 | |||
| 429 | let report = build(&src, &out); | ||
| 430 | assert!(!out.join(".git").exists(), ".git must never be published"); | ||
| 431 | assert!(!out.join(".env").exists(), "dotfiles must never be published"); | ||
| 432 | assert!( | ||
| 433 | !out.join("org-ssg.toml").exists(), | ||
| 434 | "the config is a build input, not content" | ||
| 435 | ); | ||
| 436 | assert!( | ||
| 437 | !out.join("templates").exists(), | ||
| 438 | "templates are build inputs, not content" | ||
| 439 | ); | ||
| 440 | assert_eq!( | ||
| 441 | report.assets, | ||
| 442 | vec![Utf8PathBuf::from("style.css")], | ||
| 443 | "genuine assets still copy through" | ||
| 444 | ); | ||
| 445 | } | ||
tests/constructs.rs +3 −1
| @@ -160,7 +160,9 @@ fn highlighting_emits_classes_not_inline_styles() { | |||
| 160 | "highlighting must not emit inline styles:\n{html}" | 160 | "highlighting must not emit inline styles:\n{html}" |
| 161 | ); | 161 | ); |
| 162 | assert!( | 162 | assert!( |
| 163 | org_ssg::render::syntax_css().contains(".storage"), | 163 | org_ssg::render::syntax_css("InspiredGitHub") |
| 164 | .expect("a built-in theme") | ||
| 165 | .contains(".storage"), | ||
| 164 | "the generated stylesheet must define the emitted classes" | 166 | "the generated stylesheet must define the emitted classes" |
| 165 | ); | 167 | ); |
| 166 | } | 168 | } |
tests/incremental.rs +2 −2
| @@ -85,7 +85,7 @@ fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() { | |||
| 85 | &full, | 85 | &full, |
| 86 | &BuildOptions { | 86 | &BuildOptions { |
| 87 | no_cache: true, | 87 | no_cache: true, |
| 88 | strict: false, | 88 | ..Default::default() |
| 89 | }, | 89 | }, |
| 90 | ) | 90 | ) |
| 91 | .unwrap(); | 91 | .unwrap(); |
| @@ -329,7 +329,7 @@ fn parallel_builds_are_deterministic_in_output_and_report_order() { | |||
| 329 | out, | 329 | out, |
| 330 | &BuildOptions { | 330 | &BuildOptions { |
| 331 | no_cache: true, | 331 | no_cache: true, |
| 332 | strict: false, | 332 | ..Default::default() |
| 333 | }, | 333 | }, |
| 334 | ) | 334 | ) |
| 335 | .unwrap() | 335 | .unwrap() |
tests/oracle.el +3 −4
| @@ -16,10 +16,9 @@ | |||
| 16 | ;; learn what stock org does — normalizing that away would be marking our own homework. | 16 | ;; learn what stock org does — normalizing that away would be marking our own homework. |
| 17 | (setq org-export-with-toc nil ; we emit no table of contents | 17 | (setq org-export-with-toc nil ; we emit no table of contents |
| 18 | org-export-with-section-numbers nil ; we do not number headings | 18 | org-export-with-section-numbers nil ; we do not number headings |
| 19 | org-html-toplevel-hlevel 1 ; org defaults to h2 for a level-1 heading, | 19 | ;; org-html-toplevel-hlevel is left at its default of 2. org-ssg's own default |
| 20 | ; because a template supplies the page <h1>. | 20 | ;; heading_offset is 1, which produces the same <h2>, so both sides now agree |
| 21 | ; Aligning here keeps a global +1 offset from | 21 | ;; without the oracle being told to. |
| 22 | ; drowning every real finding in the diff. | ||
| 23 | org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight | 22 | org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight |
| 24 | ; with syntect, so comparing code *text* is | 23 | ; with syntect, so comparing code *text* is |
| 25 | ; the meaningful part | 24 | ; the meaningful part |
tests/snapshots/constructs__blocks_html.snap +6 −6
| @@ -2,25 +2,25 @@ | |||
| 2 | source: tests/constructs.rs | 2 | source: tests/constructs.rs |
| 3 | expression: "render_fixture(\"blocks.org\")" | 3 | expression: "render_fixture(\"blocks.org\")" |
| 4 | --- | 4 | --- |
| 5 | <h1 id="quote">Quote</h1> | 5 | <h2 id="quote">Quote</h2> |
| 6 | <blockquote> | 6 | <blockquote> |
| 7 | <p>A quoted paragraph with <em>markup</em>.</p> | 7 | <p>A quoted paragraph with <em>markup</em>.</p> |
| 8 | <p>And a second paragraph.</p> | 8 | <p>And a second paragraph.</p> |
| 9 | </blockquote> | 9 | </blockquote> |
| 10 | <h1 id="center">Center</h1> | 10 | <h2 id="center">Center</h2> |
| 11 | <div class="center"> | 11 | <div class="center"> |
| 12 | <p>Centred text.</p> | 12 | <p>Centred text.</p> |
| 13 | </div> | 13 | </div> |
| 14 | <h1 id="example">Example</h1> | 14 | <h2 id="example">Example</h2> |
| 15 | <pre>Verbatim *not bold* text. | 15 | <pre>Verbatim *not bold* text. |
| 16 | Indentation preserved.</pre> | 16 | Indentation preserved.</pre> |
| 17 | <h1 id="export">Export</h1> | 17 | <h2 id="export">Export</h2> |
| 18 | <aside class="raw">Raw HTML passes through.</aside> | 18 | <aside class="raw">Raw HTML passes through.</aside> |
| 19 | <h1 id="source">Source</h1> | 19 | <h2 id="source">Source</h2> |
| 20 | <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> | 20 | <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> |
| 21 | <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> | 21 | <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> |
| 22 | <pre><code class="language-none">plain block, no language</code></pre> | 22 | <pre><code class="language-none">plain block, no language</code></pre> |
| 23 | <h1 id="nested">Nested</h1> | 23 | <h2 id="nested">Nested</h2> |
| 24 | <blockquote> | 24 | <blockquote> |
| 25 | <p>A quote containing a source block:</p> | 25 | <p>A quote containing a source block:</p> |
| 26 | <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> | 26 | <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> |
tests/snapshots/constructs__headings_html.snap +5 −5
| @@ -2,13 +2,13 @@ | |||
| 2 | source: tests/constructs.rs | 2 | source: tests/constructs.rs |
| 3 | expression: "render_fixture(\"headings.org\")" | 3 | expression: "render_fixture(\"headings.org\")" |
| 4 | --- | 4 | --- |
| 5 | <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> | 5 | <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> |
| 6 | <p>A heading carrying a keyword, a priority, tags and a property drawer.</p> | 6 | <p>A heading carrying a keyword, a priority, tags and a property drawer.</p> |
| 7 | <h2 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h2> | 7 | <h3 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h3> |
| 8 | <p>Sub-headings nest by star count.</p> | 8 | <p>Sub-headings nest by star count.</p> |
| 9 | <h2 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h2> | 9 | <h3 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h3> |
| 10 | <p>A priority cookie can stand alone.</p> | 10 | <p>A priority cookie can stand alone.</p> |
| 11 | <h1 id="todos-are-not-a-keyword">TODOs are not a keyword</h1> | 11 | <h2 id="todos-are-not-a-keyword">TODOs are not a keyword</h2> |
| 12 | <p>The word boundary matters: this heading has no TODO keyword.</p> | 12 | <p>The word boundary matters: this heading has no TODO keyword.</p> |
| 13 | <h1><span class="done DONE">DONE</span> </h1> | 13 | <h2><span class="done DONE">DONE</span> </h2> |
| 14 | <p>A keyword with no title at all.</p> | 14 | <p>A keyword with no title at all.</p> |
tests/snapshots/constructs__images_html.snap +5 −5
| @@ -2,13 +2,13 @@ | |||
| 2 | source: tests/constructs.rs | 2 | source: tests/constructs.rs |
| 3 | expression: "render_fixture(\"images.org\")" | 3 | expression: "render_fixture(\"images.org\")" |
| 4 | --- | 4 | --- |
| 5 | <h1 id="bare-image">Bare image</h1> | 5 | <h2 id="bare-image">Bare image</h2> |
| 6 | <p><img src="diagram.png" alt=""></p> | 6 | <p><img src="diagram.png" alt=""></p> |
| 7 | <h1 id="captioned-figure">Captioned figure</h1> | 7 | <h2 id="captioned-figure">Captioned figure</h2> |
| 8 | <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> | 8 | <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> |
| 9 | <h1 id="caption-with-markup">Caption with markup</h1> | 9 | <h2 id="caption-with-markup">Caption with markup</h2> |
| 10 | <figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> | 10 | <figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> |
| 11 | <h1 id="quoted-attribute-values">Quoted attribute values</h1> | 11 | <h2 id="quoted-attribute-values">Quoted attribute values</h2> |
| 12 | <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> | 12 | <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> |
| 13 | <h1 id="image-with-a-description-is-a-link">Image with a description is a link</h1> | 13 | <h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2> |
| 14 | <p><a href="diagram.png">the diagram</a></p> | 14 | <p><a href="diagram.png">the diagram</a></p> |
tests/snapshots/constructs__lists_html.snap +5 −5
| @@ -2,7 +2,7 @@ | |||
| 2 | source: tests/constructs.rs | 2 | source: tests/constructs.rs |
| 3 | expression: "render_fixture(\"lists.org\")" | 3 | expression: "render_fixture(\"lists.org\")" |
| 4 | --- | 4 | --- |
| 5 | <h1 id="nesting">Nesting</h1> | 5 | <h2 id="nesting">Nesting</h2> |
| 6 | <ul> | 6 | <ul> |
| 7 | <li>outer item<ul> | 7 | <li>outer item<ul> |
| 8 | <li>inner item<ul> | 8 | <li>inner item<ul> |
| @@ -14,7 +14,7 @@ expression: "render_fixture(\"lists.org\")" | |||
| 14 | </li> | 14 | </li> |
| 15 | <li>second outer</li> | 15 | <li>second outer</li> |
| 16 | </ul> | 16 | </ul> |
| 17 | <h1 id="ordered">Ordered</h1> | 17 | <h2 id="ordered">Ordered</h2> |
| 18 | <ol> | 18 | <ol> |
| 19 | <li>first</li> | 19 | <li>first</li> |
| 20 | <li>second<ol> | 20 | <li>second<ol> |
| @@ -24,13 +24,13 @@ expression: "render_fixture(\"lists.org\")" | |||
| 24 | </li> | 24 | </li> |
| 25 | <li>third</li> | 25 | <li>third</li> |
| 26 | </ol> | 26 | </ol> |
| 27 | <h1 id="checkboxes">Checkboxes</h1> | 27 | <h2 id="checkboxes">Checkboxes</h2> |
| 28 | <ul> | 28 | <ul> |
| 29 | <li><input type="checkbox" disabled> not done</li> | 29 | <li><input type="checkbox" disabled> not done</li> |
| 30 | <li><input type="checkbox" disabled checked> done</li> | 30 | <li><input type="checkbox" disabled checked> done</li> |
| 31 | <li><input type="checkbox" disabled> partially done</li> | 31 | <li><input type="checkbox" disabled> partially done</li> |
| 32 | </ul> | 32 | </ul> |
| 33 | <h1 id="description">Description</h1> | 33 | <h2 id="description">Description</h2> |
| 34 | <dl> | 34 | <dl> |
| 35 | <dt>term one</dt> | 35 | <dt>term one</dt> |
| 36 | <dd>the first definition</dd> | 36 | <dd>the first definition</dd> |
| @@ -39,7 +39,7 @@ expression: "render_fixture(\"lists.org\")" | |||
| 39 | <dt><em>marked up</em> term</dt> | 39 | <dt><em>marked up</em> term</dt> |
| 40 | <dd>definitions hold inline markup</dd> | 40 | <dd>definitions hold inline markup</dd> |
| 41 | </dl> | 41 | </dl> |
| 42 | <h1 id="multi-paragraph-items">Multi-paragraph items</h1> | 42 | <h2 id="multi-paragraph-items">Multi-paragraph items</h2> |
| 43 | <ul> | 43 | <ul> |
| 44 | <li><p>an item whose body has two paragraphs</p> | 44 | <li><p>an item whose body has two paragraphs</p> |
| 45 | <p>the second paragraph, indented under the bullet</p> | 45 | <p>the second paragraph, indented under the bullet</p> |
tests/snapshots/constructs__out_of_scope_html.snap +7 −7
| @@ -3,9 +3,9 @@ source: tests/constructs.rs | |||
| 3 | expression: "render_fixture(\"outofscope.org\")" | 3 | expression: "render_fixture(\"outofscope.org\")" |
| 4 | --- | 4 | --- |
| 5 | <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> | 5 | <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> |
| 6 | <h1 id="babel">Babel</h1> | 6 | <h2 id="babel">Babel</h2> |
| 7 | <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> | 7 | <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> |
| 8 | <h1 id="table-formulas">Table formulas</h1> | 8 | <h2 id="table-formulas">Table formulas</h2> |
| 9 | <table> | 9 | <table> |
| 10 | <thead> | 10 | <thead> |
| 11 | <tr><th>item</th><th>cost</th></tr> | 11 | <tr><th>item</th><th>cost</th></tr> |
| @@ -15,14 +15,14 @@ expression: "render_fixture(\"outofscope.org\")" | |||
| 15 | <tr><td>b</td><td>2</td></tr> | 15 | <tr><td>b</td><td>2</td></tr> |
| 16 | </tbody> | 16 | </tbody> |
| 17 | </table> | 17 | </table> |
| 18 | <h1 id="latex">LaTeX</h1> | 18 | <h2 id="latex">LaTeX</h2> |
| 19 | <p>Inline math $x^2 + y^2$ and a display block:</p> | 19 | <p>Inline math $x^2 + y^2$ and a display block:</p> |
| 20 | <p>\begin{equation} E = mc^2 \end{equation}</p> | 20 | <p>\begin{equation} E = mc^2 \end{equation}</p> |
| 21 | <h1 id="macros-and-radio-targets">Macros and radio targets</h1> | 21 | <h2 id="macros-and-radio-targets">Macros and radio targets</h2> |
| 22 | <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> | 22 | <p>A macro call {{{author}}} and a <<<radio target>>> stay literal.</p> |
| 23 | <h1 id="drawers">Drawers</h1> | 23 | <h2 id="drawers">Drawers</h2> |
| 24 | <h1 id="verse">Verse</h1> | 24 | <h2 id="verse">Verse</h2> |
| 25 | <pre>An unmodelled block type | 25 | <pre>An unmodelled block type |
| 26 | keeps its content verbatim.</pre> | 26 | keeps its content verbatim.</pre> |
| 27 | <h1 id="entities">Entities</h1> | 27 | <h2 id="entities">Entities</h2> |
| 28 | <p>The full entity set is out of scope, so \alpha stays literal.</p> | 28 | <p>The full entity set is out of scope, so \alpha stays literal.</p> |
tests/snapshots/constructs__timestamps_html.snap +4 −4
| @@ -2,13 +2,13 @@ | |||
| 2 | source: tests/constructs.rs | 2 | source: tests/constructs.rs |
| 3 | expression: "render_fixture(\"timestamps.org\")" | 3 | expression: "render_fixture(\"timestamps.org\")" |
| 4 | --- | 4 | --- |
| 5 | <h1 id="single">Single</h1> | 5 | <h2 id="single">Single</h2> |
| 6 | <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> | 6 | <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> |
| 7 | <p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p> | 7 | <p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p> |
| 8 | <h1 id="ranges">Ranges</h1> | 8 | <h2 id="ranges">Ranges</h2> |
| 9 | <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> | 9 | <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> |
| 10 | <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> | 10 | <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> |
| 11 | <h1 id="ignored-decorations">Ignored decorations</h1> | 11 | <h2 id="ignored-decorations">Ignored decorations</h2> |
| 12 | <p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p> | 12 | <p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p> |
| 13 | <h1 id="not-timestamps">Not timestamps</h1> | 13 | <h2 id="not-timestamps">Not timestamps</h2> |
| 14 | <p>Comparisons like 3 < 4 and [not a stamp] stay literal text.</p> | 14 | <p>Comparisons like 3 < 4 and [not a stamp] stay literal text.</p> |
tests/snapshots/oracle__oracle_blocks.snap +12 −12
| @@ -5,9 +5,9 @@ expression: report | |||
| 5 | agreement: 51/59 skeleton lines (86.4%) | 5 | agreement: 51/59 skeleton lines (86.4%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | "Quote" | 9 | "Quote" |
| 10 | </h1> | 10 | </h2> |
| 11 | <blockquote> | 11 | <blockquote> |
| 12 | <p> | 12 | <p> |
| 13 | "A quoted paragraph with" | 13 | "A quoted paragraph with" |
| @@ -22,27 +22,27 @@ agreement: 51/59 skeleton lines (86.4%) | |||
| 22 | "And a second paragraph." | 22 | "And a second paragraph." |
| 23 | </p> | 23 | </p> |
| 24 | </blockquote> | 24 | </blockquote> |
| 25 | <h1> | 25 | <h2> |
| 26 | "Center" | 26 | "Center" |
| 27 | </h1> | 27 | </h2> |
| 28 | <p> | 28 | <p> |
| 29 | "Centred text." | 29 | "Centred text." |
| 30 | </p> | 30 | </p> |
| 31 | <h1> | 31 | <h2> |
| 32 | "Example" | 32 | "Example" |
| 33 | </h1> | 33 | </h2> |
| 34 | <pre> | 34 | <pre> |
| 35 | "Verbatim *not bold* text. Indentation preserved." | 35 | "Verbatim *not bold* text. Indentation preserved." |
| 36 | </pre> | 36 | </pre> |
| 37 | <h1> | 37 | <h2> |
| 38 | "Export" | 38 | "Export" |
| 39 | </h1> | 39 | </h2> |
| 40 | <aside> | 40 | <aside> |
| 41 | "Raw HTML passes through." | 41 | "Raw HTML passes through." |
| 42 | </aside> | 42 | </aside> |
| 43 | <h1> | 43 | <h2> |
| 44 | "Source" | 44 | "Source" |
| 45 | </h1> | 45 | </h2> |
| 46 | <pre> | 46 | <pre> |
| 47 | - <code> | 47 | - <code> |
| 48 | "def greet(name): return f\"hello {name}\"" | 48 | "def greet(name): return f\"hello {name}\"" |
| @@ -53,9 +53,9 @@ agreement: 51/59 skeleton lines (86.4%) | |||
| 53 | "plain block, no language" | 53 | "plain block, no language" |
| 54 | - </code> | 54 | - </code> |
| 55 | </pre> | 55 | </pre> |
| 56 | <h1> | 56 | <h2> |
| 57 | "Nested" | 57 | "Nested" |
| 58 | </h1> | 58 | </h2> |
| 59 | <blockquote> | 59 | <blockquote> |
| 60 | <p> | 60 | <p> |
| 61 | "A quote containing a source block:" | 61 | "A quote containing a source block:" |
tests/snapshots/oracle__oracle_core.snap +4 −4
| @@ -16,9 +16,9 @@ agreement: 45/54 skeleton lines (83.3%) | |||
| 16 | </code> | 16 | </code> |
| 17 | "." | 17 | "." |
| 18 | </p> | 18 | </p> |
| 19 | <h1> | 19 | <h2> |
| 20 | "Ordered and checked" | 20 | "Ordered and checked" |
| 21 | </h1> | 21 | </h2> |
| 22 | <ol> | 22 | <ol> |
| 23 | <li> | 23 | <li> |
| 24 | "first item" | 24 | "first item" |
| @@ -49,9 +49,9 @@ agreement: 45/54 skeleton lines (83.3%) | |||
| 49 | </li> | 49 | </li> |
| 50 | - </ul> | 50 | - </ul> |
| 51 | + </ol> | 51 | + </ol> |
| 52 | <h1> | 52 | <h2> |
| 53 | "Links and code" | 53 | "Links and code" |
| 54 | </h1> | 54 | </h2> |
| 55 | <p> | 55 | <p> |
| 56 | "An external" | 56 | "An external" |
| 57 | <a href="https://example.org"> | 57 | <a href="https://example.org"> |
tests/snapshots/oracle__oracle_elements.snap +6 −6
| @@ -5,9 +5,9 @@ expression: report | |||
| 5 | agreement: 64/82 skeleton lines (78.0%) | 5 | agreement: 64/82 skeleton lines (78.0%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | "Code and tables" | 9 | "Code and tables" |
| 10 | </h1> | 10 | </h2> |
| 11 | <pre> | 11 | <pre> |
| 12 | - <code> | 12 | - <code> |
| 13 | "fn main() { println!(\"hello\"); }" | 13 | "fn main() { println!(\"hello\"); }" |
| @@ -47,9 +47,9 @@ agreement: 64/82 skeleton lines (78.0%) | |||
| 47 | </tr> | 47 | </tr> |
| 48 | </tbody> | 48 | </tbody> |
| 49 | </table> | 49 | </table> |
| 50 | <h1> | 50 | <h2> |
| 51 | "Links and footnotes" | 51 | "Links and footnotes" |
| 52 | </h1> | 52 | </h2> |
| 53 | <p> | 53 | <p> |
| 54 | "An external link:" | 54 | "An external link:" |
| 55 | <a href="https://example.com"> | 55 | <a href="https://example.com"> |
| @@ -71,9 +71,9 @@ agreement: 64/82 skeleton lines (78.0%) | |||
| 71 | </a> | 71 | </a> |
| 72 | </sup> | 72 | </sup> |
| 73 | </p> | 73 | </p> |
| 74 | <h1> | 74 | <h2> |
| 75 | "Blocks" | 75 | "Blocks" |
| 76 | </h1> | 76 | </h2> |
| 77 | <blockquote> | 77 | <blockquote> |
| 78 | <p> | 78 | <p> |
| 79 | "A quoted paragraph." | 79 | "A quoted paragraph." |
tests/snapshots/oracle__oracle_headings.snap +10 −10
| @@ -5,35 +5,35 @@ expression: report | |||
| 5 | agreement: 28/30 skeleton lines (93.3%) | 5 | agreement: 28/30 skeleton lines (93.3%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | - "TODO [#A] Write the parser work rust" | 9 | - "TODO [#A] Write the parser work rust" |
| 10 | + "TODO Write the parser work rust" | 10 | + "TODO Write the parser work rust" |
| 11 | </h1> | 11 | </h2> |
| 12 | <p> | 12 | <p> |
| 13 | "A heading carrying a keyword, a priority, tags and a property drawer." | 13 | "A heading carrying a keyword, a priority, tags and a property drawer." |
| 14 | </p> | 14 | </p> |
| 15 | <h2> | 15 | <h3> |
| 16 | "DONE Nested and finished" | 16 | "DONE Nested and finished" |
| 17 | </h2> | 17 | </h3> |
| 18 | <p> | 18 | <p> |
| 19 | "Sub-headings nest by star count." | 19 | "Sub-headings nest by star count." |
| 20 | </p> | 20 | </p> |
| 21 | <h2> | 21 | <h3> |
| 22 | - "[#C] Priority without a keyword" | 22 | - "[#C] Priority without a keyword" |
| 23 | + "Priority without a keyword" | 23 | + "Priority without a keyword" |
| 24 | </h2> | 24 | </h3> |
| 25 | <p> | 25 | <p> |
| 26 | "A priority cookie can stand alone." | 26 | "A priority cookie can stand alone." |
| 27 | </p> | 27 | </p> |
| 28 | <h1> | 28 | <h2> |
| 29 | "TODOs are not a keyword" | 29 | "TODOs are not a keyword" |
| 30 | </h1> | 30 | </h2> |
| 31 | <p> | 31 | <p> |
| 32 | "The word boundary matters: this heading has no TODO keyword." | 32 | "The word boundary matters: this heading has no TODO keyword." |
| 33 | </p> | 33 | </p> |
| 34 | <h1> | 34 | <h2> |
| 35 | "DONE" | 35 | "DONE" |
| 36 | </h1> | 36 | </h2> |
| 37 | <p> | 37 | <p> |
| 38 | "A keyword with no title at all." | 38 | "A keyword with no title at all." |
| 39 | </p> | 39 | </p> |
tests/snapshots/oracle__oracle_images.snap +10 −10
| @@ -5,15 +5,15 @@ expression: report | |||
| 5 | agreement: 28/42 skeleton lines (66.7%) | 5 | agreement: 28/42 skeleton lines (66.7%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | "Bare image" | 9 | "Bare image" |
| 10 | </h1> | 10 | </h2> |
| 11 | <p> | 11 | <p> |
| 12 | <img src="diagram.png"> | 12 | <img src="diagram.png"> |
| 13 | </p> | 13 | </p> |
| 14 | <h1> | 14 | <h2> |
| 15 | "Captioned figure" | 15 | "Captioned figure" |
| 16 | </h1> | 16 | </h2> |
| 17 | - <figure> | 17 | - <figure> |
| 18 | + <p> | 18 | + <p> |
| 19 | <img src="pipeline.svg"> | 19 | <img src="pipeline.svg"> |
| @@ -25,9 +25,9 @@ agreement: 28/42 skeleton lines (66.7%) | |||
| 25 | + <p> | 25 | + <p> |
| 26 | + "Figure 1: The pipeline, end to end" | 26 | + "Figure 1: The pipeline, end to end" |
| 27 | + </p> | 27 | + </p> |
| 28 | <h1> | 28 | <h2> |
| 29 | "Caption with markup" | 29 | "Caption with markup" |
| 30 | </h1> | 30 | </h2> |
| 31 | - <figure> | 31 | - <figure> |
| 32 | + <p> | 32 | + <p> |
| 33 | <img src="chart.png"> | 33 | <img src="chart.png"> |
| @@ -45,17 +45,17 @@ agreement: 28/42 skeleton lines (66.7%) | |||
| 45 | - </figcaption> | 45 | - </figcaption> |
| 46 | - </figure> | 46 | - </figure> |
| 47 | + </p> | 47 | + </p> |
| 48 | <h1> | 48 | <h2> |
| 49 | "Quoted attribute values" | 49 | "Quoted attribute values" |
| 50 | </h1> | 50 | </h2> |
| 51 | - <figure> | 51 | - <figure> |
| 52 | + <p> | 52 | + <p> |
| 53 | <img src="cat.jpg"> | 53 | <img src="cat.jpg"> |
| 54 | - </figure> | 54 | - </figure> |
| 55 | + </p> | 55 | + </p> |
| 56 | <h1> | 56 | <h2> |
| 57 | "Image with a description is a link" | 57 | "Image with a description is a link" |
| 58 | </h1> | 58 | </h2> |
| 59 | <p> | 59 | <p> |
| 60 | <a href="diagram.png"> | 60 | <a href="diagram.png"> |
| 61 | "the diagram" | 61 | "the diagram" |
tests/snapshots/oracle__oracle_lists.snap +10 −10
| @@ -5,9 +5,9 @@ expression: report | |||
| 5 | agreement: 100/111 skeleton lines (90.1%) | 5 | agreement: 100/111 skeleton lines (90.1%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | "Nesting" | 9 | "Nesting" |
| 10 | </h1> | 10 | </h2> |
| 11 | <ul> | 11 | <ul> |
| 12 | <li> | 12 | <li> |
| 13 | "outer item" | 13 | "outer item" |
| @@ -29,9 +29,9 @@ agreement: 100/111 skeleton lines (90.1%) | |||
| 29 | "second outer" | 29 | "second outer" |
| 30 | </li> | 30 | </li> |
| 31 | </ul> | 31 | </ul> |
| 32 | <h1> | 32 | <h2> |
| 33 | "Ordered" | 33 | "Ordered" |
| 34 | </h1> | 34 | </h2> |
| 35 | <ol> | 35 | <ol> |
| 36 | <li> | 36 | <li> |
| 37 | "first" | 37 | "first" |
| @@ -51,9 +51,9 @@ agreement: 100/111 skeleton lines (90.1%) | |||
| 51 | "third" | 51 | "third" |
| 52 | </li> | 52 | </li> |
| 53 | </ol> | 53 | </ol> |
| 54 | <h1> | 54 | <h2> |
| 55 | "Checkboxes" | 55 | "Checkboxes" |
| 56 | </h1> | 56 | </h2> |
| 57 | <ul> | 57 | <ul> |
| 58 | <li> | 58 | <li> |
| 59 | - <input> | 59 | - <input> |
| @@ -77,9 +77,9 @@ agreement: 100/111 skeleton lines (90.1%) | |||
| 77 | "partially done" | 77 | "partially done" |
| 78 | </li> | 78 | </li> |
| 79 | </ul> | 79 | </ul> |
| 80 | <h1> | 80 | <h2> |
| 81 | "Description" | 81 | "Description" |
| 82 | </h1> | 82 | </h2> |
| 83 | <dl> | 83 | <dl> |
| 84 | <dt> | 84 | <dt> |
| 85 | "term one" | 85 | "term one" |
| @@ -105,9 +105,9 @@ agreement: 100/111 skeleton lines (90.1%) | |||
| 105 | "definitions hold inline markup" | 105 | "definitions hold inline markup" |
| 106 | </dd> | 106 | </dd> |
| 107 | </dl> | 107 | </dl> |
| 108 | <h1> | 108 | <h2> |
| 109 | "Multi-paragraph items" | 109 | "Multi-paragraph items" |
| 110 | </h1> | 110 | </h2> |
| 111 | <ul> | 111 | <ul> |
| 112 | <li> | 112 | <li> |
| 113 | <p> | 113 | <p> |
tests/snapshots/oracle__oracle_minimal.snap +6 −6
| @@ -8,9 +8,9 @@ agreement: 38/42 skeleton lines (90.5%) | |||
| 8 | <p> | 8 | <p> |
| 9 | "A single paragraph of preamble text before any heading." | 9 | "A single paragraph of preamble text before any heading." |
| 10 | </p> | 10 | </p> |
| 11 | <h1> | 11 | <h2> |
| 12 | "First Heading" | 12 | "First Heading" |
| 13 | </h1> | 13 | </h2> |
| 14 | <p> | 14 | <p> |
| 15 | "Some body text with" | 15 | "Some body text with" |
| 16 | - <strong> | 16 | - <strong> |
| @@ -30,9 +30,9 @@ agreement: 38/42 skeleton lines (90.5%) | |||
| 30 | </code> | 30 | </code> |
| 31 | "." | 31 | "." |
| 32 | </p> | 32 | </p> |
| 33 | <h2> | 33 | <h3> |
| 34 | "A Subheading tag1 tag2" | 34 | "A Subheading tag1 tag2" |
| 35 | </h2> | 35 | </h3> |
| 36 | <ul> | 36 | <ul> |
| 37 | <li> | 37 | <li> |
| 38 | "an unordered item" | 38 | "an unordered item" |
| @@ -41,9 +41,9 @@ agreement: 38/42 skeleton lines (90.5%) | |||
| 41 | "another with a checkbox [ ]" | 41 | "another with a checkbox [ ]" |
| 42 | </li> | 42 | </li> |
| 43 | </ul> | 43 | </ul> |
| 44 | <h1> | 44 | <h2> |
| 45 | "Second Heading" | 45 | "Second Heading" |
| 46 | </h1> | 46 | </h2> |
| 47 | <p> | 47 | <p> |
| 48 | "See" | 48 | "See" |
| 49 | <a href="#first"> | 49 | <a href="#first"> |
tests/snapshots/oracle__oracle_timestamps.snap +8 −8
| @@ -5,9 +5,9 @@ expression: report | |||
| 5 | agreement: 25/62 skeleton lines (40.3%) | 5 | agreement: 25/62 skeleton lines (40.3%) |
| 6 | (- org-ssg, + emacs) | 6 | (- org-ssg, + emacs) |
| 7 | 7 | ||
| 8 | <h1> | 8 | <h2> |
| 9 | "Single" | 9 | "Single" |
| 10 | </h1> | 10 | </h2> |
| 11 | <p> | 11 | <p> |
| 12 | - "An active date" | 12 | - "An active date" |
| 13 | - <time> | 13 | - <time> |
| @@ -28,9 +28,9 @@ agreement: 25/62 skeleton lines (40.3%) | |||
| 28 | - "." | 28 | - "." |
| 29 | + "With a time: <2024-01-15 Mon 10:30>." | 29 | + "With a time: <2024-01-15 Mon 10:30>." |
| 30 | </p> | 30 | </p> |
| 31 | <h1> | 31 | <h2> |
| 32 | "Ranges" | 32 | "Ranges" |
| 33 | </h1> | 33 | </h2> |
| 34 | <p> | 34 | <p> |
| 35 | - "A same-day time range" | 35 | - "A same-day time range" |
| 36 | - <time> | 36 | - <time> |
| @@ -55,9 +55,9 @@ agreement: 25/62 skeleton lines (40.3%) | |||
| 55 | - "." | 55 | - "." |
| 56 | + "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>." | 56 | + "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>." |
| 57 | </p> | 57 | </p> |
| 58 | <h1> | 58 | <h2> |
| 59 | "Ignored decorations" | 59 | "Ignored decorations" |
| 60 | </h1> | 60 | </h2> |
| 61 | <p> | 61 | <p> |
| 62 | - "A repeater is dropped:" | 62 | - "A repeater is dropped:" |
| 63 | - <time> | 63 | - <time> |
| @@ -66,9 +66,9 @@ agreement: 25/62 skeleton lines (40.3%) | |||
| 66 | - "." | 66 | - "." |
| 67 | + "A repeater is dropped: <2024-01-15 Mon +1w>." | 67 | + "A repeater is dropped: <2024-01-15 Mon +1w>." |
| 68 | </p> | 68 | </p> |
| 69 | <h1> | 69 | <h2> |
| 70 | "Not timestamps" | 70 | "Not timestamps" |
| 71 | </h1> | 71 | </h2> |
| 72 | <p> | 72 | <p> |
| 73 | "Comparisons like 3 < 4 and [not a stamp] stay literal text." | 73 | "Comparisons like 3 < 4 and [not a stamp] stay literal text." |
| 74 | </p> | 74 | </p> |
tests/snapshots/pipeline__core_html.snap +2 −2
| @@ -3,7 +3,7 @@ source: tests/pipeline.rs | |||
| 3 | expression: "render_fixture(\"core.org\")" | 3 | expression: "render_fixture(\"core.org\")" |
| 4 | --- | 4 | --- |
| 5 | <p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p> | 5 | <p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p> |
| 6 | <h1 id="ordered-and-checked">Ordered and checked</h1> | 6 | <h2 id="ordered-and-checked">Ordered and checked</h2> |
| 7 | <ol> | 7 | <ol> |
| 8 | <li>first item</li> | 8 | <li>first item</li> |
| 9 | <li>second item with <em>emphasis</em></li> | 9 | <li>second item with <em>emphasis</em></li> |
| @@ -12,7 +12,7 @@ expression: "render_fixture(\"core.org\")" | |||
| 12 | <li><input type="checkbox" disabled> todo item</li> | 12 | <li><input type="checkbox" disabled> todo item</li> |
| 13 | <li><input type="checkbox" disabled checked> done item</li> | 13 | <li><input type="checkbox" disabled checked> done item</li> |
| 14 | </ul> | 14 | </ul> |
| 15 | <h1 id="links-and-code">Links and code</h1> | 15 | <h2 id="links-and-code">Links and code</h2> |
| 16 | <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> | 16 | <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> |
| 17 | <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> | 17 | <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> |
| 18 | <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> | 18 | <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> |
tests/snapshots/pipeline__minimal_html.snap +3 −3
| @@ -3,12 +3,12 @@ source: tests/pipeline.rs | |||
| 3 | expression: "render_fixture(\"minimal.org\")" | 3 | expression: "render_fixture(\"minimal.org\")" |
| 4 | --- | 4 | --- |
| 5 | <p>A single paragraph of preamble text before any heading.</p> | 5 | <p>A single paragraph of preamble text before any heading.</p> |
| 6 | <h1 id="first">First Heading</h1> | 6 | <h2 id="first">First Heading</h2> |
| 7 | <p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p> | 7 | <p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p> |
| 8 | <h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2> | 8 | <h3 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h3> |
| 9 | <ul> | 9 | <ul> |
| 10 | <li>an unordered item</li> | 10 | <li>an unordered item</li> |
| 11 | <li>another with a checkbox [ ]</li> | 11 | <li>another with a checkbox [ ]</li> |
| 12 | </ul> | 12 | </ul> |
| 13 | <h1 id="second-heading">Second Heading</h1> | 13 | <h2 id="second-heading">Second Heading</h2> |
| 14 | <p>See <a href="#first">the first heading</a>.</p> | 14 | <p>See <a href="#first">the first heading</a>.</p> |
tests/snapshots/site__site_guide_html.snap +8 −3
| @@ -6,19 +6,24 @@ expression: "page(&pages, \"guide.org\").html" | |||
| 6 | <html lang="en"> | 6 | <html lang="en"> |
| 7 | <head> | 7 | <head> |
| 8 | <meta charset="utf-8"> | 8 | <meta charset="utf-8"> |
| 9 | <title>Guide</title> | 9 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 10 | <title>Guide · org-ssg site</title> | ||
| 10 | <link rel="stylesheet" href="syntax.css"> | 11 | <link rel="stylesheet" href="syntax.css"> |
| 11 | </head> | 12 | </head> |
| 12 | <body> | 13 | <body> |
| 14 | <header> | ||
| 15 | <a class="site-title" href="index.html">org-ssg site</a> | ||
| 13 | <nav> | 16 | <nav> |
| 14 | <a href="about.html">About</a> | 17 | <a href="about.html">About</a> |
| 15 | <a href="#">Guide</a> | 18 | <a href="#">Guide</a> |
| 16 | <a href="index.html">Home</a> | 19 | <a href="index.html">Home</a> |
| 17 | </nav> | 20 | </nav> |
| 21 | </header> | ||
| 18 | <main> | 22 | <main> |
| 19 | <h1 id="setup">Setup</h1> | 23 | <h1>Guide</h1> |
| 24 | <h2 id="setup">Setup</h2> | ||
| 20 | <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> | 25 | <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> |
| 21 | <h1 id="data">Data</h1> | 26 | <h2 id="data">Data</h2> |
| 22 | <table> | 27 | <table> |
| 23 | <thead> | 28 | <thead> |
| 24 | <tr><th>Name</th><th>Score</th></tr> | 29 | <tr><th>Name</th><th>Score</th></tr> |
tests/snapshots/site__site_index_html.snap +7 −2
| @@ -6,19 +6,24 @@ expression: "page(&pages, \"index.org\").html" | |||
| 6 | <html lang="en"> | 6 | <html lang="en"> |
| 7 | <head> | 7 | <head> |
| 8 | <meta charset="utf-8"> | 8 | <meta charset="utf-8"> |
| 9 | <title>Home</title> | 9 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 10 | <title>Home · org-ssg site</title> | ||
| 10 | <link rel="stylesheet" href="syntax.css"> | 11 | <link rel="stylesheet" href="syntax.css"> |
| 11 | </head> | 12 | </head> |
| 12 | <body> | 13 | <body> |
| 14 | <header> | ||
| 15 | <a class="site-title" href="index.html">org-ssg site</a> | ||
| 13 | <nav> | 16 | <nav> |
| 14 | <a href="about.html">About</a> | 17 | <a href="about.html">About</a> |
| 15 | <a href="guide.html">Guide</a> | 18 | <a href="guide.html">Guide</a> |
| 16 | <a href="#">Home</a> | 19 | <a href="#">Home</a> |
| 17 | </nav> | 20 | </nav> |
| 21 | </header> | ||
| 18 | <main> | 22 | <main> |
| 23 | <h1>Home</h1> | ||
| 19 | <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> | 24 | <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> |
| 20 | <p>Also see <a href="#overview">Overview</a> further down this page.</p> | 25 | <p>Also see <a href="#overview">Overview</a> further down this page.</p> |
| 21 | <h1 id="overview">Overview</h1> | 26 | <h2 id="overview">Overview</h2> |
| 22 | <p>The overview lives on the home page.</p> | 27 | <p>The overview lives on the home page.</p> |
| 23 | </main> | 28 | </main> |
| 24 | </body> | 29 | </body> |