Compare commits

...

3 Commits

Author SHA1 Message Date
Jeff Emmett d53b8ee3bf Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m42s Details
2026-04-16 15:30:20 -04:00
Jeff Emmett 71782b1cf1 fix(mi-voice): await AudioContext.resume before source.start
Suspended contexts silently queued audio that never played. Await resume,
split connect() chain to avoid undefined-return on older browsers, and
re-check state after source setup in case first resume lost the gesture.
2026-04-16 15:30:16 -04:00
Jeff Emmett 858711e783 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>
2026-04-16 15:23:49 -04:00
8 changed files with 1290 additions and 18 deletions

View File

@ -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

View File

@ -88,12 +88,12 @@ export class MiVoiceBridge {
// ── Bridge TTS ──
#ensureAudioCtx(): AudioContext {
async #ensureAudioCtx(): Promise<AudioContext> {
if (!this.#audioCtx || this.#audioCtx.state === "closed") {
this.#audioCtx = new AudioContext();
}
if (this.#audioCtx.state === "suspended") {
this.#audioCtx.resume();
try { await this.#audioCtx.resume(); } catch { /* gesture may be required */ }
}
return this.#audioCtx;
}
@ -158,14 +158,18 @@ export class MiVoiceBridge {
} catch { /* ignore bad header */ }
const mp3Bytes = buf.slice(4 + headerLen);
const ctx = this.#ensureAudioCtx();
const ctx = await this.#ensureAudioCtx();
const audioBuffer = await ctx.decodeAudioData(mp3Bytes.slice(0)); // slice to copy
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
const gain = ctx.createGain();
gain.gain.value = gainValue;
source.connect(gain).connect(ctx.destination);
source.connect(gain);
gain.connect(ctx.destination);
this.#currentSource = source;
if (ctx.state === "suspended") {
try { await ctx.resume(); } catch { /* ignore */ }
}
source.onended = () => {
this.#currentSource = null;

View File

@ -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');

View File

@ -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>
<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 class="divider-row">
<span class="divider-line"></span>
<span class="divider-text">or send by email</span>
<span class="divider-line"></span>
</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 {

447
modules/rpubs/epub-gen.ts Normal file
View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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);
}

View File

@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 {

View File

@ -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;
}

View File

@ -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 1623). */
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 };
}