mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +10:00
Merge remote-tracking branch 'origin/main' into feat/applications
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./export-sections": "./src/export-sections.ts",
|
||||
"./icons": "./src/icons.ts",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./patch": "./src/patch.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { getResumeExportData, resumeHasCoverLetter } from "./export-sections";
|
||||
|
||||
describe("resume export sections", () => {
|
||||
it("detects visible cover letter sections", () => {
|
||||
expect(resumeHasCoverLetter(defaultResumeData)).toBe(false);
|
||||
expect(resumeHasCoverLetter(sampleResumeData)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes cover letter sections from resume-only exports", () => {
|
||||
const data = getResumeExportData(sampleResumeData, "resume");
|
||||
|
||||
expect(data.customSections.some((section) => section.type === "cover-letter")).toBe(false);
|
||||
expect(data.metadata.layout.pages.flatMap((page) => [...page.main, ...page.sidebar])).not.toContain(
|
||||
"019bef5b-0b3d-7e2a-8a7c-12d9e23a4f6b",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only cover letter sections for cover-letter exports", () => {
|
||||
const data = getResumeExportData(sampleResumeData, "cover-letter");
|
||||
|
||||
expect(data.customSections).toHaveLength(1);
|
||||
expect(data.customSections[0]?.type).toBe("cover-letter");
|
||||
expect(data.metadata.layout.pages).toEqual([
|
||||
{ fullWidth: true, main: ["019bef5b-0b3d-7e2a-8a7c-12d9e23a4f6b"], sidebar: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export type ResumeExportTarget = "resume" | "cover-letter";
|
||||
|
||||
const isCoverLetterSection = (section: ResumeData["customSections"][number]) => section.type === "cover-letter";
|
||||
|
||||
const isVisibleCoverLetterSection = (section: ResumeData["customSections"][number]) =>
|
||||
isCoverLetterSection(section) && !section.hidden && section.items.some((item) => !item.hidden);
|
||||
|
||||
export const getVisibleCoverLetterSectionIds = (data: ResumeData) =>
|
||||
data.customSections.filter(isVisibleCoverLetterSection).map((section) => section.id);
|
||||
|
||||
export const resumeHasCoverLetter = (data: ResumeData) => getVisibleCoverLetterSectionIds(data).length > 0;
|
||||
|
||||
export function getResumeExportData(data: ResumeData, target: ResumeExportTarget): ResumeData {
|
||||
const allCoverLetterIds = new Set(data.customSections.filter(isCoverLetterSection).map((section) => section.id));
|
||||
const visibleCoverLetterIds = new Set(getVisibleCoverLetterSectionIds(data));
|
||||
const keepSection =
|
||||
target === "cover-letter"
|
||||
? (id: string) => visibleCoverLetterIds.has(id)
|
||||
: (id: string) => !allCoverLetterIds.has(id);
|
||||
|
||||
const pages = data.metadata.layout.pages
|
||||
.map((page) => {
|
||||
if (target === "cover-letter") {
|
||||
return { fullWidth: true, main: [...page.main, ...page.sidebar].filter(keepSection), sidebar: [] };
|
||||
}
|
||||
|
||||
return { ...page, main: page.main.filter(keepSection), sidebar: page.sidebar.filter(keepSection) };
|
||||
})
|
||||
.filter((page) => page.main.length > 0 || page.sidebar.length > 0);
|
||||
|
||||
if (target === "cover-letter" && pages.length === 0 && visibleCoverLetterIds.size > 0) {
|
||||
for (const id of visibleCoverLetterIds) pages.push({ fullWidth: true, main: [id], sidebar: [] });
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
customSections:
|
||||
target === "cover-letter"
|
||||
? data.customSections.filter((section) => visibleCoverLetterIds.has(section.id))
|
||||
: data.customSections.filter((section) => !isCoverLetterSection(section)),
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
layout: {
|
||||
...data.metadata.layout,
|
||||
pages: pages.length > 0 ? pages : [{ fullWidth: true, main: [], sidebar: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { getResumeExportData } from "./export-sections";
|
||||
import { buildMarkdown, htmlToMarkdown } from "./markdown";
|
||||
|
||||
describe("htmlToMarkdown", () => {
|
||||
it("converts the constrained tiptap tag set", () => {
|
||||
const html = "<p>Led <strong>teams</strong> of <em>engineers</em>.</p><ul><li>Shipped X</li><li>Owned Y</li></ul>";
|
||||
expect(htmlToMarkdown(html)).toBe("Led **teams** of _engineers_.\n\n- Shipped X\n- Owned Y");
|
||||
});
|
||||
|
||||
it("converts anchors to Markdown links and decodes entities", () => {
|
||||
expect(htmlToMarkdown('<p>See <a href="https://x.com">Tom & Jerry</a></p>')).toBe(
|
||||
"See [Tom & Jerry](https://x.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(htmlToMarkdown("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMarkdown", () => {
|
||||
// Section titles are stored empty and resolved by the caller; mimic that with a simple resolver.
|
||||
const resolveTitle = (sectionId: string) =>
|
||||
({ summary: "Summary", experience: "Experience", education: "Education" })[sectionId];
|
||||
const md = buildMarkdown(getResumeExportData(sampleResumeData, "resume"), resolveTitle);
|
||||
|
||||
it("emits the name as an H1 and headline as emphasis", () => {
|
||||
expect(md.startsWith(`# ${sampleResumeData.basics.name}`)).toBe(true);
|
||||
expect(md).toContain(`_${sampleResumeData.basics.headline}_`);
|
||||
});
|
||||
|
||||
it("renders resolved section headings as H2", () => {
|
||||
expect(md).toContain("## Experience");
|
||||
});
|
||||
|
||||
it("falls back to the stored title when no resolver is given", () => {
|
||||
const bare = buildMarkdown(getResumeExportData(sampleResumeData, "resume"));
|
||||
expect(bare).not.toContain("## Experience");
|
||||
});
|
||||
|
||||
it("ends with a single trailing newline and never contains raw HTML tags", () => {
|
||||
expect(md.endsWith("\n")).toBe(true);
|
||||
expect(md).not.toMatch(/<[a-z][^>]*>/i);
|
||||
});
|
||||
|
||||
it("excludes the cover letter from the resume scope and includes it in the cover-letter scope", () => {
|
||||
const cover = buildMarkdown(getResumeExportData(sampleResumeData, "cover-letter"));
|
||||
expect(cover.length).toBeGreaterThan(0);
|
||||
expect(cover).not.toBe(md);
|
||||
});
|
||||
});
|
||||
@@ -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