mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 22:11:42 +10:00
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import type { CustomSection, CustomSectionType, ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
|
|
|
type Sections = ResumeData["sections"];
|
|
|
|
/**
|
|
* Resolves a section's display title from its id. Section titles are stored empty by default and
|
|
* resolved (locale-aware) by the caller — mirroring the PDF/DOCX path — so pass a resolver to get
|
|
* localized headings like "Experience". Without one, the stored (usually empty) title is used.
|
|
*/
|
|
export type SectionTitleResolver = (sectionId: string) => string | undefined;
|
|
|
|
function isCoverLetterSection(data: ResumeData, sectionId: string): boolean {
|
|
const section = data.customSections.find((customSection) => customSection.id === sectionId);
|
|
return section?.type === "cover-letter" && visibleItems(section).length > 0;
|
|
}
|
|
|
|
function isCoverLetterOnlyDocument(data: ResumeData): boolean {
|
|
const sectionIds = data.metadata.layout.pages.flatMap((page) => [...page.main, ...page.sidebar]);
|
|
|
|
return sectionIds.length > 0 && sectionIds.every((sectionId) => isCoverLetterSection(data, sectionId));
|
|
}
|
|
|
|
/**
|
|
* Serializes resume data to Markdown — a compact, readable format well suited for feeding
|
|
* into AI agents. Scope the input first with `getResumeExportData(data, target)` to emit only
|
|
* the resume or the cover letter.
|
|
*/
|
|
export function buildMarkdown(data: ResumeData, resolveTitle?: SectionTitleResolver): string {
|
|
const blocks: string[] = isCoverLetterOnlyDocument(data) ? [] : [...renderHeader(data.basics)];
|
|
|
|
for (const page of data.metadata.layout.pages) {
|
|
for (const sectionId of [...page.main, ...page.sidebar]) {
|
|
blocks.push(...renderSection(sectionId, data, resolveTitle));
|
|
}
|
|
}
|
|
|
|
// Collapse runs of blank lines and trailing whitespace into a clean document.
|
|
return `${blocks
|
|
.filter(Boolean)
|
|
.join("\n\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()}\n`;
|
|
}
|
|
|
|
function renderSection(sectionId: string, data: ResumeData, resolveTitle?: SectionTitleResolver): string[] {
|
|
const titled = <T extends { title: string }>(section: T): T => ({
|
|
...section,
|
|
title: resolveTitle?.(sectionId)?.trim() || section.title,
|
|
});
|
|
|
|
if (sectionId === "summary") return renderSummary(titled(data.summary));
|
|
|
|
if (sectionId in data.sections) {
|
|
const section = data.sections[sectionId as SectionType];
|
|
return section ? renderBuiltInSection(sectionId as SectionType, titled(section)) : [];
|
|
}
|
|
|
|
const customSection = data.customSections.find((cs) => cs.id === sectionId);
|
|
return customSection ? renderCustomSection(titled(customSection)) : [];
|
|
}
|
|
|
|
// --- Header ---
|
|
|
|
function renderHeader(basics: ResumeData["basics"]): string[] {
|
|
const blocks: string[] = [];
|
|
if (basics.name) blocks.push(`# ${basics.name}`);
|
|
if (basics.headline) blocks.push(`_${basics.headline}_`);
|
|
|
|
const contact = [
|
|
basics.email,
|
|
basics.phone,
|
|
basics.location,
|
|
link(basics.website.label || basics.website.url, basics.website.url),
|
|
...basics.customFields.map((field) => (field.link ? link(field.text, field.link) : field.text)),
|
|
].filter(Boolean);
|
|
|
|
if (contact.length > 0) blocks.push(contact.join(" · "));
|
|
return blocks;
|
|
}
|
|
|
|
// --- Shared helpers ---
|
|
|
|
const link = (label: string, url: string) => (url ? `[${label || url}](${url})` : "");
|
|
|
|
const heading = (title: string) => (title ? `## ${title}` : "");
|
|
|
|
/** `### Primary — Secondary` with an optional right-aligned meta suffix in parentheses. */
|
|
function entryHeading(primary: string, secondary?: string, meta?: string): string {
|
|
let line = `### ${primary}`;
|
|
if (secondary) line += ` — ${secondary}`;
|
|
if (meta) line += ` (${meta})`;
|
|
return line;
|
|
}
|
|
|
|
const italicLine = (parts: (string | undefined)[]) => {
|
|
const text = parts.filter(Boolean).join(" · ");
|
|
return text ? `_${text}_` : "";
|
|
};
|
|
|
|
function visibleItems<T extends { hidden: boolean }>(section: { hidden: boolean; items: T[] }): T[] {
|
|
if (section.hidden) return [];
|
|
return section.items.filter((item) => !item.hidden);
|
|
}
|
|
|
|
// --- Section renderers ---
|
|
|
|
function renderSummary(summary: ResumeData["summary"]): string[] {
|
|
if (summary.hidden || !summary.content) return [];
|
|
return [heading(summary.title), htmlToMarkdown(summary.content)].filter(Boolean);
|
|
}
|
|
|
|
function renderExperience(section: Sections["experience"]): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const blocks: string[] = [heading(section.title)];
|
|
for (const item of items) {
|
|
if (item.roles.length > 0) {
|
|
blocks.push(entryHeading(item.company, undefined, item.period), italicLine([item.location]));
|
|
for (const role of item.roles) {
|
|
blocks.push(italicLine([role.position, role.period]), htmlToMarkdown(role.description));
|
|
}
|
|
} else {
|
|
blocks.push(entryHeading(item.company, item.position, item.period), italicLine([item.location]));
|
|
blocks.push(htmlToMarkdown(item.description));
|
|
}
|
|
blocks.push(link(item.website.label, item.website.url));
|
|
}
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
function renderEducation(section: Sections["education"]): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const blocks: string[] = [heading(section.title)];
|
|
for (const item of items) {
|
|
const degreeArea = [item.degree, item.area].filter(Boolean).join(", ");
|
|
blocks.push(entryHeading(item.school, degreeArea, item.period));
|
|
blocks.push(italicLine([item.location, item.grade ? `Grade: ${item.grade}` : ""]));
|
|
blocks.push(htmlToMarkdown(item.description), link(item.website.label, item.website.url));
|
|
}
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
function renderProjects(section: Sections["projects"]): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const blocks: string[] = [heading(section.title)];
|
|
for (const item of items) {
|
|
blocks.push(entryHeading(item.name, undefined, item.period));
|
|
blocks.push(htmlToMarkdown(item.description), link(item.website.label, item.website.url));
|
|
}
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
/** Experience-shaped entries with a title, an issuer/subtitle, a date, and rich-text description. */
|
|
function renderDatedEntries(
|
|
section: { hidden: boolean; title: string; items: { hidden: boolean }[] },
|
|
map: (item: never) => {
|
|
title: string;
|
|
subtitle?: string;
|
|
date?: string;
|
|
description: string;
|
|
website: ResumeData["basics"]["website"];
|
|
},
|
|
): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const blocks: string[] = [heading(section.title)];
|
|
for (const item of items) {
|
|
const entry = map(item as never);
|
|
blocks.push(entryHeading(entry.title, entry.subtitle, entry.date));
|
|
blocks.push(htmlToMarkdown(entry.description), link(entry.website.label, entry.website.url));
|
|
}
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
/** Compact one-line entries: `- **Label** — detail: keywords`. */
|
|
function renderList(
|
|
section: { hidden: boolean; title: string; items: { hidden: boolean }[] },
|
|
map: (item: never) => {
|
|
label: string;
|
|
detail?: string;
|
|
keywords?: string[];
|
|
website?: ResumeData["basics"]["website"];
|
|
},
|
|
): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const lines: string[] = [];
|
|
for (const item of items) {
|
|
const entry = map(item as never);
|
|
let line = `- **${entry.label}**`;
|
|
if (entry.detail) line += ` — ${entry.detail}`;
|
|
if (entry.keywords && entry.keywords.length > 0) line += `: ${entry.keywords.join(", ")}`;
|
|
if (entry.website?.url) line += ` (${link(entry.website.label || entry.website.url, entry.website.url)})`;
|
|
lines.push(line);
|
|
}
|
|
return [heading(section.title), lines.join("\n")].filter(Boolean);
|
|
}
|
|
|
|
const sectionRenderers: Record<SectionType, (section: Sections[SectionType]) => string[]> = {
|
|
experience: renderExperience as (s: Sections[SectionType]) => string[],
|
|
education: renderEducation as (s: Sections[SectionType]) => string[],
|
|
projects: renderProjects as (s: Sections[SectionType]) => string[],
|
|
skills: (s) =>
|
|
renderList(s as Sections["skills"], (item: Sections["skills"]["items"][number]) => ({
|
|
label: item.name,
|
|
detail: item.proficiency,
|
|
keywords: item.keywords,
|
|
})),
|
|
languages: (s) =>
|
|
renderList(s as Sections["languages"], (item: Sections["languages"]["items"][number]) => ({
|
|
label: item.language,
|
|
detail: item.fluency,
|
|
})),
|
|
interests: (s) =>
|
|
renderList(s as Sections["interests"], (item: Sections["interests"]["items"][number]) => ({
|
|
label: item.name,
|
|
keywords: item.keywords,
|
|
})),
|
|
profiles: (s) =>
|
|
renderList(s as Sections["profiles"], (item: Sections["profiles"]["items"][number]) => ({
|
|
label: item.network,
|
|
detail: item.username,
|
|
website: item.website,
|
|
})),
|
|
references: (s) =>
|
|
renderList(s as Sections["references"], (item: Sections["references"]["items"][number]) => ({
|
|
label: item.name,
|
|
detail: [item.position, item.phone].filter(Boolean).join(" · "),
|
|
website: item.website,
|
|
})),
|
|
awards: (s) =>
|
|
renderDatedEntries(s as Sections["awards"], (item: Sections["awards"]["items"][number]) => ({
|
|
title: item.title,
|
|
subtitle: item.awarder,
|
|
date: item.date,
|
|
description: item.description,
|
|
website: item.website,
|
|
})),
|
|
certifications: (s) =>
|
|
renderDatedEntries(s as Sections["certifications"], (item: Sections["certifications"]["items"][number]) => ({
|
|
title: item.title,
|
|
subtitle: item.issuer,
|
|
date: item.date,
|
|
description: item.description,
|
|
website: item.website,
|
|
})),
|
|
publications: (s) =>
|
|
renderDatedEntries(s as Sections["publications"], (item: Sections["publications"]["items"][number]) => ({
|
|
title: item.title,
|
|
subtitle: item.publisher,
|
|
date: item.date,
|
|
description: item.description,
|
|
website: item.website,
|
|
})),
|
|
volunteer: (s) =>
|
|
renderDatedEntries(s as Sections["volunteer"], (item: Sections["volunteer"]["items"][number]) => ({
|
|
title: item.organization,
|
|
date: item.period,
|
|
description: item.description,
|
|
website: item.website,
|
|
})),
|
|
};
|
|
|
|
function renderBuiltInSection(type: SectionType, section: Sections[SectionType]): string[] {
|
|
return sectionRenderers[type]?.(section) ?? [];
|
|
}
|
|
|
|
function renderCustomSection(section: CustomSection): string[] {
|
|
const items = visibleItems(section);
|
|
if (items.length === 0) return [];
|
|
|
|
const sectionType = section.type as CustomSectionType;
|
|
|
|
if (sectionType === "summary") {
|
|
const blocks = [heading(section.title)];
|
|
for (const item of items) if ("content" in item && item.content) blocks.push(htmlToMarkdown(item.content));
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
if (sectionType === "cover-letter") {
|
|
const blocks: string[] = [];
|
|
for (const item of items) {
|
|
if ("recipient" in item && item.recipient) blocks.push(htmlToMarkdown(item.recipient));
|
|
if ("content" in item && item.content) blocks.push(htmlToMarkdown(item.content));
|
|
}
|
|
return blocks.filter(Boolean);
|
|
}
|
|
|
|
if (sectionType in sectionRenderers) {
|
|
const synthetic = { title: section.title, hidden: false, columns: section.columns, items } as Sections[SectionType];
|
|
return renderBuiltInSection(sectionType as SectionType, synthetic);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// --- Rich text (tiptap HTML) → Markdown ---
|
|
|
|
const ENTITIES: Record<string, string> = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
""": '"',
|
|
"'": "'",
|
|
" ": " ",
|
|
};
|
|
|
|
/**
|
|
* ponytail: regex converter, not a full HTML parser. The rich-text fields only ever hold the
|
|
* constrained tiptap tag set (p, br, strong/b, em/i, u, s, a, ul/ol/li, headings). If the editor
|
|
* gains nested/table markup, swap this for a real parser.
|
|
*/
|
|
export function htmlToMarkdown(html: string): string {
|
|
if (!html) return "";
|
|
|
|
return html
|
|
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_m, url, inner) => `[${stripTags(inner)}](${url})`)
|
|
.replace(/<(strong|b)>(.*?)<\/\1>/gis, "**$2**")
|
|
.replace(/<(em|i)>(.*?)<\/\1>/gis, "_$2_")
|
|
.replace(/<li[^>]*>(.*?)<\/li>/gis, (_m, inner) => `- ${stripTags(inner).trim()}\n`)
|
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
.replace(/<\/(p|div|h[1-6]|ul|ol)>/gi, "\n\n")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/'| |"|&|<|>/g, (m) => ENTITIES[m] ?? m)
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
const stripTags = (html: string) => html.replace(/<[^>]+>/g, "");
|