diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 10:45:16 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 10:45:16 -0500 |
| commit | 36e16946cfe11b907e0144ada68228d3c4bf7b97 (patch) | |
| tree | 5d5c08825f0ad22c66d92d242e61f7878a6d829f | |
| parent | b41f62c2851f2ac1b4eeb8acad483c2ef8af9be5 (diff) | |
| download | cleberg.net-36e16946cfe11b907e0144ada68228d3c4bf7b97.tar.gz cleberg.net-36e16946cfe11b907e0144ada68228d3c4bf7b97.tar.bz2 cleberg.net-36e16946cfe11b907e0144ada68228d3c4bf7b97.zip | |
fix: refactor tags page rendering and auto-include new blog tags
| -rw-r--r-- | __pycache__/build.cpython-314.pyc | bin | 0 -> 27588 bytes | |||
| -rw-r--r-- | build.py | 269 | ||||
| -rw-r--r-- | content/blog/2026-03-30-hutch-ios.org | 1 | ||||
| -rw-r--r-- | theme/templates/tags.html | 6 |
4 files changed, 140 insertions, 136 deletions
diff --git a/__pycache__/build.cpython-314.pyc b/__pycache__/build.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..97ccde6 --- /dev/null +++ b/__pycache__/build.cpython-314.pyc @@ -29,11 +29,19 @@ import re import shutil import subprocess import sys +from html import escape from urllib.parse import quote from datetime import datetime from pathlib import Path +SITE_TEMPLATE_VARS = { + "site_name": "cleberg.net", + "site_owner": "Christian Cleberg <[email protected]>", + "site_description": "Stillness amidst the chaos.", +} + + def run_ruff(): print("Running ruff...") for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]: @@ -46,19 +54,20 @@ def run_ruff(): sys.exit(1) -def update_index_html(html_snippet, template_path="./.build/index.html"): +def update_marked_section( + html_snippet, + template_path="./.build/index.html", + begin_marker="<!-- BEGIN_POSTS -->", + end_marker="<!-- END_POSTS -->", +): """ - Read the index.html file at `template_path`, replace everything between - <!-- BEGIN_POSTS --> and <!-- END_POSTS --> with the provided html_snippet, - and write the updated content back to the same file. + Read the file at `template_path`, replace everything between `begin_marker` + and `end_marker` with the provided html_snippet, and write the updated + content back to the same file. """ - # Read the current contents of index.html with open(template_path, "r", encoding="utf-8") as f: content = f.read() - begin_marker = "<!-- BEGIN_POSTS -->" - end_marker = "<!-- END_POSTS -->" - # Find the indices of the markers begin_index = content.find(begin_marker) end_index = content.find(end_marker) @@ -105,18 +114,72 @@ def update_index_html(html_snippet, template_path="./.build/index.html"): f.write(new_content) -def get_recent_posts_html(content_dir="./content/blog", num_posts=3): +def render_base_template(main_html, subtitle="", title=None): + """ + Render a small subset of the site's shared templates for Python-generated + pages that still need to follow the common chrome. + """ + base_template = Path("theme/templates/base.html").read_text(encoding="utf-8") + + if title is None: + title = SITE_TEMPLATE_VARS["site_name"] + + rendered = base_template + rendered = rendered.replace( + "{% block subtitle %}{% endblock %}", + escape(subtitle), + ) + rendered = rendered.replace( + '{% block title %}{{ site_name | default("cleberg.net") }}{% endblock %}', + escape(title), + ) + rendered = rendered.replace( + '{% if site_owner is defined %}<meta name="author" content="{{ site_owner }}">{% endif %}', + f'<meta name="author" content="{escape(SITE_TEMPLATE_VARS["site_owner"])}">', + ) + rendered = rendered.replace( + '{% if site_description is defined %}<meta name="description" content="{{ site_description }}">{% endif %}', + f'<meta name="description" content="{escape(SITE_TEMPLATE_VARS["site_description"])}">', + ) + rendered = rendered.replace( + '{% if site_keywords is defined %}<meta name="keywords" content="{{ site_keywords }}">{% endif %}', + "", + ) + rendered = rendered.replace( + '{{ url_for("static", file="styles.min.css") }}', + "/styles.min.css", + ) + rendered = rendered.replace("{% block meta %}{% endblock %}", "") + rendered = rendered.replace("{% block head %}", "") + rendered = rendered.replace("{% endblock %}", "", 1) + rendered = rendered.replace("{% block main %}{% endblock %}", main_html) + + return rendered + + +def render_tags_page_html(tags_html): """ - Scan the `content_dir` for .org blog post files, extract their headers, - and return an HTML snippet for the `num_posts` most recent posts. + Render the tags page by using the shared tags/base templates instead of a + hand-authored standalone HTML document. + """ + tags_template = Path("theme/templates/tags.html").read_text(encoding="utf-8") + + main_html = tags_template + main_html = main_html.replace('{% extends "base.html" %}', "") + main_html = main_html.replace( + "{% block subtitle %}tags - {% endblock %}", + "", + ) + main_html = main_html.replace("{% block main %}", "") + main_html = main_html.replace("{% endblock %}", "") + main_html = main_html.replace("<!-- BEGIN_TAGS -->\n<!-- END_TAGS -->", tags_html) - Expects each .org file to contain headers of the form: - #+title: Post Title - #+date: <YYYY-MM-DD Day HH:MM:SS> - #+slug: post-slug + return render_base_template(main_html.strip(), subtitle="tags - ") - Returns a string containing the section with the three most recent posts, - formatted like the snippet in the prompt. + +def get_blog_posts(content_dir="./content/blog"): + """ + Scan blog posts and return normalized metadata for non-draft entries. """ posts = [] @@ -124,6 +187,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3): "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE), "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"), "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE), + "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE), "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE), } @@ -131,6 +195,7 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3): title = None date_str = None slug = None + tags = [] is_draft = False 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): slug = m.group(1).strip() continue + if not tags: + m = header_patterns["tags"].match(line) + if m: + raw = m.group(1).strip().strip(":") + tags = [t.strip() for t in raw.split(":") if t.strip()] + continue + m = header_patterns["draft"].match(line) if m: draft_value = m.group(1).strip().lower() @@ -184,16 +256,20 @@ def get_recent_posts_html(content_dir="./content/blog", num_posts=3): "date_obj": date_obj, "date_full": date_full, "slug": slug, + "tags": tags, } ) - # Sort posts by date (newest first) posts.sort(key=lambda x: x["date_obj"], reverse=True) + return posts - # Take the top `num_posts` - recent = posts[:num_posts] - # Build HTML lines +def get_recent_posts_html(content_dir="./content/blog", num_posts=3): + """ + Return an HTML snippet for the `num_posts` most recent blog posts. + """ + recent = get_blog_posts(content_dir)[:num_posts] + lines = [] for post in recent: lines.append('\t<li class="post-list-item">') @@ -384,15 +460,15 @@ def inject_blog_year_separators(blog_index_path="./.build/blog/index.html"): print(f"Blog year separators injected into {blog_index_path}") -def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): +def get_tags_html(content_dir="./content/blog"): """ - Scans all blog org files for #+filetags, then writes .build/tags/index.html - with one section per tag, each post listed under its tags, sorted newest first. - Tags link to anchor IDs on the same page. + Build the tag index HTML snippet for the rendered tags template. """ - TAG_ORDER = [ + preferred_tag_order = [ "audit", "emacs", + "development", + "ios", "linux", "personal", "privacy", @@ -401,82 +477,32 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): "web", ] - header_patterns = { - "title": re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE), - "date": re.compile(r"^#\+date:\s*[\[<](\d{4}-\d{2}-\d{2})"), - "slug": re.compile(r"^#\+slug:\s*(.+)$", re.IGNORECASE), - "tags": re.compile(r"^#\+filetags:\s*(.+)$", re.IGNORECASE), - "draft": re.compile(r"^#\+draft:\s*(.+)$", re.IGNORECASE), - } + tag_map = {} - tag_map = {tag: [] for tag in TAG_ORDER} - - for org_path in Path(content_dir).glob("*.org"): - title = date_str = slug = None - tags = [] - is_draft = False - - with org_path.open("r", encoding="utf-8") as f: - for line in f: - if not title: - m = header_patterns["title"].match(line) - if m: - title = m.group(1).strip() - continue - if not date_str: - m = header_patterns["date"].match(line) - if m: - date_str = m.group(1) - continue - if not slug: - m = header_patterns["slug"].match(line) - if m: - slug = m.group(1).strip() - continue - if not tags: - m = header_patterns["tags"].match(line) - if m: - raw = m.group(1).strip().strip(":") - tags = [t.strip() for t in raw.split(":") if t.strip()] - continue - m = header_patterns["draft"].match(line) - if m and m.group(1).strip().lower() != "nil": - is_draft = True - break - if title and date_str and slug and tags: - break - - if is_draft or not (title and date_str and slug): - continue - - try: - date_obj = datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - continue + for post in get_blog_posts(content_dir): + for tag in post["tags"]: + tag_map.setdefault(tag, []).append( + { + "title": post["title"], + "slug": post["slug"], + "date_obj": post["date_obj"], + "date_str": post["date_str"], + } + ) - for tag in tags: - if tag in tag_map: - tag_map[tag].append( - { - "title": title, - "slug": slug, - "date_obj": date_obj, - "date_str": date_str, - } - ) + ordered_tags = [tag for tag in preferred_tag_order if tag in tag_map] + ordered_tags.extend(sorted(tag for tag in tag_map if tag not in preferred_tag_order)) - for tag in tag_map: + for tag in ordered_tags: tag_map[tag].sort(key=lambda x: x["date_obj"], reverse=True) - # Build TOC toc_items = "".join( f'<li><a href="#{tag}">{tag}</a> <span class="tag-count">({len(tag_map[tag])})</span></li>' - for tag in TAG_ORDER + for tag in ordered_tags ) - # Build sections sections = [] - for tag in TAG_ORDER: + for tag in ordered_tags: posts = tag_map[tag] items = "\n".join( f'<li class="post-list-item">' @@ -489,48 +515,19 @@ def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): f'<h2 id="{tag}">{tag}</h2>\n<ul class="post-list">\n{items}\n</ul>' ) - html = f"""<!doctype html> -<html lang=en-us> -<head> -<meta charset=utf-8> -<title>cleberg.net</title> -<meta name=viewport content="width=device-width,initial-scale=1"> -<meta name=author content="Christian Cleberg <[email protected]>"> -<meta name=description content="Stillness amidst the chaos."> -<link rel=stylesheet href=/styles.min.css> -<link rel=icon href=data:,> -</head> -<body> -<a href=#main-content class=skip-link>Skip to content</a> -<nav aria-label="Site Navigation"> -<ul> -<li><a href=/>Home</a> -<li><a href=/apps/>Apps</a> -<li><a href=/blog/>Blog</a> -<li><a href=/garden/>Garden</a> -<li><a href=/guides/>Guides</a> -<li><a href=/salary/>Salary</a> -<li><a href=/tags/>Tags</a> -</ul> -</nav> -<main id=main-content> -<h1>Tags</h1> -<ul class="tag-toc">{toc_items}</ul> -{"".join(f"<section>{s}</section>" for s in sections)} -</main> -<footer> -<a href=https://sr.ht/~ccleberg/cleberg.net>Source</a>· -<a href=/feed.xml>RSS</a>· -<a href=http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion/>Onion</a>· -<a href=/tips/>Tips</a> -</footer> -</body> -</html>""" - - out_dir = Path(build_dir) / "tags" - out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / "index.html" - out_path.write_text(html, encoding="utf-8") + return f'<ul class="tag-toc">{toc_items}</ul>\n' + "".join( + f"<section>{section}</section>" for section in sections + ) + + +def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): + """ + Render the tags page using the shared template structure. + """ + tags_html = get_tags_html(content_dir) + out_path = Path(build_dir) / "tags" / "index.html" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(render_tags_page_html(tags_html), encoding="utf-8") print(f"Tags page written to {out_path}") @@ -583,7 +580,7 @@ def main(): minify_css(css_src, css_min) run_emacs_publish(dev_mode=False) copy_org_sources() - update_index_html(html_snippet) + update_marked_section(html_snippet) inject_blog_year_separators() generate_tags_page() # minify_html("./.build/index.html", "./.build/index.html") @@ -599,7 +596,7 @@ def main(): minify_css(css_src, css_min) run_emacs_publish(dev_mode=True) copy_org_sources() - update_index_html(html_snippet) + update_marked_section(html_snippet) inject_blog_year_separators() generate_tags_page() minify_html("./.build/index.html", "./.build/index.html") diff --git a/content/blog/2026-03-30-hutch-ios.org b/content/blog/2026-03-30-hutch-ios.org index 915c6cf..822d240 100644 --- a/content/blog/2026-03-30-hutch-ios.org +++ b/content/blog/2026-03-30-hutch-ios.org @@ -2,6 +2,7 @@ #+title: Announcing Hutch: An iOS Client for Sourcehut #+description: Announcing Hutch, an iOS client for Sourcehut. Including links to project resources, features, and roadmap. #+slug: hutch-ios +#+filetags: :development: * Hutch diff --git a/theme/templates/tags.html b/theme/templates/tags.html new file mode 100644 index 0000000..83619d4 --- /dev/null +++ b/theme/templates/tags.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} {% block subtitle %}tags - {% endblock %} {% block main %} +<h1>Tags</h1> +<p>Browse posts by topic.</p> +<!-- BEGIN_TAGS --> +<!-- END_TAGS --> +{% endblock %} |
