feat: add markdown export

This commit is contained in:
JOYCEQL
2026-04-09 00:43:53 +08:00
parent 92e7a5739d
commit 2277da9372
8 changed files with 543 additions and 58 deletions
+1
View File
@@ -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",
+12
View File
@@ -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': {}
+41 -24
View File
@@ -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 = ({
<FileJson className="w-4 h-4 mr-2" />
{t("export.json")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleExportMarkdown}
disabled={isLoading}
>
<RiMarkdownLine className="w-4 h-4 mr-2" />
{t("export.markdown")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DockIcon>
@@ -506,4 +524,3 @@ const PreviewDock = ({
};
export default PreviewDock;
+41 -26
View File
@@ -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<HTMLIFrameElement>(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 = () => {
<FileJson className="w-4 h-4 mr-2" />
{t("button.exportJson")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleMarkdownExport} disabled={isLoading}>
<RiMarkdownLine className="w-4 h-4 mr-2" />
{t("button.exportMarkdown")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+97 -6
View File
@@ -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) {
+339
View File
@@ -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<ExportableBasicFieldKey>([
"name",
"title",
"employementStatus",
"birthDate",
"email",
"phone",
"location"
]);
const DEFAULT_BASIC_FIELD_LABELS: Record<ExportableBasicFieldKey, string> = {
name: "Name",
title: "Title",
employementStatus: "Employment Status",
birthDate: "Birth Date",
email: "Email",
phone: "Phone",
location: "Location"
};
export interface ResumeMarkdownOptions {
basicFieldLabels?: Partial<Record<ExportableBasicFieldKey, string>>;
}
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"));
};