From 4fcd6a795fdb4b7fe5a05b62d0a614fa0ac2ebec Mon Sep 17 00:00:00 2001
From: Alexander Whitestone
Date: Wed, 18 Mar 2026 21:42:56 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20complete=20The=20Scrolls=20blog=20?=
=?UTF-8?q?=E2=80=94=20post=20pages,=20linked=20index,=20RSS,=20auto-rebui?=
=?UTF-8?q?ld=20(#217)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- build.py: generates individual HTML post pages from markdown
- build.py: index now links to individual post pages
- build.py: minimal markdown-to-HTML converter (paragraphs, headers, bold, italic, code, links, blockquotes)
- aw-post: auto-rebuilds blog after creating a new post
- .gitignore: excludes generated post HTML (build artifacts)
- Sample post: Hello World with content exercising all formatters
- Atom feed now includes full HTML content in entries
Closes #217, partially addresses #218 (RSS feed) and #219 (CLI tool)
---
.gitignore | 3 +
blog/feed.xml | 16 +-
blog/index.html | 20 ++-
blog/posts/2026-03-18-hello-world.md | 14 ++
scripts/aw-post | 11 +-
scripts/build.py | 214 ++++++++++++++++++++++-----
6 files changed, 228 insertions(+), 50 deletions(-)
create mode 100644 .gitignore
create mode 100644 blog/posts/2026-03-18-hello-world.md
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 @@
-
2026-03-18
- Hello World
+ Hello World
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
+
+
+
+
+
+
+ {post["date"]}
+
+{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):
-{post_list}
+{items_html}
"""
- (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.")