360 lines
11 KiB
TypeScript
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, "&").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: `
|
|
<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",
|
|
};
|