Commit 195e1d0ff1
Unsigned
Layout: unified · split
build.py +18 −216
| @@ -32,7 +32,6 @@ import sys | ||
| 32 | 32 | from datetime import datetime |
| 33 | 33 | from html import escape |
| 34 | 34 | from pathlib import Path |
| 35 | from urllib.parse import quote | |
| 36 | 35 | |
| 37 | 36 | SITE_TEMPLATE_VARS = { |
| 38 | 37 | "site_name": "cleberg.net", |
| @@ -51,66 +50,6 @@ def run_ruff(): | ||
| 51 | 50 | sys.exit(1) |
| 52 | 51 | |
| 53 | 52 | |
| 54 | def update_marked_section( | |
| 55 | html_snippet, | |
| 56 | template_path="./.build/index.html", | |
| 57 | begin_marker="<!-- BEGIN_POSTS -->", | |
| 58 | end_marker="<!-- END_POSTS -->", | |
| 59 | ): | |
| 60 | """ | |
| 61 | Read the file at `template_path`, replace everything between `begin_marker` | |
| 62 | and `end_marker` with the provided html_snippet, and write the updated | |
| 63 | content back to the same file. | |
| 64 | """ | |
| 65 | with open(template_path, "r", encoding="utf-8") as f: | |
| 66 | content = f.read() | |
| 67 | ||
| 68 | # Find the indices of the markers | |
| 69 | begin_index = content.find(begin_marker) | |
| 70 | end_index = content.find(end_marker) | |
| 71 | ||
| 72 | if begin_index == -1 or end_index == -1: | |
| 73 | raise ValueError(f"Markers not found in {template_path}") | |
| 74 | ||
| 75 | # Compute insertion points: after the end of begin_marker line, before end_marker | |
| 76 | # Include the newline after BEGIN_POSTS | |
| 77 | insert_start = begin_index + len(begin_marker) | |
| 78 | # Ensure we capture the newline character if present | |
| 79 | if content[insert_start : insert_start + 1] == "\n": | |
| 80 | insert_start += 1 | |
| 81 | ||
| 82 | # If there is a newline before END_POSTS, trim trailing whitespace from snippet block | |
| 83 | # We will preserve indentation of BEGIN_POSTS line | |
| 84 | indent = "" | |
| 85 | # Determine the indentation by looking at characters after the newline that follows begin_marker | |
| 86 | lines_after_begin = content[begin_index:].splitlines(True) | |
| 87 | if len(lines_after_begin) > 1: | |
| 88 | # The second line starts with the indentation to preserve | |
| 89 | second_line = lines_after_begin[1] | |
| 90 | indent = "" | |
| 91 | for ch in second_line: | |
| 92 | if ch.isspace(): | |
| 93 | indent += ch | |
| 94 | else: | |
| 95 | break | |
| 96 | ||
| 97 | # Prepare the replacement block: indent each line of html_snippet | |
| 98 | snippet_lines = html_snippet.splitlines() | |
| 99 | indented_snippet = "\n".join(indent + line for line in snippet_lines) + "\n" | |
| 100 | ||
| 101 | # Compute the position just before end_marker (excluding any preceding whitespace/newline) | |
| 102 | end_line_start = content.rfind("\n", 0, end_index) | |
| 103 | if end_line_start == -1: | |
| 104 | end_line_start = end_index | |
| 105 | ||
| 106 | # Construct the new content | |
| 107 | new_content = content[:insert_start] + indented_snippet + content[end_line_start:] | |
| 108 | ||
| 109 | # Write back to index.html | |
| 110 | with open(template_path, "w", encoding="utf-8") as f: | |
| 111 | f.write(new_content) | |
| 112 | ||
| 113 | ||
| 114 | 53 | def render_base_template(main_html, subtitle="", title=None): |
| 115 | 54 | """ |
| 116 | 55 | Render a small subset of the site's shared templates for Python-generated |
| @@ -257,24 +196,6 @@ def get_blog_posts(content_dir="./content/blog"): | ||
| 257 | 196 | return posts |
| 258 | 197 | |
| 259 | 198 | |
| 260 | def get_recent_posts_html(content_dir="./content/blog", num_posts=3): | |
| 261 | """ | |
| 262 | Return an HTML snippet for the `num_posts` most recent blog posts. | |
| 263 | """ | |
| 264 | recent = get_blog_posts(content_dir)[:num_posts] | |
| 265 | ||
| 266 | lines = [] | |
| 267 | for post in recent: | |
| 268 | lines.append('\t<li class="post-list-item">') | |
| 269 | lines.append( | |
| 270 | f'\t\t<time datetime="{post["date_str"]}">{post["date_full"]}</time>' | |
| 271 | ) | |
| 272 | lines.append(f'\t\t<a href="/blog/{post["slug"]}.html">{post["title"]}</a>') | |
| 273 | lines.append("\t</li>") | |
| 274 | ||
| 275 | return "\n".join(lines) | |
| 276 | ||
| 277 | ||
| 278 | 199 | def prompt(prompt_text): |
| 279 | 200 | try: |
| 280 | 201 | return input(prompt_text).strip() |
| @@ -303,20 +224,6 @@ def minify_css(src_css, dest_css): | ||
| 303 | 224 | sys.exit(1) |
| 304 | 225 | |
| 305 | 226 | |
| 306 | def minify_html(src_html, dest_html): | |
| 307 | print(f"Minifying HTML: {src_html} → {dest_html}") | |
| 308 | result = subprocess.run( | |
| 309 | ["minify", "-o", str(dest_html), str(src_html)], | |
| 310 | capture_output=True, | |
| 311 | text=True, | |
| 312 | check=False, | |
| 313 | ) | |
| 314 | if result.returncode != 0: | |
| 315 | print("Error during HTML minification:") | |
| 316 | print(result.stderr, file=sys.stderr) | |
| 317 | sys.exit(1) | |
| 318 | ||
| 319 | ||
| 320 | 227 | def rewrite_img_urls(build_dir=".build"): |
| 321 | 228 | """ |
| 322 | 229 | Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the |
| @@ -342,31 +249,32 @@ def rewrite_img_urls(build_dir=".build"): | ||
| 342 | 249 | print(f"Rewrote {count} img.cleberg.net references to /img/") |
| 343 | 250 | |
| 344 | 251 | |
| 345 | def run_emacs_publish(dev_mode=True): | |
| 346 | mode = "development" if dev_mode else "production" | |
| 347 | print(f"Running Emacs publish script ({mode})...") | |
| 252 | def run_orgo_build(dev_mode=True): | |
| 253 | """ | |
| 254 | Build the site with orgo. | |
| 255 | ||
| 256 | Replaces the weblorg/Emacs publish. orgo reads content/orgo.toml, so the routes, | |
| 257 | templates and collections that used to live in publish.el live there now. Four of the | |
| 258 | steps this script used to perform afterwards are gone with it: the blog index groups | |
| 259 | itself by year, the tags page is a collection, the recent-posts list is generated from | |
| 260 | the blog collection, and the sitemap is written by orgo once base_url is set. | |
| 348 | 261 | |
| 262 | What is left around it is what orgo does not do: minifying CSS, copying the org | |
| 263 | sources for readers who want them, and rewriting image URLs for the onion. | |
| 264 | """ | |
| 265 | print("Building with orgo...") | |
| 349 | 266 | result = subprocess.run( |
| 350 | ["emacs", "--script", "publish.el"], | |
| 267 | ["orgo", "build", "content", "-o", ".build", "--strict"], | |
| 351 | 268 | stdout=subprocess.PIPE, |
| 352 | 269 | stderr=subprocess.STDOUT, |
| 353 | 270 | text=True, |
| 354 | 271 | check=False, |
| 355 | 272 | ) |
| 356 | ||
| 273 | print(result.stdout, end="") | |
| 357 | 274 | if result.returncode != 0: |
| 358 | print("Error running publish.el output:") | |
| 359 | print(result.stdout) | |
| 275 | print("orgo build failed", file=sys.stderr) | |
| 360 | 276 | sys.exit(1) |
| 361 | 277 | |
| 362 | annoying_file = Path(".build/cleberg-net.html") | |
| 363 | if annoying_file.exists(): | |
| 364 | os.remove(annoying_file) | |
| 365 | else: | |
| 366 | print( | |
| 367 | "Warning: .build/cleberg-net.html not found, but Emacs exited successfully." | |
| 368 | ) | |
| 369 | ||
| 370 | 278 | |
| 371 | 279 | def copy_org_sources(content_dir="./content", build_dir="./.build/org"): |
| 372 | 280 | print(f"Copying org sources: {content_dir} → {build_dir}") |
| @@ -395,90 +303,6 @@ def copy_org_sources(content_dir="./content", build_dir="./.build/org"): | ||
| 395 | 303 | print(f" {src_path.name} → {dest_name}") |
| 396 | 304 | |
| 397 | 305 | |
| 398 | def generate_sitemap(build_dir=".build", base_url="https://cleberg.net"): | |
| 399 | """ | |
| 400 | Generates a sitemap.xml based on contents of the .build directory. | |
| 401 | Only includes .html files (except 404.html). | |
| 402 | """ | |
| 403 | sitemap_entries = [] | |
| 404 | for root, dirs, files in os.walk(build_dir): | |
| 405 | for filename in files: | |
| 406 | if filename.endswith(".html") and filename != "404.html": | |
| 407 | full_path = os.path.join(root, filename) | |
| 408 | rel_path = os.path.relpath(full_path, build_dir) | |
| 409 | url_path = "/" + quote(rel_path.replace(os.sep, "/")) | |
| 410 | # Remove index.html for cleaner URLs | |
| 411 | if url_path.endswith("/index.html"): | |
| 412 | url_path = url_path[:-10] or "/" | |
| 413 | elif url_path == "/index.html": | |
| 414 | url_path = "/" | |
| 415 | loc = f"{base_url}{url_path}" | |
| 416 | ||
| 417 | # Last modified time | |
| 418 | lastmod = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime( | |
| 419 | "%Y-%m-%d" | |
| 420 | ) | |
| 421 | ||
| 422 | sitemap_entries.append(f""" <url> | |
| 423 | <loc>{loc}</loc> | |
| 424 | <lastmod>{lastmod}</lastmod> | |
| 425 | </url>""") | |
| 426 | ||
| 427 | sitemap_xml = f"""<?xml version="1.0" encoding="UTF-8"?> | |
| 428 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> | |
| 429 | {os.linesep.join(sitemap_entries)} | |
| 430 | </urlset> | |
| 431 | """ | |
| 432 | # Write to .build/sitemap.xml | |
| 433 | sitemap_path = os.path.join(build_dir, "sitemap.xml") | |
| 434 | with open(sitemap_path, "w", encoding="utf-8") as f: | |
| 435 | f.write(sitemap_xml) | |
| 436 | print(f"Sitemap generated at {sitemap_path} with {len(sitemap_entries)} entries.") | |
| 437 | ||
| 438 | ||
| 439 | def inject_blog_year_separators(blog_index_path="./.build/blog/index.html"): | |
| 440 | """ | |
| 441 | Post-processes the rendered blog index to inject year separator <li> elements | |
| 442 | between groups of posts. Weblorg/templatel doesn't support mutable loop state, | |
| 443 | so this runs after the HTML is generated. | |
| 444 | ||
| 445 | Finds each <li class="post-list-item"> that contains a <time datetime="YYYY-MM-DD">, | |
| 446 | and inserts <li class="post-list-year">YYYY</li> before the first post of each year. | |
| 447 | """ | |
| 448 | path = Path(blog_index_path) | |
| 449 | if not path.exists(): | |
| 450 | print(f"Warning: {blog_index_path} not found, skipping year separators.") | |
| 451 | return | |
| 452 | ||
| 453 | content = path.read_text(encoding="utf-8") | |
| 454 | ||
| 455 | # Match each post list item, capturing the date and the full element | |
| 456 | item_pattern = re.compile( | |
| 457 | r'(<li class="post-list-item">.*?</li>)', | |
| 458 | re.DOTALL, | |
| 459 | ) | |
| 460 | date_pattern = re.compile(r"datetime=['\"]?(\d{4})-\d{2}-\d{2}['\"]?") | |
| 461 | ||
| 462 | current_year = None | |
| 463 | ||
| 464 | def replace_item(m): | |
| 465 | nonlocal current_year | |
| 466 | item_html = m.group(1) | |
| 467 | date_match = date_pattern.search(item_html) | |
| 468 | if not date_match: | |
| 469 | return item_html | |
| 470 | year = date_match.group(1) | |
| 471 | if year != current_year: | |
| 472 | current_year = year | |
| 473 | separator = f'<li class="post-list-year">{year}</li>' | |
| 474 | return f"{separator}\n{item_html}" | |
| 475 | return item_html | |
| 476 | ||
| 477 | new_content = item_pattern.sub(replace_item, content) | |
| 478 | path.write_text(new_content, encoding="utf-8") | |
| 479 | print(f"Blog year separators injected into {blog_index_path}") | |
| 480 | ||
| 481 | ||
| 482 | 306 | def get_tags_html(content_dir="./content/blog"): |
| 483 | 307 | """ |
| 484 | 308 | Build the tag index HTML snippet for the rendered tags template. |
| @@ -541,17 +365,6 @@ def get_tags_html(content_dir="./content/blog"): | ||
| 541 | 365 | ) |
| 542 | 366 | |
| 543 | 367 | |
| 544 | def generate_tags_page(content_dir="./content/blog", build_dir="./.build"): | |
| 545 | """ | |
| 546 | Render the tags page using the shared template structure. | |
| 547 | """ | |
| 548 | tags_html = get_tags_html(content_dir) | |
| 549 | out_path = Path(build_dir) / "tags" / "index.html" | |
| 550 | out_path.parent.mkdir(parents=True, exist_ok=True) | |
| 551 | out_path.write_text(render_tags_page_html(tags_html), encoding="utf-8") | |
| 552 | print(f"Tags page written to {out_path}") | |
| 553 | ||
| 554 | ||
| 555 | 368 | def deploy_to_server(build_dir, server): |
| 556 | 369 | remote_path = f"{server}:/var/www/cleberg.net/" |
| 557 | 370 | print(f"Deploying .build/ → {remote_path}") |
| @@ -584,7 +397,6 @@ def main(): | ||
| 584 | 397 | env = os.environ.get("ENV", "").casefold() |
| 585 | 398 | if env != "prod": |
| 586 | 399 | run_ruff() |
| 587 | html_snippet = get_recent_posts_html("./content/blog", num_posts=3) | |
| 588 | 400 | |
| 589 | 401 | build_dir = Path(".build") |
| 590 | 402 | theme_dir = Path("theme/static") |
| @@ -599,14 +411,9 @@ def main(): | ||
| 599 | 411 | if build: |
| 600 | 412 | remove_build_directory(build_dir) |
| 601 | 413 | minify_css(css_src, css_min) |
| 602 | run_emacs_publish(dev_mode=False) | |
| 414 | run_orgo_build(dev_mode=False) | |
| 603 | 415 | copy_org_sources() |
| 604 | update_marked_section(html_snippet) | |
| 605 | inject_blog_year_separators() | |
| 606 | generate_tags_page() | |
| 607 | 416 | rewrite_img_urls(build_dir) |
| 608 | # minify_html("./.build/index.html", "./.build/index.html") | |
| 609 | generate_sitemap() | |
| 610 | 417 | if deploy: |
| 611 | 418 | print("Deploying to production...") |
| 612 | 419 | deploy_to_server(build_dir, "homelab") |
| @@ -616,13 +423,8 @@ def main(): | ||
| 616 | 423 | if build: |
| 617 | 424 | remove_build_directory(build_dir) |
| 618 | 425 | minify_css(css_src, css_min) |
| 619 | run_emacs_publish(dev_mode=True) | |
| 426 | run_orgo_build(dev_mode=True) | |
| 620 | 427 | copy_org_sources() |
| 621 | update_marked_section(html_snippet) | |
| 622 | inject_blog_year_separators() | |
| 623 | generate_tags_page() | |
| 624 | minify_html("./.build/index.html", "./.build/index.html") | |
| 625 | generate_sitemap() | |
| 626 | 428 | if deploy: |
| 627 | 429 | start_dev_server(build_dir) |
| 628 | 430 | |