diff options
| author | Christian Cleberg <[email protected]> | 2026-08-11 20:34:02 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-11 20:34:02 -0500 |
| commit | ab149c1ae4fe38048fc2d32edac6061e7f3b4638 (patch) | |
| tree | 984019a98b20789890566c595b52d5ddc8b21064 /build.py | |
| parent | 5be4598b09a2685d73665b403bf80aad50b15834 (diff) | |
| download | cleberg.net-ab149c1ae4fe38048fc2d32edac6061e7f3b4638.tar.gz cleberg.net-ab149c1ae4fe38048fc2d32edac6061e7f3b4638.tar.bz2 cleberg.net-ab149c1ae4fe38048fc2d32edac6061e7f3b4638.zip | |
Finish the move off weblorgmigrate-to-orgo
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-x | build.py | 385 |
1 files changed, 55 insertions, 330 deletions
@@ -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) |
