krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 903ac6c8d6

903ac6c8d657e2cedd2b6edcebfc4649864ee954

parent: 978d2a152a

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 06:25 UTC

Add a documentation site under docs/, built by org-ssg itself

Thirteen pages: install, quick start, and a ten-chapter guide covering the CLI,
configuration, collections, templates, org support, authoring, incremental builds,
watching and serving, auditing, and deploying.

The docs are an org-ssg site — org sources, a config, templates and a stylesheet in
docs/, built with `org-ssg build docs -o docs/_site`. Writing them that way was the point
as much as the result: every feature they describe is being used to render the page
describing it, so the documentation cannot drift from a tool that still builds it.

Dogfooding found four things immediately:

- Eleven broken cross-references, because the guide files are numbered for reading order
  (01-cli.org) and the prose linked to them unnumbered. The build reported every one with
  its source file, which is exactly what that reporting is for.
- The nav repeated the site title, since index.org is a top-level page and the header
  already links home. Fixed with nav.mode = "explicit", which also gives the docs a
  worked example of it.
- Highlighted code was unreadable in dark mode: syntax.css is generated from one syntect
  theme and cannot follow prefers-color-scheme. The page CSS now commits to the theme's
  palette rather than leaving one of the two schemes broken.
- syntect bundles no TOML, INI, Org or Emacs Lisp syntax, which a config-heavy docs site
  hits on its first page. Now documented, with the list of what *is* bundled — the docs'
  own uncoloured toml blocks are the example.

The site also exercises features end to end that only had unit tests: template
inheritance via {% extends %} with a block override, a collection sorted by path for
reading order rather than by date, a custom #+LEDE: keyword reaching the layout through
page.keywords, page.toc rendered as a nested tree, and an asset copied through the build.

Layout: unified · split

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