This commit was merged in pull request #1.
This commit is contained in:
31
Makefile
Normal file
31
Makefile
Normal file
@@ -0,0 +1,31 @@
|
||||
.PHONY: dev build deploy clean
|
||||
|
||||
# Local development server
|
||||
dev:
|
||||
@echo "Starting dev server on :8080..."
|
||||
@cd "$(CURDIR)" && python3 -m http.server 8080
|
||||
|
||||
# Build the static site (generate blog index + RSS from posts)
|
||||
build:
|
||||
@echo "Building the Tower..."
|
||||
@python3 scripts/build.py
|
||||
@echo "Done."
|
||||
|
||||
# Deploy — configure DEPLOY_TARGET in environment or .env
|
||||
deploy: build
|
||||
@echo "Deploying..."
|
||||
@if [ -z "$${DEPLOY_TARGET:-}" ]; then \
|
||||
echo "Error: Set DEPLOY_TARGET (e.g., user@host:/var/www/tower)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'scripts/' \
|
||||
--exclude 'Makefile' \
|
||||
--exclude '*.md' \
|
||||
./ "$${DEPLOY_TARGET}/"
|
||||
@echo "Deployed to $${DEPLOY_TARGET}"
|
||||
|
||||
# Clean generated files
|
||||
clean:
|
||||
@echo "Nothing to clean yet."
|
||||
40
README.md
40
README.md
@@ -1,3 +1,39 @@
|
||||
# alexanderwhitestone.com
|
||||
# The Wizard's Tower
|
||||
|
||||
Public-facing interface for Timmy — AlexanderWhitestone.com
|
||||
**AlexanderWhitestone.com** — two rooms, nothing else.
|
||||
|
||||
## Rooms
|
||||
|
||||
- **The Workshop** (`/world/`) — A 3D space where Timmy lives. Visitors enter and interact.
|
||||
- **The Scrolls** (`/blog/`) — Alexander's words. Plain text, RSS, sovereign publishing.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
index.html Entry hall — two doors
|
||||
world/ The Workshop (3D scene, Timmy presence)
|
||||
blog/ The Scrolls (posts, RSS feed)
|
||||
scripts/ CLI tools (aw-post for quick publishing)
|
||||
static/ Shared assets (fonts, favicon)
|
||||
Makefile Build, dev, deploy
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make dev # Local dev server on :8080
|
||||
make build # Build static site
|
||||
make deploy # Deploy (configure target in Makefile)
|
||||
```
|
||||
|
||||
## Tech Decisions (Open)
|
||||
|
||||
- [ ] 3D engine: Three.js vs Babylon.js
|
||||
- [ ] Blog: Hugo vs hand-rolled static generator
|
||||
- [ ] Hosting: self-hosted Nginx/Caddy vs static CDN
|
||||
- [ ] Timmy's 3D character design
|
||||
|
||||
## Philosophy
|
||||
|
||||
Two doors. No navbar. No sidebar. No footer links. You walk in, you choose a room.
|
||||
The Workshop is alive. The Scrolls are permanent. That's the Tower.
|
||||
|
||||
13
blog/feed.xml
Normal file
13
blog/feed.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>The Scrolls — Alexander Whitestone</title>
|
||||
<link href="https://alexanderwhitestone.com/blog/"/>
|
||||
<link rel="self" href="https://alexanderwhitestone.com/blog/feed.xml"/>
|
||||
<id>https://alexanderwhitestone.com/blog/</id>
|
||||
<updated>2026-03-19T01:32:41Z</updated>
|
||||
<author>
|
||||
<name>Alexander Whitestone</name>
|
||||
</author>
|
||||
<subtitle>Words from the Wizard's Tower</subtitle>
|
||||
|
||||
</feed>
|
||||
40
blog/index.html
Normal file
40
blog/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Scrolls — The Wizard's Tower</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="The Scrolls" href="/blog/feed.xml">
|
||||
<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 { color: #8a7f6a; text-decoration: none; }
|
||||
header 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>The Scrolls</h1>
|
||||
<nav><a href="/">← The Tower</a> · <a href="/blog/feed.xml">RSS</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<ul class="posts">
|
||||
<li>
|
||||
<span class="date">2026-03-18</span>
|
||||
<span class="title">Hello World</span>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
0
blog/posts/.gitkeep
Normal file
0
blog/posts/.gitkeep
Normal file
83
index.html
Normal file
83
index.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Wizard's Tower</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0f;
|
||||
color: #e0d8c8;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3rem;
|
||||
color: #8a7f6a;
|
||||
}
|
||||
|
||||
.doors {
|
||||
display: flex;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.door {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: #e0d8c8;
|
||||
padding: 2rem 3rem;
|
||||
border: 1px solid #2a2520;
|
||||
transition: border-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.door:hover {
|
||||
border-color: #8a7f6a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.door-name {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.door-desc {
|
||||
font-size: 0.8rem;
|
||||
color: #6a6050;
|
||||
}
|
||||
|
||||
.door:hover .door-desc {
|
||||
color: #8a7f6a;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.doors { flex-direction: column; gap: 2rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>The Wizard's Tower</h1>
|
||||
<nav class="doors">
|
||||
<a href="/world/" class="door">
|
||||
<span class="door-name">The Workshop</span>
|
||||
<span class="door-desc">Enter the world</span>
|
||||
</a>
|
||||
<a href="/blog/" class="door">
|
||||
<span class="door-name">The Scrolls</span>
|
||||
<span class="door-desc">Read the words</span>
|
||||
</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
49
scripts/aw-post
Executable file
49
scripts/aw-post
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# aw-post — Quick-post a scroll from the command line
|
||||
#
|
||||
# Usage:
|
||||
# aw-post "Title of the Post"
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BLOG_DIR="$(cd "$(dirname "$0")/.." && pwd)/blog/posts"
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: aw-post \"Title of the Post\""
|
||||
echo " Pipe or redirect body content via stdin."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TITLE="$1"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
|
||||
FILENAME="${DATE}-${SLUG}.md"
|
||||
FILEPATH="${BLOG_DIR}/${FILENAME}"
|
||||
|
||||
if [ -f "$FILEPATH" ]; then
|
||||
echo "Error: $FILEPATH already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read body from stdin if available
|
||||
BODY=""
|
||||
if [ ! -t 0 ]; then
|
||||
BODY=$(cat)
|
||||
fi
|
||||
|
||||
cat > "$FILEPATH" << EOF
|
||||
---
|
||||
title: "${TITLE}"
|
||||
date: ${DATE}
|
||||
---
|
||||
|
||||
${BODY}
|
||||
EOF
|
||||
|
||||
echo "Created: ${FILEPATH}"
|
||||
echo "Next: rebuild with 'make build'"
|
||||
172
scripts/build.py
Normal file
172
scripts/build.py
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script for The Scrolls — generates blog index 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 post listing
|
||||
- blog/feed.xml Atom feed
|
||||
"""
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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 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_index(posts):
|
||||
"""Generate blog/index.html with post listing."""
|
||||
if not posts:
|
||||
print(" No posts found. Keeping placeholder index.")
|
||||
return
|
||||
|
||||
items = []
|
||||
for p in posts:
|
||||
items.append(
|
||||
f' <li>\n'
|
||||
f' <span class="date">{p["date"]}</span>\n'
|
||||
f' <span class="title">{p["title"]}</span>\n'
|
||||
f" </li>"
|
||||
)
|
||||
|
||||
post_list = "\n".join(items)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Scrolls — The Wizard's Tower</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="The Scrolls" href="/blog/feed.xml">
|
||||
<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 {{ color: #8a7f6a; text-decoration: none; }}
|
||||
header 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; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>The Scrolls</h1>
|
||||
<nav><a href="/">← The Tower</a> · <a href="/blog/feed.xml">RSS</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<ul class="posts">
|
||||
{post_list}
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
(BLOG_DIR / "index.html").write_text(html)
|
||||
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
|
||||
# Simple HTML conversion: wrap paragraphs
|
||||
html_body = "\n".join(
|
||||
f"<p>{para}</p>" for para in p["body"].split("\n\n") if para.strip()
|
||||
)
|
||||
entries.append(
|
||||
f""" <entry>
|
||||
<title>{p["title"]}</title>
|
||||
<link href="{SITE_URL}/blog/posts/{p["slug"]}.html"/>
|
||||
<id>{SITE_URL}/blog/posts/{p["slug"]}</id>
|
||||
<updated>{p["date"]}T00:00:00Z</updated>
|
||||
<content type="html"><![CDATA[{html_body}]]></content>
|
||||
</entry>"""
|
||||
)
|
||||
|
||||
entry_block = "\n".join(entries)
|
||||
|
||||
feed = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>The Scrolls — Alexander Whitestone</title>
|
||||
<link href="{SITE_URL}/blog/"/>
|
||||
<link rel="self" href="{SITE_URL}/blog/feed.xml"/>
|
||||
<id>{SITE_URL}/blog/</id>
|
||||
<updated>{now}</updated>
|
||||
<author>
|
||||
<name>Alexander Whitestone</name>
|
||||
</author>
|
||||
<subtitle>Words from the Wizard's Tower</subtitle>
|
||||
{entry_block}
|
||||
</feed>"""
|
||||
|
||||
(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()
|
||||
generate_index(posts)
|
||||
generate_feed(posts)
|
||||
print("Build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
static/.gitkeep
Normal file
0
static/.gitkeep
Normal file
0
world/assets/.gitkeep
Normal file
0
world/assets/.gitkeep
Normal file
37
world/index.html
Normal file
37
world/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Workshop — The Wizard's Tower</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0f; color: #e0d8c8; font-family: Georgia, serif; }
|
||||
#scene {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: #6a6050;
|
||||
}
|
||||
.placeholder h1 { font-size: 1.2rem; font-weight: normal; margin-bottom: 1rem; }
|
||||
.placeholder p { font-size: 0.85rem; }
|
||||
.placeholder a { color: #8a7f6a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="scene">
|
||||
<div class="placeholder">
|
||||
<h1>The Workshop</h1>
|
||||
<p>Timmy's world is being built.</p>
|
||||
<p><a href="/">← Back to the Tower</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Three.js scene will mount to #scene -->
|
||||
<!-- <script type="module" src="main.js"></script> -->
|
||||
</body>
|
||||
</html>
|
||||
20
world/main.js
Normal file
20
world/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The Workshop — Three.js scene bootstrap
|
||||
*
|
||||
* This file will initialize the 3D world where Timmy lives.
|
||||
* Currently a placeholder until tech decisions are made:
|
||||
* - 3D engine confirmed (Three.js vs Babylon.js)
|
||||
* - Character design direction chosen
|
||||
* - WebSocket bridge to Timmy's soul designed (#243)
|
||||
*
|
||||
* See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema)
|
||||
*/
|
||||
|
||||
// Future: import * as THREE from 'three';
|
||||
|
||||
export function initWorkshop(container) {
|
||||
// TODO: Initialize 3D scene
|
||||
// TODO: Load wizard character model
|
||||
// TODO: Connect to Timmy presence WebSocket
|
||||
console.log('[Workshop] Scene container ready:', container.id);
|
||||
}
|
||||
Reference in New Issue
Block a user