Commit 36e16946cf
Verified · cmc
Layout: unified · split
__pycache__/build.cpython-314.pyc added
Binary file not shown.
build.py +133 −136
| @@ -29,11 +29,19 @@ import re | ||
| 29 | 29 | import shutil |
| 30 | 30 | import subprocess |
| 31 | 31 | import sys |
| 32 | from html import escape | |
| 32 | 33 | from urllib.parse import quote |
| 33 | 34 | from datetime import datetime |
| 34 | 35 | from pathlib import Path |
| 35 | 36 | |
| 36 | 37 | |
| 38 | SITE_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 | ||
| 37 | 45 | def run_ruff(): |
| 38 | 46 | print("Running ruff...") |
| 39 | 47 | for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]: |
| @@ -46,19 +54,20 @@ def run_ruff(): | ||
| 46 | 54 | sys.exit(1) |
| 47 | 55 | |
| 48 | 56 | |
| 49 | def update_index_html(html_snippet, template_path="./.build/index.html"): | |
| 57 | def 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 | |
| 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. | |
| 54 | 67 | """ |
| 55 | # Read the current contents of index.html | |
| 56 | 68 | with open(template_path, "r", encoding="utf-8") as f: |
| 57 | 69 | content = f.read() |
| 58 | 70 | |
| 59 | begin_marker = "<!-- BEGIN_POSTS -->" | |
| 60 | end_marker = "<!-- END_POSTS -->" | |
| 61 | ||
| 62 | 71 | # Find the indices of the markers |
| 63 | 72 | begin_index = content.find(begin_marker) |
| 64 | 73 | end_index = content.find(end_marker) |
| @@ -105,18 +114,72 @@ def update_index_html(html_snippet, template_path="./.build/index.html"): | ||
| 105 | 114 | f.write(new_content) |
| 106 | 115 | |
| 107 | 116 | |
| 108 | def get_recent_posts_html(content_dir="./content/blog", num_posts=3): | |
| 117 | def 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 | ||
| 160 | def render_tags_page_html(tags_html): | |
| 109 | 161 | """ |
| 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) | |
| 112 | 176 | |
| 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 - ") | |
| 117 | 178 | |
| 118 | Returns a string containing the section with the three most recent posts, | |
| 119 | formatted like the snippet in the prompt. | |
| 179 | ||
| 180 | def get_blog_posts(content_dir="./content/blog"): | |
| 181 | """ | |
| 182 | Scan blog posts and return normalized metadata for non-draft entries. | |
| 120 | 183 | """ |
| 121 | 184 | posts = [] |
| 122 | 185 | |
| @@ -124,6 +187,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3): | ||
| 124 | 187 | "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE), |
| 125 | 188 | "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"), |
| 126 | 189 | "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE), |
| 190 | "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE), | |
| 127 | 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 | 195 | title = None |
| 132 | 196 | date_str = None |
| 133 | 197 | slug = None |
| 198 | tags = [] | |
| 134 | 199 | is_draft = False |
| 135 | 200 | |
| 136 | 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 | 219 | slug = m.group(1).strip() |
| 155 | 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 | 229 | m = header_patterns["draft"].match(line) |
| 158 | 230 | if m: |
| 159 | 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 | 256 | "date_obj": date_obj, |
| 185 | 257 | "date_full": date_full, |
| 186 | 258 | "slug": slug, |
| 259 | "tags": tags, | |
| 187 | 260 | } |
| 188 | 261 | ) |
| 189 | 262 | |
| 190 | # Sort posts by date (newest first) | |
| 191 | 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 | |
| 267 | def 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 | 273 | lines = [] |
| 198 | 274 | for post in recent: |
| 199 | 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 | 460 | print(f"Blog year separators injected into {blog_index_path}") |
| 385 | 461 | |
| 386 | 462 | |
| 387 | def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): | |
| 463 | def get_tags_html(content_dir="./content/blog"): | |
| 388 | 464 | """ |
| 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. | |
| 392 | 466 | """ |
| 393 | TAG_ORDER = [ | |
| 467 | preferred_tag_order = [ | |
| 394 | 468 | "audit", |
| 395 | 469 | "emacs", |
| 470 | "development", | |
| 471 | "ios", | |
| 396 | 472 | "linux", |
| 397 | 473 | "personal", |
| 398 | 474 | "privacy", |
| @@ -401,82 +477,32 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): | ||
| 401 | 477 | "web", |
| 402 | 478 | ] |
| 403 | 479 | |
| 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 = {} | |
| 411 | 481 | |
| 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 | ) | |
| 456 | 492 | |
| 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)) | |
| 467 | 495 | |
| 468 | for tag in tag_map: | |
| 496 | for tag in ordered_tags: | |
| 469 | 497 | tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True) |
| 470 | 498 | |
| 471 | # Build TOC | |
| 472 | 499 | toc_items = "".join( |
| 473 | 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 | 504 | sections = [] |
| 479 | for tag in TAG_ORDER: | |
| 505 | for tag in ordered_tags: | |
| 480 | 506 | posts = tag_map[tag] |
| 481 | 507 | items = "\n".join( |
| 482 | 508 | f'<li class="post-list-item">' |
| @@ -489,48 +515,19 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): | ||
| 489 | 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> | |
| 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>· | |
| 523 | <a href=/feed.xml>RSS</a>· | |
| 524 | <a href=http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion/>Onion</a>· | |
| 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 | ||
| 523 | def 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") | |
| 534 | 531 | print(f"Tags page written to {out_path}") |
| 535 | 532 | |
| 536 | 533 | |
| @@ -583,7 +580,7 @@ def main(): | ||
| 583 | 580 | minify_css(css_src, css_min) |
| 584 | 581 | run_emacs_publish(dev_mode=False) |
| 585 | 582 | copy_org_sources() |
| 586 | update_index_html(html_snippet) | |
| 583 | update_marked_section(html_snippet) | |
| 587 | 584 | inject_blog_year_separators() |
| 588 | 585 | generate_tags_page() |
| 589 | 586 | # minify_html("./.build/index.html", "./.build/index.html") |
| @@ -599,7 +596,7 @@ def main(): | ||
| 599 | 596 | minify_css(css_src, css_min) |
| 600 | 597 | run_emacs_publish(dev_mode=True) |
| 601 | 598 | copy_org_sources() |
| 602 | update_index_html(html_snippet) | |
| 599 | update_marked_section(html_snippet) | |
| 603 | 600 | inject_blog_year_separators() |
| 604 | 601 | generate_tags_page() |
| 605 | 602 | minify_html("./.build/index.html", "./.build/index.html") |
content/blog/2026-03-30-hutch-ios.org +1
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | #+title: Announcing Hutch: An iOS Client for Sourcehut |
| 3 | 3 | #+description: Announcing Hutch, an iOS client for Sourcehut. Including links to project resources, features, and roadmap. |
| 4 | 4 | #+slug: hutch-ios |
| 5 | #+filetags: :development: | |
| 5 | 6 | |
| 6 | 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 %} | |