Commit 36e16946cf

36e16946cfe11b907e0144ada68228d3c4bf7b97

parent: b41f62c285

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-12 15:45 UTC

fix: refactor tags page rendering and auto-include new blog tags

Layout: unified · split

__pycache__/build.cpython-314.pyc added

Binary file not shown.

build.py +133 −136
@@ -29,11 +29,19 @@ import re
2929import shutil
3030import subprocess
3131import sys
32from html import escape
3233from urllib.parse import quote
3334from datetime import datetime
3435from pathlib import Path
3536
3637
38SITE_TEMPLATE_VARS = {
39 "site_name": "cleberg.net",
40 "site_owner": "Christian Cleberg <hello@cleberg.net>",
41 "site_description": "Stillness amidst the chaos.",
42}
43
44
3745def run_ruff():
3846 print("Running ruff...")
3947 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
@@ -46,19 +54,20 @@ def run_ruff():
4654 sys.exit(1)
4755
4856
49def update_index_html(html_snippet, template_path="./.build/index.html"):
57def update_marked_section(
58 html_snippet,
59 template_path="./.build/index.html",
60 begin_marker="<!-- BEGIN_POSTS -->",
61 end_marker="<!-- END_POSTS -->",
62):
5063 """
51 Read the index.html file at `template_path`, replace everything between
52 <!-- BEGIN_POSTS --> and <!-- END_POSTS --> with the provided html_snippet,
53 and write the updated content back to the same file.
64 Read the file at `template_path`, replace everything between `begin_marker`
65 and `end_marker` with the provided html_snippet, and write the updated
66 content back to the same file.
5467 """
55 # Read the current contents of index.html
5668 with open(template_path, "r", encoding="utf-8") as f:
5769 content = f.read()
5870
59 begin_marker = "<!-- BEGIN_POSTS -->"
60 end_marker = "<!-- END_POSTS -->"
61
6271 # Find the indices of the markers
6372 begin_index = content.find(begin_marker)
6473 end_index = content.find(end_marker)
@@ -105,18 +114,72 @@ def update_index_html(html_snippet, template_path="./.build/index.html"):
105114 f.write(new_content)
106115
107116
108def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
117def render_base_template(main_html, subtitle="", title=None):
118 """
119 Render a small subset of the site's shared templates for Python-generated
120 pages that still need to follow the common chrome.
121 """
122 base_template = Path("theme/templates/base.html").read_text(encoding="utf-8")
123
124 if title is None:
125 title = SITE_TEMPLATE_VARS["site_name"]
126
127 rendered = base_template
128 rendered = rendered.replace(
129 "{% block subtitle %}{% endblock %}",
130 escape(subtitle),
131 )
132 rendered = rendered.replace(
133 '{% block title %}{{ site_name | default("cleberg.net") }}{% endblock %}',
134 escape(title),
135 )
136 rendered = rendered.replace(
137 '{% if site_owner is defined %}<meta name="author" content="{{ site_owner }}">{% endif %}',
138 f'<meta name="author" content="{escape(SITE_TEMPLATE_VARS["site_owner"])}">',
139 )
140 rendered = rendered.replace(
141 '{% if site_description is defined %}<meta name="description" content="{{ site_description }}">{% endif %}',
142 f'<meta name="description" content="{escape(SITE_TEMPLATE_VARS["site_description"])}">',
143 )
144 rendered = rendered.replace(
145 '{% if site_keywords is defined %}<meta name="keywords" content="{{ site_keywords }}">{% endif %}',
146 "",
147 )
148 rendered = rendered.replace(
149 '{{ url_for("static", file="styles.min.css") }}',
150 "/styles.min.css",
151 )
152 rendered = rendered.replace("{% block meta %}{% endblock %}", "")
153 rendered = rendered.replace("{% block head %}", "")
154 rendered = rendered.replace("{% endblock %}", "", 1)
155 rendered = rendered.replace("{% block main %}{% endblock %}", main_html)
156
157 return rendered
158
159
160def render_tags_page_html(tags_html):
109161 """
110 Scan the `content_dir` for .org blog post files, extract their headers,
111 and return an HTML snippet for the `num_posts` most recent posts.
162 Render the tags page by using the shared tags/base templates instead of a
163 hand-authored standalone HTML document.
164 """
165 tags_template = Path("theme/templates/tags.html").read_text(encoding="utf-8")
166
167 main_html = tags_template
168 main_html = main_html.replace('{% extends "base.html" %}', "")
169 main_html = main_html.replace(
170 "{% block subtitle %}tags - {% endblock %}",
171 "",
172 )
173 main_html = main_html.replace("{% block main %}", "")
174 main_html = main_html.replace("{% endblock %}", "")
175 main_html = main_html.replace("<!-- BEGIN_TAGS -->\n<!-- END_TAGS -->", tags_html)
112176
113 Expects each .org file to contain headers of the form:
114 #+title: Post Title
115 #+date: <YYYY-MM-DD Day HH:MM:SS>
116 #+slug: post-slug
177 return render_base_template(main_html.strip(), subtitle="tags - ")
117178
118 Returns a string containing the section with the three most recent posts,
119 formatted like the snippet in the prompt.
179
180def get_blog_posts(content_dir="./content/blog"):
181 """
182 Scan blog posts and return normalized metadata for non-draft entries.
120183 """
121184 posts = []
122185
@@ -124,6 +187,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
124187 "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE),
125188 "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"),
126189 "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE),
190 "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE),
127191 "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE),
128192 }
129193
@@ -131,6 +195,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
131195 title = None
132196 date_str = None
133197 slug = None
198 tags = []
134199 is_draft = False
135200
136201 with org_path.open("r", encoding="utf-8") as f:
@@ -154,6 +219,13 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
154219 slug = m.group(1).strip()
155220 continue
156221
222 if not tags:
223 m = header_patterns["tags"].match(line)
224 if m:
225 raw = m.group(1).strip().strip(":")
226 tags = [t.strip() for t in raw.split(":") if t.strip()]
227 continue
228
157229 m = header_patterns["draft"].match(line)
158230 if m:
159231 draft_value = m.group(1).strip().lower()
@@ -184,16 +256,20 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
184256 "date_obj": date_obj,
185257 "date_full": date_full,
186258 "slug": slug,
259 "tags": tags,
187260 }
188261 )
189262
190 # Sort posts by date (newest first)
191263 posts.sort(key=lambda x: x["date_obj"], reverse=True)
264 return posts
192265
193 # Take the top `num_posts`
194 recent = posts[:num_posts]
195266
196 # Build HTML lines
267def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
268 """
269 Return an HTML snippet for the `num_posts` most recent blog posts.
270 """
271 recent = get_blog_posts(content_dir)[:num_posts]
272
197273 lines = []
198274 for post in recent:
199275 lines.append('\t<li class="post-list-item">')
@@ -384,15 +460,15 @@ def inject_blog_year_separators(blog_index_path="./.build/blog/index.html"):
384460 print(f"Blog year separators injected into {blog_index_path}")
385461
386462
387def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
463def get_tags_html(content_dir="./content/blog"):
388464 """
389 Scans all blog org files for #+filetags, then writes .build/tags/index.html
390 with one section per tag, each post listed under its tags, sorted newest first.
391 Tags link to anchor IDs on the same page.
465 Build the tag index HTML snippet for the rendered tags template.
392466 """
393 TAG_ORDER = [
467 preferred_tag_order = [
394468 "audit",
395469 "emacs",
470 "development",
471 "ios",
396472 "linux",
397473 "personal",
398474 "privacy",
@@ -401,82 +477,32 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
401477 "web",
402478 ]
403479
404 header_patterns = {
405 "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE),
406 "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"),
407 "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE),
408 "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE),
409 "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE),
410 }
480 tag_map = {}
411481
412 tag_map = {tag: [] for tag in TAG_ORDER}
413
414 for org_path in Path(content_dir).glob("*.org"):
415 title = date_str = slug = None
416 tags = []
417 is_draft = False
418
419 with org_path.open("r", encoding="utf-8") as f:
420 for line in f:
421 if not title:
422 m = header_patterns["title"].match(line)
423 if m:
424 title = m.group(1).strip()
425 continue
426 if not date_str:
427 m = header_patterns["date"].match(line)
428 if m:
429 date_str = m.group(1)
430 continue
431 if not slug:
432 m = header_patterns["slug"].match(line)
433 if m:
434 slug = m.group(1).strip()
435 continue
436 if not tags:
437 m = header_patterns["tags"].match(line)
438 if m:
439 raw = m.group(1).strip().strip(":")
440 tags = [t.strip() for t in raw.split(":") if t.strip()]
441 continue
442 m = header_patterns["draft"].match(line)
443 if m and m.group(1).strip().lower() != "nil":
444 is_draft = True
445 break
446 if title and date_str and slug and tags:
447 break
448
449 if is_draft or not (title and date_str and slug):
450 continue
451
452 try:
453 date_obj = datetime.strptime(date_str, "%Y-%m-%d")
454 except ValueError:
455 continue
482 for post in get_blog_posts(content_dir):
483 for tag in post["tags"]:
484 tag_map.setdefault(tag, []).append(
485 {
486 "title": post["title"],
487 "slug": post["slug"],
488 "date_obj": post["date_obj"],
489 "date_str": post["date_str"],
490 }
491 )
456492
457 for tag in tags:
458 if tag in tag_map:
459 tag_map[tag].append(
460 {
461 "title": title,
462 "slug": slug,
463 "date_obj": date_obj,
464 "date_str": date_str,
465 }
466 )
493 ordered_tags = [tag for tag in preferred_tag_order if tag in tag_map]
494 ordered_tags.extend(sorted(tag for tag in tag_map if tag not in preferred_tag_order))
467495
468 for tag in tag_map:
496 for tag in ordered_tags:
469497 tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True)
470498
471 # Build TOC
472499 toc_items = "".join(
473500 f'<li><a href="#{tag}">{tag}</a> <span class="tag-count">({len(tag_map[tag])})</span></li>'
474 for tag in TAG_ORDER
501 for tag in ordered_tags
475502 )
476503
477 # Build sections
478504 sections = []
479 for tag in TAG_ORDER:
505 for tag in ordered_tags:
480506 posts = tag_map[tag]
481507 items = "\n".join(
482508 f'<li class="post-list-item">'
@@ -489,48 +515,19 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
489515 f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>'
490516 )
491517
492 html = f"""<!doctype html>
493<html lang=en-us>
494<head>
495<meta charset=utf-8>
496<title>cleberg.net</title>
497<meta name=viewport content="width=device-width,initial-scale=1">
498<meta name=author content="Christian Cleberg <hello@cleberg.net>">
499<meta name=description content="Stillness amidst the chaos.">
500<link rel=stylesheet href=/styles.min.css>
501<link rel=icon href=data:,>
502</head>
503<body>
504<a href=#main-content class=skip-link>Skip to content</a>
505<nav aria-label="Site Navigation">
506<ul>
507<li><a href=/>Home</a>
508<li><a href=/apps/>Apps</a>
509<li><a href=/blog/>Blog</a>
510<li><a href=/garden/>Garden</a>
511<li><a href=/guides/>Guides</a>
512<li><a href=/salary/>Salary</a>
513<li><a href=/tags/>Tags</a>
514</ul>
515</nav>
516<main id=main-content>
517<h1>Tags</h1>
518<ul class="tag-toc">{toc_items}</ul>
519{"".join(f"<section>{s}</section>" for s in sections)}
520</main>
521<footer>
522<a href=https://sr.ht/~ccleberg/cleberg.net>Source</a>&#183;
523<a href=/feed.xml>RSS</a>&#183;
524<a href=http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion/>Onion</a>&#183;
525<a href=/tips/>Tips</a>
526</footer>
527</body>
528</html>"""
529
530 out_dir = Path(build_dir) / "tags"
531 out_dir.mkdir(parents=True, exist_ok=True)
532 out_path = out_dir / "index.html"
533 out_path.write_text(html, encoding="utf-8")
518 return f'<ul class="tag-toc">{toc_items}</ul>\n' + "".join(
519 f"<section>{section}</section>" for section in sections
520 )
521
522
523def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
524 """
525 Render the tags page using the shared template structure.
526 """
527 tags_html = get_tags_html(content_dir)
528 out_path = Path(build_dir) / "tags" / "index.html"
529 out_path.parent.mkdir(parents=True, exist_ok=True)
530 out_path.write_text(render_tags_page_html(tags_html), encoding="utf-8")
534531 print(f"Tags page written to {out_path}")
535532
536533
@@ -583,7 +580,7 @@ def main():
583580 minify_css(css_src, css_min)
584581 run_emacs_publish(dev_mode=False)
585582 copy_org_sources()
586 update_index_html(html_snippet)
583 update_marked_section(html_snippet)
587584 inject_blog_year_separators()
588585 generate_tags_page()
589586 # minify_html("./.build/index.html", "./.build/index.html")
@@ -599,7 +596,7 @@ def main():
599596 minify_css(css_src, css_min)
600597 run_emacs_publish(dev_mode=True)
601598 copy_org_sources()
602 update_index_html(html_snippet)
599 update_marked_section(html_snippet)
603600 inject_blog_year_separators()
604601 generate_tags_page()
605602 minify_html("./.build/index.html", "./.build/index.html")
content/blog/2026-03-30-hutch-ios.org +1
@@ -2,6 +2,7 @@
22#+title: Announcing Hutch: An iOS Client for Sourcehut
33#+description: Announcing Hutch, an iOS client for Sourcehut. Including links to project resources, features, and roadmap.
44#+slug: hutch-ios
5#+filetags: :development:
56
67* Hutch
78
theme/templates/tags.html added +6
@@ -0,0 +1,6 @@
1{% extends "base.html" %} {% block subtitle %}tags - {% endblock %} {% block main %}
2<h1>Tags</h1>
3<p>Browse posts by topic.</p>
4<!-- BEGIN_TAGS -->
5<!-- END_TAGS -->
6{% endblock %}