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:
parent
88de4c30dd
commit
ca5c1363ce
13
Dockerfile
13
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
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-pubs-editor", FolkPubsEditor);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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, "<").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",
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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(() => {}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
]
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue