mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 07:42:20 +10:00
feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown Let people export the resume and cover letter as distinct documents, and add a Markdown format alongside PDF / DOCX / JSON (handy for AI agents). - Server/API: scope PDF generation and download URLs to a resume/cover-letter target. - Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume. - Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus flattened per-format rows, reusing existing UI components and design language. - Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter. - Fix blank section headings in DOCX and Markdown by injecting the locale-aware section-title resolver (titles are stored empty and resolved at render time). - Locale catalogs updated for the new strings. * test(e2e): open the download dialog before exporting JSON The JSON export moved into the redesigned download dialog, so the spec now opens the dialog from the Export sidebar section before clicking "Download JSON".
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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[] = [...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 = [heading(section.title)];
|
||||
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, "");
|
||||
Reference in New Issue
Block a user