Publish The Body That Asks EPUB reader

This commit is contained in:
Meta Vens 2026-07-15 05:54:47 +01:00
commit d6a4186076
9 changed files with 183 additions and 59 deletions

View file

@ -5,4 +5,4 @@ Finished long-form fiction promoted from Aetheria's developmental story rooms.
## Novellas
- [[The Burden of Proof]] - A warm black comedy of pirate doctrine, repeated tactical failure, worker authority, cognition, and the difficult distinction between being needed and being chosen. [Download EPUB](/static/fiction/the-burden-of-proof.epub)
- [[The Body That Asks]] - A political mecha academy thriller about five cadets, one damaged machine person, and an institution trying to manufacture the power of a family that can refuse itself and remain.
- [[The Body That Asks]] - A political mecha academy thriller about five cadets, one damaged machine person, and an institution trying to manufacture the power of a family that can refuse itself and remain. [Download EPUB](/static/fiction/the-body-that-asks.epub)

Binary file not shown.

18
scripts/fiction/README.md Normal file
View file

@ -0,0 +1,18 @@
# Fiction EPUB Builder
`build_epub.py` is the single EPUB packaging and validation path for Aetheria fiction. It derives the title, movements, chapter order, navigation, and spine from the promoted manuscript while taking the output path and stable public identifier explicitly.
It accepts both live manuscript heading schemes:
- `# Title`, `## Movement`, `### Chapter`
- `# Title`, `# Movement`, `## Chapter`
Build the published books from the repository root:
```powershell
python scripts/fiction/build_epub.py --source "Aetheria/Fiction/The Burden of Proof.md" --output "Aetheria/static/fiction/the-burden-of-proof.epub" --identifier-url "https://aetheria.gamecult.org/Fiction/The-Burden-of-Proof" --expected-chapters 28 --expected-title "The Burden of Proof"
python scripts/fiction/build_epub.py --source "Aetheria/Fiction/The Body That Asks.md" --output "Aetheria/static/fiction/the-body-that-asks.epub" --identifier-url "https://aetheria.gamecult.org/Fiction/The-Body-That-Asks" --expected-chapters 28 --expected-title "The Body That Asks"
```
Use the same arguments with `--check` to validate an existing artifact without rebuilding it. Set `SOURCE_DATE_EPOCH` when byte-for-byte reproducibility across filesystems is required.

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build and validate The Burden of Proof as a deterministic EPUB 3 archive."""
"""Build and validate an Aetheria fiction manuscript as a deterministic EPUB 3 archive."""
from __future__ import annotations
@ -15,14 +15,9 @@ from pathlib import Path
from xml.etree import ElementTree
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SOURCE = ROOT / "Aetheria" / "Fiction" / "The Burden of Proof.md"
DEFAULT_OUTPUT = ROOT / "Aetheria" / "static" / "fiction" / "the-burden-of-proof.epub"
BOOK_ID = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'https://aetheria.gamecult.org/Fiction/The-Burden-of-Proof')}"
@dataclass
class Chapter:
number: int
title: str
movement: str
blocks: list[str]
@ -53,17 +48,42 @@ def parse_manuscript(source: Path) -> tuple[str, list[Chapter]]:
title = lines[0][2:].strip()
movement = ""
movement_level: int | None = None
heading_schema: tuple[int, int] | None = None
chapters: list[Chapter] = []
current: Chapter | None = None
for line in lines[1:]:
if line.startswith("## "):
movement = line[3:].strip()
elif line.startswith("### "):
current = Chapter(line[4:].strip(), movement, [])
heading = re.fullmatch(r"(#{1,3})\s+(.+?)\s*", line)
if heading and re.match(r"Movement\s+", heading.group(2), re.IGNORECASE):
level = len(heading.group(1))
if heading_schema is not None and level != heading_schema[0]:
raise ValueError("Movement heading level changes within the manuscript")
movement = heading.group(2)
movement_level = level
current = None
elif heading and (chapter_match := re.match(r"Chapter\s+(\d+)\b", heading.group(2), re.IGNORECASE)):
number = int(chapter_match.group(1))
expected = len(chapters) + 1
if number != expected:
raise ValueError(f"Expected Chapter {expected}, found Chapter {number}")
if not movement:
raise ValueError(f"Chapter {number} has no movement")
schema = (movement_level, len(heading.group(1)))
if schema not in {(1, 2), (2, 3)}:
raise ValueError("Use H1 movements with H2 chapters, or H2 movements with H3 chapters")
if heading_schema is None:
heading_schema = schema
elif schema != heading_schema:
raise ValueError("Movement and chapter heading levels must remain consistent")
current = Chapter(number, heading.group(2), movement, [])
chapters.append(current)
elif heading:
raise ValueError(f"Unsupported heading after title: {line}")
elif current is not None and line.strip():
current.blocks.append(line.strip())
elif line.strip():
raise ValueError(f"Prose appears before the first chapter: {line}")
if not chapters or any(not chapter.blocks for chapter in chapters):
raise ValueError("Every chapter must contain prose")
@ -82,6 +102,9 @@ def xhtml_page(title: str, body: str, body_class: str = "") -> str:
def chapter_xhtml(chapter: Chapter) -> str:
paragraphs = []
for block in chapter.blocks:
if block in {"***", "* * *", "---", "_ _ _"}:
paragraphs.append('<hr class="scene-break"/>')
continue
rendered = inline_markup(block)
css_class = " class=\"pov\"" if re.fullmatch(r"<strong>[^<]+</strong>", rendered) else ""
paragraphs.append(f"<p{css_class}>{rendered}</p>")
@ -97,8 +120,41 @@ def modified_timestamp(source: Path) -> str:
return datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def build(source: Path, output: Path) -> None:
def archive_timestamp(source: Path) -> tuple[int, int, int, int, int, int]:
epoch = int(os.environ.get("SOURCE_DATE_EPOCH", source.stat().st_mtime))
value = datetime.fromtimestamp(epoch, timezone.utc)
if value.year < 1980:
value = datetime(1980, 1, 1, tzinfo=timezone.utc)
return value.year, value.month, value.day, value.hour, value.minute, value.second
def write_entry(
archive: zipfile.ZipFile,
name: str,
data: str,
timestamp: tuple[int, int, int, int, int, int],
compress_type: int = zipfile.ZIP_DEFLATED,
) -> None:
info = zipfile.ZipInfo(name, date_time=timestamp)
info.compress_type = compress_type
info.create_system = 3
info.external_attr = 0o644 << 16
archive.writestr(info, data)
def build(
source: Path,
output: Path,
identifier_url: str,
expected_chapters: int | None = None,
expected_title: str | None = None,
) -> None:
title, chapters = parse_manuscript(source)
if expected_title is not None and title != expected_title:
raise ValueError(f"Expected title {expected_title!r}, found {title!r}")
if expected_chapters is not None and len(chapters) != expected_chapters:
raise ValueError(f"Expected {expected_chapters} chapters, found {len(chapters)}")
book_id = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, identifier_url)}"
output.parent.mkdir(parents=True, exist_ok=True)
chapter_items = "\n".join(
f'<item id="chapter-{i}" href="text/chapter-{i:02}.xhtml" media-type="application/xhtml+xml"/>'
@ -115,32 +171,33 @@ def build(source: Path, output: Path) -> None:
)
package = f'''<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="en-GB">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">{BOOK_ID}</dc:identifier><dc:title>{html.escape(title)}</dc:title><dc:creator>GameCult</dc:creator><dc:language>en-GB</dc:language><meta property="dcterms:modified">{modified_timestamp(source)}</meta></metadata>
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">{book_id}</dc:identifier><dc:title>{html.escape(title)}</dc:title><dc:creator>GameCult</dc:creator><dc:language>en-GB</dc:language><meta property="dcterms:modified">{modified_timestamp(source)}</meta></metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/><item id="css" href="styles/book.css" media-type="text/css"/><item id="title" href="text/title.xhtml" media-type="application/xhtml+xml"/>{chapter_items}</manifest>
<spine toc="ncx"><itemref idref="title"/>{spine}</spine>
</package>'''
nav = f'''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en-GB"><head><meta charset="utf-8"/><title>Contents</title></head><body><nav epub:type="toc"><h1>Contents</h1><ol>{nav_points}</ol></nav></body></html>'''
ncx = f'''<?xml version="1.0" encoding="utf-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><head><meta name="dtb:uid" content="{BOOK_ID}"/></head><docTitle><text>{html.escape(title)}</text></docTitle><navMap>{ncx_points}</navMap></ncx>'''
title_page = xhtml_page(title, f'<main class="title-page"><h1>{html.escape(title)}</h1><p>A novella set in Aetheria</p><p class="author">GameCult</p></main>', "title")
css = """body{font-family:Georgia,serif;line-height:1.62;margin:5%;color:#18191b;background:#faf8f3}h1{line-height:1.15}p{margin:0 0 .85em}.movement{text-transform:uppercase;letter-spacing:.08em;font:700 .72em system-ui,sans-serif;color:#666}.pov{margin:1.25em 0 1.5em;font-family:system-ui,sans-serif}.title-page{text-align:center;margin-top:30vh}.title-page h1{font-size:2.4em}.author{margin-top:3em}code{font-family:monospace;font-size:.9em}@media(prefers-color-scheme:dark){body{color:#e9e7e1;background:#18191b}.movement{color:#aaa}}"""
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><head><meta name="dtb:uid" content="{book_id}"/></head><docTitle><text>{html.escape(title)}</text></docTitle><navMap>{ncx_points}</navMap></ncx>'''
title_page = xhtml_page(title, f'<main class="title-page"><h1>{html.escape(title)}</h1><p>A story set in Aetheria</p><p class="author">GameCult</p></main>', "title")
css = """body{font-family:Georgia,serif;line-height:1.62;margin:5%;color:#18191b;background:#faf8f3}h1{line-height:1.15}p{margin:0 0 .85em}.movement{text-transform:uppercase;letter-spacing:.08em;font:700 .72em system-ui,sans-serif;color:#666}.pov{margin:1.25em 0 1.5em;font-family:system-ui,sans-serif}.scene-break{border:0;text-align:center;margin:1.75em 0}.scene-break::after{content:"* * *";letter-spacing:.6em}.title-page{text-align:center;margin-top:30vh}.title-page h1{font-size:2.4em}.author{margin-top:3em}code{font-family:monospace;font-size:.9em}@media(prefers-color-scheme:dark){body{color:#e9e7e1;background:#18191b}.movement{color:#aaa}}"""
container = '''<?xml version="1.0" encoding="utf-8"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'''
timestamp = archive_timestamp(source)
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
archive.writestr("META-INF/container.xml", container)
archive.writestr("EPUB/package.opf", package)
archive.writestr("EPUB/nav.xhtml", nav)
archive.writestr("EPUB/toc.ncx", ncx)
archive.writestr("EPUB/styles/book.css", css)
archive.writestr("EPUB/text/title.xhtml", title_page)
write_entry(archive, "mimetype", "application/epub+zip", timestamp, zipfile.ZIP_STORED)
write_entry(archive, "META-INF/container.xml", container, timestamp)
write_entry(archive, "EPUB/package.opf", package, timestamp)
write_entry(archive, "EPUB/nav.xhtml", nav, timestamp)
write_entry(archive, "EPUB/toc.ncx", ncx, timestamp)
write_entry(archive, "EPUB/styles/book.css", css, timestamp)
write_entry(archive, "EPUB/text/title.xhtml", title_page, timestamp)
for i, chapter in enumerate(chapters, 1):
archive.writestr(f"EPUB/text/chapter-{i:02}.xhtml", chapter_xhtml(chapter))
validate(output, len(chapters))
write_entry(archive, f"EPUB/text/chapter-{i:02}.xhtml", chapter_xhtml(chapter), timestamp)
validate(output, len(chapters), title)
def validate(epub: Path, expected_chapters: int | None = None) -> None:
def validate(epub: Path, expected_chapters: int | None = None, expected_title: str | None = None) -> None:
with zipfile.ZipFile(epub) as archive:
entries = archive.infolist()
if not entries or entries[0].filename != "mimetype" or entries[0].compress_type != zipfile.ZIP_STORED:
@ -151,20 +208,42 @@ def validate(epub: Path, expected_chapters: int | None = None) -> None:
for name in xml_entries:
ElementTree.fromstring(archive.read(name))
chapters = [name for name in archive.namelist() if re.fullmatch(r"EPUB/text/chapter-\d+\.xhtml", name)]
expected_names = [f"EPUB/text/chapter-{i:02}.xhtml" for i in range(1, len(chapters) + 1)]
if chapters != expected_names:
raise ValueError("Chapter files must be sequential and ordered")
if expected_chapters is not None and len(chapters) != expected_chapters:
raise ValueError(f"Expected {expected_chapters} chapters, found {len(chapters)}")
package = ElementTree.fromstring(archive.read("EPUB/package.opf"))
title = package.find(".//{http://purl.org/dc/elements/1.1/}title")
actual_title = None if title is None else title.text
if expected_title is not None and actual_title != expected_title:
raise ValueError(f"Expected title {expected_title!r}, found {actual_title!r}")
spine_items = package.findall(".//{http://www.idpf.org/2007/opf}spine/{http://www.idpf.org/2007/opf}itemref")
if len(spine_items) != len(chapters) + 1:
raise ValueError("Spine must contain the title page and every chapter")
nav = ElementTree.fromstring(archive.read("EPUB/nav.xhtml"))
nav_items = nav.findall(".//{http://www.w3.org/1999/xhtml}li")
if len(nav_items) != len(chapters):
raise ValueError("Navigation must contain every chapter")
ncx = ElementTree.fromstring(archive.read("EPUB/toc.ncx"))
ncx_items = ncx.findall(".//{http://www.daisy.org/z3986/2005/ncx/}navPoint")
if len(ncx_items) != len(chapters):
raise ValueError("NCX must contain every chapter")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--identifier-url", required=True)
parser.add_argument("--expected-chapters", type=int)
parser.add_argument("--expected-title")
parser.add_argument("--check", action="store_true", help="Validate the existing output without rebuilding")
args = parser.parse_args()
if args.check:
validate(args.output)
validate(args.output, args.expected_chapters, args.expected_title)
else:
build(args.source, args.output)
build(args.source, args.output, args.identifier_url, args.expected_chapters, args.expected_title)
print(args.output)

View file

@ -1,7 +1,7 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
import AetheriaAuthorMeta from "./quartz/components/AetheriaAuthorMeta"
import AetheriaEpubReader from "./quartz/components/AetheriaEpubReader"
import AetheriaEpubReader, { isAetheriaEpubReaderPage } from "./quartz/components/AetheriaEpubReader"
import AetheriaMasthead from "./quartz/components/AetheriaMasthead"
import AetheriaOverviewSidebar from "./quartz/components/AetheriaOverviewSidebar"
import AetheriaThemeLock from "./quartz/components/AetheriaThemeLock"
@ -25,32 +25,32 @@ export const defaultContentPageLayout: PageLayout = {
showCurrentPage: false,
showRoot: false,
}),
condition: (page) => page.fileData.slug !== "index" && page.fileData.slug !== "Fiction/The-Burden-of-Proof",
condition: (page) => page.fileData.slug !== "index" && !isAetheriaEpubReaderPage(page.fileData.slug),
}),
Component.ConditionalRender({
component: Component.ArticleTitle(),
condition: (page) =>
!page.fileData.slug?.endsWith("/index") &&
page.fileData.slug !== "index" &&
page.fileData.slug !== "Fiction/The-Burden-of-Proof",
!isAetheriaEpubReaderPage(page.fileData.slug),
}),
Component.ConditionalRender({
component: AetheriaAuthorMeta(),
condition: (page) =>
!page.fileData.slug?.endsWith("/index") &&
page.fileData.slug !== "index" &&
page.fileData.slug !== "Fiction/The-Burden-of-Proof",
!isAetheriaEpubReaderPage(page.fileData.slug),
}),
Component.ConditionalRender({
component: Component.ContentMeta(),
condition: (page) =>
!page.fileData.slug?.endsWith("/index") &&
page.fileData.slug !== "index" &&
page.fileData.slug !== "Fiction/The-Burden-of-Proof",
!isAetheriaEpubReaderPage(page.fileData.slug),
}),
Component.ConditionalRender({
component: AetheriaEpubReader(),
condition: (page) => page.fileData.slug === "Fiction/The-Burden-of-Proof",
condition: (page) => isAetheriaEpubReaderPage(page.fileData.slug),
}),
],
afterBody: [
@ -70,17 +70,17 @@ export const defaultContentPageLayout: PageLayout = {
left: [
Component.ConditionalRender({
component: AetheriaOverviewSidebar(),
condition: (page) => page.fileData.slug !== "Fiction/The-Burden-of-Proof",
condition: (page) => !isAetheriaEpubReaderPage(page.fileData.slug),
}),
],
right: [
Component.ConditionalRender({
component: Component.DesktopOnly(Component.TableOfContents()),
condition: (page) => page.fileData.slug !== "Fiction/The-Burden-of-Proof",
condition: (page) => !isAetheriaEpubReaderPage(page.fileData.slug),
}),
Component.ConditionalRender({
component: Component.Backlinks(),
condition: (page) => page.fileData.slug !== "Fiction/The-Burden-of-Proof",
condition: (page) => !isAetheriaEpubReaderPage(page.fileData.slug),
}),
],
}

View file

@ -1,21 +1,47 @@
import { QuartzComponent, QuartzComponentConstructor } from "./types"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
// @ts-ignore
import script from "./scripts/aetheriaEpubReader.inline"
import style from "./styles/aetheriaEpubReader.scss"
type FictionReaderConfig = {
title: string
epubHref: string
storageKey: string
}
const fictionReaders: Record<string, FictionReaderConfig> = {
"Fiction/The-Burden-of-Proof": {
title: "The Burden of Proof",
epubHref: "/static/fiction/the-burden-of-proof.epub",
storageKey: "aetheria_tbp_reader",
},
"Fiction/The-Body-That-Asks": {
title: "The Body That Asks",
epubHref: "/static/fiction/the-body-that-asks.epub",
storageKey: "aetheria_tbta_reader",
},
}
export const isAetheriaEpubReaderPage = (slug: string | undefined) => Boolean(slug && fictionReaders[slug])
export default (() => {
const AetheriaEpubReader: QuartzComponent = () => (
<section
class="aetheria-epub-reader"
data-epub-src="/static/fiction/the-burden-of-proof.epub?v=reader-native-pages-1"
aria-label="The Burden of Proof ebook reader"
>
const AetheriaEpubReader: QuartzComponent = ({ fileData }: QuartzComponentProps) => {
const config = fileData.slug ? fictionReaders[fileData.slug] : undefined
if (!config) return null
return (
<section
class="aetheria-epub-reader"
data-epub-src={`${config.epubHref}?v=reader-multibook-1`}
data-reader-storage-key={config.storageKey}
aria-label={`${config.title} ebook reader`}
>
<div class="aetheria-reader-heading">
<div>
<p class="aetheria-reader-kicker">Ebook edition</p>
<h2>Read The Burden of Proof</h2>
<h2>Read {config.title}</h2>
</div>
<a class="aetheria-reader-download" href="/static/fiction/the-burden-of-proof.epub" download>
<a class="aetheria-reader-download" href={config.epubHref} download>
Download EPUB
</a>
</div>
@ -76,8 +102,9 @@ export default (() => {
&rsaquo;
</button>
</div>
</section>
)
</section>
)
}
AetheriaEpubReader.css = style
AetheriaEpubReader.afterDOMLoaded = script

View file

@ -16,7 +16,6 @@ declare global {
}
}
const cookieName = "aetheria_tbp_reader"
const settingsVersion = 5
const defaults: ReaderSettings = {
version: settingsVersion,
@ -28,7 +27,7 @@ const defaults: ReaderSettings = {
spread: "auto",
}
function readSettings(): ReaderSettings {
function readSettings(cookieName: string): ReaderSettings {
try {
const raw = document.cookie.split("; ").find((entry) => entry.startsWith(`${cookieName}=`))?.split("=").slice(1).join("=")
const saved = raw ? JSON.parse(decodeURIComponent(raw)) : {}
@ -48,7 +47,7 @@ function readSettings(): ReaderSettings {
}
}
function saveSettings(settings: ReaderSettings) {
function saveSettings(cookieName: string, settings: ReaderSettings) {
document.cookie = `${cookieName}=${encodeURIComponent(JSON.stringify(settings))}; Max-Age=31536000; Path=/; SameSite=Lax`
}
@ -73,9 +72,10 @@ async function initReader(root: HTMLElement) {
const status = root.querySelector<HTMLElement>("[data-reader-status]")!
const progress = root.querySelector<HTMLElement>("[data-reader-progress]")!
const viewport = root.querySelector<HTMLElement>("[data-reader-viewport]")!
const settings = readSettings()
try {
const cookieName = root.dataset.readerStorageKey
if (!cookieName) throw new Error("The reader has no persistence key")
const settings = readSettings(cookieName)
await loadScript("/static/vendor/epubjs/jszip.min.js", () => Boolean(window.JSZip))
await loadScript("/static/vendor/epubjs/epub.min.js", () => Boolean(window.ePub))
if (!window.ePub) throw new Error("The EPUB renderer did not initialise")
@ -165,7 +165,7 @@ async function initReader(root: HTMLElement) {
rendition.on("relocated", (location: any) => {
clampRenditionDom()
settings.cfi = location.start.cfi
saveSettings(settings)
saveSettings(cookieName, settings)
const percentage = book.locations?.length() ? book.locations.percentageFromCfi(settings.cfi) : null
progress.textContent = percentage == null ? "Position saved" : `${Math.round(percentage * 100)}% - position saved`
if (location.start.href) {
@ -210,7 +210,7 @@ async function initReader(root: HTMLElement) {
rendition.spread(settings.spread)
resizeRendition()
} else applyTypography()
saveSettings(settings)
saveSettings(cookieName, settings)
})
})

View file

@ -1,4 +1,4 @@
body[data-slug="Fiction/The-Burden-of-Proof"] {
body:has(.aetheria-epub-reader) {
.left.sidebar,
.right.sidebar,
article.popover-hint,