Merge remote-tracking branch 'origin/main' into feat/applications

This commit is contained in:
Amruth Pillai
2026-07-05 15:17:23 +02:00
78 changed files with 2874 additions and 1880 deletions
+2
View File
@@ -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: [] },
]);
});
});
+51
View File
@@ -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: [] }],
},
},
};
}
+53
View File
@@ -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 &amp; 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);
});
});
+326
View File
@@ -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> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&nbsp;": " ",
};
/**
* 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(/&#39;|&nbsp;|&quot;|&amp;|&lt;|&gt;/g, (m) => ENTITIES[m] ?? m)
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
const stripTags = (html: string) => html.replace(/<[^>]+>/g, "");