Commit 903ac6c8d6
Verified · cmc
Layout: unified · split
.gitignore +1
| @@ -1 +1,2 @@ | ||
| 1 | 1 | /target |
| 2 | _site/ | |
README.md +7
| @@ -13,6 +13,13 @@ hashing**, treated as a first-class architectural concern from day one. The disc | ||
| 13 | 13 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real |
| 14 | 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. |
| 15 | 15 | |
| 16 | **Full documentation is in [`docs/`](docs/)** — a site written in org and built by | |
| 17 | org-ssg itself. Build and read it with: | |
| 18 | ||
| 19 | ```bash | |
| 20 | cargo run -- serve docs -o docs/_site | |
| 21 | ``` | |
| 22 | ||
| 16 | 23 | ## Quick start |
| 17 | 24 | |
| 18 | 25 | ```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 | |
| 8 | org-ssg build <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE] | |
| 9 | #+END_SRC | |
| 10 | ||
| 11 | If =INPUT= is a directory, it is walked and built into a linked site at =OUTPUT=. If it | |
| 12 | is a single =.org= file, one HTML file is written — useful for one-off conversions, | |
| 13 | though 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 | ||
| 23 | The summary line reports what happened: | |
| 24 | ||
| 25 | #+BEGIN_EXAMPLE | |
| 26 | built 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 | |
| 30 | second build with nothing changed it is zero. | |
| 31 | ||
| 32 | ** --strict is for CI | |
| 33 | ||
| 34 | Without it, a broken link is a warning and the build succeeds. With it, the build fails | |
| 35 | and names every problem. Use it wherever a bad build should not ship: | |
| 36 | ||
| 37 | #+BEGIN_SRC sh | |
| 38 | org-ssg build content -o _site --strict | |
| 39 | #+END_SRC | |
| 40 | ||
| 41 | * serve | |
| 42 | ||
| 43 | #+BEGIN_SRC sh | |
| 44 | org-ssg serve <INPUT> -o <OUTPUT> [-p PORT] [--host HOST] [--drafts] [--config FILE] | |
| 45 | #+END_SRC | |
| 46 | ||
| 47 | Builds, watches, serves, and reloads the browser when a rebuild lands. This is the | |
| 48 | command 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 | |
| 57 | laptop, so reaching the local network is something you ask for: | |
| 58 | ||
| 59 | #+BEGIN_SRC sh | |
| 60 | org-ssg serve content -o _site --host 0.0.0.0 | |
| 61 | #+END_SRC | |
| 62 | ||
| 63 | The live-reload script is injected into responses and never written to disk, so what you | |
| 64 | deploy stays clean. Details in [[file:../guide/08-workflow.org][Watching and serving]]. | |
| 65 | ||
| 66 | * watch | |
| 67 | ||
| 68 | #+BEGIN_SRC sh | |
| 69 | org-ssg watch <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE] | |
| 70 | #+END_SRC | |
| 71 | ||
| 72 | Rebuilds on filesystem events with no server — for when something else is already serving | |
| 73 | the output, or you just want the build to keep up as you write. | |
| 74 | ||
| 75 | * audit | |
| 76 | ||
| 77 | #+BEGIN_SRC sh | |
| 78 | org-ssg audit <INPUT> | |
| 79 | #+END_SRC | |
| 80 | ||
| 81 | Reports which org constructs a corpus uses and how they land against what org-ssg | |
| 82 | supports, plus a census of every keyword, block type, drawer and link scheme seen. Point | |
| 83 | it at your notes before trusting a tool with them. See [[file:../guide/09-auditing.org][Auditing a corpus]]. | |
| 84 | ||
| 85 | It prints names, counts and =file:line= locations — never document text — so an audit of | |
| 86 | private notes is safe to share. | |
| 87 | ||
| 88 | * init | |
| 89 | ||
| 90 | #+BEGIN_SRC sh | |
| 91 | org-ssg init [DIRECTORY] | |
| 92 | #+END_SRC | |
| 93 | ||
| 94 | Scaffolds a working site: a fully commented config, an editable copy of the built-in | |
| 95 | layout, listing and tag templates, an RSS template, a home page and a first post. | |
| 96 | Defaults to the current directory. | |
| 97 | ||
| 98 | Only files that do not already exist are written, so it is safe to run inside a directory | |
| 99 | that already has content — it fills in what is missing and leaves the rest alone. | |
| 100 | ||
| 101 | * clean | |
| 102 | ||
| 103 | #+BEGIN_SRC sh | |
| 104 | org-ssg clean <OUTPUT> | |
| 105 | #+END_SRC | |
| 106 | ||
| 107 | Removes the output directory, including the incremental cache manifest inside it. You | |
| 108 | rarely 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 | ||
| 117 | Diagnostics are printed as =file:line: message=, the form an editor can jump to: | |
| 118 | ||
| 119 | #+BEGIN_EXAMPLE | |
| 120 | warning: blog/post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to the end of the file was read as block content | |
| 121 | warning: 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 | ||
| 5 | org-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 | |
| 7 | builds a complete site. | |
| 8 | ||
| 9 | A *missing* config is normal and silent. A *malformed* one is an error, and an unknown | |
| 10 | key is rejected by name — a misspelled setting that silently does nothing is how people | |
| 11 | lose an afternoon. | |
| 12 | ||
| 13 | * The whole file | |
| 14 | ||
| 15 | #+BEGIN_SRC toml | |
| 16 | [site] | |
| 17 | title = "org-ssg site" | |
| 18 | base_url = "" | |
| 19 | description = "" | |
| 20 | language = "en" | |
| 21 | ||
| 22 | [nav] | |
| 23 | mode = "top-level" | |
| 24 | # pages = ["index.org", "about.org"] | |
| 25 | ||
| 26 | [templates] | |
| 27 | dir = "templates" | |
| 28 | expose_page_list = false | |
| 29 | ||
| 30 | [highlight] | |
| 31 | theme = "InspiredGitHub" | |
| 32 | ||
| 33 | [build] | |
| 34 | drafts = false | |
| 35 | ||
| 36 | [html] | |
| 37 | heading_offset = 1 | |
| 38 | toc = true | |
| 39 | section_numbers = false | |
| 40 | #+END_SRC | |
| 41 | ||
| 42 | Plus 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 | ||
| 55 | Leave it empty and the site is built entirely with relative URLs, which means it works | |
| 56 | from a subdirectory, from a filesystem path, and from any origin. That portability is why | |
| 57 | it is the default. | |
| 58 | ||
| 59 | Set it when you need absolute URLs, which two things require: *feeds*, because a feed is | |
| 60 | read away from the site that served it, and *canonical links*. The =absolute= template | |
| 61 | filter turns a site-root-relative path into a full URL, and errors if there is no base | |
| 62 | URL to build one from — rather than quietly emitting a relative URL that would make the | |
| 63 | feed invalid everywhere while looking fine. | |
| 64 | ||
| 65 | A trailing slash is rejected, because =https://example.com/= plus =blog/x.html= is | |
| 66 | =https://example.com//blog/x.html=. | |
| 67 | ||
| 68 | * [nav] | |
| 69 | ||
| 70 | The 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 | |
| 80 | grows with the square of the site. On a 1,790-page site that was 284 MB of mostly | |
| 81 | navigation. 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 | |
| 84 | contents, so nav size does not depend on how much you write. | |
| 85 | ||
| 86 | ** explicit | |
| 87 | ||
| 88 | #+BEGIN_SRC toml | |
| 89 | [nav] | |
| 90 | mode = "explicit" | |
| 91 | pages = ["index.org", "about.org", "uses.org"] | |
| 92 | #+END_SRC | |
| 93 | ||
| 94 | Paths are *source* paths relative to the source root, and the order given is the order | |
| 95 | rendered — a hand-written nav is a designed sequence, not an alphabetical one. Naming a | |
| 96 | page that does not exist is an error, because a silently shorter nav is a poor way to | |
| 97 | learn about a typo. | |
| 98 | ||
| 99 | ** Section landing pages in the nav | |
| 100 | ||
| 101 | If your sections live in subdirectories, none of them are top-level pages. Put the | |
| 102 | section's *generated* index in the nav instead, with =nav = true= on its collection — | |
| 103 | that 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 | ||
| 114 | With it on, any page can read every page's metadata — so adding one page can change any | |
| 115 | page's output, and the whole site must re-render on every add, rename or retitle. That is | |
| 116 | the 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 | |
| 118 | adding a post a one-page rebuild. | |
| 119 | ||
| 120 | * [highlight] | |
| 121 | ||
| 122 | | Key | Default | | |
| 123 | |-----+---------| | |
| 124 | | =theme= | ="InspiredGitHub"= | | |
| 125 | ||
| 126 | Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=, | |
| 127 | =base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=. | |
| 128 | An unknown name is an error listing the valid ones. | |
| 129 | ||
| 130 | Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet. | |
| 131 | Each 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 | |
| 140 | on; 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 | ||
| 152 | A level-1 org heading renders as =<h2>= by default, because the layout supplies the page | |
| 153 | title as the =<h1>=. This matches Emacs, whose =org-html-toplevel-hlevel= is 2 for the | |
| 154 | same reason. | |
| 155 | ||
| 156 | Set it to =0= if your template renders no title of its own — otherwise the document | |
| 157 | starts 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 | |
| 162 | numbered headings whether or not anyone chose them. Most sites do not want them, so the | |
| 163 | default 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 | ||
| 168 | Org'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 | ||
| 179 | Off is spelled =nil=, =false=, =no=, =0= or =off=; anything else is on. | |
| 180 | ||
| 181 | * Configuration is a cache input | |
| 182 | ||
| 183 | The resolved config is hashed into every page's render key, so editing =org-ssg.toml= | |
| 184 | re-renders exactly the pages it affects — which for most settings is all of them. You | |
| 185 | never 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 | ||
| 5 | A 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 | |
| 7 | file out, through a template. | |
| 8 | ||
| 9 | Keeping it declarative means an RSS feed is the same mechanism with an XML template | |
| 10 | rather than a second feature. | |
| 11 | ||
| 12 | * A blog index | |
| 13 | ||
| 14 | #+BEGIN_SRC toml | |
| 15 | [[collections]] | |
| 16 | source = "blog" # directory to list; empty means every page | |
| 17 | output = "blog/index.html" # where to write it | |
| 18 | template = "list.html" # template file name | |
| 19 | title = "Blog" | |
| 20 | sort = "date" # date | title | path | |
| 21 | order = "desc" # desc | asc | |
| 22 | nav = true # put this page in the site nav | |
| 23 | #+END_SRC | |
| 24 | ||
| 25 | The 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 | ||
| 47 | Date sorting uses =page.date_iso=, the =YYYY-MM-DD= extracted from =#+DATE:= whatever org | |
| 48 | syntax 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 | |
| 52 | leads a dated archive. | |
| 53 | ||
| 54 | ** nav = true | |
| 55 | ||
| 56 | The 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: | |
| 58 | the section, not any one post in it. | |
| 59 | ||
| 60 | * Tag pages | |
| 61 | ||
| 62 | Add =group_by= and the collection emits one page /per group/ instead of one page total, | |
| 63 | plus an optional index of the groups: | |
| 64 | ||
| 65 | #+BEGIN_SRC toml | |
| 66 | [[collections]] | |
| 67 | source = "blog" | |
| 68 | group_by = "tags" # "tags", or any #+KEYWORD: name | |
| 69 | output = "tags/{tag}.html" # {tag} becomes each group's slug | |
| 70 | template = "tag.html" | |
| 71 | title = "Tagged: {tag}" | |
| 72 | index_output = "tags/index.html" | |
| 73 | index_template = "tags.html" | |
| 74 | index_title = "Tags" | |
| 75 | nav = true # adds the *index*, not every tag | |
| 76 | #+END_SRC | |
| 77 | ||
| 78 | A 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 | ||
| 85 | The 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 | |
| 96 | value 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 | |
| 102 | the other. That is a build error naming both values. | |
| 103 | ||
| 104 | * Pagination | |
| 105 | ||
| 106 | #+BEGIN_SRC toml | |
| 107 | [[collections]] | |
| 108 | source = "blog" | |
| 109 | output = "blog/index.html" | |
| 110 | paginate = 10 | |
| 111 | paginate_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 | |
| 115 | changes. Only pages 2..N are named by =paginate_output=. | |
| 116 | ||
| 117 | The 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 | ||
| 139 | Every 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 | |
| 141 | where it sits. An unpaginated collection has no =paginator= at all, so | |
| 142 | ={% if paginator %}= is a reliable test in a shared template. | |
| 143 | ||
| 144 | Grouping 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 | ||
| 149 | A feed is a listing page with an XML template. Templates load by full filename and any | |
| 150 | extension, so: | |
| 151 | ||
| 152 | #+BEGIN_SRC toml | |
| 153 | [[collections]] | |
| 154 | source = "blog" | |
| 155 | output = "feed.xml" | |
| 156 | template = "feed.xml" | |
| 157 | title = "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 | ||
| 179 | This needs =site.base_url=, because a feed with relative links is invalid everywhere it | |
| 180 | is read. =org-ssg init= writes this template and leaves the collection commented out | |
| 181 | until 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 | ||
| 203 | A 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 | ||
| 211 | A tag page depends on its own posts and not on the other groups, which is why =groups= is | |
| 212 | given to the index and not to every group page: a page that can see every group would | |
| 213 | depend on every group, and one new post would re-render every tag page. | |
| 214 | ||
| 215 | When a collection shrinks below a page boundary, the pages that no longer exist are | |
| 216 | deleted 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 | ||
| 5 | Templates live in the directory named by =[templates] dir=, default =templates/=. They | |
| 6 | are [[https://docs.rs/minijinja][minijinja]] templates — Jinja2 syntax — loaded at build | |
| 7 | time, so editing one and rebuilding is the whole edit cycle. | |
| 8 | ||
| 9 | * base.html replaces the layout | |
| 10 | ||
| 11 | A 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 | ||
| 34 | A template that does not compile is a *build error*, not a fallback to the default — | |
| 35 | someone editing a layout should see the mistake, not output that looks like their edit | |
| 36 | did nothing. | |
| 37 | ||
| 38 | * Names are full filenames | |
| 39 | ||
| 40 | Templates are registered under their full relative filename: =base.html=, | |
| 41 | =partials/head.html=, =feed.xml=. That is what ={% extends "base.html" %}= names, and it | |
| 42 | is why a template can have any extension — which is how an RSS feed is just a listing | |
| 43 | page. | |
| 44 | ||
| 45 | #+BEGIN_SRC html | |
| 46 | {% extends "base.html" %} | |
| 47 | {% block content %}<p>Only this part differs.</p>{% endblock %} | |
| 48 | #+END_SRC | |
| 49 | ||
| 50 | Subdirectories work, so ={% include "partials/header.html" %}= does what you expect. | |
| 51 | ||
| 52 | * Variables | |
| 53 | ||
| 54 | ** body | |
| 55 | ||
| 56 | The rendered page HTML. Always use ={{ body | safe }}= — it is already HTML, and | |
| 57 | escaping it would print tags at the reader. | |
| 58 | ||
| 59 | Empty 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 | |
| 79 | exists. | |
| 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 | ||
| 88 | A list of ={title, url}=, with URLs relative to the current page. | |
| 89 | ||
| 90 | ** root | |
| 91 | ||
| 92 | The =../= prefix back to the site root from this page: empty at the top level, =../= one | |
| 93 | level down. Prefix it to any site-root-relative path so the same template works at any | |
| 94 | depth: | |
| 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 | ||
| 103 | URL of the generated =syntax.css=, relative to this page. Link it or code blocks are | |
| 104 | unstyled. | |
| 105 | ||
| 106 | ** pages, group, groups, paginator | |
| 107 | ||
| 108 | Present on generated pages; see [[file:03-collections.org][Collections]]. =pages= is also | |
| 109 | available on every page when =[templates] expose_page_list = true=. | |
| 110 | ||
| 111 | ** page.toc | |
| 112 | ||
| 113 | The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from | |
| 114 | a flat list of levels inside a template is what Jinja is worst at. | |
| 115 | ||
| 116 | Each 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 | ||
| 129 | Anchors come from the same function that emits heading =id= attributes, so a TOC link | |
| 130 | cannot drift from the heading it points at. The tree is empty when the page has no | |
| 131 | headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=. | |
| 132 | ||
| 133 | * Filters | |
| 134 | ||
| 135 | Beyond 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 | ||
| 149 | Apply it to the *site-root-relative* values — =page.url=, =pages[].url=, =group.url= — | |
| 150 | and not to =nav[].url=, =paginator.*_url=, =stylesheet= or =root=, which are relative to | |
| 151 | the page carrying them and already correct there. | |
| 152 | ||
| 153 | An already-absolute URL passes through, so a template can apply it uniformly to internal | |
| 154 | paths and external links. With no =base_url= it is an *error* naming the setting, rather | |
| 155 | than a relative URL that would make a feed invalid. | |
| 156 | ||
| 157 | ** truncate | |
| 158 | ||
| 159 | minijinja ships no truncate, and an excerpt is usually a whole paragraph — so without one | |
| 160 | a listing's only options are the full paragraph or nothing. | |
| 161 | ||
| 162 | * Escaping | |
| 163 | ||
| 164 | Output is HTML-escaped by default, because titles and text are author content. | |
| 165 | ={{ body | safe }}= is the deliberate exception. | |
| 166 | ||
| 167 | Unlike stock minijinja, =/= is *not* escaped. Escaping it is a defence for values | |
| 168 | interpolated into JavaScript, and since =<= is escaped anyway it buys nothing in an HTML | |
| 169 | document — while making every URL read =../index.html=. Templates emit a lot of | |
| 170 | URLs. | |
| 171 | ||
| 172 | * Templates are a cache input | |
| 173 | ||
| 174 | Every template's source is hashed, so editing a layout re-renders the pages that use it. | |
| 175 | A 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 | ||
| 5 | org-ssg parses a defined slice of org. The boundary is not aspirational: every supported | |
| 6 | construct has a golden-file test, and every excluded one has a test asserting how it | |
| 7 | degrades. That is what stops the parser drifting toward all-of-org. | |
| 8 | ||
| 9 | * Supported | |
| 10 | ||
| 11 | ** Headings | |
| 12 | ||
| 13 | Nesting 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 | ||
| 22 | The keyword set is Emacs' default — =TODO= and =DONE= — matched on a word boundary, so a | |
| 23 | heading beginning "TODOs are great" is a plain title. Keyword and priority markup uses | |
| 24 | Emacs' own export classes. | |
| 25 | ||
| 26 | Every heading gets an =id=: its =:CUSTOM_ID:= if it has one, else its =:ID:=, else a slug | |
| 27 | of its text. | |
| 28 | ||
| 29 | ** Text and inline markup | |
| 30 | ||
| 31 | =*bold*=, =/italic/=, =_underline_=, =+strike+=, ~=verbatim=~ and =~code~=. Links in | |
| 32 | every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=, | |
| 33 | =[[file:other.org]]= — plus bare URLs in running text. | |
| 34 | ||
| 35 | Timestamps, active and inactive, with times and ranges, render as =<time>= with a | |
| 36 | machine-readable =datetime=. | |
| 37 | ||
| 38 | ** Lists | |
| 39 | ||
| 40 | Unordered, ordered and description lists, nested by indentation, with checkboxes and | |
| 41 | multi-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 | |
| 53 | inside a quote block works, because block ends match their own kind. | |
| 54 | ||
| 55 | An =html= export block passes through verbatim; every other backend is dropped, because | |
| 56 | emitting LaTeX into an HTML page is worse than emitting nothing. | |
| 57 | ||
| 58 | An unknown block type keeps its content as an example block rather than vanishing. | |
| 59 | ||
| 60 | *** Which languages highlight | |
| 61 | ||
| 62 | Highlighting uses the syntax definitions [[https://docs.rs/syntect][syntect]] bundles. | |
| 63 | A 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 | ||
| 66 | Recognised, 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 | ||
| 77 | Pipe 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 | |
| 79 | numbered, back-linked notes section. | |
| 80 | ||
| 81 | ** Images | |
| 82 | ||
| 83 | A 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 | ||
| 93 | Links to non-=.org= files are understood as asset links: neither resolved nor reported as | |
| 94 | broken. | |
| 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 | ||
| 109 | Every other =#+KEYWORD:= is available to templates as | |
| 110 | ={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches | |
| 111 | your layout. | |
| 112 | ||
| 113 | * Not supported, and what happens instead | |
| 114 | ||
| 115 | The contract is not that these work — it is that they degrade predictably and never crash | |
| 116 | a 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 | ||
| 133 | Babel is never executed, so a checked-in results block is output from someone else's | |
| 134 | Emacs session at some other time. Emitting it would put unverifiable content on the page | |
| 135 | dressed as real content. The source block renders; its stale output does not. | |
| 136 | ||
| 137 | * Diagnostics | |
| 138 | ||
| 139 | Malformed input degrades rather than failing — but not *silently*, because the worst | |
| 140 | cases are severe. An unterminated =#+BEGIN_SRC= reads the rest of the file as block | |
| 141 | content, and an unterminated drawer does the same but renders to nothing, so one missing | |
| 142 | line can delete most of a page. | |
| 143 | ||
| 144 | #+BEGIN_EXAMPLE | |
| 145 | warning: post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to | |
| 146 | the end of the file was read as block content | |
| 147 | #+END_EXAMPLE | |
| 148 | ||
| 149 | Diagnostics 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 | |
| 156 | source-block text are asserted to match exactly. | |
| 157 | ||
| 158 | The 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 | ||
| 169 | One genuine semantic difference: org treats a single blank line between a =1.= list and a | |
| 170 | following =-= list as *one* list, keeping the first item's bullet type. org-ssg starts a | |
| 171 | second list. That was kept on measurement — the pattern occurred zero times across a | |
| 172 | 179-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 | ||
| 7 | By 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 | |
| 18 | how a date-prefixed filename — useful for sorting in a file manager — becomes a clean | |
| 19 | address. | |
| 20 | ||
| 21 | Slugs are reduced to a single safe path component, so a slug cannot escape the output | |
| 22 | directory however it was written. Two pages claiming one URL is a build error rather than | |
| 23 | one silently overwriting the other. | |
| 24 | ||
| 25 | Links follow slugs automatically: =[[file:blog/2018-11-28-aes-encryption.org]]= resolves | |
| 26 | to =blog/aes-encryption.html=. | |
| 27 | ||
| 28 | * Drafts | |
| 29 | ||
| 30 | #+BEGIN_SRC org | |
| 31 | ,#+DRAFT: t | |
| 32 | #+END_SRC | |
| 33 | ||
| 34 | The page is not written at all, and is absent from listings, tag pages and navigation — | |
| 35 | not merely unlinked. | |
| 36 | ||
| 37 | It is also out of the symbol table, so a link *to* a draft is reported as a broken link. | |
| 38 | That is deliberate: it is what that link would be on the published site, and better found | |
| 39 | now than by a reader. | |
| 40 | ||
| 41 | #+BEGIN_SRC sh | |
| 42 | org-ssg serve content -o _site --drafts | |
| 43 | #+END_SRC | |
| 44 | ||
| 45 | The keyword is read forgivingly. =t=, =yes=, =1= and a bare =#+DRAFT:= all mean draft, | |
| 46 | because 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 | ||
| 58 | All 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 | ||
| 61 | A page with no parseable date sorts *last* in a dated listing, in either direction, so a | |
| 62 | draft 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 | |
| 67 | otherwise: | |
| 68 | ||
| 69 | #+BEGIN_SRC org | |
| 70 | ,#+DESCRIPTION: How the borrow checker thinks about lifetimes. | |
| 71 | #+END_SRC | |
| 72 | ||
| 73 | The fallback matters more than the keyword: it means a listing has something to show | |
| 74 | whether or not the author ever thought about summaries. Use =truncate= in the template to | |
| 75 | cut 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 | |
| 80 | only*. Source and example blocks are excluded, because a post that is mostly a shell | |
| 81 | transcript should not read as an hour's work. =#+TITLE:= is metadata rendered as chrome, | |
| 82 | so it is not counted either. | |
| 83 | ||
| 84 | * Tags | |
| 85 | ||
| 86 | #+BEGIN_SRC org | |
| 87 | ,#+FILETAGS: :rust:web: | |
| 88 | #+END_SRC | |
| 89 | ||
| 90 | Available as =page.tags=, and the input to tag pages — see | |
| 91 | [[file:03-collections.org][Collections]]. | |
| 92 | ||
| 93 | * Table of contents | |
| 94 | ||
| 95 | Every page's heading tree is available as =page.toc= without any markup in the file. Turn | |
| 96 | it off for one document the way org already does: | |
| 97 | ||
| 98 | #+BEGIN_SRC org | |
| 99 | ,#+OPTIONS: toc:nil | |
| 100 | #+END_SRC | |
| 101 | ||
| 102 | Or 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 | ||
| 107 | Off by default, unlike Emacs. Turn them on for one document: | |
| 108 | ||
| 109 | #+BEGIN_SRC org | |
| 110 | ,#+OPTIONS: num:t | |
| 111 | #+END_SRC | |
| 112 | ||
| 113 | Or site-wide with =[html] section_numbers = true=. | |
| 114 | ||
| 115 | * Your own metadata | |
| 116 | ||
| 117 | Every =#+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 | ||
| 128 | Nothing needs to be registered, and org-ssg needs no release to support a keyword you | |
| 129 | invented. | |
| 130 | ||
| 131 | * Assets | |
| 132 | ||
| 133 | Any 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 | |
| 135 | ordinary relative link, and from a template with ={{ root }}img/diagram.png=. | |
| 136 | ||
| 137 | Four 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 | ||
| 146 | Note 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 | ||
| 5 | Incremental rebuilding is not an optimisation bolted on to org-ssg; it is the constraint | |
| 6 | the data model was built around. Parsing is a pure function of one file's bytes, link | |
| 7 | resolution reports the edges it used, and rendering is a pure function of a resolved | |
| 8 | document. Those properties are what make caching sound — and they are also what make the | |
| 9 | build parallel. | |
| 10 | ||
| 11 | You do not have to configure any of this. It is described here because knowing what | |
| 12 | invalidates what explains the behaviour you will see. | |
| 13 | ||
| 14 | * What you observe | |
| 15 | ||
| 16 | #+BEGIN_EXAMPLE | |
| 17 | $ org-ssg build content -o _site | |
| 18 | built 182 page(s) (182 rendered, 0 cached) ... | |
| 19 | ||
| 20 | $ org-ssg build content -o _site | |
| 21 | built 182 page(s) (0 rendered, 182 cached) ... | |
| 22 | ||
| 23 | $ vim content/blog/post.org && org-ssg build content -o _site | |
| 24 | built 182 page(s) (4 rendered, 178 cached) ... | |
| 25 | #+END_EXAMPLE | |
| 26 | ||
| 27 | The four are the post itself, its section index, its tag page, and the tag index whose | |
| 28 | counts changed. | |
| 29 | ||
| 30 | * The render key | |
| 31 | ||
| 32 | Every 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 | ||
| 41 | If a page's key matches the cached one and its output file still exists, the file on disk | |
| 42 | is already correct and is left untouched. | |
| 43 | ||
| 44 | The cache lives in =<output>/.org-ssg-cache.json= and is tagged with a format version. A | |
| 45 | version mismatch, a missing file or a corrupt file all fall back to a full rebuild — the | |
| 46 | cache is an optimisation, never a correctness dependency. There is a test for each of | |
| 47 | those three fallbacks. | |
| 48 | ||
| 49 | * Link dependencies | |
| 50 | ||
| 51 | Resolution records which targets each page consumed, which gives the build a dependency | |
| 52 | graph. That is what makes renaming a heading work: | |
| 53 | ||
| 54 | #+BEGIN_EXAMPLE | |
| 55 | a.org: * Target Heading | |
| 56 | b.org: Jump to [[*Target Heading][there]]. | |
| 57 | #+END_EXAMPLE | |
| 58 | ||
| 59 | Rename the heading in =a.org= and *both* pages re-render — =b.org= because the URL it | |
| 60 | emits has changed. Without the graph, =b.html= would keep a link to an anchor that no | |
| 61 | longer exists. On a rebuild the graph is merged with the previous build's, so a target | |
| 62 | that was *removed* still pulls in the pages that linked to it. | |
| 63 | ||
| 64 | * Global chrome | |
| 65 | ||
| 66 | The navigation appears on every page, so a change to it must re-render every page. The | |
| 67 | site-structure hash covers exactly the pages that can appear in the nav — which is why | |
| 68 | the 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 | ||
| 73 | Turning on =[templates] expose_page_list= widens that hash to every page, because then | |
| 74 | any template can read any page's metadata. That is the documented cost of building an | |
| 75 | index by hand instead of with a collection. | |
| 76 | ||
| 77 | * Generated pages | |
| 78 | ||
| 79 | A listing page has no source file, so it is cached on the thing it actually depends on: | |
| 80 | the 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 | ||
| 86 | A tag page depends on its own posts and not on the other groups. That is why the group | |
| 87 | list is given to the tag *index* and not to every tag page: a page that could see every | |
| 88 | group would depend on every group, and one new post would re-render every tag page. | |
| 89 | ||
| 90 | * Byte equivalence | |
| 91 | ||
| 92 | A full build (=--no-cache=) and an incremental rebuild produce *byte-identical* output. | |
| 93 | This is the property everything else rests on, and it is a test rather than an intention: | |
| 94 | the suite builds a site both ways and compares every emitted file. | |
| 95 | ||
| 96 | * Parallelism | |
| 97 | ||
| 98 | Parsing, resolution and rendering run across cores. Measured on a 1,790-page corpus, a | |
| 99 | full build went from 3.98s to 0.82s on 12 cores; the 179-page reference corpus builds in | |
| 100 | 0.07s. | |
| 101 | ||
| 102 | Parallelism 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, | |
| 104 | so a build is reproducible run to run. There is a test for that ordering, because a | |
| 105 | non-deterministic report over a deterministic site would be a confusing thing to debug. | |
| 106 | ||
| 107 | * When to reach for --no-cache | |
| 108 | ||
| 109 | Almost never. Config changes, template edits and cache-format upgrades all invalidate | |
| 110 | correctly on their own. It exists to answer "is the cache lying to me?" — and if it ever | |
| 111 | is, that is a bug worth reporting, with the two builds' output to compare. | |
| 112 | ||
| 113 | #+BEGIN_SRC sh | |
| 114 | org-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 | |
| 8 | org-ssg serve content -o _site | |
| 9 | #+END_SRC | |
| 10 | ||
| 11 | Builds, watches, serves at [[http://127.0.0.1:3000][127.0.0.1:3000]], and reloads the | |
| 12 | browser when a rebuild lands. This is the command to leave running while you write. | |
| 13 | ||
| 14 | Add =--drafts= to see work in progress, =--port= to move it, and =--host 0.0.0.0= to | |
| 15 | reach it from another device. | |
| 16 | ||
| 17 | ** It binds loopback deliberately | |
| 18 | ||
| 19 | A development server serves unreviewed drafts off your laptop. Exposing that to whatever | |
| 20 | network you are on — a café, a conference, an office — should be something you ask for, | |
| 21 | so the default is =127.0.0.1= and =--host= is the way out. | |
| 22 | ||
| 23 | ** The reload script never reaches disk | |
| 24 | ||
| 25 | The script is injected into HTML *responses*, not into the built files. What you deploy | |
| 26 | is the site as built, with no development machinery in it. If you are curious, compare a | |
| 27 | served page with the file in your output directory. | |
| 28 | ||
| 29 | ** How reload works | |
| 30 | ||
| 31 | The page carries the build generation it was rendered from, and asks the server "anything | |
| 32 | newer than N?". The server holds that request open until there is, then answers — so a | |
| 33 | reload is immediate rather than polled, but the mechanism is ordinary HTTP with no | |
| 34 | WebSocket. | |
| 35 | ||
| 36 | Baking the generation into the page closes a race: if a rebuild lands between a page | |
| 37 | being served and its first request going out, the server answers at once instead of the | |
| 38 | tab sitting on stale content until your *next* edit. | |
| 39 | ||
| 40 | A reload only follows a *successful* rebuild. Reloading onto an unchanged page because | |
| 41 | the build just failed tells you nothing — the error is already on your terminal. | |
| 42 | ||
| 43 | * watch | |
| 44 | ||
| 45 | #+BEGIN_SRC sh | |
| 46 | org-ssg watch content -o _site | |
| 47 | #+END_SRC | |
| 48 | ||
| 49 | The same rebuilding without the server, for when something else already serves the output. | |
| 50 | ||
| 51 | * What counts as a change | |
| 52 | ||
| 53 | Rebuilds are driven by OS filesystem events, so nothing happens while nothing happens. | |
| 54 | The rule for what triggers one is deliberately *not* the rule the build uses to find | |
| 55 | content — 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 | |
| 58 | templates directory. The last two are skipped by the build when looking for content, but | |
| 59 | both 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 | ||
| 73 | Saving a file is rarely one event: an editor writes a temp file, renames it over the | |
| 74 | original, and touches the directory. Events are collected for 120ms of quiet before a | |
| 75 | rebuild starts, so one save is one rebuild. | |
| 76 | ||
| 77 | * Rebuild failures do not stop the session | |
| 78 | ||
| 79 | A build that fails prints the error and keeps watching. The usual cause is a half-saved | |
| 80 | file, and the next keystroke fixes it. Nothing needs restarting. | |
| 81 | ||
| 82 | #+BEGIN_EXAMPLE | |
| 83 | blog/post.org changed: build failed: parsing blog/post.org: ... | |
| 84 | blog/post.org changed: 2 rendered, 180 cached | |
| 85 | #+END_EXAMPLE | |
| 86 | ||
| 87 | * Where native watching is unavailable | |
| 88 | ||
| 89 | Some network and container filesystems have no event API. org-ssg falls back to polling | |
| 90 | every two seconds and says so, rather than failing: | |
| 91 | ||
| 92 | #+BEGIN_EXAMPLE | |
| 93 | note: 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. | |
| 109 | org-ssg serve content -o _site --drafts | |
| 110 | ||
| 111 | # Write. The browser keeps up. | |
| 112 | ||
| 113 | # Before publishing, check what a real build says. | |
| 114 | org-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 | |
| 6 | org-ssg audit ~/notes | |
| 7 | #+END_SRC | |
| 8 | ||
| 9 | The audit answers two questions about a body of org files: | |
| 10 | ||
| 11 | 1. *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. | |
| 14 | 2. *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 | |
| 20 | corpus: 179 file(s), 29258 line(s) | |
| 21 | ||
| 22 | CONSTRUCTS (by frequency) | |
| 23 | construct uses files first seen | |
| 24 | IN list item 1282 109 blog/2018-11-28-aes-encryption.org:53 | |
| 25 | IN heading 1128 174 blog/2018-11-28-aes-encryption.org:7 | |
| 26 | IN source block 932 121 blog/2018-11-28-cpp-compiler.org:17 | |
| 27 | ... | |
| 28 | OUT table formula (#+TBLFM:) 4 1 blog/2024-08-11-org-mode-features.org:191 | |
| 29 | OUT entity (\name) 3 3 blog/2024-04-06-convert-onenote.org:37 | |
| 30 | ||
| 31 | coverage: 8854 in-scope use(s) (99.9%), 8 out-of-scope (0.1%) | |
| 32 | ||
| 33 | KEYWORDS | |
| 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 | ||
| 46 | Four censuses follow the construct table: every distinct =#+KEYWORD:=, block type, | |
| 47 | drawer name and link scheme in the corpus. A =???= in any of them is worth a look. | |
| 48 | ||
| 49 | * It never prints your writing | |
| 50 | ||
| 51 | Names, counts and =file:line= locations only. That is a deliberate constraint so that an | |
| 52 | audit of private notes — work notes, a journal — is safe to paste into an issue or share | |
| 53 | with someone helping you. | |
| 54 | ||
| 55 | * Why it is a separate scanner | |
| 56 | ||
| 57 | The audit deliberately does *not* reuse the parser. Auditing with the parser could only | |
| 58 | ever find constructs the parser already knows about, which is exactly the wrong | |
| 59 | instrument for the second question: it would report a blind spot as clean. | |
| 60 | ||
| 61 | * Comparing against Emacs | |
| 62 | ||
| 63 | The second half of the same idea is a differential test suite. =cargo test --test oracle= | |
| 64 | exports each fixture with org's own HTML exporter through =emacs --batch=, reduces both | |
| 65 | outputs to a semantic skeleton, and *snapshots the disagreement*. | |
| 66 | ||
| 67 | #+BEGIN_SRC sh | |
| 68 | cargo test --test oracle | |
| 69 | #+END_SRC | |
| 70 | ||
| 71 | Snapshotting rather than asserting agreement is deliberate: a checked-in divergence | |
| 72 | report gets reviewed and shows up in code review, where a permanently red test gets | |
| 73 | ignored. Three invariants /are/ asserted outright — heading structure, list nesting and | |
| 74 | source-block text — and all three hold. | |
| 75 | ||
| 76 | The suite skips cleanly with no Emacs installed, so a machine without it still gets a | |
| 77 | green 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? | |
| 83 | org-ssg audit ~/notes | |
| 84 | ||
| 85 | # Build it and see what the builder itself complains about. | |
| 86 | org-ssg build ~/notes -o /tmp/preview --strict | |
| 87 | ||
| 88 | # Look at the result. | |
| 89 | org-ssg serve ~/notes -o /tmp/preview | |
| 90 | #+END_SRC | |
| 91 | ||
| 92 | =--strict= surfaces broken internal links and malformed constructs as failures rather | |
| 93 | than warnings, which is the fastest way to find the handful of files that need attention | |
| 94 | before 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 | |
| 8 | org-ssg build content -o _site --strict | |
| 9 | #+END_SRC | |
| 10 | ||
| 11 | Two 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 | ||
| 17 | Everything in =_site= is the site: HTML, the generated =syntax.css=, and every asset | |
| 18 | copied from the source. There is no runtime, no server requirement and no build step | |
| 19 | downstream. | |
| 20 | ||
| 21 | * Set base_url for production | |
| 22 | ||
| 23 | #+BEGIN_SRC toml | |
| 24 | [site] | |
| 25 | base_url = "https://example.com" | |
| 26 | #+END_SRC | |
| 27 | ||
| 28 | Relative URLs work anywhere, which is why the default is empty — but two things need | |
| 29 | absolute 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 | |
| 31 | quietly relative link, so a feed template will tell you. | |
| 32 | ||
| 33 | No trailing slash. | |
| 34 | ||
| 35 | * One thing to exclude | |
| 36 | ||
| 37 | The build writes =.org-ssg-cache.json= into the output directory. It is a dot-file, so | |
| 38 | most static hosts ignore it, but it is not part of the site — exclude it if your host | |
| 39 | uploads everything: | |
| 40 | ||
| 41 | #+BEGIN_SRC sh | |
| 42 | rsync -a --delete --exclude '.org-ssg-cache.json' _site/ user@host:/var/www/site/ | |
| 43 | #+END_SRC | |
| 44 | ||
| 45 | Keeping the cache *between* deploys, where the CI runner can see it, is what makes CI | |
| 46 | builds incremental. Keeping it on the *server* achieves nothing. | |
| 47 | ||
| 48 | * Continuous integration | |
| 49 | ||
| 50 | #+BEGIN_SRC yaml | |
| 51 | name: build | |
| 52 | on: [push] | |
| 53 | jobs: | |
| 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 | |
| 68 | failed build. | |
| 69 | ||
| 70 | ** Caching between runs | |
| 71 | ||
| 72 | Cache =_site/.org-ssg-cache.json= *and* =_site= together, or not at all. The manifest | |
| 73 | describes files it expects to find; a cache without its outputs simply triggers a full | |
| 74 | rebuild, which is correct but pointless. | |
| 75 | ||
| 76 | Given how fast a full build is — a 179-page site in well under a second — caching CI | |
| 77 | builds is rarely worth the configuration. | |
| 78 | ||
| 79 | * Static hosts | |
| 80 | ||
| 81 | Nothing 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 | ||
| 90 | org-ssg writes =blog/post.html= and links to it that way, so the site works with no | |
| 91 | server configuration at all — including opening it from a filesystem path. | |
| 92 | ||
| 93 | If 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 | |
| 99 | org-ssg build content -o _site --strict | |
| 100 | org-ssg serve content -o _site | |
| 101 | #+END_SRC | |
| 102 | ||
| 103 | Serving the production build locally is the last check worth doing: it catches a missing | |
| 104 | asset or a broken relative link in the browser, where you would notice. | |
| 105 | ||
| 106 | * What a clean build looks like | |
| 107 | ||
| 108 | #+BEGIN_EXAMPLE | |
| 109 | built 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 | ||
| 112 | Both zeros matter. Unresolved links are internal links pointing at nothing; diagnostics | |
| 113 | are malformed org that degraded rather than failing. With =--strict= neither can reach | |
| 114 | this 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 | ||
| 6 | org-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 | |
| 8 | org-specific semantics — /is/ the document model, and that tree is rendered straight to | |
| 9 | HTML. There is no markdown-shaped intermediate representation, because the point is to | |
| 10 | preserve what markdown cannot express. | |
| 11 | ||
| 12 | #+BEGIN_SRC sh | |
| 13 | cargo run -- init my-site | |
| 14 | cargo run -- serve my-site -o _site | |
| 15 | #+END_SRC | |
| 16 | ||
| 17 | Open [[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 | ||
| 27 | Point it at a directory of org files and you get a complete site: pages, navigation, | |
| 28 | syntax-highlighted code, and the stylesheet that colours it. Nothing about your files has | |
| 29 | to change, and no =org-ssg.toml= is required. | |
| 30 | ||
| 31 | #+BEGIN_SRC sh | |
| 32 | org-ssg build ~/notes -o _site | |
| 33 | #+END_SRC | |
| 34 | ||
| 35 | Configuration 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 | ||
| 41 | Every page has a render key composed from its content, its resolved links, the site | |
| 42 | config and the templates. Editing one post re-renders that post, its section index, its | |
| 43 | tag pages, and the tag index whose counts changed — four pages, whatever the size of the | |
| 44 | site. A full build and an incremental build produce byte-identical output, and a test | |
| 45 | proves 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 | |
| 51 | source-block text match exactly. Everything that still differs is a deliberate choice, | |
| 52 | listed in [[file:guide/05-org-support.org][Org support]]. | |
| 53 | ||
| 54 | ** It tells you what your corpus actually uses | |
| 55 | ||
| 56 | #+BEGIN_SRC sh | |
| 57 | org-ssg audit ~/notes | |
| 58 | #+END_SRC | |
| 59 | ||
| 60 | The audit reports which org constructs appear in a corpus, how often, and whether each is | |
| 61 | supported — so you can find out before you trust a tool with your writing. It reports | |
| 62 | names, counts and =file:line= locations only, never document text, so auditing private | |
| 63 | notes 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 | ||
| 78 | This documentation site is itself an org-ssg site — the sources are in =docs/= and it is | |
| 79 | built with the command in [[file:quickstart.org][Quick start]]. If a feature is described here, it is being used | |
| 80 | to 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 | |
| 16 | git clone <repository-url> org-ssg | |
| 17 | cd org-ssg | |
| 18 | cargo build --release | |
| 19 | #+END_SRC | |
| 20 | ||
| 21 | The binary lands at =target/release/org-ssg=. Copy it somewhere on your =PATH=: | |
| 22 | ||
| 23 | #+BEGIN_SRC sh | |
| 24 | cp target/release/org-ssg ~/.local/bin/ | |
| 25 | #+END_SRC | |
| 26 | ||
| 27 | Or let cargo do it, which puts it in =~/.cargo/bin=: | |
| 28 | ||
| 29 | #+BEGIN_SRC sh | |
| 30 | cargo install --path . | |
| 31 | #+END_SRC | |
| 32 | ||
| 33 | * Running without installing | |
| 34 | ||
| 35 | Every command in this documentation works through cargo if you would rather not install | |
| 36 | anything. Replace =org-ssg= with =cargo run --= and add =--release= for a fast build: | |
| 37 | ||
| 38 | #+BEGIN_SRC sh | |
| 39 | cargo run --release -- build my-site -o _site | |
| 40 | #+END_SRC | |
| 41 | ||
| 42 | The debug build is fine for small sites and noticeably slower on large ones, because | |
| 43 | syntax highlighting dominates and is not optimised in a debug profile. | |
| 44 | ||
| 45 | * Check that it works | |
| 46 | ||
| 47 | #+BEGIN_SRC sh | |
| 48 | org-ssg --version | |
| 49 | org-ssg init /tmp/org-ssg-check | |
| 50 | org-ssg build /tmp/org-ssg-check -o /tmp/org-ssg-check/_site | |
| 51 | #+END_SRC | |
| 52 | ||
| 53 | You should see a line reporting the pages built: | |
| 54 | ||
| 55 | #+BEGIN_EXAMPLE | |
| 56 | built 5 page(s) (5 rendered, 0 cached), copied 0 asset(s) ... (0 unresolved link(s), 0 diagnostic(s)) | |
| 57 | #+END_EXAMPLE | |
| 58 | ||
| 59 | Open =/tmp/org-ssg-check/_site/index.html= in a browser, or serve it properly: | |
| 60 | ||
| 61 | #+BEGIN_SRC sh | |
| 62 | org-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 | |
| 68 | cargo test | |
| 69 | #+END_SRC | |
| 70 | ||
| 71 | 152 tests, covering the parser, the renderer, configuration, generated pages, the | |
| 72 | incremental cache, the watcher and the development server. | |
| 73 | ||
| 74 | The oracle suite is part of that run and compares output against Emacs: | |
| 75 | ||
| 76 | #+BEGIN_SRC sh | |
| 77 | cargo test --test oracle | |
| 78 | #+END_SRC | |
| 79 | ||
| 80 | It *skips cleanly* when there is no =emacs= on your =PATH=, so a machine without Emacs | |
| 81 | still gets a green test run — it simply measures one thing less. | |
| 82 | ||
| 83 | * Upgrading | |
| 84 | ||
| 85 | org-ssg stores an incremental cache in =<output>/.org-ssg-cache.json=, tagged with a | |
| 86 | format version. A newer binary that changes how output is produced bumps that version, | |
| 87 | and a version it does not recognise is discarded in favour of a full rebuild. You never | |
| 88 | need to clear the cache by hand after an upgrade — but if you want to: | |
| 89 | ||
| 90 | #+BEGIN_SRC sh | |
| 91 | org-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] | |
| 7 | title = "org-ssg" | |
| 8 | description = "An org-mode static site generator, in Rust." | |
| 9 | language = "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. | |
| 12 | base_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. | |
| 18 | mode = "explicit" | |
| 19 | pages = ["install.org", "quickstart.org"] | |
| 20 | ||
| 21 | [templates] | |
| 22 | dir = "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. | |
| 28 | theme = "base16-ocean.dark" | |
| 29 | ||
| 30 | [html] | |
| 31 | # The layout renders the page title as <h1>, so document headings start at <h2>. | |
| 32 | heading_offset = 1 | |
| 33 | toc = true | |
| 34 | section_numbers = false | |
| 35 | ||
| 36 | # The guide's contents page, listing every chapter in reading order rather than by date. | |
| 37 | [[collections]] | |
| 38 | source = "guide" | |
| 39 | output = "guide/index.html" | |
| 40 | template = "list.html" | |
| 41 | title = "Guide" | |
| 42 | sort = "path" | |
| 43 | order = "asc" | |
| 44 | nav = 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 | |
| 8 | org-ssg init my-site | |
| 9 | org-ssg serve my-site -o _site | |
| 10 | #+END_SRC | |
| 11 | ||
| 12 | 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 | |
| 13 | on its own. | |
| 14 | ||
| 15 | =init= writes only files that do not already exist, so running it inside a directory that | |
| 16 | already has content is safe and additive. | |
| 17 | ||
| 18 | ** What init created | |
| 19 | ||
| 20 | #+BEGIN_EXAMPLE | |
| 21 | my-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 | ||
| 34 | You do not need =init=, a config file, or templates. Point the builder at org files you | |
| 35 | already have: | |
| 36 | ||
| 37 | #+BEGIN_SRC sh | |
| 38 | org-ssg build ~/notes -o _site | |
| 39 | #+END_SRC | |
| 40 | ||
| 41 | You get a complete site with a built-in layout, navigation across your top-level pages, | |
| 42 | and syntax highlighting. Nothing in your files has to change. | |
| 43 | ||
| 44 | Before trusting it with a large collection, ask what it makes of your writing: | |
| 45 | ||
| 46 | #+BEGIN_SRC sh | |
| 47 | org-ssg audit ~/notes | |
| 48 | #+END_SRC | |
| 49 | ||
| 50 | That 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 | ||
| 55 | Any =.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 | ||
| 64 | An opening paragraph, which becomes the excerpt in listings when there is no | |
| 65 | description. | |
| 66 | ||
| 67 | ,* A heading | |
| 68 | ||
| 69 | Ordinary org: *bold*, /italic/, ~code~, [[https://orgmode.org][links]], and lists. | |
| 70 | ||
| 71 | ,#+BEGIN_SRC rust | |
| 72 | fn main() {} | |
| 73 | ,#+END_SRC | |
| 74 | #+END_SRC | |
| 75 | ||
| 76 | The keywords are all optional. =#+TITLE:= names the page, =#+DATE:= orders it in | |
| 77 | listings, =#+FILETAGS:= groups it on tag pages, and =#+DESCRIPTION:= is its summary. | |
| 78 | ||
| 79 | ** Control the URL | |
| 80 | ||
| 81 | By default the filename decides the URL. =#+SLUG:= overrides it, which is how a | |
| 82 | date-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 | ||
| 97 | The page is not written, and does not appear in listings or navigation. Preview it while | |
| 98 | you work with =--drafts=: | |
| 99 | ||
| 100 | #+BEGIN_SRC sh | |
| 101 | org-ssg serve my-site -o _site --drafts | |
| 102 | #+END_SRC | |
| 103 | ||
| 104 | * Change the design | |
| 105 | ||
| 106 | Everything visual lives in =templates/base.html=. It is an ordinary | |
| 107 | [[https://docs.rs/minijinja][minijinja]] (Jinja2) template, and replacing it replaces the | |
| 108 | whole 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 | |
| 127 | depth. Any other file in the source directory — =style.css=, images, fonts — is copied to | |
| 128 | the output untouched. | |
| 129 | ||
| 130 | Editing a template rebuilds every page that uses it, so the browser reloads while you are | |
| 131 | still looking at it. The full list of variables is in [[file:guide/04-templates.org][Templates]]. | |
| 132 | ||
| 133 | * Add a blog index | |
| 134 | ||
| 135 | Listing pages have no source file; they are declared in =org-ssg.toml=: | |
| 136 | ||
| 137 | #+BEGIN_SRC toml | |
| 138 | [[collections]] | |
| 139 | source = "blog" | |
| 140 | output = "blog/index.html" | |
| 141 | template = "list.html" | |
| 142 | title = "Blog" | |
| 143 | sort = "date" | |
| 144 | order = "desc" | |
| 145 | nav = true | |
| 146 | #+END_SRC | |
| 147 | ||
| 148 | That is also how you get tag pages, pagination and an RSS feed — same mechanism, more | |
| 149 | settings. See [[file:guide/03-collections.org][Collections]]. | |
| 150 | ||
| 151 | * Build for real | |
| 152 | ||
| 153 | #+BEGIN_SRC sh | |
| 154 | org-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 | |
| 158 | is 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 | ||
| 28 | body { | |
| 29 | margin: 0; | |
| 30 | color: var(--ink); | |
| 31 | font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| 32 | } | |
| 33 | ||
| 34 | header.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 | ||
| 43 | header.site .site-title { | |
| 44 | font-weight: 700; | |
| 45 | font-size: 1.05rem; | |
| 46 | color: var(--ink); | |
| 47 | text-decoration: none; | |
| 48 | } | |
| 49 | ||
| 50 | header.site nav { display: flex; gap: 1.25rem; flex-wrap: wrap; } | |
| 51 | header.site nav a { color: var(--muted); text-decoration: none; } | |
| 52 | header.site nav a:hover { color: var(--accent); } | |
| 53 | ||
| 54 | main { | |
| 55 | max-width: var(--measure); | |
| 56 | margin: 0 auto; | |
| 57 | padding: 2.5rem 1.5rem 5rem; | |
| 58 | } | |
| 59 | ||
| 60 | h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; letter-spacing: -0.02em; } | |
| 61 | h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; letter-spacing: -0.01em; } | |
| 62 | h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; } | |
| 63 | ||
| 64 | p.page-date, p.lede { color: var(--muted); } | |
| 65 | p.lede { font-size: 1.1rem; margin-top: 0; } | |
| 66 | ||
| 67 | a { color: var(--accent); } | |
| 68 | ||
| 69 | code { | |
| 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. */ | |
| 80 | pre { | |
| 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 | ||
| 89 | pre code { background: none; padding: 0; } | |
| 90 | ||
| 91 | blockquote { | |
| 92 | margin: 1.5rem 0; | |
| 93 | padding: .25rem 0 .25rem 1rem; | |
| 94 | border-left: 3px solid var(--rule); | |
| 95 | color: var(--muted); | |
| 96 | } | |
| 97 | ||
| 98 | table { border-collapse: collapse; width: 100%; margin: 1.25rem 0; display: block; overflow-x: auto; } | |
| 99 | th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--rule); } | |
| 100 | th { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } | |
| 101 | ||
| 102 | hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; } | |
| 103 | ||
| 104 | /* Table of contents, emitted from page.toc */ | |
| 105 | nav.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 | } | |
| 112 | nav.toc h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; margin: .25rem 0 .5rem; color: var(--muted); } | |
| 113 | nav.toc ul { margin: 0; padding-left: 1.1rem; } | |
| 114 | nav.toc li { margin: .15rem 0; } | |
| 115 | ||
| 116 | ul.post-list { list-style: none; padding: 0; } | |
| 117 | ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--rule); } | |
| 118 | ul.post-list a { font-weight: 600; font-size: 1.05rem; } | |
| 119 | p.excerpt { margin: .35rem 0 .2rem; color: var(--muted); } | |
| 120 | span.reading-time { font-size: .85rem; color: var(--muted); } | |
| 121 | ||
| 122 | footer.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 }} · {{ 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"> | |
| 48 | Built with org-ssg — 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 %} | |