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 = (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(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 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 = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", " ": " ", }; /** * 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(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_m, url, inner) => `[${stripTags(inner)}](${url})`) .replace(/<(strong|b)>(.*?)<\/\1>/gis, "**$2**") .replace(/<(em|i)>(.*?)<\/\1>/gis, "_$2_") .replace(/]*>(.*?)<\/li>/gis, (_m, inner) => `- ${stripTags(inner).trim()}\n`) .replace(//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, "");