/** * Pubs module — markdown → print-ready pocket books via Typst. * * Ported from pocket-press (Next.js) to Hono routes. * Stateless — no database needed. Artifacts stored in /tmp. */ import { Hono } from "hono"; import { resolve, join } from "node:path"; import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { parseMarkdown } from "./parse-document"; import { compileDocument } from "./typst-compile"; import { getFormat, FORMATS, listFormats } from "./formats"; import type { BookFormat } from "./formats"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts"; // ── Types ── interface ArtifactRequest { content: string; title?: string; author?: string; format: string; creator_id?: string; creator_name?: string; creator_wallet?: string; source_space?: string; license?: string; tags?: string[]; description?: string; pricing?: { creator_share_pct?: number; community_share_pct?: number; creator_min?: { amount: number; currency?: string }; suggested_retail?: { amount: number; currency?: string }; }; } // ── Helpers ── function buildArtifactEnvelope(opts: { id: string; format: BookFormat; pageCount: number; pdfSizeBytes: number; parsedTitle: string; req: ArtifactRequest; baseUrl: string; }) { const { id, format, pageCount, pdfSizeBytes, parsedTitle, req, baseUrl } = opts; const isBook = pageCount > 48; const productType = isBook ? "book" : "zine"; const binding = isBook ? "perfect-bind" : "saddle-stitch"; const substrates = isBook ? ["paper-100gsm", "paper-100gsm-recycled", "cover-250gsm"] : ["paper-100gsm-recycled", "paper-100gsm", "paper-80gsm"]; const requiredCapabilities = isBook ? ["laser-print", "perfect-bind"] : ["laser-print", "saddle-stitch"]; const pdfUrl = `${baseUrl}/api/artifact/${id}/pdf`; return { id, schema_version: "1.0" as const, type: "print-ready" as const, origin: "rpubs.online", source_space: req.source_space || null, creator: { id: req.creator_id || "anonymous", ...(req.creator_name ? { name: req.creator_name } : {}), ...(req.creator_wallet ? { wallet: req.creator_wallet } : {}), }, created_at: new Date().toISOString(), ...(req.license ? { license: req.license } : {}), payload: { title: parsedTitle, ...(req.description ? { description: req.description } : { description: `A ${pageCount}-page ${productType} in ${format.name} format, generated by rPubs.` }), ...(req.tags && req.tags.length > 0 ? { tags: req.tags } : {}), }, spec: { product_type: productType, dimensions: { width_mm: format.widthMm, height_mm: format.heightMm, bleed_mm: 3, }, pages: pageCount, color_space: "grayscale", dpi: 300, binding, finish: "uncoated", substrates, required_capabilities: requiredCapabilities, }, render_targets: { [format.id]: { url: pdfUrl, format: "pdf", dpi: 300, file_size_bytes: pdfSizeBytes, notes: `${format.name} format, grayscale, ${binding}. ${pageCount} pages.`, }, }, pricing: { creator_share_pct: req.pricing?.creator_share_pct ?? 30, community_share_pct: req.pricing?.community_share_pct ?? 10, ...(req.pricing?.creator_min ? { creator_min: { amount: req.pricing.creator_min.amount, currency: req.pricing.creator_min.currency || "USD" } } : {}), ...(req.pricing?.suggested_retail ? { suggested_retail: { amount: req.pricing.suggested_retail.amount, currency: req.pricing.suggested_retail.currency || "USD" } } : {}), }, next_actions: [ { tool: "rcart.online", action: "list-for-sale", label: "Sell in community shop", endpoint: "/api/catalog/ingest", method: "POST" }, { tool: "rpubs.online", action: "reformat", label: "Generate another format", endpoint: "/api/reformat", method: "POST" }, { tool: "rfiles.online", action: "archive", label: "Save to files", endpoint: "/api/v1/files/import", method: "POST" }, ], }; } function escapeAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } // ── Routes ── const routes = new Hono(); // ── API: List available formats ── routes.get("/api/formats", (c) => { return c.json({ formats: listFormats() }); }); // ── API: Generate PDF (direct download) ── routes.post("/api/generate", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!formatId || !getFormat(formatId)) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}.pdf`; return new Response(new Uint8Array(result.pdf), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${filename}"`, "X-Page-Count": String(result.pageCount), }, }); } catch (error) { console.error("[Pubs] Generation error:", error); return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500); } }); // ── API: Create persistent artifact ── routes.post("/api/artifact", async (c) => { try { const body: ArtifactRequest = await c.req.json(); const { content, title, author, format: formatId } = 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); } if (body.tags && !Array.isArray(body.tags)) { return c.json({ error: "tags must be an array of strings" }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); // Store artifact const artifactId = randomUUID(); const artifactDir = join(ARTIFACTS_DIR, artifactId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); await writeFile(join(artifactDir, "source.md"), content); // Build envelope const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("host") || "rpubs.online"; const baseUrl = `${proto}://${host}`; const artifact = buildArtifactEnvelope({ id: artifactId, format, pageCount: result.pageCount, pdfSizeBytes: result.pdf.length, parsedTitle: document.title, req: body, baseUrl, }); await writeFile(join(artifactDir, "artifact.json"), JSON.stringify(artifact, null, 2)); return c.json(artifact, 201); } catch (error) { console.error("[Pubs] Artifact error:", error); return c.json({ error: error instanceof Error ? error.message : "Artifact generation failed" }, 500); } }); // ── API: Get artifact (metadata or files) ── routes.get("/api/artifact/:id", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const fileType = c.req.query("file"); if (fileType === "source") { try { const source = await readFile(join(artifactDir, "source.md")); return new Response(source, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": `inline; filename="source.md"`, }, }); } catch { return c.json({ error: "Source file not found" }, 404); } } if (fileType === "pdf") { const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); } // Default: return artifact JSON try { const artifactJson = await readFile(join(artifactDir, "artifact.json"), "utf-8"); return new Response(artifactJson, { headers: { "Content-Type": "application/json" }, }); } catch { return c.json({ error: "Artifact metadata not found" }, 404); } }); // ── API: Direct PDF access ── routes.get("/api/artifact/:id/pdf", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); }); // ── Page: Editor ── routes.get("/", async (c) => { const spaceSlug = c.req.param("space") || "personal"; const formatsJSON = JSON.stringify(listFormats()); const html = renderShell({ title: `${spaceSlug} — rPubs Editor | rSpace`, moduleId: "pubs", spaceSlug, body: ` `, modules: getModuleInfoList(), theme: "light", head: ``, scripts: ` `, }); return c.html(html); }); // ── Module export ── export const pubsModule: RSpaceModule = { id: "pubs", name: "rPubs", icon: "📖", description: "Drop in a document, get a pocket book", routes, standaloneDomain: "rpubs.online", };