From e120ca3b7d91621d84ff5b14dfb37916afb4d35d Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Thu, 23 Apr 2026 23:12:17 +0800 Subject: [PATCH] feat(export): add markdown export utilities with url normalization tests Made-with: Cursor --- app/utils/markdown-export.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ app/utils/markdown-export.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 app/utils/markdown-export.test.ts create mode 100644 app/utils/markdown-export.ts diff --git a/app/utils/markdown-export.test.ts b/app/utils/markdown-export.test.ts new file mode 100644 index 0000000..9868085 --- /dev/null +++ b/app/utils/markdown-export.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { + buildMarkdownExportFileName, + normalizeMarkdownImageUrls, +} from "./markdown-export"; + +describe("normalizeMarkdownImageUrls", () => { + test("converts /public/assets image links to absolute URLs", () => { + const markdown = "![cover](/public/assets/posts/cover.png)"; + const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); + + expect(result).toBe("![cover](https://example.com/public/assets/posts/cover.png)"); + }); + + test("keeps absolute http/https image links unchanged", () => { + const markdown = [ + "![a](http://cdn.example.com/a.png)", + "![b](https://cdn.example.com/b.png)", + ].join("\n"); + + const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); + + expect(result).toBe(markdown); + }); + + test("keeps protocol-relative and data URI image links unchanged", () => { + const markdown = [ + "![protocol](//cdn.example.com/p.png)", + "![inline](data:image/png;base64,AAAB)", + ].join("\n"); + + const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); + + expect(result).toBe(markdown); + }); + + test("does not change normal markdown links", () => { + const markdown = "[read more](/public/assets/posts/cover.png)"; + const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); + + expect(result).toBe(markdown); + }); +}); + +describe("buildMarkdownExportFileName", () => { + test("prefers slug when slug is not empty", () => { + const result = buildMarkdownExportFileName({ slug: "hello-world", id: 42 }); + + expect(result).toBe("hello-world.md"); + }); + + test("falls back to post-.md when slug is empty", () => { + const result = buildMarkdownExportFileName({ slug: "", id: 42 }); + + expect(result).toBe("post-42.md"); + }); +}); diff --git a/app/utils/markdown-export.ts b/app/utils/markdown-export.ts new file mode 100644 index 0000000..74ac5ad --- /dev/null +++ b/app/utils/markdown-export.ts @@ -0,0 +1,48 @@ +type MarkdownExportFileNameInput = { + slug: string; + id: number; +}; + +const PUBLIC_ASSETS_PREFIX = "/public/assets/"; + +function isAbsoluteOrSpecialUrl(url: string): boolean { + return /^(https?:)?\/\//i.test(url) || /^data:/i.test(url); +} + +export function normalizeMarkdownImageUrls(markdown: string, origin: string): string { + const normalizedOrigin = origin.replace(/\/+$/, ""); + + return markdown.replace(/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, (full, alt, rawUrl, rest) => { + if (isAbsoluteOrSpecialUrl(rawUrl)) { + return full; + } + + if (!rawUrl.startsWith(PUBLIC_ASSETS_PREFIX)) { + return full; + } + + return `![${alt}](${normalizedOrigin}${rawUrl}${rest})`; + }); +} + +export function buildMarkdownExportFileName(input: MarkdownExportFileNameInput): string { + const base = input.slug.trim() || `post-${input.id}`; + return `${base}.md`; +} + +export function downloadMarkdownFile(filename: string, content: string): void { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +}