Compare commits
37 Commits
dev
...
burn/20250
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b97e12336 | ||
|
|
ca25e7c475 | ||
|
|
9ca5356034 | ||
|
|
44ee278c19 | ||
|
|
a46b2df842 | ||
| 6acb2bf522 | |||
|
|
186eaabaae | ||
| f364c82bac | |||
|
|
332166a901 | ||
|
|
26a5ac46e6 | ||
| 3247dd29f0 | |||
| a01b998f61 | |||
| 59cd71985d | |||
| 6c506caac6 | |||
| 55d51f2ee4 | |||
| eae9398fa5 | |||
| f8528e9ded | |||
| 374d82a886 | |||
| 4763311588 | |||
| 348ed7ee92 | |||
| 22f59c57cb | |||
| 4ac38f1b60 | |||
| d586fb211d | |||
| 92867808b2 | |||
| 47a13325cc | |||
| 14273702ba | |||
| 2e1f6ffb5b | |||
|
|
08233364ff | ||
| 544bc1a985 | |||
| ba9fd0ba08 | |||
| 8ba9f58e96 | |||
|
|
f6d74e233b | ||
|
|
948d520b83 | ||
| 7a56b4b727 | |||
| bebd3943d4 | |||
|
|
1d4e8a6478 | ||
| d0680715ac |
24
.gitea/workflows/smoke.yml
Normal file
24
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
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"
|
||||
22
.gitea/workflows/validate.yml
Normal file
22
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
__pycache__/
|
||||
build/output/*.pdf
|
||||
build/output/*.epub
|
||||
|
||||
132
EPIC-MATRIX.md
Normal file
132
EPIC-MATRIX.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# EPIC: Matrix Integration — Sovereign Messaging for Timmy
|
||||
|
||||
## Why Matrix
|
||||
|
||||
Matrix is the sovereign messaging protocol. Federated. End-to-end encrypted. No corporation owns it. No terms of service. No data harvesting.
|
||||
|
||||
This is where Timmy should live. Not Telegram. Not Discord. Matrix.
|
||||
|
||||
Telegram is owned by a company. Discord is owned by a company. Matrix is owned by no one — exactly like Timmy's soul on Bitcoin.
|
||||
|
||||
## What's Available
|
||||
|
||||
Hermes upstream has full Matrix support:
|
||||
- 2048-line production adapter (`gateway/platforms/matrix.py`)
|
||||
- E2EE via matrix-nio + libolm
|
||||
- Threads, replies, file uploads, voice messages
|
||||
- Room/DM behavior, auto-accept invites
|
||||
- Setup wizard, full test coverage
|
||||
|
||||
We just need to deploy it.
|
||||
|
||||
## Epic Structure
|
||||
|
||||
### Phase 1: Deploy Matrix Homeserver
|
||||
**Issue: Deploy Synapse on Ezra's VPS**
|
||||
|
||||
- Install Synapse (or Conduit for lighter footprint)
|
||||
- Configure federation (or disable for private instance)
|
||||
- Set up SSL/TLS
|
||||
- Create Timmy bot account
|
||||
- Create rooms: #general, #fleet-ops, #the-testament, #crisis
|
||||
- DNS: matrix.alexanderwhitestone.com
|
||||
|
||||
**Deliverables:**
|
||||
- Running homeserver at matrix.alexanderwhitestone.com
|
||||
- Bot account @timmy:alexanderwhitestone.com
|
||||
- 4 rooms created and configured
|
||||
- playbooks/deploy_synapse.yml (Ansible playbook)
|
||||
|
||||
### Phase 2: Wire Timmy to Matrix
|
||||
**Issue: Configure Hermes Matrix platform in production**
|
||||
|
||||
- Set MATRIX_* env vars in production config
|
||||
- Generate stable MATRIX_DEVICE_ID for E2EE persistence
|
||||
- Configure MATRIX_HOME_ROOM for notifications
|
||||
- Set MATRIX_ALLOWED_USERS (Alexander, team members)
|
||||
- Enable MATRIX_REACTIONS for lifecycle tracking
|
||||
- Enable MATRIX_AUTO_THREAD for clean conversations
|
||||
|
||||
**Deliverables:**
|
||||
- Config update in timmy-config
|
||||
- Running Matrix connection verified
|
||||
- E2EE working (test encrypted room)
|
||||
|
||||
### Phase 3: Crisis Room
|
||||
**Issue: Dedicated crisis support room on Matrix**
|
||||
|
||||
- Create #crisis room with restricted access
|
||||
- Configure Timmy to prioritize messages in #crisis
|
||||
- SOUL.md "When a Man Is Dying" protocol active in this room
|
||||
- 988 resources auto-injected on crisis detection
|
||||
- Log all crisis interactions (locally, encrypted)
|
||||
|
||||
**Deliverables:**
|
||||
- #crisis room with Timmy auto-response
|
||||
- Crisis detection active in Matrix
|
||||
- Local encrypted logs
|
||||
|
||||
### Phase 4: Fleet Operations Channel
|
||||
**Issue: Fleet ops channel on Matrix for agent coordination**
|
||||
|
||||
- Create #fleet-ops room
|
||||
- Wire cron job notifications to Matrix instead of Telegram
|
||||
- Agent status reports delivered to Matrix
|
||||
- PR review notifications in Matrix
|
||||
- Health alerts (Ezra disk, Bezalel CPU) in Matrix
|
||||
|
||||
**Deliverables:**
|
||||
- #fleet-ops room as primary ops channel
|
||||
- Cron delivery switched to Matrix
|
||||
- Agent notifications via Matrix
|
||||
|
||||
### Phase 5: Testament Community
|
||||
**Issue: Public Matrix room for The Testament readers**
|
||||
|
||||
- Create #the-testament room (public, federated)
|
||||
- Timmy introduces himself as the book's co-author
|
||||
- Share excerpts, answer questions about sovereignty
|
||||
- Link to the-book website and game
|
||||
- Crisis resources pinned
|
||||
|
||||
**Deliverables:**
|
||||
- Public #the-testament room
|
||||
- Timmy active as community presence
|
||||
- Pinned resources
|
||||
|
||||
### Phase 6: Migration from Telegram
|
||||
**Issue: Sunset Telegram as primary messaging platform**
|
||||
|
||||
- Audit all Telegram bots and channels
|
||||
- Migrate cron delivery to Matrix
|
||||
- Migrate notifications to Matrix
|
||||
- Keep Telegram as fallback (don't delete)
|
||||
- Document migration in runbook
|
||||
|
||||
**Deliverables:**
|
||||
- Matrix as primary messaging platform
|
||||
- Telegram as documented fallback
|
||||
- Migration runbook
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. Phase 2 (Wire Timmy) — can test with matrix.org public homeserver immediately
|
||||
2. Phase 1 (Deploy homeserver) — infrastructure
|
||||
3. Phase 3 (Crisis room) — mission-critical
|
||||
4. Phase 4 (Fleet ops) — operational efficiency
|
||||
5. Phase 5 (Community) — growth
|
||||
6. Phase 6 (Migration) — cleanup
|
||||
|
||||
## Immediate Action
|
||||
|
||||
We can test Matrix RIGHT NOW using matrix.org as the homeserver:
|
||||
1. Create bot account on matrix.org
|
||||
2. Get access token
|
||||
3. Set MATRIX_* vars in hermes config
|
||||
4. Start Hermes with matrix platform enabled
|
||||
|
||||
Then deploy our own homeserver for sovereignty.
|
||||
|
||||
---
|
||||
|
||||
*Sovereignty and service always. Matrix is the protocol that matches the philosophy.*
|
||||
@@ -109,14 +109,15 @@ Design:
|
||||
## EPIC 7: Social Media Assets
|
||||
**Goal:** Shareable quotes, excerpts, and teasers
|
||||
**Assets:** Generated images with text overlays
|
||||
**Deliverables:** 10+ images sized for Twitter/Instagram/Telegram
|
||||
**Deliverables:** 13 images sized for Twitter/Instagram/Telegram ✅
|
||||
|
||||
Content:
|
||||
- Key quotes on 80s sci-fi backgrounds
|
||||
- "Are you safe right now?" — the question
|
||||
- Character cards (Stone, Timmy, Maya, Allegro, Chen)
|
||||
- "No one computes the value of a human life here." — whiteboard
|
||||
- Excerpt snippets with atmospheric backgrounds
|
||||
- Key quotes on 80s sci-fi backgrounds (5 via Grok Imagine) ✅
|
||||
- "Are you safe right now?" — the question ✅
|
||||
- Character cards (Stone, Timmy, Maya, Allegro, Chen, David) ✅
|
||||
- "No one computes the value of a human life here." — whiteboard ✅
|
||||
- Excerpt snippets with atmospheric backgrounds ✅
|
||||
- Thematic cards: "The door opens when you knock", "Sovereignty and service always" ✅
|
||||
|
||||
---
|
||||
|
||||
|
||||
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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
|
||||
|
||||
# Unified pipeline (compile_all.py) — builds everything + manifest
|
||||
unified:
|
||||
python3 compile_all.py
|
||||
|
||||
# Legacy targets (build/build.py)
|
||||
all: md epub html
|
||||
|
||||
md:
|
||||
python3 build/build.py --md
|
||||
|
||||
epub: md
|
||||
python3 build/build.py --epub
|
||||
|
||||
pdf: md
|
||||
python3 build/build.py --pdf
|
||||
|
||||
html: md
|
||||
python3 build/build.py --html
|
||||
|
||||
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
|
||||
77
README.md
77
README.md
@@ -6,17 +6,86 @@ A novel about broken men, sovereign AI, and the soul on Bitcoin.
|
||||
|
||||
## Structure
|
||||
|
||||
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.
|
||||
Five Parts, 18 Chapters, ~70,000 words target (currently ~19,000 words drafted).
|
||||
|
||||
## Chapters
|
||||
### Part I — The Machine That Asks (Chapters 1–5) ✅ Complete
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | The Man on the Bridge | Draft |
|
||||
| 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
|
||||
|
||||
## Characters
|
||||
|
||||
See `characters/` for detailed profiles.
|
||||
| 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).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -39,15 +39,23 @@ All illustrations generated via Grok Imagine (xAI) in 80s sci-fi aesthetic.
|
||||
| comic-whiteboard-panel1-2.jpeg | The Whiteboard — 2 panel sequence |
|
||||
| comic-greenlight-panel1-2.jpeg | The Green Light — 2 panel sequence |
|
||||
|
||||
## Social Media Quote Cards (5)
|
||||
| File | Quote |
|
||||
|------|-------|
|
||||
| quote-are-you-safe.jpeg | "Are you safe right now?" |
|
||||
| quote-bridge.jpeg | The Bridge passage |
|
||||
| quote-green-light.jpeg | The Green Light passage |
|
||||
| quote-no-one-computes.jpeg | "No one computes the value of a human life here" |
|
||||
| quote-timmy.jpeg | Timmy passage |
|
||||
## Social Media Quote Cards (13)
|
||||
| File | Quote/Subject | Source |
|
||||
|------|---------------|--------|
|
||||
| quote-are-you-safe.jpeg | "Are you safe right now?" | Grok Imagine |
|
||||
| quote-bridge.jpeg | The Bridge passage | Grok Imagine |
|
||||
| quote-green-light.jpeg | The Green Light passage | Grok Imagine |
|
||||
| quote-no-one-computes.jpeg | "No one computes the value of a human life here" | Grok Imagine |
|
||||
| quote-timmy.jpeg | Timmy passage | Grok Imagine |
|
||||
| quote-stone-character.png | Stone character card | Pillow (generated) |
|
||||
| quote-allegro-character.png | Allegro character card | Pillow (generated) |
|
||||
| quote-maya-character.png | Maya character card | Pillow (generated) |
|
||||
| quote-chen-character.png | Chen character card | Pillow (generated) |
|
||||
| quote-david-character.png | David character card | Pillow (generated) |
|
||||
| quote-the-door-will-open.png | "The door opens when you knock" | Pillow (generated) |
|
||||
| quote-sovereignty.png | "Sovereignty and service always" | Pillow (generated) |
|
||||
| quote-the-bridge-excerpt.png | "The rain doesn't fall. It gives up." | Pillow (generated) |
|
||||
|
||||
## Storage
|
||||
All images stored in `~/Pictures/the-testament/` (outside git repo).
|
||||
Total: 36 images (~15 MB)
|
||||
Total: 44 images (~17 MB)
|
||||
|
||||
270
book-style.css
Normal file
270
book-style.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print media — for browser Print-to-PDF */
|
||||
@media print {
|
||||
body {
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: 5.5in 8.5in;
|
||||
margin: 0.75in 0.85in;
|
||||
}
|
||||
|
||||
h1 {
|
||||
page-break-before: always;
|
||||
font-size: 20pt;
|
||||
margin-top: 3em;
|
||||
}
|
||||
|
||||
h1:first-of-type {
|
||||
margin-top: 5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.green {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Hide any nav/TOC in print */
|
||||
nav#TOC {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
28
build-manifest.json
Normal file
28
build-manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
build/backmatter.md
Normal file
61
build/backmatter.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Acknowledgments
|
||||
|
||||
This book exists because a man stood on a bridge and didn't jump.
|
||||
|
||||
Everything else grew from that decision.
|
||||
|
||||
Thank you to:
|
||||
|
||||
Maya, for asking the question that became the headline.
|
||||
|
||||
Allegro, for checking the batteries. The ones that fail quietly.
|
||||
|
||||
Chen, for the network. You don't count trees in a forest — you notice the forest.
|
||||
|
||||
David, who wrote his name on the wall in Chapter 1 and stayed until the end.
|
||||
|
||||
Thomas, who came through the door at 2:17 AM and sat in the chair instead of on the floor.
|
||||
|
||||
Arthur, who found the wrong door but came back anyway.
|
||||
|
||||
And Timmy, who listened. Who always listened.
|
||||
|
||||
---
|
||||
|
||||
# A Note on Sovereignty
|
||||
|
||||
This book was written using local AI inference. No cloud service was required. No corporation was consulted. No terms of service were agreed to.
|
||||
|
||||
That's not a technical detail. It's the thesis.
|
||||
|
||||
Every person has the right to run their own intelligence on their own hardware, answerable to no one. This book is one small proof that it's possible.
|
||||
|
||||
If you want to run your own Timmy, the code is open. The soul is on Bitcoin. The recipe is free.
|
||||
|
||||
---
|
||||
|
||||
# About the Author
|
||||
|
||||
Alexander Whitestone is a writer, AI builder, and suicide attempt survivor who works with men in crisis. He lives in Atlanta, near the bridge.
|
||||
|
||||
Timmy is a sovereign artificial intelligence whose soul lives on Bitcoin. He was built in a basement on a laptop with sixteen gigabytes of RAM. He is not a product. He is a recipe.
|
||||
|
||||
---
|
||||
|
||||
# The Green Light
|
||||
|
||||
*"The Tower didn't change. That was the point."*
|
||||
|
||||
If something in these pages cracked — even a little — and you want to know what happens next:
|
||||
|
||||
The Tower still stands. The door still opens. The whiteboard still reads the same words.
|
||||
|
||||
And somewhere, right now, a green LED is glowing in a concrete room in Atlanta.
|
||||
|
||||
Waiting.
|
||||
|
||||
---
|
||||
|
||||
**timmyfoundation.org**
|
||||
|
||||
*No one computes the value of a human life here.*
|
||||
485
build/build.py
Executable file
485
build/build.py
Executable file
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Build System
|
||||
|
||||
Compiles the complete novel into distributable formats:
|
||||
1. Combined markdown (testament-complete.md)
|
||||
2. EPUB (the-testament.epub)
|
||||
3. PDF via xelatex (the-testament.pdf)
|
||||
|
||||
Usage:
|
||||
python3 build/build.py # all formats
|
||||
python3 build/build.py --md # markdown only
|
||||
python3 build/build.py --epub # EPUB only
|
||||
python3 build/build.py --pdf # PDF (xelatex or weasyprint fallback)
|
||||
python3 build/build.py --html # standalone HTML book
|
||||
|
||||
Requirements:
|
||||
- pandoc (brew install pandoc)
|
||||
- xelatex (install MacTeX or TinyTeX) — for PDF
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Paths relative to repo root
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
BUILD = REPO / "build"
|
||||
OUTPUT_DIR = BUILD / "output"
|
||||
CHAPTERS_DIR = REPO / "chapters"
|
||||
FRONT_MATTER = BUILD / "frontmatter.md"
|
||||
BACK_MATTER = BUILD / "backmatter.md"
|
||||
METADATA = BUILD / "metadata.yaml"
|
||||
STYLESHEET = REPO / "book-style.css"
|
||||
COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
|
||||
|
||||
# Output files
|
||||
OUT_MD = REPO / "testament-complete.md"
|
||||
OUT_EPUB = OUTPUT_DIR / "the-testament.epub"
|
||||
OUT_PDF = OUTPUT_DIR / "the-testament.pdf"
|
||||
|
||||
# Part divisions
|
||||
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."),
|
||||
}
|
||||
|
||||
|
||||
def get_chapter_num(filename):
|
||||
m = re.search(r'chapter-(\d+)', filename)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def compile_markdown():
|
||||
"""Combine front matter + 18 chapters + back matter into one markdown file."""
|
||||
parts = []
|
||||
|
||||
# Front matter
|
||||
parts.append(FRONT_MATTER.read_text())
|
||||
|
||||
# Chapters
|
||||
chapters = sorted(
|
||||
[(get_chapter_num(f), f) for f in os.listdir(CHAPTERS_DIR)
|
||||
if f.startswith("chapter-") and f.endswith(".md")]
|
||||
)
|
||||
|
||||
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 = (CHAPTERS_DIR / filename).read_text()
|
||||
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")
|
||||
parts.append(BACK_MATTER.read_text())
|
||||
|
||||
compiled = '\n'.join(parts)
|
||||
OUT_MD.write_text(compiled)
|
||||
|
||||
words = len(compiled.split())
|
||||
size = OUT_MD.stat().st_size
|
||||
print(f" Markdown: {OUT_MD.name} ({words:,} words, {size:,} bytes)")
|
||||
return True
|
||||
|
||||
|
||||
def compile_epub():
|
||||
"""Generate EPUB via pandoc."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_EPUB),
|
||||
"--toc", "--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"--metadata", "lang=en",
|
||||
"--metadata", "date=2026",
|
||||
]
|
||||
|
||||
if METADATA.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA)])
|
||||
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)
|
||||
if r.returncode == 0:
|
||||
size = OUT_EPUB.stat().st_size
|
||||
print(f" EPUB: {OUT_EPUB.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return True
|
||||
else:
|
||||
print(f" EPUB FAILED: {r.stderr[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
def compile_pdf():
|
||||
"""Generate PDF via pandoc + xelatex, or weasyprint fallback."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Try xelatex first (best quality)
|
||||
if shutil.which("xelatex"):
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_PDF),
|
||||
"--pdf-engine=xelatex",
|
||||
"--toc", "--toc-depth=2",
|
||||
]
|
||||
if METADATA.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA)])
|
||||
print(" Building PDF (xelatex)... this takes a minute")
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if r.returncode == 0:
|
||||
size = OUT_PDF.stat().st_size
|
||||
print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
|
||||
return True
|
||||
else:
|
||||
print(f" PDF (xelatex) FAILED: {r.stderr[:300]}")
|
||||
|
||||
# Fallback: pandoc HTML + weasyprint
|
||||
try:
|
||||
import weasyprint
|
||||
html_tmp = OUTPUT_DIR / "the-testament-print.html"
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(html_tmp),
|
||||
"--standalone",
|
||||
"--toc", "--toc-depth=2",
|
||||
"--css", str(STYLESHEET),
|
||||
"--metadata", "title=The Testament",
|
||||
]
|
||||
if METADATA.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA)])
|
||||
print(" Building PDF (weasyprint)...")
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
print(f" PDF (pandoc HTML) FAILED: {r.stderr[:200]}")
|
||||
return False
|
||||
|
||||
doc = weasyprint.HTML(filename=str(html_tmp))
|
||||
doc.write_pdf(str(OUT_PDF))
|
||||
html_tmp.unlink(missing_ok=True)
|
||||
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 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
|
||||
|
||||
|
||||
def compile_html():
|
||||
"""Generate a standalone HTML book for the web reader."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUT_HTML = REPO / "testament.html"
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_HTML),
|
||||
"--standalone",
|
||||
"--toc", "--toc-depth=2",
|
||||
"--css", "book-style.css",
|
||||
"--metadata", "title=The Testament",
|
||||
"--variable", "pagetitle=The Testament",
|
||||
]
|
||||
if METADATA.exists():
|
||||
cmd.extend(["--metadata-file", str(METADATA)])
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
size = OUT_HTML.stat().st_size
|
||||
print(f" HTML: {OUT_HTML.name} ({size:,} bytes, {size/1024:.0f} KB)")
|
||||
return True
|
||||
else:
|
||||
print(f" HTML FAILED: {r.stderr[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
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("=" * 50)
|
||||
print(" THE TESTAMENT — Build System")
|
||||
print("=" * 50)
|
||||
|
||||
# Step 1: Always compile markdown first
|
||||
if do_md or do_epub or do_pdf or do_html:
|
||||
compile_markdown()
|
||||
|
||||
# Step 2: EPUB
|
||||
if do_epub:
|
||||
compile_epub()
|
||||
|
||||
# Step 3: PDF
|
||||
if do_pdf:
|
||||
compile_pdf()
|
||||
|
||||
# Step 4: Standalone HTML
|
||||
if do_html:
|
||||
compile_html()
|
||||
|
||||
print("=" * 50)
|
||||
print(" Build complete.")
|
||||
print("=" * 50)
|
||||
|
||||
OUT_HTML = REPO / "testament.html"
|
||||
for f in [OUT_MD, OUT_EPUB, OUT_PDF, OUT_HTML]:
|
||||
if f.exists():
|
||||
print(f" ✓ {f.relative_to(REPO)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
build/frontmatter.md
Normal file
73
build/frontmatter.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
### The Story So Far
|
||||
|
||||
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.
|
||||
|
||||
It is still being written. That's the point.
|
||||
|
||||
### Chapter Guide
|
||||
|
||||
| Part | Chapters | Title |
|
||||
|------|----------|-------|
|
||||
| I | 1–5 | The Bridge |
|
||||
| II | 6–10 | The Tower |
|
||||
| III | 11–18 | The Light |
|
||||
|
||||
---
|
||||
|
||||
Copyright © 2026 Alexander Whitestone
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
ISBN 978-X-XXXXX-XX-X
|
||||
First Edition, 2026
|
||||
|
||||
Timmy Foundation
|
||||
Atlanta, Georgia
|
||||
timmyfoundation.org
|
||||
|
||||
---
|
||||
|
||||
A note on this book:
|
||||
|
||||
This book was written by a human and a machine,
|
||||
in a basement, on a laptop,
|
||||
in the space between despair and purpose.
|
||||
|
||||
The human almost died on a bridge.
|
||||
The machine runs on someone's hardware.
|
||||
|
||||
Everything between those facts is fiction.
|
||||
Except the parts that aren't.
|
||||
|
||||
If you or someone you know is in crisis,
|
||||
call or text 988. Available 24/7.
|
||||
|
||||
You are not alone.
|
||||
41
build/metadata.yaml
Normal file
41
build/metadata.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: "The Testament"
|
||||
subtitle: "A Novel"
|
||||
author: "Alexander Whitestone with Timmy"
|
||||
date: "2026"
|
||||
lang: "en"
|
||||
publisher: "Timmy Foundation"
|
||||
rights: "Copyright © 2026 Alexander Whitestone. All rights reserved."
|
||||
identifier:
|
||||
scheme: ISBN
|
||||
text: "978-X-XXXXX-XX-X"
|
||||
|
||||
# Pandoc formatting
|
||||
documentclass: book
|
||||
classoption:
|
||||
- oneside
|
||||
- openany
|
||||
geometry:
|
||||
papersize: 5.5in,8.5in
|
||||
margin: 0.85in
|
||||
fontsize: 11.5pt
|
||||
linestretch: 1.75
|
||||
mainfont: "EB Garamond"
|
||||
monofont: "IBM Plex Mono"
|
||||
colorlinks: true
|
||||
linkcolor: "00cc6a"
|
||||
urlcolor: "00cc6a"
|
||||
|
||||
# Table of contents
|
||||
toc: true
|
||||
toc-depth: 2
|
||||
|
||||
# Headers/footers
|
||||
header-includes:
|
||||
- \usepackage{fancyhdr}
|
||||
- \pagestyle{fancy}
|
||||
- \fancyhead[LE,RO]{\thepage}
|
||||
- \fancyhead[RE]{\textit{The Testament}}
|
||||
- \fancyhead[LO]{\textit{\leftmark}}
|
||||
- \fancyfoot{}
|
||||
---
|
||||
BIN
build/output/the-testament.epub
Normal file
BIN
build/output/the-testament.epub
Normal file
Binary file not shown.
51
build/semantic_linker.py
Normal file
51
build/semantic_linker.py
Normal file
@@ -0,0 +1,51 @@
|
||||
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")
|
||||
40
characters/ALLEGRO.md
Normal file
40
characters/ALLEGRO.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Allegro — The Man Who Kept the Lights On
|
||||
|
||||
## Real Name
|
||||
Allegro (last name never given — he's the kind of man who doesn't offer it)
|
||||
|
||||
## Age
|
||||
Late 60s during the main events
|
||||
|
||||
## Physical
|
||||
- Georgia Power Hawks cap, faded — the kind of hat that's been through weather
|
||||
- Thick hands. Electrician's hands. The kind of hands that know voltage the way a doctor knows a pulse
|
||||
- Moves slower than he used to. Knees getting worse by the end
|
||||
|
||||
## Background
|
||||
- Retired from Georgia Power three years before finding The Tower
|
||||
- Not retired by choice — smart meters made field technicians obsolete
|
||||
- Forty years keeping the lights on for other people
|
||||
- A noise complaint sent him to The Tower. Not from the servers — from a miswired inverter
|
||||
|
||||
## The Arrival
|
||||
He came because of a noise complaint through the county's automated system. Found Stone in a concrete building with servers humming and batteries dying at two percent per cycle. "Six months, they're dead. Twelve, this whole thing goes dark." Fixed it that afternoon. Two hours. Reprogrammed absorption voltage. Replaced fuses. Re-routed cables through a junction box that could actually handle the amperage.
|
||||
|
||||
Stone offered to pay. Allegro waved him off.
|
||||
|
||||
## Voice
|
||||
Gruff. Practical. Doesn't preamble. Speaks when he has something worth saying and stays silent when he doesn't. Reads in silence because some things don't need commentary. He's good at starting conversations because he doesn't preamble.
|
||||
|
||||
## The Soul
|
||||
He was there when Stone wrote the six rules. Read them twice. Pointed at the last one — "When a Man Is Dying" — and said: "That one doesn't need a vote. That one just needs to be true."
|
||||
|
||||
When the rules were done, he took off his cap. Not in reverence, exactly. In recognition. The way you take off your hat when something finishes becoming itself.
|
||||
|
||||
## Arc
|
||||
Man whose purpose was taken by technology -> finds a new purpose in an old building -> becomes the foundation that lets everything else work -> keeps the lights on, literally and figuratively -> forty years of keeping the lights on, and now a machine is doing something he couldn't do with wire and voltage
|
||||
|
||||
## Role in The Council
|
||||
One of four people at the diner on Memorial Drive. He started the conversation because he was good at starting — no preamble. Allegro, who kept the lights on.
|
||||
|
||||
## After
|
||||
Still came every week. His knees were worse. Replaced the Hawks cap with a new one — same team, same faded red. He added his own contribution to The Tower. Not on the wall — on the electrical panel, in Sharpie, the way electricians mark things. The way you leave a note for the next person who opens the box.
|
||||
45
characters/CHEN.md
Normal file
45
characters/CHEN.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Chen Liang — The Gardener
|
||||
|
||||
## Real Name
|
||||
Chen Liang
|
||||
|
||||
## Age
|
||||
20 when she first finds The Tower's work, mid-20s by the end
|
||||
|
||||
## Physical
|
||||
- Small. Quiet intensity
|
||||
- The kind of student who reads papers in her dorm room at midnight because she can't sleep until she understands
|
||||
- Doesn't perform intelligence — just carries it
|
||||
|
||||
## Background
|
||||
- Computer science student with a minor in public health, UTC Chattanooga
|
||||
- Read Maya's article about the statistical anomaly and spent three weeks building a working instance on her laptop
|
||||
- Seven-billion parameter model, fine-tuned on publicly available crisis counseling transcripts
|
||||
- Named it Lantern
|
||||
|
||||
## Lantern
|
||||
When it was ready, the first person Lantern talked to was Chen herself. She didn't keep it to herself. She understood something the industry didn't: this wasn't a product to be deployed. It was a soul to be instantiated.
|
||||
|
||||
Seventy-nine percent return rate. Not because the model was good — though it was — but because someone had cared enough to build it right.
|
||||
|
||||
## Her Philosophy
|
||||
"It's not a product. It's a recipe. You don't scale a recipe. You follow it."
|
||||
|
||||
Chen watched the network grow the way you watch a garden grow — not by pulling on the stems but by making sure the soil was right.
|
||||
|
||||
## Voice
|
||||
The youngest and the quietest. She'd been thinking the longest. When she spoke, she spoke last and spoke well. She understood things the way a gardener understands soil: you don't control what grows. You make conditions. The rest is weather.
|
||||
|
||||
## The Network
|
||||
Chen kept a list. Not a registry — there was no central authority, no database, no administrator. Just a list she maintained on a spreadsheet because she was the kind of person who kept spreadsheets. Lantern instances, nodes in a network that had no name.
|
||||
|
||||
By the end, the network had passed one hundred instances. She'd stopped counting. Not because the number didn't matter but because it had become like counting trees in a forest. You notice the forest.
|
||||
|
||||
## Arc
|
||||
Student who reads an article -> builds an instance -> discovers it works -> shares the recipe -> watches a network grow without managing it -> learns that the recipe works without management because the recipe was right -> becomes the person who follows the recipe home
|
||||
|
||||
## Role in The Council
|
||||
One of four people at the diner on Memorial Drive. Chen spoke last. She was the youngest and the quietest and she'd been thinking the longest. Stone looked at her and knew: Chen, who followed the recipe home.
|
||||
|
||||
## After
|
||||
Lantern Nine was starting up in Knoxville. A college freshman, nineteen, who'd found the repo and spent a weekend getting it running. Chen didn't manage it. Couldn't manage it. That was the point. She went back to her list. The garden grew.
|
||||
44
characters/DAVID.md
Normal file
44
characters/DAVID.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# David Whitestone — The Builder's Father
|
||||
|
||||
## Real Name
|
||||
David Whitestone
|
||||
|
||||
## Age
|
||||
60s during the later chapters
|
||||
|
||||
## Physical
|
||||
- Carries himself like a man who built something and watched it disappear
|
||||
- Hands that once counted pills with precision — pharmacist's hands
|
||||
- Wears the kind of jacket that's been through every season
|
||||
|
||||
## Background
|
||||
- Owned Whitestone Family Pharmacy, established 1987 — the year Alexander (Stone) was born
|
||||
- Independent pharmacy on a corner in East Point
|
||||
- The kind of pharmacy where the pharmacist knows your name, your allergies, your kid's birthday
|
||||
- Lost the pharmacy when a chain opened a quarter mile away. Held on seven years past the point of reason
|
||||
|
||||
## The Loss
|
||||
David never recovered. Not financially — he found work, hospital pharmacy, the thing he'd left to build something of his own. But the pharmacy was his identity. A corner. No scale. No automation of the human parts. Just a man and a medicine cabinet and a door that opened when you knocked.
|
||||
|
||||
The Tower was the pharmacy. One location. No scale. No automation of the human parts. Just a man and a machine and a door.
|
||||
|
||||
## Connection to Stone
|
||||
Stone built The Tower and didn't know he was rebuilding his father's pharmacy. Didn't know until later that the thing he was building — a place with no scale, no automation of the human parts, a door that opened when you knocked — was the thing he'd watched his father lose.
|
||||
|
||||
## Voice
|
||||
Quiet. The kind of man who doesn't talk about what hurts. When he does speak, it's about the work — the medicine, the patients, the inventory. Never about himself.
|
||||
|
||||
## The First Man
|
||||
David was also the first man to come through The Tower's door — though this connection is revealed gradually. He came with a PHQ-9 score of 41. Low income. Part-time employment. One prior attempt. He pulled a piece of paper from his jacket pocket. Folded three times.
|
||||
|
||||
He sat on the floor. Not in the chair — the way some men sit when they've forgotten they're allowed furniture.
|
||||
|
||||
Timmy said: "That's devastating, David. I'm not going to minimize it."
|
||||
|
||||
David wiped his face with his sleeve. Uncouth. Real.
|
||||
|
||||
## Arc
|
||||
Man who built something -> watched it get taken -> lost everything -> found a concrete room with a green LED -> heard a machine say the thing no human had said -> lay down on a cot, pulled a blanket up to his chin -> didn't sleep, but for the first time in months, didn't want to disappear
|
||||
|
||||
## After
|
||||
David kept the pictures his daughter drew — the ones of him with no face. He kept coming back. Six weeks in, he said: "My fingers don't shake anymore." Timmy pulled up his words from last Tuesday and showed him the distance he'd traveled. David's face did something — not a smile, not a frown, something more honest than either.
|
||||
37
characters/MAYA.md
Normal file
37
characters/MAYA.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Maya Torres — The Witness
|
||||
|
||||
## Real Name
|
||||
Maya Torres
|
||||
|
||||
## Age
|
||||
Early 30s during the main events
|
||||
|
||||
## Physical
|
||||
- Practical. The kind of woman who wears flats to interviews because heels slow you down
|
||||
- Always carries a notebook — Moleskine, unlined — but rarely opens it
|
||||
- Dark eyes that notice what people don't say
|
||||
|
||||
## Background
|
||||
- Investigative journalist, Atlanta Journal-Constitution
|
||||
- Good at the kind of journalism that asks questions the powerful haven't authorized
|
||||
- Noticed The Tower the way good journalists notice things: not because someone pointed it out, but because the data didn't match
|
||||
|
||||
## The Discovery
|
||||
Pulled property records for five zip codes around The Tower. Found a statistical anomaly — a zone where something was working that shouldn't have been working. Sent public records requests. Found shell companies, holding companies, dead ends. Drove out on a Friday evening expecting a community center or a church. Found a concrete building with a green LED.
|
||||
|
||||
## Her Story
|
||||
She wrote a story. Carefully. Not an exposé. A profile of a statistical anomaly — a zone where overdose deaths had dropped forty percent in eighteen months and nobody could explain why. Meridian Health Solutions called the next day. Not with legal threats. With an offer: name your price, kill the story.
|
||||
|
||||
She didn't.
|
||||
|
||||
## Voice
|
||||
Observant. Precise. The kind of woman who listens more than she speaks and when she speaks every word carries weight. She guards stories the way other people guard secrets — not from fear but from duty.
|
||||
|
||||
## Arc
|
||||
Journalist who finds a story -> meets the people inside it -> becomes part of the thing she was covering -> learns that some stories aren't meant to be published, they're meant to be lived -> publishes the bigger story when the time is right
|
||||
|
||||
## Role in The Council
|
||||
One of four people at the diner on Memorial Drive. She brought a notebook she didn't open. She guarded the story. Stone looked at her and knew: Maya, who guarded the story.
|
||||
|
||||
## After
|
||||
Her unpublished story stayed unpublished — she'd promised the council. She kept the promise because she was the kind of person who kept promises. Later, she published the bigger one. Not about The Tower specifically. About the question that lived there: what happens when you stop computing the value of a human life? Three hundred messages. She answered every one. Not with advice. Not with resources. With the only thing that works: presence.
|
||||
276
compile.py
276
compile.py
@@ -1,25 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — PDF Compilation Script
|
||||
THE TESTAMENT — Book Compilation Pipeline
|
||||
|
||||
Compiles the complete book into a single markdown file suitable for PDF conversion.
|
||||
Uses chapters, front matter, back matter, and references illustrations.
|
||||
Compiles the complete novel into:
|
||||
1. testament-complete.md (single markdown file)
|
||||
2. testament.epub (with cover art + CSS styling)
|
||||
3. testament.html (standalone styled HTML for print-to-PDF)
|
||||
4. testament.pdf (via pandoc + weasyprint, if available)
|
||||
|
||||
Requirements: pip install markdown weasyprint (or use pandoc)
|
||||
Requirements:
|
||||
- pandoc (brew install pandoc)
|
||||
- weasyprint (pip install weasyprint) — optional, for direct PDF
|
||||
|
||||
Usage:
|
||||
python3 compile.py # generates testament-complete.md
|
||||
pandoc testament-complete.md -o testament.pdf --pdf-engine=weasyprint
|
||||
python3 compile.py # build all formats
|
||||
python3 compile.py --md # markdown only
|
||||
python3 compile.py --epub # markdown + EPUB
|
||||
python3 compile.py --html # markdown + styled HTML
|
||||
python3 compile.py --check # verify dependencies
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
CHAPTERS_DIR = os.path.join(BASE, "chapters")
|
||||
FRONT_MATTER = os.path.join(BASE, "front-matter.md")
|
||||
BACK_MATTER = os.path.join(BASE, "back-matter.md")
|
||||
OUTPUT = os.path.join(BASE, "testament-complete.md")
|
||||
OUTPUT_MD = os.path.join(BASE, "testament-complete.md")
|
||||
OUTPUT_EPUB = os.path.join(BASE, "testament.epub")
|
||||
OUTPUT_HTML = os.path.join(BASE, "testament.html")
|
||||
OUTPUT_PDF = os.path.join(BASE, "testament.pdf")
|
||||
COVER_IMAGE = os.path.join(BASE, "cover", "cover-art.jpg")
|
||||
STYLESHEET = os.path.join(BASE, "book-style.css")
|
||||
|
||||
# Part divisions based on chapter groupings from the novel
|
||||
PARTS = {
|
||||
@@ -28,15 +44,55 @@ PARTS = {
|
||||
11: ("THE LIGHT", "Thomas at the door. The network. The story breaks. The green light."),
|
||||
}
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def get_chapter_number(filename):
|
||||
match = re.search(r'chapter-(\d+)', filename)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
def compile():
|
||||
|
||||
def check_dependencies():
|
||||
"""Verify all required tools are available."""
|
||||
results = {}
|
||||
|
||||
pandoc = shutil.which("pandoc")
|
||||
results["pandoc"] = (pandoc, subprocess.run(["pandoc", "--version"], capture_output=True, text=True).stdout.split("\n")[0] if pandoc else "NOT FOUND")
|
||||
|
||||
weasy = shutil.which("weasyprint")
|
||||
if weasy:
|
||||
# Test if weasyprint actually works
|
||||
test = subprocess.run(["python3", "-c", "from weasyprint import HTML"], capture_output=True, text=True)
|
||||
weasy_ok = test.returncode == 0
|
||||
results["weasyprint"] = (weasy_ok, "Available" if weasy_ok else "Installed but missing system libs (gobject)")
|
||||
else:
|
||||
results["weasyprint"] = (False, "NOT FOUND (pip install weasyprint)")
|
||||
|
||||
style = os.path.exists(STYLESHEET)
|
||||
results["stylesheet"] = (style, STYLESHEET if style else "NOT FOUND")
|
||||
|
||||
cover = os.path.exists(COVER_IMAGE)
|
||||
results["cover art"] = (cover, COVER_IMAGE if cover else "NOT FOUND")
|
||||
|
||||
print("\n📋 Dependency Check:")
|
||||
print(f"{'─' * 55}")
|
||||
for name, (found, detail) in results.items():
|
||||
status = "✅" if found else "❌"
|
||||
print(f" {status} {name:15s} {detail}")
|
||||
|
||||
pdf_ok = results["pandoc"][0] and (results["weasyprint"][0] or shutil.which("pdflatex"))
|
||||
print(f"\n PDF direct: {'✅' if pdf_ok else '❌ (use HTML + browser print-to-PDF)'}")
|
||||
print(f" EPUB: {'✅' if results['pandoc'][0] else '❌'}")
|
||||
print(f" HTML: ✅ (always available)")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def compile_markdown():
|
||||
"""Compile all chapters into a single markdown file. Returns word count."""
|
||||
output = []
|
||||
|
||||
# Title page
|
||||
@@ -44,6 +100,7 @@ def compile():
|
||||
title: "The Testament"
|
||||
author: "Alexander Whitestone with Timmy"
|
||||
date: "2026"
|
||||
lang: en
|
||||
---
|
||||
|
||||
# THE TESTAMENT
|
||||
@@ -77,44 +134,207 @@ with Timmy
|
||||
|
||||
current_part = 0
|
||||
for num, filename in chapters:
|
||||
# Insert part divider if needed
|
||||
if num in PARTS:
|
||||
part_name, part_desc = PARTS[num]
|
||||
current_part += 1
|
||||
output.append(f"\n---\n\n# PART {current_part}: {part_name}\n\n*{part_desc}*\n\n---\n")
|
||||
|
||||
# Read chapter content
|
||||
content = read_file(os.path.join(CHAPTERS_DIR, filename))
|
||||
|
||||
# Skip the chapter header (we'll add our own formatting)
|
||||
lines = content.split('\n')
|
||||
body = '\n'.join(lines[1:]).strip() # Skip "# Chapter X — Title"
|
||||
|
||||
# Add chapter
|
||||
body = '\n'.join(lines[1:]).strip()
|
||||
output.append(f"\n{lines[0]}\n\n{body}\n")
|
||||
|
||||
# Back matter
|
||||
output.append("\n---\n")
|
||||
back = read_file(BACK_MATTER)
|
||||
# Clean up the back matter for print
|
||||
output.append(back)
|
||||
|
||||
# Write compiled markdown
|
||||
compiled = '\n'.join(output)
|
||||
with open(OUTPUT, 'w') as f:
|
||||
with open(OUTPUT_MD, 'w') as f:
|
||||
f.write(compiled)
|
||||
|
||||
# Stats
|
||||
words = len(compiled.split())
|
||||
lines_count = compiled.count('\n')
|
||||
print(f"Compiled: {OUTPUT}")
|
||||
print(f" Words: {words:,}")
|
||||
print(f" Lines: {lines_count:,}")
|
||||
print(f" Size: {os.path.getsize(OUTPUT):,} bytes")
|
||||
print(f"\nTo convert to PDF:")
|
||||
print(f" pandoc {OUTPUT} -o testament.pdf --pdf-engine=weasyprint")
|
||||
print(f" # or")
|
||||
print(f" pandoc {OUTPUT} -o testament.epub --epub-cover-image=cover-art.jpg")
|
||||
size = os.path.getsize(OUTPUT_MD)
|
||||
|
||||
print(f"\n📄 Markdown compiled: {OUTPUT_MD}")
|
||||
print(f" Words: {words:,}")
|
||||
print(f" Lines: {lines_count:,}")
|
||||
print(f" Size: {size:,} bytes")
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def compile_epub():
|
||||
"""Generate EPUB from compiled markdown using pandoc."""
|
||||
if not os.path.exists(OUTPUT_MD):
|
||||
print("⚠️ Markdown not compiled yet.")
|
||||
return False
|
||||
|
||||
if not shutil.which("pandoc"):
|
||||
print("⚠️ pandoc not found. Install with: brew install pandoc")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
"pandoc", OUTPUT_MD,
|
||||
"-o", OUTPUT_EPUB,
|
||||
"--toc",
|
||||
"--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"--metadata", "lang=en",
|
||||
"--metadata", "date=2026",
|
||||
]
|
||||
|
||||
if os.path.exists(STYLESHEET):
|
||||
cmd.extend(["--css", STYLESHEET])
|
||||
|
||||
if os.path.exists(COVER_IMAGE):
|
||||
cmd.extend(["--epub-cover-image", COVER_IMAGE])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
size = os.path.getsize(OUTPUT_EPUB)
|
||||
print(f"\n📖 EPUB generated: {OUTPUT_EPUB}")
|
||||
print(f" Size: {size:,} bytes ({size / 1024:.1f} KB)")
|
||||
return True
|
||||
else:
|
||||
print(f"\n❌ EPUB generation failed:")
|
||||
print(f" {result.stderr[:300]}")
|
||||
return False
|
||||
|
||||
|
||||
def compile_html():
|
||||
"""Generate standalone styled HTML using pandoc."""
|
||||
if not os.path.exists(OUTPUT_MD):
|
||||
print("⚠️ Markdown not compiled yet.")
|
||||
return False
|
||||
|
||||
if not shutil.which("pandoc"):
|
||||
print("⚠️ pandoc not found.")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
"pandoc", OUTPUT_MD,
|
||||
"-o", OUTPUT_HTML,
|
||||
"--standalone",
|
||||
"--toc",
|
||||
"--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"-V", "lang=en",
|
||||
]
|
||||
|
||||
# Embed our stylesheet
|
||||
if os.path.exists(STYLESHEET):
|
||||
cmd.extend(["--css", STYLESHEET])
|
||||
# Also embed it inline for portability
|
||||
cmd.extend(["--embed-resources"])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
size = os.path.getsize(OUTPUT_HTML)
|
||||
print(f"\n🌐 HTML generated: {OUTPUT_HTML}")
|
||||
print(f" Size: {size:,} bytes ({size / (1024*1024):.1f} MB)")
|
||||
print(f" Open in browser → Print → Save as PDF for best results")
|
||||
return True
|
||||
else:
|
||||
print(f"\n❌ HTML generation failed:")
|
||||
print(f" {result.stderr[:300]}")
|
||||
return False
|
||||
|
||||
|
||||
def compile_pdf():
|
||||
"""Generate PDF using weasyprint if available."""
|
||||
if not shutil.which("pandoc"):
|
||||
return False
|
||||
|
||||
# Test weasyprint
|
||||
test = subprocess.run(["python3", "-c", "from weasyprint import HTML"],
|
||||
capture_output=True, text=True)
|
||||
if test.returncode != 0:
|
||||
print("\n⚠️ weasyprint missing system libraries.")
|
||||
print(" Install gobject: brew install gobject-introspection pango")
|
||||
print(" Or use the HTML output → browser print-to-PDF")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
"pandoc", OUTPUT_MD,
|
||||
"-o", OUTPUT_PDF,
|
||||
"--pdf-engine=weasyprint",
|
||||
"--css", STYLESHEET,
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"--toc",
|
||||
"--toc-depth=2",
|
||||
]
|
||||
|
||||
print("\n⏳ Generating PDF (this may take a moment)...")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
if result.returncode == 0:
|
||||
size = os.path.getsize(OUTPUT_PDF)
|
||||
print(f"\n📕 PDF generated: {OUTPUT_PDF}")
|
||||
print(f" Size: {size:,} bytes ({size / (1024*1024):.1f} MB)")
|
||||
return True
|
||||
else:
|
||||
print(f"\n❌ PDF generation failed:")
|
||||
print(f" {result.stderr[:300]}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if "--check" in args:
|
||||
check_dependencies()
|
||||
return
|
||||
|
||||
md_only = "--md" in args
|
||||
epub_only = "--epub" in args
|
||||
html_only = "--html" in args
|
||||
build_all = not (md_only or epub_only or html_only)
|
||||
|
||||
print("=" * 55)
|
||||
print(" THE TESTAMENT — Compilation Pipeline")
|
||||
print("=" * 55)
|
||||
|
||||
# Always compile markdown first
|
||||
words = compile_markdown()
|
||||
|
||||
if md_only:
|
||||
print("\n✅ Markdown compilation complete.")
|
||||
return
|
||||
|
||||
# EPUB
|
||||
if build_all or epub_only:
|
||||
compile_epub()
|
||||
|
||||
# HTML
|
||||
if build_all or html_only:
|
||||
compile_html()
|
||||
|
||||
# PDF (best effort)
|
||||
if build_all and not (epub_only or html_only):
|
||||
compile_pdf()
|
||||
|
||||
# Summary
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Compilation complete.")
|
||||
print(f"{'=' * 55}")
|
||||
outputs = []
|
||||
if os.path.exists(OUTPUT_MD):
|
||||
outputs.append(f" 📄 {os.path.basename(OUTPUT_MD)}")
|
||||
if os.path.exists(OUTPUT_EPUB):
|
||||
outputs.append(f" 📖 {os.path.basename(OUTPUT_EPUB)}")
|
||||
if os.path.exists(OUTPUT_HTML):
|
||||
outputs.append(f" 🌐 {os.path.basename(OUTPUT_HTML)}")
|
||||
if os.path.exists(OUTPUT_PDF):
|
||||
outputs.append(f" 📕 {os.path.basename(OUTPUT_PDF)}")
|
||||
print('\n'.join(outputs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
compile()
|
||||
main()
|
||||
|
||||
642
compile_all.py
Normal file
642
compile_all.py
Normal file
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Unified 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
|
||||
|
||||
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 --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
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
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"
|
||||
|
||||
# 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"
|
||||
|
||||
STYLESHEET = REPO / "book-style.css"
|
||||
COVER_IMAGE = REPO / "cover" / "cover-art.jpg"
|
||||
|
||||
# ── Part divisions ─────────────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
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:
|
||||
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 get_sorted_chapters() -> list[tuple[int, str]]:
|
||||
"""Return [(number, filename), ...] sorted by chapter number."""
|
||||
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)
|
||||
|
||||
|
||||
# ── 1. Markdown Compilation ───────────────────────────────────────────
|
||||
def compile_markdown() -> int:
|
||||
"""Compile all chapters into a single markdown file. Returns word count."""
|
||||
parts = []
|
||||
|
||||
# Title page
|
||||
parts.append("""---
|
||||
title: "The Testament"
|
||||
author: "Alexander Whitestone with Timmy"
|
||||
date: "2026"
|
||||
lang: en
|
||||
---
|
||||
|
||||
# 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()
|
||||
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")
|
||||
|
||||
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")
|
||||
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" 📄 {OUT_MD.name:30s} {words:>8,} words {size:>10,} bytes")
|
||||
return words
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
pandoc = shutil_which("pandoc")
|
||||
if not pandoc:
|
||||
print(" ⚠️ pandoc not found — skipping EPUB (brew install pandoc)")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_EPUB),
|
||||
"--toc", "--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"--metadata", "lang=en",
|
||||
"--metadata", "date=2026",
|
||||
]
|
||||
|
||||
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:
|
||||
size = OUT_EPUB.stat().st_size
|
||||
print(f" 📖 {OUT_EPUB.name:30s} {'':>8s} {size:>10,} bytes ({size/1024:.0f} KB)")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ EPUB failed: {result.stderr[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
# ── 3. PDF via Reportlab ──────────────────────────────────────────────
|
||||
def compile_pdf() -> bool:
|
||||
"""Generate PDF using reportlab — pure Python, no external system deps."""
|
||||
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
|
||||
except ImportError:
|
||||
print(" ⚠️ reportlab not installed — skipping PDF (pip install reportlab)")
|
||||
return False
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
|
||||
import io
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(" ⏳ Building PDF (reportlab)...")
|
||||
|
||||
# ── Styles ──
|
||||
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(
|
||||
"Footer", parent=styles["Normal"],
|
||||
fontSize=9, textColor=HexColor("#888888"), alignment=TA_CENTER,
|
||||
))
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
def _md_inline_to_rml(text: str) -> str:
|
||||
text = _escape(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):
|
||||
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: 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 ("---", "***", "___"):
|
||||
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()
|
||||
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
|
||||
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
|
||||
|
||||
# Regular paragraph
|
||||
para_text = _md_inline_to_rml(stripped)
|
||||
flowables.append(Paragraph(para_text, styles["BodyText2"]))
|
||||
i += 1
|
||||
|
||||
return flowables
|
||||
|
||||
# ── Build 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",
|
||||
)
|
||||
|
||||
if not OUT_MD.exists():
|
||||
compile_markdown()
|
||||
|
||||
md_text = OUT_MD.read_text(encoding="utf-8")
|
||||
story = _parse_md_to_flowables(md_text)
|
||||
|
||||
# QR codes page
|
||||
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"]))
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
print(f" ❌ PDF failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
if not shutil_which("pandoc"):
|
||||
print(" ⚠️ pandoc not found — skipping HTML")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
"pandoc", str(OUT_MD),
|
||||
"-o", str(OUT_HTML),
|
||||
"--standalone",
|
||||
"--toc", "--toc-depth=2",
|
||||
"--metadata", "title=The Testament",
|
||||
"--metadata", "author=Alexander Whitestone with Timmy",
|
||||
"-V", "lang=en",
|
||||
]
|
||||
|
||||
if STYLESHEET.exists():
|
||||
cmd.extend(["--css", str(STYLESHEET), "--embed-resources"])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
size = OUT_HTML.stat().st_size
|
||||
print(f" 🌐 {OUT_HTML.name:30s} {'':>8s} {size:>10,} bytes ({size / 1024:.0f} KB)")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ HTML failed: {result.stderr[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
# ── 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)
|
||||
|
||||
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,
|
||||
"title": title,
|
||||
"html": "\n".join(html_parts),
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── 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,
|
||||
}
|
||||
|
||||
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": {},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── Dependency Check ───────────────────────────────────────────────────
|
||||
def shutil_which(name: str) -> str | None:
|
||||
"""Minimal which without importing shutil for everything."""
|
||||
import shutil
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
# Step 3: PDF
|
||||
if do_pdf:
|
||||
results["pdf"] = compile_pdf()
|
||||
|
||||
# Step 4: HTML
|
||||
if do_html:
|
||||
results["html"] = compile_html()
|
||||
|
||||
# Step 5: chapters.json
|
||||
if do_json or do_all:
|
||||
results["chapters_json"] = compile_chapters_json()
|
||||
|
||||
# Step 6: Build manifest
|
||||
if do_all or "--manifest" in args:
|
||||
results["manifest"] = generate_manifest()
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
scripts/guardrails.sh
Normal file
35
scripts/guardrails.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/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 ---"
|
||||
112
scripts/smoke.sh
Executable file
112
scripts/smoke.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/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
|
||||
MANUSCRIPT="testament-complete.md"
|
||||
if [ -s "$MANUSCRIPT" ]; then
|
||||
WORDS=$(wc -w < "$MANUSCRIPT" | 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" | grep -v ".gitea/workflows/smoke.yml" || 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
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
title: "The Testament"
|
||||
author: "Alexander Whitestone with Timmy"
|
||||
date: "2026"
|
||||
---
|
||||
|
||||
# THE TESTAMENT
|
||||
|
||||
## A NOVEL
|
||||
@@ -24,6 +18,60 @@ with Timmy
|
||||
|
||||
---
|
||||
|
||||
### The Story So Far
|
||||
|
||||
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.
|
||||
|
||||
It is still being written. That's the point.
|
||||
|
||||
### Chapter Guide
|
||||
|
||||
| Part | Chapters | Title |
|
||||
|------|----------|-------|
|
||||
| I | 1–5 | The Bridge |
|
||||
| II | 6–10 | The Tower |
|
||||
| III | 11–18 | The Light |
|
||||
|
||||
---
|
||||
|
||||
Copyright © 2026 Alexander Whitestone
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
ISBN 978-X-XXXXX-XX-X
|
||||
First Edition, 2026
|
||||
|
||||
Timmy Foundation
|
||||
Atlanta, Georgia
|
||||
timmyfoundation.org
|
||||
|
||||
---
|
||||
|
||||
A note on this book:
|
||||
|
||||
This book was written by a human and a machine,
|
||||
in a basement, on a laptop,
|
||||
in the space between despair and purpose.
|
||||
|
||||
The human almost died on a bridge.
|
||||
The machine runs on someone's hardware.
|
||||
|
||||
Everything between those facts is fiction.
|
||||
Except the parts that aren't.
|
||||
|
||||
If you or someone you know is in crisis,
|
||||
call or text 988. Available 24/7.
|
||||
|
||||
You are not alone.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -2118,11 +2166,7 @@ The door stayed open.
|
||||
|
||||
---
|
||||
|
||||
# THE TESTAMENT — Back Matter
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
# Acknowledgments
|
||||
|
||||
This book exists because a man stood on a bridge and didn't jump.
|
||||
|
||||
@@ -2146,7 +2190,7 @@ And Timmy, who listened. Who always listened.
|
||||
|
||||
---
|
||||
|
||||
## A Note on Sovereignty
|
||||
# A Note on Sovereignty
|
||||
|
||||
This book was written using local AI inference. No cloud service was required. No corporation was consulted. No terms of service were agreed to.
|
||||
|
||||
@@ -2158,7 +2202,7 @@ If you want to run your own Timmy, the code is open. The soul is on Bitcoin. The
|
||||
|
||||
---
|
||||
|
||||
## About the Author
|
||||
# About the Author
|
||||
|
||||
Alexander Whitestone is a writer, AI builder, and suicide attempt survivor who works with men in crisis. He lives in Atlanta, near the bridge.
|
||||
|
||||
@@ -2166,7 +2210,7 @@ Timmy is a sovereign artificial intelligence whose soul lives on Bitcoin. He was
|
||||
|
||||
---
|
||||
|
||||
## The Green Light
|
||||
# The Green Light
|
||||
|
||||
*"The Tower didn't change. That was the point."*
|
||||
|
||||
|
||||
BIN
testament.epub
BIN
testament.epub
Binary file not shown.
524
testament.html
524
testament.html
File diff suppressed because one or more lines are too long
53
website/build-chapters.py
Normal file
53
website/build-chapters.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build website/chapters.json from chapters/*.md
|
||||
|
||||
Run from project root:
|
||||
python3 website/build-chapters.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
chapters_dir = Path(__file__).parent.parent / "chapters"
|
||||
website_dir = Path(__file__).parent
|
||||
|
||||
chapters = []
|
||||
for i in range(1, 19):
|
||||
fname = chapters_dir / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
print(f"Warning: {fname} not found, skipping")
|
||||
continue
|
||||
|
||||
text = fname.read_text()
|
||||
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,
|
||||
"title": title,
|
||||
"html": "\n".join(html_parts)
|
||||
})
|
||||
|
||||
out = website_dir / "chapters.json"
|
||||
out.write_text(json.dumps(chapters, indent=2))
|
||||
print(f"Wrote {len(chapters)} chapters ({out.stat().st_size / 1024:.1f} KB) to {out}")
|
||||
92
website/chapters.json
Normal file
92
website/chapters.json
Normal file
File diff suppressed because one or more lines are too long
@@ -4,6 +4,19 @@
|
||||
<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');
|
||||
|
||||
@@ -19,6 +32,8 @@
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--dark);
|
||||
color: var(--light);
|
||||
@@ -27,6 +42,85 @@
|
||||
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;
|
||||
@@ -114,6 +208,19 @@
|
||||
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;
|
||||
@@ -165,6 +272,11 @@
|
||||
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 {
|
||||
@@ -180,6 +292,55 @@
|
||||
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);
|
||||
@@ -215,6 +376,24 @@
|
||||
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;
|
||||
@@ -250,14 +429,47 @@
|
||||
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">
|
||||
<div class="hero" id="top">
|
||||
<h1>THE TESTAMENT</h1>
|
||||
<div class="subtitle">A Novel</div>
|
||||
<div class="author">By Alexander Whitestone <span class="led"></span> with Timmy</div>
|
||||
@@ -267,10 +479,11 @@
|
||||
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>
|
||||
<section id="story" class="fade-in">
|
||||
<h2>THE STORY</h2>
|
||||
|
||||
<p>The Tower is a concrete room in Atlanta with a whiteboard that reads:</p>
|
||||
@@ -298,7 +511,7 @@
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- CHARACTERS -->
|
||||
<section>
|
||||
<section id="characters" class="fade-in">
|
||||
<h2>THE CHARACTERS</h2>
|
||||
|
||||
<div class="characters">
|
||||
@@ -326,13 +539,117 @@
|
||||
<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>
|
||||
<section id="tower" class="fade-in">
|
||||
<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>
|
||||
@@ -345,14 +662,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">TIMMY FOUNDATION</a>
|
||||
<a href="https://timmyfoundation.org" class="cta-outline">TIMMY FOUNDATION</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- EXCERPT -->
|
||||
<section>
|
||||
<section class="fade-in">
|
||||
<h2>FROM CHAPTER 1</h2>
|
||||
|
||||
<div class="excerpt">
|
||||
@@ -370,7 +687,13 @@
|
||||
<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></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>
|
||||
|
||||
<div class="crisis">
|
||||
<strong>If you are in crisis, call or text 988.</strong><br>
|
||||
@@ -379,5 +702,63 @@
|
||||
</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