diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e74be8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts — regenerated by `make build` / `python3 scripts/build.py` +# Source of truth is blog/posts/*.md +blog/posts/*.html diff --git a/blog/feed.xml b/blog/feed.xml index 1e43dfa..55c4125 100644 --- a/blog/feed.xml +++ b/blog/feed.xml @@ -4,10 +4,22 @@ https://alexanderwhitestone.com/blog/ - 2026-03-19T01:32:41Z + 2026-03-19T01:42:22Z Alexander Whitestone Words from the Wizard's Tower - + + Hello World + + https://alexanderwhitestone.com/blog/posts/2026-03-18-hello-world + 2026-03-18T00:00:00Z + The Tower stands. Two doors. You choose.

+

The Workshop is where the wizard lives — a 3D world you walk into. Not a chatbot. A presence.

+

The Scrolls is where the words live — plain text, RSS, no platform tax. You're reading one now.

+
+

Sovereignty isn't a feature. It's the architecture.

+
+

This is the first scroll. More will follow.

]]>
+
\ No newline at end of file diff --git a/blog/index.html b/blog/index.html index 4967523..23a83b6 100644 --- a/blog/index.html +++ b/blog/index.html @@ -15,12 +15,24 @@ 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 { color: #8a7f6a; text-decoration: none; } - header a:hover { color: #e0d8c8; } + 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 .title { font-size: 1rem; } + .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; } + @@ -32,7 +44,7 @@ diff --git a/blog/posts/2026-03-18-hello-world.md b/blog/posts/2026-03-18-hello-world.md new file mode 100644 index 0000000..780ba17 --- /dev/null +++ b/blog/posts/2026-03-18-hello-world.md @@ -0,0 +1,14 @@ +--- +title: "Hello World" +date: 2026-03-18 +--- + +The Tower stands. Two doors. You choose. + +**The Workshop** is where the wizard lives — a 3D world you walk into. Not a chatbot. A presence. + +**The Scrolls** is where the words live — plain text, RSS, no platform tax. You're reading one now. + +> Sovereignty isn't a feature. It's the architecture. + +This is the first scroll. More will follow. diff --git a/scripts/aw-post b/scripts/aw-post index 6611123..1a1961a 100755 --- a/scripts/aw-post +++ b/scripts/aw-post @@ -6,12 +6,13 @@ # aw-post "Title" < body.md # echo "Post body here" | aw-post "Title" # -# Creates a new markdown file in blog/posts/ with frontmatter. -# Rebuilds the blog index and RSS feed. +# Creates a new markdown file in blog/posts/ with frontmatter, +# then rebuilds the blog index, post pages, and RSS feed. set -euo pipefail -BLOG_DIR="$(cd "$(dirname "$0")/.." && pwd)/blog/posts" +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BLOG_DIR="${REPO_DIR}/blog/posts" if [ $# -lt 1 ]; then echo "Usage: aw-post \"Title of the Post\"" @@ -46,4 +47,6 @@ ${BODY} EOF echo "Created: ${FILEPATH}" -echo "Next: rebuild with 'make build'" + +# Rebuild the blog +python3 "${REPO_DIR}/scripts/build.py" diff --git a/scripts/build.py b/scripts/build.py index 5b3ef0c..d089d91 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Build script for The Scrolls — generates blog index and RSS feed from +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: @@ -11,10 +11,12 @@ Each post is a markdown file with YAML frontmatter: Body content here. Generates: - - blog/index.html with post listing + - 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 @@ -24,6 +26,35 @@ 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.""" @@ -39,6 +70,89 @@ def parse_frontmatter(text): 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 = [] @@ -61,24 +175,57 @@ def load_posts(): 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 post listing.""" + """Generate blog/index.html with linked post listing.""" if not posts: - print(" No posts found. Keeping placeholder index.") - return + 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) - items = [] - for p in posts: - items.append( - f'
  • \n' - f' {p["date"]}\n' - f' {p["title"]}\n' - f"
  • " - ) - - post_list = "\n".join(items) - - html = f""" + page = f""" @@ -86,21 +233,7 @@ def generate_index(posts): The Scrolls — The Wizard's Tower @@ -110,13 +243,13 @@ def generate_index(posts):
    """ - (BLOG_DIR / "index.html").write_text(html) + (BLOG_DIR / "index.html").write_text(page) print(f" Generated index with {len(posts)} post(s).") @@ -126,17 +259,15 @@ def generate_feed(posts): entries = [] for p in posts[:20]: # Cap at 20 entries - # Simple HTML conversion: wrap paragraphs - html_body = "\n".join( - f"

    {para}

    " for para in p["body"].split("\n\n") if para.strip() - ) + body_html = md_to_html(p["body"]) + escaped_title = html.escape(p["title"]) entries.append( f""" - {p["title"]} + {escaped_title} {SITE_URL}/blog/posts/{p["slug"]} {p["date"]}T00:00:00Z - + """ ) @@ -163,6 +294,9 @@ def generate_feed(posts): 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.")