Commit dcfa93fc08
Unsigned
Layout: unified · split
build.py +168 −5
| @@ -1,9 +1,174 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | import os |
| 3 | import re | |
| 3 | 4 | import shutil |
| 4 | 5 | import subprocess |
| 5 | 6 | import sys |
| 6 | 7 | from pathlib import Path |
| 8 | from datetime import datetime | |
| 9 | ||
| 10 | def 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 | ||
| 72 | def 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 | |
| 9 | 174 | def prompt(prompt_text): |
| @@ -87,16 +252,14 @@ def start_dev_server(build_dir): | ||
| 87 | 252 | |
| 88 | 253 | |
| 89 | 254 | def main(): |
| 255 | html_snippet = get_recent_posts_html("./content/blog", num_posts=3) | |
| 256 | update_index_html(html_snippet) | |
| 257 | ||
| 90 | 258 | build_dir = Path(".build") |
| 91 | 259 | theme_dir = Path("theme/static") |
| 92 | 260 | css_src = theme_dir / "styles.css" |
| 93 | 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 | 263 | env = os.environ.get("ENV", "").lower() |
| 101 | 264 | if env == "prod": |
| 102 | 265 | print("Environment: Production") |
theme/templates/index.html +3 −39
| @@ -8,45 +8,9 @@ sub rsa4096 2022-11-16 [E]</pre> | ||
| 8 | 8 | </section> |
| 9 | 9 | <section> |
| 10 | 10 | <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> | |
| 50 | 14 | <a href="/blog/">All Posts →</a> |
| 51 | 15 | </section> |
| 52 | 16 | |