From f08b9591e1f32c9fc0ea1d53fdd2a149744d9812 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 11 Aug 2026 18:20:29 +0800 Subject: [PATCH] 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). --- README.md | 2 +- README.zh-CN.md | 2 +- apps/web/src/components/EditorPane.tsx | 68 +++++ apps/web/src/components/MemoListPane.tsx | 8 + apps/web/src/lib/app-helpers.ts | 2 +- apps/web/src/lib/note-html-export-assets.ts | 5 + apps/web/src/lib/note-html-export.ts | 275 +++++++++++++++++ apps/web/src/styles/note-html-export.css | 308 ++++++++++++++++++++ packages/shared/src/i18n/en-US.ts | 8 + packages/shared/src/i18n/zh-CN.ts | 8 + tests/note-html-export.test.ts | 75 +++++ 11 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/note-html-export-assets.ts create mode 100644 apps/web/src/lib/note-html-export.ts create mode 100644 apps/web/src/styles/note-html-export.css create mode 100644 tests/note-html-export.test.ts diff --git a/README.md b/README.md index 07a503df..8e769233 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index bf25de20..89222848 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,7 +42,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印 - **无限层级笔记本**:轻松构建清晰的多级目录结构。 - **微信公众号一键排版与复制**:专为中文创作者设计,支持将笔记一键转换为带行内样式的公众号美化格式,直接复制粘贴至微信公众号后台,告别复杂的第三方排版工具。 - **优雅的双视图编辑**:桌面端支持在富文本与 Markdown 源码视图之间自由切换。 -- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown 或 PDF,方便独立保存、分享与发布。 +- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown、HTML 或 PDF,方便独立保存、分享与发布。 - **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。 - **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。 - **公开笔记分享**:支持公开分享笔记,并可随时取消分享。 diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 29814d2b..b813f0a2 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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 = ({ {t("editor.exportMarkdown")} + void handleExportHtml()} + > + + {t("editor.exportHtml")} + handleExportPdf()} diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index bf70e32b..4a5466b2 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -41,6 +41,7 @@ import { TagX, Link2, FileDown, + FileCode2, Printer, Pencil, } from "lucide-react"; @@ -1538,6 +1539,13 @@ export const MemoListPane = ({ {t("editor.exportMarkdown")} + requestContextDocumentAction("export-html")} + > + + {t("editor.exportHtml")} + requestContextDocumentAction("export-pdf")} diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index 348867c2..f9a617bb 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -22,7 +22,7 @@ export type ShortcutBinding = { export type ShortcutSettings = Record; 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"); + }); +});