mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-06-01 23:38:48 +02:00
feat: add markdown export
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+12
@@ -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': {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 `- `;
|
||||
})
|
||||
.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"));
|
||||
};
|
||||
Reference in New Issue
Block a user