Commit a6ee2e0ca7

a6ee2e0ca79b83b7f6bd4a521777cffdaf2260f2

parent: 0b3783a396

Unsigned

cmc <hello@cleberg.net> · 2026-08-12 01:34 UTC

Finish the move off weblorg

Remove what the migration left behind, then cut build.py down to the work
orgo does not do: 443 lines to 168.

- Delete publish.el and theme/templates/, superseded by content/orgo.toml
  and content/templates/. theme/static/ stays; orgo publishes it as assets.
- Drop the tag builder, the org-source copy, and the helpers that only fed
  them. orgo generates /tags/ and a page per tag from a collection.
- Rewrite the README and the migration-era comments that explained the
  config by pointing at what weblorg used to do.
- Serve styles.css as authored. Minifying 1KB of CSS saved 52 bytes over
  the wire once compressed, which did not pay for a build step and a
  toolchain dependency.
- Stop wiping the output directory. The .orgo-cache.json inside it is what
  makes a build incremental, and orgo already prunes outputs whose source
  is gone and re-emits any that are missing. Production keeps .build/ and
  development gets .build-dev/, because production rewrites image URLs in
  the output and a shared directory would let one environment reuse pages
  rendered for the other.

An incremental build is byte-identical to a clean one, and the site output
is unchanged apart from the /uses/ stack row.

Layout: unified · split

.build.yml.bkp deleted −44
@@ -1,44 +0,0 @@
1image: ubuntu/lts
2
3packages:
4 - emacs-nox
5 - rsync
6 - openssh-client
7 - git
8 - curl
9 - tar
10
11sources:
12 - https://git.sr.ht/~ccleberg/cleberg.net
13
14secrets:
15 - 303fbd30-2970-4d18-b88b-0f6abf822ea5
16 - e8b41b71-c4b2-4bd6-8159-25ae1eb7d282
17
18environment:
19 ENV: prod
20 BUILD: "true"
21
22tasks:
23 - setup: |
24 curl -LsSf https://astral.sh/uv/install.sh | sh
25 mkdir -p ~/.local/bin
26 curl -sSfL https://github.com/tdewolff/minify/releases/latest/download/minify_linux_amd64.tar.gz \
27 | tar -xz -C ~/.local/bin minify
28 mkdir -p ~/.config/emacs/.local/straight/repos
29 git clone --depth 1 https://github.com/emacsorphanage/htmlize \
30 ~/.config/emacs/.local/straight/repos/htmlize
31 git clone --depth 1 https://github.com/emacs-love/weblorg \
32 ~/.config/emacs/.local/straight/repos/weblorg
33 git clone --depth 1 https://github.com/emacs-love/templatel \
34 ~/.config/emacs/.local/straight/repos/templatel
35 - build: |
36 source ~/.local/bin/env
37 cd cleberg.net
38 uv run python3 -u build.py
39 - deploy: |
40 set -euo pipefail
41 cd cleberg.net
42 chmod 600 ~/.ssh/id_rsa
43 chmod 600 ~/.ssh/config
44 rsync -r --delete-before .build/ homelab:/var/www/cleberg.net/
.gitignore +1 −1
@@ -1,4 +1,5 @@
11.build/
2.build-dev/
23.DS_Store
34.gitlab-ci-local/
45.ruff_cache/
@@ -6,5 +7,4 @@
67public/
78readme.html
89venv/
9theme/static/styles.min.css
1010__pycache__/
README.md +55 −45
@@ -3,23 +3,26 @@
33This repository holds the files for [cleberg.net](https://cleberg.net),
44a static-site with blog posts, personal links, and more.
55
6This site uses [weblorg](https://github.com/emacs-love/weblorg) to build
7the static site, which relies on
8[emacs](https://www.gnu.org/software/emacs/).
6This site uses [orgo](https://github.com/krazywarez/orgo) to build the
7static site.
98
109## Site Structure
1110
1211I write content pages (e.g., blog posts) in org-mode and templates in
13HTML. I use Weblorg to build these files into a static site, and then
14deploy them to a web server.
12HTML. orgo builds these files into a static site, and then I deploy
13them to a web server.
1514
1615The main site components are:
1716
18- Org source files containing content, including blog posts and pages.
19- A configuration file (`publish.el`) that specifies publishing
20 parameters like base URL, output directories, and export options.
17- Org source files in `content/`, containing blog posts and pages.
18 That directory is the site's URL root: `content/blog/post.org`
19 publishes at `/blog/post.html`.
20- A configuration file (`content/orgo.toml`) that specifies the base
21 URL, navigation, templates, and the generated pages (home, blog and
22 garden indexes, tags, and the RSS feed).
23- HTML templates in `content/templates/`.
2124- Assets such as images and style sheets, located in designated
22 subdirectories.
25 subdirectories. `theme/static/` is published at `/`.
2326- Utility scripts (e.g., `build.py`) to facilitate building and
2427 deployment.
2528
@@ -27,24 +30,25 @@ The main site components are:
2730
2831The publishing system depends on:
2932
30- Emacs text editor with Org-Mode.
31- The weblorg package, available at
32 [emacs-love/weblorg](https://github.com/emacs-love/weblorg), which
33 provides advanced Org publishing functionality and theming support.
33- [orgo](https://github.com/krazywarez/orgo), the static site
34 generator, installed with `cargo install --git
35 https://github.com/krazywarez/orgo`.
36- `rsync` for deployment.
37- [uv](https://github.com/astral-sh/uv) to run `build.py`.
3438
3539## Configuration
3640
37You can customize site settings can within the `publish.el` file. This
38file establishes key variables such as:
41You can customize site settings within the `content/orgo.toml` file.
42This file establishes key variables such as:
3943
4044- The base URL for links.
41- Output directories.
42- Publishing rules to define which files to convert and how.
43- Theme settings managed by weblorg.
45- Which pages appear in the navigation, and in what order.
46- Per-directory template rules.
47- The collections that generate the indexes, tag pages, and feed.
4448
4549Users intending to modify site parameters should review and edit this
46file accordingly. The weblorg documentation contains extensive details
47on configuration options and expected formats.
50file accordingly. The orgo documentation contains extensive details on
51configuration options and expected formats.
4852
4953## Setup Instructions
5054
@@ -62,48 +66,54 @@ For users employing Doom Emacs, open any repository Org file using
6266
6367## Building and Publishing the Site
6468
65The publishing process involves invoking Emacs with the `publish.el`
66script, which performs the export of Org documents to HTML output.
69The `build.py` script wraps the build: it runs orgo, and then either
70deploys the result or serves it locally.
6771
68Configure the environment variable `ENV` as follows:
72Two environment variables control what it does, and both default to
73off:
6974
70- If you set `ENV` to `prod`, the script uses production base URL
71 settings as defined in `publish.el`.
72- If you do not set `ENV` or set it differently, the script defaults to
73 development settings, typically using `localhost:8000` as the base
74 URL.
75- `BUILD=true` performs the build.
76- `DEPLOY=true` deploys in production, or starts a development server
77 on port 8000 otherwise.
7578
76Example commands to build the site:
79A third, `ENV`, selects the mode. Setting `ENV=prod` rewrites image
80URLs to be root-relative (see Deployment below) and skips the `ruff`
81pass; anything else builds for development.
7782
7883``` shell
7984# Production build:
80ENV=prod emacs --script publish.el
85ENV=prod BUILD=true uv run build.py
8186
8287# Development build:
83emacs --script publish.el
88BUILD=true uv run build.py
89
90# Development build, then serve it on localhost:8000:
91BUILD=true DEPLOY=true uv run build.py
8492```
8593
86Generated site files reside in the designated output directory, ready
87for deployment. You can deploy the resulting static site files via
88standard file transfer protocols such as `scp` or SFTP.
94Generated site files reside in `.build/` for production and
95`.build-dev/` for development, ready for deployment. You can deploy the
96resulting static site files via standard file transfer protocols such as
97`scp` or SFTP.
8998
90The `build.py` script automates the build process. You can execute this
91script with or without the `ENV` variable to perform production or
92development builds respectively.
99Builds are incremental: orgo keeps an `.orgo-cache.json` inside the
100output directory and re-renders only what changed, so the directory is
101left in place between runs. Delete it for a clean build. The two
102environments use separate directories because production rewrites image
103URLs in the output and development does not.
93104
94``` shell
95# Production build script:
96ENV=prod uv run build.py
105orgo can also build and preview on its own, without `build.py`:
97106
98# Development build script:
99uv run build.py
107``` shell
108orgo serve content -o /tmp/preview
100109```
101110
102111## Deployment
103112
104Production builds emit root-relative URLs (`/styles.min.css`,
105`/img/blog/...`) instead of absolute `https://` ones so the site renders
106standalone on the onion service without fetching assets off-onion.
113Production builds rewrite image URLs to root-relative ones
114(`/img/blog/...`) instead of absolute `https://img.cleberg.net/` ones so
115the site renders standalone on the onion service without fetching assets
116off-onion. The stylesheet is already same-origin.
107117
108118This means the web server must serve the image store at `/img/` on the
109119`cleberg.net` vhost. The images live at `/var/www/img/` (their own host,
build.py +55 −330
@@ -1,23 +1,23 @@
11#!/usr/bin/env python3
22"""
3This script automates the process of building, testing, and deploying the website.
3This script automates the process of building and deploying the website.
44It handles tasks such as:
55
6- Removing and recreating the build directory.
7- Minifying CSS assets.
8- Running the Emacs publishing script to generate site content.
9- Updating the index.html file with the latest blog posts.
6- Running orgo to generate site content.
7- Rewriting image URLs for the onion service (production only).
108- Optionally deploying the built site to a remote server.
119- Starting a local development server for previewing changes.
1210
1311Usage:
14 Set the environment variable ENV to 'prod' for production builds.
15 Run the script to perform the build process accordingly.
12 Set BUILD=true to build, DEPLOY=true to deploy or serve.
13 Set ENV=prod for production builds; anything else builds for development.
14
15 Builds are incremental. Production writes .build/ and development .build-dev/;
16 delete either one for a clean build.
1617
1718Dependencies:
1819 - Python 3
19 - Emacs with the publish.el script
20 - minify tool for CSS minification
20 - orgo, the static site generator (reads content/orgo.toml)
2121 - rsync for deployment
2222
2323Author:
@@ -26,202 +26,24 @@ Author:
2626
2727import os
2828import re
29import shutil
3029import subprocess
3130import sys
32from datetime import datetime
33from html import escape
3431from pathlib import Path
3532
36SITE_TEMPLATE_VARS = {
37 "site_name": "cleberg.net",
38 "site_owner": "Christian Cleberg <hello@cleberg.net>",
39 "site_description": "Stillness amidst the chaos.",
40}
33
34def run(cmd, error):
35 """Run cmd quietly, exiting with its stderr if it fails."""
36 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
37 if result.returncode != 0:
38 print(error, file=sys.stderr)
39 print(result.stderr, file=sys.stderr)
40 sys.exit(1)
4141
4242
4343def run_ruff():
4444 print("Running ruff...")
4545 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
46 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
47 if result.returncode != 0:
48 print(f"ruff error ({' '.join(cmd)}):")
49 print(result.stderr, file=sys.stderr)
50 sys.exit(1)
51
52
53def render_base_template(main_html, subtitle="", title=None):
54 """
55 Render a small subset of the site's shared templates for Python-generated
56 pages that still need to follow the common chrome.
57 """
58 base_template = Path("theme/templates/base.html").read_text(encoding="utf-8")
59
60 if title is None:
61 title = SITE_TEMPLATE_VARS["site_name"]
62
63 rendered = base_template
64 rendered = rendered.replace(
65 "{% block subtitle %}{% endblock %}",
66 escape(subtitle),
67 )
68 rendered = rendered.replace(
69 '{% block title %}{{ site_name | default("cleberg.net") }}{% endblock %}',
70 escape(title),
71 )
72 rendered = rendered.replace(
73 '{% if site_owner is defined %}<meta name="author" content="{{ site_owner }}">{% endif %}',
74 f'<meta name="author" content="{escape(SITE_TEMPLATE_VARS["site_owner"])}">',
75 )
76 rendered = rendered.replace(
77 '{% if site_description is defined %}<meta name="description" content="{{ site_description }}">{% endif %}',
78 f'<meta name="description" content="{escape(SITE_TEMPLATE_VARS["site_description"])}">',
79 )
80 rendered = rendered.replace(
81 '{% if site_keywords is defined %}<meta name="keywords" content="{{ site_keywords }}">{% endif %}',
82 "",
83 )
84 rendered = rendered.replace("{% block meta %}{% endblock %}", "")
85 rendered = rendered.replace("{% block head %}", "")
86 rendered = rendered.replace("{% endblock %}", "", 1)
87 rendered = rendered.replace("{% block main %}{% endblock %}", main_html)
88
89 return rendered
90
91
92def render_tags_page_html(tags_html):
93 """
94 Render the tags page by using the shared tags/base templates instead of a
95 hand-authored standalone HTML document.
96 """
97 tags_template = Path("theme/templates/tags.html").read_text(encoding="utf-8")
98
99 main_html = tags_template
100 main_html = main_html.replace('{% extends "base.html" %}', "")
101 main_html = main_html.replace(
102 "{% block subtitle %}tags - {% endblock %}",
103 "",
104 )
105 main_html = main_html.replace("{% block main %}", "")
106 main_html = main_html.replace("{% endblock %}", "")
107 main_html = main_html.replace("<!-- BEGIN_TAGS -->\n<!-- END_TAGS -->", tags_html)
108
109 return render_base_template(main_html.strip(), subtitle="tags - ")
110
111
112def get_blog_posts(content_dir="./content/blog"):
113 """
114 Scan blog posts and return normalized metadata for non-draft entries.
115 """
116 posts = []
117
118 header_patterns = {
119 "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE),
120 "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"),
121 "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE),
122 "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE),
123 "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE),
124 }
125
126 for org_path in Path(content_dir).glob("*.org"):
127 title = None
128 date_str = None
129 slug = None
130 tags = []
131 is_draft = False
132
133 with org_path.open("r", encoding="utf-8") as f:
134 for line in f:
135 if title is None:
136 m = header_patterns["title"].match(line)
137 if m:
138 title = m.group(1).strip()
139 continue
140
141 if date_str is None:
142 m = header_patterns["date"].match(line)
143 if m:
144 # date_str is just YYYY-MM-DD
145 date_str = m.group(1)
146 continue
147
148 if slug is None:
149 m = header_patterns["slug"].match(line)
150 if m:
151 slug = m.group(1).strip()
152 continue
153
154 if not tags:
155 m = header_patterns["tags"].match(line)
156 if m:
157 raw = m.group(1).strip().strip(":")
158 tags = [t.strip() for t in raw.split(":") if t.strip()]
159 continue
160
161 m = header_patterns["draft"].match(line)
162 if m:
163 draft_value = m.group(1).strip().lower()
164 if draft_value != "nil":
165 is_draft = True
166 break
167 continue
168
169 # Stop scanning once we have all required fields
170 if title and date_str and slug:
171 break
172
173 if is_draft:
174 continue
175
176 if title and date_str and slug:
177 try:
178 date_obj = datetime.strptime(date_str, "%Y-%m-%d")
179 date_full = date_obj.strftime("%Y-%m-%d")
180 except ValueError:
181 # Skip files with invalid date format
182 continue
183
184 posts.append(
185 {
186 "title": title,
187 "date_str": date_str,
188 "date_obj": date_obj,
189 "date_full": date_full,
190 "slug": slug,
191 "tags": tags,
192 }
193 )
194
195 posts.sort(key=lambda x: x["date_obj"], reverse=True)
196 return posts
197
198
199def prompt(prompt_text):
200 try:
201 return input(prompt_text).strip()
202 except EOFError:
203 return ""
204
205
206def remove_build_directory(build_dir):
207 if build_dir.exists():
208 print(f"Removing previous build directory: {build_dir}/")
209 shutil.rmtree(build_dir)
210 build_dir.mkdir(parents=True, exist_ok=True)
211
212
213def minify_css(src_css, dest_css):
214 print(f"Minifying CSS: {src_css} → {dest_css}")
215 result = subprocess.run(
216 ["minify", "-o", str(dest_css), str(src_css)],
217 capture_output=True,
218 text=True,
219 check=False,
220 )
221 if result.returncode != 0:
222 print("Error during CSS minification:")
223 print(result.stderr, file=sys.stderr)
224 sys.exit(1)
46 run(cmd, f"ruff error ({' '.join(cmd)}):")
22547
22648
22749def rewrite_img_urls(build_dir=".build"):
@@ -249,22 +71,26 @@ def rewrite_img_urls(build_dir=".build"):
24971 print(f"Rewrote {count} img.cleberg.net references to /img/")
25072
25173
252def run_orgo_build(dev_mode=True):
74def run_orgo_build(build_dir):
25375 """
25476 Build the site with orgo.
25577
256 Replaces the weblorg/Emacs publish. orgo reads content/orgo.toml, so the routes,
257 templates and collections that used to live in publish.el live there now. Four of the
258 steps this script used to perform afterwards are gone with it: the blog index groups
259 itself by year, the tags page is a collection, the recent-posts list is generated from
260 the blog collection, and the sitemap is written by orgo once base_url is set.
78 orgo reads content/orgo.toml, which holds the routes, templates and collections: the
79 blog index groups itself by year, the tags page is a collection, the recent-posts
80 list comes from the blog collection, and the sitemap is written once base_url is set.
81
82 The output directory is left in place between builds rather than wiped, because the
83 .orgo-cache.json inside it is what makes a build incremental: orgo re-renders only
84 the pages whose content, config or templates changed, re-emits any page whose output
85 is missing, and deletes the outputs of pages that have since been removed. Wiping the
86 directory would throw that away and force a full render every time. For a clean build
87 from scratch, delete the output directory by hand.
26188
262 What is left around it is what orgo does not do: minifying CSS, copying the org
263 sources for readers who want them, and rewriting image URLs for the onion.
89 What is left around it is what orgo does not do: rewriting image URLs for the onion.
26490 """
26591 print("Building with orgo...")
26692 result = subprocess.run(
267 ["orgo", "build", "content", "-o", ".build", "--strict"],
93 ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
26894 stdout=subprocess.PIPE,
26995 stderr=subprocess.STDOUT,
27096 text=True,
@@ -276,99 +102,10 @@ def run_orgo_build(dev_mode=True):
276102 sys.exit(1)
277103
278104
279def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
280 print(f"Copying org sources: {content_dir} → {build_dir}")
281 if os.path.exists(build_dir):
282 shutil.rmtree(build_dir)
283
284 slug_pattern = re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE)
285
286 for src_path in Path(content_dir).rglob("*.org"):
287 rel_dir = src_path.parent.relative_to(content_dir)
288 dest_dir = Path(build_dir) / rel_dir
289 dest_dir.mkdir(parents=True, exist_ok=True)
290
291 # Try to extract slug from file headers
292 slug = None
293 with src_path.open("r", encoding="utf-8") as f:
294 for line in f:
295 m = slug_pattern.match(line)
296 if m:
297 slug = m.group(1).strip()
298 break
299
300 dest_name = f"{slug}.org" if slug else src_path.name
301 shutil.copy2(src_path, dest_dir / dest_name)
302 if slug:
303 print(f" {src_path.name} → {dest_name}")
304
305
306def get_tags_html(content_dir="./content/blog"):
307 """
308 Build the tag index HTML snippet for the rendered tags template.
309 """
310 preferred_tag_order = [
311 "audit",
312 "emacs",
313 "development",
314 "ios",
315 "linux",
316 "personal",
317 "privacy",
318 "security",
319 "self-hosting",
320 "web",
321 ]
322
323 tag_map = {}
324
325 for post in get_blog_posts(content_dir):
326 for tag in post["tags"]:
327 tag_map.setdefault(tag, []).append(
328 {
329 "title": post["title"],
330 "slug": post["slug"],
331 "date_obj": post["date_obj"],
332 "date_str": post["date_str"],
333 }
334 )
335
336 ordered_tags = [tag for tag in preferred_tag_order if tag in tag_map]
337 ordered_tags.extend(
338 sorted(tag for tag in tag_map if tag not in preferred_tag_order)
339 )
340
341 for tag in ordered_tags:
342 tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True)
343
344 toc_items = "".join(
345 f'<li><a href="#{tag}">{tag}</a> <span class="tag-count">({len(tag_map[tag])})</span></li>'
346 for tag in ordered_tags
347 )
348
349 sections = []
350 for tag in ordered_tags:
351 posts = tag_map[tag]
352 items = "\n".join(
353 f'<li class="post-list-item">'
354 f'<time datetime="{p["date_str"]}">{p["date_str"]}</time>'
355 f'<a href="/blog/{p["slug"]}.html">{p["title"]}</a>'
356 f"</li>"
357 for p in posts
358 )
359 sections.append(
360 f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>'
361 )
362
363 return f'<ul class="tag-toc">{toc_items}</ul>\n' + "".join(
364 f"<section>{section}</section>" for section in sections
365 )
366
367
368105def deploy_to_server(build_dir, server):
369106 remote_path = f"{server}:/var/www/cleberg.net/"
370 print(f"Deploying .build/ → {remote_path}")
371 result = subprocess.run(
107 print(f"Deploying {build_dir}/ → {remote_path}")
108 run(
372109 # The build cache lives in the output directory because it describes it, but it
373110 # is not part of the site. Excluding it also stops --delete removing it locally.
374111 [
@@ -380,14 +117,8 @@ def deploy_to_server(build_dir, server):
380117 f"{build_dir}/",
381118 remote_path,
382119 ],
383 capture_output=True,
384 text=True,
385 check=False,
120 "Error during rsync deployment:",
386121 )
387 if result.returncode != 0:
388 print("Error during rsync deployment:")
389 print(result.stderr, file=sys.stderr)
390 sys.exit(1)
391122
392123
393124def start_dev_server(build_dir):
@@ -404,38 +135,32 @@ def start_dev_server(build_dir):
404135
405136
406137def main():
407 env = os.environ.get("ENV", "").casefold()
408 if env != "prod":
138 prod = os.environ.get("ENV", "").casefold() == "prod"
139 if not prod:
409140 run_ruff()
410141
411 build_dir = Path(".build")
412 theme_dir = Path("theme/static")
413 css_src = theme_dir / "styles.css"
414 css_min = theme_dir / "styles.min.css"
415
416 build = os.environ.get("BUILD", "").casefold() == "true"
417 deploy = os.environ.get("DEPLOY", "").casefold() == "true"
418
419 if env == "prod":
420 print("Environment: Production")
421 if build:
422 remove_build_directory(build_dir)
423 minify_css(css_src, css_min)
424 run_orgo_build(dev_mode=False)
425 copy_org_sources()
142 # One output directory per environment, because the two differ after orgo has run:
143 # production rewrites image URLs in place and development does not. Sharing a
144 # directory would let an incremental build reuse a page rendered for the other one —
145 # a dev preview showing /img/ paths that only resolve on the server. Production keeps
146 # .build/ because that is the directory the deploy and the CI manifest name.
147 build_dir = Path(".build" if prod else ".build-dev")
148
149 print(f"Environment: {'Production' if prod else 'Development'}")
150
151 if os.environ.get("BUILD", "").casefold() == "true":
152 run_orgo_build(build_dir)
153 # The onion needs same-origin images; dev previews keep the absolute URLs.
154 # Runs over every page, not just the re-rendered ones, so a page carried over
155 # from an earlier build is rewritten too.
156 if prod:
426157 rewrite_img_urls(build_dir)
427 if deploy:
158
159 if os.environ.get("DEPLOY", "").casefold() == "true":
160 if prod:
428161 print("Deploying to production...")
429162 deploy_to_server(build_dir, "homelab")
430 return
431 else:
432 print("Environment: Development")
433 if build:
434 remove_build_directory(build_dir)
435 minify_css(css_src, css_min)
436 run_orgo_build(dev_mode=True)
437 copy_org_sources()
438 if deploy:
163 else:
439164 start_dev_server(build_dir)
440165
441166
content/orgo.toml +4 −12
@@ -5,9 +5,6 @@
55#
66# orgo serve content -o /tmp/preview # write and preview
77# orgo build content -o _site --strict # build for real, failing on broken links
8#
9# This sits alongside the existing weblorg setup (publish.el, theme/) and changes nothing
10# about it. Delete this file and content/templates/ to remove orgo entirely.
118
129[site]
1310title = "cleberg.net"
@@ -21,8 +18,7 @@ base_url = "https://cleberg.net"
2118[nav]
2219# Explicit, because the pages that belong in the nav are not all top-level: /blog/ and
2320# /garden/ are generated below, and salary/ is a section index one level down. index.org
24# is deliberately absent — the layout writes the Home link itself, exactly as weblorg's
25# base.html does.
21# is deliberately absent — the layout writes the Home link itself.
2622#
2723# Generated pages are named by their output path, authored ones by their source. Listing
2824# them together is what puts Salary last: a collection with `nav = true` that is not
@@ -32,15 +28,13 @@ mode = "explicit"
3228pages = ["blog/index.html", "garden/index.html", "salary/index.org"]
3329
3430[build]
35# theme/static/ is weblorg's static root, published at /. orgo reads it from here
31# theme/static/ is the site's static root, published at /. orgo reads it from here
3632# rather than needing its files copied next to the writing — one copy, not two.
3733assets = ["../theme/static"]
3834
3935[templates]
4036dir = "templates"
4137
42# Blog posts render through post.html — the shared layout plus a reply footer — the way
43# weblorg routes them through theme/templates/post.html. Everything else uses base.html.
4438# Blog posts render through post.html, which adds the reply snippet. Nothing else does:
4539# the live site also invites replies on garden notes and /now/, and that is a choice
4640# rather than something a directory should decide. Any page opts in for itself with
@@ -63,13 +57,11 @@ toc = true
6357section_numbers = true
6458
6559# --- Generated pages -------------------------------------------------------------
66# These have no source .org file. weblorg produces the equivalents today through its
67# blog-index, garden-index and tags routes.
60# These have no source .org file.
6861
6962# The home page. Generated rather than authored, because it is a hand-written
7063# introduction *plus* the most recent posts — and the post list is not something to
71# maintain by hand. The prose lives in templates/home.html, which is where weblorg keeps
72# it today too.
64# maintain by hand. The prose lives in templates/home.html.
7365#
7466# content/index.org was deleted to make room: it held only #+title and #+description,
7567# both of which are now [site] settings above. Restore it any time with
content/templates/base.html +6 −6
@@ -1,9 +1,9 @@
1{# Page layout for orgo, mirroring theme/templates/base.html.
1{# Page layout for the site.
22
33 Styling comes from theme/static/styles.css, published by `[build] assets` in
4 orgo.toml — the same directory weblorg serves at /, read in place rather than
5 copied. weblorg links the minified styles.min.css instead; that file is gitignored
6 build output and the rules are the same, so this links the tracked one.
4 orgo.toml and read in place rather than copied. It is served as authored: at ~1KB,
5 minifying it saved 52 bytes over the wire once compressed, which did not pay for a
6 build step and a toolchain dependency.
77
88 Available here: page (.title .url .source .date .date_iso .tags .excerpt .toc
99 .keywords), site, nav, root, stylesheet. See the orgo guide on Templates. #}
@@ -45,8 +45,8 @@
4545<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ root }}feed.xml">
4646<link rel="stylesheet" href="{{ root }}styles.css" type="text/css">
4747<link rel="icon" href="data:,">
48{#- rel="me" identity links. weblorg's base.html writes the last one as <a>, which a
49 browser hoists out of <head>; <link> is the spelling that stays where it is put. #}
48{#- rel="me" identity links, spelled as <link> rather than <a>: a browser hoists a
49 stray <a> out of <head>, while <link> stays where it is put. #}
5050<link href="https://github.com/ccleberg" rel="me">
5151<link href="https://gitlab.com/ccleberg" rel="me">
5252<link href="mailto:hello@cleberg.net" rel="me">
content/templates/blog.html +2 −2
@@ -1,5 +1,5 @@
1{# The blog index at /blog/, mirroring theme/templates/blog.html.
2 Separate from list.html because the live page carries its own prose. #}
1{# The blog index at /blog/.
2 Separate from list.html because the page carries its own prose. #}
33{% extends "base.html" %}
44
55{#- Group the list under year headings, as the live site does. Set to false for one flat
content/templates/feed.xml +4 −5
@@ -1,10 +1,9 @@
11<?xml version="1.0" encoding="UTF-8" ?>
2{#- The RSS feed at /feed.xml, mirroring theme/templates/feed.xml. A feed is read away
3 from the site that served it, so every URL here is absolute — which is what
4 `| absolute` needs [site] base_url for.
2{#- The RSS feed at /feed.xml. A feed is read away from the site that served it, so
3 every URL here is absolute — which is what `| absolute` needs [site] base_url for.
54
6 <description> carries each post's full rendered HTML, as weblorg's feed does — the
7 collection sets `include_content` to ask for it. #}
5 <description> carries each post's full rendered HTML — the collection sets
6 `include_content` to ask for it. #}
87<rss version="2.0"
98 xmlns:content="http://purl.org/rss/1.0/modules/content/"
109 xmlns:dc="http://purl.org/dc/elements/1.1/"
content/templates/garden.html +1 −1
@@ -1,4 +1,4 @@
1{# The garden index at /garden/, mirroring theme/templates/garden.html.
1{# The garden index at /garden/.
22 Notes are listed by title and description rather than by date. #}
33{% extends "base.html" %}
44{% block main %}
content/templates/home.html +2 −3
@@ -1,9 +1,8 @@
11{# The home page: authored prose plus the most recent posts.
22
33 A generated page, so it has no source .org file — `pages` holds the blog collection's
4 entries, newest first, and the prose lives here. This mirrors what weblorg's
5 theme/templates/index.html does today, including its bare <title>: the home page is
6 "cleberg.net" and not "cleberg.net - cleberg.net". #}
4 entries, newest first, and the prose lives here. The <title> is bare on purpose: the
5 home page is "cleberg.net" and not "cleberg.net - cleberg.net". #}
76{% extends "base.html" %}
87{% block subtitle %}{% endblock %}
98{% block main %}
content/templates/tags.html +4 −5
@@ -1,11 +1,10 @@
11{# The tag index at /tags/. Receives `groups` — every tag with its count — rather than
22 `pages`, because it lists tags and not posts.
33
4 This deliberately differs in shape from the live weblorg page, which is one page with
5 every post inlined under an `#anchor` per tag. Here each tag is its own page, so a tag
6 has a URL worth linking to and the index stays short. The `id` on each entry keeps the
7 old `/tags/#audit` links landing in the right place — they scroll to the tag and its
8 link, rather than 404ing or dumping the reader at the top of a long page. #}
4 Each tag is its own page, so a tag has a URL worth linking to and the index stays
5 short. The `id` on each entry keeps older `/tags/#audit` links landing in the right
6 place — they scroll to the tag and its link, rather than dumping the reader at the
7 top of the index. #}
98{% extends "base.html" %}
109{% block main %}
1110<h1>{{ page.title }}</h1>
content/uses/index.org +1 −1
@@ -24,7 +24,7 @@ threat model justification.
2424| Operating System | [[https://ubuntu.com][Ubuntu]] + [[https://www.apple.com/os/macos/][macOS]] | Ubuntu: telemetry off, LTS, stable. macOS: workstation only. |
2525| Web Server | [[https://nginx.org][Nginx]] + [[https://community.torproject.org/onion-services/setup/][Tor]] | Static file serving. Tor layer for censored-network access. |
2626| SSL | [[https://certbot.eff.org][Certbot]] | Free automated TLS. No commercial CA dependency. Ensures Cloudflare can't view unencrypted traffic. |
27| Static Site Generator | [[https://github.com/emacs-love/weblorg][Weblorg]] + [[https://git.sr.ht/~ccleberg/cleberg.net/tree/main/item/build.py][build.py]] | Org-mode source compiles to plaintext HTML via native Lisp. |
27| Static Site Generator | [[https://github.com/krazywarez/orgo][orgo]] + [[https://git.sr.ht/~ccleberg/cleberg.net/tree/main/item/build.py][build.py]] | Org-mode source compiles to plaintext HTML. Single binary, no runtime. |
2828| Terminal | [[https://iterm2.com/][iTerm2]] | Functional. Inherited from macOS. |
2929| Shell | [[https://www.zsh.org/][Zsh]] | Portable, POSIX-adjacent, available on every target OS. |
3030| Editor | [[https://github.com/doomemacs/doomemacs][Doom Emacs]] | Editor and markup are the same tool. No proprietary format. |
publish.el deleted −128
@@ -1,128 +0,0 @@
1;;; -*- lexical-binding: t -*-
2(setq debug-on-error t)
3
4;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution
5(defvar site-lisp-base
6 (expand-file-name "~/.config/emacs/.local/straight/repos"))
7
8;; Explicitly load packages
9(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base))
10(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base))
11(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base))
12
13(require 'htmlize)
14(require 'templatel)
15(require 'weblorg)
16
17;; Set default URL for Weblorg
18;; Only works if environment variable ENV=prod
19(if (string-equal-ignore-case (or (getenv "ENV") "") "prod")
20 (setq weblorg-default-url "https://cleberg.net"))
21
22;; Define site metadata
23(weblorg-site
24 :theme nil
25 :template-vars '(("site_name" . "cleberg.net")
26 ("site_owner" . "Christian Cleberg <hello@cleberg.net>")
27 ("site_description" . "Stillness amidst the chaos.")))
28
29;; Define routes for rendering content
30
31;; Index page route
32(weblorg-route
33 :name "index"
34 :input-pattern "content/*.org"
35 :template "index.html"
36 :output ".build/index.html"
37 :url "/")
38
39;; Blog post route
40(weblorg-route
41 :name "blog"
42 :input-pattern "content/blog/*.org"
43 :template "post.html"
44 :output ".build/blog/{{ slug }}.html"
45 :url "/blog/{{ slug }}.html")
46
47;; Blog index page route
48(weblorg-route
49 :name "blog-index"
50 :input-pattern "content/blog/*.org"
51 :input-aggregate #'weblorg-input-aggregate-all-desc
52 :template "blog.html"
53 :output ".build/blog/index.html"
54 :url "/blog/")
55
56;; Garden post route
57(weblorg-route
58 :name "garden"
59 :input-pattern "content/garden/*.org"
60 :template "post.html"
61 :output ".build/garden/{{ slug }}.html"
62 :url "/garden/{{ slug }}.html")
63
64;; Garden index page route
65(weblorg-route
66 :name "garden-index"
67 :input-pattern "content/garden/*.org"
68 :input-aggregate #'weblorg-input-aggregate-all-desc
69 :template "garden.html"
70 :output ".build/garden/index.html"
71 :url "/garden/")
72
73;; Page post route
74(weblorg-route
75 :name "pages"
76 :input-pattern "content/*.org"
77 :template "page.html"
78 :output ".build/{{ slug }}.html"
79 :url "/{{ slug }}.html")
80
81;; Salary page route
82(weblorg-route
83 :name "salary"
84 :input-pattern "content/salary/*.org"
85 :template "page.html"
86 :output ".build/salary/{{ slug }}.html"
87 :url "/salary/{{ slug }}.html")
88
89;; Now page route
90(weblorg-route
91 :name "now"
92 :input-pattern "content/now/*.org"
93 :template "post.html"
94 :output ".build/now/{{ slug }}.html"
95 :url "/now/{{ slug }}.html")
96
97;; Uses page route
98(weblorg-route
99 :name "uses"
100 :input-pattern "content/uses/*.org"
101 :template "page.html"
102 :output ".build/uses/{{ slug }}.html"
103 :url "/uses/{{ slug }}.html")
104
105;; Tips page route
106(weblorg-route
107 :name "tips"
108 :input-pattern "content/tips/*.org"
109 :template "page.html"
110 :output ".build/tips/{{ slug }}.html"
111 :url "/tips/{{ slug }}.html")
112
113;; RSS feed route
114(weblorg-route
115 :name "rss"
116 :input-pattern "content/blog/*.org"
117 :input-aggregate #'weblorg-input-aggregate-all-desc
118 :template "feed.xml"
119 :output ".build/feed.xml"
120 :url "/feed.xml")
121
122;; Copy static assets and output to .build directory
123(weblorg-copy-static
124 :output ".build/{{ file }}"
125 :url "/{{ file }}")
126
127;; Export all content using Weblorg engine
128(weblorg-export)
theme/templates/base.html deleted −46
@@ -1,46 +0,0 @@
1<!doctype html>
2<html lang="en-us">
3<head>
4 {% block head %}
5 <meta charset="utf-8">
6 <title>{% block subtitle %}{% endblock %}{% block title %}{{ site_name | default("cleberg.net") }}{% endblock %}</title>
7 <meta name="viewport" content="width=device-width, initial-scale=1">
8 {% if site_owner is defined %}<meta name="author" content="{{ site_owner }}">{% endif %}
9 {% if site_description is defined %}<meta name="description" content="{{ site_description }}">{% endif %}
10 {% if site_keywords is defined %}<meta name="keywords" content="{{ site_keywords }}">{% endif %}
11 <link rel="alternate" type="application/rss+xml" title="cleberg.net" href="/feed.xml">
12 <link rel="stylesheet" href="/styles.min.css" type="text/css">
13 <link rel="icon" href="data:,">
14 {% block meta %}{% endblock %}
15 {% endblock %}
16 <link href="https://github.com/ccleberg" rel="me">
17 <link href="https://gitlab.com/ccleberg" rel="me">
18 <link href="mailto:hello@cleberg.net" rel="me">
19 <a href="https://c.im/@cmc" rel="me">
20</head>
21<body>
22 <a href="#main-content" class="skip-link">Skip to content</a>
23
24 <nav aria-label="Site Navigation">
25 <ul>
26 <li><a href="/">Home</a></li>
27 <li><a href="/blog/">Blog</a></li>
28 <li><a href="/garden/">Garden</a></li>
29 <li><a href="/salary/">Salary</a></li>
30 </ul>
31 </nav>
32
33 <main id="main-content">
34 {% block main %}{% endblock %}
35 </main>
36
37 <footer>
38 <a href="https://git.krz.sh/cmc/cleberg.net.git/">Source</a> &middot;
39 <a href="/feed.xml">RSS</a> &middot;
40 <a href="http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion/">Onion</a> &middot;
41 <a href="/tips/">Tips</a> &middot;
42 <a href="https://iheartrss.com/">I &hearts; RSS</a>
43 <p>[ <a href="https://krz.sh">krz</a> &middot; <a href="https://audit-labs.dev">audit labs</a> ]</p>
44 </footer>
45</body>
46</html>
theme/templates/blog.html deleted −13
@@ -1,13 +0,0 @@
1{% extends "base.html" %} {% block subtitle %}blog - {% endblock %} {% block main %}
2<h1>Blog</h1>
3<p>Use <code>⌘ F</code> / <code>Ctrl F</code> to search &middot; <a href="/feed.xml">RSS Feed</a></p>
4<p>Want to browse by topic instead? Head over to the <a href="/tags/">tags</a> page.</p>
5<ul class="post-list">
6 {% for post in posts %}
7 <li class="post-list-item">
8 <time datetime='{{ post.date | strftime("%Y-%m-%d") }}'>{{ post.date|strftime("%Y-%m-%d") }}</time>
9 <a href='{{ url_for("blog", slug=post.slug) }}'>{{ post.title }}</a>
10 </li>
11 {% endfor %}
12</ul>
13{% endblock %}
theme/templates/feed.xml deleted −37
@@ -1,37 +0,0 @@
1<?xml version="1.0" encoding="UTF-8" ?>
2<rss version="2.0"
3 xmlns:content="http://purl.org/rss/1.0/modules/content/"
4 xmlns:wfw="http://wellformedweb.org/CommentAPI/"
5 xmlns:dc="http://purl.org/dc/elements/1.1/"
6 xmlns:atom="http://www.w3.org/2005/Atom"
7 xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
8 xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
9 xmlns:georss="http://www.georss.org/georss"
10 xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"
11 xmlns:media="http://search.yahoo.com/mrss/">
12 <channel>
13 <title>{{ site_name | default("cleberg.net") }}</title>
14 <atom:link
15 href="{{ url_for("rss") }}"
16 rel="self" type="application/rss+xml" />
17 <link>{{ url_for("index") }}</link>
18 <description><![CDATA[]]></description>
19 <language>en</language>
20 <pubDate>{{ posts | first | getattr("date") | strftime("%a, %d %b %Y %H:%M:%S %z") }}</pubDate>
21 <lastBuildDate>{{ now() | strftime("%a, %d %b %Y %H:%M:%S %z") }}</lastBuildDate>
22 <generator>{{ meta.generator }}</generator>
23 <webMaster>{{ site_owner | default("hello@cleberg.net") }}</webMaster>
24
25 {% for post in posts %}
26 <item>
27 <title>{{ post.title }}</title>
28 <link>{{ url_for("blog", slug=post.slug) }}</link>
29 <author>{{ post.author|default("hello@cleberg.net") }}</author>
30 <guid isPermaLink="false">{{ url_for("blog", slug=post.slug) }}</guid>
31 <pubDate>{{ post.date|strftime("%a, %d %b %Y %H:%M:%S %z") }}</pubDate>
32 <description><![CDATA[{{ post.html|safe }}]]></description>
33 </item>
34 {% endfor %}
35
36 </channel>
37</rss>
theme/templates/garden.html deleted −27
@@ -1,27 +0,0 @@
1{% extends "base.html" %} {% block subtitle %}garden - {% endblock %} {% block main %}
2<h1>Garden</h1>
3<p>Notes in various states of growth. Some are stubs, some are developed. Browse
4around.</p>
5<section>
6 <h2>🪴 The Growth Stages</h2>
7 <p>Instead of dates, these metadata labels show how mature a page is:</p>
8 <span><strong>🌱 Seedlings:</strong> Rough notes, raw ideas, or "half-baked"
9 thoughts.</span><br>
10 <span><strong>🌿 Budding:</strong> Notes that have some structure and a few
11 internal links.</span><br>
12 <span><strong>🌳 Evergreen:</strong> High-conviction ideas that are
13 well-researched and stable.</span><br>
14 <span><strong>🍂 Compost:</strong> Ideas you no longer agree with but want to
15 keep for historical context.</span><br><br>
16</section>
17<ul>
18 {% for post in posts %}
19 <li>
20 <a href='{{ url_for("garden", slug=post.slug) }}'>{{ post.title }}</a>
21 {% if post.description %}
22 <span>{{ post.description }}</span>
23 {% endif %}
24 </li>
25 {% endfor %}
26</ul>
27{% endblock %}
theme/templates/index.html deleted −33
@@ -1,33 +0,0 @@
1{% extends "base.html" %}
2{% block meta %}<meta name="bubbles:fediverse" content="@cmc@c.im">{% endblock %}
3{% block main %}
4<section>
5 <h1>Christian Cleberg</h1>
6 <p>[ <a href="https://krz.sh">krz</a> &middot; <a href="https://audit-labs.dev">audit labs</a> ]</p>
7 <p>Privacy &middot; Self-Hosting &middot; iOS</p>
8 <p>I build tools, write about systems, and run my own infrastructure. Focused on privacy, user control, and long-term utility.</p>
9 <p><a href="/uses/">Uses</a> &middot; <a href="/now/">Now</a></p>
10</section>
11
12<section>
13 <h2>Recent Posts</h2>
14 <p>Things I've written recently.</p>
15 <ul class="post-list">
16 <!-- BEGIN_POSTS --><!-- END_POSTS -->
17 </ul>
18 <p><a href="/blog/">All Posts &rarr;</a></p>
19</section>
20
21<section>
22 <h2>Elsewhere</h2>
23 <p>Places to find me online.</p>
24 <ul>
25 <li>cgit (primary): <a href="https://git.krz.sh">~cmc</a></li>
26 <li>GitHub (mirror): <a href="https://github.com/ccleberg">@ccleberg</a></li>
27 <li>Email: <a href="mailto:hello@cleberg.net">hello@cleberg.net</a> (<a href="/gpg.txt">GPG</a>)</li>
28 <li>Lemmy: <a href="https://r.nf/u/cmc">@cmc</a></li>
29 <li>Mastodon: <a href="https://c.im/@cmc">@cmc</a></li>
30 <li>Pixelfed: <a href="https://gram.social/cmc">@cmc</a></li>
31 </ul>
32</section>
33{% endblock %}
theme/templates/page.html deleted −8
@@ -1,8 +0,0 @@
1{% extends "base.html" %}
2{% block subtitle %}{{ post.title }} - {% endblock %}
3{% block main %}
4<article>
5 <h1>{{ post.title }}</h1>
6 <div>{{ post.html | safe }}</div>
7</article>
8{% endblock %}
theme/templates/post.html deleted −19
@@ -1,19 +0,0 @@
1{% extends "base.html" %} {% block subtitle %} {{ post.title }} - {% endblock %}
2{% block meta %}
3<meta property="og:title" content="{{ post.title }}" />
4<meta property="og:url" content="{{ post.url }}" />
5{% if post.description is defined %}
6<meta property="og:description" content="{{ post.description }}" />
7{% elif project_description is defined %}
8<meta property="og:description" content="{{ project_description }}" />
9{% endif %}
10{% endblock %}
11{% block main %}
12<article>
13 <h1>{{ post.title }}</h1>
14 <p><time datetime='{{ post.date|strftime("%Y-%m-%d") }}'>{{ post.date|strftime("%Y-%m-%d") }}</time>{% if post.description is defined %} &middot; {{ post.description }}{% endif %}</p>
15 <div>{{ post.html | safe }}</div>
16 <div><a href="mailto:hello@cleberg.net">Reply via email &rarr;</a></div>
17 <div><i>...or, comment on this post on <a href="https://bubbles.town/source/cleberg.net">Bubbles</a>!</i></div>
18</article>
19{% endblock %}
theme/templates/tags.html deleted −6
@@ -1,6 +0,0 @@
1{% extends "base.html" %} {% block subtitle %}tags - {% endblock %} {% block main %}
2<h1>Tags</h1>
3<p>Browse posts by topic.</p>
4<!-- BEGIN_TAGS -->
5<!-- END_TAGS -->
6{% endblock %}