diff --git a/Dockerfile b/Dockerfile index a53171d..646a4ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,10 +16,23 @@ COPY . . # Build frontend (skip tsc in Docker — type checking is done in CI/local dev) 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 FROM oven/bun:1-slim AS production 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 --from=build /app/dist ./dist COPY --from=build /app/server ./server diff --git a/modules/pubs/components/folk-pubs-editor.ts b/modules/pubs/components/folk-pubs-editor.ts new file mode 100644 index 0000000..20e5c51 --- /dev/null +++ b/modules/pubs/components/folk-pubs-editor.ts @@ -0,0 +1,485 @@ +/** + * — 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()} +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+ `; + + 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 ``; + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } +} + +customElements.define("folk-pubs-editor", FolkPubsEditor); diff --git a/modules/pubs/components/pubs.css b/modules/pubs/components/pubs.css new file mode 100644 index 0000000..cab4176 --- /dev/null +++ b/modules/pubs/components/pubs.css @@ -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; +} diff --git a/modules/pubs/formats.ts b/modules/pubs/formats.ts new file mode 100644 index 0000000..6c3511b --- /dev/null +++ b/modules/pubs/formats.ts @@ -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 = { + 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); +} diff --git a/modules/pubs/mod.ts b/modules/pubs/mod.ts new file mode 100644 index 0000000..4be3b2c --- /dev/null +++ b/modules/pubs/mod.ts @@ -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, "&").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", +}; diff --git a/modules/pubs/parse-document.ts b/modules/pubs/parse-document.ts new file mode 100644 index 0000000..75a31ff --- /dev/null +++ b/modules/pubs/parse-document.ts @@ -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, + }; +} diff --git a/modules/pubs/standalone.ts b/modules/pubs/standalone.ts new file mode 100644 index 0000000..c0561f0 --- /dev/null +++ b/modules/pubs/standalone.ts @@ -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}`); diff --git a/modules/pubs/typst-compile.ts b/modules/pubs/typst-compile.ts new file mode 100644 index 0000000..9d05f36 --- /dev/null +++ b/modules/pubs/typst-compile.ts @@ -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 { + 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(() => {})); + } +} diff --git a/modules/pubs/typst/formats/a6.typ b/modules/pubs/typst/formats/a6.typ new file mode 100644 index 0000000..cf6e523 --- /dev/null +++ b/modules/pubs/typst/formats/a6.typ @@ -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 +} diff --git a/modules/pubs/typst/formats/a7.typ b/modules/pubs/typst/formats/a7.typ new file mode 100644 index 0000000..6e5dc12 --- /dev/null +++ b/modules/pubs/typst/formats/a7.typ @@ -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 +} diff --git a/modules/pubs/typst/formats/digest.typ b/modules/pubs/typst/formats/digest.typ new file mode 100644 index 0000000..3525a90 --- /dev/null +++ b/modules/pubs/typst/formats/digest.typ @@ -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 +} diff --git a/modules/pubs/typst/formats/quarter-letter.typ b/modules/pubs/typst/formats/quarter-letter.typ new file mode 100644 index 0000000..e6768c2 --- /dev/null +++ b/modules/pubs/typst/formats/quarter-letter.typ @@ -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 +} diff --git a/modules/pubs/typst/lib/series-style.typ b/modules/pubs/typst/lib/series-style.typ new file mode 100644 index 0000000..0d284d0 --- /dev/null +++ b/modules/pubs/typst/lib/series-style.typ @@ -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) +} diff --git a/modules/pubs/typst/templates/pocket-book.typ b/modules/pubs/typst/templates/pocket-book.typ new file mode 100644 index 0000000..f618583 --- /dev/null +++ b/modules/pubs/typst/templates/pocket-book.typ @@ -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] +] diff --git a/server/index.ts b/server/index.ts index 11b059a..9e2e9da 100644 --- a/server/index.ts +++ b/server/index.ts @@ -40,12 +40,14 @@ import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; import { canvasModule } from "../modules/canvas/mod"; import { booksModule } from "../modules/books/mod"; +import { pubsModule } from "../modules/pubs/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; // Register modules registerModule(canvasModule); registerModule(booksModule); +registerModule(pubsModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; diff --git a/vite.config.ts b/vite.config.ts index 802b2b8..fe3c67e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -105,6 +105,33 @@ export default defineConfig({ resolve(__dirname, "modules/books/components/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"), + ); }, }, },