feat(export): Chunk #6 Export Pipeline — 7 exporters, ExportDialog & tests
Add the @pubscript/exporters package exporting a flattened ProseMirror project to Markdown, HTML, EPUB, DOCX, ODT and PDF, plus a deterministic generatedAt timestamp threaded through every format (ZIP entry dates, PDF /ID, DOCX dc:created/modified, EPUB id, HTML/XML footers) so golden files are byte-stable. Wire an ExportDialog into the web app (Topbar trigger, App route) with format selection, theme pick, and download helpers. Add round-trip and golden-file determinism tests (22) covering non-empty output, byte-identical re-exports, Markdown round-trip through the editor, and EPUB/ODT ZIP + mimetype + XML validation.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"@pubscript/editor": "workspace:*",
|
||||
"@pubscript/exporters": "workspace:*",
|
||||
"@pubscript/ui": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Sidebar, Topbar, CommandPalette } from "./components/index.js";
|
||||
import { ExportDialog } from "./features/export/ExportDialog.js";
|
||||
import { ToastViewport } from "@pubscript/ui";
|
||||
import Write from "./routes/Write.js";
|
||||
import { Characters } from "./routes/Characters.js";
|
||||
@@ -26,6 +27,7 @@ const NAV = [
|
||||
|
||||
export default function App() {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -41,7 +43,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="ps-app">
|
||||
<Sidebar nav={NAV} />
|
||||
<Topbar onOpenPalette={() => setPaletteOpen(true)} />
|
||||
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
|
||||
<main className="ps-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Write />} />
|
||||
@@ -58,6 +60,7 @@ export default function App() {
|
||||
</main>
|
||||
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,9 +16,10 @@ const TITLES: Record<string, string> = {
|
||||
|
||||
export interface TopbarProps {
|
||||
onOpenPalette: () => void;
|
||||
onOpenExport: () => void;
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenPalette }: TopbarProps) {
|
||||
export function Topbar({ onOpenPalette, onOpenExport }: TopbarProps) {
|
||||
const location = useLocation();
|
||||
const theme = useSettings((s) => s.theme);
|
||||
const setTheme = useSettings((s) => s.setTheme);
|
||||
@@ -43,6 +44,14 @@ export function Topbar({ onOpenPalette }: TopbarProps) {
|
||||
<span className="ps-command-kbd" style={{ marginLeft: 8 }}>⌘K</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="ps-nav-item"
|
||||
onClick={onOpenExport}
|
||||
style={{ width: "auto", padding: "6px 10px" }}
|
||||
>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
<Select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as never)}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Button } from "@pubscript/ui";
|
||||
import {
|
||||
THEMES,
|
||||
exportProjectData,
|
||||
type ExportFormat,
|
||||
} from "@pubscript/exporters";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
import { buildExportProject } from "./buildExportProject.js";
|
||||
import { downloadFile } from "./download.js";
|
||||
import { EXPORT_FORMATS, type FormatInfo } from "./formats.js";
|
||||
|
||||
export interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Scope = "project" | "chapter";
|
||||
|
||||
/** Collapse a string into a filesystem-safe slug. */
|
||||
function slug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^\w-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "project";
|
||||
}
|
||||
|
||||
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const adapter = useProjectStore((s) => s.adapter);
|
||||
const projectId = useProjectStore((s) => s.project?.projectId);
|
||||
const chapters = useProjectStore((s) => s.chapters);
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>("markdown");
|
||||
const [themeId, setThemeId] = useState("ink");
|
||||
const [scope, setScope] = useState<Scope>("project");
|
||||
const [chapterId, setChapterId] = useState<string>("");
|
||||
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Populate the book/chapter selectors when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open || !adapter || !projectId) return;
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const allBooks = await adapter.listBooks(projectId);
|
||||
if (!active) return;
|
||||
setBooks(allBooks);
|
||||
// Resolve the first chapter across books so the selector has a default.
|
||||
let firstChapterId = "";
|
||||
for (const book of allBooks) {
|
||||
const cs = await adapter.listChapters(book.id);
|
||||
const first = cs[0];
|
||||
if (first) {
|
||||
firstChapterId = first.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!active) return;
|
||||
setChapterId(firstChapterId);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, adapter, projectId]);
|
||||
|
||||
const chapterOptions = useMemo(() => {
|
||||
const byBook = new Map<string, { id: string; title: string }[]>();
|
||||
for (const book of books) {
|
||||
byBook.set(book.id, chapters.filter((c) => c.bookId === book.id));
|
||||
}
|
||||
return byBook;
|
||||
}, [books, chapters]);
|
||||
|
||||
const formatInfo: FormatInfo | undefined = useMemo(
|
||||
() => EXPORT_FORMATS.find((f) => f.id === format),
|
||||
[format]
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!adapter || !projectId) return;
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await buildExportProject(adapter, projectId);
|
||||
const options: Parameters<typeof exportProjectData>[2] = {
|
||||
scope: scope === "chapter" ? "chapter" : "project",
|
||||
theme: themeId,
|
||||
};
|
||||
if (scope === "chapter") options.scopeId = chapterId;
|
||||
const data = await exportProjectData(project, format, options);
|
||||
const ext = formatInfo?.extension ?? "bin";
|
||||
downloadFile(`${slug(projectId)}.${ext}`, data, formatInfo?.mime ?? "application/octet-stream");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Export project"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={exporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={exporting || !formatInfo}>
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="ps-export-grid">
|
||||
<label className="ps-export-field">
|
||||
<span>Format</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{EXPORT_FORMATS.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="ps-export-field">
|
||||
<span>Theme</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={themeId}
|
||||
onChange={(e) => setThemeId(e.target.value)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{Object.entries(THEMES).map(([id, t]) => (
|
||||
<option key={id} value={id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="ps-export-field">
|
||||
<span>Scope</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as Scope)}
|
||||
disabled={exporting}
|
||||
>
|
||||
<option value="project">Entire book</option>
|
||||
<option value="chapter">Selected chapter</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{scope === "chapter" && (
|
||||
<label className="ps-export-field">
|
||||
<span>Chapter</span>
|
||||
<select
|
||||
className="ps-select"
|
||||
value={chapterId}
|
||||
onChange={(e) => setChapterId(e.target.value)}
|
||||
disabled={exporting || chapterOptions.size === 0}
|
||||
>
|
||||
{Array.from(chapterOptions.values()).flatMap((cs) =>
|
||||
cs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="ps-export-error" role="alert">{error}</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Book, StorageAdapter } from "@pubscript/core";
|
||||
import type { ExportProject } from "@pubscript/exporters";
|
||||
|
||||
/**
|
||||
* Assemble the export model for a project from the storage adapter.
|
||||
*
|
||||
* Fetches the project metadata and every book with its chapters, then returns
|
||||
* the `ExportProject` shape the export engine consumes. The engine flattens it
|
||||
* into blocks and applies the requested scope/theme when the export runs, so
|
||||
* this helper only needs to gather the raw source data.
|
||||
*/
|
||||
export async function buildExportProject(
|
||||
adapter: StorageAdapter,
|
||||
projectId: string,
|
||||
): Promise<ExportProject> {
|
||||
const project = await adapter.getProject(projectId);
|
||||
|
||||
const meta: ExportProject["meta"] = {
|
||||
title: project.title,
|
||||
};
|
||||
if (typeof project.settings.author === "string") meta.author = project.settings.author;
|
||||
if (project.settings.language !== undefined) meta.language = project.settings.language;
|
||||
|
||||
const books = await adapter.listBooks(projectId);
|
||||
const booksWithChapters: Book[] = await Promise.all(
|
||||
books.map(async (book) => ({
|
||||
...book,
|
||||
chapters: await adapter.listChapters(book.id),
|
||||
}))
|
||||
);
|
||||
|
||||
return { meta, books: booksWithChapters };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Trigger a browser download for an in-memory file.
|
||||
*
|
||||
* Works for both the text exporters (markdown/html → string) and the binary
|
||||
* book exporters (epub/docx/odt/pdf → Uint8Array). Uses a detached anchor so it
|
||||
* works in the web app without any backend round-trip.
|
||||
*/
|
||||
|
||||
export function downloadFile(
|
||||
filename: string,
|
||||
data: string | Uint8Array,
|
||||
mime = "application/octet-stream",
|
||||
): void {
|
||||
let blob: Blob;
|
||||
if (typeof data === "string") {
|
||||
blob = new Blob([data], { type: `${mime}; charset=utf-8` });
|
||||
} else {
|
||||
blob = new Blob([data as BlobPart], { type: mime });
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Turn a title into a filesystem-safe slug for the default export filename. */
|
||||
export function slugify(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "project";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Export format registry for the web ExportDialog.
|
||||
*
|
||||
* The exporters package exposes one function per format; this module describes
|
||||
* each format's human-facing metadata (label, file extension, MIME type) and
|
||||
* which formats serialize to a binary blob versus a plain text string.
|
||||
*/
|
||||
|
||||
/** The export formats the UI can choose from. */
|
||||
export type ExportFormat = "markdown" | "html" | "epub" | "docx" | "odt" | "pdf";
|
||||
|
||||
export interface FormatInfo {
|
||||
id: ExportFormat;
|
||||
label: string;
|
||||
extension: string;
|
||||
mime: string;
|
||||
/**
|
||||
* True for the zip-based book formats (epub/docx/odt/pdf) whose exporters
|
||||
* return a binary `Uint8Array`; false for the text formats (markdown/html)
|
||||
* whose exporters return a string.
|
||||
*/
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export const EXPORT_FORMATS: FormatInfo[] = [
|
||||
{
|
||||
id: "markdown",
|
||||
label: "Markdown",
|
||||
extension: "md",
|
||||
mime: "text/markdown",
|
||||
binary: false,
|
||||
},
|
||||
{
|
||||
id: "html",
|
||||
label: "HTML",
|
||||
extension: "html",
|
||||
mime: "text/html",
|
||||
binary: false,
|
||||
},
|
||||
{
|
||||
id: "epub",
|
||||
label: "EPUB",
|
||||
extension: "epub",
|
||||
mime: "application/epub+zip",
|
||||
binary: true,
|
||||
},
|
||||
{
|
||||
id: "docx",
|
||||
label: "Word (DOCX)",
|
||||
extension: "docx",
|
||||
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
binary: true,
|
||||
},
|
||||
{
|
||||
id: "odt",
|
||||
label: "OpenDocument (ODT)",
|
||||
extension: "odt",
|
||||
mime: "application/vnd.oasis.opendocument.text",
|
||||
binary: true,
|
||||
},
|
||||
{
|
||||
id: "pdf",
|
||||
label: "PDF",
|
||||
extension: "pdf",
|
||||
mime: "application/pdf",
|
||||
binary: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Lookup table keyed by format id. */
|
||||
export const FORMAT_BY_ID: Record<ExportFormat, FormatInfo> = Object.fromEntries(
|
||||
EXPORT_FORMATS.map((f) => [f.id, f]),
|
||||
) as Record<ExportFormat, FormatInfo>;
|
||||
|
||||
/** First (default) format shown in the dialog. */
|
||||
export const DEFAULT_FORMAT: ExportFormat = EXPORT_FORMATS[0]!.id;
|
||||
@@ -8,6 +8,7 @@ import "./styles/entities.css";
|
||||
import "./styles/plot.css";
|
||||
import "./styles/timeline.css";
|
||||
import "./styles/whiteboard.css";
|
||||
import "./styles/export.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Export dialog styles (apps/web).
|
||||
*
|
||||
* Styles for the Chunk #6 export modal: format/theme/scope selectors and the
|
||||
* export action row. Reuses the `@pubscript/ui` design tokens (`--ps-*`) and
|
||||
* component classes (`ps-btn`, `ps-input`, `ps-modal-*`).
|
||||
*/
|
||||
|
||||
.ps-export {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.ps-export-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-export-field > span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
|
||||
.ps-export select.ps-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: var(--ps-radius);
|
||||
color: var(--ps-text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ps-export select.ps-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ps-accent);
|
||||
}
|
||||
|
||||
/* Scope toggle */
|
||||
|
||||
.ps-export-scope {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ps-export-scope .ps-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
|
||||
.ps-export-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ps-danger);
|
||||
background: color-mix(in srgb, var(--ps-danger) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--ps-danger) 25%, transparent);
|
||||
border-radius: var(--ps-radius);
|
||||
}
|
||||
|
||||
/* Action row */
|
||||
|
||||
.ps-export-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@pubscript/exporters",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Format-agnostic export engine for PubScript projects (Markdown, HTML, EPUB, DOCX, ODT, PDF).",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"docx": "^8.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"jspdf": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pubscript/editor": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Determinism helpers.
|
||||
*
|
||||
* Some exporters embed time-varying bytes that are irrelevant to the document
|
||||
* content — ZIP entry timestamps (EPUB/DOCX/ODT) and the jsPDF `/ID` hash
|
||||
* (PDF). These normalize those bytes so repeated exports of the same input are
|
||||
* byte-identical, which is what golden-file stability requires. The normalized
|
||||
* bytes are semantically unchanged: a ZIP entry's modification time and a PDF
|
||||
* document identifier carry no content meaning here.
|
||||
*/
|
||||
|
||||
import * as JSZipNs from "jszip";
|
||||
|
||||
// jszip is a CommonJS module whose `module.exports` is the constructor. Vite's
|
||||
// ESM interop exposes it either as the default export or on the namespace, so
|
||||
// accept whichever form is present.
|
||||
const JSZip = (JSZipNs as unknown as { default?: typeof JSZipNs }).default ?? JSZipNs;
|
||||
|
||||
/**
|
||||
* Zero the last-modification time/date fields in every ZIP header (both the
|
||||
* local file headers `PK\x03\x04` and the central directory headers
|
||||
* `PK\x01\x02`). The fields sit at offset 14/15 (time) and 16/17 (date) from
|
||||
* each signature; zeroing them yields the canonical ZIP date 1980-01-01.
|
||||
*/
|
||||
export function normalizeZipDates(buffer: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(buffer);
|
||||
const signatures: number[][] = [
|
||||
[0x50, 0x4b, 0x03, 0x04], // local file header
|
||||
[0x50, 0x4b, 0x01, 0x02], // central directory header
|
||||
];
|
||||
for (let i = 0; i + 17 <= out.length; i++) {
|
||||
for (const sig of signatures) {
|
||||
if (
|
||||
out[i] === sig[0] &&
|
||||
out[i + 1] === sig[1] &&
|
||||
out[i + 2] === sig[2] &&
|
||||
out[i + 3] === sig[3]
|
||||
) {
|
||||
out[i + 14] = 0;
|
||||
out[i + 15] = 0;
|
||||
out[i + 16] = 0;
|
||||
out[i + 17] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal FNV-1a 32-bit hash, hex-encoded (lowercase). */
|
||||
function fnv1a(input: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the PDF `/ID [ <…> <…> ]` trailer entry with a deterministic value
|
||||
* derived from the rest of the document, so the identifier no longer depends on
|
||||
* jsPDF's internal `Date.now()`/`Math.random()` seed.
|
||||
*/
|
||||
export function normalizePdfId(buffer: Uint8Array): Uint8Array {
|
||||
const text = new TextDecoder().decode(buffer);
|
||||
const idRegex = /\/ID\s*\[\s*<[0-9a-fA-F]+>\s*<[0-9a-fA-F]+>\s*\]/;
|
||||
if (!idRegex.test(text)) return buffer;
|
||||
const withoutId = text.replace(idRegex, "");
|
||||
const seed = fnv1a(withoutId);
|
||||
const first = fnv1a(withoutId).padEnd(16, "0");
|
||||
const second = fnv1a(withoutId + seed).padEnd(16, "0");
|
||||
const normalized = text.replace(idRegex, `/ID [ <${first}> <${second}> ]`);
|
||||
return new TextEncoder().encode(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize the time-varying bytes inside a DOCX archive produced by the
|
||||
* `docx` library. That library stamps `docProps/core.xml` with `dcterms:created`
|
||||
* and `dcterms:modified` timestamps taken from `new Date()`, which change on
|
||||
* every export. We decompress the archive, rewrite every W3CDTF timestamp to the
|
||||
* caller-supplied `generatedAt`, and re-compress with normalized ZIP dates. The
|
||||
* document content is unchanged — only the metadata timestamps are pinned.
|
||||
*/
|
||||
export async function normalizeDocx(
|
||||
buffer: Uint8Array,
|
||||
generatedAt: string
|
||||
): Promise<Uint8Array> {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const core = zip.file("docProps/core.xml");
|
||||
if (core) {
|
||||
const xml = await core.async("string");
|
||||
const fixed = xml.replace(
|
||||
/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/g,
|
||||
generatedAt
|
||||
);
|
||||
zip.file("docProps/core.xml", fixed);
|
||||
}
|
||||
return normalizeZipDates(await zip.generateAsync({ type: "uint8array" }));
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* DOCX exporter (via the `docx` library, v8.x).
|
||||
*
|
||||
* Builds a WordprocessingML document from the flattened project. Headings,
|
||||
* paragraphs, scene breaks, blockquotes, code blocks, lists, and tables are
|
||||
* all mapped to native Word elements so the output is editable and re-exportable.
|
||||
* The theme supplies fonts, colours, and heading sizes.
|
||||
*/
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
Paragraph,
|
||||
Packer,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
import { normalizeDocx } from "./determinism.js";
|
||||
|
||||
function runToTextRun(run: { text: string; marks: Mark[] }, theme: Theme): TextRun {
|
||||
const hasCode = run.marks.some((m) => m.type === "code");
|
||||
const hasBold = run.marks.some((m) => m.type === "bold");
|
||||
const hasItalic = run.marks.some((m) => m.type === "italic");
|
||||
const hasStrike = run.marks.some((m) => m.type === "strike");
|
||||
const color = (run.marks.find((m) => m.type === "color")?.attrs?.color as string | undefined) ?? theme.colors.text;
|
||||
return new TextRun({
|
||||
text: run.text,
|
||||
bold: hasBold,
|
||||
italics: hasItalic,
|
||||
strike: hasStrike,
|
||||
font: hasCode ? "Courier New" : theme.fontFamily,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
function runsForInline(text: string, marks: Mark[], theme: Theme): TextRun[] {
|
||||
if (!marks.length) return [new TextRun({ text, color: theme.colors.text })];
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
const runs: TextRun[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
runs.push(runToTextRun({ text: text.slice(start, end), marks: runMarks }, theme));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function headingLevel(level: number): string {
|
||||
if (level <= 1) return "Heading1";
|
||||
if (level <= 2) return "Heading2";
|
||||
if (level <= 3) return "Heading3";
|
||||
if (level <= 4) return "Heading4";
|
||||
if (level <= 5) return "Heading5";
|
||||
return "Heading6";
|
||||
}
|
||||
|
||||
function blockToParagraph(block: Block, theme: Theme): Paragraph {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = block.level ?? 1;
|
||||
return new Paragraph({
|
||||
heading: headingLevel(level) as never,
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
}) as unknown as Paragraph;
|
||||
}
|
||||
case "paragraph":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
});
|
||||
case "sceneBreak":
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
children: [new TextRun({ text: "***", italics: true, color: theme.colors.muted })],
|
||||
});
|
||||
case "blockquote":
|
||||
return new Paragraph({
|
||||
indent: { left: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: (block.children ?? []).map((c) => c.text ?? "").join("\n"),
|
||||
italics: true,
|
||||
color: theme.colors.muted,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "codeBlock":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: block.text ?? "",
|
||||
font: "Courier New",
|
||||
size: 16,
|
||||
color: theme.colors.text,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "footnote":
|
||||
return new Paragraph({
|
||||
indent: { firstLine: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({ text: block.text ?? "", italics: true, color: theme.colors.muted, size: 18 }),
|
||||
],
|
||||
});
|
||||
case "list": {
|
||||
const items = block.children ?? [];
|
||||
const pad = block.listType === "ordered" ? "1. " : "- ";
|
||||
return new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({ text: pad, bold: true, color: theme.colors.heading }),
|
||||
...runsForInline(items[0]?.text ?? "", items[0]?.marks ?? [], theme),
|
||||
],
|
||||
});
|
||||
}
|
||||
default:
|
||||
return new Paragraph({ spacing: { after: 200 }, children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
function buildTable(blocks: Block[], theme: Theme): Table | null {
|
||||
const rows = blocks.filter((b) => b.type === "table");
|
||||
if (!rows.length) return null;
|
||||
const header = rows[0];
|
||||
const headers = header?.children ?? [];
|
||||
const body = rows.slice(1);
|
||||
const headerRow = new TableRow({
|
||||
children: headers.map((h) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: h.text ?? "", bold: true, color: theme.colors.background }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const bodyRows = body.map((row) =>
|
||||
new TableRow({
|
||||
children: (row.children ?? []).map((cell) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: cell.text ?? "" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [headerRow, ...bodyRows],
|
||||
});
|
||||
}
|
||||
|
||||
export interface DocxOptions {
|
||||
themeId?: string;
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a DOCX (`.docx` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme id (defaults to the built-in "ink" theme)
|
||||
*/
|
||||
export async function exportDocx(project: FlatProject, options?: DocxOptions): Promise<Uint8Array> {
|
||||
const theme = getTheme(options?.themeId);
|
||||
const fm = project.frontmatter;
|
||||
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
if (fm.title) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: "Title" as never,
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: fm.title, bold: true, size: 32, color: theme.colors.heading })],
|
||||
}) as unknown as Paragraph
|
||||
);
|
||||
}
|
||||
if (fm.author) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text: fm.author, italics: true, color: theme.colors.muted })],
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const block of project.blocks) {
|
||||
if (block.type === "table") continue; // handled separately
|
||||
children.push(blockToParagraph(block, theme));
|
||||
}
|
||||
const table = buildTable(project.blocks, theme);
|
||||
if (table) children.push(table);
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
return normalizeDocx(
|
||||
new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
|
||||
generatedAt
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* EPUB 3 exporter.
|
||||
*
|
||||
* Emits a valid EPUB 3 `.epub` (a ZIP archive) with one XHTML document per
|
||||
* chapter, a cover page, an EPUB3 `nav.xhtml`, a NCX `toc.ncx`, and the theme
|
||||
* stylesheet. Chapter boundaries become `<h2>` headings within a single
|
||||
* document when a scope spans multiple chapters, or one document per chapter
|
||||
* when exporting whole chapters.
|
||||
*
|
||||
* The archive layout:
|
||||
* mimetype (stored, first entry)
|
||||
* META-INF/container.xml
|
||||
* OEBPS/content.opf
|
||||
* OEBPS/toc.ncx
|
||||
* OEBPS/nav.xhtml
|
||||
* OEBPS/cover.xhtml
|
||||
* OEBPS/stylesheet.css
|
||||
* OEBPS/chapterN.xhtml
|
||||
*/
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { normalizeZipDates } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeHtml, escapeXml } from "./model.js";
|
||||
import type { Theme } from "./themes.js";
|
||||
|
||||
/** Generate a RFC-4122 v4 UUID string. */
|
||||
function documentId(project: FlatProject): string {
|
||||
// Deterministic v4-shaped UUID derived from the document content, so that
|
||||
// repeated exports of the same input share one stable identifier (and the
|
||||
// golden-file determinism test is byte-stable).
|
||||
const seed = JSON.stringify([
|
||||
project.frontmatter?.title ?? "",
|
||||
project.frontmatter?.author ?? "",
|
||||
project.frontmatter?.language ?? "",
|
||||
project.blocks?.map((b) => b.type + ":" + (b.text ?? "")).join("\n") ?? "",
|
||||
]);
|
||||
const bytes = new Uint8Array(16);
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
bytes[i % 16] = (h & 0xff) ^ (bytes[i % 16] ?? 0);
|
||||
}
|
||||
bytes[6] = ((bytes[6]! & 0x0f) | 0x40) & 0xff;
|
||||
bytes[8] = ((bytes[8]! & 0x3f) | 0x80) & 0xff;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
||||
}
|
||||
|
||||
/** Serialize inline text + marks to XHTML. */
|
||||
function serializeInlineXhtml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeHtml(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += runToXhtml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runToXhtml(segment: string, marks: Mark[]): string {
|
||||
let out = escapeHtml(segment);
|
||||
if (marks.some((m) => m.type === "italic")) out = `<em>${out}</em>`;
|
||||
if (marks.some((m) => m.type === "bold")) out = `<strong>${out}</strong>`;
|
||||
if (marks.some((m) => m.type === "strike")) out = `<del>${out}</del>`;
|
||||
if (marks.some((m) => m.type === "code")) out = `<code>${out}</code>`;
|
||||
const color = marks.find((m) => m.type === "color");
|
||||
if (color?.attrs?.color) {
|
||||
out = `<span style="color:${escapeHtml(String(color.attrs.color))}">${out}</span>`;
|
||||
}
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
out = `<a href="${escapeHtml(String(link.attrs.href))}">${out}</a>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blockToXhtml(block: Block): string {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<h${level}>${serializeInlineXhtml(block.text ?? "", block.marks ?? [])}</h${level}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${serializeInlineXhtml(block.text ?? "", block.marks ?? [])}</p>`;
|
||||
case "sceneBreak":
|
||||
return `<p class="scene-break">***</p>`;
|
||||
case "blockquote":
|
||||
return `<blockquote>${(block.children ?? []).map(blockToXhtml).join("")}</blockquote>`;
|
||||
case "codeBlock":
|
||||
return `<pre><code>${escapeHtml(block.text ?? "")}</code></pre>`;
|
||||
case "footnote":
|
||||
return `<p class="footnote">${serializeInlineXhtml(block.text ?? "", [])}</p>`;
|
||||
case "list": {
|
||||
const tag = block.listType === "ordered" ? "ol" : "ul";
|
||||
const items = (block.children ?? [])
|
||||
.map((item) => `<li>${serializeInlineXhtml(item.text ?? "", item.marks ?? [])}</li>`)
|
||||
.join("");
|
||||
return `<${tag}>${items}</${tag}>`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function chapterXhtml(chapterTitle: string, blocks: Block[]): string {
|
||||
const body = blocks.map(blockToXhtml).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${escapeHtml(chapterTitle)}</title>
|
||||
<link rel="stylesheet" type="text/css" href="stylesheet.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${escapeHtml(chapterTitle)}</h2>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function coverXhtml(project: FlatProject): string {
|
||||
const fm = project.frontmatter;
|
||||
const author = fm.author ? `<p class="cover-author">${escapeHtml(fm.author)}</p>` : "";
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Cover</title>
|
||||
</head>
|
||||
<body epub:type="cover">
|
||||
<div epub:type="cover-image">
|
||||
<h1>${escapeHtml(fm.title)}</h1>
|
||||
${author}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function navXhtml(project: FlatProject): string {
|
||||
const items = project.frontmatter.chapters
|
||||
.map((c, i) => `<li><a href="chapter${i + 1}.xhtml">${escapeHtml(c.title)}</a></li>`)
|
||||
.join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Table of Contents</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<h1>Table of Contents</h1>
|
||||
<ol>${items}</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function ncx(project: FlatProject, id: string): string {
|
||||
const points = project.frontmatter.chapters
|
||||
.map(
|
||||
(c, i) =>
|
||||
`<navPoint id="cp${i + 1}"><navLabel><text>${escapeHtml(c.title)}</text></navLabel><content src="chapter${i + 1}.xhtml"/></navPoint>`
|
||||
)
|
||||
.join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="${escapeXml(id)}"/>
|
||||
</head>
|
||||
<docTitle><text>${escapeHtml(project.frontmatter.title)}</text></docTitle>
|
||||
<navMap>${points}</navMap>
|
||||
</ncx>
|
||||
`;
|
||||
}
|
||||
|
||||
function opf(project: FlatProject, id: string, chapterCount: number, generatedAt: string): string {
|
||||
const modified = generatedAt.replace(/\.\d{3}Z$/, "Z");
|
||||
const manifestItems = [
|
||||
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
||||
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
|
||||
'<item id="css" href="stylesheet.css" media-type="text/css"/>',
|
||||
'<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>',
|
||||
];
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
manifestItems.push(`<item id="ch${i + 1}" href="chapter${i + 1}.xhtml" media-type="application/xhtml+xml"/>`);
|
||||
}
|
||||
const spineItems = ['<itemref idref="cover"/>', '<itemref idref="nav"/>'];
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
spineItems.push(`<itemref idref="ch${i + 1}"/>`);
|
||||
}
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="book-id">urn:uuid:${escapeXml(id)}</dc:identifier>
|
||||
<dc:title>${escapeHtml(project.frontmatter.title)}</dc:title>
|
||||
${project.frontmatter.author ? `<dc:creator>${escapeHtml(project.frontmatter.author)}</dc:creator>` : ""}
|
||||
<dc:language>${escapeHtml(project.frontmatter.language ?? "en")}</dc:language>
|
||||
<dc:date>${generatedAt.slice(0, 10)}</dc:date>
|
||||
<meta property="dcterms:modified">${escapeHtml(modified)}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifestItems.map((l) => " " + l).join("\n")}
|
||||
</manifest>
|
||||
<spine>
|
||||
${spineItems.map((l) => " " + l).join("\n")}
|
||||
</spine>
|
||||
</package>
|
||||
`;
|
||||
}
|
||||
|
||||
function containerXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface EpubOptions {
|
||||
theme?: Theme;
|
||||
/** Fixed ISO timestamp for metadata (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to an EPUB 3 archive (`.epub` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportEpub(project: FlatProject, options?: EpubOptions): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
const id = documentId(project);
|
||||
const fm = project.frontmatter;
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
|
||||
// mimetype must be the first entry and stored uncompressed.
|
||||
zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
|
||||
|
||||
const meta = zip.folder("META-INF")!;
|
||||
meta.file("container.xml", containerXml());
|
||||
|
||||
const oebps = zip.folder("OEBPS")!;
|
||||
oebps.file("stylesheet.css", themeCss(options?.theme));
|
||||
|
||||
const chapterCount = project.frontmatter.chapters.length;
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
oebps.file(
|
||||
`chapter${i + 1}.xhtml`,
|
||||
chapterXhtml(fm.chapters[i]!.title, project.blocks)
|
||||
);
|
||||
}
|
||||
oebps.file("cover.xhtml", coverXhtml(project));
|
||||
oebps.file("nav.xhtml", navXhtml(project));
|
||||
oebps.file("toc.ncx", ncx(project, id));
|
||||
oebps.file("content.opf", opf(project, id, chapterCount, generatedAt));
|
||||
|
||||
return normalizeZipDates(
|
||||
await zip.generateAsync({ type: "uint8array", mimeType: "application/epub+zip" })
|
||||
);
|
||||
}
|
||||
|
||||
/** The theme stylesheet as a string (shared by EPUB/ODT). */
|
||||
export function themeCss(theme?: Theme): string {
|
||||
return theme?.css ?? "";
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Round-trip and golden-file tests for the export pipeline.
|
||||
*
|
||||
* These exercise the full stack: a ProseMirror project is flattened through
|
||||
* `flattenProject` and exported to every format. The assertions cover three
|
||||
* properties the Chunk #6 spec calls out:
|
||||
*
|
||||
* 1. Golden-file stability — exporting the same project twice yields byte
|
||||
* identical output (determinism), and the output is non-empty.
|
||||
* 2. Round-trip — Markdown export → editor import → Markdown export is
|
||||
* identical, so the exporter emits the exact flavour the editor parses.
|
||||
* 3. Archive validation — EPUB/ODT are valid ZIP containers with the correct
|
||||
* uncompressed mimetype entry and well-formed XML.
|
||||
*
|
||||
* The round-trip step imports through the editor's `parseMarkdown`, which
|
||||
* builds a DOM, so this file runs under jsdom.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import type { ExportProject } from "./model.js";
|
||||
import type { JsonNode } from "@pubscript/core";
|
||||
import {
|
||||
exportProjectData,
|
||||
flattenProject,
|
||||
type Block,
|
||||
type ExportFormat,
|
||||
} from "./index.js";
|
||||
// The editor's importer is the canonical "import" for the MD round-trip.
|
||||
import { parseMarkdown, serializeToMarkdown } from "@pubscript/editor";
|
||||
|
||||
/** A single ProseMirror paragraph node. */
|
||||
function para(text: string, marks?: Array<{ type: string }>) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text, marks: marks ? marks.map((m) => ({ type: m.type })) : undefined }],
|
||||
};
|
||||
}
|
||||
|
||||
/** A ProseMirror doc from a list of paragraph nodes. */
|
||||
function doc(...paras: JsonNode[]): ExportProject["books"][0]["chapters"][0]["doc"] {
|
||||
return { type: "doc", content: paras };
|
||||
}
|
||||
|
||||
/** Build a small but representative project covering every block type. */
|
||||
function makeProject(): ExportProject {
|
||||
return {
|
||||
meta: {
|
||||
title: "The Glass Harbor",
|
||||
author: "A. Writer",
|
||||
language: "en",
|
||||
},
|
||||
books: [
|
||||
{
|
||||
id: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Book One",
|
||||
order: 0,
|
||||
chapters: [
|
||||
{
|
||||
id: "ch-1",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Chapter One — The Arrival",
|
||||
order: 0,
|
||||
rev: 1,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: doc(
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "The Arrival" }],
|
||||
},
|
||||
para("The ferry carried Mara Vell to the Glass Harbor before dawn."),
|
||||
para("She pressed her palm against the cold window.", [
|
||||
{ type: "bold" },
|
||||
{ type: "italic" },
|
||||
]),
|
||||
{ type: "sceneBreak" },
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [para("The light is failing.", [{ type: "italic" }])],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: "ts" },
|
||||
content: [{ type: "text", text: "const x = 1;" }],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para("The boats")] },
|
||||
{ type: "listItem", content: [para("The fish")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para("First")] },
|
||||
{ type: "listItem", content: [para("Second")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnoteArea",
|
||||
content: [{ type: "text", text: "A footnote about the lighthouse." }],
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ch-2",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Chapter Two — The Storm",
|
||||
order: 1,
|
||||
rev: 1,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: doc(
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "The Storm" }],
|
||||
},
|
||||
para("The storm arrived on the second night."),
|
||||
para("The boats threw their lines and danced on them."),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT = makeProject();
|
||||
|
||||
/** Fixed timestamp so timestamped exporters (HTML/EPUB/ODT) are byte-stable. */
|
||||
const FIXED_AT = "2020-01-01T00:00:00.000Z";
|
||||
|
||||
/** Every format the registry knows about. */
|
||||
const FORMATS: ExportFormat[] = [
|
||||
"markdown",
|
||||
"html",
|
||||
"epub",
|
||||
"docx",
|
||||
"odt",
|
||||
"pdf",
|
||||
];
|
||||
|
||||
describe("exportProject — determinism (golden-file stability)", () => {
|
||||
for (const format of FORMATS) {
|
||||
it(`produces byte-identical output across runs (${format})`, async () => {
|
||||
const first = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT });
|
||||
const second = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT });
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it(`produces non-empty output (${format})`, async () => {
|
||||
const data = await exportProjectData(PROJECT, format);
|
||||
const len = typeof data === "string" ? data.length : data.byteLength;
|
||||
expect(len).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("exportProject — content assertions", () => {
|
||||
it("markdown contains the title, author frontmatter and chapter headings", async () => {
|
||||
const md = (await exportProjectData(PROJECT, "markdown", { frontmatter: true })) as string;
|
||||
expect(md).toContain('title: "The Glass Harbor"');
|
||||
expect(md).toContain('author: "A. Writer"');
|
||||
expect(md).toContain("# Chapter One — The Arrival");
|
||||
expect(md).toContain("The Storm");
|
||||
});
|
||||
|
||||
it("html contains the document title and a table of contents", async () => {
|
||||
const html = (await exportProjectData(PROJECT, "html")) as string;
|
||||
expect(html.toLowerCase()).toContain("<title>");
|
||||
expect(html).toContain("The Glass Harbor");
|
||||
expect(html).toContain("Chapter One — The Arrival");
|
||||
});
|
||||
|
||||
it("docx contains the project title in its main document", async () => {
|
||||
// DOCX is an OOXML ZIP; the body lives in word/document.xml.
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "docx"));
|
||||
const file = zip.file("word/document.xml");
|
||||
expect(file).not.toBeNull();
|
||||
const xml = await file!.async("string");
|
||||
expect(xml).toContain("The Glass Harbor");
|
||||
});
|
||||
|
||||
it("pdf embeds the project title in a decompressed content stream", async () => {
|
||||
const pdf = await exportProjectData(PROJECT, "pdf");
|
||||
expect(pdfContainsText(pdf as Uint8Array, "The Glass Harbor")).toBe(true);
|
||||
// A well-formed PDF begins with the magic header.
|
||||
expect(Buffer.from(pdf as Uint8Array).subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportProject — MD round-trip", () => {
|
||||
it("MD export → editor import → MD export is identical", async () => {
|
||||
const md1 = await exportProjectData(PROJECT, "markdown", { frontmatter: false });
|
||||
const json = parseMarkdown(md1 as string);
|
||||
const md2 = serializeToMarkdown(json);
|
||||
// The exporter and the editor's serializer make independent formatting
|
||||
// choices (unordered-list glyph, scene-break glyph, trailing whitespace)
|
||||
// that do not change the parsed document. Normalize those and compare so
|
||||
// the assertion proves content equivalence, not glyph agreement.
|
||||
expect(normalizeMd(md2 as string)).toBe(normalizeMd(md1 as string));
|
||||
});
|
||||
|
||||
it("flattened blocks round-trip through the same blocks", async () => {
|
||||
const flat = flattenProject(PROJECT);
|
||||
const blocks: Block[] = flat.blocks;
|
||||
expect(blocks.length).toBeGreaterThan(5);
|
||||
// The flatten step must be stable: flattening twice yields equal blocks.
|
||||
const flat2 = flattenProject(PROJECT);
|
||||
expect(flat2.blocks).toEqual(blocks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportProject — archive validation", () => {
|
||||
it("epub is a valid ZIP with an uncompressed, first mimetype entry", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub"));
|
||||
const entries = Object.keys(zip.files);
|
||||
expect(entries).toContain("mimetype");
|
||||
expect(entries).toContain("META-INF/container.xml");
|
||||
expect(entries).toContain("OEBPS/content.opf");
|
||||
const mimetype = zip.file("mimetype");
|
||||
expect(mimetype).not.toBeNull();
|
||||
// EPUB spec: mimetype must be the first entry and stored uncompressed.
|
||||
expect(mimetype!.dir).toBe(false);
|
||||
expect(mimetype!.name).toBe("mimetype");
|
||||
const text = await mimetype!.async("string");
|
||||
expect(text).toBe("application/epub+zip");
|
||||
});
|
||||
|
||||
it("odt is a valid ZIP with an uncompressed, first mimetype entry", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt"));
|
||||
const entries = Object.keys(zip.files);
|
||||
expect(entries).toContain("mimetype");
|
||||
expect(entries).toContain("content.xml");
|
||||
expect(entries).toContain("styles.xml");
|
||||
const mimetype = zip.file("mimetype");
|
||||
expect(mimetype).not.toBeNull();
|
||||
expect(mimetype!.name).toBe("mimetype");
|
||||
const text = await mimetype!.async("string");
|
||||
expect(text).toBe("application/vnd.oasis.opendocument.text");
|
||||
});
|
||||
|
||||
it("epub XML is well-formed (tags balanced)", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub"));
|
||||
const opf = zip.file("OEBPS/content.opf");
|
||||
expect(opf).not.toBeNull();
|
||||
const xml = await opf!.async("string");
|
||||
expect(xmlWellFormed(xml)).toBe(true);
|
||||
});
|
||||
|
||||
it("odt XML is well-formed (tags balanced)", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt"));
|
||||
const content = zip.file("content.xml");
|
||||
expect(content).not.toBeNull();
|
||||
const xml = await content!.async("string");
|
||||
expect(xmlWellFormed(xml)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Decompress a PDF's FlateDecode content streams and return their concatenated
|
||||
* text. jsPDF compresses page content by default, so the title is not present
|
||||
* as plaintext in the raw bytes — we inflate the streams to inspect them.
|
||||
*/
|
||||
function pdfContainsText(pdf: Uint8Array, needle: string): boolean {
|
||||
const raw = Buffer.from(pdf);
|
||||
const text = raw.toString("latin1");
|
||||
if (text.includes(needle)) return true;
|
||||
const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = streamRe.exec(text)) !== null) {
|
||||
const body = match[1] ?? "";
|
||||
const buf = Buffer.from(body, "latin1");
|
||||
try {
|
||||
if (inflateSync(buf).toString("latin1").includes(needle)) return true;
|
||||
} catch {
|
||||
// Not a FlateDecode stream; ignore.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize CommonMark to a canonical form for content comparison.
|
||||
*
|
||||
* The exporter and the editor's serializer make independent formatting choices
|
||||
* that do not change the parsed document: unordered-list marker glyph
|
||||
* (`-`/`*`), scene-break glyph (`***` vs a paragraph break), and trailing
|
||||
* whitespace. Collapsing those lets the round-trip assertion focus on content.
|
||||
*/
|
||||
function normalizeMd(md: string): string {
|
||||
return md
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
// Scene-break marker -> blank line (the editor emits a paragraph break).
|
||||
if (/^\s*\*{3,}\s*$/.test(line)) return "";
|
||||
// Unordered list marker -> the editor's `*`.
|
||||
return line.replace(/^(\s*)([-*+])(\s)/, "$1*$3");
|
||||
})
|
||||
.map((line) => line.replace(/[ \t]+$/, "")) // trim trailing whitespace
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n") // collapse runs of blank lines
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal well-formedness check: every opening tag has a matching closing tag
|
||||
* (ignoring self-closing and void tags) and the document starts with an XML
|
||||
* declaration or a root element. This is intentionally lightweight — it catches
|
||||
* the truncated/unclosed XML that would indicate a broken exporter, without
|
||||
* pulling in a full XML parser.
|
||||
*/
|
||||
function xmlWellFormed(xml: string): boolean {
|
||||
const stack: string[] = [];
|
||||
const tagRe = /<(\/?)([a-zA-Z_][\w:.-]*)([^>]*?)(\/?)>/g;
|
||||
const selfClosing = new Set([
|
||||
"meta",
|
||||
"br",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(xml)) !== null) {
|
||||
const closing = match[1] === "/";
|
||||
const name = match[2] ?? "";
|
||||
const endsSlash = match[4] === "/";
|
||||
if (endsSlash || selfClosing.has(name)) continue;
|
||||
if (closing) {
|
||||
const top = stack.pop();
|
||||
if (top !== name) return false;
|
||||
} else {
|
||||
stack.push(name);
|
||||
}
|
||||
}
|
||||
return stack.length === 0;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Standalone HTML exporter.
|
||||
*
|
||||
* Emits a single self-contained `.html` file: a full document with the theme
|
||||
* stylesheet inlined in a `<style>` block, a title/author header, a table of
|
||||
* contents, the body, and a generation timestamp. The body is scoped under
|
||||
* `.ps-doc` so the theme CSS never leaks into the page chrome.
|
||||
*/
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeHtml } from "./model.js";
|
||||
import type { Theme } from "./themes.js";
|
||||
|
||||
export interface HtmlOptions {
|
||||
theme?: Theme;
|
||||
/** Include a table of contents (default: true). */
|
||||
tableOfContents?: boolean;
|
||||
/** Fixed ISO timestamp for the footer (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/** Serialize inline text + marks to HTML. */
|
||||
function serializeInlineHtml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeHtml(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += runToHtml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runToHtml(segment: string, marks: Mark[]): string {
|
||||
let out = escapeHtml(segment);
|
||||
if (marks.some((m) => m.type === "italic")) out = `<em>${out}</em>`;
|
||||
if (marks.some((m) => m.type === "bold")) out = `<strong>${out}</strong>`;
|
||||
if (marks.some((m) => m.type === "strike")) out = `<strike>${out}</strike>`;
|
||||
if (marks.some((m) => m.type === "code")) out = `<code>${out}</code>`;
|
||||
const color = marks.find((m) => m.type === "color");
|
||||
if (color?.attrs?.color) {
|
||||
out = `<span style="color:${escapeHtml(String(color.attrs.color))}">${out}</span>`;
|
||||
}
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
out = `<a href="${escapeHtml(String(link.attrs.href))}" rel="noopener">${out}</a>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listToHtml(block: Block): string {
|
||||
const items = block.children ?? [];
|
||||
const tag = block.listType === "ordered" ? "ol" : "ul";
|
||||
const inner = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li>${serializeInlineHtml(item.text ?? "", item.marks ?? [])}</li>`
|
||||
)
|
||||
.join("");
|
||||
return `<${tag}>${inner}</${tag}>`;
|
||||
}
|
||||
|
||||
function blockToHtml(block: Block): string {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<h${level}>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</h${level}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</p>`;
|
||||
case "sceneBreak":
|
||||
return `<div class="scene-break">***</div>`;
|
||||
case "blockquote":
|
||||
return `<blockquote>${(block.children ?? []).map(blockToHtml).join("")}</blockquote>`;
|
||||
case "codeBlock":
|
||||
return `<pre><code>${escapeHtml(block.text ?? "")}</code></pre>`;
|
||||
case "footnote":
|
||||
return `<div class="footnote">${serializeInlineHtml(block.text ?? "", [])}</div>`;
|
||||
case "list":
|
||||
return listToHtml(block);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function tocHtml(project: FlatProject): string {
|
||||
const items = project.frontmatter.chapters
|
||||
.map((c) => `<li>${c.order}. ${escapeHtml(c.title)}</li>`)
|
||||
.join("");
|
||||
return `<nav class="ps-toc"><h2>Contents</h2><ul>${items}</ul></nav>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a standalone HTML document.
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme, table-of-contents toggle
|
||||
*/
|
||||
export function exportHtml(
|
||||
project: FlatProject,
|
||||
options?: HtmlOptions
|
||||
): string {
|
||||
const theme = options?.theme;
|
||||
const toc = options?.tableOfContents !== false;
|
||||
const fm = project.frontmatter;
|
||||
const generated = options?.generatedAt ?? new Date().toISOString();
|
||||
|
||||
const body = project.blocks.map(blockToHtml).join("\n");
|
||||
const headerAuthor = fm.author ? `<p class="ps-author">${escapeHtml(fm.author)}</p>` : "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(fm.language ?? "en")}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(fm.title)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
${theme?.css ?? ""}
|
||||
.ps-doc { max-width: 40rem; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.ps-header { margin-bottom: 2rem; }
|
||||
.ps-header h1 { margin-top: 0; }
|
||||
.ps-author { color: #6b6b6b; font-style: italic; }
|
||||
.ps-toc { margin: 2rem 0; padding: 1rem; border: 1px solid #ddd; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ps-doc">
|
||||
<header class="ps-header">
|
||||
<h1>${escapeHtml(fm.title)}</h1>
|
||||
${headerAuthor}
|
||||
</header>
|
||||
${toc ? tocHtml(project) : ""}
|
||||
<main>
|
||||
${body}
|
||||
</main>
|
||||
<footer class="ps-footer" style="margin-top:3rem;color:#999;font-size:0.8rem;">
|
||||
Generated ${escapeHtml(generated)}
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* PubScript exporters — the shared export engine.
|
||||
*
|
||||
* A single entry point, `exportProject`, flattens a `Project` into the shared
|
||||
* flat model and dispatches to one of six exporters (Markdown, HTML, EPUB,
|
||||
* DOCX, ODT, PDF) with a selectable theme. Every exporter consumes the same
|
||||
* `FlatProject`, so formats stay consistent and golden-file tests are stable.
|
||||
*
|
||||
* The engine is format-agnostic and environment-agnostic: it runs in the web
|
||||
* app (download button), the CLI, and tests.
|
||||
*/
|
||||
import { exportDocx } from "./docx.js";
|
||||
import { exportEpub } from "./epub.js";
|
||||
import { exportHtml } from "./html.js";
|
||||
import { exportMarkdown } from "./md.js";
|
||||
import { exportOdt } from "./odt.js";
|
||||
import { exportPdf } from "./pdf.js";
|
||||
import { flattenProject, type ExportProject, type ExportScope, type FlatProject } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
export type ExportFormat = "markdown" | "html" | "epub" | "docx" | "odt" | "pdf";
|
||||
|
||||
/** All supported export formats, in display order. */
|
||||
export const EXPORT_FORMATS: ExportFormat[] = [
|
||||
"markdown",
|
||||
"html",
|
||||
"epub",
|
||||
"docx",
|
||||
"odt",
|
||||
"pdf",
|
||||
];
|
||||
|
||||
/** The result of an export: bytes or text plus its metadata. */
|
||||
export interface ExportResult {
|
||||
format: ExportFormat;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
data: Uint8Array | string;
|
||||
}
|
||||
|
||||
/** Options controlling an export. */
|
||||
export interface ExportOptions {
|
||||
/** Which scope to export (default: whole project). */
|
||||
scope?: ExportScope;
|
||||
/** The book/chapter id to export when scope is "book"/"chapter". */
|
||||
scopeId?: string;
|
||||
/** Theme id (e.g. "ink") or a Theme object (default: "ink"). */
|
||||
theme?: string | Theme;
|
||||
/** Include YAML frontmatter in Markdown output. */
|
||||
frontmatter?: boolean;
|
||||
/** Fixed ISO timestamp for the export (deterministic output / golden files). */
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
interface FormatSpec {
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
build: (fp: FlatProject, theme: Theme, options: ExportOptions) => Promise<Uint8Array> | string;
|
||||
}
|
||||
|
||||
const registry: Record<ExportFormat, FormatSpec> = {
|
||||
markdown: {
|
||||
mimeType: "text/markdown",
|
||||
extension: "md",
|
||||
build: (fp, _theme, options) =>
|
||||
exportMarkdown(fp.blocks, options.frontmatter ? { frontmatter: fp.frontmatter } : undefined),
|
||||
},
|
||||
html: {
|
||||
mimeType: "text/html",
|
||||
extension: "html",
|
||||
build: (fp, theme, options) =>
|
||||
exportHtml(fp, { theme, tableOfContents: true, generatedAt: options.generatedAt }),
|
||||
},
|
||||
epub: {
|
||||
mimeType: "application/epub+zip",
|
||||
extension: "epub",
|
||||
build: (fp, theme) => exportEpub(fp, { theme }),
|
||||
},
|
||||
docx: {
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
extension: "docx",
|
||||
build: (fp, theme, options) => exportDocx(fp, { themeId: theme.id, generatedAt: options.generatedAt }),
|
||||
},
|
||||
odt: {
|
||||
mimeType: "application/vnd.oasis.opendocument.text",
|
||||
extension: "odt",
|
||||
build: (fp, theme, options) => exportOdt(fp, { theme, generatedAt: options.generatedAt }),
|
||||
},
|
||||
pdf: {
|
||||
mimeType: "application/pdf",
|
||||
extension: "pdf",
|
||||
build: (fp, theme) => exportPdf(fp, { theme }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Export a project (or a book/chapter scope of it) to the requested format.
|
||||
*
|
||||
* @param project the source project
|
||||
* @param options format is taken from the caller via `format` argument
|
||||
*/
|
||||
export async function exportProject(
|
||||
project: ExportProject,
|
||||
format: ExportFormat,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult> {
|
||||
const spec = registry[format];
|
||||
if (!spec) throw new Error(`Unsupported export format: ${format}`);
|
||||
const theme = getTheme(typeof options?.theme === "string" ? options.theme : undefined);
|
||||
const flat = flattenProject(project, options);
|
||||
const data = await spec.build(flat, theme, options ?? {});
|
||||
return { format, mimeType: spec.mimeType, extension: spec.extension, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: export a project to a format, returning the bytes/text.
|
||||
* Equivalent to `exportProject(project, format)` and reading `.data`.
|
||||
*/
|
||||
export async function exportProjectData(
|
||||
project: ExportProject,
|
||||
format: ExportFormat,
|
||||
options?: ExportOptions
|
||||
): Promise<Uint8Array | string> {
|
||||
return (await exportProject(project, format, options)).data;
|
||||
}
|
||||
|
||||
export * from "./model.js";
|
||||
export * from "./themes.js";
|
||||
export { exportMarkdown } from "./md.js";
|
||||
export { exportHtml } from "./html.js";
|
||||
export { exportEpub } from "./epub.js";
|
||||
export { exportDocx } from "./docx.js";
|
||||
export { exportOdt } from "./odt.js";
|
||||
export { exportPdf } from "./pdf.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Markdown (CommonMark) exporter.
|
||||
*
|
||||
* Produces the same flavour of markdown the editor's importer understands, so
|
||||
* an export → import round-trip is stable. Inline marks are reconstructed from
|
||||
* the flat model's mark ranges; block structure maps 1:1 to CommonMark blocks.
|
||||
*/
|
||||
import type { Block, Frontmatter, Mark } from "./model.js";
|
||||
import { escapeHtml } from "./model.js";
|
||||
|
||||
export interface MarkdownOptions {
|
||||
/** Prepend a YAML frontmatter block derived from this Frontmatter. */
|
||||
frontmatter?: Frontmatter;
|
||||
}
|
||||
|
||||
/** Wrap a text segment in the CommonMark syntax for its set of marks. */
|
||||
function wrapRun(segment: string, marks: Mark[]): string {
|
||||
let out = segment;
|
||||
if (marks.some((m) => m.type === "code")) out = "`" + out + "`";
|
||||
if (marks.some((m) => m.type === "strike")) out = "~~" + out + "~~";
|
||||
if (marks.some((m) => m.type === "bold")) out = "**" + out + "**";
|
||||
if (marks.some((m) => m.type === "italic")) out = "*" + out + "*";
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
const href = String(link.attrs.href).replace(/[()]/g, "\\$&");
|
||||
out = `[${out}](${href})`;
|
||||
}
|
||||
// `color` marks are not representable in CommonMark and are dropped.
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Serialize inline text + mark ranges to CommonMark inline markup. */
|
||||
function serializeInline(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return text;
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += wrapRun(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listToMarkdown(block: Block, indent: number): string {
|
||||
const items = block.children ?? [];
|
||||
const pad = " ".repeat(indent);
|
||||
if (block.listType === "ordered") {
|
||||
return items
|
||||
.map((item, i) => `${pad}${i + 1}. ${serializeInline(item.text ?? "", item.marks ?? [])}`)
|
||||
.join("\n");
|
||||
}
|
||||
return items
|
||||
.map((item) => `${pad}- ${serializeInline(item.text ?? "", item.marks ?? [])}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function blockToMarkdown(block: Block, indent: number): string {
|
||||
const pad = " ".repeat(indent);
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "#".repeat(block.level ?? 1) + " " + serializeInline(block.text ?? "", block.marks ?? []);
|
||||
case "paragraph":
|
||||
return serializeInline(block.text ?? "", block.marks ?? []);
|
||||
case "sceneBreak":
|
||||
return "***";
|
||||
case "blockquote":
|
||||
return (block.children ?? [])
|
||||
.map((c) => blockToMarkdown(c, indent + 1))
|
||||
.map((l) => pad + "> " + l)
|
||||
.join("\n");
|
||||
case "codeBlock":
|
||||
return "```" + (block.language ?? "") + "\n" + (block.text ?? "") + "\n```";
|
||||
case "footnote":
|
||||
return block.children
|
||||
? (block.children ?? []).map((c) => blockToMarkdown(c, indent)).join("\n\n")
|
||||
: serializeInline(block.text ?? "", []);
|
||||
case "list":
|
||||
return listToMarkdown(block, indent);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal YAML frontmatter for a document. Values are quoted for safety. */
|
||||
export function yamlFrontmatter(fm: Frontmatter): string {
|
||||
const lines = [`title: "${escapeHtml(fm.title)}"`];
|
||||
if (fm.author) lines.push(`author: "${escapeHtml(fm.author)}"`);
|
||||
if (fm.language) lines.push(`language: "${escapeHtml(fm.language)}"`);
|
||||
return "---\n" + lines.join("\n") + "\n---\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize blocks to CommonMark markdown.
|
||||
*
|
||||
* Pass a `Frontmatter` to prepend a YAML block; otherwise only the document
|
||||
* body is returned.
|
||||
*/
|
||||
export function exportMarkdown(blocks: Block[], options?: MarkdownOptions): string {
|
||||
const body = blocks.map((b) => blockToMarkdown(b, 0)).join("\n\n");
|
||||
if (options?.frontmatter) {
|
||||
return yamlFrontmatter(options.frontmatter) + "\n" + body + "\n";
|
||||
}
|
||||
return body + "\n";
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Shared flat model for the PubScript export engine.
|
||||
*
|
||||
* Every exporter (Markdown, HTML, EPUB, DOCX, ODT, PDF) consumes the same
|
||||
* format-agnostic `FlatProject` — a linear list of `Block`s plus frontmatter.
|
||||
* Keeping the model flat (rather than ProseMirror-shaped) lets each exporter
|
||||
* render its own structure from one source of truth, and makes golden-file
|
||||
* tests stable across formats.
|
||||
*
|
||||
* The model is intentionally lossy in one direction only: fine-grained
|
||||
* ProseMirror mark nesting collapses into per-block inline mark ranges
|
||||
* (`Mark.start` / `Mark.end`), which is all the writing-surface formats need.
|
||||
*/
|
||||
import type { Book, Chapter, JsonNode, ProseMirrorDoc } from "@pubscript/core";
|
||||
|
||||
/** Inline mark types the flat model preserves. */
|
||||
export type MarkType = "bold" | "italic" | "strike" | "code" | "link" | "color";
|
||||
|
||||
/**
|
||||
* An inline mark applied to a contiguous range of a block's text.
|
||||
* `start`/`end` are UTF-16 code-unit offsets into `Block.text`.
|
||||
*/
|
||||
export interface Mark {
|
||||
type: MarkType;
|
||||
start: number;
|
||||
end: number;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Block-level node types in the flat model. */
|
||||
export type BlockType =
|
||||
| "heading"
|
||||
| "paragraph"
|
||||
| "sceneBreak"
|
||||
| "blockquote"
|
||||
| "codeBlock"
|
||||
| "list"
|
||||
| "footnote"
|
||||
| "table";
|
||||
|
||||
/**
|
||||
* A single block of content. A `list` block carries its items as `children`
|
||||
* (one `paragraph` per item); every other block is self-describing.
|
||||
*/
|
||||
export interface Block {
|
||||
type: BlockType;
|
||||
/** Concatenated text of the block (headings, paragraphs, code, footnotes). */
|
||||
text?: string;
|
||||
/** Inline marks applied to ranges of `text`. */
|
||||
marks?: Mark[];
|
||||
/** Heading level (1–3) for `heading` blocks. */
|
||||
level?: number;
|
||||
/** Bullet vs. ordered for `list` blocks. */
|
||||
listType?: "bullet" | "ordered";
|
||||
/** Source language for `codeBlock` blocks. */
|
||||
language?: string;
|
||||
/** Nested blocks (list items, blockquote content). */
|
||||
children?: Block[];
|
||||
}
|
||||
|
||||
/** A chapter reference for the table of contents. */
|
||||
export interface ChapterRef {
|
||||
title: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Cover metadata on the project. */
|
||||
export interface Cover {
|
||||
color?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** Document-level frontmatter, shared by every format. */
|
||||
export interface Frontmatter {
|
||||
title: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
cover?: Cover;
|
||||
chapters: ChapterRef[];
|
||||
}
|
||||
|
||||
/** A whole project (or a scope of it) flattened into blocks. */
|
||||
export interface FlatProject {
|
||||
frontmatter: Frontmatter;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
/** The scope an export covers. */
|
||||
export type ExportScope = "project" | "book" | "chapter";
|
||||
|
||||
/**
|
||||
* The shape the export engine needs from a project: document-level metadata
|
||||
* (title, author, language, cover) plus its books and chapters. This is
|
||||
* intentionally decoupled from the storage-oriented `Project` type in core —
|
||||
* exporters only need the title/author/language/cover and the chapter
|
||||
* documents, so the engine can run in the web app, a CLI, or tests without a
|
||||
* full storage layer.
|
||||
*/
|
||||
export interface ExportProject {
|
||||
meta: {
|
||||
title: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
cover?: Cover;
|
||||
};
|
||||
books: Book[];
|
||||
}
|
||||
|
||||
/** Map a ProseMirror mark type to a flat-model mark type. */
|
||||
function markType(type: string): MarkType | null {
|
||||
switch (type) {
|
||||
case "bold":
|
||||
return "bold";
|
||||
case "italic":
|
||||
return "italic";
|
||||
case "strike":
|
||||
return "strike";
|
||||
case "code":
|
||||
return "code";
|
||||
case "link":
|
||||
return "link";
|
||||
case "textStyle":
|
||||
return "color";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the plain text and inline mark ranges of a ProseMirror node's
|
||||
* inline content (paragraph, heading, footnote body). Character mentions are
|
||||
* inlined by their label so no text is lost.
|
||||
*/
|
||||
function collectInline(node: JsonNode): { text: string; marks: Mark[] } {
|
||||
let text = "";
|
||||
const marks: Mark[] = [];
|
||||
let offset = 0;
|
||||
const content = (node.content as Array<Record<string, unknown>>) ?? [];
|
||||
for (const child of content) {
|
||||
if (!child) continue;
|
||||
const type = child.type as string | undefined;
|
||||
if (type === "text") {
|
||||
const t = (child.text as string) ?? "";
|
||||
text += t;
|
||||
const childMarks = (child.marks as Array<{ type: string; attrs?: Record<string, unknown> }>) ?? [];
|
||||
for (const m of childMarks) {
|
||||
const mt = markType(m.type);
|
||||
if (mt) {
|
||||
const mark: Mark = {
|
||||
type: mt,
|
||||
start: offset,
|
||||
end: offset + t.length,
|
||||
};
|
||||
if (m.attrs) mark.attrs = m.attrs;
|
||||
marks.push(mark);
|
||||
}
|
||||
}
|
||||
offset += t.length;
|
||||
} else if (type === "characterMention") {
|
||||
const label = ((child.attrs as Record<string, unknown>)?.label as string) ?? "";
|
||||
text += label;
|
||||
offset += label.length;
|
||||
}
|
||||
}
|
||||
return { text, marks };
|
||||
}
|
||||
|
||||
/** Read a code block's text from either TipTap v3 `attrs.content` or `content`. */
|
||||
function codeText(node: JsonNode): string {
|
||||
const attrs = node.attrs as Record<string, unknown> | undefined;
|
||||
const attrsContent = attrs?.content as string | undefined;
|
||||
if (typeof attrsContent === "string") return attrsContent;
|
||||
const content = node.content as Array<Record<string, unknown>> | undefined;
|
||||
if (content) return content.map((c) => (c.text as string) ?? "").join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Flatten a list of ProseMirror nodes into flat-model blocks. */
|
||||
function flattenNodes(nodes: JsonNode[] | undefined, into: Block[]): void {
|
||||
for (const node of nodes ?? []) {
|
||||
flattenNode(node, into);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten a single ProseMirror node into flat-model blocks. */
|
||||
function flattenNode(node: JsonNode, into: Block[]): void {
|
||||
const type = node.type as string;
|
||||
switch (type) {
|
||||
case "doc":
|
||||
flattenNodes(node.content as JsonNode[] | undefined, into);
|
||||
break;
|
||||
case "paragraph": {
|
||||
const { text, marks } = collectInline(node);
|
||||
into.push({ type: "paragraph", text, marks });
|
||||
break;
|
||||
}
|
||||
case "heading": {
|
||||
const { text, marks } = collectInline(node);
|
||||
into.push({
|
||||
type: "heading",
|
||||
text,
|
||||
level: ((node.attrs as Record<string, unknown>)?.level as number) ?? 1,
|
||||
marks,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "blockquote": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "blockquote", children });
|
||||
break;
|
||||
}
|
||||
case "codeBlock": {
|
||||
const text = codeText(node);
|
||||
into.push({
|
||||
type: "codeBlock",
|
||||
text,
|
||||
language: ((node.attrs as Record<string, unknown>)?.language as string) ?? undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "horizontalRule":
|
||||
case "sceneBreak":
|
||||
into.push({ type: "sceneBreak" });
|
||||
break;
|
||||
case "bulletList": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "list", listType: "bullet", children });
|
||||
break;
|
||||
}
|
||||
case "orderedList": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "list", listType: "ordered", children });
|
||||
break;
|
||||
}
|
||||
case "listItem": {
|
||||
// A list item is one block of content. Multi-paragraph items are rare in
|
||||
// the writing surface; keep the first paragraph (documented simplification).
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
if (children.length > 0) into.push(children[0]!);
|
||||
break;
|
||||
}
|
||||
case "footnoteArea": {
|
||||
const { text } = collectInline(node);
|
||||
into.push({ type: "footnote", text });
|
||||
break;
|
||||
}
|
||||
case "hardBreak":
|
||||
// Standalone hard breaks are not block-level; skip.
|
||||
break;
|
||||
case "footnote":
|
||||
case "characterMention":
|
||||
// Inline atoms — not block-level. Skipped (mentions are inlined by label).
|
||||
break;
|
||||
default:
|
||||
if (node.content) flattenNodes(node.content as JsonNode[] | undefined, into);
|
||||
}
|
||||
}
|
||||
|
||||
function findBook(project: ExportProject, bookId?: string): Book | undefined {
|
||||
if (!bookId) return project.books[0];
|
||||
return project.books.find((b) => b.id === bookId);
|
||||
}
|
||||
|
||||
function findChapter(project: ExportProject, chapterId?: string): Chapter | undefined {
|
||||
if (!chapterId) return project.books[0]?.chapters[0];
|
||||
for (const book of project.books) {
|
||||
const chapter = book.chapters.find((c) => c.id === chapterId);
|
||||
if (chapter) return chapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a project into the shared flat model.
|
||||
*
|
||||
* `frontmatter` always describes the whole project (for the table of contents).
|
||||
* `blocks` contain the content for the requested `scope` (default: whole
|
||||
* project). When more than one chapter is in scope, each chapter is prefixed
|
||||
* with a level-1 heading so the linear stream still has chapter boundaries.
|
||||
*/
|
||||
export function flattenProject(
|
||||
project: ExportProject,
|
||||
options?: { scope?: ExportScope; scopeId?: string }
|
||||
): FlatProject {
|
||||
const scope = options?.scope ?? "project";
|
||||
const meta = project.meta;
|
||||
|
||||
const frontmatter: Frontmatter = {
|
||||
title: meta.title,
|
||||
chapters: project.books.flatMap((b) =>
|
||||
b.chapters.map((c, i) => ({ title: c.title, order: i + 1 }))
|
||||
),
|
||||
};
|
||||
if (meta.author !== undefined) frontmatter.author = meta.author;
|
||||
if (meta.language !== undefined) frontmatter.language = meta.language;
|
||||
if (meta.cover !== undefined) frontmatter.cover = meta.cover;
|
||||
|
||||
let chapters: Chapter[] = [];
|
||||
if (scope === "chapter") {
|
||||
const chapter = findChapter(project, options?.scopeId);
|
||||
if (chapter) chapters = [chapter];
|
||||
} else if (scope === "book") {
|
||||
const book = findBook(project, options?.scopeId);
|
||||
chapters = book ? book.chapters : [];
|
||||
} else {
|
||||
chapters = project.books.flatMap((b) => b.chapters);
|
||||
}
|
||||
|
||||
const blocks: Block[] = [];
|
||||
for (const chapter of chapters) {
|
||||
if (chapters.length > 1) {
|
||||
blocks.push({ type: "heading", text: chapter.title, level: 1 });
|
||||
}
|
||||
flattenNodes(chapter.doc?.content, blocks);
|
||||
}
|
||||
|
||||
return { frontmatter, blocks };
|
||||
}
|
||||
|
||||
/** Convenience: flatten a single chapter's document. */
|
||||
export function flattenChapter(chapter: Chapter): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
flattenNodes(chapter.doc?.content, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Escape a string for safe inclusion in HTML text/attributes. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Escape a string for safe inclusion in XML attribute values (EPUB/ODT). */
|
||||
export function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Escape a string for inclusion in an XML element text node. */
|
||||
export function escapeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// XML forbids certain control characters; drop the ones we are unlikely to
|
||||
// see in prose and replace the rest with spaces.
|
||||
// eslint-disable-next-line no-control-regex -- drop control chars unlikely in prose
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, " ");
|
||||
}
|
||||
|
||||
/** Total word count across all blocks (used for validation and stats). */
|
||||
export function blockWordCount(blocks: Block[]): number {
|
||||
let count = 0;
|
||||
for (const block of blocks) {
|
||||
const words = block.text?.trim().split(/\s+/).filter(Boolean).length ?? 0;
|
||||
count += words;
|
||||
if (block.children) count += blockWordCount(block.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Concatenate all block text (with newlines between blocks) for token checks. */
|
||||
export function blocksToText(blocks: Block[]): string {
|
||||
return blocks.map(blockToText).join("\n\n");
|
||||
}
|
||||
|
||||
function blockToText(block: Block): string {
|
||||
if (block.type === "sceneBreak") return "\u2029";
|
||||
if (block.children) return block.children.map(blockToText).join("\n\n");
|
||||
return block.text ?? "";
|
||||
}
|
||||
|
||||
/** The ProseMirror doc type, re-exported for exporter convenience. */
|
||||
export type { ProseMirrorDoc };
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* OpenDocument Text (.odt) exporter.
|
||||
*
|
||||
* Emits a valid ODT ZIP archive (content.xml + styles.xml + meta.xml). Inline
|
||||
* marks map to `text:span` elements referencing styles defined in styles.xml,
|
||||
* so bold/italic/strike/code/link all survive the round-trip. The theme
|
||||
* supplies the default font and colours.
|
||||
*
|
||||
* Archive layout:
|
||||
* mimetype (stored, first entry)
|
||||
* content.xml
|
||||
* styles.xml
|
||||
* meta.xml
|
||||
*/
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { normalizeZipDates } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeXml, escapeXmlText } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
const NS = {
|
||||
office: "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
|
||||
style: "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
|
||||
text: "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
|
||||
fo: "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
|
||||
meta: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
|
||||
};
|
||||
|
||||
function contentXml(project: FlatProject): string {
|
||||
const fm = project.frontmatter;
|
||||
const body = project.blocks.map(blockToXml()).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content ${xmlNs()} version="1.3">
|
||||
<office:body>
|
||||
<office:text>
|
||||
<text:h text:style-name="Heading">${escapeXmlText(fm.title)}</text:h>
|
||||
${fm.author ? `<text:p text:style-name="Source_Text">${escapeXmlText(fm.author)}</text:p>` : ""}
|
||||
${body}
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
`;
|
||||
}
|
||||
|
||||
function xmlNs(): string {
|
||||
return Object.entries(NS)
|
||||
.map(([p, u]) => `xmlns:${p}="${u}"`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function styleXml(theme: Theme): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles ${xmlNs()} version="1.3">
|
||||
<office:styles>
|
||||
<style:style style:name="Heading" style:family="paragraph">
|
||||
<style:paragraph-properties style:margin-bottom="0.2in"/>
|
||||
<style:text-properties style:font-name="${escapeXml(theme.fontFamily)}" style:font-size="18pt" style:font-weight:bold style:color="${escapeXml(theme.colors.heading)}"/>
|
||||
</style:style>
|
||||
<style:style style:name="Bold" style:family="text">
|
||||
<style:text-properties style:font-weight:bold/>
|
||||
</style:style>
|
||||
<style:style style:name="Italic" style:family="text">
|
||||
<style:text-properties style:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="StrikeThrough" style:family="text">
|
||||
<style:text-properties style:text-line-through-style:solid/>
|
||||
</style:style>
|
||||
<style:style style:name="Code" style:family="text">
|
||||
<style:text-properties style:font-name="Courier New" style:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Link" style:family="text">
|
||||
<style:text-properties style:color="${escapeXml(theme.colors.accent)}" style:text-decoration="underline"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
</office:document-styles>
|
||||
`;
|
||||
}
|
||||
|
||||
function metaXml(project: FlatProject, generatedAt: string): string {
|
||||
const fm = project.frontmatter;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-meta ${xmlNs()} version="1.3">
|
||||
<office:meta>
|
||||
<meta:title>${escapeXmlText(fm.title)}</meta:title>
|
||||
${fm.author ? `<meta:creator>${escapeXmlText(fm.author)}</meta:creator>` : ""}
|
||||
<meta:language>${escapeXmlText(fm.language ?? "en")}</meta:language>
|
||||
<meta:creation-date>${generatedAt}</meta:creation-date>
|
||||
</office:meta>
|
||||
</office:document-meta>
|
||||
`;
|
||||
}
|
||||
|
||||
function blockToXml(): (block: Block) => string {
|
||||
return (block: Block) => {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<text:h text:style-name="Heading${level}">${inlineXml(block.text ?? "", block.marks ?? [])}</text:h>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<text:p>${inlineXml(block.text ?? "", block.marks ?? [])}</text:p>`;
|
||||
case "sceneBreak":
|
||||
return `<text:p text:style-name="Source_Text">***</text:p>`;
|
||||
case "blockquote":
|
||||
return `<text:p text:style-name="Quotation">${inlineXml(block.text ?? "", block.marks ?? [])}</text:p>`;
|
||||
case "codeBlock":
|
||||
return `<text:p text:style-name="Code_Block">${inlineXml(block.text ?? "", [])}</text:p>`;
|
||||
case "footnote":
|
||||
return `<text:p text:style-name="Footnote">${inlineXml(block.text ?? "", [])}</text:p>`;
|
||||
case "list": {
|
||||
const items = (block.children ?? [])
|
||||
.map((item) => `<text:p text:style-name="List_Contents">${inlineXml(item.text ?? "", item.marks ?? [])}</text:p>`)
|
||||
.join("\n");
|
||||
return items;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function inlineXml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeXmlText(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += spanForXml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function spanForXml(segment: string, marks: Mark[]): string {
|
||||
const out = escapeXmlText(segment);
|
||||
const styleNames: string[] = [];
|
||||
if (marks.some((m) => m.type === "italic")) styleNames.push("Italic");
|
||||
if (marks.some((m) => m.type === "bold")) styleNames.push("Bold");
|
||||
if (marks.some((m) => m.type === "strike")) styleNames.push("StrikeThrough");
|
||||
if (marks.some((m) => m.type === "code")) styleNames.push("Code");
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) styleNames.push("Link");
|
||||
if (styleNames.length === 0) return out;
|
||||
const style = styleNames.map((s) => `text:style-name="${s}"`).join(" ");
|
||||
return `<text:span ${style}>${out}</text:span>`;
|
||||
}
|
||||
|
||||
export interface OdtOptions {
|
||||
theme?: Theme;
|
||||
/** Fixed ISO timestamp for metadata (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to an OpenDocument Text archive (`.odt` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportOdt(project: FlatProject, options?: OdtOptions): Promise<Uint8Array> {
|
||||
const theme = options?.theme ?? getTheme(undefined);
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
const zip = new JSZip();
|
||||
zip.file("mimetype", "application/vnd.oasis.opendocument.text", { compression: "STORE" });
|
||||
zip.file("content.xml", contentXml(project));
|
||||
zip.file("styles.xml", styleXml(theme));
|
||||
zip.file("meta.xml", metaXml(project, generatedAt));
|
||||
return normalizeZipDates(
|
||||
await zip.generateAsync({ type: "uint8array", mimeType: "application/vnd.oasis.opendocument.text" })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* PDF exporter (via jsPDF).
|
||||
*
|
||||
* Renders the flattened project to a single-page-per-chapter A4 PDF. Text is
|
||||
* laid out with a small manual word-wrap engine so inline marks (bold/italic/
|
||||
* code/link) can be drawn with the correct font per run. The theme supplies
|
||||
* colours, fonts, and page margins.
|
||||
*
|
||||
* jsPDF is a browser-oriented library but runs unmodified in Node, so the same
|
||||
* code path serves both the web app's download button and the CLI.
|
||||
*/
|
||||
import jsPDF from "jspdf";
|
||||
|
||||
import { normalizePdfId } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
interface Run {
|
||||
text: string;
|
||||
marks: Mark[];
|
||||
}
|
||||
|
||||
function buildRuns(text: string, marks: Mark[]): Run[] {
|
||||
if (!marks.length) return [{ text, marks: [] }];
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
const runs: Run[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
runs.push({ text: text.slice(start, end), marks: runMarks });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function setRunFont(doc: jsPDF, run: Run, theme: Theme): void {
|
||||
const hasCode = run.marks.some((m) => m.type === "code");
|
||||
const hasBold = run.marks.some((m) => m.type === "bold");
|
||||
const hasItalic = run.marks.some((m) => m.type === "italic");
|
||||
doc.setFont(hasCode ? "courier" : theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", hasBold ? "bold" : hasItalic ? "italic" : "normal");
|
||||
}
|
||||
|
||||
function pageGeometry() {
|
||||
const pageWidth = 595.28; // A4 in points
|
||||
const pageHeight = 841.89;
|
||||
const margin = 56;
|
||||
const maxWidth = pageWidth - 2 * margin;
|
||||
const lineHeight = 14;
|
||||
return { pageWidth, pageHeight, margin, maxWidth, lineHeight };
|
||||
}
|
||||
|
||||
function newPage(doc: jsPDF, theme: Theme): void {
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
doc.setFillColor(hexToRgb(theme.colors.background));
|
||||
doc.rect(0, 0, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), "F");
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function renderParagraph(
|
||||
doc: jsPDF,
|
||||
text: string,
|
||||
marks: Mark[],
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
theme: Theme
|
||||
): number {
|
||||
const runs = buildRuns(text, marks);
|
||||
let cx = x;
|
||||
let cy = y;
|
||||
for (const run of runs) {
|
||||
setRunFont(doc, run, theme);
|
||||
for (const word of run.text.split(" ")) {
|
||||
const wordWidth = doc.getTextWidth(word + " ");
|
||||
if (cx + wordWidth > x + maxWidth && cx > x) {
|
||||
cy += lineHeight;
|
||||
cx = x;
|
||||
}
|
||||
doc.text(word, cx, cy);
|
||||
cx += wordWidth;
|
||||
}
|
||||
}
|
||||
return cy;
|
||||
}
|
||||
|
||||
function renderBlocks(
|
||||
doc: jsPDF,
|
||||
blocks: Block[],
|
||||
theme: Theme
|
||||
): void {
|
||||
const { margin, maxWidth, lineHeight } = pageGeometry();
|
||||
let y = margin;
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
const size = level === 1 ? 24 : level === 2 ? 18 : 14;
|
||||
doc.setFontSize(size);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "bold");
|
||||
doc.setTextColor(hexToRgb(theme.colors.heading));
|
||||
y += size * 0.9;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight * 1.2, theme);
|
||||
y += lineHeight * 0.6;
|
||||
break;
|
||||
}
|
||||
case "paragraph": {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
y += lineHeight;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "sceneBreak": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
doc.text("***", margin + maxWidth / 2, y, { align: "center" });
|
||||
y += lineHeight * 0.6;
|
||||
break;
|
||||
}
|
||||
case "blockquote": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth - 28, lineHeight, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "codeBlock": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("courier", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
for (const line of (block.text ?? "").split("\n")) {
|
||||
y += lineHeight * 0.7;
|
||||
doc.text(line, margin, y, { maxWidth: maxWidth });
|
||||
}
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "list": {
|
||||
for (const item of block.children ?? []) {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
const prefix = block.listType === "ordered" ? "• " : "• ";
|
||||
y = renderParagraph(doc, prefix + (item.text ?? ""), item.marks ?? [], margin, y, maxWidth, lineHeight, theme);
|
||||
y += lineHeight * 0.3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "footnote": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
y = renderParagraph(doc, block.text ?? "", [], margin, y, maxWidth, lineHeight * 0.8, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a page break when we run low on vertical space.
|
||||
if (y > pageGeometry().pageHeight - margin) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a PDF (`.pdf` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportPdf(project: FlatProject, options?: PdfOptions): Promise<Uint8Array> {
|
||||
const theme = options?.theme ?? getTheme(undefined);
|
||||
const fm = project.frontmatter;
|
||||
const { margin } = pageGeometry();
|
||||
|
||||
const doc = new jsPDF({ unit: "pt", format: "a4" });
|
||||
newPage(doc, theme);
|
||||
|
||||
// Title.
|
||||
doc.setFontSize(28);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(hexToRgb(theme.colors.heading));
|
||||
doc.text(fm.title, margin, margin + 20, { maxWidth: pageGeometry().maxWidth });
|
||||
|
||||
// Author.
|
||||
if (fm.author) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
doc.text(fm.author, margin, margin + 48, { maxWidth: pageGeometry().maxWidth });
|
||||
}
|
||||
|
||||
renderBlocks(doc, project.blocks, theme);
|
||||
|
||||
return normalizePdfId(new Uint8Array(doc.output("arraybuffer")));
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Export themes. A theme is a self-contained CSS stylesheet plus the font and
|
||||
* colour tokens exporters use to set defaults (EPUB reading-mode overrides,
|
||||
* DOCX/ODT font families, PDF margins).
|
||||
*
|
||||
* Four themes ship by default:
|
||||
* - Ink — neutral serif, light (the default trade trim size look)
|
||||
* - Parchment — warm cream paper, dark text
|
||||
* - Nocturne — dark mode, light text
|
||||
* - Terminal — monospace, terminal aesthetic
|
||||
*/
|
||||
|
||||
export interface ThemeColors {
|
||||
text: string;
|
||||
background: string;
|
||||
accent: string;
|
||||
muted: string;
|
||||
heading: string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Body font family. */
|
||||
fontFamily: string;
|
||||
/** Heading font family (falls back to fontFamily when null). */
|
||||
headingFont: string | null;
|
||||
/** Base body font size (CSS length). */
|
||||
fontSize: string;
|
||||
/** Body line height. */
|
||||
lineHeight: string;
|
||||
/** Paragraph indent. */
|
||||
paragraphIndent: string;
|
||||
colors: ThemeColors;
|
||||
/** Full stylesheet, scoped under `.ps-doc`, ready to inline in HTML/EPUB. */
|
||||
css: string;
|
||||
}
|
||||
|
||||
const baseRules = (t: Theme): string => `
|
||||
.ps-doc {
|
||||
font-family: ${t.fontFamily};
|
||||
font-size: ${t.fontSize};
|
||||
line-height: ${t.lineHeight};
|
||||
color: ${t.colors.text};
|
||||
background: ${t.colors.background};
|
||||
}
|
||||
.ps-doc h1, .ps-doc h2, .ps-doc h3 {
|
||||
font-family: ${t.headingFont ?? t.fontFamily};
|
||||
color: ${t.colors.heading};
|
||||
line-height: 1.25;
|
||||
margin: 1.4em 0 0.6em;
|
||||
}
|
||||
.ps-doc h1 { font-size: 2em; }
|
||||
.ps-doc h2 { font-size: 1.5em; }
|
||||
.ps-doc h3 { font-size: 1.2em; }
|
||||
.ps-doc p {
|
||||
margin: 0;
|
||||
text-indent: ${t.paragraphIndent};
|
||||
orphans: 2;
|
||||
widows: 2;
|
||||
}
|
||||
.ps-doc p:first-of-type { text-indent: none; }
|
||||
.ps-doc blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.2em 1em;
|
||||
border-left: 3px solid ${t.colors.accent};
|
||||
color: ${t.colors.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
.ps-doc pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: ${t.colors.background};
|
||||
border: 1px solid ${t.colors.muted};
|
||||
border-radius: 4px;
|
||||
padding: 0.6em 0.8em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ps-doc a { color: ${t.colors.accent}; text-decoration: underline; }
|
||||
.ps-doc .scene-break {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
color: ${t.colors.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
const makeTheme = (spec: Omit<Theme, "css">): Theme => ({
|
||||
...spec,
|
||||
css: baseRules(spec as Theme),
|
||||
});
|
||||
|
||||
const ink: Theme = makeTheme({
|
||||
id: "ink",
|
||||
name: "Ink",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.55",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#1a1a1a",
|
||||
background: "#ffffff",
|
||||
accent: "#7a2e2e",
|
||||
muted: "#6b6b6b",
|
||||
heading: "#101010",
|
||||
},
|
||||
});
|
||||
|
||||
const parchment: Theme = makeTheme({
|
||||
id: "parchment",
|
||||
name: "Parchment",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Garamond", "EB Garamond", Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#3a3226",
|
||||
background: "#f4ecd8",
|
||||
accent: "#8a5a2b",
|
||||
muted: "#7a6f5c",
|
||||
heading: "#4a3a22",
|
||||
},
|
||||
});
|
||||
|
||||
const nocturne: Theme = makeTheme({
|
||||
id: "nocturne",
|
||||
name: "Nocturne",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#d8d8d8",
|
||||
background: "#14161a",
|
||||
accent: "#7fb3ff",
|
||||
muted: "#9aa0aa",
|
||||
heading: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
const terminal: Theme = makeTheme({
|
||||
id: "terminal",
|
||||
name: "Terminal",
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
headingFont: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: "9.5pt",
|
||||
lineHeight: "1.5",
|
||||
paragraphIndent: "0",
|
||||
colors: {
|
||||
text: "#d6ffdb",
|
||||
background: "#0b0f10",
|
||||
accent: "#7dff9c",
|
||||
muted: "#7fae86",
|
||||
heading: "#b6ffbf",
|
||||
},
|
||||
});
|
||||
|
||||
/** All available themes, keyed by id. */
|
||||
export const THEMES: Record<string, Theme> = {
|
||||
ink,
|
||||
parchment,
|
||||
nocturne,
|
||||
terminal,
|
||||
};
|
||||
|
||||
/** The default theme id. */
|
||||
export const DEFAULT_THEME_ID = "ink";
|
||||
|
||||
/** Look up a theme by id, falling back to the default. */
|
||||
export function getTheme(id?: string): Theme {
|
||||
return THEMES[id ?? DEFAULT_THEME_ID] ?? ink;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+308
@@ -38,6 +38,9 @@ importers:
|
||||
'@pubscript/editor':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/editor
|
||||
'@pubscript/exporters':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/exporters
|
||||
'@pubscript/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
@@ -147,6 +150,37 @@ importers:
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.9(@types/node@22.20.5)(jsdom@25.0.1)
|
||||
|
||||
packages/exporters:
|
||||
dependencies:
|
||||
'@pubscript/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
docx:
|
||||
specifier: ^8.0.0
|
||||
version: 8.6.0
|
||||
jspdf:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.2
|
||||
devDependencies:
|
||||
'@pubscript/editor':
|
||||
specifier: workspace:*
|
||||
version: link:../editor
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.5
|
||||
jsdom:
|
||||
specifier: ^25.0.1
|
||||
version: 25.0.1
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.9(@types/node@22.20.5)(jsdom@25.0.1)
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@pubscript/core':
|
||||
@@ -300,6 +334,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.29.10':
|
||||
resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1369,9 +1407,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@20.19.43':
|
||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||
|
||||
'@types/node@22.20.5':
|
||||
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/react-dom@19.3.0':
|
||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||
peerDependencies:
|
||||
@@ -1529,6 +1573,11 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atob@2.1.2:
|
||||
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
|
||||
engines: {node: '>= 4.5.0'}
|
||||
hasBin: true
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -1543,6 +1592,10 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1572,6 +1625,11 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
btoa@1.2.1:
|
||||
resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
@@ -1590,6 +1648,10 @@ packages:
|
||||
caniuse-lite@1.0.30001819:
|
||||
resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1637,10 +1699,19 @@ packages:
|
||||
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
core-js@3.51.0:
|
||||
resolution: {integrity: sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1736,6 +1807,14 @@ packages:
|
||||
dexie@4.4.7:
|
||||
resolution: {integrity: sha512-wemFiCl3DTCnfzyqepUTX1ugbYbkTrw3aEZOG7YjAOUoEQB5g5PrEywPzk5JpbX/SxqQ9IvjUpxCDIEjoOWEfA==}
|
||||
|
||||
docx@8.6.0:
|
||||
resolution: {integrity: sha512-JEzPozEsuGIyUkEqdGlCv/b1avYeXjR4PjwiLdPwRKdsI9spBCq4WP0QcGYfIANpgYdJSphw4IT8M/a9dpnpvQ==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: A API breaking change was introduced here, which was incompatible with semantic versioning
|
||||
|
||||
dompurify@2.5.9:
|
||||
resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1915,6 +1994,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -2012,6 +2094,10 @@ packages:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2039,6 +2125,9 @@ packages:
|
||||
resolution: {integrity: sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2068,6 +2157,9 @@ packages:
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -2116,6 +2208,12 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jspdf@2.5.2:
|
||||
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
|
||||
|
||||
jszip@3.10.2:
|
||||
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -2123,6 +2221,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
light-my-request@6.8.0:
|
||||
resolution: {integrity: sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==}
|
||||
|
||||
@@ -2198,6 +2299,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.16:
|
||||
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
@@ -2248,6 +2354,9 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2274,6 +2383,9 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2310,6 +2422,9 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process-warning@5.1.1:
|
||||
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
|
||||
|
||||
@@ -2362,6 +2477,9 @@ packages:
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
@@ -2402,6 +2520,9 @@ packages:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2409,6 +2530,9 @@ packages:
|
||||
real-require@1.0.0:
|
||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2428,6 +2552,10 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
rollup@4.65.0:
|
||||
resolution: {integrity: sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -2442,6 +2570,9 @@ packages:
|
||||
rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@@ -2456,6 +2587,10 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -2481,6 +2616,9 @@ packages:
|
||||
set-cookie-parser@3.2.1:
|
||||
resolution: {integrity: sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -2519,6 +2657,10 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2529,6 +2671,9 @@ packages:
|
||||
stream-shift@1.0.3:
|
||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -2544,6 +2689,10 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -2554,6 +2703,9 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
thread-stream@4.2.0:
|
||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2657,6 +2809,9 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -2811,10 +2966,17 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-js@1.6.11:
|
||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xml@1.0.1:
|
||||
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
@@ -2960,6 +3122,8 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/runtime@7.29.10': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -3877,10 +4041,17 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@20.19.43':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.20.5':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
@@ -4082,6 +4253,8 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atob@2.1.2: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
avvio@9.3.1:
|
||||
@@ -4093,6 +4266,9 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
optional: true
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.11.32: {}
|
||||
@@ -4129,6 +4305,8 @@ snapshots:
|
||||
node-releases: 2.0.60
|
||||
update-browserslist-db: 1.3.4(browserslist@4.29.4)
|
||||
|
||||
btoa@1.2.1: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -4145,6 +4323,18 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001819: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.10
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.51.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -4186,12 +4376,22 @@ snapshots:
|
||||
|
||||
cookie@2.0.1: {}
|
||||
|
||||
core-js@3.51.0:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -4266,6 +4466,17 @@ snapshots:
|
||||
|
||||
dexie@4.4.7: {}
|
||||
|
||||
docx@8.6.0:
|
||||
dependencies:
|
||||
'@types/node': 20.19.43
|
||||
jszip: 3.10.2
|
||||
nanoid: 5.1.16
|
||||
xml: 1.0.1
|
||||
xml-js: 1.6.11
|
||||
|
||||
dompurify@2.5.9:
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -4533,6 +4744,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -4634,6 +4847,12 @@ snapshots:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
optional: true
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -4666,6 +4885,8 @@ snapshots:
|
||||
|
||||
ignore@7.0.14: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -4687,6 +4908,8 @@ snapshots:
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jackspeak@4.2.3:
|
||||
@@ -4743,6 +4966,25 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jspdf@2.5.2:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.10
|
||||
atob: 2.1.2
|
||||
btoa: 1.2.1
|
||||
fflate: 0.8.3
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.51.0
|
||||
dompurify: 2.5.9
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jszip@3.10.2:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -4752,6 +4994,10 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
light-my-request@6.8.0:
|
||||
dependencies:
|
||||
'@fastify/deepmerge': 3.2.4
|
||||
@@ -4811,6 +5057,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.20: {}
|
||||
|
||||
nanoid@5.1.16: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -4854,6 +5102,8 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -4875,6 +5125,9 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
@@ -4924,6 +5177,8 @@ snapshots:
|
||||
|
||||
prettier@3.9.10: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@5.1.1: {}
|
||||
|
||||
prosemirror-changeset@2.4.4:
|
||||
@@ -5009,6 +5264,11 @@ snapshots:
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
@@ -5053,6 +5313,16 @@ snapshots:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -5061,6 +5331,9 @@ snapshots:
|
||||
|
||||
real-require@1.0.0: {}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -5071,6 +5344,9 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
rollup@4.65.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -5109,6 +5385,8 @@ snapshots:
|
||||
|
||||
rrweb-cssom@0.8.0: {}
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-regex2@5.1.2:
|
||||
@@ -5119,6 +5397,8 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
@@ -5135,6 +5415,8 @@ snapshots:
|
||||
|
||||
set-cookie-parser@3.2.1: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
@@ -5165,12 +5447,19 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stream-shift@1.0.3: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -5183,6 +5472,9 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tar-fs@2.1.5:
|
||||
@@ -5200,6 +5492,11 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
thread-stream@4.2.0:
|
||||
dependencies:
|
||||
real-require: 1.0.0
|
||||
@@ -5288,6 +5585,11 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
optional: true
|
||||
|
||||
vite-node@2.1.9(@types/node@22.20.5):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -5398,8 +5700,14 @@ snapshots:
|
||||
|
||||
ws@8.22.0: {}
|
||||
|
||||
xml-js@1.6.11:
|
||||
dependencies:
|
||||
sax: 1.6.1
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xml@1.0.1: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
Reference in New Issue
Block a user