import hljs from "highlight.js/lib/common"; import { renderMermaidSVG, THEMES } from "beautiful-mermaid"; import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider"; import { scaleMermaidSvg } from "@/lib/mermaid-svg"; const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const EXPORT_FONT_FAMILY = 'system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'; export type NoteHtmlExportMeta = { title: string; notebook?: string; tags?: string[]; updatedAt?: string; language?: string; }; export type BuildStandaloneHtmlDocumentOptions = NoteHtmlExportMeta & { bodyHtml: string; styles?: string; }; export type DownloadNoteHtmlOptions = NoteHtmlExportMeta & { bodyHtml: string; fallbackTitle: string; styles?: string; }; export type HtmlImageEmbedResult = { /** Images with a non-empty src attribute. */ total: number; /** Already data: URLs plus successfully fetched/inlined images. */ embedded: number; /** Images that could not be inlined and may break offline. */ failed: number; }; export type PreparedNoteHtmlExport = { bodyHtml: string; images: HtmlImageEmbedResult; }; export type DownloadNoteHtmlResult = { filename: string; images: HtmlImageEmbedResult; }; export const createEmptyHtmlImageEmbedResult = (): HtmlImageEmbedResult => ({ total: 0, embedded: 0, failed: 0, }); /** Decide whether the UI should warn about image embedding after a successful download. */ export const getHtmlImageEmbedNoticeKind = ( images: HtmlImageEmbedResult, ): "none" | "partial" | "failed-all" => { if (images.total === 0 || images.failed === 0) { return "none"; } if (images.embedded === 0) { return "failed-all"; } return "partial"; }; export const buildHtmlFilename = (title: string, fallback: string) => { const sanitized = title .replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-") .replace(/\s+/g, " ") .replace(/[. ]+$/g, "") .trim() .slice(0, 100); const basename = sanitized || fallback; const safeBasename = WINDOWS_RESERVED_NAME.test(basename) ? `_${basename}` : basename; return /\.html?$/i.test(safeBasename) ? safeBasename.replace(/\.htm$/i, ".html") : `${safeBasename}.html`; }; export const escapeHtml = (value: string) => value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); export const buildNoteHtmlContentMarkup = ({ title, notebook = "", tags = [], updatedAt = "", bodyHtml, }: Pick) => { const metadataParts = [notebook, updatedAt, ...tags.map((tag) => `#${tag}`)].filter(Boolean); const metaHtml = metadataParts.length > 0 ? `
${escapeHtml(metadataParts.join(" ยท "))}
` : ""; return `

${escapeHtml(title)}

${metaHtml}
${bodyHtml}
`; }; export const buildStandaloneHtmlDocument = ({ title, notebook = "", tags = [], updatedAt = "", language = "en", bodyHtml, styles = "", }: BuildStandaloneHtmlDocumentOptions) => { const contentMarkup = buildNoteHtmlContentMarkup({ title, notebook, tags, updatedAt, bodyHtml }); return ` ${escapeHtml(title)} ${contentMarkup} `; }; export const createHtmlFile = (html: string, title: string, fallback: string) => ({ blob: new Blob([html], { type: "text/html;charset=utf-8" }), filename: buildHtmlFilename(title, fallback), }); export const downloadHtmlFile = (html: string, title: string, fallback: string) => { const { blob, filename } = createHtmlFile(html, title, fallback); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.hidden = true; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); return filename; }; const blobToDataUrl = (blob: Blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Failed to encode resource as data URL")); }; reader.onerror = () => reject(reader.error ?? new Error("Failed to read resource blob")); reader.readAsDataURL(blob); }); const resolveResourceUrl = (src: string) => { try { return new URL(src, window.location.href).href; } catch { return src; } }; export const renderMermaidBlocksForHtmlExport = async (root: HTMLElement) => { const blocks = Array.from(root.querySelectorAll("pre > code.language-mermaid")); await Promise.all(blocks.map(async (code) => { const source = code.textContent?.trim(); const container = code.closest("pre"); if (!source || !container) { return; } try { const svg = scaleMermaidSvg(renderMermaidSVG(source, { ...THEMES["zinc-light"], ...MERMAID_THEME_PALETTES["zinc-light"], transparent: true, font: EXPORT_FONT_FAMILY, padding: 24, })); const figure = document.createElement("figure"); figure.className = "edgeever-html-mermaid"; figure.innerHTML = svg; container.replaceWith(figure); } catch { // Keep invalid Mermaid source readable in the exported document. } })); }; export const highlightCodeBlocksForHtmlExport = (root: HTMLElement) => { root.querySelectorAll("pre > code:not(.language-mermaid)").forEach((code) => { try { hljs.highlightElement(code); } catch { // Leave unhighlighted source readable when language detection fails. } }); }; export const embedImagesForHtmlExport = async (root: HTMLElement): Promise => { const images = Array.from(root.querySelectorAll("img[src]")); const result = createEmptyHtmlImageEmbedResult(); await Promise.all(images.map(async (image) => { const src = image.getAttribute("src")?.trim(); if (!src) { return; } result.total += 1; if (src.startsWith("data:")) { result.embedded += 1; return; } try { const response = await fetch(src.startsWith("blob:") ? src : resolveResourceUrl(src), { credentials: src.startsWith("blob:") ? "omit" : "include", }); if (!response.ok) { result.failed += 1; return; } image.setAttribute("src", await blobToDataUrl(await response.blob())); result.embedded += 1; } catch { // Leave the original URL so partial exports still open, but report the miss. result.failed += 1; } })); return result; }; /** Enrich note body HTML for a self-contained offline file. */ export const prepareNoteBodyHtmlForExport = async (bodyHtml: string): Promise => { const container = document.createElement("div"); container.innerHTML = bodyHtml; await renderMermaidBlocksForHtmlExport(container); highlightCodeBlocksForHtmlExport(container); const images = await embedImagesForHtmlExport(container); return { bodyHtml: container.innerHTML, images, }; }; export const downloadNoteHtmlFile = async ({ bodyHtml, title, notebook, tags, updatedAt, language, fallbackTitle, styles = "", }: DownloadNoteHtmlOptions): Promise => { const prepared = await prepareNoteBodyHtmlForExport(bodyHtml); const html = buildStandaloneHtmlDocument({ title, notebook, tags, updatedAt, language, bodyHtml: prepared.bodyHtml, styles, }); return { filename: downloadHtmlFile(html, title, fallbackTitle), images: prepared.images, }; };