978858362d
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.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
/**
|
||
* 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 };
|