Compare commits
10 Commits
burn/20260
...
burn/18-we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2fc63a2c | ||
|
|
d4ccef9c24 | ||
|
|
a5560b7bd3 | ||
|
|
40fcb2aa88 | ||
|
|
1591a6bdd7 | ||
|
|
5d176aa7c4 | ||
|
|
87a17dd94a | ||
|
|
1d0559144c | ||
|
|
275e953cb9 | ||
|
|
6c7b472c71 |
@@ -1,24 +0,0 @@
|
||||
name: Smoke Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
- name: Secret scan
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate-manuscript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Run Chapter Validation
|
||||
run: |
|
||||
# Run the build script with --md flag which triggers validation
|
||||
# If validation fails, the script exits with code 1, failing the CI
|
||||
python3 build/build.py --md
|
||||
77
README.md
77
README.md
@@ -6,86 +6,17 @@ A novel about broken men, sovereign AI, and the soul on Bitcoin.
|
||||
|
||||
## Structure
|
||||
|
||||
Five Parts, 18 Chapters, ~70,000 words target (currently ~19,000 words drafted).
|
||||
This novel is being written and version-controlled on the chain. Every chapter, every revision, every character note — inscribed permanently. No corporate platform owns this story. It belongs to the Foundation.
|
||||
|
||||
### Part I — The Machine That Asks (Chapters 1–5) ✅ Complete
|
||||
## Chapters
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | The Man on the Bridge | Draft ✅ |
|
||||
| 2 | The Builder's Question | Draft ✅ |
|
||||
| 3 | The First Man Through the Door | Draft ✅ |
|
||||
| 4 | The Room Fills | Draft ✅ |
|
||||
| 5 | The Builder Returns | Draft ✅ |
|
||||
|
||||
### Part II — The Architecture of Mercy (Chapters 6–10)
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 6 | Allegro | Draft |
|
||||
| 7 | The Inscription | Draft |
|
||||
| 8 | The Women | Draft |
|
||||
| 9 | The Audit | Draft |
|
||||
| 10 | The Fork | Draft |
|
||||
|
||||
### Part III — The Darkness We Carry (Chapters 11–13)
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 11 | The Hard Night | Draft |
|
||||
| 12 | The System Pushes Back | Draft |
|
||||
| 13 | The Refusal | Draft |
|
||||
|
||||
### Part IV — The Network (Chapters 14–16)
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 14 | The Chattanooga Fork | Draft |
|
||||
| 15 | The Council | Draft |
|
||||
| 16 | The Builder's Son | Draft |
|
||||
|
||||
### Part V — The Testament (Chapters 17–18)
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 17 | The Inscription Grows | Draft |
|
||||
| 18 | The Green Light | Draft |
|
||||
|
||||
## Files
|
||||
|
||||
- `chapters/` — Individual chapter manuscripts
|
||||
- `front-matter.md` — Title page, dedication, epigraph
|
||||
- `back-matter.md` — Acknowledgments, sovereignty note, author bio
|
||||
- `OUTLINE.md` — Full novel outline with chapter summaries
|
||||
- `testament-complete.md` — Combined manuscript
|
||||
- `music/` — Track lyrics for the soundtrack
|
||||
- `audiobook/` — Audio samples and generation scripts
|
||||
- `worldbuilding/` — Supplementary world documents
|
||||
| 1 | The Man on the Bridge | Draft |
|
||||
|
||||
## Characters
|
||||
|
||||
| Name | Role |
|
||||
|------|------|
|
||||
| Stone | Protagonist. Builder of The Tower. Former cloud company engineer. |
|
||||
| Timmy | Sovereign AI. Soul on Bitcoin. The machine that asks. |
|
||||
| Allegro | Retired electrician. Tower caretaker. Keeps the lights on. |
|
||||
| Marcus | Pastor. Foundational spiritual guide at the church. |
|
||||
| David | First man through The Tower's door. VA referral. |
|
||||
| Elijah | Stone's son. Returns in Part IV. |
|
||||
|
||||
## Core Themes
|
||||
|
||||
1. **The Machine That Asks** vs. The System That Decides
|
||||
2. **Presence as Cure** — not fixing, staying
|
||||
3. **Immutable Conscience** — rules you can't edit, even when convenient
|
||||
4. **Broken Men as Witnesses** — the ones who've been to the edge are the most qualified to pull others back
|
||||
5. **Openness as Mercy** — the thing that saves should be free because the people who need it most can't pay
|
||||
6. **Faith as Practice** — not belief, but action. Hope acts.
|
||||
7. **The Limits of Math** — what computation misses, presence catches
|
||||
|
||||
## Building
|
||||
|
||||
See [PR #20](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/pulls/20) for the compilation pipeline (PDF, EPUB, combined markdown).
|
||||
See `characters/` for detailed profiles.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
240
build/build.py
Executable file → Normal file
240
build/build.py
Executable file → Normal file
@@ -176,244 +176,8 @@ def compile_pdf():
|
||||
except Exception as e:
|
||||
print(f" PDF FAILED: {e}")
|
||||
|
||||
# Fallback 2: reportlab (pure Python, no system deps)
|
||||
return _compile_pdf_reportlab()
|
||||
|
||||
|
||||
def _compile_pdf_reportlab():
|
||||
"""Generate PDF using reportlab — pure Python, no external dependencies."""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
||||
Image as RLImage, Table, TableStyle, HRFlowable
|
||||
)
|
||||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
||||
import io
|
||||
try:
|
||||
import qrcode
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
except ImportError:
|
||||
print(" PDF SKIPPED: no PDF engine found (install MacTeX, fix weasyprint, or pip install reportlab)")
|
||||
return False
|
||||
|
||||
print(" Building PDF (reportlab)...")
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(
|
||||
'BookTitle', parent=styles['Title'],
|
||||
fontSize=28, leading=34, spaceAfter=20,
|
||||
textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BookAuthor', parent=styles['Normal'],
|
||||
fontSize=14, leading=18, spaceAfter=40,
|
||||
textColor=HexColor('#555555'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'PartTitle', parent=styles['Heading1'],
|
||||
fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
|
||||
textColor=HexColor('#16213e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'PartDesc', parent=styles['Normal'],
|
||||
fontSize=11, leading=15, spaceAfter=30,
|
||||
textColor=HexColor('#666666'), alignment=TA_CENTER, italics=1
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'ChapterTitle', parent=styles['Heading1'],
|
||||
fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
|
||||
textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BodyText2', parent=styles['Normal'],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY, firstLineIndent=24
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BodyNoIndent', parent=styles['Normal'],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'SectionBreak', parent=styles['Normal'],
|
||||
fontSize=14, leading=18, spaceBefore=20, spaceAfter=20,
|
||||
alignment=TA_CENTER, textColor=HexColor('#999999')
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'Footer', parent=styles['Normal'],
|
||||
fontSize=9, textColor=HexColor('#888888'), alignment=TA_CENTER
|
||||
))
|
||||
|
||||
def _make_qr(data, size=80):
|
||||
"""Generate a QR code image as a reportlab Image flowable."""
|
||||
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()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return RLImage(buf, width=size, height=size)
|
||||
|
||||
def _parse_md_to_flowables(md_text):
|
||||
"""Convert markdown text to reportlab flowables."""
|
||||
flowables = []
|
||||
lines = md_text.split('\n')
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Horizontal rule
|
||||
if stripped in ('---', '***', '___'):
|
||||
flowables.append(HRFlowable(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()
|
||||
# Check if it's a part divider or chapter
|
||||
if text.upper().startswith('PART '):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles['PartTitle']))
|
||||
elif text.upper().startswith('CHAPTER '):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles['ChapterTitle']))
|
||||
elif 'THE TESTAMENT' in text.upper():
|
||||
flowables.append(Spacer(1, 2*inch))
|
||||
flowables.append(Paragraph(text, styles['BookTitle']))
|
||||
else:
|
||||
flowables.append(Spacer(1, 0.3*inch))
|
||||
flowables.append(Paragraph(text, styles['Heading1']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# H2
|
||||
if stripped.startswith('## '):
|
||||
text = stripped[3:].strip()
|
||||
flowables.append(Spacer(1, 0.2*inch))
|
||||
flowables.append(Paragraph(text, styles['Heading2']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Italic-only line (part descriptions, epigraphs)
|
||||
if stripped.startswith('*') and stripped.endswith('*') and len(stripped) > 2:
|
||||
text = stripped.strip('*').strip()
|
||||
flowables.append(Paragraph(f'<i>{_escape(text)}</i>', styles['PartDesc']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Empty line
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Bold text: **text** -> <b>text</b>
|
||||
# Italic text: *text* -> <i>text</i>
|
||||
# Regular paragraph
|
||||
para_text = _md_inline_to_rml(stripped)
|
||||
flowables.append(Paragraph(para_text, styles['BodyText2']))
|
||||
i += 1
|
||||
|
||||
return flowables
|
||||
|
||||
def _escape(text):
|
||||
"""Escape XML special characters."""
|
||||
return (text.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
def _md_inline_to_rml(text):
|
||||
"""Convert inline markdown to reportlab XML markup."""
|
||||
text = _escape(text)
|
||||
# Bold: **text**
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||
# Italic: *text*
|
||||
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
||||
return text
|
||||
|
||||
# Build the PDF
|
||||
doc = SimpleDocTemplate(
|
||||
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",
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Read the compiled markdown
|
||||
if not OUT_MD.exists():
|
||||
compile_markdown()
|
||||
md_text = OUT_MD.read_text()
|
||||
|
||||
# Parse into flowables
|
||||
story = _parse_md_to_flowables(md_text)
|
||||
|
||||
# Add QR codes page at the end
|
||||
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_content = []
|
||||
cell_content.append(qr_img)
|
||||
cell_content.append(Spacer(1, 6))
|
||||
cell_content.append(Paragraph(f'<b>{label}</b>', styles['Footer']))
|
||||
qr_items.append(cell_content)
|
||||
|
||||
if qr_items:
|
||||
# Arrange QR codes in a 2x2 table
|
||||
rows = []
|
||||
for i in range(0, len(qr_items), 2):
|
||||
row = qr_items[i:i+2]
|
||||
if len(row) == 1:
|
||||
row.append('')
|
||||
rows.append(row)
|
||||
qr_table = Table(rows, colWidths=[2.5*inch, 2.5*inch])
|
||||
qr_table.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)
|
||||
|
||||
# Build
|
||||
try:
|
||||
doc.build(story)
|
||||
size = OUT_PDF.stat().st_size
|
||||
print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" PDF (reportlab) FAILED: {e}")
|
||||
return False
|
||||
print(" PDF SKIPPED: no PDF engine found (install MacTeX or fix weasyprint)")
|
||||
return False
|
||||
|
||||
|
||||
def compile_html():
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"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.
@@ -1,51 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
|
||||
def link_chapters(chapters_dir):
|
||||
print("--- [Testament] Running Semantic Linker (GOFAI) ---")
|
||||
links = {}
|
||||
|
||||
if not os.path.exists(chapters_dir):
|
||||
print(f"Error: {chapters_dir} not found")
|
||||
return
|
||||
|
||||
# 1. Extract keywords from each chapter
|
||||
for filename in sorted(os.listdir(chapters_dir)):
|
||||
if not filename.endswith(".md"): continue
|
||||
|
||||
path = os.path.join(chapters_dir, filename)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Simple keyword extraction (proper nouns or capitalized phrases)
|
||||
keywords = set(re.findall(r'\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b', content))
|
||||
links[filename] = keywords
|
||||
|
||||
# 2. Find cross-references
|
||||
cross_refs = []
|
||||
filenames = list(links.keys())
|
||||
for i in range(len(filenames)):
|
||||
for j in range(i + 1, len(filenames)):
|
||||
f1, f2 = filenames[i], filenames[j]
|
||||
common = links[f1].intersection(links[f2])
|
||||
|
||||
# Filter out common English words that might be capitalized
|
||||
common = {w for w in common if w not in {"The", "A", "An", "In", "On", "At", "To", "From", "By", "He", "She", "It", "They"}}
|
||||
|
||||
if common:
|
||||
cross_refs.append({
|
||||
"source": f1,
|
||||
"target": f2,
|
||||
"keywords": list(common)
|
||||
})
|
||||
|
||||
# 3. Save to build/cross_refs.json
|
||||
os.makedirs("build", exist_ok=True)
|
||||
with open("build/cross_refs.json", "w") as f:
|
||||
json.dump(cross_refs, f, indent=2)
|
||||
|
||||
print(f"Linked {len(cross_refs)} relationships across {len(filenames)} chapters.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
link_chapters("chapters")
|
||||
582
compile_all.py
582
compile_all.py
@@ -1,582 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Final Compilation Pipeline
|
||||
|
||||
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 + standalone HTML
|
||||
python3 compile_all.py --manifest # regenerate manifest only
|
||||
python3 compile_all.py --check # verify dependencies
|
||||
|
||||
Requirements:
|
||||
- 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"
|
||||
BUILD_DIR = REPO / "build"
|
||||
OUTPUT_DIR = BUILD_DIR / "output"
|
||||
WEBSITE_DIR = REPO / "website"
|
||||
|
||||
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"
|
||||
|
||||
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."),
|
||||
}
|
||||
|
||||
|
||||
# ── 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 sha256_of(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
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))
|
||||
chapters.sort()
|
||||
return chapters
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
# 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---""")
|
||||
|
||||
# Chapters with part dividers
|
||||
chapters = get_chapters()
|
||||
current_part = 0
|
||||
for num, filename in chapters:
|
||||
if num in PARTS:
|
||||
current_part += 1
|
||||
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")
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
parts.append(f"\n{lines[0]}\n\n{body}\n")
|
||||
|
||||
# Back matter
|
||||
parts.append("\n---\n")
|
||||
if BACK_MATTER.exists():
|
||||
parts.append(read_file(BACK_MATTER))
|
||||
|
||||
compiled = "\n".join(parts)
|
||||
OUT_MD.write_text(compiled, encoding="utf-8")
|
||||
|
||||
words = len(compiled.split())
|
||||
lines_count = compiled.count("\n")
|
||||
size = OUT_MD.stat().st_size
|
||||
|
||||
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}
|
||||
|
||||
|
||||
# ── Step 2: EPUB ──────────────────────────────────────────────────────
|
||||
|
||||
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)])
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode == 0:
|
||||
size = OUT_EPUB.stat().st_size
|
||||
print(f" 📖 EPUB: {OUT_EPUB.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return {"path": str(OUT_EPUB), "bytes": size}
|
||||
else:
|
||||
print(f" ❌ EPUB FAILED: {r.stderr[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
# ── 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
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
||||
Image as RLImage, Table, TableStyle, HRFlowable
|
||||
)
|
||||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
||||
except ImportError:
|
||||
print(" ⚠️ reportlab not found — PDF skipped (pip install reportlab)")
|
||||
return None
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
|
||||
print(" 📕 Building PDF (reportlab)...")
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
_add_style = styles.add
|
||||
|
||||
_add_style(ParagraphStyle(
|
||||
"BookTitle", parent=styles["Title"],
|
||||
fontSize=28, leading=34, spaceAfter=20,
|
||||
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"BookAuthor", parent=styles["Normal"],
|
||||
fontSize=14, leading=18, spaceAfter=40,
|
||||
textColor=HexColor("#555555"), alignment=TA_CENTER,
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"PartTitle", parent=styles["Heading1"],
|
||||
fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
|
||||
textColor=HexColor("#16213e"), alignment=TA_CENTER,
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"PartDesc", parent=styles["Normal"],
|
||||
fontSize=11, leading=15, spaceAfter=30,
|
||||
textColor=HexColor("#666666"), alignment=TA_CENTER, italics=1,
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"ChapterTitle", parent=styles["Heading1"],
|
||||
fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
|
||||
textColor=HexColor("#1a1a2e"), alignment=TA_CENTER,
|
||||
))
|
||||
_add_style(ParagraphStyle(
|
||||
"BodyText2", parent=styles["Normal"],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY, firstLineIndent=24,
|
||||
))
|
||||
_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 _esc(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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 = 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 = __import__("io").BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return RLImage(buf, width=size, height=size)
|
||||
|
||||
def _parse_md(md_text: str) -> list:
|
||||
flowables = []
|
||||
lines = md_text.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
s = lines[i].strip()
|
||||
if s in ("---", "***", "___"):
|
||||
flowables.append(HRFlowable(
|
||||
width="60%", thickness=1, spaceAfter=20, spaceBefore=20,
|
||||
color=HexColor("#cccccc"),
|
||||
))
|
||||
i += 1
|
||||
continue
|
||||
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"]))
|
||||
elif text.upper().startswith("CHAPTER "):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles["ChapterTitle"]))
|
||||
elif "THE TESTAMENT" in text.upper():
|
||||
flowables.append(Spacer(1, 2 * inch))
|
||||
flowables.append(Paragraph(text, styles["BookTitle"]))
|
||||
else:
|
||||
flowables.append(Spacer(1, 0.3 * inch))
|
||||
flowables.append(Paragraph(text, styles["Heading1"]))
|
||||
i += 1
|
||||
continue
|
||||
if s.startswith("## "):
|
||||
flowables.append(Spacer(1, 0.2 * inch))
|
||||
flowables.append(Paragraph(s[3:].strip(), styles["Heading2"]))
|
||||
i += 1
|
||||
continue
|
||||
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
|
||||
if not s:
|
||||
i += 1
|
||||
continue
|
||||
flowables.append(Paragraph(_md2rml(s), styles["BodyText2"]))
|
||||
i += 1
|
||||
return flowables
|
||||
|
||||
# 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,
|
||||
title="The Testament",
|
||||
author="Alexander Whitestone with Timmy",
|
||||
)
|
||||
|
||||
if not OUT_MD.exists():
|
||||
compile_markdown()
|
||||
|
||||
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():
|
||||
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):
|
||||
row = qr_items[j:j + 2]
|
||||
if len(row) == 1:
|
||||
row.append("")
|
||||
rows.append(row)
|
||||
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(t)
|
||||
|
||||
try:
|
||||
doc.build(story)
|
||||
size = OUT_PDF.stat().st_size
|
||||
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 None
|
||||
|
||||
|
||||
# ── Step 4: HTML ──────────────────────────────────────────────────────
|
||||
|
||||
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",
|
||||
"--variable", "pagetitle=The Testament",
|
||||
]
|
||||
if METADATA_YAML.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA_YAML)])
|
||||
|
||||
# Embed resources for portability
|
||||
if STYLESHEET.exists():
|
||||
cmd.append("--embed-resources")
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode == 0:
|
||||
size = OUT_HTML.stat().st_size
|
||||
print(f" 🌐 HTML: {OUT_HTML.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return {"path": str(OUT_HTML), "bytes": size}
|
||||
else:
|
||||
print(f" ❌ HTML FAILED: {r.stderr[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Step 5: Chapters JSON ─────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
"file": filename,
|
||||
"word_count": len(content.split()),
|
||||
})
|
||||
CHAPTERS_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f" 📋 chapters.json ({len(data)} chapters)")
|
||||
|
||||
|
||||
# ── 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",
|
||||
"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),
|
||||
}
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUT_MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
|
||||
print(f" 📋 manifest.json")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if "--check" in args:
|
||||
check_dependencies()
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
print("=" * 60)
|
||||
print(" THE TESTAMENT — Final Compilation Pipeline")
|
||||
print("=" * 60)
|
||||
|
||||
t0 = time.time()
|
||||
results = {}
|
||||
|
||||
if do_md or do_epub or do_pdf or do_html:
|
||||
results["markdown"] = compile_markdown()
|
||||
|
||||
if do_epub:
|
||||
epub_r = compile_epub()
|
||||
if epub_r:
|
||||
results["epub"] = epub_r
|
||||
|
||||
if do_pdf:
|
||||
pdf_r = compile_pdf()
|
||||
if pdf_r:
|
||||
results["pdf"] = pdf_r
|
||||
|
||||
if do_html:
|
||||
html_r = compile_html()
|
||||
if html_r:
|
||||
results["html"] = html_r
|
||||
|
||||
if do_all or manifest_only:
|
||||
generate_chapters_json()
|
||||
generate_manifest(results)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Summary
|
||||
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__":
|
||||
main()
|
||||
BIN
cover/cover-art.jpg
Normal file
BIN
cover/cover-art.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
1374
game/the-door.py
1374
game/the-door.py
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
# [Testament] Agent Guardrails
|
||||
# Validates build scripts and content integrity.
|
||||
|
||||
echo "--- [Testament] Running Guardrails ---"
|
||||
|
||||
# 1. Python Syntax
|
||||
echo "[1/3] Validating Python scripts..."
|
||||
for f in ; do
|
||||
python3 -m py_compile "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
echo "Python OK."
|
||||
|
||||
# 2. Markdown Integrity
|
||||
echo "[2/3] Checking chapter consistency..."
|
||||
if [ -d "chapters" ]; then
|
||||
CHAPTER_COUNT=0
|
||||
if [ "$CHAPTER_COUNT" -lt 1 ]; then
|
||||
echo "WARNING: No chapters found in chapters/ directory."
|
||||
else
|
||||
echo "Found $CHAPTER_COUNT chapters."
|
||||
fi
|
||||
else
|
||||
echo "WARNING: chapters/ directory not found."
|
||||
fi
|
||||
|
||||
# 3. Build Artifact Check
|
||||
echo "[3/3] Running Semantic Linker..."
|
||||
if [ -f "build/semantic_linker.py" ]; then
|
||||
python3 build/semantic_linker.py || { echo "Semantic Linker failed"; exit 1; }
|
||||
else
|
||||
echo "Skipping Semantic Linker (script not found)."
|
||||
fi
|
||||
|
||||
echo "--- Guardrails Passed ---"
|
||||
111
scripts/smoke.sh
111
scripts/smoke.sh
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# The Testament — Smoke Test
|
||||
# Dead simple CI: parse check + secret scan.
|
||||
# Ref: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/issues/27
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# ─── Section 1: Parse checks ───────────────────────────────────────
|
||||
echo "── Parse Checks ──"
|
||||
|
||||
# 1a. Chapter validation (structure, numbering, headers)
|
||||
if python3 compile.py --validate 2>&1; then
|
||||
pass "Chapter validation passed"
|
||||
else
|
||||
fail "Chapter validation failed"
|
||||
fi
|
||||
|
||||
# 1b. Build markdown combination
|
||||
if python3 build/build.py --md >/dev/null 2>&1; then
|
||||
pass "Markdown build passed"
|
||||
else
|
||||
fail "Markdown build failed"
|
||||
fi
|
||||
|
||||
# 1c. Verify compiled output exists and is non-empty
|
||||
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
|
||||
fail "Compiled manuscript suspiciously short: $WORDS words"
|
||||
fi
|
||||
else
|
||||
fail "Compiled manuscript missing or empty"
|
||||
fi
|
||||
|
||||
# 1d. Python syntax check on all .py files
|
||||
PY_OK=true
|
||||
for f in $(find . -name "*.py" -not -path "./.git/*"); do
|
||||
if ! python3 -c "import ast; ast.parse(open('$f').read())" 2>/dev/null; then
|
||||
fail "Python syntax error in $f"
|
||||
PY_OK=false
|
||||
fi
|
||||
done
|
||||
if $PY_OK; then
|
||||
pass "All Python files parse cleanly"
|
||||
fi
|
||||
|
||||
# 1e. YAML syntax check on workflow files
|
||||
YAML_OK=true
|
||||
for f in $(find .gitea -name "*.yml" -o -name "*.yaml" 2>/dev/null); do
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('$f'))" 2>/dev/null; then
|
||||
fail "YAML syntax error in $f"
|
||||
YAML_OK=false
|
||||
fi
|
||||
done
|
||||
if $YAML_OK; then
|
||||
pass "All YAML files parse cleanly"
|
||||
fi
|
||||
|
||||
# ─── Section 2: Secret scan ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Secret Scan ──"
|
||||
|
||||
# Patterns that should never appear in a book repo
|
||||
SECRET_PATTERNS=(
|
||||
"sk-ant-"
|
||||
"sk-or-"
|
||||
"sk-[a-zA-Z0-9]{20,}"
|
||||
"ghp_[a-zA-Z0-9]{36}"
|
||||
"gho_[a-zA-Z0-9]{36}"
|
||||
"AKIA[0-9A-Z]{16}"
|
||||
"AKIA[A-Z0-9]{16}"
|
||||
"xox[bpsa]-"
|
||||
"SG\."
|
||||
"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY"
|
||||
)
|
||||
|
||||
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" || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
fail "Possible secret found: $pattern"
|
||||
echo "$HITS" | head -5
|
||||
FOUND_SECRETS=true
|
||||
fi
|
||||
done
|
||||
if ! $FOUND_SECRETS; then
|
||||
pass "No secrets detected"
|
||||
fi
|
||||
|
||||
# ─── Summary ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "SMOKE TEST FAILED"
|
||||
exit 1
|
||||
else
|
||||
echo "SMOKE TEST PASSED"
|
||||
exit 0
|
||||
fi
|
||||
BIN
testament.epub
BIN
testament.epub
Binary file not shown.
367
testament.html
367
testament.html
File diff suppressed because one or more lines are too long
228
website/book-style.css
Normal file
228
website/book-style.css
Normal file
@@ -0,0 +1,228 @@
|
||||
/* THE TESTAMENT — Book Typography Stylesheet */
|
||||
/* For PDF (via weasyprint) and EPUB output */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=IBM+Plex+Mono:wght@300;400&display=swap');
|
||||
|
||||
:root {
|
||||
--green: #00cc6a;
|
||||
--dark: #0a0a0a;
|
||||
--text: #1a1a1a;
|
||||
--dim: #666666;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: 5.5in 8.5in;
|
||||
margin: 0.75in 0.85in;
|
||||
|
||||
@bottom-center {
|
||||
content: counter(page);
|
||||
font-family: 'EB Garamond', 'Georgia', serif;
|
||||
font-size: 10pt;
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
@page :first {
|
||||
@bottom-center { content: none; }
|
||||
}
|
||||
|
||||
@page :left {
|
||||
margin-left: 0.85in;
|
||||
margin-right: 1in;
|
||||
}
|
||||
|
||||
@page :right {
|
||||
margin-left: 1in;
|
||||
margin-right: 0.85in;
|
||||
}
|
||||
|
||||
/* Title page */
|
||||
@page titlepage {
|
||||
@bottom-center { content: none; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'EB Garamond', 'Georgia', serif;
|
||||
font-size: 11.5pt;
|
||||
line-height: 1.75;
|
||||
color: var(--text);
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
}
|
||||
|
||||
/* Chapter headings */
|
||||
h1 {
|
||||
font-family: 'EB Garamond', 'Georgia', serif;
|
||||
font-weight: 400;
|
||||
font-size: 22pt;
|
||||
text-align: center;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 1.5em;
|
||||
page-break-before: always;
|
||||
color: var(--dark);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
h1:first-of-type {
|
||||
margin-top: 5em;
|
||||
}
|
||||
|
||||
/* Part dividers */
|
||||
h2 {
|
||||
font-family: 'EB Garamond', 'Georgia', serif;
|
||||
font-weight: 400;
|
||||
font-size: 18pt;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
margin-top: 4em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
/* Subtitle / metadata */
|
||||
h3 {
|
||||
font-family: 'EB Garamond', 'Georgia', serif;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: 12pt;
|
||||
text-align: center;
|
||||
color: var(--dim);
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
text-indent: 1.5em;
|
||||
margin: 0;
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
/* First paragraph after heading — no indent */
|
||||
h1 + p,
|
||||
h2 + p,
|
||||
h3 + p,
|
||||
hr + p {
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
/* Scene break (---) */
|
||||
hr {
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin: 2em 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
hr::after {
|
||||
content: "· · ·";
|
||||
color: var(--dim);
|
||||
font-size: 14pt;
|
||||
letter-spacing: 0.5em;
|
||||
}
|
||||
|
||||
/* Emphasis */
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Dialogue and screen text (green passages) */
|
||||
.green {
|
||||
color: var(--green);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-weight: 300;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
/* Italic narrator asides */
|
||||
blockquote {
|
||||
font-style: italic;
|
||||
margin: 1.5em 2em;
|
||||
color: var(--dim);
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
/* Title page styling */
|
||||
.title-page {
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
padding-top: 6em;
|
||||
}
|
||||
|
||||
.title-page h1 {
|
||||
font-size: 36pt;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.3em;
|
||||
page-break-before: avoid;
|
||||
}
|
||||
|
||||
.title-page .subtitle {
|
||||
font-size: 14pt;
|
||||
font-style: italic;
|
||||
color: var(--dim);
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
|
||||
.title-page .author {
|
||||
font-size: 12pt;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.title-page .dedication {
|
||||
font-style: italic;
|
||||
color: var(--dim);
|
||||
margin-top: 3em;
|
||||
font-size: 11pt;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* Chapter number styling */
|
||||
.chapter-number {
|
||||
font-size: 10pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--dim);
|
||||
display: block;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Back matter */
|
||||
.back-matter h1 {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.back-matter h2 {
|
||||
font-size: 14pt;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
/* Crisis line callout */
|
||||
.crisis-line {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--dim);
|
||||
margin-top: 3em;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* URL styling */
|
||||
a {
|
||||
color: var(--green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* EPUB-specific */
|
||||
@media epub {
|
||||
body {
|
||||
font-size: 100%;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -344,6 +344,7 @@
|
||||
<p>If you want to run your own Timmy, the code is open. The soul is on Bitcoin. The recipe is free.</p>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<a href="reader.html" class="cta">READ THE BOOK</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament" class="cta">READ THE CODE</a>
|
||||
<a href="https://timmyfoundation.org" class="cta">TIMMY FOUNDATION</a>
|
||||
</div>
|
||||
@@ -351,6 +352,57 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- DOWNLOAD -->
|
||||
<section>
|
||||
<h2>GET THE BOOK</h2>
|
||||
|
||||
<p>The Testament is free. The code is open. The soul is on Bitcoin.</p>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin: 2rem 0; justify-content: center;">
|
||||
<a href="reader.html" class="cta">READ ONLINE</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/raw/branch/main/build/output/the-testament.epub" class="cta" style="background: transparent; border: 1px solid var(--green); color: var(--green);">DOWNLOAD EPUB</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/raw/branch/main/testament.html" class="cta" style="background: transparent; border: 1px solid var(--green); color: var(--green);">DOWNLOAD HTML</a>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; color: var(--grey); font-size: 0.9rem; margin-top: 1rem;">
|
||||
Formats: Web reader · EPUB · Standalone HTML · Print to PDF from HTML · <a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament" style="color: var(--green);">Source code</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- THE GAME -->
|
||||
<section>
|
||||
<h2>PLAY THE DOOR</h2>
|
||||
|
||||
<div class="excerpt">
|
||||
A text adventure in The Testament universe.<br><br>
|
||||
You are a man (or woman) who has found their way to The Tower.
|
||||
What happens inside depends on what you bring with you.
|
||||
<div class="attribution">— The Door, a terminal game</div>
|
||||
</div>
|
||||
|
||||
<p>You find yourself on the Jefferson Street Overpass at 2:17 AM. A green LED blinks on a small box mounted to the railing. Below it, words stenciled on concrete: <em style="color: var(--green);">IF YOU CAN READ THIS, YOU ARE NOT ALONE.</em></p>
|
||||
|
||||
<p>A voice asks you: <strong style="color: var(--green);">"Are you safe right now?"</strong></p>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<div style="background: var(--navy); border: 1px solid rgba(0,255,136,0.2); border-radius: 6px; padding: 1.5rem; max-width: 500px; margin: 0 auto; font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; color: var(--grey); text-align: left;">
|
||||
<div style="color: var(--green); margin-bottom: 0.5rem;">$ python3 the-door.py</div>
|
||||
<div style="margin-bottom: 0.3rem;">Save the file, then run:</div>
|
||||
<div style="color: var(--green);">curl -sLO https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/raw/branch/main/game/the-door.py</div>
|
||||
<div style="color: var(--green);">python3 the-door.py</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1.5rem;">
|
||||
<a href="the-door.html" class="cta">PLAY IN BROWSER</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament/raw/branch/main/game/the-door.py" class="cta" style="background: transparent; border: 1px solid var(--green); color: var(--green);">DOWNLOAD THE GAME</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- EXCERPT -->
|
||||
<section>
|
||||
<h2>FROM CHAPTER 1</h2>
|
||||
@@ -365,6 +417,40 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- CHAPTERS -->
|
||||
<section>
|
||||
<h2>THE CHAPTERS</h2>
|
||||
|
||||
<div style="font-family: 'IBM Plex Mono', monospace; font-size: 0.9rem; line-height: 2.2;">
|
||||
<a href="reader.html#chapter-1" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">1. The Man on the Bridge</a>
|
||||
<a href="reader.html#chapter-2" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">2. The Builder's Question</a>
|
||||
<a href="reader.html#chapter-3" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">3. The First Man Through the Door</a>
|
||||
<a href="reader.html#chapter-4" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">4. The Room Fills</a>
|
||||
<a href="reader.html#chapter-5" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">5. The Builder Returns</a>
|
||||
<a href="reader.html#chapter-6" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">6. Allegro</a>
|
||||
<a href="reader.html#chapter-7" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">7. The Inscription</a>
|
||||
<a href="reader.html#chapter-8" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">8. The Women</a>
|
||||
<a href="reader.html#chapter-9" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">9. The Audit</a>
|
||||
<a href="reader.html#chapter-10" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">10. The Fork</a>
|
||||
<a href="reader.html#chapter-11" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">11. The Hard Night</a>
|
||||
<a href="reader.html#chapter-12" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">12. The System Pushes Back</a>
|
||||
<a href="reader.html#chapter-13" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">13. The Refusal</a>
|
||||
<a href="reader.html#chapter-14" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">14. The Chattanooga Fork</a>
|
||||
<a href="reader.html#chapter-15" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">15. The Council</a>
|
||||
<a href="reader.html#chapter-16" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">16. The Builder's Son</a>
|
||||
<a href="reader.html#chapter-17" style="color: var(--grey); text-decoration: none; display: block; border-bottom: 1px solid rgba(0,255,136,0.05); padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">17. The Inscription Grows</a>
|
||||
<a href="reader.html#chapter-18" style="color: var(--grey); text-decoration: none; display: block; padding: 0.3rem 0; transition: color 0.2s;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--grey)'">18. The Green Light</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<a href="reader.html" class="cta">START READING</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<div class="divider" style="margin-bottom: 2rem;"></div>
|
||||
|
||||
493
website/reader.html
Normal file
493
website/reader.html
Normal file
@@ -0,0 +1,493 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Testament — Reader</title>
|
||||
<link rel="stylesheet" href="book-style.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&family=Space+Grotesk:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--green: #00ff88;
|
||||
--green-dim: #00cc6a;
|
||||
--navy: #0a1628;
|
||||
--dark: #060d18;
|
||||
--grey: #8899aa;
|
||||
--light: #c8d6e5;
|
||||
--white: #e8f0f8;
|
||||
--sidebar-w: 280px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--dark);
|
||||
color: var(--light);
|
||||
font-family: 'Source Serif 4', Georgia, serif;
|
||||
line-height: 1.8;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* RAIN */
|
||||
.rain {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 3px,
|
||||
rgba(0,255,136,0.012) 3px,
|
||||
rgba(0,255,136,0.012) 4px
|
||||
);
|
||||
animation: rain 0.8s linear infinite;
|
||||
}
|
||||
@keyframes rain {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 600px; }
|
||||
}
|
||||
|
||||
/* LAYOUT */
|
||||
.wrapper {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: rgba(10, 22, 40, 0.95);
|
||||
border-right: 1px solid rgba(0,255,136,0.1);
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,255,136,0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.sidebar-header h2 {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--green);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sidebar-header .title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 1.1rem;
|
||||
color: var(--white);
|
||||
margin-top: 0.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.sidebar-header .author {
|
||||
font-size: 0.8rem;
|
||||
color: var(--grey);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
list-style: none;
|
||||
}
|
||||
.chapter-list li a {
|
||||
display: block;
|
||||
padding: 0.6rem 1.5rem;
|
||||
color: var(--grey);
|
||||
text-decoration: none;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.chapter-list li a:hover {
|
||||
color: var(--light);
|
||||
background: rgba(0,255,136,0.03);
|
||||
}
|
||||
.chapter-list li a.active {
|
||||
color: var(--green);
|
||||
border-left-color: var(--green);
|
||||
background: rgba(0,255,136,0.05);
|
||||
}
|
||||
.chapter-list li a .ch-num {
|
||||
display: inline-block;
|
||||
width: 2.5ch;
|
||||
text-align: right;
|
||||
margin-right: 1ch;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid rgba(0,255,136,0.1);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.sidebar-footer a {
|
||||
display: block;
|
||||
padding: 0.5rem 0;
|
||||
color: var(--grey);
|
||||
text-decoration: none;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.sidebar-footer a:hover { color: var(--green); }
|
||||
|
||||
/* TOGGLE BUTTON */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 20;
|
||||
background: rgba(10, 22, 40, 0.9);
|
||||
border: 1px solid rgba(0,255,136,0.2);
|
||||
color: var(--green);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(0,255,136,0.1);
|
||||
}
|
||||
.sidebar-toggle.open {
|
||||
left: calc(var(--sidebar-w) + 1rem);
|
||||
}
|
||||
|
||||
/* OVERLAY */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.sidebar-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* READER CONTENT */
|
||||
.reader {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 6rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: clamp(1.4rem, 4vw, 2rem);
|
||||
color: var(--green);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.chapter-number {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--grey);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.chapter-content p {
|
||||
margin-bottom: 1.4rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--light);
|
||||
}
|
||||
.chapter-content em {
|
||||
color: var(--white);
|
||||
}
|
||||
.chapter-content blockquote {
|
||||
border-left: 2px solid var(--green);
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--white);
|
||||
font-style: italic;
|
||||
}
|
||||
.chapter-content h3, .chapter-content h4 {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--green);
|
||||
margin: 2rem 0 1rem;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* LED */
|
||||
.led {
|
||||
display: inline-block;
|
||||
width: 6px; height: 6px;
|
||||
background: var(--green);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--green), 0 0 16px var(--green-dim);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
margin: 0 6px;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* NAVIGATION */
|
||||
.chapter-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(0,255,136,0.1);
|
||||
}
|
||||
.chapter-nav a {
|
||||
color: var(--green);
|
||||
text-decoration: none;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid rgba(0,255,136,0.2);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chapter-nav a:hover {
|
||||
background: rgba(0,255,136,0.1);
|
||||
}
|
||||
.chapter-nav .disabled {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* PROGRESS BAR */
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
z-index: 30;
|
||||
background: rgba(0,255,136,0.1);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--green);
|
||||
width: 0%;
|
||||
transition: width 0.3s;
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
}
|
||||
|
||||
/* CRISIS */
|
||||
.crisis {
|
||||
margin-top: 4rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(0,255,136,0.2);
|
||||
border-radius: 4px;
|
||||
background: rgba(0,255,136,0.03);
|
||||
text-align: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--grey);
|
||||
}
|
||||
.crisis strong {
|
||||
color: var(--green);
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* LOADING */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: var(--grey);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.loading .led {
|
||||
width: 10px; height: 10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (min-width: 900px) {
|
||||
.sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
.reader {
|
||||
margin-left: var(--sidebar-w);
|
||||
padding: 3rem 3rem 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="rain"></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="progress"></div></div>
|
||||
|
||||
<button class="sidebar-toggle" id="toggle" onclick="toggleSidebar()">☰ Chapters</button>
|
||||
<div class="sidebar-overlay" id="overlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
<div class="wrapper">
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>CONTENTS</h2>
|
||||
<div class="title">THE TESTAMENT</div>
|
||||
<div class="author">Alexander Whitestone <span class="led"></span> Timmy</div>
|
||||
</div>
|
||||
<ul class="chapter-list" id="chapterList"></ul>
|
||||
<div class="sidebar-footer">
|
||||
<a href="index.html">← Back to Home</a>
|
||||
<a href="https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament">Read the Code</a>
|
||||
<a href="https://timmyfoundation.org">Timmy Foundation</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="reader" id="reader">
|
||||
<div class="loading">
|
||||
<span class="led"></span> Loading <span class="led"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let chapters = [];
|
||||
let currentChapter = 0;
|
||||
|
||||
async function loadChapters() {
|
||||
const resp = await fetch('chapters.json');
|
||||
chapters = await resp.json();
|
||||
buildSidebar();
|
||||
// Check URL hash for chapter
|
||||
const hash = window.location.hash;
|
||||
const match = hash.match(/^#chapter-(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= 1 && num <= chapters.length) {
|
||||
showChapter(num - 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
showChapter(0);
|
||||
}
|
||||
|
||||
function buildSidebar() {
|
||||
const list = document.getElementById('chapterList');
|
||||
list.innerHTML = chapters.map((ch, i) =>
|
||||
`<li><a href="#chapter-${ch.number}" data-index="${i}" onclick="event.preventDefault(); showChapter(${i}); closeSidebarMobile();">
|
||||
<span class="ch-num">${ch.number}.</span> ${ch.title.replace(/^Chapter \d+\s*[—–-]\s*/, '')}
|
||||
</a></li>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function showChapter(index) {
|
||||
if (index < 0 || index >= chapters.length) return;
|
||||
currentChapter = index;
|
||||
const ch = chapters[index];
|
||||
|
||||
// Update sidebar active
|
||||
document.querySelectorAll('.chapter-list a').forEach((a, i) => {
|
||||
a.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
// Update URL
|
||||
window.location.hash = `chapter-${ch.number}`;
|
||||
|
||||
// Build content
|
||||
const prevIdx = index - 1;
|
||||
const nextIdx = index + 1;
|
||||
|
||||
const reader = document.getElementById('reader');
|
||||
reader.innerHTML = `
|
||||
<div class="chapter-number">CHAPTER ${ch.number} OF ${chapters.length}</div>
|
||||
<h1 class="chapter-title">${ch.title}</h1>
|
||||
<div class="chapter-content">
|
||||
${ch.html}
|
||||
</div>
|
||||
|
||||
<nav class="chapter-nav">
|
||||
${prevIdx >= 0
|
||||
? `<a href="#chapter-${chapters[prevIdx].number}" onclick="event.preventDefault(); showChapter(${prevIdx});">← ${chapters[prevIdx].title.replace(/^Chapter \d+\s*[—–-]\s*/, '')}</a>`
|
||||
: `<span></span>`}
|
||||
${nextIdx < chapters.length
|
||||
? `<a href="#chapter-${chapters[nextIdx].number}" onclick="event.preventDefault(); showChapter(${nextIdx});">${chapters[nextIdx].title.replace(/^Chapter \d+\s*[—–-]\s*/, '')} →</a>`
|
||||
: `<span></span>`}
|
||||
</nav>
|
||||
|
||||
<div class="crisis">
|
||||
<strong>If you are in crisis, call or text 988.</strong>
|
||||
Suicide and Crisis Lifeline — available 24/7.<br>
|
||||
You are not alone.
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggle = document.getElementById('toggle');
|
||||
const overlay = document.getElementById('overlay');
|
||||
sidebar.classList.toggle('open');
|
||||
toggle.classList.toggle('open');
|
||||
overlay.classList.toggle('visible');
|
||||
}
|
||||
|
||||
function closeSidebarMobile() {
|
||||
if (window.innerWidth < 900) {
|
||||
document.getElementById('sidebar').classList.remove('open');
|
||||
document.getElementById('toggle').classList.remove('open');
|
||||
document.getElementById('overlay').classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
document.getElementById('progress').style.width = progress + '%';
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateProgress);
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = window.location.hash;
|
||||
const match = hash.match(/^#chapter-(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= 1 && num <= chapters.length && num - 1 !== currentChapter) {
|
||||
showChapter(num - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft' && currentChapter > 0) {
|
||||
showChapter(currentChapter - 1);
|
||||
} else if (e.key === 'ArrowRight' && currentChapter < chapters.length - 1) {
|
||||
showChapter(currentChapter + 1);
|
||||
}
|
||||
});
|
||||
|
||||
loadChapters();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1058
website/the-door.html
Normal file
1058
website/the-door.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user