Add single-note HTML export with offline image embedding

Allow exporting the current note as a standalone .html file from the editor
and list menus, embedding images when possible and warning when some cannot
be inlined offline. Closes the HTML gap left from single-note Markdown/PDF
export (#218).
This commit is contained in:
tianma-if committed 2026-08-11 18:20:29 +08:00
1 parent fe959f14af
commit f08b9591e1
11 files changed
+758 -3

No files matched your search

+1 -1
View File
@@ -42,7 +42,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s
- **Unlimited Nested Notebooks**: Organize your knowledge with arbitrary folder depth.
- **One-Click Rich Copy for Newsletters & Blogs**: Designed for creators to convert notes into beautifully formatted rich text with inline CSS, ready to paste directly into Substack, Medium, WordPress, or newsletter editors without extra tools.
- **Seamless Dual-View Editor**: Switch effortlessly between intuitive rich text editing and Markdown source code on desktop.
- **Convenient Single-Note Export**: Export the current note directly as Markdown or PDF for standalone storage, sharing, or publishing.
- **Convenient Single-Note Export**: Export the current note directly as Markdown, HTML, or PDF for standalone storage, sharing, or publishing.
- **Native Mermaid Diagram Rendering**: Render clear flowcharts, sequence diagrams, and mind maps directly in notes, preserving clean, editable source code across Markdown and rich text views.
- **Revision History**: Inspect and restore previous iterations of your notes with built-in version tracking.
- **Public Note Sharing**: Share a note publicly and stop sharing it at any time.
+1 -1
View File
@@ -42,7 +42,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印
- **无限层级笔记本**:轻松构建清晰的多级目录结构。
- **微信公众号一键排版与复制**:专为中文创作者设计,支持将笔记一键转换为带行内样式的公众号美化格式,直接复制粘贴至微信公众号后台,告别复杂的第三方排版工具。
- **优雅的双视图编辑**:桌面端支持在富文本与 Markdown 源码视图之间自由切换。
- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown 或 PDF,方便独立保存、分享与发布。
- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown、HTML 或 PDF,方便独立保存、分享与发布。
- **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。
- **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。
- **公开笔记分享**:支持公开分享笔记,并可随时取消分享。
+68
View File
@@ -35,6 +35,7 @@ import {
LoaderCircle,
Info,
FileDown,
FileCode2,
Printer,
Link2,
Copy,
@@ -130,6 +131,8 @@ import { SystemInfoDialog } from "./SystemInfoDialog";
import { fetchLatestRelease, isVersionOutdated } from "@/lib/version-check";
import { RELEASE_STATUS_EVENT } from "@/lib/release-notice";
import { downloadMarkdownFile } from "@/lib/note-markdown-export";
import { NOTE_HTML_FULL_STYLES } from "@/lib/note-html-export-assets";
import { downloadNoteHtmlFile, getHtmlImageEmbedNoticeKind } from "@/lib/note-html-export";
import { openNotePrintPreview, serializeNoteDocumentForPrint } from "@/lib/note-print";
import { isBrowserOffline } from "@/lib/network-status";
import {
@@ -2411,6 +2414,60 @@ const RichEditorPane = ({
useMobilePlainTextEditor,
]);
const handleExportHtml = useCallback(async () => {
if (!isEditorReady(editor) || !memo) {
return;
}
const currentDocument = useMobilePlainTextEditor
? markdownToDoc(getMobilePlainTextValue())
: useMarkdownSourceEditor
? markdownToDoc(markdownSource)
: editor.getJSON() as TiptapDoc;
const bodyHtml = serializeNoteDocumentForPrint(editor, currentDocument);
try {
const { images } = await downloadNoteHtmlFile({
bodyHtml,
title: title.trim() || t("common.untitledMemo"),
notebook: notebookOptions.find((notebook) => notebook.id === memo.notebookId)?.name ?? "",
tags: parseTagsText(tagsText),
updatedAt: formatDateTime(memo.updatedAt),
language: i18n.resolvedLanguage ?? i18n.language,
fallbackTitle: t("common.untitledMemo"),
styles: NOTE_HTML_FULL_STYLES,
});
const noticeKind = getHtmlImageEmbedNoticeKind(images);
if (noticeKind === "partial") {
window.alert(t("editor.htmlExport.imageEmbedPartial", {
embedded: images.embedded,
total: images.total,
failed: images.failed,
}));
} else if (noticeKind === "failed-all") {
window.alert(t("editor.htmlExport.imageEmbedFailed", {
total: images.total,
}));
}
} catch {
window.alert(t("editor.htmlExport.error"));
}
}, [
editor,
getMobilePlainTextValue,
i18n.language,
i18n.resolvedLanguage,
markdownSource,
memo,
notebookOptions,
t,
tagsText,
title,
useMarkdownSourceEditor,
useMobilePlainTextEditor,
]);
const handleSaveAsTemplate = useCallback(() => {
if (!memo) {
return;
@@ -2455,6 +2512,9 @@ const RichEditorPane = ({
case "export-markdown":
handleExportMarkdown();
break;
case "export-html":
void handleExportHtml();
break;
case "export-pdf":
handleExportPdf(documentActionRequest.printWindow);
break;
@@ -2465,6 +2525,7 @@ const RichEditorPane = ({
}, [
documentActionRequest,
editor,
handleExportHtml,
handleExportMarkdown,
handleExportPdf,
handleSaveAsTemplate,
@@ -3648,6 +3709,13 @@ const RichEditorPane = ({
<FileDown className="h-4 w-4 text-slate-500" />
{t("editor.exportMarkdown")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => void handleExportHtml()}
>
<FileCode2 className="h-4 w-4 text-slate-500" />
{t("editor.exportHtml")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => handleExportPdf()}
+8
View File
@@ -41,6 +41,7 @@ import {
TagX,
Link2,
FileDown,
FileCode2,
Printer,
Pencil,
} from "lucide-react";
@@ -1538,6 +1539,13 @@ export const MemoListPane = ({
<FileDown className="h-4 w-4 text-slate-500" />
{t("editor.exportMarkdown")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => requestContextDocumentAction("export-html")}
>
<FileCode2 className="h-4 w-4 text-slate-500" />
{t("editor.exportHtml")}
</DropdownMenuItem>
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => requestContextDocumentAction("export-pdf")}
+1 -1
View File
@@ -22,7 +22,7 @@ export type ShortcutBinding = {
export type ShortcutSettings = Record<ShortcutAction, ShortcutBinding>;
export type MobileBottomNavItem = "home" | "search" | "templates" | "settings";
export type MemoContextMenuState = { memo: MemoSummary; x: number; y: number };
export type MemoDocumentAction = "share" | "export-markdown" | "export-pdf" | "save-as-template";
export type MemoDocumentAction = "share" | "export-markdown" | "export-html" | "export-pdf" | "save-as-template";
export type MemoDocumentActionRequest = {
id: number;
memoId: string;
@@ -0,0 +1,5 @@
import katexStyles from "katex/dist/katex.min.css?inline";
import exportStyles from "@/styles/note-html-export.css?inline";
/** Full stylesheet bundle for offline standalone HTML notes (browser/Vite only). */
export const NOTE_HTML_FULL_STYLES = `${katexStyles}\n${exportStyles}`;
+275
View File
@@ -0,0 +1,275 @@
import hljs from "highlight.js/lib/common";
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
export const buildStandaloneHtmlDocument = ({
title,
notebook = "",
tags = [],
updatedAt = "",
language = "en",
bodyHtml,
styles = "",
}: BuildStandaloneHtmlDocumentOptions) => {
const metadataParts = [notebook, updatedAt, ...tags.map((tag) => `#${tag}`)].filter(Boolean);
const metaHtml = metadataParts.length > 0
? `<div class="edgeever-html-meta">${escapeHtml(metadataParts.join(" · "))}</div>`
: "";
return `<!DOCTYPE html>
<html lang="${escapeHtml(language)}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content="EdgeEver" />
<title>${escapeHtml(title)}</title>
<style>
${styles}
</style>
</head>
<body>
<div class="edgeever-html-shell">
<article class="edgeever-html-document">
<h1 class="edgeever-html-title">${escapeHtml(title)}</h1>
${metaHtml}
<div class="edgeever-html-content">
${bodyHtml}
</div>
</article>
</div>
</body>
</html>
`;
};
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<string>((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<HTMLElement>("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 = renderMermaidSVG(source, {
...THEMES["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<HTMLElement>("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<HtmlImageEmbedResult> => {
const images = Array.from(root.querySelectorAll<HTMLImageElement>("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<PreparedNoteHtmlExport> => {
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<DownloadNoteHtmlResult> => {
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,
};
};
+308
View File
@@ -0,0 +1,308 @@
* {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
background: #f8fafc;
color: #172033;
}
.edgeever-html-shell {
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.edgeever-html-document {
color: #172033;
font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 16px;
line-height: 1.72;
overflow-wrap: anywhere;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 2rem 1.75rem 2.5rem;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.edgeever-html-title {
margin: 0 0 0.75rem;
color: #0f172a;
font-size: 2rem;
font-weight: 760;
line-height: 1.2;
}
.edgeever-html-meta {
margin: 0 0 1.5rem;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.85rem;
color: #64748b;
font-size: 0.875rem;
}
.edgeever-html-content > :first-child {
margin-top: 0;
}
.edgeever-html-content h1,
.edgeever-html-content h2,
.edgeever-html-content h3 {
color: #0f172a;
line-height: 1.25;
}
.edgeever-html-content h1 {
margin: 1.75rem 0 0.75rem;
font-size: 1.6rem;
}
.edgeever-html-content h2 {
margin: 1.5rem 0 0.65rem;
font-size: 1.35rem;
}
.edgeever-html-content h3 {
margin: 1.25rem 0 0.55rem;
font-size: 1.15rem;
}
.edgeever-html-content p {
margin: 0 0 0.9rem;
}
.edgeever-html-content ul,
.edgeever-html-content ol {
margin: 0 0 1rem;
padding-left: 1.5rem;
}
.edgeever-html-content li {
margin: 0.25rem 0;
}
.edgeever-html-content ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
.edgeever-html-content li[data-type="taskItem"] {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.edgeever-html-content li[data-type="taskItem"] > label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
margin-top: 0.25rem;
}
.edgeever-html-content li[data-type="taskItem"] > label input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #16a06e;
}
.edgeever-html-content li[data-type="taskItem"] > div {
min-width: 0;
flex: 1 1 auto;
}
.edgeever-html-content li[data-type="taskItem"] > div > p {
margin-bottom: 0;
}
.edgeever-html-content ul[data-type="taskList"] ul[data-type="taskList"] {
margin: 0.25rem 0 0;
padding-left: 1.5rem;
}
.edgeever-html-content blockquote {
margin: 1rem 0;
border-left: 4px solid #10b981;
background: #f0fdf4;
padding: 0.75rem 1rem;
color: #334155;
}
.edgeever-html-content hr {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid #cbd5e1;
}
.edgeever-html-content hr.edgeever-merge-divider,
.edgeever-html-content hr[data-edgeever-merge-divider] {
margin: 1.75rem 0;
border: 0;
border-top: 2px solid #66ca80;
background: transparent;
height: 0;
}
.edgeever-html-content a {
color: #047857;
text-decoration: underline;
}
.edgeever-html-content code {
border-radius: 0.25rem;
background: #f1f5f9;
padding: 0.1rem 0.35rem;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.9em;
}
.edgeever-html-content pre,
.edgeever-html-content .edgeever-code-source {
max-width: 100%;
margin: 1rem 0;
border: 1px solid #dbe4ea;
border-radius: 0.5rem;
background: #f8fafc;
padding: 0.85rem 1rem;
color: #1e293b;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.875rem;
line-height: 1.55;
overflow-x: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.edgeever-html-content pre code {
background: transparent;
padding: 0;
}
.edgeever-html-content img,
.edgeever-html-mermaid svg {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 80vh;
object-fit: contain;
}
.edgeever-html-content figure,
.edgeever-html-mermaid {
max-width: 100%;
margin: 1.25rem auto;
}
.edgeever-html-mermaid svg {
margin: 0 auto;
}
.edgeever-html-content table {
width: 100%;
margin: 1.25rem 0;
border-collapse: collapse;
font-size: 0.9rem;
line-height: 1.45;
}
.edgeever-html-content th,
.edgeever-html-content td {
border: 1px solid #cbd5e1;
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.edgeever-html-content th {
background: #f1f5f9;
font-weight: 700;
}
.edgeever-html-content [data-edgeever-theme-block] {
margin: 1.25rem 0;
border: 1px solid #a7f3d0;
border-left: 4px solid #059669;
border-radius: 0.5rem;
background: #f0fdf4;
padding: 0.85rem 1rem;
}
.edgeever-html-content [data-theme-block-kind="chapter"] {
border-width: 3px 0 0;
border-radius: 0;
background: transparent;
}
.edgeever-html-content .tableWrapper {
max-width: 100%;
overflow-x: auto;
}
.edgeever-html-content .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1.25rem 0;
overflow-x: auto;
text-align: center;
}
.edgeever-html-content .edgeever-image-controls,
.edgeever-html-content .edgeever-code-copy-button,
.edgeever-html-content .edgeever-theme-block__toolbar,
.edgeever-html-content .edgeever-mermaid-source {
display: none !important;
}
.hljs-comment,
.hljs-quote {
color: #64748b;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal {
color: #7c3aed;
}
.hljs-string,
.hljs-title,
.hljs-section {
color: #047857;
}
.hljs-number,
.hljs-symbol,
.hljs-bullet {
color: #b45309;
}
.hljs-built_in,
.hljs-type,
.hljs-attribute {
color: #0369a1;
}
@media print {
body {
background: #ffffff;
}
.edgeever-html-shell {
max-width: none;
margin: 0;
padding: 0;
}
.edgeever-html-document {
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
}
}
+8
View File
@@ -809,7 +809,15 @@ export const enUS = {
noteIdCopied: "Note ID copied: {{id}}",
noteIdCopyFailed: "Could not copy the note ID.",
exportMarkdown: "Export Markdown",
exportHtml: "Export HTML",
exportPdf: "Export PDF",
htmlExport: {
error: "Could not export the note as HTML.",
imageEmbedPartial:
"HTML exported. Embedded {{embedded}} of {{total}} images. {{failed}} image(s) could not be embedded and may not display when the file is opened offline.",
imageEmbedFailed:
"HTML exported, but none of the {{total}} images could be embedded. They may not display when the file is opened offline.",
},
pdfExport: {
preparing: "Preparing print preview…",
ready: "Print preview is ready",
+8
View File
@@ -809,7 +809,15 @@ export const zhCN = {
noteIdCopied: "笔记 ID 已复制:{{id}}",
noteIdCopyFailed: "复制笔记 ID 失败。",
exportMarkdown: "导出 Markdown",
exportHtml: "导出 HTML",
exportPdf: "导出 PDF",
htmlExport: {
error: "导出 HTML 失败。",
imageEmbedPartial:
"HTML 已导出。已内嵌 {{embedded}}/{{total}} 张图片;另有 {{failed}} 张未能内嵌,离线打开时可能无法显示。",
imageEmbedFailed:
"HTML 已导出,但全部 {{total}} 张图片都未能内嵌,离线打开时可能无法显示。",
},
pdfExport: {
preparing: "正在生成打印预览…",
ready: "打印预览已就绪",
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import {
buildHtmlFilename,
buildStandaloneHtmlDocument,
createEmptyHtmlImageEmbedResult,
createHtmlFile,
escapeHtml,
getHtmlImageEmbedNoticeKind,
} from "../apps/web/src/lib/note-html-export";
describe("single-note HTML export", () => {
test("creates a UTF-8 HTML file with the current content", async () => {
const file = createHtmlFile(
"<!DOCTYPE html><html><body><p>当前未保存内容</p></body></html>",
"项目 / 计划",
"Untitled note",
);
expect(file.filename).toBe("项目 - 计划.html");
expect(file.blob.type).toBe("text/html;charset=utf-8");
expect(await file.blob.text()).toContain("当前未保存内容");
});
test("sanitizes unsafe and reserved filenames", () => {
expect(buildHtmlFilename("CON", "Untitled note")).toBe("_CON.html");
expect(buildHtmlFilename('<>:"/\\|?*', "Untitled note")).toBe("---------.html");
expect(buildHtmlFilename("...", "Untitled note")).toBe("Untitled note.html");
expect(buildHtmlFilename("README.html", "Untitled note")).toBe("README.html");
expect(buildHtmlFilename("notes.htm", "Untitled note")).toBe("notes.html");
});
test("escapes HTML special characters", () => {
expect(escapeHtml(`<script>alert("x")</script>&'`)).toBe(
"&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&#39;",
);
});
test("builds a standalone HTML document with metadata and styles", () => {
const html = buildStandaloneHtmlDocument({
title: 'Release <notes> & "docs"',
notebook: "Product",
tags: ["ship", "html"],
updatedAt: "2026-08-11 12:00",
language: "zh-CN",
bodyHtml: "<p>Hello <strong>world</strong></p>",
styles: "body { color: #111; }",
});
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain('lang="zh-CN"');
expect(html).toContain("<title>Release &lt;notes&gt; &amp; &quot;docs&quot;</title>");
expect(html).toContain('<h1 class="edgeever-html-title">Release &lt;notes&gt; &amp; &quot;docs&quot;</h1>');
expect(html).toContain("Product · 2026-08-11 12:00 · #ship · #html");
expect(html).toContain("<p>Hello <strong>world</strong></p>");
expect(html).toContain("body { color: #111; }");
expect(html).toContain('meta name="generator" content="EdgeEver"');
});
test("omits empty metadata row", () => {
const html = buildStandaloneHtmlDocument({
title: "Untitled",
bodyHtml: "<p>body</p>",
});
expect(html).not.toContain("edgeever-html-meta");
expect(html).toContain("<p>body</p>");
});
test("only warns about image embedding when some or all images fail", () => {
expect(getHtmlImageEmbedNoticeKind(createEmptyHtmlImageEmbedResult())).toBe("none");
expect(getHtmlImageEmbedNoticeKind({ total: 3, embedded: 3, failed: 0 })).toBe("none");
expect(getHtmlImageEmbedNoticeKind({ total: 3, embedded: 2, failed: 1 })).toBe("partial");
expect(getHtmlImageEmbedNoticeKind({ total: 2, embedded: 0, failed: 2 })).toBe("failed-all");
});
});