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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user