feat(rpubs): EPUB export + Publish-to-Space hosted URLs
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
1f084fa674
commit
858711e783
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const SVG_ARROW_RIGHT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="n
|
|||
const SVG_ARROW_LEFT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>`;
|
||||
const SVG_EXPAND = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`;
|
||||
const SVG_DOWNLOAD = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
||||
const SVG_BOOK = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`;
|
||||
const SVG_SPINNER = `<span class="btn-spinner"></span>`;
|
||||
|
||||
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 {
|
|||
<div class="preview-actions">
|
||||
<button class="action-btn" data-action="back-to-write">${SVG_ARROW_LEFT} Edit</button>
|
||||
<button class="action-btn" data-action="fullscreen-toggle">${SVG_EXPAND} Fullscreen</button>
|
||||
<a class="action-btn" href="${this._pdfUrl}" download>${SVG_DOWNLOAD} Download</a>
|
||||
<a class="action-btn" href="${this._pdfUrl}" download>${SVG_DOWNLOAD} PDF</a>
|
||||
<button class="action-btn" data-action="preview-download-epub" data-epub-mode="reflowable" ${this._previewEpubLoading === 'reflowable' ? 'disabled' : ''}>${this._previewEpubLoading === 'reflowable' ? SVG_SPINNER : SVG_BOOK} EPUB</button>
|
||||
<button class="action-btn" data-action="preview-download-epub" data-epub-mode="fixed" ${this._previewEpubLoading === 'fixed' ? 'disabled' : ''}>${this._previewEpubLoading === 'fixed' ? SVG_SPINNER : SVG_BOOK} EPUB (fixed)</button>
|
||||
${currentFormat ? `<span class="format-chip">${this.escapeHtml(currentFormat.name)} \u00B7 ${dims}</span>` : ''}
|
||||
</div>
|
||||
<div class="preview-flipbook">
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<a class="action-btn primary" href="${this._pdfUrl}" download>
|
||||
${this.renderPublishSection()}
|
||||
|
||||
<div class="divider-row"><span class="divider-line"></span><span class="divider-text">downloads</span><span class="divider-line"></span></div>
|
||||
|
||||
<a class="action-btn secondary" href="${this._pdfUrl}" download>
|
||||
${FolkPubsPublishPanel.ICONS.download}
|
||||
<span>Download PDF</span>
|
||||
</a>
|
||||
|
||||
<button class="action-btn secondary" data-action="copy-link">
|
||||
${FolkPubsPublishPanel.ICONS.copy}
|
||||
<span>Copy Flipbook Link</span>
|
||||
</button>
|
||||
|
||||
<div class="divider-row">
|
||||
<span class="divider-line"></span>
|
||||
<span class="divider-text">or send by email</span>
|
||||
<span class="divider-line"></span>
|
||||
<div class="epub-row">
|
||||
<button class="action-btn secondary epub-btn" data-action="download-epub-reflow" ${this._epubReflowLoading ? 'disabled' : ''}>
|
||||
${this._epubReflowLoading ? '<span class="spinner"></span>' : FolkPubsPublishPanel.ICONS.book}
|
||||
<span>${this._epubReflowLoading ? 'Building...' : 'EPUB · reflowable'}</span>
|
||||
</button>
|
||||
<button class="action-btn secondary epub-btn" data-action="download-epub-fixed" ${this._epubFixedLoading ? 'disabled' : ''}>
|
||||
${this._epubFixedLoading ? '<span class="spinner"></span>' : FolkPubsPublishPanel.ICONS.book}
|
||||
<span>${this._epubFixedLoading ? 'Building...' : 'EPUB · fixed layout'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Reflowable reads on any e-reader. Fixed layout preserves page design.</p>
|
||||
${this._epubError ? `<div class="msg error">${this.esc(this._epubError)}</div>` : ''}
|
||||
|
||||
<div class="divider-row"><span class="divider-line"></span><span class="divider-text">or send by email</span><span class="divider-line"></span></div>
|
||||
|
||||
<div class="email-row">
|
||||
<div class="email-input-wrap">
|
||||
|
|
@ -147,6 +162,40 @@ export class FolkPubsPublishPanel extends HTMLElement {
|
|||
`;
|
||||
}
|
||||
|
||||
private renderPublishSection(): string {
|
||||
if (this._publishResult) {
|
||||
return `
|
||||
<div class="publish-success">
|
||||
<div class="publish-success-head">
|
||||
${FolkPubsPublishPanel.ICONS.check}
|
||||
<span>Published to ${this.esc(this._spaceSlug)}</span>
|
||||
</div>
|
||||
<div class="publish-url-row">
|
||||
<input type="text" class="publish-url" readonly value="${this.esc(this._publishResult.hosted_url)}" />
|
||||
<button class="action-btn secondary publish-copy-btn" data-action="copy-published-link">${FolkPubsPublishPanel.ICONS.copy}<span>Copy</span></button>
|
||||
</div>
|
||||
<div class="publish-actions-row">
|
||||
<a class="publish-link" href="${this.esc(this._publishResult.hosted_url)}" target="_blank" rel="noopener">Open page</a>
|
||||
<button class="publish-link-btn" data-action="publish-again">Publish again</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<button class="action-btn primary publish-btn" data-action="publish-to-space" ${this._publishing ? 'disabled' : ''}>
|
||||
${this._publishing ? '<span class="spinner"></span>' : FolkPubsPublishPanel.ICONS.share}
|
||||
<span>${this._publishing ? 'Publishing…' : `Publish to ${this._spaceSlug}`}</span>
|
||||
</button>
|
||||
<label class="publish-option">
|
||||
<input type="checkbox" data-action="toggle-include-fixed" ${this._publishIncludeFixed ? 'checked' : ''} />
|
||||
<span>Also build fixed-layout EPUB (slower, preserves page design)</span>
|
||||
</label>
|
||||
<p class="hint">Saves PDF + EPUB to your space and returns a public URL anyone can visit.</p>
|
||||
${this._publishError ? `<div class="msg error">${this.esc(this._publishError)}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDiyTab(): string {
|
||||
return `
|
||||
<div class="section">
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, """)
|
||||
.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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`;
|
||||
}
|
||||
|
||||
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 ` <item ${attrs.join(" ")}/>`;
|
||||
})
|
||||
.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 ` <itemref ${attrs.join(" ")}/>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const layoutMeta = fixedLayout
|
||||
? `
|
||||
<meta property="rendition:layout">pre-paginated</meta>
|
||||
<meta property="rendition:orientation">auto</meta>
|
||||
<meta property="rendition:spread">auto</meta>`
|
||||
: "";
|
||||
|
||||
const coverMeta = coverImageId
|
||||
? `\n <meta name="cover" content="${esc(coverImageId)}"/>`
|
||||
: "";
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="${esc(language)}">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="pub-id">urn:uuid:${esc(uuid)}</dc:identifier>
|
||||
<dc:title>${esc(title)}</dc:title>
|
||||
<dc:creator>${esc(author)}</dc:creator>
|
||||
<dc:language>${esc(language)}</dc:language>
|
||||
${description ? `<dc:description>${esc(description)}</dc:description>` : ""}
|
||||
<meta property="dcterms:modified">${now}</meta>${layoutMeta}${coverMeta}
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifest}
|
||||
</manifest>
|
||||
<spine>
|
||||
${spine}
|
||||
</spine>
|
||||
</package>`;
|
||||
}
|
||||
|
||||
function buildNav(items: { href: string; title: string }[], language: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
|
||||
<head><meta charset="utf-8"/><title>Contents</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc" id="toc"><h1>Contents</h1>
|
||||
<ol>
|
||||
${items.map((i) => ` <li><a href="${esc(i.href)}">${esc(i.title)}</a></li>`).join("\n")}
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildNcx(opts: { uuid: string; title: string; items: { href: string; title: string }[] }): string {
|
||||
const { uuid, title, items } = opts;
|
||||
return `<?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="urn:uuid:${esc(uuid)}"/>
|
||||
<meta name="dtb:depth" content="1"/>
|
||||
<meta name="dtb:totalPageCount" content="0"/>
|
||||
<meta name="dtb:maxPageNumber" content="0"/>
|
||||
</head>
|
||||
<docTitle><text>${esc(title)}</text></docTitle>
|
||||
<navMap>
|
||||
${items
|
||||
.map(
|
||||
(it, i) => ` <navPoint id="np-${i + 1}" playOrder="${i + 1}">
|
||||
<navLabel><text>${esc(it.title)}</text></navLabel>
|
||||
<content src="${esc(it.href)}"/>
|
||||
</navPoint>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</navMap>
|
||||
</ncx>`;
|
||||
}
|
||||
|
||||
async function finalizeZip(zip: JSZip): Promise<Buffer> {
|
||||
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 `<p>${marked.parseInline(block.text) as string}</p>`;
|
||||
case "quote": {
|
||||
const inner = marked.parseInline(block.text) as string;
|
||||
const attrib = block.attribution
|
||||
? `<footer>— ${esc(block.attribution)}</footer>`
|
||||
: "";
|
||||
return `<blockquote><p>${inner}</p>${attrib}</blockquote>`;
|
||||
}
|
||||
case "list": {
|
||||
const tag = block.ordered ? "ol" : "ul";
|
||||
const items = block.items
|
||||
.map((i) => `<li>${marked.parseInline(i) as string}</li>`)
|
||||
.join("");
|
||||
return `<${tag}>${items}</${tag}>`;
|
||||
}
|
||||
case "code": {
|
||||
const lang = block.language ? ` class="language-${esc(block.language)}"` : "";
|
||||
return `<pre><code${lang}>${esc(block.code)}</code></pre>`;
|
||||
}
|
||||
case "separator":
|
||||
return `<hr/>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSectionXhtml(section: DocumentSection, language: string): string {
|
||||
const body = [
|
||||
section.heading
|
||||
? `<h${Math.min(section.level || 2, 6)}>${esc(section.heading)}</h${Math.min(section.level || 2, 6)}>`
|
||||
: "",
|
||||
...section.blocks.map(renderBlockToHtml),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${esc(section.heading || "Section")}</title>
|
||||
<link rel="stylesheet" type="text/css" href="styles/book.css"/>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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<Buffer> {
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="${esc(language)}">
|
||||
<head><meta charset="utf-8"/><title>${esc(document.title)}</title><link rel="stylesheet" type="text/css" href="styles/book.css"/></head>
|
||||
<body><div class="title-page"><h1>${esc(document.title)}</h1>${document.author ? `<div class="author">${esc(document.author)}</div>` : ""}</div></body>
|
||||
</html>`;
|
||||
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Page ${pageNum}</title>
|
||||
<meta name="viewport" content="width=${width}, height=${height}"/>
|
||||
<link rel="stylesheet" type="text/css" href="styles/page.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page"><img src="${esc(imagePath)}" alt="Page ${pageNum}"/></div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function buildFixedLayoutEpub(opts: {
|
||||
document: ParsedDocument;
|
||||
pages: PageImage[];
|
||||
formatId: string;
|
||||
language?: string;
|
||||
description?: string;
|
||||
}): Promise<Buffer> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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, "<").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 `
|
||||
<style>
|
||||
.reader-wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 80px; color: var(--rs-text, #e5e7eb); }
|
||||
.reader-hero { margin-bottom: 20px; }
|
||||
.reader-title { font-size: 1.6rem; font-weight: 700; margin: 0 0 4px; }
|
||||
.reader-author { color: var(--rs-text-muted, #94a3b8); font-size: 0.95rem; }
|
||||
.reader-meta { color: var(--rs-text-muted, #94a3b8); font-size: 0.82rem; margin-top: 6px; }
|
||||
.reader-description { margin: 12px 0 0; line-height: 1.6; color: var(--rs-text, #cbd5e1); }
|
||||
.reader-flipbook { margin: 20px 0 28px; min-height: 420px; }
|
||||
.reader-downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.dl-card { display: flex; flex-direction: column; gap: 4px; padding: 14px; background: var(--rs-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 8px; text-decoration: none; color: inherit; transition: all 0.15s; }
|
||||
.dl-card:hover { border-color: var(--rs-accent, #14b8a6); transform: translateY(-1px); }
|
||||
.dl-head { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.dl-title { font-weight: 600; font-size: 0.98rem; }
|
||||
.dl-size { color: var(--rs-text-muted, #94a3b8); font-size: 0.78rem; }
|
||||
.dl-sub { color: var(--rs-text-muted, #94a3b8); font-size: 0.82rem; }
|
||||
.reader-share { margin-top: 20px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.reader-share input { flex: 1; min-width: 200px; padding: 8px 10px; background: var(--rs-surface-alt, #0f172a); color: inherit; border: 1px solid var(--rs-border, #334155); border-radius: 6px; font-family: inherit; font-size: 0.85rem; }
|
||||
.reader-share button { padding: 8px 14px; background: var(--rs-accent, #14b8a6); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; }
|
||||
.reader-share button:hover { background: var(--rs-accent-hover, #0d9488); }
|
||||
</style>
|
||||
|
||||
<div class="reader-wrap">
|
||||
<header class="reader-hero">
|
||||
<h1 class="reader-title">${escapeAttr(record.title)}</h1>
|
||||
${record.author ? `<div class="reader-author">by ${escapeAttr(record.author)}</div>` : ""}
|
||||
<div class="reader-meta">${escapeAttr(record.formatName)} · ${record.pageCount} pages · ${new Date(record.createdAt).toLocaleDateString()}</div>
|
||||
${record.description ? `<p class="reader-description">${escapeAttr(record.description)}</p>` : ""}
|
||||
</header>
|
||||
|
||||
<div class="reader-flipbook">
|
||||
<folk-pubs-flipbook pdf-url="${pdfUrl}"></folk-pubs-flipbook>
|
||||
</div>
|
||||
|
||||
<div class="reader-downloads">
|
||||
<a class="dl-card" href="${pdfUrl}" download>
|
||||
<div class="dl-head"><span class="dl-title">Download PDF</span><span class="dl-size">${formatBytes(record.pdfBytes)}</span></div>
|
||||
<span class="dl-sub">Print-ready, preserves design</span>
|
||||
</a>
|
||||
<a class="dl-card" href="${epubUrl}" download>
|
||||
<div class="dl-head"><span class="dl-title">EPUB · reflowable</span><span class="dl-size">${formatBytes(record.reflowableEpubBytes)}</span></div>
|
||||
<span class="dl-sub">Resizable text, works on any e-reader</span>
|
||||
</a>
|
||||
${epubFixedUrl ? `
|
||||
<a class="dl-card" href="${epubFixedUrl}" download>
|
||||
<div class="dl-head"><span class="dl-title">EPUB · fixed layout</span><span class="dl-size">${record.fixedEpubBytes ? formatBytes(record.fixedEpubBytes) : ""}</span></div>
|
||||
<span class="dl-sub">Preserves page design, for Kindle/iPad</span>
|
||||
</a>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="reader-share">
|
||||
<input type="text" id="share-url" readonly value="" />
|
||||
<button onclick="(() => { const el = document.getElementById('share-url'); el.select(); navigator.clipboard.writeText(el.value); const btn = event.target; const orig = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = orig, 1500); })()">Copy Link</button>
|
||||
</div>
|
||||
<script>document.getElementById('share-url').value = window.location.href;</script>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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(`<!doctype html><meta charset="utf-8"><title>Not found</title><body style="font:14px system-ui;padding:40px;color:#ddd;background:#111"><h1>Publication not found</h1><p>No publication exists at this URL.</p><p><a style="color:#14b8a6" href="/${escapeAttr(spaceSlug)}/rpubs">Back to rPubs</a></p></body>`, 404);
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${record.title} — ${spaceSlug} | rSpace`,
|
||||
moduleId: "rpubs",
|
||||
spaceSlug,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderReaderPage({ record, spaceSlug }),
|
||||
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── 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 {
|
||||
|
|
|
|||
|
|
@ -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<PublicationRecord[]> {
|
||||
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<PublicationRecord | null> {
|
||||
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<Buffer | null> {
|
||||
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<string> {
|
||||
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<PublicationRecord> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<CompileResult> {
|
||||
const { document, formatId } = options;
|
||||
const jobId = randomUUID();
|
||||
|
|
@ -93,3 +104,90 @@ export async function compileDocument(options: CompileOptions): Promise<CompileR
|
|||
import("node:fs/promises").then(({ rmdir }) => 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<CompilePagesResult> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue