krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
903ac6c8d657e2cedd2b6edcebfc4649864ee954
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T06:25:58Z
.gitignore | 1 + README.md | 7 ++ docs/guide/01-cli.org | 122 +++++++++++++++++++++++ docs/guide/02-configuration.org | 185 ++++++++++++++++++++++++++++++++++ docs/guide/03-collections.org | 216 ++++++++++++++++++++++++++++++++++++++++ docs/guide/04-templates.org | 175 ++++++++++++++++++++++++++++++++ docs/guide/05-org-support.org | 172 ++++++++++++++++++++++++++++++++ docs/guide/06-authoring.org | 146 +++++++++++++++++++++++++++ docs/guide/07-incremental.org | 115 +++++++++++++++++++++ docs/guide/08-workflow.org | 115 +++++++++++++++++++++ docs/guide/09-auditing.org | 94 +++++++++++++++++ docs/guide/10-deploying.org | 114 +++++++++++++++++++++ docs/index.org | 80 +++++++++++++++ docs/install.org | 96 ++++++++++++++++++ docs/org-ssg.toml | 44 ++++++++ docs/quickstart.org | 165 ++++++++++++++++++++++++++++++ docs/style.css | 130 ++++++++++++++++++++++++ docs/templates/base.html | 51 ++++++++++ docs/templates/list.html | 14 +++ 19 files changed, 2042 insertions(+) @@ -1 +1,2 @@ /target +_site/ @@ -13,6 +13,13 @@ hashing**, treated as a first-class architectural concern from day one. The disc it imposes on the data model — pure, hashable, dependency-tracked units — is the real deliverable, even while the corpus is small enough that a full rebuild is instant. +**Full documentation is in [`docs/`](docs/)** — a site written in org and built by +org-ssg itself. Build and read it with: + +```bash +cargo run -- serve docs -o docs/_site +``` + ## Quick start ```bash new file mode 100644 @@ -0,0 +1,122 @@ +#+TITLE: Command reference +#+DESCRIPTION: Every command and flag, and what each is actually for. +#+LEDE: Six commands: build, serve, watch, audit, init, clean. + +* build + +#+BEGIN_SRC sh +org-ssg build <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE] +#+END_SRC + +If =INPUT= is a directory, it is walked and built into a linked site at =OUTPUT=. If it +is a single =.org= file, one HTML file is written — useful for one-off conversions, +though with no other documents to resolve against, internal links keep a best-effort URL. + +| Flag | Effect | +|------+--------| +| =-o, --output= | Output directory (or =.html= file for single-file input). Required for a site. | +| =--no-cache= | Ignore the incremental cache and re-render every page. | +| =--strict= | Broken internal links and parse diagnostics become a non-zero exit. | +| =--drafts= | Include pages marked =#+DRAFT:=. | +| =--config FILE= | Use this config instead of =org-ssg.toml= in the source directory. | + +The summary line reports what happened: + +#+BEGIN_EXAMPLE +built 182 page(s) (4 rendered, 178 cached), copied 3 asset(s) from src -> _site (0 unresolved link(s), 0 diagnostic(s)) +#+END_EXAMPLE + +=rendered= is the invalidation set — the pages that actually needed rewriting. On a +second build with nothing changed it is zero. + +** --strict is for CI + +Without it, a broken link is a warning and the build succeeds. With it, the build fails +and names every problem. Use it wherever a bad build should not ship: + +#+BEGIN_SRC sh +org-ssg build content -o _site --strict +#+END_SRC + +* serve + +#+BEGIN_SRC sh +org-ssg serve <INPUT> -o <OUTPUT> [-p PORT] [--host HOST] [--drafts] [--config FILE] +#+END_SRC + +Builds, watches, serves, and reloads the browser when a rebuild lands. This is the +command to use while writing. + +| Flag | Default | Effect | +|------+---------+--------| +| =-p, --port= | =3000= | Port to listen on. | +| =--host= | =127.0.0.1= | Address to bind. | +| =--drafts= | off | Include =#+DRAFT:= pages, so you can see what you are writing. | + +*It binds loopback on purpose.* A development server serves unreviewed drafts off your +laptop, so reaching the local network is something you ask for: + +#+BEGIN_SRC sh +org-ssg serve content -o _site --host 0.0.0.0 +#+END_SRC + +The live-reload script is injected into responses and never written to disk, so what you +deploy stays clean. Details in [[file:../guide/08-workflow.org][Watching and serving]]. + +* watch + +#+BEGIN_SRC sh +org-ssg watch <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE] +#+END_SRC + +Rebuilds on filesystem events with no server — for when something else is already serving +the output, or you just want the build to keep up as you write. + +* audit + +#+BEGIN_SRC sh +org-ssg audit <INPUT> +#+END_SRC + +Reports which org constructs a corpus uses and how they land against what org-ssg +supports, plus a census of every keyword, block type, drawer and link scheme seen. Point +it at your notes before trusting a tool with them. See [[file:../guide/09-auditing.org][Auditing a corpus]]. + +It prints names, counts and =file:line= locations — never document text — so an audit of +private notes is safe to share. + +* init + +#+BEGIN_SRC sh +org-ssg init [DIRECTORY] +#+END_SRC + +Scaffolds a working site: a fully commented config, an editable copy of the built-in +layout, listing and tag templates, an RSS template, a home page and a first post. +Defaults to the current directory. + +Only files that do not already exist are written, so it is safe to run inside a directory +that already has content — it fills in what is missing and leaves the rest alone. + +* clean + +#+BEGIN_SRC sh +org-ssg clean <OUTPUT> +#+END_SRC + +Removes the output directory, including the incremental cache manifest inside it. You +rarely need this: the cache is versioned and discards itself when it stops being valid. + +* Exit codes + +| Code | Meaning | +|------+---------| +| =0= | Success. Warnings may still have been printed. | +| =1= | The build failed, or =--strict= found problems. | + +Diagnostics are printed as =file:line: message=, the form an editor can jump to: + +#+BEGIN_EXAMPLE +warning: blog/post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to the end of the file was read as block content +warning: index.org: unresolved link [[#setup]] +#+END_EXAMPLE new file mode 100644 @@ -0,0 +1,185 @@ +#+TITLE: Configuration +#+DESCRIPTION: Every setting in org-ssg.toml, what it changes, and what it costs. +#+LEDE: All of it optional. A missing config is a valid config. + +org-ssg looks for =org-ssg.toml= in the source directory. Pass a different path with +=--config=. Every field has a default, so a directory of org files with no config still +builds a complete site. + +A *missing* config is normal and silent. A *malformed* one is an error, and an unknown +key is rejected by name — a misspelled setting that silently does nothing is how people +lose an afternoon. + +* The whole file + +#+BEGIN_SRC toml +[site] +title = "org-ssg site" +base_url = "" +description = "" +language = "en" + +[nav] +mode = "top-level" +# pages = ["index.org", "about.org"] + +[templates] +dir = "templates" +expose_page_list = false + +[highlight] +theme = "InspiredGitHub" + +[build] +drafts = false + +[html] +heading_offset = 1 +toc = true +section_numbers = false +#+END_SRC + +Plus any number of =[[collections]]= blocks, documented in [[file:03-collections.org][Collections]]. + +* [site] + +| Key | Default | Meaning | +|-----+---------+---------| +| =title= | ="org-ssg site"= | Site name. Available as ={{ site.title }}=. | +| =base_url= | ="" | Absolute origin, *no trailing slash*. | +| =description= | ="" | Available as ={{ site.description }}=. | +| =language= | ="en"= | Goes in =<html lang>= in the built-in layout. | + +** base_url + +Leave it empty and the site is built entirely with relative URLs, which means it works +from a subdirectory, from a filesystem path, and from any origin. That portability is why +it is the default. + +Set it when you need absolute URLs, which two things require: *feeds*, because a feed is +read away from the site that served it, and *canonical links*. The =absolute= template +filter turns a site-root-relative path into a full URL, and errors if there is no base +URL to build one from — rather than quietly emitting a relative URL that would make the +feed invalid everywhere while looking fine. + +A trailing slash is rejected, because =https://example.com/= plus =blog/x.html= is +=https://example.com//blog/x.html=. + +* [nav] + +The navigation shared by every page. + +| =mode= | Includes | +|--------+----------| +| ="top-level"= (default) | Pages at the site root. | +| ="all"= | Every page. | +| ="explicit"= | Only =nav.pages=, in the order listed. | +| ="none"= | Nothing. | + +*"all" makes output quadratic.* Each of /n/ pages carries /n/ links, so total output +grows with the square of the site. On a 1,790-page site that was 284 MB of mostly +navigation. It is fine for a handful of pages and a trap beyond that. + +="top-level"= keeps the nav a map of the site's top level rather than an index of its +contents, so nav size does not depend on how much you write. + +** explicit + +#+BEGIN_SRC toml +[nav] +mode = "explicit" +pages = ["index.org", "about.org", "uses.org"] +#+END_SRC + +Paths are *source* paths relative to the source root, and the order given is the order +rendered — a hand-written nav is a designed sequence, not an alphabetical one. Naming a +page that does not exist is an error, because a silently shorter nav is a poor way to +learn about a typo. + +** Section landing pages in the nav + +If your sections live in subdirectories, none of them are top-level pages. Put the +section's *generated* index in the nav instead, with =nav = true= on its collection — +that is the page a nav entry should point at anyway. + +* [templates] + +| Key | Default | Meaning | +|-----+---------+---------| +| =dir= | ="templates"= | Directory of templates, relative to the source root. | +| =expose_page_list= | =false= | Give every template a =pages= list of all page metadata. | + +** expose_page_list costs incremental precision + +With it on, any page can read every page's metadata — so adding one page can change any +page's output, and the whole site must re-render on every add, rename or retitle. That is +the trade for building an index by hand in a template. Most people want a +[[file:03-collections.org][collection]] instead, which gets the same result while keeping +adding a post a one-page rebuild. + +* [highlight] + +| Key | Default | +|-----+---------| +| =theme= | ="InspiredGitHub"= | + +Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=, +=base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=. +An unknown name is an error listing the valid ones. + +Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet. +Each build writes =syntax.css= into the output and every page links it. + +* [build] + +| Key | Default | Meaning | +|-----+---------+---------| +| =drafts= | =false= | Include pages marked =#+DRAFT:=. | + +=--drafts= on the command line turns this on for one run. The flag can only turn drafts +on; it never turns off a config that asked for them. + +* [html] + +| Key | Default | Meaning | +|-----+---------+---------| +| =heading_offset= | =1= | Added to every org heading level. | +| =toc= | =true= | Make =page.toc= available to templates. | +| =section_numbers= | =false= | Number headings =1.=, =1.1.=, … | + +** heading_offset + +A level-1 org heading renders as =<h2>= by default, because the layout supplies the page +title as the =<h1>=. This matches Emacs, whose =org-html-toplevel-hlevel= is 2 for the +same reason. + +Set it to =0= if your template renders no title of its own — otherwise the document +starts at =<h2>= with nothing above it. + +** section_numbers differs from Emacs on purpose + +=org-export-with-section-numbers= is on in Emacs, so an org-published site inherits +numbered headings whether or not anyone chose them. Most sites do not want them, so the +default here is the taste rather than the inheritance. Turning it on emits Emacs' own +=section-number-N= classes. + +* Per-file overrides + +Org's own =#+OPTIONS:= switches override the site setting for one document: + +#+BEGIN_SRC org +,#+OPTIONS: toc:nil num:t +#+END_SRC + +| Switch | Overrides | +|--------+-----------| +| =toc:nil= / =toc:t= | =[html] toc= | +| =num:t= / =num:nil= | =[html] section_numbers= | + +Off is spelled =nil=, =false=, =no=, =0= or =off=; anything else is on. + +* Configuration is a cache input + +The resolved config is hashed into every page's render key, so editing =org-ssg.toml= +re-renders exactly the pages it affects — which for most settings is all of them. You +never need =--no-cache= after a config change. new file mode 100644 @@ -0,0 +1,216 @@ +#+TITLE: Collections +#+DESCRIPTION: Generated pages — blog indexes, tag pages, pagination and RSS feeds. +#+LEDE: The one kind of output that is not a translation of some input. + +A blog index exists because a set of posts exists, not because someone wrote +=index.org=. A =[[collections]]= block declares one: a source directory in, an output +file out, through a template. + +Keeping it declarative means an RSS feed is the same mechanism with an XML template +rather than a second feature. + +* A blog index + +#+BEGIN_SRC toml +[[collections]] +source = "blog" # directory to list; empty means every page +output = "blog/index.html" # where to write it +template = "list.html" # template file name +title = "Blog" +sort = "date" # date | title | path +order = "desc" # desc | asc +nav = true # put this page in the site nav +#+END_SRC + +The template receives the collection's entries as =pages=, already sorted, plus the usual +=site=, =nav= and =root=: + +#+BEGIN_SRC html +{% extends "base.html" %} +{% block content %} +<ul> + {% for post in pages %} + <li> + <time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> + <a href="{{ root }}{{ post.url }}">{{ post.title }}</a> + <p>{{ post.excerpt | truncate(180) }}</p> + </li> + {% endfor %} +</ul> +{% endblock %} +#+END_SRC + +** Sorting + +=sort= is =date= (default), =title= or =path=; =order= is =desc= (default) or =asc=. + +Date sorting uses =page.date_iso=, 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 a bare +=2024-05-01= all work. + +*Pages with no parseable date sort last in either direction*, so an undated draft never +leads a dated archive. + +** nav = true + +The listing page joins the site navigation. This is how a section landing page — =/blog/=, +=/notes/= — gets into a nav built from top-level pages, and it points at the right thing: +the section, not any one post in it. + +* 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: + +#+BEGIN_SRC toml +[[collections]] +source = "blog" +group_by = "tags" # "tags", or any #+KEYWORD: name +output = "tags/{tag}.html" # {tag} becomes each group's slug +template = "tag.html" +title = "Tagged: {tag}" +index_output = "tags/index.html" +index_template = "tags.html" +index_title = "Tags" +nav = true # adds the *index*, not every tag +#+END_SRC + +A group page receives its own posts as =pages= and itself as =group=: + +#+BEGIN_SRC html +<h1>{{ group.name }} ({{ group.count }})</h1> +{% for post in pages %}<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>{% endfor %} +#+END_SRC + +The index receives =groups=, sorted by name: + +#+BEGIN_SRC html +<ul>{% for tag in groups %} + <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li> +{% endfor %}</ul> +#+END_SRC + +** Grouping by anything + +=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 pages by +=#+CATEGORY:= with no extra machinery. + +** Two tags that would collide are an error + +=web_dev= and =web@dev= both slugify to =web-dev=, so one page would silently overwrite +the other. That is a build error naming both values. + +* Pagination + +#+BEGIN_SRC toml +[[collections]] +source = "blog" +output = "blog/index.html" +paginate = 10 +paginate_output = "blog/page/{n}.html" # {n} is the 1-based page number +#+END_SRC + +*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=: + +#+BEGIN_SRC html +{% if paginator and paginator.total > 1 %} +<nav> + {% if paginator.prev_url %}<a href="{{ paginator.prev_url }}">Newer</a>{% endif %} + {% for pg in paginator.pages %} + <a href="{{ pg.url }}"{% if pg.current %} aria-current="page"{% endif %}>{{ pg.number }}</a> + {% endfor %} + {% if paginator.next_url %}<a href="{{ paginator.next_url }}">Older</a>{% endif %} +</nav> +{% endif %} +#+END_SRC + +| Field | Meaning | +|-------+---------| +| =current=, =total= | This page's number, and how many there are. | +| =per_page=, =total_entries= | As configured, and across the whole listing. | +| =prev_url=, =next_url= | =none= at the ends. | +| =first_url=, =last_url= | Always present. | +| =pages= | =[{number, url, current}]= for a numbered strip. | + +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 RSS feed + +A feed is a listing page with an XML template. Templates load by full filename and any +extension, so: + +#+BEGIN_SRC toml +[[collections]] +source = "blog" +output = "feed.xml" +template = "feed.xml" +title = "Feed" +#+END_SRC + +#+BEGIN_SRC html +<?xml version="1.0" encoding="utf-8"?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> +<channel> + <title>{{ site.title }}</title> + <link>{{ "index.html" | absolute }}</link> + <atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/> + {% for post in pages %} + <item> + <title>{{ post.title }}</title> + <link>{{ post.url | absolute }}</link> + <guid isPermaLink="true">{{ post.url | absolute }}</guid> + <pubDate>{{ post.date_iso | rfc822 }}</pubDate> + </item> + {% endfor %} +</channel> +</rss> +#+END_SRC + +This needs =site.base_url=, because a feed with relative links is invalid everywhere it +is read. =org-ssg init= writes this template and leaves the collection commented out +until there is a base URL to make absolute links from. + +* Every setting + +| Key | Default | Meaning | +|-----+---------+---------| +| =source= | ="" | Directory to list. Empty means every page. | +| =output= | ="index.html"= | Where to write. Needs ={tag}= when grouped. | +| =template= | ="list.html"= | Template file name. | +| =title= | ="Index"= | ={{ page.title }}=. ={tag}= is substituted when grouped. | +| =group_by= | ="" | ="tags"=, or a =#+KEYWORD:= name. Empty means one page. | +| =index_output= | ="" | Where to write the group index. Empty means none. | +| =index_template= | ="tags.html"= | Template for the group index. | +| =index_title= | ="Tags"= | Title for the group index. | +| =sort= | ="date"= | =date=, =title= or =path=. | +| =order= | ="desc"= | =desc= or =asc=. | +| =paginate= | =0= | Entries per page. =0= means no pagination. | +| =paginate_output= | ="" | Where pages 2..N go. Needs ={n}=. | +| =nav= | =false= | Add this page — or its index, when grouped — to the nav. | + +* Incremental behaviour + +A listing page is cached on the entries it lists, so: + +- Adding a post re-renders that post, its section index, its tag pages and the tag index + whose counts changed. Nothing else. +- Editing a post's *body* changes no listing metadata, so the index is not touched at + all. +- Retitling a post does re-render the listings that display the title. + +A tag page depends on its own posts and not on the other groups, which is why =groups= is +given to the index and not to every group page: a page that can see every group would +depend on every group, and one new post would re-render every tag page. + +When a collection shrinks below a page boundary, the pages that no longer exist are +deleted rather than left serving stale content. new file mode 100644 @@ -0,0 +1,175 @@ +#+TITLE: Templates +#+DESCRIPTION: Layouts, inheritance, every variable and filter available to a template. +#+LEDE: minijinja layouts loaded from disk, hashed into the cache, replaceable entirely. + +Templates live in the directory named by =[templates] dir=, default =templates/=. They +are [[https://docs.rs/minijinja][minijinja]] templates — Jinja2 syntax — loaded at build +time, so editing one and rebuilding is the whole edit cycle. + +* base.html replaces the layout + +A file called =base.html= becomes the page layout. Without one, a built-in layout is used +— which is what makes a bare directory of org files build into a real site. + +#+BEGIN_SRC html +<!DOCTYPE html> +<html lang="{{ site.language }}"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ page.title }} — {{ site.title }}</title> + <link rel="stylesheet" href="{{ root }}style.css"> + {% if stylesheet %}<link rel="stylesheet" href="{{ stylesheet }}">{% endif %} +</head> +<body> + <nav>{% for item in nav %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}</nav> + <main> + <h1>{{ page.title }}</h1> + {{ body | safe }} + </main> +</body> +</html> +#+END_SRC + +A template that does not compile is a *build error*, not a fallback to the default — +someone editing a layout should see the mistake, not output that looks like their edit +did nothing. + +* Names are full filenames + +Templates are registered under their full relative filename: =base.html=, +=partials/head.html=, =feed.xml=. That is what ={% extends "base.html" %}= names, and it +is why a template can have any extension — which is how an RSS feed is just a listing +page. + +#+BEGIN_SRC html +{% extends "base.html" %} +{% block content %}<p>Only this part differs.</p>{% endblock %} +#+END_SRC + +Subdirectories work, so ={% include "partials/header.html" %}= does what you expect. + +* Variables + +** body + +The rendered page HTML. Always use ={{ body | safe }}= — it is already HTML, and +escaping it would print tags at the reader. + +Empty on generated pages, which build their content from =pages= or =groups= instead. + +** page + +| Field | Meaning | +|-------+---------| +| =title= | =#+TITLE:=, or the filename stem. | +| =url= | Output path relative to the site root, e.g. =blog/post.html=. | +| =source= | Source path relative to the source root, e.g. =blog/post.org=. | +| =date= | =#+DATE:= verbatim, in whatever org syntax was written. | +| =date_iso= | The =YYYY-MM-DD= inside it, or =none=. | +| =tags= | =#+FILETAGS:=, split. | +| =excerpt= | =#+DESCRIPTION:=, or the first paragraph. | +| =word_count= | Words of prose, excluding code blocks. | +| =reading_time= | Minutes at 200 wpm, rounded up. | +| =toc= | The heading tree. See below. | +| =keywords= | *Every* =#+KEYWORD:=, by lowercased name. | + +=page.keywords= is the escape hatch: =#+CUSTOM_THING: x= is +={{ page.keywords.custom_thing }}=, so your own metadata works without org-ssg knowing it +exists. + +** site + +=site.title=, =site.base_url=, =site.description=, =site.language= — straight from +=[site]= in the config. + +** nav + +A list of ={title, url}=, with URLs relative to the current page. + +** root + +The =../= prefix back to the site root from this page: empty at the top level, =../= one +level down. Prefix it to any site-root-relative path so the same template works at any +depth: + +#+BEGIN_SRC html +<link rel="stylesheet" href="{{ root }}style.css"> +<a href="{{ root }}{{ post.url }}">{{ post.title }}</a> +#+END_SRC + +** stylesheet + +URL of the generated =syntax.css=, relative to this page. Link it or code blocks are +unstyled. + +** pages, group, groups, paginator + +Present on generated pages; see [[file:03-collections.org][Collections]]. =pages= is also +available on every page when =[templates] expose_page_list = true=. + +** page.toc + +The page's headings as a *tree* — 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. + +Each entry has =title=, =anchor=, =level= and =children=: + +#+BEGIN_SRC html +{% macro toc_list(entries) %} +<ul>{% for e in entries %} + <li><a href="#{{ e.anchor }}">{{ e.title }}</a> + {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li> +{% endfor %}</ul> +{% endmacro %} + +{% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %} +#+END_SRC + +Anchors come from the same function that emits heading =id= attributes, so a TOC link +cannot drift from the heading it points at. The tree is empty when the page has no +headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=. + +* Filters + +Beyond minijinja's built-ins: + +| Filter | Does | +|--------+------| +| =absolute= | Site-root-relative path → absolute URL, using =site.base_url=. | +| =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. | + +** absolute + +#+BEGIN_SRC html +<link>{{ post.url | absolute }}</link> +#+END_SRC + +Apply it 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. + +An already-absolute URL passes through, so a template can apply it uniformly to internal +paths and external links. With no =base_url= it is an *error* naming the setting, rather +than a relative URL that would make a feed invalid. + +** truncate + +minijinja ships no truncate, and an excerpt is usually a whole paragraph — so without one +a listing's only options are the full paragraph or nothing. + +* Escaping + +Output is HTML-escaped by default, because titles and text are author content. +={{ body | safe }}= is the deliberate exception. + +Unlike stock minijinja, =/= is *not* escaped. Escaping it is a defence for values +interpolated into JavaScript, and since =<= is escaped anyway it buys nothing in an HTML +document — while making every URL read =../index.html=. Templates emit a lot of +URLs. + +* Templates are a cache input + +Every template's source is hashed, so editing a layout re-renders the pages that use it. +A design change never leaves a site half-updated. new file mode 100644 @@ -0,0 +1,172 @@ +#+TITLE: Org support +#+DESCRIPTION: Exactly which org syntax is handled, which is not, and how the rest degrades. +#+LEDE: A deliberate subset, with the boundary enforced by tests rather than by hope. + +org-ssg parses a defined slice of org. The boundary is not aspirational: every supported +construct has a golden-file test, and every excluded one has a test asserting how it +degrades. That is what stops the parser drifting toward all-of-org. + +* Supported + +** Headings + +Nesting by star count, with TODO keywords, priority cookies and tags: + +#+BEGIN_SRC org +,* TODO [#A] Write the parser :work:rust: +,:PROPERTIES: +,:CUSTOM_ID: write-parser +,:END: +#+END_SRC + +The keyword set is Emacs' default — =TODO= and =DONE= — matched on a word boundary, so a +heading beginning "TODOs are great" is a plain title. Keyword and priority markup uses +Emacs' own export classes. + +Every heading gets an =id=: its =:CUSTOM_ID:= if it has one, else its =:ID:=, else a slug +of its text. + +** Text and inline markup + +=*bold*=, =/italic/=, =_underline_=, =+strike+=, ~=verbatim=~ and =~code~=. Links in +every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=, +=[[file:other.org]]= — plus bare URLs in running text. + +Timestamps, active and inactive, with times and ranges, render as =<time>= with a +machine-readable =datetime=. + +** Lists + +Unordered, ordered and description lists, nested by indentation, with checkboxes and +multi-paragraph items: + +#+BEGIN_SRC org +- outer item + - inner item +- [X] a checked item +- term :: definition +#+END_SRC + +** Blocks + +=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block +inside a quote block works, because block ends match their own kind. + +An =html= export block passes through verbatim; every other backend is dropped, because +emitting LaTeX into an HTML page is worse than emitting nothing. + +An unknown block type keeps its content as an example block rather than vanishing. + +*** Which languages highlight + +Highlighting uses the syntax definitions [[https://docs.rs/syntect][syntect]] bundles. +A language it does not know is not an error — the block renders as escaped +=<pre><code class="language-…">= with its content intact, just uncoloured. + +Recognised, among others: =bash= / =sh=, =c=, =c++=, =css=, =clojure=, =diff=, =erlang=, +=go=, =haskell=, =html=, =java=, =javascript=, =json=, =latex=, =lisp=, =lua=, +=makefile=, =markdown=, =matlab=, =objective-c=, =ocaml=, =perl=, =php=, =python=, =r=, +=ruby=, =rust=, =scala=, =sql=, =tcl=, =xml=, =yaml=. + +*Not* bundled, and worth knowing before you write a page full of them: *TOML*, *INI*, +*Org* and *Emacs Lisp*. The pages of this documentation are a live example — its +=#+BEGIN_SRC toml= blocks are readable but uncoloured. + +** Tables and footnotes + +Pipe tables, with the rule row establishing a header band. Footnotes in all three forms — +=[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a +numbered, back-linked notes section. + +** Images + +A description-less link to an image file renders as =<img>=. With an affiliated +=#+CAPTION:= or =#+ATTR_HTML:= it becomes a =<figure>= with the caption as both +=<figcaption>= and alt text: + +#+BEGIN_SRC org +,#+CAPTION: The pipeline, end to end +,#+ATTR_HTML: :width 640 :class diagram +[[file:pipeline.svg]] +#+END_SRC + +Links to non-=.org= files are understood as asset links: neither resolved nor reported as +broken. + +* Keywords with meaning + +| Keyword | Effect | +|---------+--------| +| =#+TITLE:= | Page title. Falls back to the filename stem. | +| =#+DATE:= | Sorts listings. Any org date syntax. | +| =#+DESCRIPTION:= | The excerpt shown in listings. | +| =#+FILETAGS:= | Tags, for grouping and =page.tags=. | +| =#+SLUG:= | Sets the output filename. | +| =#+DRAFT:= | Keeps the page out of the build. | +| =#+OPTIONS:= | Per-file export switches. | +| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. | + +Every other =#+KEYWORD:= is available to templates as +={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches +your layout. + +* Not supported, and what happens instead + +The contract is not that these work — it is that they degrade predictably and never crash +a build. + +| Construct | What happens | +|-----------+--------------| +| Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. | +| =#+TBLFM:= | Inert. The table renders with the values as written. | +| =#+INCLUDE:= | Never expanded. Captured as an inert keyword. | +| LaTeX, MathJax | Survives as the literal text you typed. | +| Macros ={{{name}}}=, radio targets | Literal text. | +| Drawers other than =PROPERTIES= | Captured and dropped, including =LOGBOOK=. | +| Non-HTML export blocks | Dropped entirely. | +| Entities =\alpha= | Literal text. | +| =#+TODO:= sequences | Not read; the default keyword set is used. | +| Planning lines, =: = fixed-width | Render as ordinary paragraphs. | + +** Why #+RESULTS: is dropped rather than rendered + +Babel is never executed, so a checked-in results block is output from someone else's +Emacs session at some other time. Emitting it would put unverifiable content on the page +dressed as real content. The source block renders; its stale output does not. + +* Diagnostics + +Malformed input degrades rather than failing — but not *silently*, because the worst +cases are severe. An unterminated =#+BEGIN_SRC= reads the rest of the file as block +content, and an unterminated drawer does the same but renders to nothing, so one missing +line can delete most of a page. + +#+BEGIN_EXAMPLE +warning: post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to +the end of the file was read as block content +#+END_EXAMPLE + +Diagnostics carry exact line numbers through arbitrarily nested constructs, and +=--strict= turns them into a non-zero exit. + +* Measured against Emacs + +=cargo test --test oracle= exports each fixture with org's own exporter via +=emacs --batch= and snapshots the disagreement. Heading structure, list nesting and +source-block text are asserted to match exactly. + +The rest differs deliberately: + +| | org-ssg | Emacs | +|-+---------+-------| +| emphasis | =<em>= / =<strong>= | =<i>= / =<b>= | +| captioned image | =<figure>= / =<figcaption>= | =<p>= + "Figure 1: …" | +| timestamp | =<time datetime="…">= | literal =<2024-01-15 Mon>= | +| footnotes | =<section><ol>= | =<h2>Footnotes:</h2>= | +| heading anchor | slug of the text | =org1a2b3c4= | +| code | =<pre><code>= | =<pre>= | + +One genuine semantic difference: org treats a single blank line between a =1.= list and a +following =-= list as *one* list, keeping the first item's bullet type. org-ssg starts a +second list. That was kept on measurement — the pattern occurred zero times across a +179-file reference corpus — rather than on taste. new file mode 100644 @@ -0,0 +1,146 @@ +#+TITLE: Authoring +#+DESCRIPTION: URLs, drafts, excerpts, tables of contents — the metadata that shapes a page. +#+LEDE: What to put at the top of a file, and what each keyword buys you. + +* URLs + +By default a source path becomes the matching output path: =blog/post.org= → +=blog/post.html=. + +=#+SLUG:= overrides the *filename*, never the directory: + +#+BEGIN_SRC org +,#+TITLE: AES Encryption +,#+SLUG: aes-encryption +#+END_SRC + +=blog/2018-11-28-aes-encryption.org= now publishes at =blog/aes-encryption.html=. This is +how a date-prefixed filename — useful for sorting in a file manager — becomes a clean +address. + +Slugs are reduced to a single safe path component, so a slug cannot escape the output +directory however it was written. Two pages claiming one URL is a build error rather than +one silently overwriting the other. + +Links follow slugs automatically: =[[file:blog/2018-11-28-aes-encryption.org]]= resolves +to =blog/aes-encryption.html=. + +* Drafts + +#+BEGIN_SRC org +,#+DRAFT: t +#+END_SRC + +The page is not written at all, and is absent from listings, tag pages and navigation — +not merely unlinked. + +It is also out of the symbol table, so a link *to* a draft is reported as a broken link. +That is deliberate: it is what that link would be on the published site, and better found +now than by a reader. + +#+BEGIN_SRC sh +org-ssg serve content -o _site --drafts +#+END_SRC + +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 — publishing someone's unfinished post because they typed +=yes= instead of =t= is the wrong way to be strict. + +* Dates + +#+BEGIN_SRC org +,#+DATE: <2026-02-02 Mon> +,#+DATE: [2025-09-05 Fri 10:21:00] +,#+DATE: 2024-05-01 +#+END_SRC + +All three work. =page.date= keeps what you wrote, and =page.date_iso= is the +=YYYY-MM-DD= inside it — the value listings sort on and templates usually print. + +A page with no parseable date sorts *last* in a dated listing, in either direction, so a +draft with no date never leads an archive. + +* Excerpts + +=page.excerpt= is =#+DESCRIPTION:= when the page sets one, and its first paragraph +otherwise: + +#+BEGIN_SRC org +,#+DESCRIPTION: How the borrow checker thinks about lifetimes. +#+END_SRC + +The fallback matters more than the keyword: it means a listing has something to show +whether or not the author ever thought about summaries. Use =truncate= in the template to +cut a long paragraph to size. + +* Reading time + +=page.word_count= and =page.reading_time= (minutes at 200 wpm, rounded up) count *prose +only*. Source and example blocks are excluded, because a post that is mostly a shell +transcript should not read as an hour's work. =#+TITLE:= is metadata rendered as chrome, +so it is not counted either. + +* Tags + +#+BEGIN_SRC org +,#+FILETAGS: :rust:web: +#+END_SRC + +Available as =page.tags=, and the input to tag pages — see +[[file:03-collections.org][Collections]]. + +* Table of contents + +Every page's heading tree is available as =page.toc= without any markup in the file. Turn +it off for one document the way org already does: + +#+BEGIN_SRC org +,#+OPTIONS: toc:nil +#+END_SRC + +Or site-wide with =[html] toc = false=. Rendering it is the template's business; see +[[file:04-templates.org][Templates]]. + +* Section numbers + +Off by default, unlike Emacs. Turn them on for one document: + +#+BEGIN_SRC org +,#+OPTIONS: num:t +#+END_SRC + +Or site-wide with =[html] section_numbers = true=. + +* Your own metadata + +Every =#+KEYWORD:= reaches templates under its lowercased name: + +#+BEGIN_SRC org +,#+SUBTITLE: A closer look +,#+REVIEWED_BY: someone +#+END_SRC + +#+BEGIN_SRC html +{% if page.keywords.subtitle %}<p class="subtitle">{{ page.keywords.subtitle }}</p>{% endif %} +#+END_SRC + +Nothing needs to be registered, and org-ssg needs no release to support a keyword you +invented. + +* Assets + +Any non-=.org= file in the source directory is copied to the output, preserving layout: +=content/img/diagram.png= → =_site/img/diagram.png=. Reference it from a page with an +ordinary relative link, and from a template with ={{ root }}img/diagram.png=. + +Four things are *never* published: + +- Dot-entries such as =.git= and =.env=. A source directory is often a repository, and + publishing its history next to the homepage is a real way to leak a project. +- =org-ssg.toml=, which is a build input. +- The templates directory, likewise. +- The output directory, when it lives inside the source — so =org-ssg build . -o _site= + does the obvious thing rather than copying its own output back into itself. + +Note that excluding dot-entries also means =.well-known/= cannot be published. new file mode 100644 @@ -0,0 +1,115 @@ +#+TITLE: Incremental builds +#+DESCRIPTION: How the cache decides what to re-render, and why that shape is the architecture. +#+LEDE: Editing one post rebuilds four pages, whatever the size of the site. + +Incremental rebuilding is not an optimisation bolted on to org-ssg; it is the constraint +the data model was built around. Parsing is a pure function of one file's bytes, link +resolution reports the edges it used, and rendering is a pure function of a resolved +document. Those properties are what make caching sound — and they are also what make the +build parallel. + +You do not have to configure any of this. It is described here because knowing what +invalidates what explains the behaviour you will see. + +* What you observe + +#+BEGIN_EXAMPLE +$ org-ssg build content -o _site +built 182 page(s) (182 rendered, 0 cached) ... + +$ org-ssg build content -o _site +built 182 page(s) (0 rendered, 182 cached) ... + +$ vim content/blog/post.org && org-ssg build content -o _site +built 182 page(s) (4 rendered, 178 cached) ... +#+END_EXAMPLE + +The four are the post itself, its section index, its tag page, and the tag index whose +counts changed. + +* The render key + +Every page has a key composed from four hashes: + +| Component | Changes when | +|-----------+--------------| +| content | The source file's bytes change. | +| resolved links | A link's target moves, is renamed, or disappears. | +| config | =org-ssg.toml= changes, or the shared chrome does. | +| templates | Any template's source changes. | + +If a page's key matches the cached one and its output file still exists, the file on disk +is already correct and is left untouched. + +The cache lives in =<output>/.org-ssg-cache.json= and is tagged with a format version. A +version mismatch, a missing file or a corrupt file all fall back to a full rebuild — the +cache is an optimisation, never a correctness dependency. There is a test for each of +those three fallbacks. + +* Link dependencies + +Resolution records which targets each page consumed, which gives the build a dependency +graph. That is what makes renaming a heading work: + +#+BEGIN_EXAMPLE +a.org: * Target Heading +b.org: Jump to [[*Target Heading][there]]. +#+END_EXAMPLE + +Rename the heading in =a.org= and *both* pages re-render — =b.org= because the URL it +emits has changed. Without the graph, =b.html= would keep a link to an anchor that no +longer exists. On a rebuild the graph is merged with the previous build's, so a target +that was *removed* still pulls in the pages that linked to it. + +* Global chrome + +The navigation appears on every page, so a change to it must re-render every page. The +site-structure hash covers exactly the pages that can appear in the nav — which is why +the default =nav.mode = "top-level"= matters for more than aesthetics: + +- Retitling a *top-level* page changes the nav everywhere, and re-renders the site. +- Adding a *nested* page cannot change anyone's nav, and re-renders one page. + +Turning on =[templates] expose_page_list= widens that hash to every page, because then +any template can read any page's metadata. That is the documented cost of building an +index by hand instead of with a collection. + +* Generated pages + +A listing page has no source file, so it is cached on the thing it actually depends on: +the entries it lists — their URLs, titles, dates and tags. + +- Adding a post re-renders the indexes that list it. +- Editing a post's *body* changes no listing metadata, so no index is touched. +- Retitling a post re-renders the listings that display the title. + +A tag page depends on its own posts and not on the other groups. That is why the group +list is given to the tag *index* and not to every tag page: a page that could see every +group would depend on every group, and one new post would re-render every tag page. + +* Byte equivalence + +A full build (=--no-cache=) and an incremental rebuild produce *byte-identical* output. +This is the property everything else rests on, and it is a test rather than an intention: +the suite builds a site both ways and compares every emitted file. + +* Parallelism + +Parsing, resolution and rendering run across cores. Measured on a 1,790-page corpus, a +full build went from 3.98s to 0.82s on 12 cores; the 179-page reference corpus builds in +0.07s. + +Parallelism is not observable in the result. Emitted bytes are unaffected, and the build +*report* — the order of =rendered= and =skipped= — is assembled sequentially afterwards, +so a build is reproducible run to run. There is a test for that ordering, because a +non-deterministic report over a deterministic site would be a confusing thing to debug. + +* When to reach for --no-cache + +Almost never. Config changes, template edits and cache-format upgrades all invalidate +correctly on their own. It exists to answer "is the cache lying to me?" — and if it ever +is, that is a bug worth reporting, with the two builds' output to compare. + +#+BEGIN_SRC sh +org-ssg build content -o _site --no-cache +#+END_SRC new file mode 100644 @@ -0,0 +1,115 @@ +#+TITLE: Watching and serving +#+DESCRIPTION: The write-save-see loop, and what the development server does and does not do. +#+LEDE: Filesystem events, debounced rebuilds, and a browser that reloads itself. + +* serve + +#+BEGIN_SRC sh +org-ssg serve content -o _site +#+END_SRC + +Builds, watches, serves at [[http://127.0.0.1:3000][127.0.0.1:3000]], and reloads the +browser when a rebuild lands. This is the command to leave running while you write. + +Add =--drafts= to see work in progress, =--port= to move it, and =--host 0.0.0.0= to +reach it from another device. + +** It binds loopback deliberately + +A development server serves unreviewed drafts off your laptop. Exposing that to whatever +network you are on — a café, a conference, an office — should be something you ask for, +so the default is =127.0.0.1= and =--host= is the way out. + +** The reload script never reaches disk + +The script is injected into HTML *responses*, not into the built files. What you deploy +is the site as built, with no development machinery in it. If you are curious, compare a +served page with the file in your output directory. + +** How reload works + +The page carries the build generation it was rendered from, and asks the server "anything +newer than N?". The server holds that request open until there is, then answers — so a +reload is immediate rather than polled, but the mechanism is ordinary HTTP with no +WebSocket. + +Baking the generation into the page closes a race: if a rebuild lands between a page +being served and its first request going out, the server answers at once instead of the +tab sitting on stale content until your *next* edit. + +A reload only follows a *successful* rebuild. Reloading onto an unchanged page because +the build just failed tells you nothing — the error is already on your terminal. + +* watch + +#+BEGIN_SRC sh +org-ssg watch content -o _site +#+END_SRC + +The same rebuilding without the server, for when something else already serves the output. + +* What counts as a change + +Rebuilds are driven by OS filesystem events, so nothing happens while nothing happens. +The rule for what triggers one is deliberately *not* the rule the build uses to find +content — the question is "would this change the site?", not "is this a page?". + +*Triggers a rebuild:* any =.org= file, any asset, =org-ssg.toml=, and anything in the +templates directory. The last two are skipped by the build when looking for content, but +both change the output. + +*Does not:* + +- The output directory. Without this the build's own writes would raise events that + trigger a rebuild, forever. +- Dot-directories. =.git= churns on every command, and rebuilding a site because git + wrote an index lock would make watching useless in a repository. +- Editor scratch files: =file.org~=, =#file.org#=, =.#file.org=, =*.swp=, =*.tmp=. Emacs' + backup files matter here — they do not start with a dot, so they would otherwise look + exactly like content. + +* Debouncing + +Saving a file is rarely one event: an editor writes a temp file, renames it over the +original, and touches the directory. Events are collected for 120ms of quiet before a +rebuild starts, so one save is one rebuild. + +* Rebuild failures do not stop the session + +A build that fails prints the error and keeps watching. The usual cause is a half-saved +file, and the next keystroke fixes it. Nothing needs restarting. + +#+BEGIN_EXAMPLE +blog/post.org changed: build failed: parsing blog/post.org: ... +blog/post.org changed: 2 rendered, 180 cached +#+END_EXAMPLE + +* Where native watching is unavailable + +Some network and container filesystems have no event API. org-ssg falls back to polling +every two seconds and says so, rather than failing: + +#+BEGIN_EXAMPLE +note: native file watching unavailable (...); polling every 2s +#+END_EXAMPLE + +* Serving details + +- =/= and any directory URL serve =index.html=. +- Content types are set by extension; unknown extensions are served as binary. +- Everything is sent =Cache-Control: no-store=, because a cached dev response makes an + edit look like it did not land. +- URL resolution refuses to leave the output directory. =..=, percent-encoded =..=, + backslashes, absolute paths and embedded NULs all resolve to nothing. + +* A typical session + +#+BEGIN_SRC sh +# One terminal, left running. +org-ssg serve content -o _site --drafts + +# Write. The browser keeps up. + +# Before publishing, check what a real build says. +org-ssg build content -o _site --strict +#+END_SRC new file mode 100644 @@ -0,0 +1,94 @@ +#+TITLE: Auditing a corpus +#+DESCRIPTION: Find out what a tool will make of your writing before you trust it with it. +#+LEDE: Construct frequencies, unknown-name census, and no document text in the output. + +#+BEGIN_SRC sh +org-ssg audit ~/notes +#+END_SRC + +The audit answers two questions about a body of org files: + +1. *Coverage.* Of the constructs this corpus uses, which are supported? A construct that + is common here and unsupported is a problem with the tool's scope, not with your + writing. +2. *Blind spots.* Which names appear that org-ssg has no opinion about at all? These are + the dangerous ones — not "known unsupported" but unknown. + +* Reading the output + +#+BEGIN_EXAMPLE +corpus: 179 file(s), 29258 line(s) + +CONSTRUCTS (by frequency) + construct uses files first seen +IN list item 1282 109 blog/2018-11-28-aes-encryption.org:53 +IN heading 1128 174 blog/2018-11-28-aes-encryption.org:7 +IN source block 932 121 blog/2018-11-28-cpp-compiler.org:17 +... +OUT table formula (#+TBLFM:) 4 1 blog/2024-08-11-org-mode-features.org:191 +OUT entity (\name) 3 3 blog/2024-04-06-convert-onenote.org:37 + +coverage: 8854 in-scope use(s) (99.9%), 8 out-of-scope (0.1%) + +KEYWORDS + TITLE 179 179 blog/2018-11-28-aes-encryption.org:2 +??? SLUG 178 178 blog/2018-11-28-aes-encryption.org:4 + DESCRIPTION 176 176 blog/2018-11-28-aes-encryption.org:3 +... +#+END_EXAMPLE + +- =IN= is supported; =OUT= is excluded by design and degrades as described in + [[file:05-org-support.org][Org support]]. +- The *coverage* line is the number to look at first. +- =???= marks a name org-ssg does not recognise at all — in this example =#+SLUG:=, from + a run made before it was supported. + +Four censuses follow the construct table: every distinct =#+KEYWORD:=, block type, +drawer name and link scheme in the corpus. A =???= in any of them is worth a look. + +* It never prints your writing + +Names, counts and =file:line= locations only. That is a deliberate constraint so that an +audit of private notes — work notes, a journal — is safe to paste into an issue or share +with someone helping you. + +* Why it is a separate scanner + +The audit deliberately does *not* reuse the parser. Auditing with the parser could only +ever find constructs the parser already knows about, which is exactly the wrong +instrument for the second question: it would report a blind spot as clean. + +* Comparing against Emacs + +The second half of the same idea is a differential test suite. =cargo test --test oracle= +exports each fixture with org's own HTML exporter through =emacs --batch=, reduces both +outputs to a semantic skeleton, and *snapshots the disagreement*. + +#+BEGIN_SRC sh +cargo test --test oracle +#+END_SRC + +Snapshotting rather than asserting agreement is deliberate: a checked-in divergence +report gets reviewed and shows up in code review, where a permanently red test gets +ignored. Three invariants /are/ asserted outright — heading structure, list nesting and +source-block text — and all three hold. + +The suite skips cleanly with no Emacs installed, so a machine without it still gets a +green run; it simply measures one thing less. + +* Using the audit before a migration + +#+BEGIN_SRC sh +# What is in there? +org-ssg audit ~/notes + +# Build it and see what the builder itself complains about. +org-ssg build ~/notes -o /tmp/preview --strict + +# Look at the result. +org-ssg serve ~/notes -o /tmp/preview +#+END_SRC + +=--strict= surfaces broken internal links and malformed constructs as failures rather +than warnings, which is the fastest way to find the handful of files that need attention +before you commit to anything. new file mode 100644 @@ -0,0 +1,114 @@ +#+TITLE: Deploying +#+DESCRIPTION: Producing a production build, and putting it somewhere. +#+LEDE: The output is a directory of files. Everything after that is your host's problem. + +* The production build + +#+BEGIN_SRC sh +org-ssg build content -o _site --strict +#+END_SRC + +Two differences from the build you run while writing: + +- =--strict= turns broken internal links and parse diagnostics into a non-zero exit, so a + bad build fails rather than shipping. +- No =--drafts=, so pages marked =#+DRAFT:= stay out. + +Everything in =_site= is the site: HTML, the generated =syntax.css=, and every asset +copied from the source. There is no runtime, no server requirement and no build step +downstream. + +* Set base_url for production + +#+BEGIN_SRC toml +[site] +base_url = "https://example.com" +#+END_SRC + +Relative URLs work anywhere, which is why the default is empty — but two things need +absolute ones: *feeds*, because a feed is read away from the site that served it, and +*canonical links*. Without a base URL the =absolute= filter is an error rather than a +quietly relative link, so a feed template will tell you. + +No trailing slash. + +* One thing to exclude + +The build writes =.org-ssg-cache.json= into the output directory. It is a dot-file, so +most static hosts ignore it, but it is not part of the site — exclude it if your host +uploads everything: + +#+BEGIN_SRC sh +rsync -a --delete --exclude '.org-ssg-cache.json' _site/ user@host:/var/www/site/ +#+END_SRC + +Keeping the cache *between* deploys, where the CI runner can see it, is what makes CI +builds incremental. Keeping it on the *server* achieves nothing. + +* Continuous integration + +#+BEGIN_SRC yaml +name: build +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo install --path . + - run: org-ssg build content -o _site --strict + - uses: actions/upload-artifact@v4 + with: + name: site + path: _site +#+END_SRC + +=--strict= is the point of running this in CI at all: it turns a broken link into a +failed build. + +** Caching between runs + +Cache =_site/.org-ssg-cache.json= *and* =_site= together, or not at all. The manifest +describes files it expects to find; a cache without its outputs simply triggers a full +rebuild, which is correct but pointless. + +Given how fast a full build is — a 179-page site in well under a second — caching CI +builds is rarely worth the configuration. + +* Static hosts + +Nothing here is org-ssg-specific; a built site is ordinary static files. + +- *Netlify, Vercel, Cloudflare Pages*: publish directory =_site=, build command + =cargo install --path . && org-ssg build content -o _site --strict=. +- *GitHub Pages*: upload =_site= as the Pages artifact. +- *Any web server*: copy =_site= to the document root. + +** URLs end in .html + +org-ssg writes =blog/post.html= and links to it that way, so the site works with no +server configuration at all — including opening it from a filesystem path. + +If you prefer extensionless URLs, that is a server-side rewrite, and you should also set +=base_url= and check that your rewrite rules do not break the relative links in the pages. + +* Checking a build before shipping + +#+BEGIN_SRC sh +org-ssg build content -o _site --strict +org-ssg serve content -o _site +#+END_SRC + +Serving the production build locally is the last check worth doing: it catches a missing +asset or a broken relative link in the browser, where you would notice. + +* What a clean build looks like + +#+BEGIN_EXAMPLE +built 182 page(s) (182 rendered, 0 cached), copied 3 asset(s) from content -> _site (0 unresolved link(s), 0 diagnostic(s)) +#+END_EXAMPLE + +Both zeros matter. Unresolved links are internal links pointing at nothing; diagnostics +are malformed org that degraded rather than failing. With =--strict= neither can reach +this line, because either would have failed the build. new file mode 100644 @@ -0,0 +1,80 @@ +#+TITLE: org-ssg +#+DESCRIPTION: An org-mode static site generator in Rust, where the org element tree is the document model. +#+LEDE: Org is the source language, not an inconvenient input to be normalised into markdown. +#+OPTIONS: toc:nil + +org-ssg turns a directory of =.org= files into a static website. It treats org as the +*source language*: the org element tree — headings, drawers, blocks, links with their +org-specific semantics — /is/ the document model, and that tree is rendered straight to +HTML. There is no markdown-shaped intermediate representation, because the point is to +preserve what markdown cannot express. + +#+BEGIN_SRC sh +cargo run -- init my-site +cargo run -- serve my-site -o _site +#+END_SRC + +Open [[http://127.0.0.1:3000][127.0.0.1:3000]], edit any =.org= file, and the browser reloads itself. + +* Start here + +- [[file:install.org][Install]] — get the binary built and on your PATH. +- [[file:quickstart.org][Quick start]] — a working site in two commands, then your own content. +- [[file:guide/01-cli.org][The guide]] — every command, setting, template variable and org construct. + +* What you get with no configuration at all + +Point it at a directory of org files and you get a complete site: pages, navigation, +syntax-highlighted code, and the stylesheet that colours it. Nothing about your files has +to change, and no =org-ssg.toml= is required. + +#+BEGIN_SRC sh +org-ssg build ~/notes -o _site +#+END_SRC + +Configuration changes what you get. It is never what makes it work. + +* What it does that is unusual + +** Incremental builds are the architecture + +Every page has a render key composed from its content, its resolved links, the site +config and the templates. Editing one post re-renders that post, its section index, its +tag pages, and the tag index whose counts changed — four pages, whatever the size of the +site. A full build and an incremental build produce byte-identical output, and a test +proves it. + +** It is measured against Emacs + +=cargo test --test oracle= exports each test fixture with org's own HTML exporter through +=emacs --batch= and records the disagreement. Heading structure, list nesting and +source-block text match exactly. Everything that still differs is a deliberate choice, +listed in [[file:guide/05-org-support.org][Org support]]. + +** It tells you what your corpus actually uses + +#+BEGIN_SRC sh +org-ssg audit ~/notes +#+END_SRC + +The audit reports which org constructs appear in a corpus, how often, and whether each is +supported — so you can find out before you trust a tool with your writing. It reports +names, counts and =file:line= locations only, never document text, so auditing private +notes stays safe to paste into an issue. + +* Feature summary + +| Area | What is there | +|------+---------------| +| Org syntax | headings with TODO/priority/tags, lists (nested, description, checkboxes), tables, source blocks, quote/center/example/export blocks, footnotes, timestamps, links, images with captions | +| Output | syntax highlighting via syntect, table of contents, section numbers, heading anchors | +| Structure | =#+SLUG:= URLs, drafts, generated listing pages, tag pages and tag indexes, pagination, RSS feeds | +| Templates | minijinja layouts with inheritance, rich page metadata, custom filters | +| Workflow | incremental rebuilds, =watch= on filesystem events, =serve= with live reload | +| Confidence | 152 tests, an =emacs --batch= differential oracle, a corpus audit tool | + +* Status + +This documentation site is itself an org-ssg site — the sources are in =docs/= and it is +built with the command in [[file:quickstart.org][Quick start]]. If a feature is described here, it is being used +to render the page describing it. new file mode 100644 @@ -0,0 +1,96 @@ +#+TITLE: Install +#+DESCRIPTION: Build org-ssg from source, put it on your PATH, and check that it works. +#+LEDE: One Rust toolchain, one command, no runtime dependencies. + +* Requirements + +- *Rust 1.82 or newer.* Install from [[https://rustup.rs][rustup.rs]] if you do not have it. There is no other + runtime requirement: the binary is self-contained, with syntax definitions and + highlighting themes compiled in. +- *Emacs (optional).* Only the differential test suite uses it, to compare output against + org's own exporter. Nothing about building a site needs Emacs. + +* From source + +#+BEGIN_SRC sh +git clone <repository-url> org-ssg +cd org-ssg +cargo build --release +#+END_SRC + +The binary lands at =target/release/org-ssg=. Copy it somewhere on your =PATH=: + +#+BEGIN_SRC sh +cp target/release/org-ssg ~/.local/bin/ +#+END_SRC + +Or let cargo do it, which puts it in =~/.cargo/bin=: + +#+BEGIN_SRC sh +cargo install --path . +#+END_SRC + +* Running without installing + +Every command in this documentation works through cargo if you would rather not install +anything. Replace =org-ssg= with =cargo run --= and add =--release= for a fast build: + +#+BEGIN_SRC sh +cargo run --release -- build my-site -o _site +#+END_SRC + +The debug build is fine for small sites and noticeably slower on large ones, because +syntax highlighting dominates and is not optimised in a debug profile. + +* Check that it works + +#+BEGIN_SRC sh +org-ssg --version +org-ssg init /tmp/org-ssg-check +org-ssg build /tmp/org-ssg-check -o /tmp/org-ssg-check/_site +#+END_SRC + +You should see a line reporting the pages built: + +#+BEGIN_EXAMPLE +built 5 page(s) (5 rendered, 0 cached), copied 0 asset(s) ... (0 unresolved link(s), 0 diagnostic(s)) +#+END_EXAMPLE + +Open =/tmp/org-ssg-check/_site/index.html= in a browser, or serve it properly: + +#+BEGIN_SRC sh +org-ssg serve /tmp/org-ssg-check -o /tmp/org-ssg-check/_site +#+END_SRC + +* Running the test suite + +#+BEGIN_SRC sh +cargo test +#+END_SRC + +152 tests, covering the parser, the renderer, configuration, generated pages, the +incremental cache, the watcher and the development server. + +The oracle suite is part of that run and compares output against Emacs: + +#+BEGIN_SRC sh +cargo test --test oracle +#+END_SRC + +It *skips cleanly* when there is no =emacs= on your =PATH=, so a machine without Emacs +still gets a green test run — it simply measures one thing less. + +* Upgrading + +org-ssg stores an incremental cache in =<output>/.org-ssg-cache.json=, tagged with a +format version. A newer binary that changes how output is produced bumps that version, +and a version it does not recognise is discarded in favour of a full rebuild. You never +need to clear the cache by hand after an upgrade — but if you want to: + +#+BEGIN_SRC sh +org-ssg clean _site +#+END_SRC + +* Next + +[[file:quickstart.org][Quick start]] builds a real site and puts your own writing into it. new file mode 100644 @@ -0,0 +1,44 @@ +# Configuration for the org-ssg documentation site. +# +# This site is built by org-ssg itself, so this file doubles as a worked example: every +# setting here is one the docs describe, used the way the docs recommend. + +[site] +title = "org-ssg" +description = "An org-mode static site generator, in Rust." +language = "en" +# Left empty so the docs build with relative URLs and open from the filesystem. Set it to +# your real origin to enable canonical links and feeds. +base_url = "" + +[nav] +# Explicit, because the header already links home: listing index.org here as well would +# repeat the site title in the nav directly beside itself. The guide reaches the nav as a +# collection below, since it lives in a subdirectory rather than at the top level. +mode = "explicit" +pages = ["install.org", "quickstart.org"] + +[templates] +dir = "templates" + +[highlight] +# A dark theme, with code blocks styled dark in both colour schemes. syntax.css is +# generated from a single syntect theme and cannot respond to prefers-color-scheme, so +# the page CSS matches the theme rather than leaving code unreadable in one of them. +theme = "base16-ocean.dark" + +[html] +# The layout renders the page title as <h1>, so document headings start at <h2>. +heading_offset = 1 +toc = true +section_numbers = false + +# The guide's contents page, listing every chapter in reading order rather than by date. +[[collections]] +source = "guide" +output = "guide/index.html" +template = "list.html" +title = "Guide" +sort = "path" +order = "asc" +nav = true new file mode 100644 @@ -0,0 +1,165 @@ +#+TITLE: Quick start +#+DESCRIPTION: A working site in two commands, then your own writing, then your own design. +#+LEDE: Five minutes from nothing to a site that reloads as you type. + +* Two commands + +#+BEGIN_SRC sh +org-ssg init my-site +org-ssg serve my-site -o _site +#+END_SRC + +Open [[http://127.0.0.1:3000][127.0.0.1:3000]]. Edit =my-site/index.org= in your editor, save, and the page reloads +on its own. + +=init= writes only files that do not already exist, so running it inside a directory that +already has content is safe and additive. + +** What init created + +#+BEGIN_EXAMPLE +my-site/ + org-ssg.toml every setting, at its default, commented + index.org the home page + blog/first-post.org a post, to show the collection working + templates/ + base.html the page layout — edit this + list.html the blog index + tags.html the tag index + feed.xml an RSS feed +#+END_EXAMPLE + +* Or skip all of that + +You do not need =init=, a config file, or templates. Point the builder at org files you +already have: + +#+BEGIN_SRC sh +org-ssg build ~/notes -o _site +#+END_SRC + +You get a complete site with a built-in layout, navigation across your top-level pages, +and syntax highlighting. Nothing in your files has to change. + +Before trusting it with a large collection, ask what it makes of your writing: + +#+BEGIN_SRC sh +org-ssg audit ~/notes +#+END_SRC + +That reports which org constructs appear, how often, and whether each is supported. See +[[file:guide/09-auditing.org][Auditing a corpus]]. + +* Write a page + +Any =.org= file under the source directory becomes a page at the matching path. +=notes/rust/borrowing.org= becomes =notes/rust/borrowing.html=. + +#+BEGIN_SRC org +,#+TITLE: Borrowing +,#+DATE: <2026-02-02 Mon> +,#+FILETAGS: :rust:notes: +,#+DESCRIPTION: How the borrow checker thinks about lifetimes. + +An opening paragraph, which becomes the excerpt in listings when there is no +description. + +,* A heading + +Ordinary org: *bold*, /italic/, ~code~, [[https://orgmode.org][links]], and lists. + +,#+BEGIN_SRC rust +fn main() {} +,#+END_SRC +#+END_SRC + +The keywords are all optional. =#+TITLE:= names the page, =#+DATE:= orders it in +listings, =#+FILETAGS:= groups it on tag pages, and =#+DESCRIPTION:= is its summary. + +** Control the URL + +By default the filename decides the URL. =#+SLUG:= overrides it, which is how a +date-prefixed filename becomes a clean address: + +#+BEGIN_SRC org +,#+TITLE: Borrowing +,#+SLUG: borrowing-explained +#+END_SRC + +=2026-02-02-borrowing.org= now publishes as =borrowing-explained.html=. + +** Keep something unfinished out of the build + +#+BEGIN_SRC org +,#+DRAFT: t +#+END_SRC + +The page is not written, and does not appear in listings or navigation. Preview it while +you work with =--drafts=: + +#+BEGIN_SRC sh +org-ssg serve my-site -o _site --drafts +#+END_SRC + +* Change the design + +Everything visual lives in =templates/base.html=. It is an ordinary +[[https://docs.rs/minijinja][minijinja]] (Jinja2) template, and replacing it replaces the +whole layout: + +#+BEGIN_SRC html +<!DOCTYPE html> +<html lang="{{ site.language }}"> +<head> + <meta charset="utf-8"> + <title>{{ page.title }} — {{ site.title }}</title> + <link rel="stylesheet" href="{{ root }}style.css"> +</head> +<body> + <nav>{% for item in nav %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}</nav> + <h1>{{ page.title }}</h1> + {{ body | safe }} +</body> +</html> +#+END_SRC + +=root= is the =../= prefix back to the site root, so the same template works at any +depth. Any other file in the source directory — =style.css=, images, fonts — is copied to +the output untouched. + +Editing a template rebuilds every page that uses it, so the browser reloads while you are +still looking at it. The full list of variables is in [[file:guide/04-templates.org][Templates]]. + +* Add a blog index + +Listing pages have no source file; they are declared in =org-ssg.toml=: + +#+BEGIN_SRC toml +[[collections]] +source = "blog" +output = "blog/index.html" +template = "list.html" +title = "Blog" +sort = "date" +order = "desc" +nav = true +#+END_SRC + +That is also how you get tag pages, pagination and an RSS feed — same mechanism, more +settings. See [[file:guide/03-collections.org][Collections]]. + +* Build for real + +#+BEGIN_SRC sh +org-ssg build my-site -o _site --strict +#+END_SRC + +=--strict= turns broken internal links and parse diagnostics into a non-zero exit, which +is what you want in CI. Deployment is just copying =_site= somewhere; see +[[file:guide/10-deploying.org][Deploying]]. + +* Next + +- [[file:guide/01-cli.org][Command reference]] — every command and flag. +- [[file:guide/02-configuration.org][Configuration]] — every setting in =org-ssg.toml=. +- [[file:guide/05-org-support.org][Org support]] — exactly which org syntax is handled. new file mode 100644 @@ -0,0 +1,130 @@ +/* Documentation site styling. + * + * A plain asset, copied through the build untouched — which is also how any other CSS, + * image or font in a source directory reaches the output. */ + +:root { + --ink: #1c1f24; + --muted: #5b6472; + --rule: #dfe3e8; + --accent: #0b5fa5; + --surface: #f6f8fa; + --measure: 42rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #dee3ea; + --muted: #9aa4b2; + --rule: #2b3138; + --accent: #79b8ff; + --surface: #171a1f; + } + body { background: #0f1216; } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + color: var(--ink); + font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +header.site { + border-bottom: 1px solid var(--rule); + padding: 1rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 1rem 1.5rem; + align-items: baseline; +} + +header.site .site-title { + font-weight: 700; + font-size: 1.05rem; + color: var(--ink); + text-decoration: none; +} + +header.site nav { display: flex; gap: 1.25rem; flex-wrap: wrap; } +header.site nav a { color: var(--muted); text-decoration: none; } +header.site nav a:hover { color: var(--accent); } + +main { + max-width: var(--measure); + margin: 0 auto; + padding: 2.5rem 1.5rem 5rem; +} + +h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; letter-spacing: -0.02em; } +h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; letter-spacing: -0.01em; } +h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; } + +p.page-date, p.lede { color: var(--muted); } +p.lede { font-size: 1.1rem; margin-top: 0; } + +a { color: var(--accent); } + +code { + font: 0.875em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + background: var(--surface); + padding: .1em .35em; + border-radius: 3px; +} + +/* Code blocks are dark in both colour schemes, matching the syntect theme named in + org-ssg.toml. One generated stylesheet cannot follow prefers-color-scheme, so the page + commits to the theme's palette instead of leaving highlighted code unreadable in one + of the two. */ +pre { + background: #2b303b; + color: #c0c5ce; + border: 1px solid #1f232b; + border-radius: 6px; + padding: .9rem 1rem; + overflow-x: auto; +} + +pre code { background: none; padding: 0; } + +blockquote { + margin: 1.5rem 0; + padding: .25rem 0 .25rem 1rem; + border-left: 3px solid var(--rule); + color: var(--muted); +} + +table { border-collapse: collapse; width: 100%; margin: 1.25rem 0; display: block; overflow-x: auto; } +th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--rule); } +th { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } + +hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; } + +/* Table of contents, emitted from page.toc */ +nav.toc { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 6px; + padding: .75rem 1.25rem 1rem; + margin: 1.5rem 0 2.5rem; +} +nav.toc h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; margin: .25rem 0 .5rem; color: var(--muted); } +nav.toc ul { margin: 0; padding-left: 1.1rem; } +nav.toc li { margin: .15rem 0; } + +ul.post-list { list-style: none; padding: 0; } +ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--rule); } +ul.post-list a { font-weight: 600; font-size: 1.05rem; } +p.excerpt { margin: .35rem 0 .2rem; color: var(--muted); } +span.reading-time { font-size: .85rem; color: var(--muted); } + +footer.site { + border-top: 1px solid var(--rule); + padding: 1.5rem; + color: var(--muted); + font-size: .9rem; + text-align: center; +} + +.tag { font-size: .75rem; background: var(--surface); border: 1px solid var(--rule); border-radius: 999px; padding: .1em .6em; color: var(--muted); } new file mode 100644 @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html lang="{{ site.language }}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ page.title }} · {{ site.title }}</title> +{%- if site.base_url %} +<link rel="canonical" href="{{ page.url | absolute }}"> +{%- endif %} +<meta name="description" content="{{ page.excerpt | truncate(150) }}"> +<link rel="stylesheet" href="{{ root }}style.css"> +{%- if stylesheet %} +<link rel="stylesheet" href="{{ stylesheet }}"> +{%- endif %} +</head> +<body> +<header class="site"> +<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a> +{%- if nav %} +<nav> +{%- for item in nav %} +<a href="{{ item.url }}">{{ item.title }}</a> +{%- endfor %} +</nav> +{%- endif %} +</header> +<main> +<h1>{{ page.title }}</h1> +{%- if page.keywords.lede %} +<p class="lede">{{ page.keywords.lede }}</p> +{%- endif %} +{%- if page.toc | length > 1 %} +{%- macro toc_list(entries) %} +<ul> +{%- for entry in entries %} +<li><a href="#{{ entry.anchor }}">{{ entry.title }}</a> +{%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li> +{%- endfor %} +</ul> +{%- endmacro %} +<nav class="toc" aria-label="On this page"> +<h2>On this page</h2> +{{- toc_list(page.toc) }} +</nav> +{%- endif %} +{% block content %}{{ body | safe }}{% endblock %}</main> +<footer class="site"> +Built with org-ssg — these docs are an org-ssg site. +</footer> +</body> +</html> new file mode 100644 @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +<ul class="post-list"> +{%- for entry in pages %} +<li> +<a href="{{ root }}{{ entry.url }}">{{ entry.title }}</a> +{%- if entry.excerpt %} +<p class="excerpt">{{ entry.excerpt | truncate(180) }}</p> +{%- endif %} +<span class="reading-time">{{ entry.reading_time }} min read</span> +</li> +{%- endfor %} +</ul> +{% endblock %}