Commit 903ac6c8d6
Verified · cmc
Layout: unified · split
.gitignore +1
| @@ -1 +1,2 @@ | |||
| 1 | /target | 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 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real | 13 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real |
| 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. | 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. |
| 15 | 15 | ||
| 16 | **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 | ## Quick start | 23 | ## Quick start |
| 17 | 24 | ||
| 18 | ```bash | 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 %} | ||