aboutsummaryrefslogtreecommitdiff
path: root/build.py
blob: 61f6110bfcee73f6a572f0efecc2d258c14fd3ac (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
#!/usr/bin/env python3
"""
This script automates the process of building and deploying the website.
It handles tasks such as:

- Running orgo to generate site content.
- Rewriting image URLs for the onion service (production only).
- Optionally deploying the built site to a remote server.
- Starting a local development server for previewing changes.

Usage:
    Set BUILD=true to build, DEPLOY=true to deploy or serve.
    Set ENV=prod for production builds; anything else builds for development.

    Builds are incremental. Production writes .build/ and development .build-dev/;
    delete either one for a clean build.

Dependencies:
    - Python 3
    - orgo, the static site generator (reads content/orgo.toml)
    - rsync for deployment

Author:
    Christian Cleberg <[email protected]>
"""

import os
import re
import subprocess
import sys
from pathlib import Path


def run(cmd, error):
    """Run cmd quietly, exiting with its stderr if it fails."""
    result = subprocess.run(cmd, capture_output=True, text=True, check=False)
    if result.returncode != 0:
        print(error, file=sys.stderr)
        print(result.stderr, file=sys.stderr)
        sys.exit(1)


def run_ruff():
    print("Running ruff...")
    for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
        run(cmd, f"ruff error ({' '.join(cmd)}):")


def rewrite_img_urls(build_dir=".build"):
    """
    Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
    onion serves images from its own origin instead of fetching them off-onion.
    Production only: dev builds keep the absolute URLs so local previews still
    load images from the live image host.

    Requires the server to serve /var/www/img/ at /img/ on the cleberg.net vhost
    (e.g. `ln -s /var/www/img /var/www/cleberg.net/img`).

    The pattern tolerates a stray number of slashes after the scheme
    (https:/img, https:///img, ...) so a typo in the org source can't silently
    slip through un-rewritten and ship a broken cross-origin URL.
    """
    pattern = re.compile(r"https:/+img\.cleberg\.net/")
    count = 0
    for html in Path(build_dir).rglob("*.html"):
        text = html.read_text(encoding="utf-8")
        new_text, n = pattern.subn("/img/", text)
        if n:
            count += n
            html.write_text(new_text, encoding="utf-8")
    print(f"Rewrote {count} img.cleberg.net references to /img/")


def run_orgo_build(build_dir):
    """
    Build the site with orgo.

    orgo reads content/orgo.toml, which holds the routes, templates and collections: the
    blog index groups itself by year, the tags page is a collection, the recent-posts
    list comes from the blog collection, and the sitemap is written once base_url is set.

    The output directory is left in place between builds rather than wiped, because the
    .orgo-cache.json inside it is what makes a build incremental: orgo re-renders only
    the pages whose content, config or templates changed, re-emits any page whose output
    is missing, and deletes the outputs of pages that have since been removed. Wiping the
    directory would throw that away and force a full render every time. For a clean build
    from scratch, delete the output directory by hand.

    What is left around it is what orgo does not do: rewriting image URLs for the onion.
    """
    print("Building with orgo...")
    result = subprocess.run(
        ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=False,
    )
    print(result.stdout, end="")
    if result.returncode != 0:
        print("orgo build failed", file=sys.stderr)
        sys.exit(1)


def deploy_to_server(build_dir, server):
    remote_path = f"{server}:/var/www/cleberg.net/"
    print(f"Deploying {build_dir}/ → {remote_path}")
    run(
        # The build cache lives in the output directory because it describes it, but it
        # is not part of the site. Excluding it also stops --delete removing it locally.
        [
            "rsync",
            "-r",
            "--delete-before",
            "--exclude",
            ".orgo-cache.json",
            f"{build_dir}/",
            remote_path,
        ],
        "Error during rsync deployment:",
    )


def start_dev_server(build_dir):
    print(f"Starting development HTTP server from {build_dir}/ on port 8000")
    os.chdir(build_dir)
    # This will run until interrupted (Ctrl+C)
    try:
        subprocess.run([sys.executable, "-m", "http.server", "8000"], check=True)
    except KeyboardInterrupt:
        print("\nDevelopment server stopped.")
    except subprocess.CalledProcessError as e:
        print(f"Error starting development server: {e}", file=sys.stderr)
        sys.exit(1)


def main():
    prod = os.environ.get("ENV", "").casefold() == "prod"
    if not prod:
        run_ruff()

    # One output directory per environment, because the two differ after orgo has run:
    # production rewrites image URLs in place and development does not. Sharing a
    # directory would let an incremental build reuse a page rendered for the other one —
    # a dev preview showing /img/ paths that only resolve on the server. Production keeps
    # .build/ because that is the directory the deploy and the CI manifest name.
    build_dir = Path(".build" if prod else ".build-dev")

    print(f"Environment: {'Production' if prod else 'Development'}")

    if os.environ.get("BUILD", "").casefold() == "true":
        run_orgo_build(build_dir)
        # The onion needs same-origin images; dev previews keep the absolute URLs.
        # Runs over every page, not just the re-rendered ones, so a page carried over
        # from an earlier build is rewritten too.
        if prod:
            rewrite_img_urls(build_dir)

    if os.environ.get("DEPLOY", "").casefold() == "true":
        if prod:
            print("Deploying to production...")
            deploy_to_server(build_dir, "homelab")
        else:
            start_dev_server(build_dir)


if __name__ == "__main__":
    main()