318 lines
8.1 KiB
Python
318 lines
8.1 KiB
Python
#!/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 Floundry Affair - A Phelan Varrant Novel (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 Floundry Affair</h1>
|
|
<div class="subtitle">A Phelan Varrant Novel — Book 1</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()
|