From 8570c1c70aca958859b4b0c673b540f639d860f4 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 7 Jul 2026 17:47:42 +0200 Subject: [PATCH] fix: render cover letter exports without resume chrome --- packages/docx/src/builder.ts | 8 ++-- packages/docx/src/cover-letter-layout.test.ts | 14 +++++++ packages/docx/src/cover-letter.ts | 15 ++++++++ packages/docx/src/section-renderers.test.ts | 4 +- packages/docx/src/section-renderers.ts | 2 +- .../pdf/src/templates/azurill/AzurillPage.tsx | 3 +- .../pdf/src/templates/bronzor/BronzorPage.tsx | 3 +- .../src/templates/chikorita/ChikoritaPage.tsx | 3 +- .../src/templates/cover-letter-layout.test.ts | 37 +++++++++++++++++++ .../pdf/src/templates/ditgar/DitgarPage.tsx | 3 +- .../pdf/src/templates/ditto/DittoPage.tsx | 3 +- .../pdf/src/templates/gengar/GengarPage.tsx | 3 +- .../pdf/src/templates/glalie/GlaliePage.tsx | 3 +- .../pdf/src/templates/kakuna/KakunaPage.tsx | 3 +- .../pdf/src/templates/lapras/LaprasPage.tsx | 3 +- .../pdf/src/templates/leafish/LeafishPage.tsx | 3 +- .../pdf/src/templates/meowth/MeowthPage.tsx | 3 +- packages/pdf/src/templates/onyx/OnyxPage.tsx | 3 +- .../pdf/src/templates/pikachu/PikachuPage.tsx | 3 +- .../pdf/src/templates/rhyhorn/RhyhornPage.tsx | 3 +- .../pdf/src/templates/scizor/ScizorPage.tsx | 3 +- .../src/templates/shared/cover-letter.test.ts | 33 +++++++++++++++++ .../pdf/src/templates/shared/cover-letter.ts | 16 ++++++++ packages/resume/src/markdown.test.ts | 9 +++++ packages/resume/src/markdown.ts | 15 +++++++- 25 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 packages/docx/src/cover-letter-layout.test.ts create mode 100644 packages/docx/src/cover-letter.ts create mode 100644 packages/pdf/src/templates/cover-letter-layout.test.ts create mode 100644 packages/pdf/src/templates/shared/cover-letter.test.ts create mode 100644 packages/pdf/src/templates/shared/cover-letter.ts diff --git a/packages/docx/src/builder.ts b/packages/docx/src/builder.ts index bbc878609..697130a23 100644 --- a/packages/docx/src/builder.ts +++ b/packages/docx/src/builder.ts @@ -15,6 +15,7 @@ import { WidthType, } from "docx"; import { parseColorString } from "@reactive-resume/utils/color"; +import { shouldShowResumeHeader } from "./cover-letter"; import { toSafeDocxLink } from "./link-utils"; import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers"; @@ -413,9 +414,10 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol textColorHex, primaryColorHex: colorHex, }; + const showHeader = shouldShowResumeHeader(data); // Header placement depends on template - if (templateConfig.headerPosition === "full-width") { + if (templateConfig.headerPosition === "full-width" && showHeader) { setRenderConfig(mainConfig); documentChildren.push(...buildHeader(data, colorHex, textColorHex)); } @@ -434,7 +436,7 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol setRenderConfig(mainConfig); const mainParagraphs: Paragraph[] = []; - if (templateConfig.headerPosition === "main-only") { + if (templateConfig.headerPosition === "main-only" && showHeader) { mainParagraphs.push(...buildHeader(data, colorHex, textColorHex)); } for (const sectionId of layoutPage.main) { @@ -445,7 +447,7 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol setRenderConfig({ ...mainConfig, textColorHex: sidebarTextColorHex, primaryColorHex: sidebarHeadingColorHex }); const sidebarParagraphs: Paragraph[] = []; - if (templateConfig.headerPosition === "sidebar-only") { + if (templateConfig.headerPosition === "sidebar-only" && showHeader) { sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex)); } for (const sectionId of layoutPage.sidebar) { diff --git a/packages/docx/src/cover-letter-layout.test.ts b/packages/docx/src/cover-letter-layout.test.ts new file mode 100644 index 000000000..41171aacf --- /dev/null +++ b/packages/docx/src/cover-letter-layout.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const builderSource = readFileSync(fileURLToPath(new URL("./builder.ts", import.meta.url)), "utf8"); + +describe("cover letter DOCX layout", () => { + it("gates resume headers for cover-letter-only documents", () => { + expect(builderSource).toContain("shouldShowResumeHeader(data)"); + expect(builderSource).toContain('templateConfig.headerPosition === "full-width" && showHeader'); + expect(builderSource).toContain('templateConfig.headerPosition === "main-only" && showHeader'); + expect(builderSource).toContain('templateConfig.headerPosition === "sidebar-only" && showHeader'); + }); +}); diff --git a/packages/docx/src/cover-letter.ts b/packages/docx/src/cover-letter.ts new file mode 100644 index 000000000..a55e31d1c --- /dev/null +++ b/packages/docx/src/cover-letter.ts @@ -0,0 +1,15 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; + +const isCoverLetterSection = (data: ResumeData, sectionId: string) => { + const section = data.customSections.find((customSection) => customSection.id === sectionId); + + return section?.type === "cover-letter" && !section.hidden && section.items.some((item) => !item.hidden); +}; + +const isCoverLetterOnlyDocument = (data: ResumeData) => { + const sectionIds = data.metadata.layout.pages.flatMap((page) => [...page.main, ...page.sidebar]); + + return sectionIds.length > 0 && sectionIds.every((sectionId) => isCoverLetterSection(data, sectionId)); +}; + +export const shouldShowResumeHeader = (data: ResumeData) => !isCoverLetterOnlyDocument(data); diff --git a/packages/docx/src/section-renderers.test.ts b/packages/docx/src/section-renderers.test.ts index bd63d3c82..fa31e62ce 100644 --- a/packages/docx/src/section-renderers.test.ts +++ b/packages/docx/src/section-renderers.test.ts @@ -150,6 +150,7 @@ describe("renderCustomSection", () => { const section: CustomSection = { ...baseCustom, type: "cover-letter", + title: "Cover Letter", items: [ { id: "x", @@ -160,8 +161,7 @@ describe("renderCustomSection", () => { ], }; const paragraphs = renderCustomSection(section, HEX); - // 1 heading + at least two paragraphs (recipient + body) - expect(paragraphs.length).toBeGreaterThanOrEqual(3); + expect(paragraphs).toHaveLength(2); }); }); diff --git a/packages/docx/src/section-renderers.ts b/packages/docx/src/section-renderers.ts index a2acc62cc..65856e52f 100644 --- a/packages/docx/src/section-renderers.ts +++ b/packages/docx/src/section-renderers.ts @@ -506,7 +506,7 @@ export function renderCustomSection(section: CustomSection, colorHex: string): P // Cover letter type — render recipient + content if (sectionType === "cover-letter") { - const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)]; + const paragraphs: Paragraph[] = []; for (const item of visibleItems) { if ("recipient" in item && item.recipient) { paragraphs.push(...htmlToParagraphs(item.recipient, getHtmlStyle())); diff --git a/packages/pdf/src/templates/azurill/AzurillPage.tsx b/packages/pdf/src/templates/azurill/AzurillPage.tsx index dbad02893..5ec7899af 100644 --- a/packages/pdf/src/templates/azurill/AzurillPage.tsx +++ b/packages/pdf/src/templates/azurill/AzurillPage.tsx @@ -20,6 +20,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -64,7 +65,7 @@ export const AzurillPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/bronzor/BronzorPage.tsx b/packages/pdf/src/templates/bronzor/BronzorPage.tsx index 0bd4296cc..31de90f30 100644 --- a/packages/pdf/src/templates/bronzor/BronzorPage.tsx +++ b/packages/pdf/src/templates/bronzor/BronzorPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -76,7 +77,7 @@ export const BronzorPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); const sections = getBronzorSections({ mainSections, sidebarSections, fullWidth: page.fullWidth }); diff --git a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx index 4a3e3a9bb..8967d748a 100644 --- a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx +++ b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -54,7 +55,7 @@ export const ChikoritaPage = ({ page, pageIndex }: TemplatePageProps) => { const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); const hasPicture = hasTemplatePicture(picture); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/cover-letter-layout.test.ts b/packages/pdf/src/templates/cover-letter-layout.test.ts new file mode 100644 index 000000000..a7b02f76a --- /dev/null +++ b/packages/pdf/src/templates/cover-letter-layout.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const pageFiles = [ + "azurill/AzurillPage.tsx", + "bronzor/BronzorPage.tsx", + "chikorita/ChikoritaPage.tsx", + "ditgar/DitgarPage.tsx", + "ditto/DittoPage.tsx", + "gengar/GengarPage.tsx", + "glalie/GlaliePage.tsx", + "kakuna/KakunaPage.tsx", + "lapras/LaprasPage.tsx", + "leafish/LeafishPage.tsx", + "meowth/MeowthPage.tsx", + "onyx/OnyxPage.tsx", + "pikachu/PikachuPage.tsx", + "rhyhorn/RhyhornPage.tsx", + "scizor/ScizorPage.tsx", +]; + +const readTemplate = (file: string) => { + const path = fileURLToPath(new URL(file, import.meta.url)); + return readFileSync(path, "utf8"); +}; + +describe("cover letter PDF layout", () => { + it.each(pageFiles)("%s suppresses the resume header for cover-letter-only documents", (file) => { + const source = readTemplate(file); + + expect(source, basename(file)).toContain('from "../shared/cover-letter"'); + expect(source, basename(file)).toContain("shouldShowResumeHeader(data, pageIndex)"); + expect(source, basename(file)).not.toContain("const showHeader = pageIndex === 0;"); + }); +}); diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index f345bb0dc..e6924ccd6 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -15,6 +15,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { getFeaturedSummaryLayout } from "../shared/featured-summary"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; @@ -64,7 +65,7 @@ export const DitgarPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const showSidebar = !page.fullWidth || showHeader; const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/ditto/DittoPage.tsx b/packages/pdf/src/templates/ditto/DittoPage.tsx index 9f9b4711c..079b0fe36 100644 --- a/packages/pdf/src/templates/ditto/DittoPage.tsx +++ b/packages/pdf/src/templates/ditto/DittoPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -59,7 +60,7 @@ export const DittoPage = ({ page, pageIndex }: TemplatePageProps) => { const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); const hasPicture = hasTemplatePicture(picture); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/gengar/GengarPage.tsx b/packages/pdf/src/templates/gengar/GengarPage.tsx index f073d1c9d..8d886b4f6 100644 --- a/packages/pdf/src/templates/gengar/GengarPage.tsx +++ b/packages/pdf/src/templates/gengar/GengarPage.tsx @@ -15,6 +15,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { getFeaturedSummaryLayout } from "../shared/featured-summary"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; @@ -59,7 +60,7 @@ export const GengarPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const showSidebar = !page.fullWidth || showHeader; const sidebarSections = filterSections(page.sidebar, data); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index a2909bcae..f5bc6820e 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -15,6 +15,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -61,7 +62,7 @@ export const GlaliePage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const showSidebar = !page.fullWidth || showHeader; const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/kakuna/KakunaPage.tsx b/packages/pdf/src/templates/kakuna/KakunaPage.tsx index fb685df33..c8772c058 100644 --- a/packages/pdf/src/templates/kakuna/KakunaPage.tsx +++ b/packages/pdf/src/templates/kakuna/KakunaPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -52,7 +53,7 @@ export const KakunaPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/lapras/LaprasPage.tsx b/packages/pdf/src/templates/lapras/LaprasPage.tsx index 093ed805f..59681031e 100644 --- a/packages/pdf/src/templates/lapras/LaprasPage.tsx +++ b/packages/pdf/src/templates/lapras/LaprasPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -51,7 +52,7 @@ export const LaprasPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/leafish/LeafishPage.tsx b/packages/pdf/src/templates/leafish/LeafishPage.tsx index e0cb73edc..687f63036 100644 --- a/packages/pdf/src/templates/leafish/LeafishPage.tsx +++ b/packages/pdf/src/templates/leafish/LeafishPage.tsx @@ -15,6 +15,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -57,7 +58,7 @@ export const LeafishPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data).filter((section) => section !== "summary"); const sidebarSections = filterSections(page.sidebar, data).filter((section) => section !== "summary"); diff --git a/packages/pdf/src/templates/meowth/MeowthPage.tsx b/packages/pdf/src/templates/meowth/MeowthPage.tsx index ab4cd271b..77807e97d 100644 --- a/packages/pdf/src/templates/meowth/MeowthPage.tsx +++ b/packages/pdf/src/templates/meowth/MeowthPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -56,7 +57,7 @@ export const MeowthPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/onyx/OnyxPage.tsx b/packages/pdf/src/templates/onyx/OnyxPage.tsx index 39f6900f0..56a9a465a 100644 --- a/packages/pdf/src/templates/onyx/OnyxPage.tsx +++ b/packages/pdf/src/templates/onyx/OnyxPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -51,7 +52,7 @@ export const OnyxPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/pikachu/PikachuPage.tsx b/packages/pdf/src/templates/pikachu/PikachuPage.tsx index 1365998cc..c270c0454 100644 --- a/packages/pdf/src/templates/pikachu/PikachuPage.tsx +++ b/packages/pdf/src/templates/pikachu/PikachuPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -61,7 +62,7 @@ export const PikachuPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const showSidebar = !page.fullWidth; const hasPicture = hasTemplatePicture(picture); const mainSections = filterSections(page.main, data); diff --git a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx index 938fcdda0..fb6202f64 100644 --- a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx +++ b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx @@ -15,6 +15,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -54,7 +55,7 @@ export const RhyhornPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); diff --git a/packages/pdf/src/templates/scizor/ScizorPage.tsx b/packages/pdf/src/templates/scizor/ScizorPage.tsx index b4fd79d74..6f67cd0f5 100644 --- a/packages/pdf/src/templates/scizor/ScizorPage.tsx +++ b/packages/pdf/src/templates/scizor/ScizorPage.tsx @@ -14,6 +14,7 @@ import { WebsiteContactItem, } from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; +import { shouldShowResumeHeader } from "../shared/cover-letter"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size"; @@ -52,7 +53,7 @@ export const ScizorPage = ({ page, pageIndex }: TemplatePageProps) => { const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); - const showHeader = pageIndex === 0; + const showHeader = shouldShowResumeHeader(data, pageIndex); const mainSections = filterSections(page.main, data); const sidebarSections = page.fullWidth ? [] : filterSections(page.sidebar, data); const sections = [...mainSections, ...sidebarSections]; diff --git a/packages/pdf/src/templates/shared/cover-letter.test.ts b/packages/pdf/src/templates/shared/cover-letter.test.ts new file mode 100644 index 000000000..b8e28a3ae --- /dev/null +++ b/packages/pdf/src/templates/shared/cover-letter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { shouldShowResumeHeader } from "./cover-letter"; + +const createCoverLetterOnlyData = () => { + const data = structuredClone(sampleResumeData); + const coverLetter = data.customSections.find((section) => section.type === "cover-letter"); + + if (!coverLetter) throw new Error("sample resume must include a cover letter"); + + return { + ...data, + customSections: [coverLetter], + metadata: { + ...data.metadata, + layout: { + ...data.metadata.layout, + pages: [{ fullWidth: true, main: [coverLetter.id], sidebar: [] }], + }, + }, + }; +}; + +describe("shouldShowResumeHeader", () => { + it("hides the header when every visible layout section is a cover letter", () => { + expect(shouldShowResumeHeader(createCoverLetterOnlyData(), 0)).toBe(false); + }); + + it("keeps the first-page header for normal resume documents", () => { + expect(shouldShowResumeHeader(sampleResumeData, 0)).toBe(true); + expect(shouldShowResumeHeader(sampleResumeData, 1)).toBe(false); + }); +}); diff --git a/packages/pdf/src/templates/shared/cover-letter.ts b/packages/pdf/src/templates/shared/cover-letter.ts new file mode 100644 index 000000000..8d580a5fe --- /dev/null +++ b/packages/pdf/src/templates/shared/cover-letter.ts @@ -0,0 +1,16 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { filterSections } from "./filtering"; + +const isCoverLetterSection = (data: ResumeData, sectionId: string) => + data.customSections.some((section) => section.id === sectionId && section.type === "cover-letter"); + +const isCoverLetterOnlyDocument = (data: ResumeData) => { + const visibleSections = data.metadata.layout.pages.flatMap((page) => + filterSections([...page.main, ...page.sidebar], data), + ); + + return visibleSections.length > 0 && visibleSections.every((sectionId) => isCoverLetterSection(data, sectionId)); +}; + +export const shouldShowResumeHeader = (data: ResumeData, pageIndex: number) => + pageIndex === 0 && !isCoverLetterOnlyDocument(data); diff --git a/packages/resume/src/markdown.test.ts b/packages/resume/src/markdown.test.ts index 7590f43ce..0dc3ab931 100644 --- a/packages/resume/src/markdown.test.ts +++ b/packages/resume/src/markdown.test.ts @@ -50,4 +50,13 @@ describe("buildMarkdown", () => { expect(cover.length).toBeGreaterThan(0); expect(cover).not.toBe(md); }); + + it("renders the cover-letter scope without resume header or section heading", () => { + const cover = buildMarkdown(getResumeExportData(sampleResumeData, "cover-letter")); + + expect(cover).toContain("Dear Hiring Manager"); + expect(cover).not.toContain(`# ${sampleResumeData.basics.name}`); + expect(cover).not.toContain(`_${sampleResumeData.basics.headline}_`); + expect(cover).not.toContain("## Cover Letter"); + }); }); diff --git a/packages/resume/src/markdown.ts b/packages/resume/src/markdown.ts index 416aa1bf5..d0e526dca 100644 --- a/packages/resume/src/markdown.ts +++ b/packages/resume/src/markdown.ts @@ -9,13 +9,24 @@ type Sections = ResumeData["sections"]; */ 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[] = [...renderHeader(data.basics)]; + const blocks: string[] = isCoverLetterOnlyDocument(data) ? [] : [...renderHeader(data.basics)]; for (const page of data.metadata.layout.pages) { for (const sectionId of [...page.main, ...page.sidebar]) { @@ -274,7 +285,7 @@ function renderCustomSection(section: CustomSection): string[] { } if (sectionType === "cover-letter") { - const blocks = [heading(section.title)]; + 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));