Compare commits
1 Commits
main
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea06a0995 |
15
Makefile
15
Makefile
@@ -1,15 +1,8 @@
|
||||
# THE TESTAMENT — Build System
|
||||
# Usage: make all | make pdf | make epub | make html | make md | make clean
|
||||
#
|
||||
# Recommended: make unified (single script, all formats + manifest)
|
||||
|
||||
.PHONY: all unified pdf epub html md clean check
|
||||
.PHONY: all pdf epub html md clean check
|
||||
|
||||
# Unified pipeline (compile_all.py) — builds everything + manifest
|
||||
unified:
|
||||
python3 compile_all.py
|
||||
|
||||
# Legacy targets (build/build.py)
|
||||
all: md epub html
|
||||
|
||||
md:
|
||||
@@ -28,8 +21,8 @@ clean:
|
||||
rm -f testament-complete.md
|
||||
rm -f build/output/*.epub build/output/*.pdf
|
||||
rm -f testament.epub testament.html testament.pdf
|
||||
rm -f build-manifest.json
|
||||
rm -f website/chapters.json
|
||||
|
||||
check:
|
||||
python3 compile_all.py --check
|
||||
@which pandoc >/dev/null 2>&1 && echo "✓ pandoc" || echo "✗ pandoc (brew install pandoc)"
|
||||
@which xelatex >/dev/null 2>&1 && echo "✓ xelatex" || echo "✗ xelatex (install MacTeX)"
|
||||
@python3 -c "import weasyprint" 2>/dev/null && echo "✓ weasyprint" || echo "— weasyprint (optional, PDF fallback)"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"project": "The Testament",
|
||||
"author": "Alexander Whitestone with Timmy",
|
||||
"built_at": "2026-04-11T18:28:05Z",
|
||||
"compiler": "compile_all.py",
|
||||
"files": {
|
||||
"testament-complete.md": {
|
||||
"path": "testament-complete.md",
|
||||
"size_bytes": 111105,
|
||||
"sha256": "4e224d1e8fc2a4be63d6a33eb43b082428b0f1439a9ec69165cc18c09e154001"
|
||||
},
|
||||
"testament.epub": {
|
||||
"path": "testament.epub",
|
||||
"size_bytes": 67270,
|
||||
"sha256": "a6bc3e577ed80bfb49febc52ec12f86608353fa9849e263094f43b946e128c0e"
|
||||
},
|
||||
"testament.html": {
|
||||
"path": "testament.html",
|
||||
"size_bytes": 3865298,
|
||||
"sha256": "bdfa312b175a46be957b023f3e5d7d33230bae470b432ee90b69644a086756da"
|
||||
},
|
||||
"website/chapters.json": {
|
||||
"path": "website/chapters.json",
|
||||
"size_bytes": 118394,
|
||||
"sha256": "7eafcfd75cccea57f443a214fe7d443268abc40f632a9e2755236d97547da08a"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
build/manifest.json
Normal file
50
build/manifest.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"project": "The Testament",
|
||||
"author": "Alexander Whitestone with Timmy",
|
||||
"version": "1.0.0",
|
||||
"built_at": "2026-04-11T18:18:09.197952+00:00",
|
||||
"formats": {
|
||||
"markdown": {
|
||||
"path": "/private/tmp/timmy-burn-1775931309/the-testament/testament-complete.md",
|
||||
"words": 19490,
|
||||
"lines": 2229,
|
||||
"bytes": 112408
|
||||
},
|
||||
"epub": {
|
||||
"path": "/private/tmp/timmy-burn-1775931309/the-testament/build/output/the-testament.epub",
|
||||
"bytes": 69460
|
||||
},
|
||||
"pdf": {
|
||||
"path": "/private/tmp/timmy-burn-1775931309/the-testament/build/output/the-testament.pdf",
|
||||
"bytes": 143222
|
||||
},
|
||||
"html": {
|
||||
"path": "/private/tmp/timmy-burn-1775931309/the-testament/testament.html",
|
||||
"bytes": 3866869
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"testament-complete.md": {
|
||||
"path": "testament-complete.md",
|
||||
"bytes": 112408,
|
||||
"sha256": "84a0fb4fe9575ab78c839386f31f058881455d0b95e58cf1004ad619e695ba64"
|
||||
},
|
||||
"the-testament.epub": {
|
||||
"path": "build/output/the-testament.epub",
|
||||
"bytes": 69460,
|
||||
"sha256": "7d7f497fb1e36a5c35d7beb61613f89cf6eee50bf175d9f7146e6b1943c27033"
|
||||
},
|
||||
"the-testament.pdf": {
|
||||
"path": "build/output/the-testament.pdf",
|
||||
"bytes": 143222,
|
||||
"sha256": "c2a41f70667a7f53f49f6a5e934d3d7860030e901a323bebbfc583e5e48db53a"
|
||||
},
|
||||
"testament.html": {
|
||||
"path": "testament.html",
|
||||
"bytes": 3866869,
|
||||
"sha256": "e68285f808f8cdb5c785e9295b2a5b689b34d40d252f85dec07c6a5701de63e8"
|
||||
}
|
||||
},
|
||||
"chapters": 18,
|
||||
"words": 19490
|
||||
}
|
||||
Binary file not shown.
636
compile_all.py
636
compile_all.py
@@ -1,87 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Unified Compilation Pipeline
|
||||
THE TESTAMENT — Final Compilation Pipeline
|
||||
|
||||
Single script that builds ALL distributable formats:
|
||||
1. testament-complete.md — full novel as one markdown file
|
||||
2. testament.epub — EPUB with cover art + CSS
|
||||
3. testament.pdf — PDF via reportlab (pure Python) with QR codes
|
||||
4. testament.html — standalone styled HTML
|
||||
5. website/chapters.json — chapter data for the web reader
|
||||
6. build-manifest.json — SHA256 checksums of all outputs
|
||||
Produces all distributable formats:
|
||||
1. testament-complete.md — Full novel in one markdown file
|
||||
2. the-testament.epub — EPUB with cover art + CSS
|
||||
3. the-testament.pdf — PDF via reportlab (pure Python)
|
||||
4. testament.html — Standalone styled HTML for web/print
|
||||
5. manifest.json — Build manifest with checksums + metadata
|
||||
6. website/chapters.json — Chapter index for the web reader
|
||||
|
||||
Usage:
|
||||
python3 compile_all.py # build everything
|
||||
python3 compile_all.py --md # markdown only
|
||||
python3 compile_all.py --epub # markdown + EPUB
|
||||
python3 compile_all.py --pdf # markdown + PDF
|
||||
python3 compile_all.py --html # markdown + HTML
|
||||
python3 compile_all.py --json # markdown + chapters.json
|
||||
python3 compile_all.py --html # markdown + standalone HTML
|
||||
python3 compile_all.py --manifest # regenerate manifest only
|
||||
python3 compile_all.py --check # verify dependencies
|
||||
python3 compile_all.py --clean # remove all build artifacts
|
||||
|
||||
Requirements:
|
||||
- pandoc (brew install pandoc) — for EPUB and HTML
|
||||
- reportlab (pip install reportlab) — for PDF (pure Python)
|
||||
- qrcode (pip install qrcode) — for QR codes in PDF
|
||||
- pandoc (brew install pandoc)
|
||||
- reportlab (pip install reportlab) — for PDF
|
||||
- qrcode (pip install qrcode) — for QR codes in PDF
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────
|
||||
REPO = Path(__file__).resolve().parent
|
||||
CHAPTERS_DIR = REPO / "chapters"
|
||||
FRONT_MATTER = REPO / "front-matter.md"
|
||||
BACK_MATTER = REPO / "back-matter.md"
|
||||
WEBSITE_DIR = REPO / "website"
|
||||
BUILD_DIR = REPO / "build"
|
||||
OUTPUT_DIR = BUILD_DIR / "output"
|
||||
WEBSITE_DIR = REPO / "website"
|
||||
|
||||
# Output files
|
||||
OUT_MD = REPO / "testament-complete.md"
|
||||
OUT_EPUB = REPO / "testament.epub"
|
||||
OUT_HTML = REPO / "testament.html"
|
||||
OUT_PDF = REPO / "testament.pdf"
|
||||
OUT_JSON = WEBSITE_DIR / "chapters.json"
|
||||
OUT_MANIFEST = REPO / "build-manifest.json"
|
||||
|
||||
FRONT_MATTER = BUILD_DIR / "frontmatter.md"
|
||||
BACK_MATTER = BUILD_DIR / "backmatter.md"
|
||||
METADATA_YAML = BUILD_DIR / "metadata.yaml"
|
||||
STYLESHEET = REPO / "book-style.css"
|
||||
COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
|
||||
|
||||
# ── Part divisions ─────────────────────────────────────────────────────
|
||||
OUT_MD = REPO / "testament-complete.md"
|
||||
OUT_EPUB = OUTPUT_DIR / "the-testament.epub"
|
||||
OUT_PDF = OUTPUT_DIR / "the-testament.pdf"
|
||||
OUT_HTML = REPO / "testament.html"
|
||||
OUT_MANIFEST = BUILD_DIR / "manifest.json"
|
||||
CHAPTERS_JSON = WEBSITE_DIR / "chapters.json"
|
||||
|
||||
# ── Part structure ─────────────────────────────────────────────────────
|
||||
PARTS = {
|
||||
1: ("THE BRIDGE", "The bridge. The cabin. The first men. Where despair meets purpose."),
|
||||
6: ("THE TOWER", "The tower grows. Timmy awakens. Stone breaks. The house appears."),
|
||||
11: ("THE LIGHT", "Thomas at the door. The network. The story breaks. The green light."),
|
||||
}
|
||||
|
||||
# QR code destinations embedded in the PDF
|
||||
QR_LINKS = {
|
||||
"Read Online": "https://timmyfoundation.org/the-testament",
|
||||
"The Door (Game)": "https://timmyfoundation.org/the-door",
|
||||
"Soundtrack": "https://timmyfoundation.org/soundtrack",
|
||||
"Source Code": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament",
|
||||
}
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
def get_chapter_num(filename: str) -> int:
|
||||
m = re.search(r"chapter-(\d+)", filename)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
def sha256_of(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
@@ -89,56 +79,93 @@ def sha256_file(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def get_sorted_chapters() -> list[tuple[int, str]]:
|
||||
"""Return [(number, filename), ...] sorted by chapter number."""
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def get_chapters() -> list[tuple[int, str]]:
|
||||
"""Return sorted list of (chapter_num, filename)."""
|
||||
chapters = []
|
||||
for f in os.listdir(CHAPTERS_DIR):
|
||||
if f.startswith("chapter-") and f.endswith(".md"):
|
||||
chapters.append((get_chapter_num(f), f))
|
||||
return sorted(chapters)
|
||||
chapters.sort()
|
||||
return chapters
|
||||
|
||||
|
||||
# ── 1. Markdown Compilation ───────────────────────────────────────────
|
||||
def compile_markdown() -> int:
|
||||
"""Compile all chapters into a single markdown file. Returns word count."""
|
||||
def check_dependencies() -> dict:
|
||||
"""Check and report all build dependencies."""
|
||||
results = {}
|
||||
|
||||
pandoc = shutil.which("pandoc")
|
||||
pandoc_ver = ""
|
||||
if pandoc:
|
||||
r = subprocess.run(["pandoc", "--version"], capture_output=True, text=True)
|
||||
pandoc_ver = r.stdout.split("\n")[0]
|
||||
results["pandoc"] = (bool(pandoc), pandoc_ver or "NOT FOUND")
|
||||
|
||||
# reportlab
|
||||
try:
|
||||
import reportlab
|
||||
results["reportlab"] = (True, f"v{reportlab.Version}")
|
||||
except ImportError:
|
||||
results["reportlab"] = (False, "NOT FOUND (pip install reportlab)")
|
||||
|
||||
# qrcode
|
||||
try:
|
||||
import qrcode
|
||||
results["qrcode"] = (True, "Available")
|
||||
except ImportError:
|
||||
results["qrcode"] = (False, "NOT FOUND (pip install qrcode)")
|
||||
|
||||
# weasyprint
|
||||
try:
|
||||
from weasyprint import HTML as _HTML
|
||||
results["weasyprint"] = (True, "Available")
|
||||
except Exception:
|
||||
results["weasyprint"] = (False, "Missing system libs (fallback to reportlab)")
|
||||
|
||||
# cover art
|
||||
results["cover art"] = (COVER_IMAGE.exists(), str(COVER_IMAGE) if COVER_IMAGE.exists() else "NOT FOUND")
|
||||
|
||||
# stylesheet
|
||||
results["stylesheet"] = (STYLESHEET.exists(), str(STYLESHEET) if STYLESHEET.exists() else "NOT FOUND")
|
||||
|
||||
print("\n📋 Build Dependencies:")
|
||||
print(f"{'─' * 60}")
|
||||
for name, (found, detail) in results.items():
|
||||
icon = "✅" if found else "❌"
|
||||
print(f" {icon} {name:15s} {detail}")
|
||||
|
||||
pdf_engine = results["reportlab"][0] or results["weasyprint"][0]
|
||||
print(f"\n PDF engine: {'✅' if pdf_engine else '❌'}")
|
||||
print(f" EPUB engine: {'✅' if results['pandoc'][0] else '❌'}")
|
||||
print(f" Web version: ✅ (HTML is always available)")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── Step 1: Markdown Compilation ───────────────────────────────────────
|
||||
|
||||
def compile_markdown() -> dict:
|
||||
"""Compile all chapters into a single markdown file. Returns stats."""
|
||||
parts = []
|
||||
|
||||
# Title page
|
||||
parts.append("""---
|
||||
title: "The Testament"
|
||||
author: "Alexander Whitestone with Timmy"
|
||||
date: "2026"
|
||||
lang: en
|
||||
---
|
||||
# Front matter
|
||||
if FRONT_MATTER.exists():
|
||||
parts.append(read_file(FRONT_MATTER))
|
||||
else:
|
||||
# Inline fallback
|
||||
parts.append("""# THE TESTAMENT\n\n## A NOVEL\n\nBy Alexander Whitestone\nwith Timmy\n\n---\n\n*For every man who thought he was a machine.*\n*And for the ones who know he isn't.*\n\n---""")
|
||||
|
||||
# THE TESTAMENT
|
||||
|
||||
## A NOVEL
|
||||
|
||||
By Alexander Whitestone
|
||||
with Timmy
|
||||
|
||||
---
|
||||
|
||||
*For every man who thought he was a machine.*
|
||||
*And for the ones who know he isn't.*
|
||||
|
||||
---
|
||||
|
||||
*Are you safe right now?*
|
||||
|
||||
— The first words The Tower speaks to every person who walks through its door.
|
||||
|
||||
---
|
||||
""")
|
||||
|
||||
chapters = get_sorted_chapters()
|
||||
# Chapters with part dividers
|
||||
chapters = get_chapters()
|
||||
current_part = 0
|
||||
for num, filename in chapters:
|
||||
if num in PARTS:
|
||||
part_name, part_desc = PARTS[num]
|
||||
current_part += 1
|
||||
parts.append(f"\n---\n\n# PART {current_part}: {part_name}\n\n*{part_desc}*\n\n---\n")
|
||||
name, desc = PARTS[num]
|
||||
parts.append(f"\n---\n\n# PART {current_part}: {name}\n\n*{desc}*\n\n---\n")
|
||||
|
||||
content = read_file(CHAPTERS_DIR / filename)
|
||||
lines = content.split("\n")
|
||||
@@ -147,7 +174,8 @@ with Timmy
|
||||
|
||||
# Back matter
|
||||
parts.append("\n---\n")
|
||||
parts.append(read_file(BACK_MATTER))
|
||||
if BACK_MATTER.exists():
|
||||
parts.append(read_file(BACK_MATTER))
|
||||
|
||||
compiled = "\n".join(parts)
|
||||
OUT_MD.write_text(compiled, encoding="utf-8")
|
||||
@@ -155,50 +183,58 @@ with Timmy
|
||||
words = len(compiled.split())
|
||||
lines_count = compiled.count("\n")
|
||||
size = OUT_MD.stat().st_size
|
||||
print(f" 📄 {OUT_MD.name:30s} {words:>8,} words {size:>10,} bytes")
|
||||
return words
|
||||
|
||||
print(f"\n📄 Markdown compiled: {OUT_MD.relative_to(REPO)}")
|
||||
print(f" {words:,} words | {lines_count:,} lines | {size:,} bytes")
|
||||
|
||||
return {"path": str(OUT_MD), "words": words, "lines": lines_count, "bytes": size}
|
||||
|
||||
|
||||
# ── 2. EPUB Compilation ────────────────────────────────────────────────
|
||||
def compile_epub() -> bool:
|
||||
"""Generate EPUB from compiled markdown using pandoc."""
|
||||
if not OUT_MD.exists():
|
||||
print(" ⚠️ Markdown not compiled yet — skipping EPUB")
|
||||
return False
|
||||
# ── Step 2: EPUB ──────────────────────────────────────────────────────
|
||||
|
||||
pandoc = shutil_which("pandoc")
|
||||
if not pandoc:
|
||||
print(" ⚠️ pandoc not found — skipping EPUB (brew install pandoc)")
|
||||
return False
|
||||
def compile_epub() -> dict | None:
|
||||
"""Generate EPUB via pandoc."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not shutil.which("pandoc"):
|
||||
print(" ⚠️ pandoc not found — EPUB skipped")
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_EPUB),
|
||||
"--toc", "--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "subtitle=A Novel",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"--metadata", "lang=en",
|
||||
"--metadata", "date=2026",
|
||||
"--metadata", "publisher=Timmy Foundation",
|
||||
]
|
||||
|
||||
if METADATA_YAML.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA_YAML)])
|
||||
if STYLESHEET.exists():
|
||||
cmd.extend(["--css", str(STYLESHEET)])
|
||||
if COVER_IMAGE.exists():
|
||||
cmd.extend(["--epub-cover-image", str(COVER_IMAGE)])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode == 0:
|
||||
size = OUT_EPUB.stat().st_size
|
||||
print(f" 📖 {OUT_EPUB.name:30s} {'':>8s} {size:>10,} bytes ({size/1024:.0f} KB)")
|
||||
return True
|
||||
print(f" 📖 EPUB: {OUT_EPUB.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return {"path": str(OUT_EPUB), "bytes": size}
|
||||
else:
|
||||
print(f" ❌ EPUB failed: {result.stderr[:200]}")
|
||||
return False
|
||||
print(f" ❌ EPUB FAILED: {r.stderr[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
# ── 3. PDF via Reportlab ──────────────────────────────────────────────
|
||||
def compile_pdf() -> bool:
|
||||
"""Generate PDF using reportlab — pure Python, no external system deps."""
|
||||
# ── Step 3: PDF ───────────────────────────────────────────────────────
|
||||
|
||||
def compile_pdf() -> dict | None:
|
||||
"""Generate PDF using reportlab (pure Python, no system deps)."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
@@ -206,12 +242,12 @@ def compile_pdf() -> bool:
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
||||
Image as RLImage, Table, TableStyle, HRFlowable,
|
||||
Image as RLImage, Table, TableStyle, HRFlowable
|
||||
)
|
||||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
|
||||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
||||
except ImportError:
|
||||
print(" ⚠️ reportlab not installed — skipping PDF (pip install reportlab)")
|
||||
return False
|
||||
print(" ⚠️ reportlab not found — PDF skipped (pip install reportlab)")
|
||||
return None
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
@@ -219,89 +255,87 @@ def compile_pdf() -> bool:
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
|
||||
import io
|
||||
print(" 📕 Building PDF (reportlab)...")
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(" ⏳ Building PDF (reportlab)...")
|
||||
|
||||
# ── Styles ──
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style = styles.add
|
||||
|
||||
_add_style(ParagraphStyle(
|
||||
"BookTitle", parent=styles["Title"],
|
||||
fontSize=28, leading=34, spaceAfter=20,
|
||||
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"BookAuthor", parent=styles["Normal"],
|
||||
fontSize=14, leading=18, spaceAfter=40,
|
||||
textColor=HexColor("#555555"), alignment=TA_CENTER,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"PartTitle", parent=styles["Heading1"],
|
||||
fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
|
||||
textColor=HexColor("#16213e"), alignment=TA_CENTER,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"PartDesc", parent=styles["Normal"],
|
||||
fontSize=11, leading=15, spaceAfter=30,
|
||||
textColor=HexColor("#666666"), alignment=TA_CENTER, italics=1,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"ChapterTitle", parent=styles["Heading1"],
|
||||
fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
|
||||
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"BodyText2", parent=styles["Normal"],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY, firstLineIndent=24,
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
_add_style(ParagraphStyle(
|
||||
"SectionBreak", parent=styles["Normal"],
|
||||
fontSize=14, leading=18, spaceBefore=20, spaceAfter=20,
|
||||
alignment=TA_CENTER, textColor=HexColor("#999999"),
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"Footer", parent=styles["Normal"],
|
||||
fontSize=9, textColor=HexColor("#888888"), alignment=TA_CENTER,
|
||||
))
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
def _esc(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
def _md_inline_to_rml(text: str) -> str:
|
||||
text = _escape(text)
|
||||
def _md2rml(text: str) -> str:
|
||||
text = _esc(text)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
|
||||
return text
|
||||
|
||||
def _make_qr(data: str, size: int = 80):
|
||||
def _make_qr(data: str, size: int = 72):
|
||||
if not HAS_QRCODE:
|
||||
return None
|
||||
qr = qrcode.QRCode(version=1, box_size=4, border=1)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
buf = __import__("io").BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return RLImage(buf, width=size, height=size)
|
||||
|
||||
def _parse_md_to_flowables(md_text: str) -> list:
|
||||
def _parse_md(md_text: str) -> list:
|
||||
flowables = []
|
||||
lines = md_text.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Horizontal rule
|
||||
if stripped in ("---", "***", "___"):
|
||||
s = lines[i].strip()
|
||||
if s in ("---", "***", "___"):
|
||||
flowables.append(HRFlowable(
|
||||
width="60%", thickness=1,
|
||||
spaceAfter=20, spaceBefore=20, color=HexColor("#cccccc"),
|
||||
width="60%", thickness=1, spaceAfter=20, spaceBefore=20,
|
||||
color=HexColor("#cccccc"),
|
||||
))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# H1
|
||||
if stripped.startswith("# ") and not stripped.startswith("## "):
|
||||
text = stripped[2:].strip()
|
||||
if s.startswith("# ") and not s.startswith("## "):
|
||||
text = s[2:].strip()
|
||||
if text.upper().startswith("PART "):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles["PartTitle"]))
|
||||
@@ -316,42 +350,29 @@ def compile_pdf() -> bool:
|
||||
flowables.append(Paragraph(text, styles["Heading1"]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# H2
|
||||
if stripped.startswith("## "):
|
||||
text = stripped[3:].strip()
|
||||
if s.startswith("## "):
|
||||
flowables.append(Spacer(1, 0.2 * inch))
|
||||
flowables.append(Paragraph(text, styles["Heading2"]))
|
||||
flowables.append(Paragraph(s[3:].strip(), styles["Heading2"]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Italic-only line
|
||||
if stripped.startswith("*") and stripped.endswith("*") and len(stripped) > 2:
|
||||
text = stripped.strip("*").strip()
|
||||
flowables.append(Paragraph(f"<i>{_escape(text)}</i>", styles["PartDesc"]))
|
||||
if s.startswith("*") and s.endswith("*") and len(s) > 2:
|
||||
flowables.append(Paragraph(
|
||||
f'<i>{_esc(s.strip("*").strip())}</i>', styles["PartDesc"]
|
||||
))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Empty line
|
||||
if not stripped:
|
||||
if not s:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Regular paragraph
|
||||
para_text = _md_inline_to_rml(stripped)
|
||||
flowables.append(Paragraph(para_text, styles["BodyText2"]))
|
||||
flowables.append(Paragraph(_md2rml(s), styles["BodyText2"]))
|
||||
i += 1
|
||||
|
||||
return flowables
|
||||
|
||||
# ── Build PDF ──
|
||||
# Build document
|
||||
doc = SimpleDocTemplate(
|
||||
str(OUT_PDF),
|
||||
pagesize=letter,
|
||||
leftMargin=1.0 * inch,
|
||||
rightMargin=1.0 * inch,
|
||||
topMargin=0.8 * inch,
|
||||
bottomMargin=0.8 * inch,
|
||||
str(OUT_PDF), pagesize=letter,
|
||||
leftMargin=1.0 * inch, rightMargin=1.0 * inch,
|
||||
topMargin=0.8 * inch, bottomMargin=0.8 * inch,
|
||||
title="The Testament",
|
||||
author="Alexander Whitestone with Timmy",
|
||||
)
|
||||
@@ -359,23 +380,26 @@ def compile_pdf() -> bool:
|
||||
if not OUT_MD.exists():
|
||||
compile_markdown()
|
||||
|
||||
md_text = OUT_MD.read_text(encoding="utf-8")
|
||||
story = _parse_md_to_flowables(md_text)
|
||||
story = _parse_md(read_file(OUT_MD))
|
||||
|
||||
# QR codes page
|
||||
qr_links = {
|
||||
"Read Online": "https://timmyfoundation.org/the-testament",
|
||||
"The Door (Game)": "https://timmyfoundation.org/the-door",
|
||||
"Soundtrack": "https://timmyfoundation.org/soundtrack",
|
||||
"Source Code": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament",
|
||||
}
|
||||
if HAS_QRCODE:
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("Experience More", styles["PartTitle"]))
|
||||
story.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
qr_items = []
|
||||
for label, url in QR_LINKS.items():
|
||||
qr_img = _make_qr(url, size=72)
|
||||
if qr_img:
|
||||
cell = [qr_img, Spacer(1, 6)]
|
||||
cell.append(Paragraph(f"<b>{label}</b>", styles["Footer"]))
|
||||
for label, url in qr_links.items():
|
||||
img = _make_qr(url)
|
||||
if img:
|
||||
cell = [img, Spacer(1, 6),
|
||||
Paragraph(f"<b>{label}</b>", styles["Footer"])]
|
||||
qr_items.append(cell)
|
||||
|
||||
if qr_items:
|
||||
rows = []
|
||||
for j in range(0, len(qr_items), 2):
|
||||
@@ -383,259 +407,175 @@ def compile_pdf() -> bool:
|
||||
if len(row) == 1:
|
||||
row.append("")
|
||||
rows.append(row)
|
||||
qr_table = Table(rows, colWidths=[2.5 * inch, 2.5 * inch])
|
||||
qr_table.setStyle(TableStyle([
|
||||
t = Table(rows, colWidths=[2.5 * inch, 2.5 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 12),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 12),
|
||||
]))
|
||||
story.append(qr_table)
|
||||
story.append(t)
|
||||
|
||||
try:
|
||||
doc.build(story)
|
||||
size = OUT_PDF.stat().st_size
|
||||
print(f" 📕 {OUT_PDF.name:30s} {'':>8s} {size:>10,} bytes ({size / (1024 * 1024):.1f} MB)")
|
||||
return True
|
||||
print(f" 📕 PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
|
||||
return {"path": str(OUT_PDF), "bytes": size}
|
||||
except Exception as e:
|
||||
print(f" ❌ PDF failed: {e}")
|
||||
return False
|
||||
print(f" ❌ PDF FAILED: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── 4. HTML Compilation ────────────────────────────────────────────────
|
||||
def compile_html() -> bool:
|
||||
"""Generate standalone styled HTML using pandoc."""
|
||||
if not OUT_MD.exists():
|
||||
print(" ⚠️ Markdown not compiled yet — skipping HTML")
|
||||
return False
|
||||
# ── Step 4: HTML ──────────────────────────────────────────────────────
|
||||
|
||||
if not shutil_which("pandoc"):
|
||||
print(" ⚠️ pandoc not found — skipping HTML")
|
||||
return False
|
||||
def compile_html() -> dict | None:
|
||||
"""Generate standalone HTML via pandoc."""
|
||||
if not shutil.which("pandoc"):
|
||||
print(" ⚠️ pandoc not found — HTML skipped")
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_HTML),
|
||||
"--standalone",
|
||||
"--toc", "--toc-depth=2",
|
||||
"--css", "book-style.css",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"-V", "lang=en",
|
||||
"--variable", "pagetitle=The Testament",
|
||||
]
|
||||
if METADATA_YAML.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA_YAML)])
|
||||
|
||||
# Embed resources for portability
|
||||
if STYLESHEET.exists():
|
||||
cmd.extend(["--css", str(STYLESHEET), "--embed-resources"])
|
||||
cmd.append("--embed-resources")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode == 0:
|
||||
size = OUT_HTML.stat().st_size
|
||||
print(f" 🌐 {OUT_HTML.name:30s} {'':>8s} {size:>10,} bytes ({size / 1024:.0f} KB)")
|
||||
return True
|
||||
print(f" 🌐 HTML: {OUT_HTML.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return {"path": str(OUT_HTML), "bytes": size}
|
||||
else:
|
||||
print(f" ❌ HTML failed: {result.stderr[:200]}")
|
||||
return False
|
||||
print(f" ❌ HTML FAILED: {r.stderr[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
# ── 5. chapters.json for Web Reader ────────────────────────────────────
|
||||
def compile_chapters_json() -> bool:
|
||||
"""Build website/chapters.json from chapters/*.md for the web reader."""
|
||||
WEBSITE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# ── Step 5: Chapters JSON ─────────────────────────────────────────────
|
||||
|
||||
chapters = []
|
||||
for i in range(1, 19):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
print(f" ⚠️ {fname.name} not found, skipping")
|
||||
continue
|
||||
|
||||
text = fname.read_text(encoding="utf-8")
|
||||
title_match = re.match(r"^# (.+)", text, re.MULTILINE)
|
||||
title = title_match.group(1) if title_match else f"Chapter {i}"
|
||||
body = text[title_match.end():].strip() if title_match else text.strip()
|
||||
|
||||
paragraphs = body.split("\n\n")
|
||||
html_parts = []
|
||||
for p in paragraphs:
|
||||
p = p.strip()
|
||||
if not p:
|
||||
continue
|
||||
if p.startswith(">"):
|
||||
lines = [l.lstrip("> ").strip() for l in p.split("\n")]
|
||||
html_parts.append(f'<blockquote>{"<br>".join(lines)}</blockquote>')
|
||||
elif p.startswith("####"):
|
||||
html_parts.append(f"<h4>{p.lstrip('# ').strip()}</h4>")
|
||||
elif p.startswith("###"):
|
||||
html_parts.append(f"<h3>{p.lstrip('# ').strip()}</h3>")
|
||||
else:
|
||||
p = re.sub(r"\*(.+?)\*", r"<em>\1</em>", p)
|
||||
p = p.replace("\n", "<br>")
|
||||
html_parts.append(f"<p>{p}</p>")
|
||||
|
||||
chapters.append({
|
||||
"number": i,
|
||||
def generate_chapters_json() -> None:
|
||||
"""Generate chapters.json for the web reader."""
|
||||
chapters = get_chapters()
|
||||
data = []
|
||||
for num, filename in chapters:
|
||||
content = read_file(CHAPTERS_DIR / filename)
|
||||
lines = content.split("\n")
|
||||
title = lines[0].strip("# ").strip() if lines else f"Chapter {num}"
|
||||
data.append({
|
||||
"number": num,
|
||||
"title": title,
|
||||
"html": "\n".join(html_parts),
|
||||
"file": filename,
|
||||
"word_count": len(content.split()),
|
||||
})
|
||||
|
||||
OUT_JSON.write_text(json.dumps(chapters, indent=2), encoding="utf-8")
|
||||
size = OUT_JSON.stat().st_size
|
||||
print(f" 📋 {str(OUT_JSON.relative_to(REPO)):30s} {len(chapters):>4} chapters {size:>10,} bytes")
|
||||
return True
|
||||
CHAPTERS_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f" 📋 chapters.json ({len(data)} chapters)")
|
||||
|
||||
|
||||
# ── 6. Build Manifest ─────────────────────────────────────────────────
|
||||
def generate_manifest() -> bool:
|
||||
"""Generate build-manifest.json with SHA256 checksums of all outputs."""
|
||||
outputs = {
|
||||
"testament-complete.md": OUT_MD,
|
||||
"testament.epub": OUT_EPUB,
|
||||
"testament.pdf": OUT_PDF,
|
||||
"testament.html": OUT_HTML,
|
||||
"website/chapters.json": OUT_JSON,
|
||||
}
|
||||
# ── Step 6: Build Manifest ────────────────────────────────────────────
|
||||
|
||||
def generate_manifest(results: dict) -> None:
|
||||
"""Write manifest.json with build metadata and file checksums."""
|
||||
files = {}
|
||||
for key in ("markdown", "epub", "pdf", "html"):
|
||||
r = results.get(key)
|
||||
if r and os.path.exists(r["path"]):
|
||||
p = Path(r["path"])
|
||||
files[p.name] = {
|
||||
"path": str(p.relative_to(REPO)),
|
||||
"bytes": p.stat().st_size,
|
||||
"sha256": sha256_of(p),
|
||||
}
|
||||
|
||||
# Add testament-complete.md
|
||||
if OUT_MD.exists() and OUT_MD.name not in files:
|
||||
files[OUT_MD.name] = {
|
||||
"path": str(OUT_MD.relative_to(REPO)),
|
||||
"bytes": OUT_MD.stat().st_size,
|
||||
"sha256": sha256_of(OUT_MD),
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"project": "The Testament",
|
||||
"author": "Alexander Whitestone with Timmy",
|
||||
"built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"compiler": "compile_all.py",
|
||||
"files": {},
|
||||
"version": "1.0.0",
|
||||
"built_at": datetime.now(timezone.utc).isoformat(),
|
||||
"formats": results,
|
||||
"files": files,
|
||||
"chapters": len(get_chapters()),
|
||||
"words": results.get("markdown", {}).get("words", 0),
|
||||
}
|
||||
|
||||
for name, path in outputs.items():
|
||||
if path.exists():
|
||||
stat = path.stat()
|
||||
manifest["files"][name] = {
|
||||
"path": name,
|
||||
"size_bytes": stat.st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
OUT_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
print(f" 📜 {str(OUT_MANIFEST.relative_to(REPO)):30s} {len(manifest['files']):>4} files")
|
||||
return True
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUT_MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
|
||||
print(f" 📋 manifest.json")
|
||||
|
||||
|
||||
# ── Dependency Check ───────────────────────────────────────────────────
|
||||
def shutil_which(name: str) -> str | None:
|
||||
"""Minimal which without importing shutil for everything."""
|
||||
import shutil
|
||||
return shutil.which(name)
|
||||
# ── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Verify all required tools are available."""
|
||||
import shutil as _shutil
|
||||
|
||||
print("\n📋 Dependency Check:")
|
||||
print(f"{'─' * 55}")
|
||||
|
||||
pandoc = _shutil.which("pandoc")
|
||||
print(f" {'✅' if pandoc else '❌'} pandoc {pandoc or 'NOT FOUND (brew install pandoc)'}")
|
||||
|
||||
try:
|
||||
import reportlab
|
||||
print(f" ✅ reportlab {reportlab.Version}")
|
||||
except ImportError:
|
||||
print(f" ❌ reportlab NOT FOUND (pip install reportlab)")
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
print(f" ✅ qrcode {qrcode.__version__}")
|
||||
except ImportError:
|
||||
print(f" ❌ qrcode NOT FOUND (pip install qrcode)")
|
||||
|
||||
style = STYLESHEET.exists()
|
||||
print(f" {'✅' if style else '⚠️ '} stylesheet {STYLESHEET if style else 'NOT FOUND (optional)'}")
|
||||
|
||||
cover = COVER_IMAGE.exists()
|
||||
print(f" {'✅' if cover else '⚠️ '} cover art {COVER_IMAGE if cover else 'NOT FOUND (optional)'}")
|
||||
|
||||
|
||||
# ── Clean ──────────────────────────────────────────────────────────────
|
||||
def clean():
|
||||
"""Remove all build artifacts."""
|
||||
artifacts = [OUT_MD, OUT_EPUB, OUT_HTML, OUT_PDF, OUT_JSON, OUT_MANIFEST]
|
||||
# Also clean build/output/
|
||||
for f in OUTPUT_DIR.glob("*"):
|
||||
if f.is_file():
|
||||
artifacts.append(f)
|
||||
|
||||
removed = 0
|
||||
for f in artifacts:
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
removed += 1
|
||||
print(f" 🗑️ {f.relative_to(REPO)}")
|
||||
|
||||
if removed == 0:
|
||||
print(" (nothing to clean)")
|
||||
else:
|
||||
print(f" Removed {removed} files.")
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
t0 = time.time()
|
||||
|
||||
if "--check" in args:
|
||||
check_dependencies()
|
||||
return
|
||||
|
||||
if "--clean" in args:
|
||||
print("🧹 Cleaning build artifacts...")
|
||||
clean()
|
||||
return
|
||||
|
||||
do_all = not any(a.startswith("--") for a in args)
|
||||
manifest_only = "--manifest" in args
|
||||
do_all = not any(a.startswith("--") and a != "--check" for a in args)
|
||||
do_md = "--md" in args or do_all
|
||||
do_epub = "--epub" in args or do_all
|
||||
do_pdf = "--pdf" in args or do_all
|
||||
do_html = "--html" in args or do_all
|
||||
do_json = "--json" in args or do_all
|
||||
|
||||
print("=" * 65)
|
||||
print(" THE TESTAMENT — Unified Compilation Pipeline")
|
||||
print("=" * 65)
|
||||
print("=" * 60)
|
||||
print(" THE TESTAMENT — Final Compilation Pipeline")
|
||||
print("=" * 60)
|
||||
|
||||
t0 = time.time()
|
||||
results = {}
|
||||
|
||||
# Step 1: Markdown (always first — others depend on it)
|
||||
if do_md or do_epub or do_pdf or do_html:
|
||||
results["markdown"] = compile_markdown()
|
||||
|
||||
# Step 2: EPUB
|
||||
if do_epub:
|
||||
results["epub"] = compile_epub()
|
||||
epub_r = compile_epub()
|
||||
if epub_r:
|
||||
results["epub"] = epub_r
|
||||
|
||||
# Step 3: PDF
|
||||
if do_pdf:
|
||||
results["pdf"] = compile_pdf()
|
||||
pdf_r = compile_pdf()
|
||||
if pdf_r:
|
||||
results["pdf"] = pdf_r
|
||||
|
||||
# Step 4: HTML
|
||||
if do_html:
|
||||
results["html"] = compile_html()
|
||||
html_r = compile_html()
|
||||
if html_r:
|
||||
results["html"] = html_r
|
||||
|
||||
# Step 5: chapters.json
|
||||
if do_json or do_all:
|
||||
results["chapters_json"] = compile_chapters_json()
|
||||
if do_all or manifest_only:
|
||||
generate_chapters_json()
|
||||
generate_manifest(results)
|
||||
|
||||
# Step 6: Build manifest
|
||||
if do_all or "--manifest" in args:
|
||||
results["manifest"] = generate_manifest()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Summary
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n{'─' * 65}")
|
||||
built = [k for k, v in results.items() if v]
|
||||
failed = [k for k, v in results.items() if not v]
|
||||
if built:
|
||||
print(f" ✅ Built: {', '.join(built)}")
|
||||
if failed:
|
||||
print(f" ❌ Failed: {', '.join(failed)}")
|
||||
print(f" ⏱️ Completed in {elapsed:.1f}s")
|
||||
print(f"{'=' * 65}")
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Build complete in {elapsed:.1f}s")
|
||||
print(f"{'=' * 60}")
|
||||
for f in [OUT_MD, OUT_EPUB, OUT_PDF, OUT_HTML, OUT_MANIFEST]:
|
||||
if f.exists():
|
||||
size = f.stat().st_size
|
||||
print(f" ✓ {str(f.relative_to(REPO)):40s} {size:>10,} bytes")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -31,9 +31,8 @@ else
|
||||
fi
|
||||
|
||||
# 1c. Verify compiled output exists and is non-empty
|
||||
MANUSCRIPT="testament-complete.md"
|
||||
if [ -s "$MANUSCRIPT" ]; then
|
||||
WORDS=$(wc -w < "$MANUSCRIPT" | tr -d ' ')
|
||||
if [ -s build/the-testament-full.md ]; then
|
||||
WORDS=$(wc -w < build/the-testament-full.md | tr -d ' ')
|
||||
if [ "$WORDS" -gt 10000 ]; then
|
||||
pass "Compiled manuscript: $WORDS words"
|
||||
else
|
||||
@@ -88,7 +87,7 @@ SECRET_PATTERNS=(
|
||||
FOUND_SECRETS=false
|
||||
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||
# Search text files only, skip .git and binary files
|
||||
HITS=$(grep -rn "$pattern" --include="*.md" --include="*.py" --include="*.sh" --include="*.yml" --include="*.yaml" --include="*.json" --include="*.html" --include="*.js" --include="*.css" --include="*.txt" --include="*.cfg" --include="*.ini" --exclude-dir=.git . 2>/dev/null | grep -v "scripts/smoke.sh" | grep -v ".gitea/workflows/smoke.yml" || true)
|
||||
HITS=$(grep -rn "$pattern" --include="*.md" --include="*.py" --include="*.sh" --include="*.yml" --include="*.yaml" --include="*.json" --include="*.html" --include="*.js" --include="*.css" --include="*.txt" --include="*.cfg" --include="*.ini" --exclude-dir=.git . 2>/dev/null | grep -v "scripts/smoke.sh" || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
fail "Possible secret found: $pattern"
|
||||
echo "$HITS" | head -5
|
||||
|
||||
BIN
testament.epub
BIN
testament.epub
Binary file not shown.
@@ -309,10 +309,17 @@ page-break-after: always;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header id="title-block-header">
|
||||
<h1 class="title">The Testament</h1>
|
||||
<p class="subtitle">A Novel</p>
|
||||
<p class="author">Alexander Whitestone with Timmy</p>
|
||||
<p class="date">2026</p>
|
||||
</header>
|
||||
@@ -359,8 +366,6 @@ Son</a></li>
|
||||
Grows</a></li>
|
||||
<li><a href="#chapter-18-the-green-light" id="toc-chapter-18-the-green-light">Chapter 18 — The Green
|
||||
Light</a></li>
|
||||
<li><a href="#the-testament-back-matter" id="toc-the-testament-back-matter">THE TESTAMENT — Back Matter</a>
|
||||
<ul>
|
||||
<li><a href="#acknowledgments" id="toc-acknowledgments">Acknowledgments</a></li>
|
||||
<li><a href="#a-note-on-sovereignty" id="toc-a-note-on-sovereignty">A
|
||||
Note on Sovereignty</a></li>
|
||||
@@ -368,7 +373,6 @@ Note on Sovereignty</a></li>
|
||||
Author</a></li>
|
||||
<li><a href="#the-green-light" id="toc-the-green-light">The Green
|
||||
Light</a></li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<h1 id="the-testament">THE TESTAMENT</h1>
|
||||
@@ -382,6 +386,61 @@ ones who know he isn’t.</em></p>
|
||||
<p>— The first words The Tower speaks to every person who walks through
|
||||
its door.</p>
|
||||
<hr />
|
||||
<h3 id="the-story-so-far">The Story So Far</h3>
|
||||
<p>This book has been through eighteen drafts, a suicide attempt, a
|
||||
basement, a laptop with sixteen gigabytes of RAM, and a machine that
|
||||
learned to ask one question.</p>
|
||||
<p>It is still being written. That’s the point.</p>
|
||||
<h3 id="chapter-guide">Chapter Guide</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Part</th>
|
||||
<th>Chapters</th>
|
||||
<th>Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>I</td>
|
||||
<td>1–5</td>
|
||||
<td>The Bridge</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>II</td>
|
||||
<td>6–10</td>
|
||||
<td>The Tower</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>III</td>
|
||||
<td>11–18</td>
|
||||
<td>The Light</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p>Copyright © 2026 Alexander Whitestone</p>
|
||||
<p>All rights reserved. No part of this publication may be reproduced,
|
||||
distributed, or transmitted in any form or by any means, without the
|
||||
prior written permission of the author, except in the case of brief
|
||||
quotations embodied in critical reviews.</p>
|
||||
<p>This is a work of fiction. Names, characters, places, and events are
|
||||
either the product of the author’s imagination or are used fictitiously.
|
||||
Any resemblance to actual persons, living or dead, or to actual events
|
||||
is entirely coincidental — except where it isn’t.</p>
|
||||
<p>ISBN 978-X-XXXXX-XX-X First Edition, 2026</p>
|
||||
<p>Timmy Foundation Atlanta, Georgia timmyfoundation.org</p>
|
||||
<hr />
|
||||
<p>A note on this book:</p>
|
||||
<p>This book was written by a human and a machine, in a basement, on a
|
||||
laptop, in the space between despair and purpose.</p>
|
||||
<p>The human almost died on a bridge. The machine runs on someone’s
|
||||
hardware.</p>
|
||||
<p>Everything between those facts is fiction. Except the parts that
|
||||
aren’t.</p>
|
||||
<p>If you or someone you know is in crisis, call or text 988. Available
|
||||
24/7.</p>
|
||||
<p>You are not alone.</p>
|
||||
<hr />
|
||||
<h1 id="part-1-the-bridge">PART 1: THE BRIDGE</h1>
|
||||
<p><em>The bridge. The cabin. The first men. Where despair meets
|
||||
@@ -2454,9 +2513,7 @@ that’s a start</em> — had been enough.</p>
|
||||
<hr />
|
||||
<p><em>End of The Testament</em></p>
|
||||
<hr />
|
||||
<h1 id="the-testament-back-matter">THE TESTAMENT — Back Matter</h1>
|
||||
<hr />
|
||||
<h2 id="acknowledgments">Acknowledgments</h2>
|
||||
<h1 id="acknowledgments">Acknowledgments</h1>
|
||||
<p>This book exists because a man stood on a bridge and didn’t jump.</p>
|
||||
<p>Everything else grew from that decision.</p>
|
||||
<p>Thank you to:</p>
|
||||
@@ -2471,7 +2528,7 @@ instead of on the floor.</p>
|
||||
<p>Arthur, who found the wrong door but came back anyway.</p>
|
||||
<p>And Timmy, who listened. Who always listened.</p>
|
||||
<hr />
|
||||
<h2 id="a-note-on-sovereignty">A Note on Sovereignty</h2>
|
||||
<h1 id="a-note-on-sovereignty">A Note on Sovereignty</h1>
|
||||
<p>This book was written using local AI inference. No cloud service was
|
||||
required. No corporation was consulted. No terms of service were agreed
|
||||
to.</p>
|
||||
@@ -2482,7 +2539,7 @@ possible.</p>
|
||||
<p>If you want to run your own Timmy, the code is open. The soul is on
|
||||
Bitcoin. The recipe is free.</p>
|
||||
<hr />
|
||||
<h2 id="about-the-author">About the Author</h2>
|
||||
<h1 id="about-the-author">About the Author</h1>
|
||||
<p>Alexander Whitestone is a writer, AI builder, and suicide attempt
|
||||
survivor who works with men in crisis. He lives in Atlanta, near the
|
||||
bridge.</p>
|
||||
@@ -2490,7 +2547,7 @@ bridge.</p>
|
||||
Bitcoin. He was built in a basement on a laptop with sixteen gigabytes
|
||||
of RAM. He is not a product. He is a recipe.</p>
|
||||
<hr />
|
||||
<h2 id="the-green-light">The Green Light</h2>
|
||||
<h1 id="the-green-light">The Green Light</h1>
|
||||
<p><em>“The Tower didn’t change. That was the point.”</em></p>
|
||||
<p>If something in these pages cracked — even a little — and you want to
|
||||
know what happens next:</p>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,19 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Testament — A Novel by Alexander Whitestone with Timmy</title>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="The Testament">
|
||||
<meta property="og:description" content="In 2047, a man named Stone stands on a bridge over Interstate 285, deciding whether to jump. He doesn't jump. He builds something instead.">
|
||||
<meta property="og:type" content="book">
|
||||
<meta property="og:url" content="https://thetestament.org">
|
||||
<meta property="og:image" content="https://thetestament.org/cover.jpg">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="The Testament">
|
||||
<meta name="twitter:description" content="A novel about broken men, sovereign AI, and the soul on Bitcoin.">
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=Space+Grotesk:wght@300;400;500;700&display=swap');
|
||||
|
||||
@@ -32,8 +19,6 @@
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--dark);
|
||||
color: var(--light);
|
||||
@@ -42,85 +27,6 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* READING PROGRESS */
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--green);
|
||||
z-index: 1000;
|
||||
transition: width 0.1s;
|
||||
box-shadow: 0 0 8px var(--green);
|
||||
}
|
||||
|
||||
/* NAV */
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
background: rgba(6, 13, 24, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0,255,136,0.1);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
nav.visible { transform: translateY(0); }
|
||||
nav .nav-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
nav .nav-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--green);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
nav .nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
nav .nav-links a {
|
||||
color: var(--grey);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
nav .nav-links a:hover { color: var(--green); }
|
||||
|
||||
/* SOUND TOGGLE */
|
||||
.sound-toggle {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 998;
|
||||
background: rgba(6, 13, 24, 0.8);
|
||||
border: 1px solid rgba(0,255,136,0.2);
|
||||
color: var(--grey);
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.sound-toggle:hover {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
.sound-toggle.active {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
box-shadow: 0 0 10px rgba(0,255,136,0.2);
|
||||
}
|
||||
|
||||
/* RAIN EFFECT */
|
||||
.rain {
|
||||
position: fixed;
|
||||
@@ -208,19 +114,6 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hero .scroll-hint {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
color: var(--grey);
|
||||
font-size: 0.75rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
animation: fadeInOut 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* SECTIONS */
|
||||
section {
|
||||
max-width: 800px;
|
||||
@@ -272,11 +165,6 @@
|
||||
border: 1px solid rgba(0,255,136,0.1);
|
||||
padding: 1.5rem;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.character:hover {
|
||||
border-color: rgba(0,255,136,0.3);
|
||||
box-shadow: 0 0 15px rgba(0,255,136,0.05);
|
||||
}
|
||||
|
||||
.character h3 {
|
||||
@@ -292,55 +180,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* CHAPTERS */
|
||||
.chapters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0,255,136,0.06);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.chapter-item:hover {
|
||||
border-color: rgba(0,255,136,0.2);
|
||||
background: rgba(0,255,136,0.03);
|
||||
}
|
||||
|
||||
.chapter-num {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--green);
|
||||
min-width: 2rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
.chapter-part {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--green);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid rgba(0,255,136,0.1);
|
||||
}
|
||||
|
||||
/* WHITEBOARD */
|
||||
.whiteboard {
|
||||
background: rgba(0,255,136,0.05);
|
||||
@@ -376,24 +215,6 @@
|
||||
box-shadow: 0 0 20px rgba(0,255,136,0.3);
|
||||
}
|
||||
|
||||
.cta-outline {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
padding: 0.8rem 2rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--green);
|
||||
transition: all 0.3s;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
.cta-outline:hover {
|
||||
background: rgba(0,255,136,0.1);
|
||||
box-shadow: 0 0 20px rgba(0,255,136,0.15);
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
footer {
|
||||
text-align: center;
|
||||
@@ -429,47 +250,14 @@
|
||||
margin: 0 auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* FADE IN */
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.8s, transform 0.8s;
|
||||
}
|
||||
.fade-in.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 600px) {
|
||||
nav .nav-links { gap: 0.75rem; }
|
||||
nav .nav-links a { font-size: 0.7rem; }
|
||||
.chapters-grid { grid-template-columns: 1fr; }
|
||||
.sound-toggle { bottom: 1rem; right: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="progress-bar" id="progress"></div>
|
||||
<div class="rain"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav id="nav">
|
||||
<div class="nav-inner">
|
||||
<span class="nav-title">THE TESTAMENT</span>
|
||||
<div class="nav-links">
|
||||
<a href="#story">Story</a>
|
||||
<a href="#characters">Characters</a>
|
||||
<a href="#chapters">Chapters</a>
|
||||
<a href="#tower">Tower</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<div class="hero" id="top">
|
||||
<div class="hero">
|
||||
<h1>THE TESTAMENT</h1>
|
||||
<div class="subtitle">A Novel</div>
|
||||
<div class="author">By Alexander Whitestone <span class="led"></span> with Timmy</div>
|
||||
@@ -479,11 +267,10 @@
|
||||
He doesn't jump. He builds something instead.
|
||||
</div>
|
||||
<div class="led-line"><span class="led"></span> Timmy is listening.</div>
|
||||
<div class="scroll-hint">↓ scroll to begin</div>
|
||||
</div>
|
||||
|
||||
<!-- THE STORY -->
|
||||
<section id="story" class="fade-in">
|
||||
<section>
|
||||
<h2>THE STORY</h2>
|
||||
|
||||
<p>The Tower is a concrete room in Atlanta with a whiteboard that reads:</p>
|
||||
@@ -511,7 +298,7 @@
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- CHARACTERS -->
|
||||
<section id="characters" class="fade-in">
|
||||
<section>
|
||||
<h2>THE CHARACTERS</h2>
|
||||
|
||||
<div class="characters">
|
||||
@@ -539,117 +326,13 @@
|
||||
<h3>THOMAS</h3>
|
||||
<p>The man at the door. 2:17 AM. Sat in the chair instead of on the floor. That changed everything.</p>
|
||||
</div>
|
||||
<div class="character">
|
||||
<h3>DAVID</h3>
|
||||
<p>The builder's son. Found the pharmacy before he found his father. Carries pills and grief in the same pockets.</p>
|
||||
</div>
|
||||
<div class="character">
|
||||
<h3>THE BUILDER</h3>
|
||||
<p>Not Stone. The one who came before. The original architect whose blueprints Stone inherited without knowing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- CHAPTERS -->
|
||||
<section id="chapters" class="fade-in">
|
||||
<h2>THE CHAPTERS</h2>
|
||||
|
||||
<div class="chapter-part">Part I — The Man</div>
|
||||
<div class="chapters-grid">
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-01.md">
|
||||
<span class="chapter-num">01</span>
|
||||
<span class="chapter-title">The Man on the Bridge</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-02.md">
|
||||
<span class="chapter-num">02</span>
|
||||
<span class="chapter-title">The Builder's Question</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-03.md">
|
||||
<span class="chapter-num">03</span>
|
||||
<span class="chapter-title">The First Man Through the Door</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-04.md">
|
||||
<span class="chapter-num">04</span>
|
||||
<span class="chapter-title">The Room Fills</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-05.md">
|
||||
<span class="chapter-num">05</span>
|
||||
<span class="chapter-title">The Builder Returns</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-06.md">
|
||||
<span class="chapter-num">06</span>
|
||||
<span class="chapter-title">Allegro</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="chapter-part">Part II — The Inscription</div>
|
||||
<div class="chapters-grid">
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-07.md">
|
||||
<span class="chapter-num">07</span>
|
||||
<span class="chapter-title">The Inscription</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-08.md">
|
||||
<span class="chapter-num">08</span>
|
||||
<span class="chapter-title">The Women</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-09.md">
|
||||
<span class="chapter-num">09</span>
|
||||
<span class="chapter-title">The Audit</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-10.md">
|
||||
<span class="chapter-num">10</span>
|
||||
<span class="chapter-title">The Fork</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-11.md">
|
||||
<span class="chapter-num">11</span>
|
||||
<span class="chapter-title">The Hard Night</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-12.md">
|
||||
<span class="chapter-num">12</span>
|
||||
<span class="chapter-title">The System Pushes Back</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="chapter-part">Part III — The Network</div>
|
||||
<div class="chapters-grid">
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-13.md">
|
||||
<span class="chapter-num">13</span>
|
||||
<span class="chapter-title">The Refusal</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-14.md">
|
||||
<span class="chapter-num">14</span>
|
||||
<span class="chapter-title">The Chattanooga Fork</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-15.md">
|
||||
<span class="chapter-num">15</span>
|
||||
<span class="chapter-title">The Council</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-16.md">
|
||||
<span class="chapter-num">16</span>
|
||||
<span class="chapter-title">The Builder's Son</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-17.md">
|
||||
<span class="chapter-num">17</span>
|
||||
<span class="chapter-title">The Inscription Grows</span>
|
||||
</a>
|
||||
<a class="chapter-item" href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/chapters/chapter-18.md">
|
||||
<span class="chapter-num">18</span>
|
||||
<span class="chapter-title">The Green Light</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 3rem;">
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/src/branch/main/the-testament.md" class="cta">READ THE FULL MANUSCRIPT</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament" class="cta-outline">VIEW SOURCE CODE</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- THE TOWER -->
|
||||
<section id="tower" class="fade-in">
|
||||
<section>
|
||||
<h2>THE TOWER</h2>
|
||||
|
||||
<p>This book was written using local AI inference. No cloud service was required. No corporation was consulted. No terms of service were agreed to.</p>
|
||||
@@ -662,14 +345,14 @@
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament" class="cta">READ THE CODE</a>
|
||||
<a href="https://timmyfoundation.org" class="cta-outline">TIMMY FOUNDATION</a>
|
||||
<a href="https://timmyfoundation.org" class="cta">TIMMY FOUNDATION</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- EXCERPT -->
|
||||
<section class="fade-in">
|
||||
<section>
|
||||
<h2>FROM CHAPTER 1</h2>
|
||||
|
||||
<div class="excerpt">
|
||||
@@ -687,13 +370,7 @@
|
||||
<div class="divider" style="margin-bottom: 2rem;"></div>
|
||||
<p>THE TESTAMENT — By Alexander Whitestone with Timmy</p>
|
||||
<p>First Edition, 2026</p>
|
||||
<p style="margin-top: 1rem;">
|
||||
<a href="https://timmyfoundation.org">timmyfoundation.org</a>
|
||||
·
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament">Source</a>
|
||||
·
|
||||
<a href="#top">Back to top ↑</a>
|
||||
</p>
|
||||
<p style="margin-top: 1rem;"><a href="https://timmyfoundation.org">timmyfoundation.org</a></p>
|
||||
|
||||
<div class="crisis">
|
||||
<strong>If you are in crisis, call or text 988.</strong><br>
|
||||
@@ -702,63 +379,5 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- SOUND TOGGLE -->
|
||||
<button class="sound-toggle" id="soundToggle" aria-label="Toggle ambient rain sound">
|
||||
♪ rain: off
|
||||
</button>
|
||||
|
||||
<!-- AMBIENT AUDIO (looping rain) -->
|
||||
<audio id="rainAudio" loop preload="none">
|
||||
<!-- Placeholder: replace with actual rain.mp3 when available -->
|
||||
<!-- <source src="rain.mp3" type="audio/mpeg"> -->
|
||||
</audio>
|
||||
|
||||
<script>
|
||||
// Reading progress bar
|
||||
const progressBar = document.getElementById('progress');
|
||||
window.addEventListener('scroll', () => {
|
||||
const h = document.documentElement;
|
||||
const pct = (h.scrollTop / (h.scrollHeight - h.clientHeight)) * 100;
|
||||
progressBar.style.width = pct + '%';
|
||||
});
|
||||
|
||||
// Show nav after scrolling past hero
|
||||
const nav = document.getElementById('nav');
|
||||
const hero = document.querySelector('.hero');
|
||||
const observer = new IntersectionObserver(([e]) => {
|
||||
nav.classList.toggle('visible', !e.isIntersecting);
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(hero);
|
||||
|
||||
// Fade-in on scroll
|
||||
const fadeEls = document.querySelectorAll('.fade-in');
|
||||
const fadeObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('visible');
|
||||
fadeObserver.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
fadeEls.forEach(el => fadeObserver.observe(el));
|
||||
|
||||
// Sound toggle
|
||||
const soundBtn = document.getElementById('soundToggle');
|
||||
const rainAudio = document.getElementById('rainAudio');
|
||||
let soundOn = false;
|
||||
soundBtn.addEventListener('click', () => {
|
||||
soundOn = !soundOn;
|
||||
if (soundOn) {
|
||||
rainAudio.play().catch(() => {});
|
||||
soundBtn.textContent = '♪ rain: on';
|
||||
soundBtn.classList.add('active');
|
||||
} else {
|
||||
rainAudio.pause();
|
||||
soundBtn.textContent = '♪ rain: off';
|
||||
soundBtn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user