aboutsummaryrefslogtreecommitdiff
path: root/build.py
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-11 20:34:02 -0500
committerChristian Cleberg <[email protected]>2026-08-11 20:35:15 -0500
commita6ee2e0ca79b83b7f6bd4a521777cffdaf2260f2 (patch)
tree984019a98b20789890566c595b52d5ddc8b21064 /build.py
parent0b3783a396ad74a869639b74719c3a3769e4b6e5 (diff)
downloadcleberg.net-a6ee2e0ca79b83b7f6bd4a521777cffdaf2260f2.tar.gz
cleberg.net-a6ee2e0ca79b83b7f6bd4a521777cffdaf2260f2.tar.bz2
cleberg.net-a6ee2e0ca79b83b7f6bd4a521777cffdaf2260f2.zip
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.
Diffstat (limited to 'build.py')
-rwxr-xr-xbuild.py385
1 files changed, 55 insertions, 330 deletions
diff --git a/build.py b/build.py
index 0d90e2b..61f6110 100755
--- a/build.py
+++ b/build.py
@@ -1,23 +1,23 @@
#!/usr/bin/env python3
"""
-This script automates the process of building, testing, and deploying the website.
+This script automates the process of building and deploying the website.
It handles tasks such as:
-- Removing and recreating the build directory.
-- Minifying CSS assets.
-- Running the Emacs publishing script to generate site content.
-- Updating the index.html file with the latest blog posts.
+- 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 the environment variable ENV to 'prod' for production builds.
- Run the script to perform the build process accordingly.
+ 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
- - Emacs with the publish.el script
- - minify tool for CSS minification
+ - orgo, the static site generator (reads content/orgo.toml)
- rsync for deployment
Author:
@@ -26,202 +26,24 @@ Author:
import os
import re
-import shutil
import subprocess
import sys
-from datetime import datetime
-from html import escape
from pathlib import Path
-SITE_TEMPLATE_VARS = {
- "site_name": "cleberg.net",
- "site_owner": "Christian Cleberg <[email protected]>",
- "site_description": "Stillness amidst the chaos.",
-}
+
+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"]]:
- result = subprocess.run(cmd, capture_output=True, text=True, check=False)
- if result.returncode != 0:
- print(f"ruff error ({' '.join(cmd)}):")
- print(result.stderr, file=sys.stderr)
- sys.exit(1)
-
-
-def render_base_template(main_html, subtitle="", title=None):
- """
- Render a small subset of the site's shared templates for Python-generated
- pages that still need to follow the common chrome.
- """
- base_template = Path("theme/templates/base.html").read_text(encoding="utf-8")
-
- if title is None:
- title = SITE_TEMPLATE_VARS["site_name"]
-
- rendered = base_template
- rendered = rendered.replace(
- "{% block subtitle %}{% endblock %}",
- escape(subtitle),
- )
- rendered = rendered.replace(
- '{% block title %}{{ site_name | default("cleberg.net") }}{% endblock %}',
- escape(title),
- )
- rendered = rendered.replace(
- '{% if site_owner is defined %}<meta name="author" content="{{ site_owner }}">{% endif %}',
- f'<meta name="author" content="{escape(SITE_TEMPLATE_VARS["site_owner"])}">',
- )
- rendered = rendered.replace(
- '{% if site_description is defined %}<meta name="description" content="{{ site_description }}">{% endif %}',
- f'<meta name="description" content="{escape(SITE_TEMPLATE_VARS["site_description"])}">',
- )
- rendered = rendered.replace(
- '{% if site_keywords is defined %}<meta name="keywords" content="{{ site_keywords }}">{% endif %}',
- "",
- )
- rendered = rendered.replace("{% block meta %}{% endblock %}", "")
- rendered = rendered.replace("{% block head %}", "")
- rendered = rendered.replace("{% endblock %}", "", 1)
- rendered = rendered.replace("{% block main %}{% endblock %}", main_html)
-
- return rendered
-
-
-def render_tags_page_html(tags_html):
- """
- Render the tags page by using the shared tags/base templates instead of a
- hand-authored standalone HTML document.
- """
- tags_template = Path("theme/templates/tags.html").read_text(encoding="utf-8")
-
- main_html = tags_template
- main_html = main_html.replace('{% extends "base.html" %}', "")
- main_html = main_html.replace(
- "{% block subtitle %}tags - {% endblock %}",
- "",
- )
- main_html = main_html.replace("{% block main %}", "")
- main_html = main_html.replace("{% endblock %}", "")
- main_html = main_html.replace("<!-- BEGIN_TAGS -->\n<!-- END_TAGS -->", tags_html)
-
- return render_base_template(main_html.strip(), subtitle="tags - ")
-
-
-def get_blog_posts(content_dir="./content/blog"):
- """
- Scan blog posts and return normalized metadata for non-draft entries.
- """
- posts = []
-
- header_patterns = {
- "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE),
- "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"),
- "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE),
- "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE),
- "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE),
- }
-
- for org_path in Path(content_dir).glob("*.org"):
- title = None
- date_str = None
- slug = None
- tags = []
- is_draft = False
-
- with org_path.open("r", encoding="utf-8") as f:
- for line in f:
- if title is None:
- m = header_patterns["title"].match(line)
- if m:
- title = m.group(1).strip()
- continue
-
- if date_str is None:
- m = header_patterns["date"].match(line)
- if m:
- # date_str is just YYYY-MM-DD
- date_str = m.group(1)
- continue
-
- if slug is None:
- m = header_patterns["slug"].match(line)
- if m:
- slug = m.group(1).strip()
- continue
-
- if not tags:
- m = header_patterns["tags"].match(line)
- if m:
- raw = m.group(1).strip().strip(":")
- tags = [t.strip() for t in raw.split(":") if t.strip()]
- continue
-
- m = header_patterns["draft"].match(line)
- if m:
- draft_value = m.group(1).strip().lower()
- if draft_value != "nil":
- is_draft = True
- break
- continue
-
- # Stop scanning once we have all required fields
- if title and date_str and slug:
- break
-
- if is_draft:
- continue
-
- if title and date_str and slug:
- try:
- date_obj = datetime.strptime(date_str, "%Y-%m-%d")
- date_full = date_obj.strftime("%Y-%m-%d")
- except ValueError:
- # Skip files with invalid date format
- continue
-
- posts.append(
- {
- "title": title,
- "date_str": date_str,
- "date_obj": date_obj,
- "date_full": date_full,
- "slug": slug,
- "tags": tags,
- }
- )
-
- posts.sort(key=lambda x: x["date_obj"], reverse=True)
- return posts
-
-
-def prompt(prompt_text):
- try:
- return input(prompt_text).strip()
- except EOFError:
- return ""
-
-
-def remove_build_directory(build_dir):
- if build_dir.exists():
- print(f"Removing previous build directory: {build_dir}/")
- shutil.rmtree(build_dir)
- build_dir.mkdir(parents=True, exist_ok=True)
-
-
-def minify_css(src_css, dest_css):
- print(f"Minifying CSS: {src_css} → {dest_css}")
- result = subprocess.run(
- ["minify", "-o", str(dest_css), str(src_css)],
- capture_output=True,
- text=True,
- check=False,
- )
- if result.returncode != 0:
- print("Error during CSS minification:")
- print(result.stderr, file=sys.stderr)
- sys.exit(1)
+ run(cmd, f"ruff error ({' '.join(cmd)}):")
def rewrite_img_urls(build_dir=".build"):
@@ -249,22 +71,26 @@ def rewrite_img_urls(build_dir=".build"):
print(f"Rewrote {count} img.cleberg.net references to /img/")
-def run_orgo_build(dev_mode=True):
+def run_orgo_build(build_dir):
"""
Build the site with orgo.
- Replaces the weblorg/Emacs publish. orgo reads content/orgo.toml, so the routes,
- templates and collections that used to live in publish.el live there now. Four of the
- steps this script used to perform afterwards are gone with it: the blog index groups
- itself by year, the tags page is a collection, the recent-posts list is generated from
- the blog collection, and the sitemap is written by orgo once base_url is set.
+ 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: minifying CSS, copying the org
- sources for readers who want them, and rewriting image URLs for the onion.
+ 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", ".build", "--strict"],
+ ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -276,99 +102,10 @@ def run_orgo_build(dev_mode=True):
sys.exit(1)
-def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
- print(f"Copying org sources: {content_dir} → {build_dir}")
- if os.path.exists(build_dir):
- shutil.rmtree(build_dir)
-
- slug_pattern = re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE)
-
- for src_path in Path(content_dir).rglob("*.org"):
- rel_dir = src_path.parent.relative_to(content_dir)
- dest_dir = Path(build_dir) / rel_dir
- dest_dir.mkdir(parents=True, exist_ok=True)
-
- # Try to extract slug from file headers
- slug = None
- with src_path.open("r", encoding="utf-8") as f:
- for line in f:
- m = slug_pattern.match(line)
- if m:
- slug = m.group(1).strip()
- break
-
- dest_name = f"{slug}.org" if slug else src_path.name
- shutil.copy2(src_path, dest_dir / dest_name)
- if slug:
- print(f" {src_path.name} → {dest_name}")
-
-
-def get_tags_html(content_dir="./content/blog"):
- """
- Build the tag index HTML snippet for the rendered tags template.
- """
- preferred_tag_order = [
- "audit",
- "emacs",
- "development",
- "ios",
- "linux",
- "personal",
- "privacy",
- "security",
- "self-hosting",
- "web",
- ]
-
- tag_map = {}
-
- for post in get_blog_posts(content_dir):
- for tag in post["tags"]:
- tag_map.setdefault(tag, []).append(
- {
- "title": post["title"],
- "slug": post["slug"],
- "date_obj": post["date_obj"],
- "date_str": post["date_str"],
- }
- )
-
- ordered_tags = [tag for tag in preferred_tag_order if tag in tag_map]
- ordered_tags.extend(
- sorted(tag for tag in tag_map if tag not in preferred_tag_order)
- )
-
- for tag in ordered_tags:
- tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True)
-
- toc_items = "".join(
- f'<li><a href="#{tag}">{tag}</a> <span class="tag-count">({len(tag_map[tag])})</span></li>'
- for tag in ordered_tags
- )
-
- sections = []
- for tag in ordered_tags:
- posts = tag_map[tag]
- items = "\n".join(
- f'<li class="post-list-item">'
- f'<time datetime="{p["date_str"]}">{p["date_str"]}</time>'
- f'<a href="/blog/{p["slug"]}.html">{p["title"]}</a>'
- f"</li>"
- for p in posts
- )
- sections.append(
- f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>'
- )
-
- return f'<ul class="tag-toc">{toc_items}</ul>\n' + "".join(
- f"<section>{section}</section>" for section in sections
- )
-
-
def deploy_to_server(build_dir, server):
remote_path = f"{server}:/var/www/cleberg.net/"
- print(f"Deploying .build/ → {remote_path}")
- result = subprocess.run(
+ 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.
[
@@ -380,14 +117,8 @@ def deploy_to_server(build_dir, server):
f"{build_dir}/",
remote_path,
],
- capture_output=True,
- text=True,
- check=False,
+ "Error during rsync deployment:",
)
- if result.returncode != 0:
- print("Error during rsync deployment:")
- print(result.stderr, file=sys.stderr)
- sys.exit(1)
def start_dev_server(build_dir):
@@ -404,38 +135,32 @@ def start_dev_server(build_dir):
def main():
- env = os.environ.get("ENV", "").casefold()
- if env != "prod":
+ prod = os.environ.get("ENV", "").casefold() == "prod"
+ if not prod:
run_ruff()
- build_dir = Path(".build")
- theme_dir = Path("theme/static")
- css_src = theme_dir / "styles.css"
- css_min = theme_dir / "styles.min.css"
-
- build = os.environ.get("BUILD", "").casefold() == "true"
- deploy = os.environ.get("DEPLOY", "").casefold() == "true"
-
- if env == "prod":
- print("Environment: Production")
- if build:
- remove_build_directory(build_dir)
- minify_css(css_src, css_min)
- run_orgo_build(dev_mode=False)
- copy_org_sources()
+ # 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 deploy:
+
+ if os.environ.get("DEPLOY", "").casefold() == "true":
+ if prod:
print("Deploying to production...")
deploy_to_server(build_dir, "homelab")
- return
- else:
- print("Environment: Development")
- if build:
- remove_build_directory(build_dir)
- minify_css(css_src, css_min)
- run_orgo_build(dev_mode=True)
- copy_org_sources()
- if deploy:
+ else:
start_dev_server(build_dir)