adding chapter 6, the build script and timeline docs
This commit is contained in:
317
build_book.py
Normal file
317
build_book.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a styled HTML file from chapter markdown drafts for email/review."""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
|
||||
|
||||
CHAPTER_DIR = Path(__file__).parent / "chapters" / "book1"
|
||||
OUTPUT_DIR = Path(__file__).parent / "exports"
|
||||
|
||||
HTML_TEMPLATE = """\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Shade Series - Book 1: The Unbreakable Curse (Draft)</title>
|
||||
<style>
|
||||
:root[data-theme="dark"] {{
|
||||
--bg: #1a1a2e;
|
||||
--text: #d4d4dc;
|
||||
--text-muted: #888899;
|
||||
--text-subtle: #6a6a7a;
|
||||
--border: #2a2a3e;
|
||||
--toggle-bg: #2a2a3e;
|
||||
--toggle-hover: #3a3a4e;
|
||||
}}
|
||||
:root[data-theme="light"] {{
|
||||
--bg: #fefefe;
|
||||
--text: #222;
|
||||
--text-muted: #555;
|
||||
--text-subtle: #888;
|
||||
--border: #ccc;
|
||||
--toggle-bg: #eee;
|
||||
--toggle-hover: #ddd;
|
||||
}}
|
||||
body {{
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
transition: color 0.3s, background 0.3s;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 28px;
|
||||
margin-top: 80px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
page-break-before: always;
|
||||
}}
|
||||
h1:first-of-type {{
|
||||
margin-top: 40px;
|
||||
page-break-before: avoid;
|
||||
}}
|
||||
p {{
|
||||
margin: 0 0 1em 0;
|
||||
text-indent: 1.5em;
|
||||
}}
|
||||
/* Scene breaks */
|
||||
p:has(> br:only-child), p:empty {{
|
||||
text-indent: 0;
|
||||
}}
|
||||
.scene-break {{
|
||||
text-align: center;
|
||||
margin: 2em 0;
|
||||
text-indent: 0 !important;
|
||||
letter-spacing: 0.5em;
|
||||
font-size: 14px;
|
||||
}}
|
||||
em {{ font-style: italic; }}
|
||||
strong {{ font-weight: bold; }}
|
||||
/* Title page */
|
||||
.title-page {{
|
||||
text-align: center;
|
||||
padding: 80px 0 60px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 40px;
|
||||
}}
|
||||
.title-page h1 {{
|
||||
font-size: 36px;
|
||||
margin: 0 0 10px;
|
||||
page-break-before: avoid;
|
||||
}}
|
||||
.title-page .subtitle {{
|
||||
font-size: 22px;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 30px;
|
||||
}}
|
||||
.title-page .draft-notice {{
|
||||
font-size: 14px;
|
||||
color: var(--text-subtle);
|
||||
margin-top: 20px;
|
||||
}}
|
||||
/* Table of contents */
|
||||
.toc {{
|
||||
max-width: 400px;
|
||||
margin: 0 auto 40px;
|
||||
padding: 20px 30px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}}
|
||||
.toc h2 {{
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
margin: 0 0 16px;
|
||||
color: var(--text-muted);
|
||||
font-variant: small-caps;
|
||||
letter-spacing: 0.1em;
|
||||
}}
|
||||
.toc ol {{
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}}
|
||||
.toc li {{
|
||||
margin: 8px 0;
|
||||
}}
|
||||
.toc a {{
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
transition: color 0.2s;
|
||||
}}
|
||||
.toc a:hover {{
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
}}
|
||||
/* First paragraph after heading shouldn't indent */
|
||||
h1 + p {{
|
||||
text-indent: 0;
|
||||
}}
|
||||
/* Theme toggle */
|
||||
.theme-toggle {{
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: var(--toggle-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.3s, color 0.3s, border-color 0.3s;
|
||||
z-index: 100;
|
||||
}}
|
||||
.theme-toggle:hover {{
|
||||
background: var(--toggle-hover);
|
||||
}}
|
||||
@media print {{
|
||||
.theme-toggle {{ display: none; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light/dark mode">
|
||||
<span id="theme-icon">☼</span>
|
||||
</button>
|
||||
<div class="title-page">
|
||||
<h1>The Unbreakable Curse</h1>
|
||||
<div class="subtitle">Book 1 of The Shade Series</div>
|
||||
<div class="draft-notice">DRAFT - Chapters {chapter_label} - For Review Only</div>
|
||||
</div>
|
||||
{toc}
|
||||
{content}
|
||||
<script>
|
||||
function toggleTheme() {{
|
||||
var html = document.documentElement;
|
||||
var icon = document.getElementById('theme-icon');
|
||||
if (html.getAttribute('data-theme') === 'dark') {{
|
||||
html.setAttribute('data-theme', 'light');
|
||||
icon.textContent = '\\u263E';
|
||||
}} else {{
|
||||
html.setAttribute('data-theme', 'dark');
|
||||
icon.textContent = '\\u2606';
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def parse_chapter_range(spec: str) -> list[int]:
|
||||
"""Parse a chapter spec like '1-5' or '1,3,5' or '3' into a list of ints."""
|
||||
chapters = []
|
||||
for part in spec.split(","):
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
start, end = part.split("-", 1)
|
||||
chapters.extend(range(int(start), int(end) + 1))
|
||||
else:
|
||||
chapters.append(int(part))
|
||||
return sorted(set(chapters))
|
||||
|
||||
|
||||
def find_chapter_file(num: int) -> Path:
|
||||
"""Find the best available file for a chapter number (prefer final, fall back to draft)."""
|
||||
final = CHAPTER_DIR / f"ch{num:02d}-final.md"
|
||||
draft = CHAPTER_DIR / f"ch{num:02d}-draft.md"
|
||||
if final.exists():
|
||||
return final
|
||||
if draft.exists():
|
||||
return draft
|
||||
return None
|
||||
|
||||
|
||||
def convert_scene_breaks(html: str) -> str:
|
||||
"""Replace bare '* * *' paragraphs with styled scene breaks."""
|
||||
html = html.replace("<p>* * *</p>", '<p class="scene-break">* * *</p>')
|
||||
return html
|
||||
|
||||
|
||||
def extract_title(text: str) -> str:
|
||||
"""Extract the chapter title from the first # heading in the markdown."""
|
||||
match = re.match(r"^#\s+(.+)$", text.strip(), re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def add_heading_id(html: str, anchor_id: str) -> str:
|
||||
"""Add an id attribute to the first <h1> tag in the HTML."""
|
||||
return html.replace("<h1>", f'<h1 id="{anchor_id}">', 1)
|
||||
|
||||
|
||||
def build_toc(entries: list[tuple[str, str]]) -> str:
|
||||
"""Build a table of contents HTML block from (anchor_id, title) pairs."""
|
||||
items = "\n".join(
|
||||
f' <li><a href="#{aid}">{title}</a></li>' for aid, title in entries
|
||||
)
|
||||
return f'<nav class="toc">\n <h2>Contents</h2>\n <ol>\n{items}\n </ol>\n</nav>'
|
||||
|
||||
|
||||
def build(chapters: list[int]) -> str:
|
||||
"""Combine and convert chapters to styled HTML."""
|
||||
md = markdown.Markdown(extensions=["smarty"])
|
||||
combined_html = []
|
||||
toc_entries = []
|
||||
|
||||
for num in chapters:
|
||||
path = find_chapter_file(num)
|
||||
if path is None:
|
||||
print(f"Warning: No file found for chapter {num}, skipping.", file=sys.stderr)
|
||||
continue
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
title = extract_title(text)
|
||||
anchor_id = f"ch{num:02d}"
|
||||
|
||||
if title:
|
||||
toc_entries.append((anchor_id, title))
|
||||
|
||||
md.reset()
|
||||
html = md.convert(text)
|
||||
html = convert_scene_breaks(html)
|
||||
html = add_heading_id(html, anchor_id)
|
||||
combined_html.append(html)
|
||||
print(f" Added: {path.name}")
|
||||
|
||||
if not combined_html:
|
||||
print("Error: No chapters found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if len(chapters) == 1:
|
||||
chapter_label = str(chapters[0])
|
||||
else:
|
||||
chapter_label = f"{chapters[0]}-{chapters[-1]}"
|
||||
|
||||
toc = build_toc(toc_entries) if len(toc_entries) > 1 else ""
|
||||
content = "\n\n".join(combined_html)
|
||||
return HTML_TEMPLATE.format(content=content, chapter_label=chapter_label, toc=toc)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build HTML from chapter markdown drafts.")
|
||||
parser.add_argument(
|
||||
"-chapters",
|
||||
required=True,
|
||||
help="Chapter range: e.g. '1-5', '1,3,5', or '3'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output filename (default: auto-generated in exports/)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
chapters = parse_chapter_range(args.chapters)
|
||||
print(f"Building HTML for chapters: {chapters}")
|
||||
|
||||
html = build(chapters)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
else:
|
||||
if len(chapters) == 1:
|
||||
label = f"ch{chapters[0]:02d}"
|
||||
else:
|
||||
label = f"ch{chapters[0]:02d}-{chapters[-1]:02d}"
|
||||
out_path = OUTPUT_DIR / f"book1-{label}-review.html"
|
||||
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
print(f"Output: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user