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
3232from datetime import datetime
3333from html import escape
3434from pathlib import Path
35from urllib.parse import quote
3635
3736SITE_TEMPLATE_VARS = {
3837 "site_name": "cleberg.net",
@@ -51,66 +50,6 @@ def run_ruff():
5150 sys.exit(1)
5251
5352
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
11453def render_base_template(main_html, subtitle="", title=None):
11554 """
11655 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"):
257196 return posts
258197
259198
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
278199def prompt(prompt_text):
279200 try:
280201 return input(prompt_text).strip()
@@ -303,20 +224,6 @@ def minify_css(src_css, dest_css):
303224 sys.exit(1)
304225
305226
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
320227def rewrite_img_urls(build_dir=".build"):
321228 """
322229 Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
@@ -342,31 +249,32 @@ def rewrite_img_urls(build_dir=".build"):
342249 print(f"Rewrote {count} img.cleberg.net references to /img/")
343250
344251
345def run_emacs_publish(dev_mode=True):
346 mode = "development" if dev_mode else "production"
347 print(f"Running Emacs publish script ({mode})...")
252def run_orgo_build(dev_mode=True):
253 """
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.
348261
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...")
349266 result = subprocess.run(
350 ["emacs", "--script", "publish.el"],
267 ["orgo", "build", "content", "-o", ".build", "--strict"],
351268 stdout=subprocess.PIPE,
352269 stderr=subprocess.STDOUT,
353270 text=True,
354271 check=False,
355272 )
356
273 print(result.stdout, end="")
357274 if result.returncode != 0:
358 print("Error running publish.el output:")
359 print(result.stdout)
275 print("orgo build failed", file=sys.stderr)
360276 sys.exit(1)
361277
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
370278
371279def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
372280 print(f"Copying org sources: {content_dir} → {build_dir}")
@@ -395,90 +303,6 @@ def copy_org_sources(content_dir="./content", build_dir="./.build/org"):
395303 print(f" {src_path.name} → {dest_name}")
396304
397305
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
482306def get_tags_html(content_dir="./content/blog"):
483307 """
484308 Build the tag index HTML snippet for the rendered tags template.
@@ -541,17 +365,6 @@ def get_tags_html(content_dir="./content/blog"):
541365 )
542366
543367
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
555368def deploy_to_server(build_dir, server):
556369 remote_path = f"{server}:/var/www/cleberg.net/"
557370 print(f"Deploying .build/ → {remote_path}")
@@ -584,7 +397,6 @@ def main():
584397 env = os.environ.get("ENV", "").casefold()
585398 if env != "prod":
586399 run_ruff()
587 html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
588400
589401 build_dir = Path(".build")
590402 theme_dir = Path("theme/static")
@@ -599,14 +411,9 @@ def main():
599411 if build:
600412 remove_build_directory(build_dir)
601413 minify_css(css_src, css_min)
602 run_emacs_publish(dev_mode=False)
414 run_orgo_build(dev_mode=False)
603415 copy_org_sources()
604 update_marked_section(html_snippet)
605 inject_blog_year_separators()
606 generate_tags_page()
607416 rewrite_img_urls(build_dir)
608 # minify_html("./.build/index.html", "./.build/index.html")
609 generate_sitemap()
610417 if deploy:
611418 print("Deploying to production...")
612419 deploy_to_server(build_dir, "homelab")
@@ -616,13 +423,8 @@ def main():
616423 if build:
617424 remove_build_directory(build_dir)
618425 minify_css(css_src, css_min)
619 run_emacs_publish(dev_mode=True)
426 run_orgo_build(dev_mode=True)
620427 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()
626428 if deploy:
627429 start_dev_server(build_dir)
628430