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 | import shutil | 29 | import shutil |
| 30 | import subprocess | 30 | import subprocess |
| 31 | import sys | 31 | import sys |
| 32 | from html import escape | ||
| 32 | from urllib.parse import quote | 33 | from urllib.parse import quote |
| 33 | from datetime import datetime | 34 | from datetime import datetime |
| 34 | from pathlib import Path | 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 | def run_ruff(): | 45 | def 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 | ||
| 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 | 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 | ||
| 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, | 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. | 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 | 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 | 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 | 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 | ||
| 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 | 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"> | 523 | def 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>· | ||
| 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") | ||
| 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 %} | ||