@@ -147,6 +162,40 @@ export class FolkPubsPublishPanel extends HTMLElement {
`;
}
+ private renderPublishSection(): string {
+ if (this._publishResult) {
+ return `
+
Saves PDF + EPUB to your space and returns a public URL anyone can visit.
@@ -320,6 +369,40 @@ export class FolkPubsPublishPanel extends HTMLElement {
this.sendEmailPdf();
});
+ this.shadowRoot.querySelector('[data-action="download-epub-reflow"]')?.addEventListener("click", () => {
+ this.downloadEpub("reflowable");
+ });
+
+ this.shadowRoot.querySelector('[data-action="download-epub-fixed"]')?.addEventListener("click", () => {
+ this.downloadEpub("fixed");
+ });
+
+ this.shadowRoot.querySelector('[data-action="publish-to-space"]')?.addEventListener("click", () => {
+ this.publishToSpace();
+ });
+
+ this.shadowRoot.querySelector('[data-action="toggle-include-fixed"]')?.addEventListener("change", (e) => {
+ this._publishIncludeFixed = (e.target as HTMLInputElement).checked;
+ });
+
+ this.shadowRoot.querySelector('[data-action="copy-published-link"]')?.addEventListener("click", () => {
+ if (!this._publishResult) return;
+ navigator.clipboard.writeText(this._publishResult.hosted_url).then(() => {
+ const btn = this.shadowRoot!.querySelector('[data-action="copy-published-link"] span');
+ if (btn) {
+ const orig = btn.textContent;
+ btn.textContent = "Copied!";
+ setTimeout(() => { btn.textContent = orig; }, 1500);
+ }
+ });
+ });
+
+ this.shadowRoot.querySelector('[data-action="publish-again"]')?.addEventListener("click", () => {
+ this._publishResult = null;
+ this._publishError = null;
+ this.render();
+ });
+
// DIY tab
this.shadowRoot.querySelector('[data-action="download-imposition"]')?.addEventListener("click", () => {
this.downloadImposition();
@@ -400,6 +483,83 @@ export class FolkPubsPublishPanel extends HTMLElement {
}
}
+ private async publishToSpace() {
+ this._publishing = true;
+ this._publishError = null;
+ this.render();
+
+ try {
+ const res = await fetch(`${getModuleApiBase("rpubs")}/api/publish`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ format: this._formatId,
+ include_fixed_epub: this._publishIncludeFixed,
+ ...this.getEditorContent(),
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error((err as any).error || "Publish failed");
+ }
+
+ const data = await res.json();
+ this._publishResult = {
+ hosted_url: data.hosted_url,
+ pdf_url: data.pdf_url,
+ epub_url: data.epub_url,
+ epub_fixed_url: data.epub_fixed_url,
+ };
+ } catch (e: any) {
+ this._publishError = e.message;
+ console.error("[rpubs] Publish error:", e);
+ } finally {
+ this._publishing = false;
+ this.render();
+ }
+ }
+
+ private async downloadEpub(mode: "reflowable" | "fixed") {
+ this._epubError = null;
+ if (mode === "reflowable") this._epubReflowLoading = true;
+ else this._epubFixedLoading = true;
+ this.render();
+
+ try {
+ const res = await fetch(`${getModuleApiBase("rpubs")}/api/generate-epub`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ format: this._formatId,
+ mode,
+ ...this.getEditorContent(),
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error((err as any).error || "EPUB generation failed");
+ }
+
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ const suffix = mode === "fixed" ? `-${this._formatId}-fixed` : "";
+ a.download = `publication${suffix}.epub`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e: any) {
+ this._epubError = e.message;
+ console.error("[rpubs] EPUB error:", e);
+ } finally {
+ if (mode === "reflowable") this._epubReflowLoading = false;
+ else this._epubFixedLoading = false;
+ this.render();
+ }
+ }
+
private async downloadImposition() {
this._impositionLoading = true;
this.render();
@@ -753,6 +913,51 @@ export class FolkPubsPublishPanel extends HTMLElement {
.send-btn { width: auto; flex-shrink: 0; padding: 0.5rem 0.875rem; }
+ .epub-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+ }
+ .epub-row .epub-btn { font-size: 0.8125rem; padding: 0.5rem 0.5rem; }
+
+ .publish-btn { font-size: 0.95rem; padding: 0.7rem 1rem; }
+ .publish-option {
+ display: flex; align-items: flex-start; gap: 0.5rem;
+ margin: 0.5rem 0 0; font-size: 0.8125rem;
+ color: var(--rs-text-muted, #94a3b8); cursor: pointer;
+ }
+ .publish-option input { margin-top: 0.15rem; flex-shrink: 0; }
+
+ .publish-success {
+ background: var(--rs-surface-alt, #0f172a);
+ border: 1px solid color-mix(in srgb, var(--rs-accent, #14b8a6) 30%, transparent);
+ border-radius: 8px;
+ padding: 0.75rem;
+ display: flex; flex-direction: column; gap: 0.5rem;
+ }
+ .publish-success-head {
+ display: flex; align-items: center; gap: 0.4rem;
+ font-weight: 600; font-size: 0.875rem;
+ color: var(--rs-accent, #14b8a6);
+ }
+ .publish-url-row { display: flex; gap: 0.4rem; align-items: center; }
+ .publish-url {
+ flex: 1; min-width: 0; padding: 0.4rem 0.5rem;
+ background: var(--rs-bg-page, #020617);
+ color: inherit; border: 1px solid var(--rs-border, #334155);
+ border-radius: 4px; font-family: inherit; font-size: 0.78rem;
+ }
+ .publish-copy-btn { width: auto; flex-shrink: 0; padding: 0.4rem 0.6rem; font-size: 0.78rem; }
+ .publish-actions-row {
+ display: flex; justify-content: space-between; align-items: center;
+ font-size: 0.8125rem;
+ }
+ .publish-link, .publish-link-btn {
+ background: none; border: none; color: var(--rs-accent, #14b8a6);
+ font: inherit; padding: 0; cursor: pointer; text-decoration: underline;
+ }
+ .publish-link:hover, .publish-link-btn:hover { opacity: 0.8; }
+
/* ── Spinner ── */
.spinner {
diff --git a/modules/rpubs/epub-gen.ts b/modules/rpubs/epub-gen.ts
new file mode 100644
index 00000000..3a446049
--- /dev/null
+++ b/modules/rpubs/epub-gen.ts
@@ -0,0 +1,447 @@
+/**
+ * EPUB3 generator — reflowable (from markdown) + fixed-layout (from Typst PNGs).
+ *
+ * Produces valid EPUB3 packages using JSZip (already a dep). No external tools.
+ *
+ * Reflowable mode: markdown sections → styled xhtml chapters. Resizable text,
+ * tiny file, reads well on any device.
+ *
+ * Fixed-layout mode: each page of the compiled Typst PDF is embedded as a PNG
+ * image in a pre-paginated xhtml page — preserves the pocket-book design 1:1.
+ */
+
+import JSZip from "jszip";
+import { marked } from "marked";
+import { randomUUID } from "node:crypto";
+import type { ParsedDocument, DocumentSection, DocumentBlock } from "./parse-document";
+import type { PageImage } from "./typst-compile";
+import { getFormat } from "./formats";
+
+// ── Shared helpers ──
+
+function esc(s: string): string {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function slug(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60) || "doc";
+}
+
+function containerXml(): string {
+ return `
+
+
+
+
+`;
+}
+
+interface ManifestItem {
+ id: string;
+ href: string;
+ mediaType: string;
+ properties?: string;
+ inSpine?: boolean;
+ spineProperties?: string;
+}
+
+function buildPackageOpf(opts: {
+ uuid: string;
+ title: string;
+ author: string;
+ language: string;
+ description?: string;
+ items: ManifestItem[];
+ fixedLayout?: boolean;
+ coverImageId?: string;
+}): string {
+ const { uuid, title, author, language, description, items, fixedLayout, coverImageId } = opts;
+ const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
+
+ const manifest = items
+ .map((i) => {
+ const attrs = [
+ `id="${esc(i.id)}"`,
+ `href="${esc(i.href)}"`,
+ `media-type="${esc(i.mediaType)}"`,
+ ];
+ if (i.properties) attrs.push(`properties="${esc(i.properties)}"`);
+ return `
`;
+ })
+ .join("\n");
+
+ const spine = items
+ .filter((i) => i.inSpine)
+ .map((i) => {
+ const attrs = [`idref="${esc(i.id)}"`];
+ if (i.spineProperties) attrs.push(`properties="${esc(i.spineProperties)}"`);
+ return `
`;
+ })
+ .join("\n");
+
+ const layoutMeta = fixedLayout
+ ? `
+
pre-paginated
+
auto
+
auto`
+ : "";
+
+ const coverMeta = coverImageId
+ ? `\n
`
+ : "";
+
+ return `
+
+
+ urn:uuid:${esc(uuid)}
+ ${esc(title)}
+ ${esc(author)}
+ ${esc(language)}
+ ${description ? `${esc(description)}` : ""}
+ ${now}${layoutMeta}${coverMeta}
+
+
+${manifest}
+
+
+${spine}
+
+`;
+}
+
+function buildNav(items: { href: string; title: string }[], language: string): string {
+ return `
+
+
Contents
+
+
+
+`;
+}
+
+function buildNcx(opts: { uuid: string; title: string; items: { href: string; title: string }[] }): string {
+ const { uuid, title, items } = opts;
+ return `
+
+
+
+
+
+
+
+ ${esc(title)}
+
+${items
+ .map(
+ (it, i) => `
+ ${esc(it.title)}
+
+ `,
+ )
+ .join("\n")}
+
+`;
+}
+
+async function finalizeZip(zip: JSZip): Promise
{
+ const data = await zip.generateAsync({
+ type: "nodebuffer",
+ compression: "DEFLATE",
+ compressionOptions: { level: 9 },
+ mimeType: "application/epub+zip",
+ });
+ return data as Buffer;
+}
+
+function seedEpubSkeleton(): JSZip {
+ const zip = new JSZip();
+ // mimetype MUST be first and uncompressed.
+ zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
+ zip.folder("META-INF")!.file("container.xml", containerXml());
+ return zip;
+}
+
+// ── Reflowable ──
+
+function renderBlockToHtml(block: DocumentBlock): string {
+ switch (block.type) {
+ case "paragraph":
+ return `${marked.parseInline(block.text) as string}
`;
+ case "quote": {
+ const inner = marked.parseInline(block.text) as string;
+ const attrib = block.attribution
+ ? ``
+ : "";
+ return `${inner}
${attrib}
`;
+ }
+ case "list": {
+ const tag = block.ordered ? "ol" : "ul";
+ const items = block.items
+ .map((i) => `${marked.parseInline(i) as string}`)
+ .join("");
+ return `<${tag}>${items}${tag}>`;
+ }
+ case "code": {
+ const lang = block.language ? ` class="language-${esc(block.language)}"` : "";
+ return `${esc(block.code)}
`;
+ }
+ case "separator":
+ return `
`;
+ }
+}
+
+function renderSectionXhtml(section: DocumentSection, language: string): string {
+ const body = [
+ section.heading
+ ? `${esc(section.heading)}`
+ : "",
+ ...section.blocks.map(renderBlockToHtml),
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ return `
+
+
+
+ ${esc(section.heading || "Section")}
+
+
+
+${body}
+
+`;
+}
+
+const REFLOWABLE_CSS = `@namespace epub "http://www.idpf.org/2007/ops";
+html, body { margin: 0; padding: 0; }
+body { font-family: Georgia, "Times New Roman", serif; line-height: 1.55; padding: 0 1em; }
+h1, h2, h3, h4 { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; line-height: 1.25; margin: 1.2em 0 0.5em; }
+h1 { font-size: 1.6em; }
+h2 { font-size: 1.35em; }
+h3 { font-size: 1.15em; }
+p { margin: 0.6em 0; text-align: justify; hyphens: auto; -webkit-hyphens: auto; }
+blockquote { margin: 1em 1.5em; font-style: italic; color: #444; border-left: 2px solid #999; padding-left: 0.8em; }
+blockquote footer { font-style: normal; font-size: 0.9em; color: #666; margin-top: 0.3em; }
+pre { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; background: #f4f4f4; padding: 0.6em 0.8em; border-radius: 3px; overflow-x: auto; white-space: pre-wrap; }
+code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
+ul, ol { margin: 0.6em 0 0.6em 1.5em; }
+li { margin: 0.2em 0; }
+hr { border: none; border-top: 1px solid #ccc; margin: 2em auto; width: 40%; }
+img { max-width: 100%; height: auto; }
+.title-page { text-align: center; padding-top: 20%; }
+.title-page h1 { font-size: 2.2em; margin-bottom: 0.3em; }
+.title-page .author { font-style: italic; color: #555; margin-top: 1em; }`;
+
+export async function buildReflowableEpub(opts: {
+ document: ParsedDocument;
+ language?: string;
+ description?: string;
+}): Promise {
+ const { document, language = "en", description } = opts;
+ const uuid = randomUUID();
+ const zip = seedEpubSkeleton();
+ const oebps = zip.folder("OEBPS")!;
+
+ oebps.folder("styles")!.file("book.css", REFLOWABLE_CSS);
+
+ // Title page
+ const titlePage = `
+
+${esc(document.title)}
+${esc(document.title)}
${document.author ? `
${esc(document.author)}
` : ""}
+`;
+ oebps.file("title.xhtml", titlePage);
+
+ const navItems: { href: string; title: string }[] = [
+ { href: "title.xhtml", title: document.title },
+ ];
+ const manifestItems: ManifestItem[] = [
+ { id: "css", href: "styles/book.css", mediaType: "text/css" },
+ { id: "title", href: "title.xhtml", mediaType: "application/xhtml+xml", inSpine: true },
+ ];
+
+ document.sections.forEach((section, idx) => {
+ const href = `sections/section-${String(idx + 1).padStart(3, "0")}.xhtml`;
+ oebps.folder("sections")!.file(
+ `section-${String(idx + 1).padStart(3, "0")}.xhtml`,
+ renderSectionXhtml(section, language),
+ );
+ manifestItems.push({
+ id: `sec${idx + 1}`,
+ href,
+ mediaType: "application/xhtml+xml",
+ inSpine: true,
+ });
+ if (section.heading) navItems.push({ href, title: section.heading });
+ });
+
+ // nav.xhtml (EPUB3) + toc.ncx (back-compat)
+ oebps.file("nav.xhtml", buildNav(navItems, language));
+ manifestItems.push({
+ id: "nav",
+ href: "nav.xhtml",
+ mediaType: "application/xhtml+xml",
+ properties: "nav",
+ });
+
+ oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems }));
+ manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" });
+
+ oebps.file(
+ "content.opf",
+ buildPackageOpf({
+ uuid,
+ title: document.title,
+ author: document.author || "Unknown",
+ language,
+ description,
+ items: manifestItems,
+ }),
+ );
+
+ return finalizeZip(zip);
+}
+
+// ── Fixed-layout ──
+
+const FIXED_CSS = `@namespace epub "http://www.idpf.org/2007/ops";
+html, body { margin: 0; padding: 0; overflow: hidden; }
+.page { position: absolute; inset: 0; width: 100%; height: 100%; }
+.page img { width: 100%; height: 100%; object-fit: contain; display: block; }`;
+
+function renderFixedPageXhtml(opts: {
+ imagePath: string;
+ width: number;
+ height: number;
+ pageNum: number;
+ language: string;
+}): string {
+ const { imagePath, width, height, pageNum, language } = opts;
+ return `
+
+
+
+ Page ${pageNum}
+
+
+
+
+ })
+
+`;
+}
+
+export async function buildFixedLayoutEpub(opts: {
+ document: ParsedDocument;
+ pages: PageImage[];
+ formatId: string;
+ language?: string;
+ description?: string;
+}): Promise {
+ const { document, pages, formatId, language = "en", description } = opts;
+ if (pages.length === 0) {
+ throw new Error("No pages to package");
+ }
+
+ const uuid = randomUUID();
+ const zip = seedEpubSkeleton();
+ const oebps = zip.folder("OEBPS")!;
+
+ oebps.folder("styles")!.file("page.css", FIXED_CSS);
+
+ const manifestItems: ManifestItem[] = [
+ { id: "css", href: "styles/page.css", mediaType: "text/css" },
+ ];
+ const navItems: { href: string; title: string }[] = [];
+
+ pages.forEach((page, i) => {
+ const num = i + 1;
+ const pad = String(num).padStart(4, "0");
+ const imgHref = `images/page-${pad}.png`;
+ const xhtmlHref = `pages/page-${pad}.xhtml`;
+
+ oebps.folder("images")!.file(`page-${pad}.png`, page.png);
+ oebps.folder("pages")!.file(
+ `page-${pad}.xhtml`,
+ renderFixedPageXhtml({
+ imagePath: `../${imgHref}`,
+ width: page.widthPx,
+ height: page.heightPx,
+ pageNum: num,
+ language,
+ }),
+ );
+
+ manifestItems.push({
+ id: `img-${pad}`,
+ href: imgHref,
+ mediaType: "image/png",
+ properties: i === 0 ? "cover-image" : undefined,
+ });
+ manifestItems.push({
+ id: `page-${pad}`,
+ href: xhtmlHref,
+ mediaType: "application/xhtml+xml",
+ inSpine: true,
+ spineProperties: i === 0 ? undefined : num % 2 === 0 ? "page-spread-left" : "page-spread-right",
+ });
+
+ if (i === 0) navItems.push({ href: xhtmlHref, title: document.title });
+ });
+
+ // Supplemental TOC entries for long docs
+ if (pages.length > 4) {
+ navItems.push({
+ href: `pages/page-${String(Math.floor(pages.length / 2)).padStart(4, "0")}.xhtml`,
+ title: "Middle",
+ });
+ navItems.push({
+ href: `pages/page-${String(pages.length).padStart(4, "0")}.xhtml`,
+ title: "End",
+ });
+ }
+
+ oebps.file("nav.xhtml", buildNav(navItems, language));
+ manifestItems.push({
+ id: "nav",
+ href: "nav.xhtml",
+ mediaType: "application/xhtml+xml",
+ properties: "nav",
+ });
+
+ oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems }));
+ manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" });
+
+ const fmt = getFormat(formatId);
+ const fmtDesc = fmt ? `${fmt.name} fixed-layout EPUB from rPubs` : undefined;
+
+ oebps.file(
+ "content.opf",
+ buildPackageOpf({
+ uuid,
+ title: document.title,
+ author: document.author || "Unknown",
+ language,
+ description: description || fmtDesc,
+ items: manifestItems,
+ fixedLayout: true,
+ coverImageId: "img-0001",
+ }),
+ );
+
+ return finalizeZip(zip);
+}
diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts
index b470e625..0bf19633 100644
--- a/modules/rpubs/mod.ts
+++ b/modules/rpubs/mod.ts
@@ -11,8 +11,16 @@ import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { createTransport, type Transporter } from "nodemailer";
import { parseMarkdown } from "./parse-document";
-import { compileDocument } from "./typst-compile";
+import { compileDocument, compileDocumentToPages } from "./typst-compile";
+import { buildReflowableEpub, buildFixedLayoutEpub } from "./epub-gen";
import { getFormat, FORMATS, listFormats } from "./formats";
+import {
+ listPublications,
+ getPublication,
+ getPublicationFile,
+ savePublication,
+ slugify as slugifyPub,
+} from "./publications-store";
import type { BookFormat } from "./formats";
import { generateImposition } from "./imposition";
import { discoverPrinters } from "./printer-discovery";
@@ -184,6 +192,77 @@ function escapeAttr(s: string): string {
return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
}
+function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function renderReaderPage(opts: { record: import("./publications-store").PublicationRecord; spaceSlug: string }): string {
+ const { record, spaceSlug } = opts;
+ const base = `/${escapeAttr(spaceSlug)}/rpubs/publications/${escapeAttr(record.slug)}`;
+ const pdfUrl = `${base}/pdf`;
+ const epubUrl = `${base}/epub`;
+ const epubFixedUrl = record.fixedEpubBytes ? `${base}/epub-fixed` : null;
+
+ return `
+
+
+
+
+ ${escapeAttr(record.title)}
+ ${record.author ? `by ${escapeAttr(record.author)}
` : ""}
+ ${escapeAttr(record.formatName)} · ${record.pageCount} pages · ${new Date(record.createdAt).toLocaleDateString()}
+ ${record.description ? `${escapeAttr(record.description)}
` : ""}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
// ── Routes ──
let _syncServer: SyncServer | null = null;
@@ -370,6 +449,215 @@ routes.get("/api/artifact/:id/pdf", async (c) => {
});
});
+// ── API: Generate EPUB (reflowable or fixed-layout) ──
+routes.post("/api/generate-epub", async (c) => {
+ try {
+ const body = await c.req.json();
+ const { content, title, author, format: formatId, mode = "reflowable", description } = body;
+
+ if (!content || typeof content !== "string" || content.trim().length === 0) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ const epubMode = mode === "fixed" ? "fixed" : "reflowable";
+
+ // Format is only required for fixed-layout (used by Typst).
+ if (epubMode === "fixed" && (!formatId || !getFormat(formatId))) {
+ return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400);
+ }
+
+ const document = parseMarkdown(content, title, author);
+ const slug = (document.title || "document").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "document";
+
+ let epubBuf: Buffer;
+ let filename: string;
+ let pageCount = 0;
+
+ if (epubMode === "fixed") {
+ const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 });
+ pageCount = pages.length;
+ epubBuf = await buildFixedLayoutEpub({ document, pages, formatId, description });
+ filename = `${slug}-${formatId}-fixed.epub`;
+ } else {
+ epubBuf = await buildReflowableEpub({ document, description });
+ filename = `${slug}.epub`;
+ }
+
+ return new Response(new Uint8Array(epubBuf), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/epub+zip",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ "Content-Length": String(epubBuf.length),
+ "X-Epub-Mode": epubMode,
+ ...(pageCount ? { "X-Page-Count": String(pageCount) } : {}),
+ },
+ });
+ } catch (error) {
+ console.error("[Pubs] EPUB error:", error);
+ return c.json({ error: error instanceof Error ? error.message : "EPUB generation failed" }, 500);
+ }
+});
+
+// ── API: Publish to a space (persistent, publicly addressable) ──
+routes.post("/api/publish", async (c) => {
+ try {
+ const body = await c.req.json();
+ const {
+ content,
+ title,
+ author,
+ format: formatId,
+ description,
+ include_fixed_epub = false,
+ } = body;
+
+ if (!content || typeof content !== "string" || content.trim().length === 0) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ const format = getFormat(formatId);
+ if (!formatId || !format) {
+ return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400);
+ }
+
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+
+ const document = parseMarkdown(content, title, author);
+
+ // Compile PDF (always) + reflowable EPUB (always).
+ const pdfResult = await compileDocument({ document, formatId });
+ const reflowable = await buildReflowableEpub({ document, description });
+
+ // Fixed EPUB only if requested — it's the costly path.
+ let fixedEpub: Buffer | undefined;
+ if (include_fixed_epub) {
+ const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 });
+ fixedEpub = await buildFixedLayoutEpub({ document, pages, formatId, description });
+ }
+
+ const record = await savePublication({
+ space,
+ title: document.title,
+ author: document.author || author || "Unknown",
+ description,
+ formatId,
+ formatName: format.name,
+ pageCount: pdfResult.pageCount,
+ sourceMarkdown: content,
+ pdf: pdfResult.pdf,
+ reflowableEpub: reflowable,
+ fixedEpub,
+ });
+
+ const proto = c.req.header("x-forwarded-proto") || "https";
+ const host = c.req.header("host") || "rspace.online";
+ const baseUrl = `${proto}://${host}`;
+ // The reader lives under the module mount (`/:space/rpubs/publications/:slug`)
+ // or, on the standalone rpubs.online domain, at `/publications/:slug`.
+ const isStandalone = host.includes("rpubs.online");
+ const hostedPath = isStandalone
+ ? `/publications/${record.slug}`
+ : `/${record.space}/rpubs/publications/${record.slug}`;
+
+ return c.json({
+ ...record,
+ hosted_url: `${baseUrl}${hostedPath}`,
+ hosted_path: hostedPath,
+ pdf_url: `${baseUrl}${hostedPath}/pdf`,
+ epub_url: `${baseUrl}${hostedPath}/epub`,
+ epub_fixed_url: fixedEpub ? `${baseUrl}${hostedPath}/epub-fixed` : null,
+ }, 201);
+ } catch (error) {
+ console.error("[Pubs] Publish error:", error);
+ return c.json({ error: error instanceof Error ? error.message : "Publish failed" }, 500);
+ }
+});
+
+// ── API: List publications in a space ──
+routes.get("/api/publications", async (c) => {
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+ const records = await listPublications(space);
+ return c.json({ publications: records });
+});
+
+// ── API: Get publication metadata ──
+routes.get("/api/publications/:slug", async (c) => {
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+ const slug = c.req.param("slug");
+ const record = await getPublication(space, slug);
+ if (!record) return c.json({ error: "Publication not found" }, 404);
+ return c.json(record);
+});
+
+// ── Public: Reader page ──
+routes.get("/publications/:slug", async (c) => {
+ const spaceSlug = c.req.param("space") || "personal";
+ const dataSpace = c.get("effectiveSpace") || spaceSlug;
+ const slug = c.req.param("slug");
+
+ const record = await getPublication(dataSpace, slug);
+ if (!record) {
+ return c.html(`Not foundPublication not found
No publication exists at this URL.
Back to rPubs
`, 404);
+ }
+
+ return c.html(renderShell({
+ title: `${record.title} — ${spaceSlug} | rSpace`,
+ moduleId: "rpubs",
+ spaceSlug,
+ modules: getModuleInfoList(),
+ theme: "dark",
+ body: renderReaderPage({ record, spaceSlug }),
+ scripts: ``,
+ styles: ``,
+ }));
+});
+
+// ── Public: Download PDF ──
+routes.get("/publications/:slug/pdf", async (c) => {
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+ const slug = c.req.param("slug");
+ const buf = await getPublicationFile(space, slug, "pdf");
+ if (!buf) return c.json({ error: "PDF not found" }, 404);
+ return new Response(new Uint8Array(buf), {
+ headers: {
+ "Content-Type": "application/pdf",
+ "Content-Disposition": `inline; filename="${slugifyPub(slug)}.pdf"`,
+ "Content-Length": String(buf.length),
+ },
+ });
+});
+
+// ── Public: Download EPUB (reflowable default) ──
+routes.get("/publications/:slug/epub", async (c) => {
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+ const slug = c.req.param("slug");
+ const buf = await getPublicationFile(space, slug, "reflowable.epub");
+ if (!buf) return c.json({ error: "EPUB not found" }, 404);
+ return new Response(new Uint8Array(buf), {
+ headers: {
+ "Content-Type": "application/epub+zip",
+ "Content-Disposition": `attachment; filename="${slugifyPub(slug)}.epub"`,
+ "Content-Length": String(buf.length),
+ },
+ });
+});
+
+// ── Public: Download fixed-layout EPUB (if available) ──
+routes.get("/publications/:slug/epub-fixed", async (c) => {
+ const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
+ const slug = c.req.param("slug");
+ const buf = await getPublicationFile(space, slug, "fixed.epub");
+ if (!buf) return c.json({ error: "Fixed-layout EPUB not available for this publication" }, 404);
+ return new Response(new Uint8Array(buf), {
+ headers: {
+ "Content-Type": "application/epub+zip",
+ "Content-Disposition": `attachment; filename="${slugifyPub(slug)}-fixed.epub"`,
+ "Content-Length": String(buf.length),
+ },
+ });
+});
+
// ── API: Generate imposition PDF ──
routes.post("/api/imposition", async (c) => {
try {
diff --git a/modules/rpubs/publications-store.ts b/modules/rpubs/publications-store.ts
new file mode 100644
index 00000000..9f95445d
--- /dev/null
+++ b/modules/rpubs/publications-store.ts
@@ -0,0 +1,180 @@
+/**
+ * Publications store — filesystem-backed persistent publications per space.
+ *
+ * Layout:
+ * {PUBS_DIR}/{space}/{slug}/publication.json
+ * {PUBS_DIR}/{space}/{slug}/source.pdf
+ * {PUBS_DIR}/{space}/{slug}/source.md
+ * {PUBS_DIR}/{space}/{slug}/reflowable.epub
+ * {PUBS_DIR}/{space}/{slug}/fixed.epub
+ *
+ * The reflowable EPUB is always built. The fixed EPUB is optional (costlier
+ * to generate — we only build it if asked).
+ */
+
+import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+
+export const PUBS_DIR = process.env.PUBS_DIR || "/data/rpubs-publications";
+
+export interface PublicationRecord {
+ id: string;
+ space: string;
+ slug: string;
+ title: string;
+ author: string;
+ description?: string;
+ formatId: string;
+ formatName: string;
+ pageCount: number;
+ pdfBytes: number;
+ reflowableEpubBytes: number;
+ fixedEpubBytes?: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export function slugify(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 80) || "publication";
+}
+
+export function safeSpace(space: string): string {
+ // Limit to safe chars — we're using this as a directory name.
+ return space.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 60) || "personal";
+}
+
+function spaceDir(space: string): string {
+ return resolve(PUBS_DIR, safeSpace(space));
+}
+
+function pubDir(space: string, slug: string): string {
+ return resolve(spaceDir(space), slugify(slug));
+}
+
+export async function listPublications(space: string): Promise {
+ const dir = spaceDir(space);
+ try {
+ await stat(dir);
+ } catch {
+ return [];
+ }
+
+ const slugs = await readdir(dir);
+ const records: PublicationRecord[] = [];
+ for (const slug of slugs) {
+ try {
+ const meta = await readFile(join(dir, slug, "publication.json"), "utf-8");
+ records.push(JSON.parse(meta));
+ } catch {
+ // skip malformed entries
+ }
+ }
+ return records.sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export async function getPublication(
+ space: string,
+ slug: string,
+): Promise {
+ try {
+ const meta = await readFile(
+ join(pubDir(space, slug), "publication.json"),
+ "utf-8",
+ );
+ return JSON.parse(meta);
+ } catch {
+ return null;
+ }
+}
+
+export async function getPublicationFile(
+ space: string,
+ slug: string,
+ file: "pdf" | "reflowable.epub" | "fixed.epub" | "source.md",
+): Promise {
+ const name =
+ file === "pdf" ? "source.pdf"
+ : file === "reflowable.epub" ? "reflowable.epub"
+ : file === "fixed.epub" ? "fixed.epub"
+ : "source.md";
+
+ try {
+ return Buffer.from(await readFile(join(pubDir(space, slug), name)));
+ } catch {
+ return null;
+ }
+}
+
+/** Ensure the slug is unique within the space — if not, append a short suffix. */
+async function uniqueSlug(space: string, desired: string): Promise {
+ let slug = slugify(desired);
+ const existing = new Set(
+ (await listPublications(space)).map((p) => p.slug),
+ );
+ if (!existing.has(slug)) return slug;
+
+ const suffix = Math.random().toString(36).slice(2, 7);
+ return `${slug}-${suffix}`;
+}
+
+export interface SavePublicationInput {
+ space: string;
+ title: string;
+ author: string;
+ description?: string;
+ formatId: string;
+ formatName: string;
+ pageCount: number;
+ sourceMarkdown: string;
+ pdf: Buffer;
+ reflowableEpub: Buffer;
+ fixedEpub?: Buffer;
+}
+
+export async function savePublication(
+ input: SavePublicationInput,
+): Promise {
+ const space = safeSpace(input.space);
+ const slug = await uniqueSlug(space, input.title);
+ const dir = pubDir(space, slug);
+ await mkdir(dir, { recursive: true });
+
+ await Promise.all([
+ writeFile(join(dir, "source.md"), input.sourceMarkdown, "utf-8"),
+ writeFile(join(dir, "source.pdf"), input.pdf),
+ writeFile(join(dir, "reflowable.epub"), input.reflowableEpub),
+ input.fixedEpub
+ ? writeFile(join(dir, "fixed.epub"), input.fixedEpub)
+ : Promise.resolve(),
+ ]);
+
+ const now = Date.now();
+ const record: PublicationRecord = {
+ id: crypto.randomUUID(),
+ space,
+ slug,
+ title: input.title,
+ author: input.author,
+ description: input.description,
+ formatId: input.formatId,
+ formatName: input.formatName,
+ pageCount: input.pageCount,
+ pdfBytes: input.pdf.length,
+ reflowableEpubBytes: input.reflowableEpub.length,
+ fixedEpubBytes: input.fixedEpub?.length,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ await writeFile(
+ join(dir, "publication.json"),
+ JSON.stringify(record, null, 2),
+ "utf-8",
+ );
+
+ return record;
+}
diff --git a/modules/rpubs/typst-compile.ts b/modules/rpubs/typst-compile.ts
index 9d05f361..79663548 100644
--- a/modules/rpubs/typst-compile.ts
+++ b/modules/rpubs/typst-compile.ts
@@ -5,7 +5,7 @@
* Uses Bun.spawn() instead of Node execFile().
*/
-import { writeFile, readFile, mkdir, unlink } from "node:fs/promises";
+import { writeFile, readFile, readdir, mkdir, unlink, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import type { ParsedDocument } from "./parse-document";
@@ -22,6 +22,17 @@ export interface CompileResult {
pageCount: number;
}
+export interface PageImage {
+ png: Buffer;
+ widthPx: number;
+ heightPx: number;
+}
+
+export interface CompilePagesResult {
+ pages: PageImage[];
+ pageCount: number;
+}
+
export async function compileDocument(options: CompileOptions): Promise {
const { document, formatId } = options;
const jobId = randomUUID();
@@ -93,3 +104,90 @@ export async function compileDocument(options: CompileOptions): Promise rmdir(tmpDir).catch(() => {}));
}
}
+
+/**
+ * Compile a document to per-page PNG images. Used for fixed-layout EPUB.
+ * Uses Typst's built-in PNG rasterizer — no extra deps.
+ */
+export async function compileDocumentToPages(
+ options: CompileOptions & { ppi?: number },
+): Promise {
+ const { document, formatId, ppi = 144 } = options;
+ const jobId = randomUUID();
+ const tmpDir = join("/tmp", `rpubs-png-${jobId}`);
+ await mkdir(tmpDir, { recursive: true });
+
+ const dataPath = join(tmpDir, "data.json");
+ const driverPath = join(tmpDir, "main.typ");
+ const outputPattern = join(tmpDir, "page-{n}.png");
+
+ try {
+ await writeFile(dataPath, JSON.stringify(document, null, 2));
+
+ const formatImport = join(TYPST_DIR, "formats", `${formatId}.typ`);
+ const templateImport = join(TYPST_DIR, "templates", "pocket-book.typ");
+
+ const driverContent = `
+#import "${formatImport}": page-setup
+#show: page-setup
+#include "${templateImport}"
+`;
+ await writeFile(driverPath, driverContent);
+
+ const proc = Bun.spawn(
+ [
+ "typst",
+ "compile",
+ driverPath,
+ outputPattern,
+ "--format",
+ "png",
+ "--ppi",
+ String(ppi),
+ "--root",
+ "/",
+ "--input",
+ `data-path=${dataPath}`,
+ "--font-path",
+ join(TYPST_DIR, "fonts"),
+ ],
+ { stdout: "pipe", stderr: "pipe" },
+ );
+
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ const stderr = await new Response(proc.stderr).text();
+ throw new Error(`Typst PNG compilation failed: ${stderr}`);
+ }
+
+ // Collect rendered pages in numeric order.
+ const files = (await readdir(tmpDir))
+ .filter((f) => f.startsWith("page-") && f.endsWith(".png"))
+ .sort((a, b) => {
+ const na = parseInt(a.replace(/[^0-9]/g, ""), 10);
+ const nb = parseInt(b.replace(/[^0-9]/g, ""), 10);
+ return na - nb;
+ });
+
+ const pages: PageImage[] = [];
+ for (const f of files) {
+ const png = Buffer.from(await readFile(join(tmpDir, f)));
+ const dims = readPngDimensions(png);
+ pages.push({ png, widthPx: dims.width, heightPx: dims.height });
+ }
+
+ return { pages, pageCount: pages.length };
+ } finally {
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
+/** Read width/height from a PNG IHDR chunk (bytes 16–23). */
+function readPngDimensions(png: Buffer): { width: number; height: number } {
+ if (png.length < 24 || png[0] !== 0x89 || png[1] !== 0x50) {
+ return { width: 0, height: 0 };
+ }
+ const width = png.readUInt32BE(16);
+ const height = png.readUInt32BE(20);
+ return { width, height };
+}