build.py
249 lines · 9924 bytes · executable
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 the footer's orgo version to match the orgo that built the site.
8- Rewriting image URLs for the onion service (production only).
9- Optionally deploying the built site to a remote server.
10- Starting a local development server for previewing changes.
11
12Usage:
13 Set BUILD=true to build, DEPLOY=true to deploy or serve.
14 Set ENV=prod for production builds; anything else builds for development.
15
16 Builds are incremental. Production writes .build/ and development .build-dev/;
17 delete either one for a clean build.
18
19Dependencies:
20 - Python 3
21 - orgo, the static site generator (reads content/orgo.toml)
22 - rsync for deployment
23
24Author:
25 Christian Cleberg <hello@cleberg.net>
26"""
27
28import os
29import re
30import subprocess
31import sys
32from pathlib import Path
33
34
35def run(cmd, error, echo=False):
36 """Run cmd quietly, exiting with its stderr if it fails.
37
38 echo prints stdout on success. Only the dry run needs it: its output is the
39 whole reason to run it, and the default of swallowing stdout would hide it.
40 """
41 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
42 if result.returncode != 0:
43 print(error, file=sys.stderr)
44 print(result.stderr, file=sys.stderr)
45 sys.exit(1)
46 if echo:
47 print(result.stdout, end="")
48
49
50def run_ruff():
51 print("Running ruff...")
52 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
53 run(cmd, f"ruff error ({' '.join(cmd)}):")
54
55
56def rewrite_img_urls(build_dir=".build"):
57 """
58 Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
59 onion serves images from its own origin instead of fetching them off-onion.
60 Production only: dev builds keep the absolute URLs so local previews still
61 load images from the live image host.
62
63 Requires the server to serve /var/www/img/ at /img/ on the cleberg.net vhost
64 (e.g. `ln -s /var/www/img /var/www/cleberg.net/img`).
65
66 The pattern tolerates a stray number of slashes after the scheme
67 (https:/img, https:///img, ...) so a typo in the org source can't silently
68 slip through un-rewritten and ship a broken cross-origin URL.
69 """
70 pattern = re.compile(r"https:/+img\.cleberg\.net/")
71 count = 0
72 for html in Path(build_dir).rglob("*.html"):
73 text = html.read_text(encoding="utf-8")
74 new_text, n = pattern.subn("/img/", text)
75 if n:
76 count += n
77 html.write_text(new_text, encoding="utf-8")
78 print(f"Rewrote {count} img.cleberg.net references to /img/")
79
80
81# The footer's "built with orgo <version>" note. Matching the whole phrase rewrites a
82# page carrying an old version as readily as one still carrying the template's
83# placeholder, and leaves prose that ends a paragraph with a link to orgo alone.
84ORGO_NOTE = re.compile(
85 r'(built with <a href="https://gitbay\.org/krz/orgo">orgo</a> )[^<]*(</p>)'
86)
87
88
89def orgo_version():
90 """The version reported by the orgo on PATH, e.g. "0.22.0"."""
91 result = subprocess.run(
92 ["orgo", "--version"], capture_output=True, text=True, check=False
93 )
94 if result.returncode != 0:
95 print("Could not read the orgo version:", file=sys.stderr)
96 print(result.stderr, file=sys.stderr)
97 sys.exit(1)
98 # `orgo --version` prints "orgo X.Y.Z". Anything else means the output format
99 # changed, and guessing at it would ship a wrong version to every page.
100 parts = result.stdout.split()
101 if len(parts) != 2 or parts[0] != "orgo":
102 print(f"Unexpected `orgo --version` output: {result.stdout!r}", file=sys.stderr)
103 sys.exit(1)
104 return parts[1]
105
106
107def rewrite_orgo_version(build_dir):
108 """Point the footer's orgo version at the orgo that just built the site.
109
110 Runs over every page rather than only the re-rendered ones, because the build cache
111 keys on a cache *format* version and not on orgo's release version: after a version
112 bump that leaves the format alone, the pages carried over from the previous build
113 would otherwise keep printing the old one.
114
115 The whole point of this is that the note cannot go stale, so a footer that no longer
116 matches is a failure and not a no-op — matching nothing anywhere means the note was
117 removed or its markup changed, and a silent pass would ship the template's
118 placeholder to every page.
119 """
120 version = orgo_version()
121 count = 0
122 for html in Path(build_dir).rglob("*.html"):
123 text = html.read_text(encoding="utf-8")
124 new_text, n = ORGO_NOTE.subn(rf"\g<1>{version}\g<2>", text)
125 if n and new_text != text:
126 html.write_text(new_text, encoding="utf-8")
127 count += n
128 if count == 0:
129 print(
130 "No 'built with orgo' note found in the build — the footer in "
131 "content/templates/base.html no longer matches ORGO_NOTE",
132 file=sys.stderr,
133 )
134 sys.exit(1)
135 print(f"Set the footer orgo version to {version} on {count} pages")
136
137
138def run_orgo_build(build_dir):
139 """
140 Build the site with orgo.
141
142 orgo reads content/orgo.toml, which holds the routes, templates and collections: the
143 blog index groups itself by year, the tags page is a collection, the recent-posts
144 list comes from the blog collection, and the sitemap is written once base_url is set.
145
146 The output directory is left in place between builds rather than wiped, because the
147 .orgo-cache.json inside it is what makes a build incremental: orgo re-renders only
148 the pages whose content, config or templates changed, re-emits any page whose output
149 is missing, and deletes the outputs of pages that have since been removed. Wiping the
150 directory would throw that away and force a full render every time. For a clean build
151 from scratch, delete the output directory by hand.
152
153 What is left around it is what orgo does not do: rewriting image URLs for the onion.
154 """
155 print("Building with orgo...")
156 result = subprocess.run(
157 ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
158 stdout=subprocess.PIPE,
159 stderr=subprocess.STDOUT,
160 text=True,
161 check=False,
162 )
163 print(result.stdout, end="")
164 if result.returncode != 0:
165 print("orgo build failed", file=sys.stderr)
166 sys.exit(1)
167
168
169def deploy_to_server(build_dir, server, dry_run=False):
170 """Push the built site to the server, or show what pushing it would do.
171
172 The deploy deletes remote files the build no longer produces, so "what would
173 this remove" is a question worth being able to ask before answering it
174 irreversibly. DRY_RUN=true asks it: rsync connects and compares, then reports
175 instead of transferring.
176 """
177 remote_path = f"{server}:/var/www/cleberg.net/"
178 print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
179 cmd = [
180 "rsync",
181 "-rt",
182 # -t, or rsync gives every transferred file the time it arrived, no two
183 # deploys agree on a timestamp, and the next run re-sends the whole site
184 # because mtimes never match. With it, only what changed goes over.
185 "--delete-before",
186 # The build cache lives in the output directory because it describes it, but it
187 # is not part of the site. Excluding it also stops --delete removing it locally.
188 "--exclude",
189 ".orgo-cache.json",
190 ]
191 if dry_run:
192 # --itemize-changes because --dry-run alone prints almost nothing: the
193 # point is to name every file that would be sent or deleted.
194 cmd += ["--dry-run", "--itemize-changes"]
195 cmd += [f"{build_dir}/", remote_path]
196 run(cmd, "Error during rsync deployment:", echo=dry_run)
197
198
199def start_dev_server(build_dir):
200 print(f"Starting development HTTP server from {build_dir}/ on port 8000")
201 os.chdir(build_dir)
202 # This will run until interrupted (Ctrl+C)
203 try:
204 subprocess.run([sys.executable, "-m", "http.server", "8000"], check=True)
205 except KeyboardInterrupt:
206 print("\nDevelopment server stopped.")
207 except subprocess.CalledProcessError as e:
208 print(f"Error starting development server: {e}", file=sys.stderr)
209 sys.exit(1)
210
211
212def main():
213 prod = os.environ.get("ENV", "").casefold() == "prod"
214 if not prod:
215 run_ruff()
216
217 # One output directory per environment, because the two differ after orgo has run:
218 # production rewrites image URLs in place and development does not. Sharing a
219 # directory would let an incremental build reuse a page rendered for the other one —
220 # a dev preview showing /img/ paths that only resolve on the server. Production keeps
221 # .build/ because that is the directory the deploy and the CI manifest name.
222 build_dir = Path(".build" if prod else ".build-dev")
223
224 print(f"Environment: {'Production' if prod else 'Development'}")
225
226 if os.environ.get("BUILD", "").casefold() == "true":
227 run_orgo_build(build_dir)
228 rewrite_orgo_version(build_dir)
229 # The onion needs same-origin images; dev previews keep the absolute URLs.
230 # Runs over every page, not just the re-rendered ones, so a page carried over
231 # from an earlier build is rewritten too.
232 if prod:
233 rewrite_img_urls(build_dir)
234
235 if os.environ.get("DEPLOY", "").casefold() == "true":
236 if prod:
237 dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
238 print(
239 "Dry run — the server will not be modified"
240 if dry_run
241 else "Deploying to production..."
242 )
243 deploy_to_server(build_dir, "homelab", dry_run)
244 else:
245 start_dev_server(build_dir)
246
247
248if __name__ == "__main__":
249 main()