cmc/cleberg.net
My personal web garden & blog.
clone: git clone https://gitbay.org/cmc/cleberg.net.git
main: build.py · raw
1#!/usr/bin/env python3
2"""
3This script automates the process of building and deploying the website.
4It handles tasks such as:
5
6- Running orgo to generate site content.
7- Rewriting image URLs for the onion service (production only).
8- Optionally deploying the built site to a remote server.
9- Starting a local development server for previewing changes.
10
11Usage:
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.
17
18Dependencies:
19 - Python 3
20 - orgo, the static site generator (reads content/orgo.toml)
21 - rsync for deployment
22
23Author:
24 Christian Cleberg <hello@cleberg.net>
25"""
26
27import os
28import re
29import subprocess
30import sys
31from pathlib import Path
32
33
34def run(cmd, error, echo=False):
35 """Run cmd quietly, exiting with its stderr if it fails.
36
37 echo prints stdout on success. Only the dry run needs it: its output is the
38 whole reason to run it, and the default of swallowing stdout would hide it.
39 """
40 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
41 if result.returncode != 0:
42 print(error, file=sys.stderr)
43 print(result.stderr, file=sys.stderr)
44 sys.exit(1)
45 if echo:
46 print(result.stdout, end="")
47
48
49def run_ruff():
50 print("Running ruff...")
51 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
52 run(cmd, f"ruff error ({' '.join(cmd)}):")
53
54
55def rewrite_img_urls(build_dir=".build"):
56 """
57 Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
58 onion serves images from its own origin instead of fetching them off-onion.
59 Production only: dev builds keep the absolute URLs so local previews still
60 load images from the live image host.
61
62 Requires the server to serve /var/www/img/ at /img/ on the cleberg.net vhost
63 (e.g. `ln -s /var/www/img /var/www/cleberg.net/img`).
64
65 The pattern tolerates a stray number of slashes after the scheme
66 (https:/img, https:///img, ...) so a typo in the org source can't silently
67 slip through un-rewritten and ship a broken cross-origin URL.
68 """
69 pattern = re.compile(r"https:/+img\.cleberg\.net/")
70 count = 0
71 for html in Path(build_dir).rglob("*.html"):
72 text = html.read_text(encoding="utf-8")
73 new_text, n = pattern.subn("/img/", text)
74 if n:
75 count += n
76 html.write_text(new_text, encoding="utf-8")
77 print(f"Rewrote {count} img.cleberg.net references to /img/")
78
79
80def run_orgo_build(build_dir):
81 """
82 Build the site with orgo.
83
84 orgo reads content/orgo.toml, which holds the routes, templates and collections: the
85 blog index groups itself by year, the tags page is a collection, the recent-posts
86 list comes from the blog collection, and the sitemap is written once base_url is set.
87
88 The output directory is left in place between builds rather than wiped, because the
89 .orgo-cache.json inside it is what makes a build incremental: orgo re-renders only
90 the pages whose content, config or templates changed, re-emits any page whose output
91 is missing, and deletes the outputs of pages that have since been removed. Wiping the
92 directory would throw that away and force a full render every time. For a clean build
93 from scratch, delete the output directory by hand.
94
95 What is left around it is what orgo does not do: rewriting image URLs for the onion.
96 """
97 print("Building with orgo...")
98 result = subprocess.run(
99 ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
100 stdout=subprocess.PIPE,
101 stderr=subprocess.STDOUT,
102 text=True,
103 check=False,
104 )
105 print(result.stdout, end="")
106 if result.returncode != 0:
107 print("orgo build failed", file=sys.stderr)
108 sys.exit(1)
109
110
111def deploy_to_server(build_dir, server, dry_run=False):
112 """Push the built site to the server, or show what pushing it would do.
113
114 The deploy deletes remote files the build no longer produces, so "what would
115 this remove" is a question worth being able to ask before answering it
116 irreversibly. DRY_RUN=true asks it: rsync connects and compares, then reports
117 instead of transferring.
118 """
119 remote_path = f"{server}:/var/www/cleberg.net/"
120 print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
121 cmd = [
122 "rsync",
123 "-r",
124 "--delete-before",
125 # The build cache lives in the output directory because it describes it, but it
126 # is not part of the site. Excluding it also stops --delete removing it locally.
127 "--exclude",
128 ".orgo-cache.json",
129 ]
130 if dry_run:
131 # --itemize-changes because --dry-run alone prints almost nothing: the
132 # point is to name every file that would be sent or deleted.
133 cmd += ["--dry-run", "--itemize-changes"]
134 cmd += [f"{build_dir}/", remote_path]
135 run(cmd, "Error during rsync deployment:", echo=dry_run)
136
137
138def start_dev_server(build_dir):
139 print(f"Starting development HTTP server from {build_dir}/ on port 8000")
140 os.chdir(build_dir)
141 # This will run until interrupted (Ctrl+C)
142 try:
143 subprocess.run([sys.executable, "-m", "http.server", "8000"], check=True)
144 except KeyboardInterrupt:
145 print("\nDevelopment server stopped.")
146 except subprocess.CalledProcessError as e:
147 print(f"Error starting development server: {e}", file=sys.stderr)
148 sys.exit(1)
149
150
151def main():
152 prod = os.environ.get("ENV", "").casefold() == "prod"
153 if not prod:
154 run_ruff()
155
156 # One output directory per environment, because the two differ after orgo has run:
157 # production rewrites image URLs in place and development does not. Sharing a
158 # directory would let an incremental build reuse a page rendered for the other one —
159 # a dev preview showing /img/ paths that only resolve on the server. Production keeps
160 # .build/ because that is the directory the deploy and the CI manifest name.
161 build_dir = Path(".build" if prod else ".build-dev")
162
163 print(f"Environment: {'Production' if prod else 'Development'}")
164
165 if os.environ.get("BUILD", "").casefold() == "true":
166 run_orgo_build(build_dir)
167 # The onion needs same-origin images; dev previews keep the absolute URLs.
168 # Runs over every page, not just the re-rendered ones, so a page carried over
169 # from an earlier build is rewritten too.
170 if prod:
171 rewrite_img_urls(build_dir)
172
173 if os.environ.get("DEPLOY", "").casefold() == "true":
174 if prod:
175 dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
176 print(
177 "Dry run — the server will not be modified"
178 if dry_run
179 else "Deploying to production..."
180 )
181 deploy_to_server(build_dir, "homelab", dry_run)
182 else:
183 start_dev_server(build_dir)
184
185
186if __name__ == "__main__":
187 main()