Commit 195e1d0ff1
Unsigned
Layout: unified · split
build.py +18 −216
| @@ -32,7 +32,6 @@ import sys | |||
| 32 | from datetime import datetime | 32 | from datetime import datetime |
| 33 | from html import escape | 33 | from html import escape |
| 34 | from pathlib import Path | 34 | from pathlib import Path |
| 35 | from urllib.parse import quote | ||
| 36 | 35 | ||
| 37 | SITE_TEMPLATE_VARS = { | 36 | SITE_TEMPLATE_VARS = { |
| 38 | "site_name": "cleberg.net", | 37 | "site_name": "cleberg.net", |
| @@ -51,66 +50,6 @@ def run_ruff(): | |||
| 51 | sys.exit(1) | 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 | def render_base_template(main_html, subtitle="", title=None): | 53 | def render_base_template(main_html, subtitle="", title=None): |
| 115 | """ | 54 | """ |
| 116 | Render a small subset of the site's shared templates for Python-generated | 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 | return posts | 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 | def prompt(prompt_text): | 199 | def prompt(prompt_text): |
| 279 | try: | 200 | try: |
| 280 | return input(prompt_text).strip() | 201 | return input(prompt_text).strip() |
| @@ -303,20 +224,6 @@ def minify_css(src_css, dest_css): | |||
| 303 | sys.exit(1) | 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 | def rewrite_img_urls(build_dir=".build"): | 227 | def rewrite_img_urls(build_dir=".build"): |
| 321 | """ | 228 | """ |
| 322 | Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the | 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 | print(f"Rewrote {count} img.cleberg.net references to /img/") | 249 | print(f"Rewrote {count} img.cleberg.net references to /img/") |
| 343 | 250 | ||
| 344 | 251 | ||
| 345 | def run_emacs_publish(dev_mode=True): | 252 | def run_orgo_build(dev_mode=True): |
| 346 | mode = "development" if dev_mode else "production" | 253 | """ |
| 347 | print(f"Running Emacs publish script ({mode})...") | 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 | result = subprocess.run( | 266 | result = subprocess.run( |
| 350 | ["emacs", "--script", "publish.el"], | 267 | ["orgo", "build", "content", "-o", ".build", "--strict"], |
| 351 | stdout=subprocess.PIPE, | 268 | stdout=subprocess.PIPE, |
| 352 | stderr=subprocess.STDOUT, | 269 | stderr=subprocess.STDOUT, |
| 353 | text=True, | 270 | text=True, |
| 354 | check=False, | 271 | check=False, |
| 355 | ) | 272 | ) |
| 356 | 273 | print(result.stdout, end="") | |
| 357 | if result.returncode != 0: | 274 | if result.returncode != 0: |
| 358 | print("Error running publish.el output:") | 275 | print("orgo build failed", file=sys.stderr) |
| 359 | print(result.stdout) | ||
| 360 | sys.exit(1) | 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 | def copy_org_sources(content_dir="./content", build_dir="./.build/org"): | 279 | def copy_org_sources(content_dir="./content", build_dir="./.build/org"): |
| 372 | print(f"Copying org sources: {content_dir} → {build_dir}") | 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 | print(f" {src_path.name} → {dest_name}") | 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 | def get_tags_html(content_dir="./content/blog"): | 306 | def get_tags_html(content_dir="./content/blog"): |
| 483 | """ | 307 | """ |
| 484 | Build the tag index HTML snippet for the rendered tags template. | 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 | def deploy_to_server(build_dir, server): | 368 | def deploy_to_server(build_dir, server): |
| 556 | remote_path = f"{server}:/var/www/cleberg.net/" | 369 | remote_path = f"{server}:/var/www/cleberg.net/" |
| 557 | print(f"Deploying .build/ → {remote_path}") | 370 | print(f"Deploying .build/ → {remote_path}") |
| @@ -584,7 +397,6 @@ def main(): | |||
| 584 | env = os.environ.get("ENV", "").casefold() | 397 | env = os.environ.get("ENV", "").casefold() |
| 585 | if env != "prod": | 398 | if env != "prod": |
| 586 | run_ruff() | 399 | run_ruff() |
| 587 | html_snippet = get_recent_posts_html("./content/blog", num_posts=3) | ||
| 588 | 400 | ||
| 589 | build_dir = Path(".build") | 401 | build_dir = Path(".build") |
| 590 | theme_dir = Path("theme/static") | 402 | theme_dir = Path("theme/static") |
| @@ -599,14 +411,9 @@ def main(): | |||
| 599 | if build: | 411 | if build: |
| 600 | remove_build_directory(build_dir) | 412 | remove_build_directory(build_dir) |
| 601 | minify_css(css_src, css_min) | 413 | minify_css(css_src, css_min) |
| 602 | run_emacs_publish(dev_mode=False) | 414 | run_orgo_build(dev_mode=False) |
| 603 | copy_org_sources() | 415 | copy_org_sources() |
| 604 | update_marked_section(html_snippet) | ||
| 605 | inject_blog_year_separators() | ||
| 606 | generate_tags_page() | ||
| 607 | rewrite_img_urls(build_dir) | 416 | rewrite_img_urls(build_dir) |
| 608 | # minify_html("./.build/index.html", "./.build/index.html") | ||
| 609 | generate_sitemap() | ||
| 610 | if deploy: | 417 | if deploy: |
| 611 | print("Deploying to production...") | 418 | print("Deploying to production...") |
| 612 | deploy_to_server(build_dir, "homelab") | 419 | deploy_to_server(build_dir, "homelab") |
| @@ -616,13 +423,8 @@ def main(): | |||
| 616 | if build: | 423 | if build: |
| 617 | remove_build_directory(build_dir) | 424 | remove_build_directory(build_dir) |
| 618 | minify_css(css_src, css_min) | 425 | minify_css(css_src, css_min) |
| 619 | run_emacs_publish(dev_mode=True) | 426 | run_orgo_build(dev_mode=True) |
| 620 | copy_org_sources() | 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 | if deploy: | 428 | if deploy: |
| 627 | start_dev_server(build_dir) | 429 | start_dev_server(build_dir) |
| 628 | 430 | ||