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.
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
/**
|
|
* 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>
|
|
`;
|
|
}
|