diff --git a/package.json b/package.json
index 5252d59..56b0efa 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.1.3",
+ "@remixicon/react": "^4.9.0",
"@sparticuz/chromium": "^131.0.0",
"@tanstack/react-router": "^1.160.2",
"@tanstack/react-start": "^1.160.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69b87cc..d29667b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.1.3
version: 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@remixicon/react':
+ specifier: ^4.9.0
+ version: 4.9.0(react@18.3.1)
'@sparticuz/chromium':
specifier: ^131.0.0
version: 131.0.1
@@ -3049,6 +3052,11 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
+ '@remixicon/react@4.9.0':
+ resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==}
+ peerDependencies:
+ react: '>=18.2.0'
+
'@rolldown/pluginutils@1.0.0-beta.40':
resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==}
@@ -9566,6 +9574,10 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
+ '@remixicon/react@4.9.0(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+
'@rolldown/pluginutils@1.0.0-beta.40': {}
'@rolldown/pluginutils@1.0.0-rc.3': {}
diff --git a/src/components/preview/PreviewDock.tsx b/src/components/preview/PreviewDock.tsx
index 3bb02dd..e534984 100644
--- a/src/components/preview/PreviewDock.tsx
+++ b/src/components/preview/PreviewDock.tsx
@@ -13,12 +13,13 @@ import {
Eye,
FileText
} from "lucide-react";
+import { RiMarkdownLine } from "@remixicon/react";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { useTranslations } from "@/i18n/compat/client";
import { useRouter } from "@/lib/navigation";
import { exportResumeToBrowserPrint } from "@/utils/print";
-import { exportToPdf } from "@/utils/export";
+import { exportResumeAsJson, exportResumeAsMarkdown, exportToPdf } from "@/utils/export";
import { Dock, DockIcon } from "@/components/magicui/dock";
import { Button } from "@/components/ui/button";
import {
@@ -98,9 +99,11 @@ const PreviewDock = ({
const router = useRouter();
const t = useTranslations("previewDock");
const tPdf = useTranslations("pdfExport");
+ const tBasicField = useTranslations("workbench.basicPanel.basicFields");
const { checkGrammar, isChecking } = useGrammarCheck();
const [isExporting, setIsExporting] = useState(false);
const [isExportingJson, setIsExportingJson] = useState(false);
+ const [isExportingMarkdown, setIsExportingMarkdown] = useState(false);
const {
selectedModel,
@@ -130,28 +133,36 @@ const PreviewDock = ({
};
const handleExportJson = () => {
- try {
- setIsExportingJson(true);
- if (!activeResume) {
- throw new Error("No active resume");
+ exportResumeAsJson({
+ resume: activeResume,
+ title,
+ onStart: () => setIsExportingJson(true),
+ onEnd: () => setIsExportingJson(false),
+ successMessage: tPdf("toast.jsonSuccess"),
+ errorMessage: tPdf("toast.jsonError")
+ });
+ };
+
+ const handleExportMarkdown = () => {
+ exportResumeAsMarkdown({
+ resume: activeResume,
+ title,
+ onStart: () => setIsExportingMarkdown(true),
+ onEnd: () => setIsExportingMarkdown(false),
+ successMessage: tPdf("toast.markdownSuccess"),
+ errorMessage: tPdf("toast.markdownError"),
+ markdownOptions: {
+ basicFieldLabels: {
+ name: tBasicField("name"),
+ title: tBasicField("title"),
+ employementStatus: tBasicField("employementStatus"),
+ birthDate: tBasicField("birthDate"),
+ email: tBasicField("email"),
+ phone: tBasicField("phone"),
+ location: tBasicField("location")
+ }
}
-
- const jsonStr = JSON.stringify(activeResume, null, 2);
- const blob = new Blob([jsonStr], { type: "application/json" });
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${title}.json`;
- link.click();
-
- window.URL.revokeObjectURL(url);
- toast.success(tPdf("toast.jsonSuccess"));
- } catch (error) {
- console.error("JSON export error:", error);
- toast.error(tPdf("toast.jsonError"));
- } finally {
- setIsExportingJson(false);
- }
+ });
};
const handlePrint = () => {
@@ -220,7 +231,7 @@ const PreviewDock = ({
}
}, [activeResumeId, duplicateResume, router, setActiveResume, t]);
- const isLoading = isExporting || isExportingJson;
+ const isLoading = isExporting || isExportingJson || isExportingMarkdown;
return (
<>
@@ -349,6 +360,13 @@ const PreviewDock = ({
{t("export.json")}
+
+
+ {t("export.markdown")}
+
@@ -506,4 +524,3 @@ const PreviewDock = ({
};
export default PreviewDock;
-
diff --git a/src/components/shared/PdfExport.tsx b/src/components/shared/PdfExport.tsx
index 9c93cf5..73187b8 100644
--- a/src/components/shared/PdfExport.tsx
+++ b/src/components/shared/PdfExport.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef } from "react";
+import React, { useState } from "react";
import { useTranslations } from "@/i18n/compat/client";
import {
Download,
@@ -7,10 +7,10 @@ import {
Printer,
ChevronDown
} from "lucide-react";
-import { toast } from "sonner";
+import { RiMarkdownLine } from "@remixicon/react";
import { useResumeStore } from "@/store/useResumeStore";
import { Button } from "@/components/ui/button";
-import { exportToPdf } from "@/utils/export";
+import { exportResumeAsJson, exportResumeAsMarkdown, exportToPdf } from "@/utils/export";
import { exportResumeToBrowserPrint } from "@/utils/print";
import {
DropdownMenu,
@@ -22,10 +22,11 @@ import {
const PdfExport = () => {
const [isExporting, setIsExporting] = useState(false);
const [isExportingJson, setIsExportingJson] = useState(false);
+ const [isExportingMarkdown, setIsExportingMarkdown] = useState(false);
const { activeResume } = useResumeStore();
const { globalSettings = {}, title } = activeResume || {};
const t = useTranslations("pdfExport");
- const printFrameRef = useRef(null);
+ const tBasicField = useTranslations("workbench.basicPanel.basicFields");
const handleExport = async () => {
await exportToPdf({
@@ -41,28 +42,36 @@ const PdfExport = () => {
};
const handleJsonExport = () => {
- try {
- setIsExportingJson(true);
- if (!activeResume) {
- throw new Error("No active resume");
+ exportResumeAsJson({
+ resume: activeResume,
+ title,
+ onStart: () => setIsExportingJson(true),
+ onEnd: () => setIsExportingJson(false),
+ successMessage: t("toast.jsonSuccess"),
+ errorMessage: t("toast.jsonError")
+ });
+ };
+
+ const handleMarkdownExport = () => {
+ exportResumeAsMarkdown({
+ resume: activeResume,
+ title,
+ onStart: () => setIsExportingMarkdown(true),
+ onEnd: () => setIsExportingMarkdown(false),
+ successMessage: t("toast.markdownSuccess"),
+ errorMessage: t("toast.markdownError"),
+ markdownOptions: {
+ basicFieldLabels: {
+ name: tBasicField("name"),
+ title: tBasicField("title"),
+ employementStatus: tBasicField("employementStatus"),
+ birthDate: tBasicField("birthDate"),
+ email: tBasicField("email"),
+ phone: tBasicField("phone"),
+ location: tBasicField("location")
+ }
}
-
- const jsonStr = JSON.stringify(activeResume, null, 2);
- const blob = new Blob([jsonStr], { type: "application/json" });
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${title}.json`;
- link.click();
-
- window.URL.revokeObjectURL(url);
- toast.success(t("toast.jsonSuccess"));
- } catch (error) {
- console.error("JSON export error:", error);
- toast.error(t("toast.jsonError"));
- } finally {
- setIsExportingJson(false);
- }
+ });
};
const handlePrint = async () => {
@@ -80,11 +89,13 @@ const PdfExport = () => {
);
};
- const isLoading = isExporting || isExportingJson;
+ const isLoading = isExporting || isExportingJson || isExportingMarkdown;
const loadingText = isExporting
? t("button.exporting")
: isExportingJson
? t("button.exportingJson")
+ : isExportingMarkdown
+ ? t("button.exportingMarkdown")
: "";
return (
@@ -123,6 +134,10 @@ const PdfExport = () => {
{t("button.exportJson")}
+
+
+ {t("button.exportMarkdown")}
+
>
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index de988df..fe17169 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -258,15 +258,19 @@
"export": "Export",
"exportPdf": "Export PDF (Server)",
"exportJson": "Export JSON Config",
+ "exportMarkdown": "Export Markdown",
"exporting": "Exporting...",
"exportingJson": "Exporting...",
+ "exportingMarkdown": "Exporting...",
"print": "Browser Print"
},
"toast": {
"success": "PDF exported successfully",
"error": "PDF export failed",
"jsonSuccess": "Configuration exported successfully",
- "jsonError": "Configuration export failed"
+ "jsonError": "Configuration export failed",
+ "markdownSuccess": "Markdown exported successfully",
+ "markdownError": "Markdown export failed"
}
},
"previewDock": {
@@ -301,6 +305,7 @@
"tooltip": "Export Resume",
"pdf": "Export PDF",
"json": "Export JSON",
+ "markdown": "Export Markdown",
"print": "Print"
},
"autoOnePage": {
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index c54b4ff..3a0052e 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -259,15 +259,19 @@
"export": "导出",
"exportPdf": "PDF",
"exportJson": "JSON配置",
+ "exportMarkdown": "Markdown",
"exporting": "导出中...",
"exportingJson": "导出中...",
+ "exportingMarkdown": "导出中...",
"print": "PDF(备份)"
},
"toast": {
"success": "PDF导出成功",
"error": "PDF导出失败",
"jsonSuccess": "配置导出成功",
- "jsonError": "配置导出失败"
+ "jsonError": "配置导出失败",
+ "markdownSuccess": "Markdown 导出成功",
+ "markdownError": "Markdown 导出失败"
}
},
"workbench": {
@@ -714,6 +718,7 @@
"tooltip": "导出简历",
"pdf": "导出PDF",
"json": "导出JSON",
+ "markdown": "导出Markdown",
"print": "导出PDF(备份)"
},
"autoOnePage": {
diff --git a/src/utils/export.ts b/src/utils/export.ts
index 61d51d7..328be52 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -1,6 +1,33 @@
import { toast } from "sonner";
import { PDF_EXPORT_CONFIG } from "@/config";
import { normalizeFontFamily } from "@/utils/fonts";
+import { ResumeData } from "@/types/resume";
+import { generateResumeMarkdown, ResumeMarkdownOptions } from "@/utils/markdown";
+
+const INVALID_FILE_NAME_CHAR_REGEX = /[\\/:*?"<>|]/g;
+
+const getSafeFileName = (title?: string) => {
+ const normalized = (title || "resume")
+ .trim()
+ .replace(INVALID_FILE_NAME_CHAR_REGEX, "_")
+ .replace(/\s+/g, " ");
+
+ return normalized || "resume";
+};
+
+const downloadBlob = (blob: Blob, fileName: string) => {
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ window.URL.revokeObjectURL(url);
+};
+
+const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
+ const blob = new Blob([content], { type: mimeType });
+ downloadBlob(blob, fileName);
+};
export const getOptimizedStyles = () => {
const styleCache = new Map();
@@ -79,6 +106,74 @@ export interface ExportToPdfOptions {
errorMessage?: string;
}
+interface ExportResumeFileOptions {
+ resume?: ResumeData | null;
+ title?: string;
+ onStart?: () => void;
+ onEnd?: () => void;
+ successMessage?: string;
+ errorMessage?: string;
+}
+
+interface ExportResumeMarkdownOptions extends ExportResumeFileOptions {
+ markdownOptions?: ResumeMarkdownOptions;
+}
+
+export const exportResumeAsJson = ({
+ resume,
+ title,
+ onStart,
+ onEnd,
+ successMessage,
+ errorMessage
+}: ExportResumeFileOptions) => {
+ onStart?.();
+
+ try {
+ if (!resume) {
+ throw new Error("No active resume");
+ }
+
+ const json = JSON.stringify(resume, null, 2);
+ const fileName = `${getSafeFileName(title || resume.title)}.json`;
+ downloadTextFile(json, fileName, "application/json;charset=utf-8");
+ if (successMessage) toast.success(successMessage);
+ } catch (error) {
+ console.error("JSON export error:", error);
+ if (errorMessage) toast.error(errorMessage);
+ } finally {
+ onEnd?.();
+ }
+};
+
+export const exportResumeAsMarkdown = ({
+ resume,
+ title,
+ onStart,
+ onEnd,
+ successMessage,
+ errorMessage,
+ markdownOptions
+}: ExportResumeMarkdownOptions) => {
+ onStart?.();
+
+ try {
+ if (!resume) {
+ throw new Error("No active resume");
+ }
+
+ const markdown = generateResumeMarkdown(resume, markdownOptions);
+ const fileName = `${getSafeFileName(title || resume.title)}.md`;
+ downloadTextFile(markdown, fileName, "text/markdown;charset=utf-8");
+ if (successMessage) toast.success(successMessage);
+ } catch (error) {
+ console.error("Markdown export error:", error);
+ if (errorMessage) toast.error(errorMessage);
+ } finally {
+ onEnd?.();
+ }
+};
+
export const exportToPdf = async ({
elementId,
title,
@@ -160,13 +255,9 @@ export const exportToPdf = async ({
}
const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${title}.pdf`;
- link.click();
+ const fileName = `${getSafeFileName(title)}.pdf`;
+ downloadBlob(blob, fileName);
- window.URL.revokeObjectURL(url);
if (successMessage) toast.success(successMessage);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
} catch (error) {
diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts
new file mode 100644
index 0000000..f1214ef
--- /dev/null
+++ b/src/utils/markdown.ts
@@ -0,0 +1,339 @@
+import TurndownService from "turndown";
+import { DEFAULT_FIELD_ORDER } from "@/config";
+import { getCustomFieldDisplayText, getCustomFieldHref, shouldShowCustomFieldLabelPrefix } from "@/lib/customField";
+import { getProjectLinkMeta } from "@/lib/projectLink";
+import { BasicFieldType, BasicInfo, CustomItem, MenuSection, ResumeData } from "@/types/resume";
+
+const HTML_TAG_REGEX = /<\/?[a-z][\s\S]*>/i;
+const DATA_URL_REGEX = /^data:/i;
+
+const DEFAULT_BASIC_SECTION_TITLES = {
+ basic: "Basic Info",
+ skills: "Skills",
+ experience: "Experience",
+ projects: "Projects",
+ education: "Education",
+ selfEvaluation: "Self Evaluation",
+ certificates: "Certificates"
+} as const;
+
+type ExportableBasicFieldKey =
+ | "name"
+ | "title"
+ | "employementStatus"
+ | "birthDate"
+ | "email"
+ | "phone"
+ | "location";
+
+const BASIC_FIELD_KEYS = new Set([
+ "name",
+ "title",
+ "employementStatus",
+ "birthDate",
+ "email",
+ "phone",
+ "location"
+]);
+
+const DEFAULT_BASIC_FIELD_LABELS: Record = {
+ name: "Name",
+ title: "Title",
+ employementStatus: "Employment Status",
+ birthDate: "Birth Date",
+ email: "Email",
+ phone: "Phone",
+ location: "Location"
+};
+
+export interface ResumeMarkdownOptions {
+ basicFieldLabels?: Partial>;
+}
+
+const normalizeText = (value?: string) => value?.trim() || "";
+
+const createTurndownService = () =>
+ new TurndownService({
+ headingStyle: "atx",
+ bulletListMarker: "-"
+ });
+
+const markdownFromText = (value?: string) => {
+ const normalized = normalizeText(value);
+ if (!normalized) return "";
+
+ if (!HTML_TAG_REGEX.test(normalized)) {
+ return normalized;
+ }
+
+ return createTurndownService().turndown(normalized).trim();
+};
+
+const normalizeMarkdown = (content: string) =>
+ content
+ .replace(/\r\n/g, "\n")
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+
+const pickBasicFieldValue = (
+ basic: BasicInfo,
+ key: ExportableBasicFieldKey
+) => normalizeText((basic[key] as string | undefined) || "");
+
+const getOrderedEnabledSections = (resume: ResumeData): MenuSection[] => {
+ const enabledSections = (resume.menuSections || [])
+ .filter((section) => section.enabled)
+ .sort((a, b) => a.order - b.order);
+
+ if (enabledSections.length > 0) return enabledSections;
+
+ return [
+ { id: "basic", title: DEFAULT_BASIC_SECTION_TITLES.basic, icon: "", enabled: true, order: 0 },
+ { id: "skills", title: DEFAULT_BASIC_SECTION_TITLES.skills, icon: "", enabled: true, order: 1 },
+ { id: "experience", title: DEFAULT_BASIC_SECTION_TITLES.experience, icon: "", enabled: true, order: 2 },
+ { id: "projects", title: DEFAULT_BASIC_SECTION_TITLES.projects, icon: "", enabled: true, order: 3 },
+ { id: "education", title: DEFAULT_BASIC_SECTION_TITLES.education, icon: "", enabled: true, order: 4 },
+ { id: "selfEvaluation", title: DEFAULT_BASIC_SECTION_TITLES.selfEvaluation, icon: "", enabled: true, order: 5 },
+ { id: "certificates", title: DEFAULT_BASIC_SECTION_TITLES.certificates, icon: "", enabled: true, order: 6 }
+ ];
+};
+
+const renderBasicSection = (
+ title: string,
+ resume: ResumeData,
+ options?: ResumeMarkdownOptions
+) => {
+ const fieldOrder = (resume.basic.fieldOrder?.length
+ ? resume.basic.fieldOrder
+ : DEFAULT_FIELD_ORDER) as BasicFieldType[];
+
+ const lines: string[] = [];
+ const name = pickBasicFieldValue(resume.basic, "name");
+ const summaryTitle = pickBasicFieldValue(resume.basic, "title");
+
+ if (name) lines.push(`### ${name}`);
+ if (summaryTitle) lines.push(summaryTitle);
+
+ for (const field of fieldOrder) {
+ const key = field.key as ExportableBasicFieldKey;
+ if (!BASIC_FIELD_KEYS.has(key)) continue;
+ if (field.visible === false) continue;
+ if (key === "name" || key === "title") continue;
+
+ const value = pickBasicFieldValue(resume.basic, key);
+ if (!value) continue;
+
+ const label =
+ options?.basicFieldLabels?.[key] ||
+ normalizeText(field.label) ||
+ DEFAULT_BASIC_FIELD_LABELS[key];
+
+ lines.push(`- ${label}: ${value}`);
+ }
+
+ const customFieldLines = (resume.basic.customFields || [])
+ .filter((field) => field.visible !== false)
+ .map((field) => {
+ const displayText = normalizeText(getCustomFieldDisplayText(field));
+ if (!displayText) return "";
+
+ const href = getCustomFieldHref(field);
+ const normalizedLabel = normalizeText(field.label);
+ const showPrefix = shouldShowCustomFieldLabelPrefix(field) && normalizedLabel;
+ const markdownValue = href
+ ? `[${displayText}](${href})`
+ : displayText;
+
+ return showPrefix
+ ? `- ${normalizedLabel}: ${markdownValue}`
+ : `- ${markdownValue}`;
+ })
+ .filter(Boolean);
+
+ const sectionBlocks = [...lines, ...customFieldLines];
+ if (sectionBlocks.length === 0) return "";
+
+ return `## ${title}\n\n${sectionBlocks.join("\n")}`;
+};
+
+const renderSkillsSection = (title: string, resume: ResumeData) => {
+ const content = markdownFromText(resume.skillContent);
+ if (!content) return "";
+ return `## ${title}\n\n${content}`;
+};
+
+const renderSelfEvaluationSection = (title: string, resume: ResumeData) => {
+ const content = markdownFromText(resume.selfEvaluationContent);
+ if (!content) return "";
+ return `## ${title}\n\n${content}`;
+};
+
+const renderExperienceSection = (title: string, resume: ResumeData) => {
+ const blocks = resume.experience
+ .filter((item) => item.visible)
+ .map((item) => {
+ const heading = [normalizeText(item.company), normalizeText(item.position)]
+ .filter(Boolean)
+ .join(" | ");
+ const date = normalizeText(item.date);
+ const details = markdownFromText(item.details);
+ const lines: string[] = [];
+
+ if (heading) lines.push(`### ${heading}`);
+ if (date) lines.push(`_${date}_`);
+ if (details) lines.push(details);
+
+ return lines.join("\n\n");
+ })
+ .filter(Boolean);
+
+ if (blocks.length === 0) return "";
+ return `## ${title}\n\n${blocks.join("\n\n")}`;
+};
+
+const renderProjectSection = (title: string, resume: ResumeData) => {
+ const blocks = resume.projects
+ .filter((item) => item.visible)
+ .map((item) => {
+ const heading = normalizeText(item.name);
+ const meta = [normalizeText(item.role), normalizeText(item.date)]
+ .filter(Boolean)
+ .join(" | ");
+ const description = markdownFromText(item.description);
+ const linkMeta = getProjectLinkMeta(item, { preferFullUrl: true });
+ const lines: string[] = [];
+
+ if (heading) lines.push(`### ${heading}`);
+ if (meta) lines.push(`_${meta}_`);
+ if (description) lines.push(description);
+ if (linkMeta?.href) {
+ lines.push(`[${normalizeText(linkMeta.label) || linkMeta.href}](${linkMeta.href})`);
+ }
+
+ return lines.join("\n\n");
+ })
+ .filter(Boolean);
+
+ if (blocks.length === 0) return "";
+ return `## ${title}\n\n${blocks.join("\n\n")}`;
+};
+
+const renderEducationSection = (title: string, resume: ResumeData) => {
+ const blocks = resume.education
+ .filter((item) => item.visible)
+ .map((item) => {
+ const heading = [normalizeText(item.school), normalizeText(item.major)]
+ .filter(Boolean)
+ .join(" | ");
+ const duration = [normalizeText(item.startDate), normalizeText(item.endDate)]
+ .filter(Boolean)
+ .join(" - ");
+ const metadata = [normalizeText(item.degree), duration, item.gpa ? `GPA: ${normalizeText(item.gpa)}` : ""]
+ .filter(Boolean)
+ .join(" | ");
+ const description = markdownFromText(item.description);
+ const lines: string[] = [];
+
+ if (heading) lines.push(`### ${heading}`);
+ if (metadata) lines.push(`_${metadata}_`);
+ if (description) lines.push(description);
+
+ return lines.join("\n\n");
+ })
+ .filter(Boolean);
+
+ if (blocks.length === 0) return "";
+ return `## ${title}\n\n${blocks.join("\n\n")}`;
+};
+
+const renderCertificateSection = (title: string, resume: ResumeData) => {
+ const lines = resume.certificates
+ .map((certificate, index) => {
+ const url = normalizeText(certificate.url);
+ if (!url) return "";
+ if (DATA_URL_REGEX.test(url)) {
+ return `- Certificate ${index + 1} (embedded image omitted)`;
+ }
+ return `- `;
+ })
+ .filter(Boolean);
+
+ if (lines.length === 0) return "";
+ return `## ${title}\n\n${lines.join("\n")}`;
+};
+
+const renderCustomSection = (title: string, items: CustomItem[]) => {
+ const blocks = items
+ .filter((item) => item.visible)
+ .map((item, index) => {
+ const heading =
+ normalizeText(item.title) ||
+ normalizeText(item.subtitle) ||
+ `Item ${index + 1}`;
+ const subtitle = normalizeText(item.subtitle);
+ const dateRange = normalizeText(item.dateRange);
+ const details = markdownFromText(item.description);
+ const metadata = [subtitle !== heading ? subtitle : "", dateRange]
+ .filter(Boolean)
+ .join(" | ");
+ const lines: string[] = [];
+
+ if (heading) lines.push(`### ${heading}`);
+ if (metadata) lines.push(`_${metadata}_`);
+ if (details) lines.push(details);
+
+ return lines.join("\n\n");
+ })
+ .filter(Boolean);
+
+ if (blocks.length === 0) return "";
+ return `## ${title}\n\n${blocks.join("\n\n")}`;
+};
+
+export const generateResumeMarkdown = (
+ resume: ResumeData,
+ options?: ResumeMarkdownOptions
+) => {
+ const topTitle = normalizeText(resume.title) || "Resume";
+ const sections = getOrderedEnabledSections(resume);
+ const blocks: string[] = [`# ${topTitle}`];
+
+ for (const section of sections) {
+ const sectionTitle = normalizeText(section.title) || section.id;
+ let content = "";
+
+ switch (section.id) {
+ case "basic":
+ content = renderBasicSection(sectionTitle, resume, options);
+ break;
+ case "skills":
+ content = renderSkillsSection(sectionTitle, resume);
+ break;
+ case "experience":
+ content = renderExperienceSection(sectionTitle, resume);
+ break;
+ case "projects":
+ content = renderProjectSection(sectionTitle, resume);
+ break;
+ case "education":
+ content = renderEducationSection(sectionTitle, resume);
+ break;
+ case "selfEvaluation":
+ content = renderSelfEvaluationSection(sectionTitle, resume);
+ break;
+ case "certificates":
+ content = renderCertificateSection(sectionTitle, resume);
+ break;
+ default: {
+ const customItems = resume.customData?.[section.id] || [];
+ content = renderCustomSection(sectionTitle, customItems);
+ break;
+ }
+ }
+
+ if (content) blocks.push(content);
+ }
+
+ return normalizeMarkdown(blocks.join("\n\n"));
+};