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
29import shutil 29import shutil
30import subprocess 30import subprocess
31import sys 31import sys
32from html import escape
32from urllib.parse import quote 33from urllib.parse import quote
33from datetime import datetime 34from datetime import datetime
34from pathlib import Path 35from pathlib import Path
35 36
36 37
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
37def run_ruff(): 45def run_ruff():
38 print("Running ruff...") 46 print("Running ruff...")
39 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]: 47 for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
@@ -46,19 +54,20 @@ def run_ruff():
46 sys.exit(1) 54 sys.exit(1)
47 55
48 56
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):
50 """ 63 """
51 Read the index.html file at `template_path`, replace everything between 64 Read the file at `template_path`, replace everything between `begin_marker`
52 <!-- BEGIN_POSTS --> and <!-- END_POSTS --> with the provided html_snippet, 65 and `end_marker` with the provided html_snippet, and write the updated
53 and write the updated content back to the same file. 66 content back to the same file.
54 """ 67 """
55 # Read the current contents of index.html
56 with open(template_path, "r", encoding="utf-8") as f: 68 with open(template_path, "r", encoding="utf-8") as f:
57 content = f.read() 69 content = f.read()
58 70
59 begin_marker = "<!-- BEGIN_POSTS -->"
60 end_marker = "<!-- END_POSTS -->"
61
62 # Find the indices of the markers 71 # Find the indices of the markers
63 begin_index = content.find(begin_marker) 72 begin_index = content.find(begin_marker)
64 end_index = content.find(end_marker) 73 end_index = content.find(end_marker)
@@ -105,18 +114,72 @@ def update_index_html(html_snippet, template_path="./.build/index.html"):
105 f.write(new_content) 114 f.write(new_content)
106 115
107 116
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):
109 """ 161 """
110 Scan the `content_dir` for .org blog post files, extract their headers, 162 Render the tags page by using the shared tags/base templates instead of a
111 and return an HTML snippet for the `num_posts` most recent posts. 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)
112 176
113 Expects each .org file to contain headers of the form: 177 return render_base_template(main_html.strip(), subtitle="tags - ")
114 #+title: Post Title
115 #+date: <YYYY-MM-DD Day HH:MM:SS>
116 #+slug: post-slug
117 178
118 Returns a string containing the section with the three most recent posts, 179
119 formatted like the snippet in the prompt. 180def get_blog_posts(content_dir="./content/blog"):
181 """
182 Scan blog posts and return normalized metadata for non-draft entries.
120 """ 183 """
121 posts = [] 184 posts = []
122 185
@@ -124,6 +187,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
124 "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE), 187 "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE),
125 "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"), 188 "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"),
126 "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE), 189 "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE),
190 "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE),
127 "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE), 191 "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE),
128 } 192 }
129 193
@@ -131,6 +195,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
131 title = None 195 title = None
132 date_str = None 196 date_str = None
133 slug = None 197 slug = None
198 tags = []
134 is_draft = False 199 is_draft = False
135 200
136 with org_path.open("r", encoding="utf-8") as f: 201 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):
154 slug = m.group(1).strip() 219 slug = m.group(1).strip()
155 continue 220 continue
156 221
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
157 m = header_patterns["draft"].match(line) 229 m = header_patterns["draft"].match(line)
158 if m: 230 if m:
159 draft_value = m.group(1).strip().lower() 231 draft_value = m.group(1).strip().lower()
@@ -184,16 +256,20 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
184 "date_obj": date_obj, 256 "date_obj": date_obj,
185 "date_full": date_full, 257 "date_full": date_full,
186 "slug": slug, 258 "slug": slug,
259 "tags": tags,
187 } 260 }
188 ) 261 )
189 262
190 # Sort posts by date (newest first)
191 posts.sort(key=lambda x: x["date_obj"], reverse=True) 263 posts.sort(key=lambda x: x["date_obj"], reverse=True)
264 return posts
192 265
193 # Take the top `num_posts`
194 recent = posts[:num_posts]
195 266
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
197 lines = [] 273 lines = []
198 for post in recent: 274 for post in recent:
199 lines.append('\t<li class="post-list-item">') 275 lines.append('\t<li class="post-list-item">')
@@ -384,15 +460,15 @@ def inject_blog_year_separators(blog_index_path="./.build/blog/index.html"):
384 print(f"Blog year separators injected into {blog_index_path}") 460 print(f"Blog year separators injected into {blog_index_path}")
385 461
386 462
387def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): 463def get_tags_html(content_dir="./content/blog"):
388 """ 464 """
389 Scans all blog org files for #+filetags, then writes .build/tags/index.html 465 Build the tag index HTML snippet for the rendered tags template.
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.
392 """ 466 """
393 TAG_ORDER = [ 467 preferred_tag_order = [
394 "audit", 468 "audit",
395 "emacs", 469 "emacs",
470 "development",
471 "ios",
396 "linux", 472 "linux",
397 "personal", 473 "personal",
398 "privacy", 474 "privacy",
@@ -401,82 +477,32 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
401 "web", 477 "web",
402 ] 478 ]
403 479
404 header_patterns = { 480 tag_map = {}
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 }
411 481
412 tag_map = {tag: [] for tag in TAG_ORDER} 482 for post in get_blog_posts(content_dir):
413 483 for tag in post["tags"]:
414 for org_path in Path(content_dir).glob("*.org"): 484 tag_map.setdefault(tag, []).append(
415 title = date_str = slug = None 485 {
416 tags = [] 486 "title": post["title"],
417 is_draft = False 487 "slug": post["slug"],
418 488 "date_obj": post["date_obj"],
419 with org_path.open("r", encoding="utf-8") as f: 489 "date_str": post["date_str"],
420 for line in f: 490 }
421 if not title: 491 )
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
456 492
457 for tag in tags: 493 ordered_tags = [tag for tag in preferred_tag_order if tag in tag_map]
458 if tag in tag_map: 494 ordered_tags.extend(sorted(tag for tag in tag_map if tag not in preferred_tag_order))
459 tag_map[tag].append(
460 {
461 "title": title,
462 "slug": slug,
463 "date_obj": date_obj,
464 "date_str": date_str,
465 }
466 )
467 495
468 for tag in tag_map: 496 for tag in ordered_tags:
469 tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True) 497 tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True)
470 498
471 # Build TOC
472 toc_items = "".join( 499 toc_items = "".join(
473 f'<li><a href="#{tag}">{tag}</a> <span class="tag-count">({len(tag_map[tag])})</span></li>' 500 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
475 ) 502 )
476 503
477 # Build sections
478 sections = [] 504 sections = []
479 for tag in TAG_ORDER: 505 for tag in ordered_tags:
480 posts = tag_map[tag] 506 posts = tag_map[tag]
481 items = "\n".join( 507 items = "\n".join(
482 f'<li class="post-list-item">' 508 f'<li class="post-list-item">'
@@ -489,48 +515,19 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
489 f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>' 515 f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>'
490 ) 516 )
491 517
492 html = f"""<!doctype html> 518 return f'<ul class="tag-toc">{toc_items}</ul>\n' + "".join(
493<html lang=en-us> 519 f"<section>{section}</section>" for section in sections
494<head> 520 )
495<meta charset=utf-8> 521
496<title>cleberg.net</title> 522
497<meta name=viewport content="width=device-width,initial-scale=1"> 523def generate_tags_page(content_dir="./content/blog", build_dir="./.build"):
498<meta name=author content="Christian Cleberg <hello@cleberg.net>"> 524 """
499<meta name=description content="Stillness amidst the chaos."> 525 Render the tags page using the shared template structure.
500<link rel=stylesheet href=/styles.min.css> 526 """
501<link rel=icon href=data:,> 527 tags_html = get_tags_html(content_dir)
502</head> 528 out_path = Path(build_dir) / "tags" / "index.html"
503<body> 529 out_path.parent.mkdir(parents=True, exist_ok=True)
504<a href=#main-content class=skip-link>Skip to content</a> 530 out_path.write_text(render_tags_page_html(tags_html), encoding="utf-8")
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")
534 print(f"Tags page written to {out_path}") 531 print(f"Tags page written to {out_path}")
535 532
536 533
@@ -583,7 +580,7 @@ def main():
583 minify_css(css_src, css_min) 580 minify_css(css_src, css_min)
584 run_emacs_publish(dev_mode=False) 581 run_emacs_publish(dev_mode=False)
585 copy_org_sources() 582 copy_org_sources()
586 update_index_html(html_snippet) 583 update_marked_section(html_snippet)
587 inject_blog_year_separators() 584 inject_blog_year_separators()
588 generate_tags_page() 585 generate_tags_page()
589 # minify_html("./.build/index.html", "./.build/index.html") 586 # minify_html("./.build/index.html", "./.build/index.html")
@@ -599,7 +596,7 @@ def main():
599 minify_css(css_src, css_min) 596 minify_css(css_src, css_min)
600 run_emacs_publish(dev_mode=True) 597 run_emacs_publish(dev_mode=True)
601 copy_org_sources() 598 copy_org_sources()
602 update_index_html(html_snippet) 599 update_marked_section(html_snippet)
603 inject_blog_year_separators() 600 inject_blog_year_separators()
604 generate_tags_page() 601 generate_tags_page()
605 minify_html("./.build/index.html", "./.build/index.html") 602 minify_html("./.build/index.html", "./.build/index.html")
content/blog/2026-03-30-hutch-ios.org +1
@@ -2,6 +2,7 @@
2#+title: Announcing Hutch: An iOS Client for Sourcehut 2#+title: Announcing Hutch: An iOS Client for Sourcehut
3#+description: Announcing Hutch, an iOS client for Sourcehut. Including links to project resources, features, and roadmap. 3#+description: Announcing Hutch, an iOS client for Sourcehut. Including links to project resources, features, and roadmap.
4#+slug: hutch-ios 4#+slug: hutch-ios
5#+filetags: :development:
5 6
6* Hutch 7* Hutch
7 8
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 %}