rspace-online/modules/pubs/mod.ts

360 lines
11 KiB
TypeScript

/**
* 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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: `
<folk-pubs-editor id="editor"></folk-pubs-editor>
`,
modules: getModuleInfoList(),
theme: "light",
head: `<link rel="stylesheet" href="/modules/pubs/pubs.css">`,
scripts: `
<script type="module">
import { FolkPubsEditor } from '/modules/pubs/folk-pubs-editor.js';
const editor = document.getElementById('editor');
editor.formats = ${formatsJSON};
editor.spaceSlug = '${escapeAttr(spaceSlug)}';
</script>
`,
});
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",
};