diff --git a/Aetheria/Fiction/index.md b/Aetheria/Fiction/index.md index b58fac7..f44cd6d 100644 --- a/Aetheria/Fiction/index.md +++ b/Aetheria/Fiction/index.md @@ -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) diff --git a/Aetheria/static/fiction/the-body-that-asks.epub b/Aetheria/static/fiction/the-body-that-asks.epub new file mode 100644 index 0000000..ba43d58 Binary files /dev/null and b/Aetheria/static/fiction/the-body-that-asks.epub differ diff --git a/Aetheria/static/fiction/the-burden-of-proof.epub b/Aetheria/static/fiction/the-burden-of-proof.epub index 89c5c06..0d097ba 100644 Binary files a/Aetheria/static/fiction/the-burden-of-proof.epub and b/Aetheria/static/fiction/the-burden-of-proof.epub differ diff --git a/scripts/fiction/README.md b/scripts/fiction/README.md new file mode 100644 index 0000000..c14d92e --- /dev/null +++ b/scripts/fiction/README.md @@ -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. diff --git a/scripts/fiction/build_the_burden_of_proof_epub.py b/scripts/fiction/build_epub.py similarity index 51% rename from scripts/fiction/build_the_burden_of_proof_epub.py rename to scripts/fiction/build_epub.py index cdcb163..a623712 100644 --- a/scripts/fiction/build_the_burden_of_proof_epub.py +++ b/scripts/fiction/build_epub.py @@ -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('
') + continue rendered = inline_markup(block) css_class = " class=\"pov\"" if re.fullmatch(r"[^<]+", rendered) else "" paragraphs.append(f"{rendered}

") @@ -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'' @@ -115,32 +171,33 @@ def build(source: Path, output: Path) -> None: ) package = f''' -{BOOK_ID}{html.escape(title)}GameCulten-GB{modified_timestamp(source)} +{book_id}{html.escape(title)}GameCulten-GB{modified_timestamp(source)} {chapter_items} {spine} ''' nav = f''' Contents''' ncx = f''' -{html.escape(title)}{ncx_points}''' - title_page = xhtml_page(title, f'

{html.escape(title)}

A novella set in Aetheria

GameCult

', "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}}""" +{html.escape(title)}{ncx_points}''' + title_page = xhtml_page(title, f'

{html.escape(title)}

A story set in Aetheria

GameCult

', "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 = '''''' + 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) diff --git a/site/quartz.layout.ts b/site/quartz.layout.ts index 3cf1dae..2c00efd 100644 --- a/site/quartz.layout.ts +++ b/site/quartz.layout.ts @@ -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), }), ], } diff --git a/site/quartz/components/AetheriaEpubReader.tsx b/site/quartz/components/AetheriaEpubReader.tsx index fda398d..b239e17 100644 --- a/site/quartz/components/AetheriaEpubReader.tsx +++ b/site/quartz/components/AetheriaEpubReader.tsx @@ -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 = { + "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 = () => ( -
+ const AetheriaEpubReader: QuartzComponent = ({ fileData }: QuartzComponentProps) => { + const config = fileData.slug ? fictionReaders[fileData.slug] : undefined + if (!config) return null + + return ( +

Ebook edition

-

Read The Burden of Proof

+

Read {config.title}

- + Download EPUB
@@ -76,8 +102,9 @@ export default (() => { › -
- ) +
+ ) + } AetheriaEpubReader.css = style AetheriaEpubReader.afterDOMLoaded = script diff --git a/site/quartz/components/scripts/aetheriaEpubReader.inline.ts b/site/quartz/components/scripts/aetheriaEpubReader.inline.ts index bbb0ee9..c724d6d 100644 --- a/site/quartz/components/scripts/aetheriaEpubReader.inline.ts +++ b/site/quartz/components/scripts/aetheriaEpubReader.inline.ts @@ -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("[data-reader-status]")! const progress = root.querySelector("[data-reader-progress]")! const viewport = root.querySelector("[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) }) }) diff --git a/site/quartz/components/styles/aetheriaEpubReader.scss b/site/quartz/components/styles/aetheriaEpubReader.scss index 0d9cf04..12d9dd2 100644 --- a/site/quartz/components/styles/aetheriaEpubReader.scss +++ b/site/quartz/components/styles/aetheriaEpubReader.scss @@ -1,4 +1,4 @@ -body[data-slug="Fiction/The-Burden-of-Proof"] { +body:has(.aetheria-epub-reader) { .left.sidebar, .right.sidebar, article.popover-hint,