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:
@@ -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";
|
||||
Reference in New Issue
Block a user