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 `- ![Certificate ${index + 1}](${url})`; + }) + .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")); +};