;
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;
diff --git a/apps/web/src/lib/note-html-export-assets.ts b/apps/web/src/lib/note-html-export-assets.ts
new file mode 100644
index 00000000..c868344a
--- /dev/null
+++ b/apps/web/src/lib/note-html-export-assets.ts
@@ -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}`;
diff --git a/apps/web/src/lib/note-html-export.ts b/apps/web/src/lib/note-html-export.ts
new file mode 100644
index 00000000..b8128cbd
--- /dev/null
+++ b/apps/web/src/lib/note-html-export.ts
@@ -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, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+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
+ ? `${escapeHtml(metadataParts.join(" · "))}
`
+ : "";
+
+ return `
+
+
+
+
+
+${escapeHtml(title)}
+
+
+
+
+
+${escapeHtml(title)}
+${metaHtml}
+
+${bodyHtml}
+
+
+
+
+
+`;
+};
+
+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 = 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("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,
+ };
+};
diff --git a/apps/web/src/styles/note-html-export.css b/apps/web/src/styles/note-html-export.css
new file mode 100644
index 00000000..fdff9cf8
--- /dev/null
+++ b/apps/web/src/styles/note-html-export.css
@@ -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;
+ }
+}
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index 71a4de79..6fdfb862 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -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",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 36fe2414..7eb5a205 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -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: "打印预览已就绪",
diff --git a/tests/note-html-export.test.ts b/tests/note-html-export.test.ts
new file mode 100644
index 00000000..c5c0237b
--- /dev/null
+++ b/tests/note-html-export.test.ts
@@ -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(
+ "当前未保存内容
",
+ "项目 / 计划",
+ "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(`&'`)).toBe(
+ "<script>alert("x")</script>&'",
+ );
+ });
+
+ test("builds a standalone HTML document with metadata and styles", () => {
+ const html = buildStandaloneHtmlDocument({
+ title: 'Release & "docs"',
+ notebook: "Product",
+ tags: ["ship", "html"],
+ updatedAt: "2026-08-11 12:00",
+ language: "zh-CN",
+ bodyHtml: "Hello world
",
+ styles: "body { color: #111; }",
+ });
+
+ expect(html).toContain("");
+ expect(html).toContain('lang="zh-CN"');
+ expect(html).toContain("Release <notes> & "docs"");
+ expect(html).toContain('Release <notes> & "docs"
');
+ expect(html).toContain("Product · 2026-08-11 12:00 · #ship · #html");
+ expect(html).toContain("Hello world
");
+ 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: "body
",
+ });
+
+ expect(html).not.toContain("edgeever-html-meta");
+ expect(html).toContain("body
");
+ });
+
+ 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");
+ });
+});