/** * 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; } /** 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>) ?? []; 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 }>) ?? []; 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)?.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 | undefined; const attrsContent = attrs?.content as string | undefined; if (typeof attrsContent === "string") return attrsContent; const content = node.content as Array> | 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)?.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)?.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, """); } /** 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, "'"); } /** Escape a string for inclusion in an XML element text node. */ export function escapeXmlText(value: string): string { return value .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 };