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
+151
View File
@@ -0,0 +1,151 @@
/**
* Standalone HTML exporter.
*
* Emits a single self-contained `.html` file: a full document with the theme
* stylesheet inlined in a `<style>` block, a title/author header, a table of
* contents, the body, and a generation timestamp. The body is scoped under
* `.ps-doc` so the theme CSS never leaks into the page chrome.
*/
import type { Block, FlatProject, Mark } from "./model.js";
import { escapeHtml } from "./model.js";
import type { Theme } from "./themes.js";
export interface HtmlOptions {
theme?: Theme;
/** Include a table of contents (default: true). */
tableOfContents?: boolean;
/** Fixed ISO timestamp for the footer (deterministic output). */
generatedAt?: string | undefined;
}
/** Serialize inline text + marks to HTML. */
function serializeInlineHtml(text: string, marks: Mark[]): string {
if (!marks.length) return escapeHtml(text);
const boundaries = new Set<number>([0, text.length]);
for (const m of marks) {
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
}
const points = Array.from(boundaries).sort((a, b) => a - b);
let out = "";
for (let i = 0; i < points.length - 1; i++) {
const start = points[i]!;
const end = points[i + 1]!;
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
out += runToHtml(text.slice(start, end), runMarks);
}
return out;
}
function runToHtml(segment: string, marks: Mark[]): string {
let out = escapeHtml(segment);
if (marks.some((m) => m.type === "italic")) out = `<em>${out}</em>`;
if (marks.some((m) => m.type === "bold")) out = `<strong>${out}</strong>`;
if (marks.some((m) => m.type === "strike")) out = `<strike>${out}</strike>`;
if (marks.some((m) => m.type === "code")) out = `<code>${out}</code>`;
const color = marks.find((m) => m.type === "color");
if (color?.attrs?.color) {
out = `<span style="color:${escapeHtml(String(color.attrs.color))}">${out}</span>`;
}
const link = marks.find((m) => m.type === "link");
if (link?.attrs?.href) {
out = `<a href="${escapeHtml(String(link.attrs.href))}" rel="noopener">${out}</a>`;
}
return out;
}
function listToHtml(block: Block): string {
const items = block.children ?? [];
const tag = block.listType === "ordered" ? "ol" : "ul";
const inner = items
.map(
(item) =>
`<li>${serializeInlineHtml(item.text ?? "", item.marks ?? [])}</li>`
)
.join("");
return `<${tag}>${inner}</${tag}>`;
}
function blockToHtml(block: Block): string {
switch (block.type) {
case "heading": {
const level = Math.max(1, Math.min(6, block.level ?? 1));
return `<h${level}>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</h${level}>`;
}
case "paragraph":
return `<p>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</p>`;
case "sceneBreak":
return `<div class="scene-break">***</div>`;
case "blockquote":
return `<blockquote>${(block.children ?? []).map(blockToHtml).join("")}</blockquote>`;
case "codeBlock":
return `<pre><code>${escapeHtml(block.text ?? "")}</code></pre>`;
case "footnote":
return `<div class="footnote">${serializeInlineHtml(block.text ?? "", [])}</div>`;
case "list":
return listToHtml(block);
default:
return "";
}
}
function tocHtml(project: FlatProject): string {
const items = project.frontmatter.chapters
.map((c) => `<li>${c.order}. ${escapeHtml(c.title)}</li>`)
.join("");
return `<nav class="ps-toc"><h2>Contents</h2><ul>${items}</ul></nav>`;
}
/**
* Serialize a flattened project to a standalone HTML document.
*
* @param project the flattened project (frontmatter + blocks)
* @param options theme, table-of-contents toggle
*/
export function exportHtml(
project: FlatProject,
options?: HtmlOptions
): string {
const theme = options?.theme;
const toc = options?.tableOfContents !== false;
const fm = project.frontmatter;
const generated = options?.generatedAt ?? new Date().toISOString();
const body = project.blocks.map(blockToHtml).join("\n");
const headerAuthor = fm.author ? `<p class="ps-author">${escapeHtml(fm.author)}</p>` : "";
return `<!DOCTYPE html>
<html lang="${escapeHtml(fm.language ?? "en")}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(fm.title)}</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; }
${theme?.css ?? ""}
.ps-doc { max-width: 40rem; margin: 0 auto; padding: 2rem 1rem; }
.ps-header { margin-bottom: 2rem; }
.ps-header h1 { margin-top: 0; }
.ps-author { color: #6b6b6b; font-style: italic; }
.ps-toc { margin: 2rem 0; padding: 1rem; border: 1px solid #ddd; border-radius: 6px; }
</style>
</head>
<body>
<div class="ps-doc">
<header class="ps-header">
<h1>${escapeHtml(fm.title)}</h1>
${headerAuthor}
</header>
${toc ? tocHtml(project) : ""}
<main>
${body}
</main>
<footer class="ps-footer" style="margin-top:3rem;color:#999;font-size:0.8rem;">
Generated ${escapeHtml(generated)}
</footer>
</div>
</body>
</html>
`;
}