Browse Source

feat(export): add markdown export utilities with url normalization tests

Made-with: Cursor
main
npmrun 2 weeks ago
parent
commit
e120ca3b7d
  1. 57
      app/utils/markdown-export.test.ts
  2. 48
      app/utils/markdown-export.ts

57
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-<id>.md when slug is empty", () => {
const result = buildMarkdownExportFileName({ slug: "", id: 42 });
expect(result).toBe("post-42.md");
});
});

48
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);
}
Loading…
Cancel
Save