kanban / src /utils /markdown.ts
Leon4gr45's picture
Deploy magic-resume as Docker Space (port 3000) (part 2)
aa1ee73 verified
Raw
History Blame Contribute Delete
11 kB
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 normalizeDateRangeText = (value?: string) =>
normalizeText(value)
.split(/\s+-\s*/)
.map((part) => part.trim())
.filter(Boolean)
.join(" - ");
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 = normalizeDateRangeText(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), normalizeDateRangeText(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 = normalizeDateRangeText(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"));
};