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
+385
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Escape a string for safe inclusion in XML attribute values (EPUB/ODT). */
export function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Escape a string for inclusion in an XML element text node. */
export function escapeXmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// 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 };