feat(resume): add per-section heading visibility (#3477)

This commit is contained in:
Amruth Pillai
2026-09-05 20:34:21 -07:00
committed by GitHub
parent 66c25efe18
commit 368858a56f
25 changed files with 449 additions and 23 deletions
+33
View File
@@ -0,0 +1,33 @@
# Plan 21 implementation evidence
## Revision and scope
- Worktree: `issue-3060-section-heading-visibility`
- Base: current `origin/main` at dispatch, `2a4a1583b` (`fix(pdf): restore Gengar skill rating order (#3473)`)
- Product decisions applied: Q1 explicit Show heading toggle; Q2 Move-to continuations default visible; Q3 visual omission in preview/PDF/DOCX with accessible outline labels retained.
- No `.codegraph/` directory exists in this worktree, so CodeGraph was skipped after the required presence check.
- Intent discovery ran before edits; no matching local skill was available for this schema/PDF/DOCX/web change.
## Implementation
- Added backward-compatible `showHeading` section data for summary, built-ins, and custom sections. `parseResumeData` normalizes absent legacy values to `true`; explicit `false` survives round trips.
- Added heading toggles to built-in/summary and custom section menus. Toggle mutations use `useUpdateResumeData`, preserving undo/autosave/save/reload behavior; legacy absent values are treated as visible. Existing lock fieldset remains authoritative.
- Move-to-created custom sections explicitly set `showHeading: true`, independent of source heading state or copied title.
- `SectionShell` omits complete heading/icon/decoration output when disabled in both icon and no-icon branches. Empty titles still resolve localized defaults.
- DOCX section renderers omit visible heading paragraphs for summary, built-in, and custom sections while retaining content. Screen-reader mirror continues to expose section labels regardless of visual setting.
- Added characterization for Semantic CSS `section[id="..."] section-heading { display: none; }`; body remains while heading is omitted.
- Updated default/sample fixtures, generated schema references, recovery hashes, and compatibility tests; existing Gengar renderer/order changes remain untouched.
## Verification
- `pnpm --filter @reactive-resume/schema test`: 9 files, 132 tests passed.
- `pnpm --filter @reactive-resume/pdf test`: 81 files, 1059 tests passed.
- `pnpm --filter @reactive-resume/docx test`: 9 files, 76 tests passed.
- `pnpm --filter web test`: 135 files, 942 tests passed.
- `pnpm test`: full Turborepo suite passed (19 successful tasks; 10 cache hits).
- Affected typechecks passed: schema, PDF, DOCX, web.
- Focused menu, Move-to, accessible-outline, schema, PDF semantic, and DOCX renderer tests passed.
- `pnpm exec turbo boundaries`: passed (1108 files, 20 packages).
- Read-only `pnpm exec biome check` on 22 changed source/test files: passed; no write-capable `pnpm check` run.
- `git diff --check`: passed.
- Final diff review completed; local commit follows.
@@ -39,6 +39,7 @@ const defaultValues: FormValues = {
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -38,3 +38,28 @@ it.each([false, true])("exposes keyword list semantics for custom=%s", (custom)
if (!list) throw new Error("Missing keyword list");
expect(within(list).getAllByRole("listitem")).toHaveLength(3);
});
it("retains section labels when visual heading is disabled", () => {
const data = structuredClone(defaultResumeData);
data.sections.skills.showHeading = false;
data.sections.skills.items = [
{
id: "skill",
name: "TypeScript",
hidden: false,
icon: "",
iconColor: "",
proficiency: "Expert",
level: 3,
keywords: [],
},
];
render(
<I18nProvider i18n={i18n}>
<ResumeAccessibleText data={data} />
</I18nProvider>,
);
expect(screen.getByRole("heading", { level: 2, name: "Skills" })).toBeInTheDocument();
});
@@ -213,6 +213,7 @@ describe("createCustomSectionWithItem", () => {
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0]?.title).toBe("My Section");
expect(result.customSections[0]?.showHeading).toBe(true);
expect(result.customSections[0]?.items).toHaveLength(1);
expect(result.metadata.layout.pages[0]?.main).toContain(newSectionId);
});
+1
View File
@@ -204,6 +204,7 @@ function makeCustomSection(id: string, type: CustomSectionType, title: string, i
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [item as never],
@@ -225,6 +225,7 @@ function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps)
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const showHeading = section.showHeading !== false;
const onToggleSectionVisibility = () => {
updateResumeData((draft) => {
@@ -234,6 +235,14 @@ function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps)
});
};
const onToggleHeading = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].showHeading = !(draft.customSections[sectionIndex].showHeading !== false);
});
};
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
@@ -287,6 +296,11 @@ function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps)
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleHeading}>
{showHeading ? <EyeClosedIcon /> : <EyeIcon />}
{showHeading ? <Trans>Hide heading</Trans> : <Trans>Show heading</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onUpdateSection}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
@@ -170,6 +170,27 @@ describe("SkillsSectionDropdownMenu", () => {
});
});
describe("heading visibility", () => {
it("toggles heading visibility without changing section visibility", async () => {
render(
<I18nProvider i18n={i18n}>
<SectionDropdownMenu type="experience" />
</I18nProvider>,
);
await openSectionOptions();
fireEvent.click(await screen.findByRole("menuitem", { name: "Hide heading" }));
expect(mocks.updateResumeData).toHaveBeenCalledTimes(1);
const mutation = mocks.updateResumeData.mock.calls[0]?.[0] as (draft: Resume["data"]) => void;
const draft = structuredClone(mocks.resume.value.data);
mutation(draft);
expect(draft.sections.experience.showHeading).toBe(false);
expect(draft.sections.experience.hidden).toBe(false);
});
});
describe("chronological section sorting", () => {
it("sorts Experience once through one draft mutation and names only unresolved items", async () => {
render(
@@ -45,6 +45,7 @@ export function SectionDropdownMenu({ type }: Props) {
const updateResumeData = useUpdateResumeData();
const resume = useCurrentResume();
const section = type === "summary" ? resume.data.summary : resume.data.sections[type];
const showHeading = section.showHeading !== false;
const dropDownValue =
type === "skills" && resume.data.sections[type].layout === "inline" ? "inline" : section.columns.toString();
@@ -93,6 +94,16 @@ export function SectionDropdownMenu({ type }: Props) {
});
};
const onToggleHeading = () => {
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.showHeading = !(draft.summary.showHeading !== false);
} else {
draft.sections[type].showHeading = !(draft.sections[type].showHeading !== false);
}
});
};
const onRenameSection = async () => {
const newTitle = await prompt(t`What do you want to rename this section to?`, {
description: t`Leave empty to reset the title to the original.`,
@@ -190,6 +201,11 @@ export function SectionDropdownMenu({ type }: Props) {
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleHeading}>
{showHeading ? <EyeClosedIcon /> : <EyeIcon />}
{showHeading ? <Trans>Hide heading</Trans> : <Trans>Show heading</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onRenameSection}>
<PencilSimpleLineIcon />
<Trans>Rename</Trans>
+135
View File
@@ -338,6 +338,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the summary from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the summary heading, icon, and decoration while retaining summary content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the summary is kept on a single page instead of splitting across a page break.",
@@ -390,6 +395,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -508,6 +518,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -663,6 +678,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -795,6 +815,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -908,6 +933,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1030,6 +1060,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1115,6 +1150,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1207,6 +1247,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1324,6 +1369,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1441,6 +1491,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1558,6 +1613,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1675,6 +1735,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1813,6 +1878,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1901,6 +1971,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -1984,6 +2059,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2114,6 +2194,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2281,6 +2366,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2425,6 +2515,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2549,6 +2644,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2674,6 +2774,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2771,6 +2876,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -2875,6 +2985,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -3004,6 +3119,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -3133,6 +3253,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -3262,6 +3387,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -3391,6 +3521,11 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"type": "boolean",
"description": "Whether to hide the section from the resume."
},
"showHeading": {
"default": true,
"description": "Whether to show the section heading, icon, and decoration while retaining section content.",
"type": "boolean"
},
"keepTogether": {
"default": false,
"description": "If true, the section is kept on a single page instead of splitting across a page break.",
@@ -25,6 +25,7 @@ describe("renderSummary", () => {
icon: getDefaultSectionIconName("summary"),
content: "<p>Hello</p>",
hidden: true,
showHeading: true,
columns: 1,
keepTogether: false,
startOnNewPage: false,
@@ -38,6 +39,7 @@ describe("renderSummary", () => {
icon: getDefaultSectionIconName("summary"),
content: "",
hidden: false,
showHeading: true,
columns: 1,
keepTogether: false,
startOnNewPage: false,
@@ -51,6 +53,7 @@ describe("renderSummary", () => {
icon: getDefaultSectionIconName("summary"),
content: "<p>Hello world</p>",
hidden: false,
showHeading: true,
columns: 1,
keepTogether: false,
startOnNewPage: false,
@@ -60,12 +63,30 @@ describe("renderSummary", () => {
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
});
it("omits a disabled heading while retaining summary content", () => {
const summary: ResumeData["summary"] = {
title: "Summary",
icon: getDefaultSectionIconName("summary"),
content: "<p>Hello world</p>",
hidden: false,
showHeading: false,
columns: 1,
keepTogether: false,
startOnNewPage: false,
};
const paragraphs = renderSummary(summary, HEX);
expect(paragraphs).toHaveLength(1);
});
it("omits the heading when title is empty but still renders content", () => {
const summary: ResumeData["summary"] = {
title: "",
icon: getDefaultSectionIconName("summary"),
content: "<p>Hello world</p>",
hidden: false,
showHeading: true,
columns: 1,
keepTogether: false,
startOnNewPage: false,
@@ -81,6 +102,7 @@ const emptySection = <T extends SectionType>(type: T): ResumeData["sections"][T]
icon: getDefaultSectionIconName(type),
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -110,6 +132,7 @@ describe("renderCustomSection", () => {
icon: getDefaultSectionIconName("summary"),
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -147,6 +170,16 @@ describe("renderCustomSection", () => {
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
});
it("omits a disabled custom section heading while retaining content", () => {
const section: CustomSection = {
...baseCustom,
showHeading: false,
items: [{ id: "x", hidden: false, content: "<p>Hello</p>" } as never],
};
expect(renderCustomSection(section, HEX)).toHaveLength(1);
});
it("renders recipient + content for a cover-letter custom section", () => {
const section: CustomSection = {
...baseCustom,
+23 -16
View File
@@ -78,6 +78,14 @@ function sectionHeading(title: string, colorHex: string): Paragraph {
});
}
function sectionHeadingParagraph<T extends { showHeading?: boolean | undefined }>(
section: T,
title: string,
colorHex: string,
): Paragraph[] {
return section.showHeading !== false && title ? [sectionHeading(title, colorHex)] : [];
}
function titleAndSubtitle(primary: string, secondary: string, rightText?: string, bold = true): Paragraph {
const baseRun = getBaseRun();
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: primary, bold, ...baseRun })];
@@ -144,9 +152,7 @@ export function renderSummary(summary: ResumeData["summary"], colorHex: string):
if (summary.hidden || !summary.content) return [];
const paragraphs: Paragraph[] = [];
if (summary.title) {
paragraphs.push(sectionHeading(summary.title, colorHex));
}
paragraphs.push(...sectionHeadingParagraph(summary, summary.title, colorHex));
paragraphs.push(...htmlToParagraphs(summary.content, getHtmlStyle()));
return paragraphs;
}
@@ -155,7 +161,7 @@ function renderExperience(section: Sections["experience"], colorHex: string): Pa
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -201,7 +207,7 @@ function renderEducation(section: Sections["education"], colorHex: string): Para
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -234,7 +240,7 @@ function renderProjects(section: Sections["projects"], colorHex: string): Paragr
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of items) {
paragraphs.push(titleAndSubtitle(item.name, "", item.period));
@@ -254,7 +260,7 @@ function renderSkills(section: Sections["skills"], colorHex: string): Paragraph[
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -285,7 +291,7 @@ function renderLanguages(section: Sections["languages"], colorHex: string): Para
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -305,7 +311,7 @@ function renderInterests(section: Sections["interests"], colorHex: string): Para
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -325,7 +331,7 @@ function renderAwards(section: Sections["awards"], colorHex: string): Paragraph[
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of items) {
paragraphs.push(titleAndSubtitle(item.title, item.awarder, item.date, false));
@@ -345,7 +351,7 @@ function renderCertifications(section: Sections["certifications"], colorHex: str
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of items) {
paragraphs.push(titleAndSubtitle(item.title, item.issuer, item.date));
@@ -365,7 +371,7 @@ function renderPublications(section: Sections["publications"], colorHex: string)
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of items) {
paragraphs.push(titleAndSubtitle(item.title, item.publisher, item.date));
@@ -385,7 +391,7 @@ function renderVolunteer(section: Sections["volunteer"], colorHex: string): Para
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of items) {
paragraphs.push(titleAndSubtitle(item.organization, "", item.period));
@@ -408,7 +414,7 @@ function renderReferences(section: Sections["references"], colorHex: string): Pa
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -443,7 +449,7 @@ function renderProfiles(section: Sections["profiles"], colorHex: string): Paragr
const items = section.items.filter((item) => !item.hidden);
if (section.hidden || items.length === 0) return [];
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
const baseRun = getBaseRun();
for (const item of items) {
@@ -502,7 +508,7 @@ export function renderCustomSection(section: CustomSection, colorHex: string): P
// Summary-type custom sections render HTML content
if (sectionType === "summary") {
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
const paragraphs: Paragraph[] = sectionHeadingParagraph(section, section.title, colorHex);
for (const item of visibleItems) {
if ("content" in item && item.content) {
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
@@ -533,6 +539,7 @@ export function renderCustomSection(section: CustomSection, colorHex: string): P
title: section.title,
columns: section.columns,
hidden: false,
showHeading: section.showHeading,
items: visibleItems,
} as Sections[SectionType];
+4
View File
@@ -109,6 +109,7 @@ describe("getResumeSectionIcon", () => {
icon: "lightning",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -129,6 +130,7 @@ describe("getResumeSectionIcon", () => {
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -149,6 +151,7 @@ describe("getResumeSectionIcon", () => {
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -169,6 +172,7 @@ describe("getResumeSectionIcon", () => {
icon: "none",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
+2
View File
@@ -112,6 +112,7 @@ describe("getResumeSectionTitle", () => {
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -132,6 +133,7 @@ describe("getResumeSectionTitle", () => {
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [],
@@ -239,6 +239,7 @@ describe("semantic binding inventory", () => {
columns: 1,
hidden: false,
keepTogether: false,
showHeading: true,
startOnNewPage: false,
items: [
{
@@ -242,6 +242,34 @@ describe("semantic issue fixtures", () => {
expect(containsStyle(document, "opacity", 0.2)).toBe(true);
});
it("hides section heading decoration without hiding section content", async () => {
const data = buildIssueFixture();
data.sections.experience.showHeading = false;
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(findText(document, "Experience")).toBeUndefined();
expect(findText(document, "Analytical Engines")).toBeDefined();
});
it("characterizes section-heading display:none as a CSS alternative", async () => {
const data = buildIssueFixture();
data.metadata.page.hideSectionIcons = false;
data.metadata.stylesheet = {
mode: "semantic",
source: source('@version 1; section[id="experience"] section-heading { display: none; }'),
};
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(findText(document, "Experience")).toBeUndefined();
expect(findText(document, "Analytical Engines")).toBeDefined();
});
it("unbolds only the final skill-name primitive and preserves the experience title weight (#2223)", async () => {
const data = buildIssueFixture();
const stylesheet = source(`
@@ -133,6 +133,7 @@ const buildFixture = (rules: StyleRule[]): ResumeData => {
columns: 1,
hidden: false,
keepTogether: false,
showHeading: true,
startOnNewPage: false,
items: [
{
@@ -126,6 +126,7 @@ const buildFixture = (rules: StyleRule[]): ResumeData => {
columns: 1,
hidden: false,
keepTogether: false,
showHeading: true,
startOnNewPage: false,
items: [
{
+1
View File
@@ -316,6 +316,7 @@ const buildCompleteFixture = (): ResumeData => {
icon: "star",
columns: 1,
hidden: true,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -320,6 +320,11 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
const sectionHeadingContainerStyle = useTemplateStyle("sectionHeadingContainer");
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
const sectionHeadingEnabled = (() => {
if (sectionId === "summary") return data.summary.showHeading !== false;
if (sectionId in data.sections) return data.sections[sectionId as SectionType].showHeading !== false;
return data.customSections.find((section) => section.id === sectionId)?.showHeading !== false;
})();
const sectionHeadingNodeKey = semanticNodeKeys.sectionHeading(sectionNodeKey);
const sectionHeadingResolved = useResolvedNode(sectionHeadingNodeKey);
const sectionHeadingVisible = useSemanticNodeVisible(sectionHeadingNodeKey);
@@ -343,7 +348,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
{showHeading && (
{showHeading && sectionHeadingEnabled && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
{children}
@@ -356,7 +361,7 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
{showHeading && sectionHeadingVisible && (
{showHeading && sectionHeadingEnabled && sectionHeadingVisible && (
<View
{...resolvedPdfFlowProps(sectionHeadingResolved)}
style={composeStyles(
+36
View File
@@ -103,6 +103,42 @@ const customSectionFixture = (type: CustomSectionType, item: Record<string, unkn
});
describe("resumeDataSchema", () => {
it("defaults heading visibility to true for legacy summary and section data", () => {
const legacySummary = {
title: "",
icon: "article",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
content: "<p>Summary</p>",
};
const legacySection = {
title: "",
icon: "briefcase",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
};
expect(
parseResumeData({
...defaultResumeData,
summary: legacySummary,
sections: { ...defaultResumeData.sections, experience: legacySection },
}).summary.showHeading,
).toBe(true);
expect(
parseResumeData({
...defaultResumeData,
summary: legacySummary,
sections: { ...defaultResumeData.sections, experience: legacySection },
}).sections.experience.showHeading,
).toBe(true);
});
it("validates the default resume", () => {
expect(resumeDataSchema.safeParse(defaultResumeData).success).toBe(true);
});
+17 -1
View File
@@ -107,6 +107,11 @@ export const summarySchema = z.object({
),
columns: z.number().int().min(1).max(6).catch(1).describe("The number of columns the summary should span across."),
hidden: z.boolean().describe("Whether to hide the summary from the resume."),
showHeading: z
.boolean()
.optional()
.catch(true)
.describe("Whether to show the summary heading, icon, and decoration while retaining summary content."),
keepTogether: z
.boolean()
.catch(false)
@@ -295,6 +300,11 @@ export const baseSectionSchema = z.object({
),
columns: z.number().int().min(1).max(6).catch(1).describe("The number of columns the section should span across."),
hidden: z.boolean().describe("Whether to hide the section from the resume."),
showHeading: z
.boolean()
.optional()
.catch(true)
.describe("Whether to show the section heading, icon, and decoration while retaining section content."),
keepTogether: z
.boolean()
.catch(false)
@@ -694,7 +704,13 @@ export const resumeDataSchema = z.looseObject({
export type ResumeData = z.infer<typeof resumeDataSchema>;
export const parseResumeData = (data: unknown): ResumeData => resumeDataSchema.parse(data);
export const parseResumeData = (data: unknown): ResumeData => {
const parsed = resumeDataSchema.parse(data);
parsed.summary.showHeading ??= true;
for (const section of Object.values(parsed.sections)) section.showHeading ??= true;
for (const section of parsed.customSections) section.showHeading ??= true;
return parsed;
};
export type LayoutPage = z.infer<typeof pageLayoutSchema>;
export type Typography = z.infer<typeof typographySchema>;
+2
View File
@@ -6,6 +6,7 @@ const section = (icon: string) => ({
icon,
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [] as never[],
@@ -39,6 +40,7 @@ export const defaultResumeData: ResumeData = {
icon: "article",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
content: "",
+15
View File
@@ -44,6 +44,7 @@ export const sampleResumeData: ResumeData = {
icon: "article",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
content:
@@ -55,6 +56,7 @@ export const sampleResumeData: ResumeData = {
icon: "messenger-logo",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -91,6 +93,7 @@ export const sampleResumeData: ResumeData = {
icon: "briefcase",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -117,6 +120,7 @@ export const sampleResumeData: ResumeData = {
icon: "graduation-cap",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -144,6 +148,7 @@ export const sampleResumeData: ResumeData = {
icon: "code-simple",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -195,6 +200,7 @@ export const sampleResumeData: ResumeData = {
layout: "default",
keywordLayout: "inline",
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -265,6 +271,7 @@ export const sampleResumeData: ResumeData = {
icon: "translate",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -289,6 +296,7 @@ export const sampleResumeData: ResumeData = {
icon: "football",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -331,6 +339,7 @@ export const sampleResumeData: ResumeData = {
icon: "trophy",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -369,6 +378,7 @@ export const sampleResumeData: ResumeData = {
icon: "certificate",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -405,6 +415,7 @@ export const sampleResumeData: ResumeData = {
icon: "books",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -443,6 +454,7 @@ export const sampleResumeData: ResumeData = {
icon: "hand-heart",
columns: 2,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -481,6 +493,7 @@ export const sampleResumeData: ResumeData = {
icon: "phone",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [
@@ -506,6 +519,7 @@ export const sampleResumeData: ResumeData = {
icon: "briefcase",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
id: "019becaf-0b87-769d-98a6-46ccf558c0e8",
@@ -550,6 +564,7 @@ export const sampleResumeData: ResumeData = {
icon: "envelope-simple",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
id: "019bef5b-0b3d-7e2a-8a7c-12d9e23a4f6b",
@@ -65,6 +65,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `summary.icon` | `string` | yes | default: "" | Phosphor icon name to display before the summary section title in the PDF output. Empty string uses the default summary icon; 'none' hides the icon. |
| `summary.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the summary should span across. |
| `summary.hidden` | `boolean` | yes | — | Whether to hide the summary from the resume. |
| `summary.showHeading` | `boolean` | no | default: true | Whether to show the summary heading, icon, and decoration while retaining summary content. |
| `summary.keepTogether` | `boolean` | yes | default: false | If true, the summary is kept on a single page instead of splitting across a page break. |
| `summary.startOnNewPage` | `boolean` | yes | default: false | If true, the summary always begins on a new page. |
| `summary.content` | `string` | yes | — | The content of the summary of the resume. This should be a HTML-formatted string. |
@@ -74,6 +75,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.profiles.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.profiles.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.profiles.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.profiles.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.profiles.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.profiles.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.profiles.items` | `array` | yes | — | The items to display in the profiles section. |
@@ -93,6 +95,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.experience.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.experience.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.experience.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.experience.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.experience.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.experience.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.experience.items` | `array` | yes | — | The items to display in the experience section. |
@@ -119,6 +122,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.education.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.education.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.education.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.education.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.education.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.education.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.education.items` | `array` | yes | — | The items to display in the education section. |
@@ -141,6 +145,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.projects.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.projects.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.projects.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.projects.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.projects.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.projects.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.projects.items` | `array` | yes | — | The items to display in the projects section. |
@@ -159,6 +164,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.skills.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.skills.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.skills.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.skills.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.skills.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.skills.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.skills.items` | `array` | yes | — | The items to display in the skills section. |
@@ -179,6 +185,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.languages.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.languages.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.languages.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.languages.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.languages.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.languages.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.languages.items` | `array` | yes | — | The items to display in the languages section. |
@@ -193,6 +200,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.interests.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.interests.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.interests.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.interests.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.interests.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.interests.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.interests.items` | `array` | yes | — | The items to display in the interests section. |
@@ -209,6 +217,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.awards.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.awards.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.awards.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.awards.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.awards.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.awards.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.awards.items` | `array` | yes | — | The items to display in the awards section. |
@@ -228,6 +237,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.certifications.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.certifications.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.certifications.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.certifications.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.certifications.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.certifications.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.certifications.items` | `array` | yes | — | The items to display in the certifications section. |
@@ -247,6 +257,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.publications.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.publications.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.publications.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.publications.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.publications.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.publications.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.publications.items` | `array` | yes | — | The items to display in the publications section. |
@@ -266,6 +277,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.volunteer.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.volunteer.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.volunteer.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.volunteer.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.volunteer.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.volunteer.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.volunteer.items` | `array` | yes | — | The items to display in the volunteer section. |
@@ -285,6 +297,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.references.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `sections.references.columns` | `integer` | yes | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `sections.references.hidden` | `boolean` | yes | — | Whether to hide the section from the resume. |
| `sections.references.showHeading` | `boolean` | no | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `sections.references.keepTogether` | `boolean` | yes | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `sections.references.startOnNewPage` | `boolean` | yes | default: false | If true, the section always begins on a new page. |
| `sections.references.items` | `array` | yes | — | The items to display in the references section. |
@@ -306,6 +319,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type summary, schema summaryItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type summary, schema summaryItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type summary, schema summaryItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type summary, schema summaryItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type summary, schema summaryItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type summary, schema summaryItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type summary, schema summaryItemSchema at customSections[]) | — | — |
@@ -321,6 +335,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type profiles, schema profileItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type profiles, schema profileItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type profiles, schema profileItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type profiles, schema profileItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type profiles, schema profileItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type profiles, schema profileItemSchema at customSections[]) | — | — |
@@ -343,6 +358,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type experience, schema experienceItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type experience, schema experienceItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type experience, schema experienceItemSchema at customSections[]) | — | — |
@@ -372,6 +388,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type education, schema educationItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type education, schema educationItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type education, schema educationItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type education, schema educationItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type education, schema educationItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type education, schema educationItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type education, schema educationItemSchema at customSections[]) | — | — |
@@ -397,6 +414,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type projects, schema projectItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type projects, schema projectItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type projects, schema projectItemSchema at customSections[]) | — | — |
@@ -418,6 +436,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type skills, schema skillItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type skills, schema skillItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type skills, schema skillItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type skills, schema skillItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type skills, schema skillItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `string` | no (type skills, schema skillItemSchema at customSections[]) | enum: ["inline","list"]; default: "inline" | How skill keywords are displayed: inline separated by commas, or one bullet per keyword. |
@@ -439,6 +458,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type languages, schema languageItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type languages, schema languageItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type languages, schema languageItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type languages, schema languageItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type languages, schema languageItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type languages, schema languageItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type languages, schema languageItemSchema at customSections[]) | — | — |
@@ -456,6 +476,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type interests, schema interestItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type interests, schema interestItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type interests, schema interestItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type interests, schema interestItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type interests, schema interestItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type interests, schema interestItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type interests, schema interestItemSchema at customSections[]) | — | — |
@@ -475,6 +496,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type awards, schema awardItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type awards, schema awardItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type awards, schema awardItemSchema at customSections[]) | — | — |
@@ -497,6 +519,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type certifications, schema certificationItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type certifications, schema certificationItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type certifications, schema certificationItemSchema at customSections[]) | — | — |
@@ -519,6 +542,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type publications, schema publicationItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type publications, schema publicationItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type publications, schema publicationItemSchema at customSections[]) | — | — |
@@ -541,6 +565,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type volunteer, schema volunteerItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type volunteer, schema volunteerItemSchema at customSections[]) | — | — |
@@ -563,6 +588,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type references, schema referenceItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type references, schema referenceItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type references, schema referenceItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type references, schema referenceItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type references, schema referenceItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type references, schema referenceItemSchema at customSections[]) | — | — |
@@ -585,6 +611,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].icon` | `string` | yes (type cover-letter, schema coverLetterItemSchema at customSections[]) | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |
| `customSections[].columns` | `integer` | yes (type cover-letter, schema coverLetterItemSchema at customSections[]) | minimum: 1; maximum: 6; default: 1 | The number of columns the section should span across. |
| `customSections[].hidden` | `boolean` | yes (type cover-letter, schema coverLetterItemSchema at customSections[]) | — | Whether to hide the section from the resume. |
| `customSections[].showHeading` | `boolean` | no (type cover-letter, schema coverLetterItemSchema at customSections[]) | default: true | Whether to show the section heading, icon, and decoration while retaining section content. |
| `customSections[].keepTogether` | `boolean` | yes (type cover-letter, schema coverLetterItemSchema at customSections[]) | default: false | If true, the section is kept on a single page instead of splitting across a page break. |
| `customSections[].startOnNewPage` | `boolean` | yes (type cover-letter, schema coverLetterItemSchema at customSections[]) | default: false | If true, the section always begins on a new page. |
| `customSections[].keywordLayout` | `any` | no (type cover-letter, schema coverLetterItemSchema at customSections[]) | — | — |
+4 -4
View File
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { compareResumeRecovery } from "./compare-resume";
const SYNTHETIC_SOURCE_HASH = "2fc24960657492304068db58c4f4f8b0861ef4937bf12aadeced12ed03856d41";
const RECOVERED_COPY_HASH = "87b3c6293158e14591387969b804dda0233ae83df79e59043b340c9f0180bf6d";
const CURRENT_COPY_HASH = "701ee9fde9161c974ee6325e169132249ccb77adb31d875541ab70dd20835017";
const DEFAULT_RESUME_HASH = "bb0b5937876766c5a0d5014538257870396f30902d86a2d32d2c901f644ffb1f";
const SYNTHETIC_SOURCE_HASH = "33bd2901474d077a37ed73f0646ac2812a0fbb6ca8ca7bd5825c56ec4c598b8c";
const RECOVERED_COPY_HASH = "1c0e1eefac8375d15b5d0d5fc040a970d1f10242c31a224f3c2a0ea5750c719e";
const CURRENT_COPY_HASH = "0e8a2e90ccb44068f500a92bc0d312b4290269f1b68ba925fdfe6109a15e739e";
const DEFAULT_RESUME_HASH = "1870f56666738b8748ac2769f5c79fe7b19863efecc07142a34c425254d871d8";
const FORMAT_CHARACTERS = [
["zero-width space (U+200B)", "\u200B"],