Commit dcfa93fc08

dcfa93fc083780c03d516b77052b2389da667e13

parent: e8577d98fa

Unsigned

cmc <hello@cleberg.net> · 2025-06-04 01:03 UTC

feat: upgrade build script to dynamically create latest posts section

Layout: unified · split

build.py +168 −5
@@ -1,9 +1,174 @@
11#!/usr/bin/env python3
22import os
3import re
34import shutil
45import subprocess
56import sys
67from pathlib import Path
8from datetime import datetime
9
10def update_index_html(html_snippet, template_path="./theme/templates/index.html"):
11 """
12 Read the index.html file at `template_path`, replace everything between
13 <!-- BEGIN_POSTS --> and <!-- END_POSTS --> with the provided html_snippet,
14 and write the updated content back to the same file.
15 """
16 # Read the current contents of index.html
17 with open(template_path, "r", encoding="utf-8") as f:
18 content = f.read()
19
20 begin_marker = "<!-- BEGIN_POSTS -->"
21 end_marker = "<!-- END_POSTS -->"
22
23 # Find the indices of the markers
24 begin_index = content.find(begin_marker)
25 end_index = content.find(end_marker)
26
27 if begin_index == -1 or end_index == -1:
28 raise ValueError(f"Markers not found in {template_path}")
29
30 # Compute insertion points: after the end of begin_marker line, before end_marker
31 # Include the newline after BEGIN_POSTS
32 insert_start = begin_index + len(begin_marker)
33 # Ensure we capture the newline character if present
34 if content[insert_start:insert_start+1] == "\n":
35 insert_start += 1
36
37 # If there is a newline before END_POSTS, trim trailing whitespace from snippet block
38 # We will preserve indentation of BEGIN_POSTS line
39 indent = ""
40 # Determine the indentation by looking at characters after the newline that follows begin_marker
41 lines_after_begin = content[begin_index:].splitlines(True)
42 if len(lines_after_begin) > 1:
43 # The second line starts with the indentation to preserve
44 second_line = lines_after_begin[1]
45 indent = ""
46 for ch in second_line:
47 if ch.isspace():
48 indent += ch
49 else:
50 break
51
52 # Prepare the replacement block: indent each line of html_snippet
53 snippet_lines = html_snippet.splitlines()
54 indented_snippet = "\n".join(indent + line for line in snippet_lines) + "\n"
55
56 # Compute the position just before end_marker (excluding any preceding whitespace/newline)
57 end_line_start = content.rfind("\n", 0, end_index)
58 if end_line_start == -1:
59 end_line_start = end_index
60
61 # Construct the new content
62 new_content = (
63 content[:insert_start]
64 + indented_snippet
65 + content[end_line_start:]
66 )
67
68 # Write back to index.html
69 with open(template_path, "w", encoding="utf-8") as f:
70 f.write(new_content)
71
72def get_recent_posts_html(content_dir="./content/blog", num_posts=3):
73 """
74 Scan the `content_dir` for .org blog post files, extract their headers,
75 and return an HTML snippet for the `num_posts` most recent posts.
76
77 Expects each .org file to contain headers of the form:
78 #+title: Post Title
79 #+date: <YYYY-MM-DD Day HH:MM:SS>
80 #+slug: post-slug
81 #+filetags: :tag1:tag2:tag3:
82
83 Returns a string containing the section with the three most recent posts,
84 formatted like the snippet in the prompt.
85 """
86 posts = []
87
88 header_patterns = {
89 "title": re.compile(r'^#\+title:\s*(.+)$', re.IGNORECASE),
90 "date": re.compile(r'^#\+date:\s*<(\d{4}-\d{2}-\d{2})'),
91 "slug": re.compile(r'^#\+slug:\s*(.+)$', re.IGNORECASE),
92 "filetags": re.compile(r'^#\+filetags:\s*(.+)$', re.IGNORECASE),
93 }
94
95 for org_path in Path(content_dir).glob("*.org"):
96 title = None
97 date_str = None
98 slug = None
99 tags = []
100
101 with org_path.open("r", encoding="utf-8") as f:
102 for line in f:
103 if title is None:
104 m = header_patterns["title"].match(line)
105 if m:
106 title = m.group(1).strip()
107 continue
108
109 if date_str is None:
110 m = header_patterns["date"].match(line)
111 if m:
112 # date_str is just YYYY-MM-DD
113 date_str = m.group(1)
114 continue
115
116 if slug is None:
117 m = header_patterns["slug"].match(line)
118 if m:
119 slug = m.group(1).strip()
120 continue
121
122 if not tags:
123 m = header_patterns["filetags"].match(line)
124 if m:
125 # filetags are in the form ":tag1:tag2:tag3:"
126 raw = m.group(1).strip()
127 # split on ":" and filter out empty strings
128 tags = [t for t in raw.split(":") if t]
129 continue
130
131 # Stop scanning once we have all required fields
132 if title and date_str and slug and tags:
133 break
134
135 if title and date_str and slug:
136 try:
137 date_obj = datetime.strptime(date_str, "%Y-%m-%d")
138 except ValueError:
139 # Skip files with invalid date format
140 continue
141
142 posts.append({
143 "title": title,
144 "date_str": date_str,
145 "date_obj": date_obj,
146 "slug": slug,
147 "tags": tags,
148 })
149
150 # Sort posts by date (newest first)
151 posts.sort(key=lambda x: x["date_obj"], reverse=True)
152
153 # Take the top `num_posts`
154 recent = posts[:num_posts]
155
156 # Build HTML lines
157 lines = []
158 for post in recent:
159 lines.append('\t<div class="post">')
160 lines.append(f'\t\t<time datetime="{post["date_str"]}">{post["date_str"]}</time>')
161 lines.append('\t\t<div class="post-content">')
162 lines.append(f'\t\t\t<a href="/blog/{post["slug"]}.html">{post["title"]}</a>')
163 if post["tags"]:
164 lines.append('\t\t\t<div class="post-tags">')
165 for tag in post["tags"]:
166 lines.append(f'\t\t\t\t<span class="tag">{tag}</span>')
167 lines.append('\t\t\t</div>')
168 lines.append('\t\t</div>')
169 lines.append('\t</div>')
170
171 return "\n".join(lines)
7172
8173
9174def prompt(prompt_text):
@@ -87,16 +252,14 @@ def start_dev_server(build_dir):
87252
88253
89254def main():
255 html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
256 update_index_html(html_snippet)
257
90258 build_dir = Path(".build")
91259 theme_dir = Path("theme/static")
92260 css_src = theme_dir / "styles.css"
93261 css_min = theme_dir / "styles.min.css"
94262
95 answer = prompt("Did you update the 'Recent Blog Posts' section? [yn] ")
96 if not answer.lower().startswith("y"):
97 print("Please update the 'Recent Blog Posts' section before publishing!")
98 sys.exit(0)
99
100263 env = os.environ.get("ENV", "").lower()
101264 if env == "prod":
102265 print("Environment: Production")
theme/templates/index.html +3 −39
@@ -8,45 +8,9 @@ sub rsa4096 2022-11-16 [E]</pre>
88</section>
99<section>
1010 <h2>Recent Blog Posts</h2>
11
12
13 <div class="post">
14 <time datetime="2025-06-03 15:26:05">2025-06-02</time>
15 <div class="post-content">
16 <a href="/blog/private-ios-apps.html">Selection of Privacy-Focused iOS Applications for Minimalist Users</a>
17 <div class="post-tags">
18 <span class="tag">ios</span>
19 <span class="tag">privacy</span>
20 <span class="tag">security</span>
21 </div>
22 </div>
23 </div>
24
25 <div class="post">
26 <time datetime="2025-05-30 10:53:28">2025-05-30</time>
27 <div class="post-content">
28 <a href="/blog/it-audit-career.html">Career Advancement through Mastery of Emerging Technologies in IT Audit</a>
29 <div class="post-tags">
30 <span class="tag">audit</span>
31 <span class="tag">technology</span>
32 <span class="tag">career</span>
33 </div>
34 </div>
35 </div>
36
37 <div class="post">
38 <time datetime="2025-05-02 Fri 21:10:00">2025-05-02</time>
39 <div class="post-content">
40 <a href="/blog/asahi-linux.html">Performance and Compatibility Report of Asahi Linux on Apple M2 MacBook Pro 16"</a>
41 <div class="post-tags">
42 <span class="tag">mac</span>
43 <span class="tag">apple</span>
44 <span class="tag">linux</span>
45 </div>
46 </div>
47 </div>
48
49 <br />
11 <!-- BEGIN_POSTS -->
12 <!-- END_POSTS -->
13 <br>
5014 <a href="/blog/">All Posts →</a>
5115</section>
5216