#!/usr/bin/env python3 """ Build script for The Scrolls — generates blog pages and RSS feed from markdown posts in blog/posts/. Each post is a markdown file with YAML frontmatter: --- title: "Post Title" date: 2026-03-18 --- Body content here. Generates: - blog/index.html with linked post listing - blog/posts/.html for each post - blog/feed.xml Atom feed """ import html import os import re from datetime import datetime, timezone from pathlib import Path SITE_URL = "https://alexanderwhitestone.com" BLOG_DIR = Path(__file__).parent.parent / "blog" POSTS_DIR = BLOG_DIR / "posts" PAGE_STYLE = """\ * { margin: 0; padding: 0; box-sizing: border-box; } body { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; background: #0a0a0f; color: #e0d8c8; font-family: Georgia, serif; line-height: 1.6; } header { margin-bottom: 3rem; border-bottom: 1px solid #2a2520; padding-bottom: 1rem; } header h1 { font-size: 1.2rem; font-weight: normal; letter-spacing: 0.1em; } header nav { margin-top: 0.5rem; font-size: 0.8rem; } header a, a { color: #8a7f6a; text-decoration: none; } header a:hover, a:hover { color: #e0d8c8; } .posts { list-style: none; } .posts li { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #1a1510; } .posts .date { font-size: 0.8rem; color: #6a6050; display: block; } .posts a { color: #e0d8c8; text-decoration: none; } .posts a:hover { color: #fff; } .post-date { font-size: 0.85rem; color: #6a6050; margin-bottom: 2rem; display: block; } .post-body p { margin-bottom: 1.2rem; } .post-body h2 { font-size: 1.1rem; margin: 2rem 0 1rem; color: #c0b8a8; } .post-body h3 { font-size: 1rem; margin: 1.5rem 0 0.8rem; color: #a09888; } .post-body blockquote { border-left: 2px solid #2a2520; padding-left: 1rem; color: #a09888; margin-bottom: 1.2rem; } .post-body code { background: #1a1510; padding: 0.15em 0.4em; font-size: 0.9em; } .post-body pre { background: #1a1510; padding: 1rem; overflow-x: auto; margin-bottom: 1.2rem; } .post-body pre code { background: none; padding: 0; } .post-body em { font-style: italic; } .post-body strong { color: #fff; } """ def parse_frontmatter(text): """Extract YAML frontmatter from markdown text.""" match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL) if not match: return {}, text meta = {} for line in match.group(1).strip().split("\n"): if ":" in line: key, val = line.split(":", 1) meta[key.strip()] = val.strip().strip('"').strip("'") return meta, match.group(2).strip() def md_to_html(text): """Minimal markdown to HTML — paragraphs, headers, bold, italic, code, links, blockquotes.""" lines = text.split("\n") out = [] in_code_block = False in_blockquote = False paragraph = [] def flush_paragraph(): if paragraph: content = "\n".join(paragraph) content = inline_format(content) out.append(f"

{content}

") paragraph.clear() def inline_format(s): # Code spans first (protect from other formatting) s = re.sub(r"`([^`]+)`", r"\1", s) # Bold s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # Italic s = re.sub(r"\*(.+?)\*", r"\1", s) # Links s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s) return s for line in lines: # Fenced code blocks if line.strip().startswith("```"): if in_code_block: out.append("") in_code_block = False else: flush_paragraph() out.append("
")
                in_code_block = True
            continue

        if in_code_block:
            out.append(html.escape(line))
            continue

        stripped = line.strip()

        # Empty line — flush paragraph
        if not stripped:
            flush_paragraph()
            if in_blockquote:
                out.append("")
                in_blockquote = False
            continue

        # Headers
        header_match = re.match(r"^(#{1,3})\s+(.+)$", stripped)
        if header_match:
            flush_paragraph()
            level = len(header_match.group(1)) + 1  # h2, h3, h4 (h1 is title)
            content = inline_format(header_match.group(2))
            out.append(f"{content}")
            continue

        # Blockquotes
        if stripped.startswith("> "):
            flush_paragraph()
            content = inline_format(stripped[2:])
            if not in_blockquote:
                out.append("
") in_blockquote = True out.append(f"

{content}

") continue # Regular text — accumulate paragraph paragraph.append(stripped) flush_paragraph() if in_blockquote: out.append("
") if in_code_block: out.append("
") return "\n".join(out) def load_posts(): """Load and sort all posts by date (newest first).""" posts = [] if not POSTS_DIR.exists(): return posts for path in sorted(POSTS_DIR.glob("*.md"), reverse=True): text = path.read_text() meta, body = parse_frontmatter(text) if meta.get("title") and meta.get("date"): posts.append( { "title": meta["title"], "date": meta["date"], "slug": path.stem, "body": body, "path": path, } ) return posts def generate_post_page(post): """Generate an individual post HTML page.""" body_html = md_to_html(post["body"]) page = f""" {html.escape(post["title"])} — The Scrolls

{html.escape(post["title"])}

{body_html}
""" out_path = POSTS_DIR / f"{post['slug']}.html" out_path.write_text(page) return out_path def generate_index(posts): """Generate blog/index.html with linked post listing.""" if not posts: items_html = '
  • No scrolls yet.
  • ' else: items = [] for p in posts: escaped_title = html.escape(p["title"]) items.append( f'
  • \n' f' {p["date"]}\n' f' {escaped_title}\n' f'
  • ' ) items_html = "\n".join(items) page = f""" The Scrolls — The Wizard's Tower

    The Scrolls

    """ (BLOG_DIR / "index.html").write_text(page) print(f" Generated index with {len(posts)} post(s).") def generate_feed(posts): """Generate blog/feed.xml Atom feed.""" now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") entries = [] for p in posts[:20]: # Cap at 20 entries body_html = md_to_html(p["body"]) escaped_title = html.escape(p["title"]) entries.append( f""" {escaped_title} {SITE_URL}/blog/posts/{p["slug"]} {p["date"]}T00:00:00Z """ ) entry_block = "\n".join(entries) feed = f""" The Scrolls — Alexander Whitestone {SITE_URL}/blog/ {now} Alexander Whitestone Words from the Wizard's Tower {entry_block} """ (BLOG_DIR / "feed.xml").write_text(feed) print(f" Generated feed with {len(entries)} entry/entries.") def main(): print("Building The Scrolls...") posts = load_posts() for p in posts: out = generate_post_page(p) print(f" Built: {out.relative_to(BLOG_DIR.parent)}") generate_index(posts) generate_feed(posts) print("Build complete.") if __name__ == "__main__": main()