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()