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 @@
1#!/usr/bin/env python3 1#!/usr/bin/env python3
2import os 2import os
3import re
3import shutil 4import shutil
4import subprocess 5import subprocess
5import sys 6import sys
6from pathlib import Path 7from 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)
7 172
8 173
9def prompt(prompt_text): 174def prompt(prompt_text):
@@ -87,16 +252,14 @@ def start_dev_server(build_dir):
87 252
88 253
89def main(): 254def main():
255 html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
256 update_index_html(html_snippet)
257
90 build_dir = Path(".build") 258 build_dir = Path(".build")
91 theme_dir = Path("theme/static") 259 theme_dir = Path("theme/static")
92 css_src = theme_dir / "styles.css" 260 css_src = theme_dir / "styles.css"
93 css_min = theme_dir / "styles.min.css" 261 css_min = theme_dir / "styles.min.css"
94 262
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
100 env = os.environ.get("ENV", "").lower() 263 env = os.environ.get("ENV", "").lower()
101 if env == "prod": 264 if env == "prod":
102 print("Environment: Production") 265 print("Environment: Production")
theme/templates/index.html +3 −39
@@ -8,45 +8,9 @@ sub rsa4096 2022-11-16 [E]</pre>
8</section> 8</section>
9<section> 9<section>
10 <h2>Recent Blog Posts</h2> 10 <h2>Recent Blog Posts</h2>
11 11 <!-- BEGIN_POSTS -->
12 12 <!-- END_POSTS -->
13 <div class="post"> 13 <br>
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 />
50 <a href="/blog/">All Posts →</a> 14 <a href="/blog/">All Posts →</a>
51</section> 15</section>
52 16