# orgo An org-mode static site generator, in Rust. Org is treated as the *source language*, not an inconvenient input to be normalized into markdown. The org element tree — headings, drawers, blocks, links with their org-specific semantics — **is** the document model, and we render that tree straight to HTML. We never round-trip through a markdown-shaped intermediate representation, because the point is to preserve what markdown cannot express: property drawers, TODO/priority/tag metadata on headings, `#+` directives, ID links, named/captioned blocks, footnote semantics. The one non-obvious early commitment is **incremental builds keyed on content hashing**, treated as a first-class architectural concern from day one. The discipline it imposes on the data model — pure, hashable, dependency-tracked units — is the real deliverable, even while the corpus is small enough that a full rebuild is instant. **Full documentation is in [`docs/`](docs/)** — a site written in org and built by orgo itself. Build and read it with: ```bash cargo run -- serve docs -o docs/_site ``` ## Quick start ```bash cargo run -- init my-site # config + an editable copy of the layout + a page cargo run -- build my-site -o _site ``` Or skip the scaffolding entirely — point it at any directory of `.org` files: ```bash cargo run -- build ~/notes -o _site ``` **Zero configuration is a supported path, not a demo.** With no `orgo.toml`, no templates and no orgo-specific markup in your files, you get a complete site: pages, navigation, syntax-highlighted code and the stylesheet to colour it. Configuration changes what you get; it is never what makes it work. Discovery skips what should not be published — dot-directories such as `.git`, the config file, the templates directory, and the output directory when it sits inside the source, so `orgo build . -o _site` does the obvious thing. ## Configuration Everything is optional. `orgo init` writes a fully commented `orgo.toml`; every value below is the default. ```toml [site] title = "orgo site" base_url = "" # absolute URL, no trailing slash; needed for feeds/canonical links description = "" language = "en" [nav] mode = "top-level" # top-level | all | explicit | none # pages = ["index.org", "about.org"] # for mode = "explicit"; order is preserved [templates] dir = "templates" # base.html replaces the built-in layout expose_page_list = false # [[pages]] # which layout a section renders through; base.html by default # match = "blog" # a source directory or one .org file; most specific rule wins # template = "post.html" [highlight] theme = "InspiredGitHub" [build] drafts = false assets = [] # extra directories copied to the site root, e.g. ["../theme/static"] [html] heading_offset = 1 # a level-1 org heading becomes

, beneath the layout's

``` ### Templates Drop a `base.html` into the templates directory and it replaces the built-in layout entirely. Any other `.html` file there is available to `{% include %}` and `{% extends %}`. Templates are [minijinja](https://docs.rs/minijinja) (Jinja2 syntax) and receive: | Variable | What it is | |---|---| | `body` | the rendered page HTML — use `{{ body \| safe }}` | | `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.content`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` | | `site` | `.title`, `.base_url`, `.description`, `.language` | | `nav` | list of `{title, url}`, relative to this page | | `root` | `../`-prefix back to the site root from this page | | `stylesheet` | URL of the generated `syntax.css` | | `pages` | every page's metadata — only when `expose_page_list = true` | `page.keywords` carries **every** `#+KEYWORD:` in the file under its lowercased name, so your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is `{{ page.keywords.custom_thing }}`. `base.html` is the default layout, not the only one. A `[[pages]]` rule gives a section its own — `match = "blog"`, `template = "post.html"` — and `#+TEMPLATE: wide.html` gives one page its own, which wins over any rule. A second layout usually starts with `{% extends "base.html" %}`. Editing a template re-renders the pages that use it — template sources are a hash input, so a design change never leaves a site half-updated. ### Generated listing pages A blog index, an archive, a feed — output files with no source `.org` behind them. Repeat the block for each one: ```toml [[collections]] source = "blog" # directory to list; empty means every page output = "blog/index.html" # where to write it template = "list.html" title = "Blog" sort = "date" # date | title | path order = "desc" # desc | asc nav = true # put this listing page in the nav ``` The template gets the collection's entries as `pages`, already sorted, plus the usual `site`/`nav`/`root`. It can `{% extends "base.html" %}` to inherit the site chrome: ```jinja {% extends "base.html" %} {% block main %} {% endblock %} ``` `p.date_iso` is the `YYYY-MM-DD` extracted from `#+DATE:`, whatever org syntax it was written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`. It is also the sort key; pages without a parseable date sort last, so an undated draft never leads a dated archive. #### Pagination Set `paginate` to split a long listing across numbered pages: ```toml [[collections]] source = "blog" output = "blog/index.html" paginate = 10 paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number ``` Page 1 stays at `output`, so a section's canonical URL never moves as its page count changes; only pages 2..N are named by `paginate_output`. The template gets a `paginator`: ```jinja {% if paginator and paginator.total > 1 %} {% endif %} ``` `paginator` carries `current`, `total`, `per_page`, `total_entries`, `prev_url`, `next_url`, `first_url`, `last_url`, and `pages`. Every URL is relative to the page carrying it, so links work from page 1 (`page/2.html`) and from page 5 (`../index.html`, `6.html`) without the template knowing where it sits. An unpaginated collection has no `paginator` at all, so `{% if paginator %}` is a reliable test in a shared template. Grouping and pagination compose: each group paginates independently, which is why `paginate_output` needs `{tag}` as well as `{n}` on a grouped collection. An empty collection still emits page 1 — a section that exists but has nothing in it should say so rather than 404. When the entry count shrinks, pages that no longer exist are deleted instead of being left serving stale posts. #### Tag pages Add `group_by` and the collection emits one page *per group* instead of one page total, plus an optional index of the groups: ```toml [[collections]] source = "blog" group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value output = "tags/{tag}.html" # {tag} is replaced by each group's slug template = "tag.html" title = "Tagged: {tag}" index_output = "tags/index.html" # the tag index index_template = "tags.html" index_title = "Tags" nav = true # adds the *index*, not every tag ``` A group page receives its own posts as `pages` and itself as `group` (`.name`, `.slug`, `.url`, `.count`). The index receives `groups` — every group, sorted by name: ```jinja ``` `group_by = "tags"` is multi-valued: a post appears under every tag it carries. Any other value names a single-valued `#+KEYWORD:`, so `group_by = "category"` buckets by `#+CATEGORY:`. Two tags that would produce the same URL (`web_dev` and `web@dev` both slugify to `web-dev`) are a build error rather than one page silently overwriting the other. A tag page depends on its own posts and nothing else, so adding a post tagged `rust` re-renders that post, its section index, `tags/rust.html`, and the tag index whose counts changed — four pages, not one per tag. That precision is why `groups` is given to the index and not to every group page: a page that can see every group depends on every group. #### Feeds and absolute URLs **A feed is a listing page with an XML template**, not a separate feature — templates are loaded by full filename and any extension, so `output = "feed.xml"` with `template = "feed.xml"` is all it takes. `orgo init` writes a working RSS template. A feed is read away from the site that served it, so relative links in one are simply broken. Set `site.base_url` and use the `absolute` filter: ```jinja {{ post.url | absolute }} {{ post.date_iso | rfc822 }} ``` | Filter | Does | |---|---| | `absolute` | site-root-relative path → absolute URL; already-absolute URLs pass through | | `rfc822` | any org or ISO date → the format RSS `pubDate` requires | | `truncate(n)` | shorten to at most `n` characters on a word boundary, with an ellipsis | Apply `absolute` to the site-root-relative values — `page.url`, `pages[].url`, `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, which are relative to the page carrying them and already correct there. With no `base_url`, `absolute` is an **error** naming the setting, rather than quietly emitting a relative URL that would make the feed invalid everywhere while looking fine. The default layout also emits `` when a base URL is set. Listing pages are cached on the entries they list, so adding a post re-renders that section's index and nothing else. ### Table of contents and `#+OPTIONS:` `page.toc` is the page's headings as a **tree** — `{title, anchor, level, children}` — because a table of contents is one, and rebuilding a tree from a flat list of levels inside a template is what Jinja is worst at. Its anchors come from the same function the renderer uses to emit heading `id`s, so a TOC link cannot drift from the heading it points at. ```jinja {% macro toc_list(entries) %} {% endmacro %} {% if page.toc %}{{ toc_list(page.toc) }}{% endif %} ``` Org's own per-file export switches are honoured, so a document can turn a feature off for itself the way its author already knows: | Switch | Effect | Site default | |---|---|---| | `#+OPTIONS: toc:nil` | empties `page.toc` for this page | `[html] toc = true` | | `#+OPTIONS: num:t` | numbers headings `1.`, `1.1.`, … | `[html] section_numbers = false` | **Section numbers default to off, which differs from Emacs on purpose.** `org-export-with-section-numbers` is on there, so an org-published site inherits numbered headings whether or not anyone chose them. Most sites do not want them; `num:t` or `section_numbers = true` gets Emacs' behaviour back, with Emacs' own `section-number-N` classes so the output stays diffable against the oracle. ### Excerpts and drafts `page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph otherwise, so a listing has something to show whether or not the author thought about summaries. `page.word_count` and `page.reading_time` (minutes at 200 wpm) count prose only — a post that is mostly a shell transcript should not read as an hour's work. `truncate` exists because an excerpt is usually a whole paragraph and minijinja has no such filter. `#+DRAFT:` keeps a page out of the build entirely — no page, and absent from listings and the nav rather than merely unlinked. `--drafts` includes them, which is what you want under `watch` while writing one. A draft is out of the symbol table too, so a link *to* one is reported as the dead link it would be once published. The keyword is read forgivingly: `t`, `yes`, `1` and a bare `#+DRAFT:` all mean draft, because writing the keyword at all is the signal. Only an explicit `nil`, `false`, `no`, `0` or `off` means published. ### `#+SLUG:` A page's output filename comes from its `#+SLUG:` when it has one, so `2018-11-28-aes-encryption.org` can publish as `aes-encryption.html`. Without one the source filename is used. Slugs are sanitized to a single safe path component, and two pages claiming one URL is a build error rather than a silently dropped page. ## Pipeline ``` DISCOVER → PARSE → INDEX → RESOLVE → RENDER → TEMPLATE → EMIT ``` PARSE and RENDER are pure functions of their inputs (cacheable, hashable). INDEX/RESOLVE is the only inherently global stage — it is where the link dependency graph is born. | Stage | Module | Notes | |---|---|---| | config | `src/config.rs` | `orgo.toml`: site metadata, nav mode, templates, theme. A hash input. | | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | | audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | | INDEX | `src/index.rs` | Collect link targets into a symbol table. | | RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). | | RENDER | `src/render.rs` | Tree → HTML fragment; syntect highlighting; footnote two-pass. | | TEMPLATE | `src/template.rs` | minijinja: fragment + metadata → full page. | | incremental | `src/incremental.rs` | Content/config/template hashing, dep graph, cache manifest, invalidation. | ## v1 scope (delivered as of v0.4; still to be reconciled against a corpus audit) **IN — v1 must handle:** headings with nesting, at levels relative to the document's shallowest; TODO keywords; priorities `[#A]`; tags; property drawers; plain lists (unordered/ordered/description, checkboxes, `[@N]` counters, nesting); tables (with rule rows and org's special marker column, no `#+TBLFM:`); source blocks with syntax highlighting; example/quote/center/verse blocks and named special blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`, `id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup (bold/italic/underline/verbatim/code/strike); org's export-time text conversions (`--`/`---`/`...`, `x^2`, `a_{b}`, `\alpha`); timestamps (active/inactive, ranges); paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`, numbered `Figure N:`. **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / `:results`; `#+TBLFM:` formulas; LaTeX / MathJax (passed through untouched, including past the text conversions); `#+INCLUDE:` (never expanded — reported as a diagnostic, so a page is never quietly short of content); citations; radio targets and macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda semantics; non-HTML export blocks. **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test asserting it degrades predictably (ignored, no crash). The IN/OUT line is enforced by `tests/constructs.rs`, defending against the project's #1 risk: scope creep back toward all-of-org. Phase 0 checked this line against a real 179-file corpus and found it sound (99.9% of construct uses in scope) — but also found one thing missing from it entirely: `#+SLUG:`. See [Phase 0](#phase-0-the-corpus-audit-and-the-emacs-oracle). ## Phase plan | Phase | Scope | Status | |---|---|---| | **M0** | **Buildable skeleton: crate layout, module stubs, deps, test harness, fixtures** | **done** | | **v0.1** | **End-to-end core parse → render: `build` a single `.org` file to HTML** | **done** | | **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build `, tables + footnotes** | **done** | | **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** | | **v0.4** | **MVP: the full v1 construct scope — heading metadata, nested/description lists, block types, timestamps, images, syntect highlighting — with the IN/OUT line under test** | **done** | | **0** | **Corpus audit + `emacs --batch` ground-truth oracle** | **done** | | 1 | Line lexer + heading/section skeleton | done | | 2 | Block elements — lists, source blocks, tables, footnote defs, blocks by type, drawers | done | | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done | | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | | 6 | Incremental build layer (hashing, dep graph, invalidation); `watch` on OS filesystem events | done | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | | **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | | **17** | **Bundled TOML and Org syntaxes, a user syntax directory, and org's comma escape** | **done** | | **18** | **Per-page layouts: `[[pages]]` rules and `#+TEMPLATE:`** | **done** | | **19** | **Export parity: relative heading levels, special strings, sub/superscript, caption numbering, checkbox and counter markup, table marker columns, special blocks** | **done** | | **20** | **Correctness debt: org's entity table, table captions, a reported `#+INCLUDE:`, and an oracle that separates deliberate divergence from defects** | **done** | | **21** | **Extra asset roots; per-template hashing so one layout edit does not re-render the site** | **done** | | **22** | **Release engineering: CI on both platforms, a checked MSRV, release binaries, a changelog, and a written compatibility promise** | **done** | ### v0.2 in / out **Added in v0.2:** the INDEX stage (`SymbolTable` of `:ID:`/`:CUSTOM_ID:`/heading/`file:` targets across a directory); the RESOLVE stage — rewrites `[[#custom-id]]`, `[[id:...]]`, `[[*Heading]]` and `[[file:other.org]]` links to real relative output URLs, returns the `used_targets` list (the `uses` edges, spec §4.3/R2) and reports unresolved links as warnings rather than crashing; a minijinja base layout (title, nav, body) applied to every page; a `build ` path that walks the tree, parses + resolves + renders + templates every `.org` into a linked static site and copies non-`.org` assets through; plus two new constructs — pipe **tables** (with header band from the rule row) and **footnotes** (block `[fn:1]` definitions, referenced `[fn:1]`, and inline `[fn:1:text]`, rendered as a numbered, back-linked notes section). **Left stubbed at v0.2, all closed in v0.4:** timestamps; TODO keywords and priorities; generic (non-PROPERTIES) drawers; real syntect tokenizing behind the `Highlighter` trait. ### v0.3 in / out **Added in v0.3 — the incremental build layer (spec §4, the flagship, non-retrofittable feature):** - **Three hash classes (spec §4.1)** in `src/incremental.rs`: a **content hash** (blake3 of a file's bytes), a **config hash** (blake3 of the resolved `BuildConfig`), and a **template hash** (blake3 of the template sources). A change in any one invalidates the pages it affects. - **Dependency graph (spec §4.3)** built from RESOLVE's `defines`/`uses` edges: a page depends on the targets it links to, so editing (or renaming a heading in) a file invalidates the pages that *link into* it, not just the file itself — the load-bearing R2 invariant. On rebuild the graph is merged with the previous build's `defines` so a *removed* target still pulls in its linkers. - **Per-page `render_key`** = `H(content ⊕ resolved-links ⊕ config ⊕ template)`. If a page's render key is unchanged, its on-disk output is already correct and it is skipped. The config component folds in a **site-structure hash** (every page's `(path, title)`), because the shared nav bar is global chrome — a title change or a page add/remove alters the nav on every page and so must re-render them all (otherwise byte-equivalence breaks). - **Persisted cache manifest** (`/.orgo-cache.json`, JSON), carrying per-page records, the config/template hashes, and the serialized dependency graph, tagged with `CACHE_FORMAT_VERSION`. A version mismatch, a missing file, or a corrupt file all fall back to a clean full rebuild — the cache is an optimization, never a correctness dependency. - **Wired into `build_site`**: only pages whose render key changed (or that link into a changed file's targets) are re-rendered; unchanged outputs are left in place. `--no-cache` forces a full rebuild; `clean ` removes the output directory (and its cache). `SiteReport` now reports `rendered` vs `skipped` counts. The hard gates are enforced by `tests/incremental.rs`: full-vs-incremental **byte equivalence** (and a second unchanged build re-rendering **zero** pages); **edit-one-file** re-renders exactly the changed page plus its linkers; **renamed-heading** invalidates the linking page and updates its emitted anchor; and cache **version-bump / missing / corrupt** all fall back to a full rebuild. **Out of scope in v0.3:** real syntect highlighting; timestamps and TODO keywords (all landed in v0.4). `watch` is a minimal mtime poll loop (`watch -o `), not an OS file-watcher — the fs-notify integration is deferred. The parse-tree cache (spec §4.5, "optionally") is not persisted: PARSE/INDEX/RESOLVE run for every file each build (cheap and pure); the incremental win is on RENDER + EMIT. ### v0.4 in / out — the MVP v0.4 closes the gap between the v1 scope above and what the code actually did, so every construct the IN list claims is now parsed, rendered, and pinned by a golden file: - **Heading metadata** — TODO keywords (the Emacs default `TODO`/`DONE` set, matched on a word boundary so `TODOs` is not one) and `[#A]` priority cookies, rendered with Emacs' own export classes so the output stays diffable against an `emacs --batch` oracle. - **Lists** — indentation-based nesting (a sub-list renders *inside* its parent `
  • `), multi-paragraph item bodies, and `term :: definition` description lists as `
    `. - **Blocks by type** — `QUOTE`, `CENTER`, `EXAMPLE`, `EXPORT` and `SRC` are now distinct elements rather than all collapsing to a verbatim example block. Block matching is on the specific kind, so a source block can nest inside a quote. An `html` export block passes through; every other backend drops. - **Timestamps** — active `<...>` and inactive `[...]`, optional times, same-day time ranges and `--`-joined date ranges, rendered as `