Files
PubScript/packages/exporters/src/model.ts
T
Schwitzz 978858362d 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.
2026-10-26 23:30:47 -05:00

386 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };