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:
Schwitzz
2026-10-26 23:30:47 -05:00
parent 35b015bb04
commit 978858362d
24 changed files with 3103 additions and 2 deletions
+134
View File
@@ -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";