Commit 195e1d0ff1

195e1d0ff130ff53d7915832e8403f123a3ed629

parent: 5d60a70d6a

Unsigned

cmc <hello@cleberg.net> · 2026-08-12 00:16 UTC

Build with orgo

The generator changes; the steps around it that orgo does not do stay. Four of
them go, because it does:

- the blog index groups itself by year (a template, using page.year)
- the tags page is a collection
- the recent-posts list on the home page comes from the blog collection
- the sitemap is written once site.base_url is set

What remains is minifying CSS, copying the org sources for readers who want
them, and rewriting img.cleberg.net to /img/ so the onion serves images from its
own origin. That last one is why this script still exists.

Verified against the live site before deploying: 195 pages, 186 identical in
content, and every URL in the live sitemap still produced. The nine that differ
are the ones we chose — the reply footer is opt-in now, /tags/ has a page per
tag, and footnotes carry aria labels.

publish.el and theme/templates/ are left alone. They are weblorg's, and there is
no reason to delete a fallback on the same day the replacement goes live.

Layout: unified · split

build.py +18 −216
@@ -32,7 +32,6 @@ import sys
32from datetime import datetime 32from datetime import datetime
33from html import escape 33from html import escape
34from pathlib import Path 34from pathlib import Path
35from urllib.parse import quote
36 35
37SITE_TEMPLATE_VARS = { 36SITE_TEMPLATE_VARS = {
38 "site_name": "cleberg.net", 37 "site_name": "cleberg.net",
@@ -51,66 +50,6 @@ def run_ruff():
51 sys.exit(1) 50 sys.exit(1)
52 51
53 52
54def update_marked_section(
55 html_snippet,
56 template_path="./.build/index.html",
57 begin_marker="<!-- BEGIN_POSTS -->",
58 end_marker="<!-- END_POSTS -->",
59):
60 """
61 Read the file at `template_path`, replace everything between `begin_marker`
62 and `end_marker` with the provided html_snippet, and write the updated
63 content back to the same file.
64 """
65 with open(template_path, "r", encoding="utf-8") as f:
66 content = f.read()
67
68 # Find the indices of the markers
69 begin_index = content.find(begin_marker)
70 end_index = content.find(end_marker)
71
72 if begin_index == -1 or end_index == -1:
73 raise ValueError(f"Markers not found in {template_path}")
74
75 # Compute insertion points: after the end of begin_marker line, before end_marker
76 # Include the newline after BEGIN_POSTS
77 insert_start = begin_index + len(begin_marker)
78 # Ensure we capture the newline character if present
79 if content[insert_start : insert_start + 1] == "\n":
80 insert_start += 1
81
82 # If there is a newline before END_POSTS, trim trailing whitespace from snippet block
83 # We will preserve indentation of BEGIN_POSTS line
84 indent = ""
85 # Determine the indentation by looking at characters after the newline that follows begin_marker
86 lines_after_begin = content[begin_index:].splitlines(True)
87 if len(lines_after_begin) > 1:
88 # The second line starts with the indentation to preserve
89 second_line = lines_after_begin[1]
90 indent = ""
91 for ch in second_line:
92 if ch.isspace():
93 indent += ch
94 else:
95 break
96
97 # Prepare the replacement block: indent each line of html_snippet
98 snippet_lines = html_snippet.splitlines()
99 indented_snippet = "\n".join(indent + line for line in snippet_lines) + "\n"
100
101 # Compute the position just before end_marker (excluding any preceding whitespace/newline)
102 end_line_start = content.rfind("\n", 0, end_index)
103 if end_line_start == -1:
104 end_line_start = end_index
105
106 # Construct the new content
107 new_content = content[:insert_start] + indented_snippet + content[end_line_start:]
108
109 # Write back to index.html
110 with open(template_path, "w", encoding="utf-8") as f:
111 f.write(new_content)
112
113
114def render_base_template(main_html, subtitle="", title=None): 53def render_base_template(main_html, subtitle="", title=None):
115 """ 54 """
116 Render a small subset of the site's shared templates for Python-generated 55 Render a small subset of the site's shared templates for Python-generated
@@ -257,24 +196,6 @@ def get_blog_posts(content_dir="./content/blog"):
257 return posts 196 return posts
258 197
259 198
260def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
261 """
262 Return an HTML snippet for the `num_posts` most recent blog posts.
263 """
264 recent = get_blog_posts(content_dir)[:num_posts]
265
266 lines = []
267 for post in recent:
268 lines.append('\t<li class="post-list-item">')
269 lines.append(
270 f'\t\t<time datetime="{post["date_str"]}">{post["date_full"]}</time>'
271 )
272 lines.append(f'\t\t<a href="/blog/{post["slug"]}.html">{post["title"]}</a>')
273 lines.append("\t</li>")
274
275 return "\n".join(lines)
276
277
278def prompt(prompt_text): 199def prompt(prompt_text):
279 try: 200 try:
280 return input(prompt_text).strip() 201 return input(prompt_text).strip()
@@ -303,20 +224,6 @@ def minify_css(src_css, dest_css):
303 sys.exit(1) 224 sys.exit(1)
304 225
305 226
306def minify_html(src_html, dest_html):
307 print(f"Minifying HTML: {src_html} → {dest_html}")
308 result = subprocess.run(
309 ["minify", "-o", str(dest_html), str(src_html)],
310 capture_output=True,
311 text=True,
312 check=False,
313 )
314 if result.returncode != 0:
315 print("Error during HTML minification:")
316 print(result.stderr, file=sys.stderr)
317 sys.exit(1)
318
319
320def rewrite_img_urls(build_dir=".build"): 227def rewrite_img_urls(build_dir=".build"):
321 """ 228 """
322 Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the 229 Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
@@ -342,31 +249,32 @@ def rewrite_img_urls(build_dir=".build"):
342 print(f"Rewrote {count} img.cleberg.net references to /img/") 249 print(f"Rewrote {count} img.cleberg.net references to /img/")
343 250
344 251
345def run_emacs_publish(dev_mode=True): 252def run_orgo_build(dev_mode=True):
346 mode = "development" if dev_mode else "production" 253 """
347 print(f"Running Emacs publish script ({mode})...") 254 Build the site with orgo.
255
256 Replaces the weblorg/Emacs publish. orgo reads content/orgo.toml, so the routes,
257 templates and collections that used to live in publish.el live there now. Four of the
258 steps this script used to perform afterwards are gone with it: the blog index groups
259 itself by year, the tags page is a collection, the recent-posts list is generated from
260 the blog collection, and the sitemap is written by orgo once base_url is set.
348 261
262 What is left around it is what orgo does not do: minifying CSS, copying the org
263 sources for readers who want them, and rewriting image URLs for the onion.
264 """
265 print("Building with orgo...")
349 result = subprocess.run( 266 result = subprocess.run(
350 ["emacs", "--script", "publish.el"], 267 ["orgo", "build", "content", "-o", ".build", "--strict"],
351 stdout=subprocess.PIPE, 268 stdout=subprocess.PIPE,
352 stderr=subprocess.STDOUT, 269 stderr=subprocess.STDOUT,
353 text=True, 270 text=True,
354 check=False, 271 check=False,
355 ) 272 )
356 273 print(result.stdout, end="")
357 if result.returncode != 0: 274 if result.returncode != 0:
358 print("Error running publish.el output:") 275 print("orgo build failed", file=sys.stderr)
359 print(result.stdout)
360 sys.exit(1) 276 sys.exit(1)
361 277
362 annoying_file = Path(".build/cleberg-net.html")
363 if annoying_file.exists():
364 os.remove(annoying_file)
365 else:
366 print(
367 "Warning: .build/cleberg-net.html not found, but Emacs exited successfully."
368 )
369
370 278
371def copy_org_sources(content_dir="./content", build_dir="./.build/org"): 279def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
372 print(f"Copying org sources: {content_dir} → {build_dir}") 280 print(f"Copying org sources: {content_dir} → {build_dir}")
@@ -395,90 +303,6 @@ def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
395 print(f" {src_path.name} → {dest_name}") 303 print(f" {src_path.name} → {dest_name}")
396 304
397 305
398def generate_sitemap(build_dir=".build", base_url="https://cleberg.net"):
399 """
400 Generates a sitemap.xml based on contents of the .build directory.
401 Only includes .html files (except 404.html).
402 """
403 sitemap_entries = []
404 for root, dirs, files in os.walk(build_dir):
405 for filename in files:
406 if filename.endswith(".html") and filename != "404.html":
407 full_path = os.path.join(root, filename)
408 rel_path = os.path.relpath(full_path, build_dir)
409 url_path = "/" + quote(rel_path.replace(os.sep, "/"))
410 # Remove index.html for cleaner URLs
411 if url_path.endswith("/index.html"):
412 url_path = url_path[:-10] or "/"
413 elif url_path == "/index.html":
414 url_path = "/"
415 loc = f"{base_url}{url_path}"
416
417 # Last modified time
418 lastmod = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime(
419 "%Y-%m-%d"
420 )
421
422 sitemap_entries.append(f""" <url>
423 <loc>{loc}</loc>
424 <lastmod>{lastmod}</lastmod>
425 </url>""")
426
427 sitemap_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
428<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
429{os.linesep.join(sitemap_entries)}
430</urlset>
431"""
432 # Write to .build/sitemap.xml
433 sitemap_path = os.path.join(build_dir, "sitemap.xml")
434 with open(sitemap_path, "w", encoding="utf-8") as f:
435 f.write(sitemap_xml)
436 print(f"Sitemap generated at {sitemap_path} with {len(sitemap_entries)} entries.")
437
438
439def inject_blog_year_separators(blog_index_path="./.build/blog/index.html"):
440 """
441 Post-processes the rendered blog index to inject year separator <li> elements
442 between groups of posts. Weblorg/templatel doesn't support mutable loop state,
443 so this runs after the HTML is generated.
444
445 Finds each <li class="post-list-item"> that contains a <time datetime="YYYY-MM-DD">,
446 and inserts <li class="post-list-year">YYYY</li> before the first post of each year.
447 """
448 path = Path(blog_index_path)
449 if not path.exists():
450 print(f"Warning: {blog_index_path} not found, skipping year separators.")
451 return
452
453 content = path.read_text(encoding="utf-8")
454
455 # Match each post list item, capturing the date and the full element
456 item_pattern = re.compile(
457 r'(<li class="post-list-item">.*?</li>)',
458 re.DOTALL,
459 )
460 date_pattern = re.compile(r"datetime=['\"]?(\d{4})-\d{2}-\d{2}['\"]?")
461
462 current_year = None
463
464 def replace_item(m):
465 nonlocal current_year
466 item_html = m.group(1)
467 date_match = date_pattern.search(item_html)
468 if not date_match:
469 return item_html
470 year = date_match.group(1)
471 if year != current_year:
472 current_year = year
473 separator = f'<li class="post-list-year">{year}</li>'
474 return f"{separator}\n{item_html}"
475 return item_html
476
477 new_content = item_pattern.sub(replace_item, content)
478 path.write_text(new_content, encoding="utf-8")
479 print(f"Blog year separators injected into {blog_index_path}")
480
481
482def get_tags_html(content_dir="./content/blog"): 306def get_tags_html(content_dir="./content/blog"):
483 """ 307 """
484 Build the tag index HTML snippet for the rendered tags template. 308 Build the tag index HTML snippet for the rendered tags template.
@@ -541,17 +365,6 @@ def get_tags_html(content_dir="./content/blog"):
541 ) 365 )
542 366
543 367
544def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
545 """
546 Render the tags page using the shared template structure.
547 """
548 tags_html = get_tags_html(content_dir)
549 out_path = Path(build_dir) / "tags" / "index.html"
550 out_path.parent.mkdir(parents=True, exist_ok=True)
551 out_path.write_text(render_tags_page_html(tags_html), encoding="utf-8")
552 print(f"Tags page written to {out_path}")
553
554
555def deploy_to_server(build_dir, server): 368def deploy_to_server(build_dir, server):
556 remote_path = f"{server}:/var/www/cleberg.net/" 369 remote_path = f"{server}:/var/www/cleberg.net/"
557 print(f"Deploying .build/ → {remote_path}") 370 print(f"Deploying .build/ → {remote_path}")
@@ -584,7 +397,6 @@ def main():
584 env = os.environ.get("ENV", "").casefold() 397 env = os.environ.get("ENV", "").casefold()
585 if env != "prod": 398 if env != "prod":
586 run_ruff() 399 run_ruff()
587 html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
588 400
589 build_dir = Path(".build") 401 build_dir = Path(".build")
590 theme_dir = Path("theme/static") 402 theme_dir = Path("theme/static")
@@ -599,14 +411,9 @@ def main():
599 if build: 411 if build:
600 remove_build_directory(build_dir) 412 remove_build_directory(build_dir)
601 minify_css(css_src, css_min) 413 minify_css(css_src, css_min)
602 run_emacs_publish(dev_mode=False) 414 run_orgo_build(dev_mode=False)
603 copy_org_sources() 415 copy_org_sources()
604 update_marked_section(html_snippet)
605 inject_blog_year_separators()
606 generate_tags_page()
607 rewrite_img_urls(build_dir) 416 rewrite_img_urls(build_dir)
608 # minify_html("./.build/index.html", "./.build/index.html")
609 generate_sitemap()
610 if deploy: 417 if deploy:
611 print("Deploying to production...") 418 print("Deploying to production...")
612 deploy_to_server(build_dir, "homelab") 419 deploy_to_server(build_dir, "homelab")
@@ -616,13 +423,8 @@ def main():
616 if build: 423 if build:
617 remove_build_directory(build_dir) 424 remove_build_directory(build_dir)
618 minify_css(css_src, css_min) 425 minify_css(css_src, css_min)
619 run_emacs_publish(dev_mode=True) 426 run_orgo_build(dev_mode=True)
620 copy_org_sources() 427 copy_org_sources()
621 update_marked_section(html_snippet)
622 inject_blog_year_separators()
623 generate_tags_page()
624 minify_html("./.build/index.html", "./.build/index.html")
625 generate_sitemap()
626 if deploy: 428 if deploy:
627 start_dev_server(build_dir) 429 start_dev_server(build_dir)
628 430