feat: add pubs module — Phase 3 port of rPubs to rSpace platform

Port pocket-press (Next.js + Typst) as an rSpace module with Hono routes,
vanilla folk-pubs-editor web component, and Typst v0.13.1 for PDF generation.
Includes all 4 format templates (A7, A6, Quarter Letter, Digest), artifact
envelope creation with cosmolocal spec, and standalone deployment support.

Typst binary installed in Docker via multi-stage build from debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 22:41:10 +00:00
parent 88de4c30dd
commit ca5c1363ce
16 changed files with 1606 additions and 0 deletions

View File

@ -16,10 +16,23 @@ COPY . .
# Build frontend (skip tsc in Docker — type checking is done in CI/local dev) # Build frontend (skip tsc in Docker — type checking is done in CI/local dev)
RUN bunx vite build RUN bunx vite build
# Typst binary stage — download once, reuse in production
FROM debian:bookworm-slim AS typst
RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \
&& curl -fsSL https://github.com/typst/typst/releases/download/v0.13.1/typst-x86_64-unknown-linux-musl.tar.xz \
-o /tmp/typst.tar.xz \
&& tar xf /tmp/typst.tar.xz -C /tmp \
&& mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst \
&& rm -rf /tmp/typst* \
&& chmod +x /usr/local/bin/typst
# Production stage # Production stage
FROM oven/bun:1-slim AS production FROM oven/bun:1-slim AS production
WORKDIR /app WORKDIR /app
# Install Typst binary (for rPubs PDF generation)
COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
# Copy built assets and server # Copy built assets and server
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server COPY --from=build /app/server ./server

View File

@ -0,0 +1,485 @@
/**
* <folk-pubs-editor> Markdown editor with format selector and PDF generation.
*
* Drop in markdown text, pick a pocket-book format, generate a print-ready PDF.
* Supports file drag-and-drop, sample content, and PDF preview/download.
*/
interface BookFormat {
id: string;
name: string;
widthMm: number;
heightMm: number;
description: string;
minPages: number;
maxPages: number;
}
const SAMPLE_CONTENT = `# The Commons
## What Are Commons?
Commons are shared resources managed by communities. They can be natural resources like forests, fisheries, and water systems, or digital resources like open-source software, Wikipedia, and creative works.
The concept of the commons has deep historical roots. In medieval England, common land was shared among villagers for grazing animals, gathering firewood, and growing food. These weren't unmanaged free-for-alls they operated under sophisticated rules developed over generations.
## Elinor Ostrom's Design Principles
Elinor Ostrom, who won the Nobel Prize in Economics in 2009, identified eight design principles for successful commons governance:
1. Clearly defined boundaries
2. Rules adapted to local conditions
3. Collective-choice arrangements
4. Monitoring by community members
5. Graduated sanctions for rule violators
6. Accessible conflict-resolution mechanisms
7. Recognition of the right to organize
8. Nested enterprises for larger-scale resources
> The tragedy of the commons is not inevitable. Communities around the world have managed shared resources sustainably for centuries when given the tools and authority to do so.
## The Digital Commons
The internet has created entirely new forms of commons. Open-source software, Creative Commons licensing, and collaborative platforms demonstrate that the commons model scales beyond physical resources.
---
These ideas matter because they challenge the assumption that only private ownership or government control can manage resources effectively. The commons represent a third way community governance of shared wealth.`;
export class FolkPubsEditor extends HTMLElement {
private _formats: BookFormat[] = [];
private _spaceSlug = "personal";
private _selectedFormat = "digest";
private _loading = false;
private _error: string | null = null;
private _pdfUrl: string | null = null;
private _pdfInfo: string | null = null;
set formats(val: BookFormat[]) {
this._formats = val;
if (this.shadowRoot) this.render();
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
connectedCallback() {
this.attachShadow({ mode: "open" });
this.render();
}
disconnectedCallback() {
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
}
private render() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="editor-layout">
<div class="editor-main">
<div class="editor-toolbar">
<div class="toolbar-left">
<input type="text" class="title-input" placeholder="Title (optional)" />
<input type="text" class="author-input" placeholder="Author (optional)" />
</div>
<div class="toolbar-right">
<button class="btn-sample" title="Load sample content">Sample</button>
<label class="btn-upload" title="Open a text file">
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File
</label>
</div>
</div>
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
</div>
<div class="sidebar">
<h3>Format</h3>
<div class="format-grid">
${this._formats.map((f) => `
<button class="format-btn ${f.id === this._selectedFormat ? "active" : ""}" data-format="${f.id}">
<span class="format-name">${f.name}</span>
<span class="format-desc">${f.widthMm}×${f.heightMm}mm</span>
</button>
`).join("")}
</div>
<button class="btn-generate" ${this._loading ? "disabled" : ""}>
${this._loading ? "Generating..." : "Generate PDF"}
</button>
${this._error ? `<div class="error">${this.escapeHtml(this._error)}</div>` : ""}
${this._pdfUrl ? `
<div class="result">
<div class="result-info">${this._pdfInfo || ""}</div>
<iframe class="pdf-preview" src="${this._pdfUrl}"></iframe>
<a class="btn-download" href="${this._pdfUrl}" download>Download PDF</a>
</div>
` : `
<div class="placeholder">
<p>Choose a format and click Generate to create your pocket book.</p>
<div class="format-details">
${this._formats.map((f) => `
<div class="format-detail" data-for="${f.id}" ${f.id !== this._selectedFormat ? "hidden" : ""}>
<strong>${f.name}</strong>
<span>${f.description}</span>
<span class="pages">${f.minPages}${f.maxPages} pages</span>
</div>
`).join("")}
</div>
</div>
`}
</div>
</div>
`;
this.bindEvents();
}
private bindEvents() {
if (!this.shadowRoot) return;
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement;
const sampleBtn = this.shadowRoot.querySelector(".btn-sample");
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
// Format buttons
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
btn.addEventListener("click", () => {
this._selectedFormat = (btn as HTMLElement).dataset.format!;
// Clear previous PDF on format change
if (this._pdfUrl) {
URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this._pdfInfo = null;
}
this._error = null;
this.render();
});
});
// Sample content
sampleBtn?.addEventListener("click", () => {
textarea.value = SAMPLE_CONTENT;
titleInput.value = "";
authorInput.value = "";
});
// File upload
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
textarea.value = reader.result as string;
};
reader.readAsText(file);
}
});
// Drag-and-drop
textarea?.addEventListener("dragover", (e) => {
e.preventDefault();
textarea.classList.add("dragover");
});
textarea?.addEventListener("dragleave", () => {
textarea.classList.remove("dragover");
});
textarea?.addEventListener("drop", (e) => {
e.preventDefault();
textarea.classList.remove("dragover");
const file = (e as DragEvent).dataTransfer?.files[0];
if (file && (file.type.startsWith("text/") || file.name.match(/\.(md|txt|markdown)$/))) {
const reader = new FileReader();
reader.onload = () => {
textarea.value = reader.result as string;
};
reader.readAsText(file);
}
});
// Generate PDF
generateBtn?.addEventListener("click", async () => {
const content = textarea.value.trim();
if (!content) {
this._error = "Please enter some content first.";
this.render();
return;
}
this._loading = true;
this._error = null;
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
this._pdfUrl = null;
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/pubs/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
title: titleInput.value.trim() || undefined,
author: authorInput.value.trim() || undefined,
format: this._selectedFormat,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Generation failed");
}
const blob = await res.blob();
const pageCount = res.headers.get("X-Page-Count") || "?";
const format = this._formats.find((f) => f.id === this._selectedFormat);
this._pdfUrl = URL.createObjectURL(blob);
this._pdfInfo = `${pageCount} pages · ${format?.name || this._selectedFormat}`;
this._loading = false;
this.render();
} catch (e: any) {
this._loading = false;
this._error = e.message;
this.render();
}
});
}
private getStyles(): string {
return `<style>
:host {
display: block;
height: calc(100vh - 52px);
background: #0f172a;
color: #f1f5f9;
}
.editor-layout {
display: flex;
height: 100%;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #1e293b;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
gap: 0.5rem;
flex: 1;
}
.toolbar-right {
display: flex;
gap: 0.5rem;
}
.title-input, .author-input {
padding: 0.375rem 0.625rem;
border: 1px solid #334155;
border-radius: 0.375rem;
background: #1e293b;
color: #f1f5f9;
font-size: 0.8rem;
min-width: 120px;
}
.title-input { flex: 1; max-width: 240px; }
.author-input { flex: 0.7; max-width: 180px; }
.title-input::placeholder, .author-input::placeholder { color: #64748b; }
.title-input:focus, .author-input:focus { outline: none; border-color: #60a5fa; }
.btn-sample, .btn-upload {
padding: 0.375rem 0.75rem;
border: 1px solid #334155;
border-radius: 0.375rem;
background: #1e293b;
color: #94a3b8;
font-size: 0.8rem;
cursor: pointer;
}
.btn-sample:hover, .btn-upload:hover { border-color: #60a5fa; color: #f1f5f9; }
.content-area {
flex: 1;
padding: 1rem;
border: none;
background: #0f172a;
color: #e2e8f0;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85rem;
line-height: 1.6;
resize: none;
}
.content-area::placeholder { color: #475569; }
.content-area:focus { outline: none; }
.content-area.dragover {
background: #1e293b;
outline: 2px dashed #60a5fa;
outline-offset: -4px;
}
.sidebar {
width: 280px;
border-left: 1px solid #1e293b;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sidebar h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.format-btn {
padding: 0.5rem;
border: 1px solid #334155;
border-radius: 0.5rem;
background: #1e293b;
color: #f1f5f9;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.125rem;
transition: all 0.15s;
}
.format-btn:hover { border-color: #60a5fa; }
.format-btn.active {
border-color: #60a5fa;
background: #1e3a5f;
}
.format-name {
font-size: 0.8rem;
font-weight: 600;
}
.format-desc {
font-size: 0.65rem;
color: #64748b;
}
.btn-generate {
padding: 0.625rem 1rem;
border: none;
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-generate:hover { background: #1d4ed8; }
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
color: #f87171;
font-size: 0.8rem;
padding: 0.5rem;
background: rgba(248, 113, 113, 0.1);
border-radius: 0.375rem;
}
.result {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-info {
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
}
.pdf-preview {
width: 100%;
height: 300px;
border: 1px solid #334155;
border-radius: 0.375rem;
background: #fff;
}
.btn-download {
display: block;
text-align: center;
padding: 0.5rem;
border: 1px solid #22c55e;
border-radius: 0.375rem;
color: #22c55e;
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
}
.btn-download:hover { background: rgba(34, 197, 94, 0.1); }
.placeholder {
color: #64748b;
font-size: 0.8rem;
line-height: 1.5;
}
.placeholder p { margin: 0 0 0.75rem; }
.format-details { margin-top: 0.5rem; }
.format-detail {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.75rem;
}
.format-detail strong { color: #e2e8f0; }
.format-detail .pages { color: #60a5fa; }
@media (max-width: 768px) {
.editor-layout { flex-direction: column; }
.sidebar {
width: 100%;
border-left: none;
border-top: 1px solid #1e293b;
max-height: 50vh;
}
.content-area { min-height: 40vh; }
}
</style>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
}
customElements.define("folk-pubs-editor", FolkPubsEditor);

View File

@ -0,0 +1,11 @@
/* Pubs module — editor theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}
body[data-theme="light"] .rstack-header {
background: #0f172a;
border-bottom-color: #1e293b;
}

61
modules/pubs/formats.ts Normal file
View File

@ -0,0 +1,61 @@
export interface BookFormat {
id: string;
name: string;
widthMm: number;
heightMm: number;
description: string;
typstFormat: string; // filename in typst/formats/ without extension
minPages: number;
maxPages: number;
}
export const FORMATS: Record<string, BookFormat> = {
a7: {
id: "a7",
name: "A7 Pocket",
widthMm: 74,
heightMm: 105,
description: "Smallest pocket format — fits in a shirt pocket",
typstFormat: "a7",
minPages: 16,
maxPages: 48,
},
a6: {
id: "a6",
name: "A6 Booklet",
widthMm: 105,
heightMm: 148,
description: "Standard postcard-sized pocket book",
typstFormat: "a6",
minPages: 16,
maxPages: 64,
},
"quarter-letter": {
id: "quarter-letter",
name: "Quarter Letter",
widthMm: 108,
heightMm: 140,
description: "Quarter US Letter — easy to print at home",
typstFormat: "quarter-letter",
minPages: 16,
maxPages: 64,
},
digest: {
id: "digest",
name: 'Digest (5.5" × 8.5")',
widthMm: 140,
heightMm: 216,
description: "Half US Letter — most POD-friendly format",
typstFormat: "digest",
minPages: 16,
maxPages: 96,
},
};
export function getFormat(id: string): BookFormat | undefined {
return FORMATS[id];
}
export function listFormats(): BookFormat[] {
return Object.values(FORMATS);
}

359
modules/pubs/mod.ts Normal file
View File

@ -0,0 +1,359 @@
/**
* 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",
};

View File

@ -0,0 +1,198 @@
export interface DocumentSection {
heading?: string;
level?: number;
blocks: DocumentBlock[];
}
export type DocumentBlock =
| { type: "paragraph"; text: string }
| { type: "quote"; text: string; attribution?: string }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "code"; language?: string; code: string }
| { type: "separator" };
export interface ParsedDocument {
title: string;
subtitle?: string;
author?: string;
sections: DocumentSection[];
}
export function parseMarkdown(
content: string,
title?: string,
author?: string,
): ParsedDocument {
const lines = content.split("\n");
const sections: DocumentSection[] = [];
let currentSection: DocumentSection = { blocks: [] };
let detectedTitle = title;
let inCodeBlock = false;
let codeBlockLang = "";
let codeLines: string[] = [];
let inBlockquote = false;
let quoteLines: string[] = [];
let inList = false;
let listItems: string[] = [];
let listOrdered = false;
function flushQuote() {
if (quoteLines.length > 0) {
const text = quoteLines.join("\n").trim();
currentSection.blocks.push({ type: "quote", text });
quoteLines = [];
inBlockquote = false;
}
}
function flushList() {
if (listItems.length > 0) {
currentSection.blocks.push({
type: "list",
ordered: listOrdered,
items: listItems,
});
listItems = [];
inList = false;
}
}
function flushCodeBlock() {
if (codeLines.length > 0) {
currentSection.blocks.push({
type: "code",
language: codeBlockLang || undefined,
code: codeLines.join("\n"),
});
codeLines = [];
inCodeBlock = false;
codeBlockLang = "";
}
}
for (const line of lines) {
// Code block handling
if (line.trimStart().startsWith("```")) {
if (inCodeBlock) {
flushCodeBlock();
} else {
flushQuote();
flushList();
inCodeBlock = true;
codeBlockLang = line.trimStart().slice(3).trim();
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
if (headingMatch) {
flushQuote();
flushList();
const level = headingMatch[1].length;
const headingText = headingMatch[2].trim();
// Use first h1 as title if none provided
if (level === 1 && !detectedTitle) {
detectedTitle = headingText;
continue;
}
// Start a new section
if (currentSection.blocks.length > 0 || currentSection.heading) {
sections.push(currentSection);
}
currentSection = { heading: headingText, level, blocks: [] };
continue;
}
// Horizontal rule
if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line.trim())) {
flushQuote();
flushList();
currentSection.blocks.push({ type: "separator" });
continue;
}
// Blockquote
if (line.trimStart().startsWith("> ")) {
flushList();
inBlockquote = true;
quoteLines.push(line.trimStart().slice(2));
continue;
} else if (inBlockquote) {
if (line.trim() === "") {
flushQuote();
} else {
quoteLines.push(line);
}
continue;
}
// Ordered list
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
if (orderedMatch) {
flushQuote();
if (inList && !listOrdered) {
flushList();
}
inList = true;
listOrdered = true;
listItems.push(orderedMatch[1]);
continue;
}
// Unordered list
const unorderedMatch = line.match(/^\s*[-*+]\s+(.+)$/);
if (unorderedMatch) {
flushQuote();
if (inList && listOrdered) {
flushList();
}
inList = true;
listOrdered = false;
listItems.push(unorderedMatch[1]);
continue;
}
// Empty line
if (line.trim() === "") {
flushQuote();
flushList();
continue;
}
// Regular paragraph text
flushQuote();
flushList();
// Check if last block is a paragraph — append to it
const lastBlock = currentSection.blocks[currentSection.blocks.length - 1];
if (lastBlock && lastBlock.type === "paragraph") {
lastBlock.text += " " + line.trim();
} else {
currentSection.blocks.push({ type: "paragraph", text: line.trim() });
}
}
// Flush remaining state
flushQuote();
flushList();
flushCodeBlock();
if (currentSection.blocks.length > 0 || currentSection.heading) {
sections.push(currentSection);
}
return {
title: detectedTitle || "Untitled",
author: author || undefined,
sections,
};
}

View File

@ -0,0 +1,57 @@
/**
* rPubs standalone server independent deployment at rpubs.online.
*
* Usage: bun run modules/pubs/standalone.ts
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { resolve } from "node:path";
import { pubsModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.use("/api/*", cors());
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", pubsModule.routes);
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(assetPath) } });
}
}
}
return app.fetch(req);
},
});
console.log(`rPubs standalone server running on http://localhost:${PORT}`);

View File

@ -0,0 +1,95 @@
/**
* Typst compiler spawns the Typst CLI to generate PDFs.
*
* Ported from pocket-press/lib/typst.ts.
* Uses Bun.spawn() instead of Node execFile().
*/
import { writeFile, readFile, mkdir, unlink } from "node:fs/promises";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import type { ParsedDocument } from "./parse-document";
const TYPST_DIR = resolve(import.meta.dir, "typst");
export interface CompileOptions {
document: ParsedDocument;
formatId: string;
}
export interface CompileResult {
pdf: Buffer;
pageCount: number;
}
export async function compileDocument(options: CompileOptions): Promise<CompileResult> {
const { document, formatId } = options;
const jobId = randomUUID();
const tmpDir = join("/tmp", `rpubs-${jobId}`);
await mkdir(tmpDir, { recursive: true });
const dataPath = join(tmpDir, "data.json");
const driverPath = join(tmpDir, "main.typ");
const outputPath = join(tmpDir, "output.pdf");
try {
// Write the content data as JSON
await writeFile(dataPath, JSON.stringify(document, null, 2));
// Write the Typst driver file that imports format + template
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);
// Compile with Typst CLI using Bun.spawn
const proc = Bun.spawn(
[
"typst",
"compile",
driverPath,
outputPath,
"--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 compilation failed: ${stderr}`);
}
const pdf = Buffer.from(await readFile(outputPath));
// Count pages (rough: look for /Type /Page in PDF)
const pdfStr = pdf.toString("latin1");
const pageCount = (pdfStr.match(/\/Type\s*\/Page[^s]/g) || []).length;
return { pdf, pageCount };
} finally {
// Clean up temp files
await Promise.allSettled([
unlink(dataPath),
unlink(driverPath),
unlink(outputPath),
]).catch(() => {});
// Remove temp dir (best effort)
import("node:fs/promises").then(({ rmdir }) => rmdir(tmpDir).catch(() => {}));
}
}

View File

@ -0,0 +1,18 @@
// A6 Booklet — 105×148mm
// Standard postcard size, versatile pocket book
#let page-setup(body) = {
set page(
width: 105mm,
height: 148mm,
margin: (
top: 10mm,
bottom: 10mm,
inside: 12mm,
outside: 10mm,
),
numbering: "1",
number-align: center + bottom,
)
body
}

View File

@ -0,0 +1,20 @@
// A7 Pocket — 74×105mm
// Smallest format, fits in a shirt pocket
#let page-setup(body) = {
set page(
width: 74mm,
height: 105mm,
margin: (
top: 8mm,
bottom: 8mm,
inside: 10mm,
outside: 8mm,
),
numbering: "1",
number-align: center + bottom,
)
set text(size: 7.5pt)
set par(leading: 0.6em)
body
}

View File

@ -0,0 +1,19 @@
// Digest — 5.5" × 8.5" (140mm × 216mm)
// Half US letter, classic digest/trade paperback size
// Most POD-friendly format
#let page-setup(body) = {
set page(
width: 5.5in,
height: 8.5in,
margin: (
top: 15mm,
bottom: 15mm,
inside: 18mm,
outside: 12mm,
),
numbering: "1",
number-align: center + bottom,
)
body
}

View File

@ -0,0 +1,18 @@
// Quarter US Letter — 4.25" × 5.5" (108mm × 140mm)
// Fits standard US letter paper folded twice, easy DIY printing
#let page-setup(body) = {
set page(
width: 4.25in,
height: 5.5in,
margin: (
top: 10mm,
bottom: 10mm,
inside: 12mm,
outside: 10mm,
),
numbering: "1",
number-align: center + bottom,
)
body
}

View File

@ -0,0 +1,72 @@
// Series Style — shared typography and visual identity
// This file defines the consistent "look" across all rPubs publications.
// Change this to rebrand the entire series.
#let series-fonts = (
body: "New Computer Modern",
heading: "New Computer Modern Sans",
mono: "New Computer Modern Mono",
)
#let series-colors = (
accent: rgb("#2d4a3e"),
muted: rgb("#666666"),
rule: rgb("#cccccc"),
cover-bg: rgb("#1a1a2e"),
cover-fg: rgb("#e0e0e0"),
)
#let apply-series-style(body) = {
set text(
font: series-fonts.body,
size: 9pt,
lang: "en",
)
set par(
leading: 0.7em,
spacing: 0.9em,
justify: true,
)
set heading(numbering: none)
show heading.where(level: 1): it => {
set text(font: series-fonts.heading, size: 14pt, weight: "bold", fill: series-colors.accent)
v(1em)
it
v(0.5em)
}
show heading.where(level: 2): it => {
set text(font: series-fonts.heading, size: 11pt, weight: "bold", fill: series-colors.accent)
v(0.8em)
it
v(0.3em)
}
show heading.where(level: 3): it => {
set text(font: series-fonts.heading, size: 10pt, weight: "semibold")
v(0.6em)
it
v(0.2em)
}
show raw: set text(font: series-fonts.mono, size: 7.5pt)
show raw.where(block: true): it => {
set par(justify: false)
block(
fill: rgb("#f5f5f5"),
inset: 8pt,
radius: 2pt,
width: 100%,
it,
)
}
show link: set text(fill: series-colors.accent)
body
}
#let series-rule() = {
line(length: 100%, stroke: 0.5pt + series-colors.rule)
}
#let series-separator() = {
v(0.5em)
align(center, text(fill: series-colors.muted, size: 8pt)[· · ·])
v(0.5em)
}

View File

@ -0,0 +1,151 @@
// rPubs Book Template — main entry point
// Reads structured content from JSON and renders a complete pocket book.
// Works with any format file (A6, A7, digest, etc.)
#import "../lib/series-style.typ": apply-series-style, series-colors, series-fonts, series-rule, series-separator
#let data = json(sys.inputs.at("data-path"))
// Apply series typography
#show: apply-series-style
// --- Cover Page ---
#page(
numbering: none,
margin: (x: 15mm, y: 20mm),
fill: series-colors.cover-bg,
)[
#set text(fill: series-colors.cover-fg)
#v(1fr)
#align(center)[
#block(width: 100%)[
#set text(font: series-fonts.heading)
// Title
#text(size: 18pt, weight: "bold")[
#data.title
]
#if data.at("subtitle", default: none) != none {
v(0.3em)
text(size: 11pt, fill: series-colors.cover-fg.lighten(20%))[
#data.subtitle
]
}
#v(1em)
#line(length: 40%, stroke: 0.5pt + series-colors.cover-fg.darken(40%))
#v(1em)
// Author
#if data.at("author", default: none) != none {
text(size: 10pt)[
#data.author
]
}
]
]
#v(1fr)
#align(center)[
#text(size: 7pt, fill: series-colors.cover-fg.darken(40%))[
rpubs
]
]
]
// --- Table of Contents (if there are headings) ---
#let has-headings = data.sections.any(s => s.at("heading", default: none) != none)
#if has-headings {
page(numbering: none)[
#v(2em)
#text(font: series-fonts.heading, size: 12pt, weight: "bold", fill: series-colors.accent)[Contents]
#v(1em)
#series-rule()
#v(0.5em)
#for (i, section) in data.sections.enumerate() {
if section.at("heading", default: none) != none {
let level = section.at("level", default: 1)
let indent = (level - 1) * 1em
pad(left: indent)[
#text(size: 9pt)[#section.heading]
]
v(0.2em)
}
}
]
}
// --- Body Content ---
#for section in data.sections {
// Section heading
if section.at("heading", default: none) != none {
let level = section.at("level", default: 1)
if level == 1 {
heading(level: 1)[#section.heading]
} else if level == 2 {
heading(level: 2)[#section.heading]
} else {
heading(level: 3)[#section.heading]
}
}
// Content blocks
for item in section.at("blocks", default: ()) {
if item.type == "paragraph" {
par[#item.text]
} else if item.type == "quote" {
pad(left: 1em)[
#block(
inset: (left: 8pt, y: 4pt),
stroke: (left: 2pt + series-colors.accent),
)[
#set text(style: "italic", size: 8.5pt)
#item.text
#if item.at("attribution", default: none) != none {
v(0.2em)
align(right)[
#text(size: 7.5pt, fill: series-colors.muted)[— #item.attribution]
]
}
]
]
} else if item.type == "list" {
if item.at("ordered", default: false) {
enum(..item.items.map(entry => [#entry]))
} else {
list(..item.items.map(entry => [#entry]))
}
} else if item.type == "code" {
raw(block: true, lang: item.at("language", default: none), item.code)
} else if item.type == "separator" {
series-separator()
}
}
}
// --- Colophon (back page) ---
#pagebreak()
#v(1fr)
#series-rule()
#v(0.5em)
#set text(size: 7pt, fill: series-colors.muted)
#if data.at("title", default: none) != none [
*#data.title*
#if data.at("author", default: none) != none [ by #data.author]
#v(0.3em)
]
#[
Typeset with rPubs. \
Formatted for standardized pocket-book printing.
]
#v(0.5em)
#align(center)[
#text(size: 6pt)[rpubs · drop in a document · get a book]
]

View File

@ -40,12 +40,14 @@ import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";
import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
import { canvasModule } from "../modules/canvas/mod"; import { canvasModule } from "../modules/canvas/mod";
import { booksModule } from "../modules/books/mod"; import { booksModule } from "../modules/books/mod";
import { pubsModule } from "../modules/pubs/mod";
import { spaces } from "./spaces"; import { spaces } from "./spaces";
import { renderShell } from "./shell"; import { renderShell } from "./shell";
// Register modules // Register modules
registerModule(canvasModule); registerModule(canvasModule);
registerModule(booksModule); registerModule(booksModule);
registerModule(pubsModule);
// ── Config ── // ── Config ──
const PORT = Number(process.env.PORT) || 3000; const PORT = Number(process.env.PORT) || 3000;

View File

@ -105,6 +105,33 @@ export default defineConfig({
resolve(__dirname, "modules/books/components/books.css"), resolve(__dirname, "modules/books/components/books.css"),
resolve(__dirname, "dist/modules/books/books.css"), resolve(__dirname, "dist/modules/books/books.css"),
); );
// Build pubs module component
await build({
configFile: false,
root: resolve(__dirname, "modules/pubs/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/pubs"),
lib: {
entry: resolve(__dirname, "modules/pubs/components/folk-pubs-editor.ts"),
formats: ["es"],
fileName: () => "folk-pubs-editor.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-pubs-editor.js",
},
},
},
});
// Copy pubs CSS
mkdirSync(resolve(__dirname, "dist/modules/pubs"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/pubs/components/pubs.css"),
resolve(__dirname, "dist/modules/pubs/pubs.css"),
);
}, },
}, },
}, },