Publish The Body That Asks EPUB reader
This commit is contained in:
parent
620f9dbba9
commit
d6a4186076
9 changed files with 183 additions and 59 deletions
|
|
@ -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)
|
||||
|
|
|
|||
BIN
Aetheria/static/fiction/the-body-that-asks.epub
Normal file
BIN
Aetheria/static/fiction/the-body-that-asks.epub
Normal file
Binary file not shown.
Binary file not shown.
18
scripts/fiction/README.md
Normal file
18
scripts/fiction/README.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
@ -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),
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (() => {
|
|||
›
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
AetheriaEpubReader.css = style
|
||||
AetheriaEpubReader.afterDOMLoaded = script
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
body[data-slug="Fiction/The-Burden-of-Proof"] {
|
||||
body:has(.aetheria-epub-reader) {
|
||||
.left.sidebar,
|
||||
.right.sidebar,
|
||||
article.popover-hint,
|
||||
|
|
|
|||
Loading…
Reference in a new issue