From 858711e78300340b39bf64b52c70a5bebf844547 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 15:23:49 -0400 Subject: [PATCH 1/2] feat(rpubs): EPUB export + Publish-to-Space hosted URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - epub-gen.ts: reflowable (markdown → styled xhtml) and fixed-layout (Typst per-page PNGs wrapped as pre-paginated EPUB3). No new deps. - typst-compile.ts: compileDocumentToPages() rasterizes Typst directly to PNG via the CLI (no poppler/mupdf needed). - Persistent publications store at /data/rpubs-publications/{space}/{slug} with public reader page, PDF + EPUB downloads at /{space}/rpubs/publications/{slug}. - Preview/Press step now has quick EPUB download buttons next to PDF. - Publish panel: "Publish to {space}" is now the primary action, showing hosted URL + copy-link after publishing. EPUB variants remain in downloads. - Dockerfile: new PUBS_DIR volume for persistence. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 4 +- modules/rpubs/components/folk-pubs-editor.ts | 50 +- .../components/folk-pubs-publish-panel.ts | 225 ++++++++- modules/rpubs/epub-gen.ts | 447 ++++++++++++++++++ modules/rpubs/mod.ts | 290 +++++++++++- modules/rpubs/publications-store.ts | 180 +++++++ modules/rpubs/typst-compile.ts | 100 +++- 7 files changed, 1282 insertions(+), 14 deletions(-) create mode 100644 modules/rpubs/epub-gen.ts create mode 100644 modules/rpubs/publications-store.ts diff --git a/Dockerfile b/Dockerfile index d464b756..6db601b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,7 @@ COPY --from=build /encryptid-sdk /encryptid-sdk RUN bun install --production # Create data directories -RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats +RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats /data/rpubs-publications # Copy entrypoint for Infisical secret injection COPY entrypoint.sh /app/entrypoint.sh @@ -67,6 +67,7 @@ ENV BOOKS_DIR=/data/books ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts ENV FILES_DIR=/data/files ENV SPLATS_DIR=/data/splats +ENV PUBS_DIR=/data/rpubs-publications ENV PORT=3000 # Data volumes for persistence @@ -75,6 +76,7 @@ VOLUME /data/books VOLUME /data/swag-artifacts VOLUME /data/files VOLUME /data/splats +VOLUME /data/rpubs-publications EXPOSE 3000 diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 360a9784..3c58bec6 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -72,6 +72,7 @@ const SVG_ARROW_RIGHT = ``; const SVG_EXPAND = ``; const SVG_DOWNLOAD = ``; +const SVG_BOOK = ``; const SVG_SPINNER = ``; export class FolkPubsEditor extends HTMLElement { @@ -83,6 +84,7 @@ export class FolkPubsEditor extends HTMLElement { private _pdfUrl: string | null = null; private _pdfInfo: string | null = null; private _pdfPageCount = 0; + private _previewEpubLoading: "reflowable" | "fixed" | null = null; // ── Cached content (for publish panel access when textarea isn't in DOM) ── private _cachedContent = ""; @@ -534,7 +536,9 @@ export class FolkPubsEditor extends HTMLElement {
- ${SVG_DOWNLOAD} Download + ${SVG_DOWNLOAD} PDF + + ${currentFormat ? `${this.escapeHtml(currentFormat.name)} \u00B7 ${dims}` : ''}
@@ -604,6 +608,14 @@ export class FolkPubsEditor extends HTMLElement { this.render(); }); + // EPUB quick downloads from preview step + this.shadowRoot.querySelectorAll('[data-action="preview-download-epub"]').forEach((btn) => { + btn.addEventListener("click", () => { + const mode = (btn as HTMLElement).dataset.epubMode as "reflowable" | "fixed"; + this.previewDownloadEpub(mode); + }); + }); + // Fullscreen toggle this.shadowRoot.querySelector('[data-action="fullscreen-toggle"]')?.addEventListener("click", () => { const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook"); @@ -803,6 +815,42 @@ export class FolkPubsEditor extends HTMLElement { }); } + private async previewDownloadEpub(mode: "reflowable" | "fixed") { + if (!this._cachedContent) return; + this._previewEpubLoading = mode; + this.render(); + try { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/generate-epub`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: this._cachedContent, + title: this._cachedTitle || undefined, + author: this._cachedAuthor || undefined, + format: this._selectedFormat, + mode, + }), + }); + 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 titleSlug = (this._cachedTitle || "publication").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "publication"; + a.download = mode === "fixed" ? `${titleSlug}-${this._selectedFormat}-fixed.epub` : `${titleSlug}.epub`; + a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { + this._error = e.message; + } finally { + this._previewEpubLoading = null; + this.render(); + } + } + private updateWordCount() { if (!this.shadowRoot) return; const el = this.shadowRoot.querySelector('.word-count'); diff --git a/modules/rpubs/components/folk-pubs-publish-panel.ts b/modules/rpubs/components/folk-pubs-publish-panel.ts index 8ce035d1..b184a1e5 100644 --- a/modules/rpubs/components/folk-pubs-publish-panel.ts +++ b/modules/rpubs/components/folk-pubs-publish-panel.ts @@ -29,6 +29,13 @@ export class FolkPubsPublishPanel extends HTMLElement { private _emailSent = false; private _emailError: string | null = null; private _impositionLoading = false; + private _epubReflowLoading = false; + private _epubFixedLoading = false; + private _epubError: string | null = null; + private _publishing = false; + private _publishResult: { hosted_url: string; pdf_url: string; epub_url: string; epub_fixed_url: string | null } | null = null; + private _publishError: string | null = null; + private _publishIncludeFixed = false; private _orderStatus: string | null = null; private _batchStatus: any = null; @@ -113,21 +120,29 @@ export class FolkPubsPublishPanel extends HTMLElement {
- + ${this.renderPublishSection()} + +
downloads
+ +
${FolkPubsPublishPanel.ICONS.download} Download PDF - - -
- - or send by email - +
+ +
+

Reflowable reads on any e-reader. Fixed layout preserves page design.

+ ${this._epubError ? `
${this.esc(this._epubError)}
` : ''} + +
or send by email