From 4d53a6d1de414bf4589367b01bc2464c74151126 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Wed, 19 Aug 2026 02:24:38 +0200 Subject: [PATCH] fix(stylesheet): apply item-header to every header row on every template (#3357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SectionItemHeader` only rendered its own box when a template opted into `mainItemHeaderBorder` (only Ditgar did). Everywhere else it walked the header children and attached the resolved `item-header` style to the first descendant that happened to be a literal `View` or `InlineItemHeader`. Sections whose header starts with anything else — certifications, awards, projects, publications, references — matched nothing, so the style was silently dropped; stacked headers such as experience matched only their first row, so a second row went unstyled. The header now always renders its own `Div`, so `item-header` covers the whole header row of every section on every template. `Div` rather than `View` keeps the base row gap the rows used to inherit from the item box, and Ditgar keeps its tight header via `rowGap: 0` on its own `sectionItemHeader` slot, so rendered output is unchanged apart from the newly styled rows. `mainItemHeaderBorder` is now dead and removed. Fixes #3349 --- .../src/semantic/item-header-binding.test.tsx | 92 +++++++++++++++++++ .../pdf/src/templates/ditgar/DitgarPage.tsx | 3 +- .../pdf/src/templates/shared/sections.tsx | 74 ++++----------- packages/pdf/src/templates/shared/types.ts | 1 - 4 files changed, 113 insertions(+), 57 deletions(-) create mode 100644 packages/pdf/src/semantic/item-header-binding.test.tsx diff --git a/packages/pdf/src/semantic/item-header-binding.test.tsx b/packages/pdf/src/semantic/item-header-binding.test.tsx new file mode 100644 index 000000000..7e6ec7e8b --- /dev/null +++ b/packages/pdf/src/semantic/item-header-binding.test.tsx @@ -0,0 +1,92 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Template } from "@reactive-resume/schema/templates"; +import { describe, expect, it } from "vitest"; +import { pdf } from "@react-pdf/renderer"; +import { createElement } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { templateSchema } from "@reactive-resume/schema/templates"; +import { ResumeDocument } from "../document"; +import { resolveResumeRuntime } from "./resolve"; + +type HostNode = { type: string; style?: unknown; value?: string; children?: HostNode[] }; + +const HEADER_BACKGROUND = "#ff0000"; +const STYLESHEET = `@version 1;\nitem-header { background-color: ${HEADER_BACKGROUND}; }`; + +const mergedStyle = (node: HostNode): Record => + Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : [])); +const nodeText = (node: HostNode): string => node.value ?? (node.children ?? []).map(nodeText).join(""); +const flatten = (node: HostNode): HostNode[] => [node, ...(node.children ?? []).flatMap(flatten)]; + +const buildFixture = (): ResumeData => { + const data = structuredClone(defaultResumeData); + data.picture.hidden = true; + data.sections.certifications.items = [ + { + id: "certification/1", + hidden: false, + title: "Certified Kubernetes Administrator", + issuer: "The Linux Foundation", + date: "Sep 2023", + website: { url: "", label: "", inlineLink: false }, + description: "", + }, + ]; + data.sections.experience.items = [ + { + id: "experience/1", + hidden: false, + company: "Braincore", + position: "Automation Engineer", + location: "Jakarta", + period: "Jan 2024 - Mar 2025", + website: { url: "", label: "", inlineLink: false }, + description: "", + roles: [], + }, + ]; + const page = data.metadata.layout.pages[0]; + if (!page) throw new Error("Missing authored page"); + page.main = ["certifications", "experience"]; + page.sidebar = []; + + return data; +}; + +const renderHeaderBoxes = async (template: Template): Promise => { + const data = buildFixture(); + const semanticRuntime = resolveResumeRuntime({ + data, + template, + mode: "semantic", + source: { languageVersion: 1, text: STYLESHEET }, + }); + const element = createElement(ResumeDocument, { data, template, semanticRuntime }) as unknown as Parameters< + typeof pdf + >[0]; + const instance = pdf(element); + await expect.poll(() => instance.container.document).not.toBeNull(); + const document = instance.container.document as unknown as HostNode; + + return flatten(document) + .filter((node) => mergedStyle(node).backgroundColor === HEADER_BACKGROUND) + .map((node) => nodeText(node)); +}; + +// https://github.com/amruthpillai/reactive-resume/issues/3349: the resolved `item-header` style used +// to be bound onto the first child that happened to be a literal `View`, so sections whose header +// starts with anything else lost it entirely and stacked headers only styled their first row. +describe("item-header binding", () => { + it.each(templateSchema.options)("covers every header row of every section on %s", async (template) => { + const headers = await renderHeaderBoxes(template); + + expect(headers).toHaveLength(2); + const [certification, experience] = headers; + expect(certification).toContain("Certified Kubernetes Administrator"); + expect(certification).toContain("The Linux Foundation"); + expect(certification).toContain("Sep 2023"); + expect(experience).toContain("Braincore"); + expect(experience).toContain("Automation Engineer"); + expect(experience).toContain("Jan 2024 - Mar 2025"); + }); +}); diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index b854761c6..2670cc3d8 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -63,7 +63,6 @@ type DitgarHeaderProps = { const ditgarFeatures = { stackSidebarItemHeader: true, - mainItemHeaderBorder: true, } satisfies TemplateFeatures; export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => { @@ -319,6 +318,8 @@ const useDitgarTemplate = (): DitgarTemplate => { borderBottomColor: accentFor(context), }), sectionItemHeader: (context) => ({ + /** Ditgar packs the header rows tight inside the accent border, without the usual row gap. */ + rowGap: 0, ...(context.placement === "main" ? { borderLeftWidth: 2, diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index 472c1024f..e33e7e3cd 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -19,11 +19,11 @@ import type { VolunteerItem, } from "@reactive-resume/schema/resume/data"; import type { IconName } from "phosphor-icons-react-pdf/dynamic"; -import type { ReactElement, ReactNode } from "react"; +import type { ReactNode } from "react"; import type { CombinedTextName } from "../../semantic/node-keys"; import type { StyleInput, TemplatePlacement } from "./styles"; import type { CustomItemSection, ItemSection } from "./types"; -import { Children, cloneElement, createContext, Fragment, isValidElement, use } from "react"; +import { Children, createContext, Fragment, isValidElement, use } from "react"; import { View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { getResumeSectionIcon } from "../../section-icon"; @@ -527,21 +527,19 @@ const SectionItem = ({ itemId, children, style }: SectionItemProps) => { ); }; -const InlineItemHeader = ({ - leading, - middle, - trailing, - nodeKey, -}: InlineItemHeaderProps & { nodeKey?: string | undefined }) => { +/** + * The single-line variant of an item header. `SectionItemHeader` owns the `item-header` node and + * its resolved style, so this only lays the three slots out and addresses their template parts + * against the surrounding item-header key. + */ +const InlineItemHeader = ({ leading, middle, trailing }: InlineItemHeaderProps) => { const inlineItemHeaderStyle = useTemplateStyle("inlineItemHeader"); const leadingStyle = useTemplateStyle("inlineItemHeaderLeading"); const middleStyle = useTemplateStyle("inlineItemHeaderMiddle"); const trailingStyle = useTemplateStyle("inlineItemHeaderTrailing"); - const resolved = useResolvedNode(nodeKey); + const nodeKey = useSemanticNodeKey(); const renderedChildKeys = useRenderedChildKeys(nodeKey); - const visible = useSemanticNodeVisible(nodeKey); - if (!visible) return null; const parts = [ { nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-leading") ?? "inline-item-header-leading", @@ -584,11 +582,7 @@ const InlineItemHeader = ({ }, ]; - return ( - - {projectRenderedChildren(renderedChildKeys, parts)} - - ); + return {projectRenderedChildren(renderedChildKeys, parts)}; }; const stackedSidebarSplitRowStyle = { @@ -653,55 +647,25 @@ const ItemHeaderRow = ({ children, style }: ItemHeaderRowProps) => { ); }; +/** + * The box around an item's header rows, exposed to Semantic CSS as `item-header`. + * + * It always renders its own `Div`, whatever shape the section's header markup has, so a resolved + * `item-header` style covers every header row of every section on every template. `Div` rather than + * `View` because the header rows keep the row gap they used to get from the item box around them. + */ const SectionItemHeader = ({ children }: SectionItemHeaderProps) => { const itemNodeKey = useSemanticNodeKey(); const itemHeaderNodeKey = itemNodeKey ? semanticNodeKeys.itemHeader(itemNodeKey) : undefined; - const resolved = useResolvedNode(itemHeaderNodeKey); - const mainItemHeaderBorder = useTemplateFeature("mainItemHeaderBorder"); const sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader"); const exists = useSemanticNodeExists(itemHeaderNodeKey); - const visible = useSemanticNodeVisible(itemHeaderNodeKey); if (!exists) return <>{children}; - if (!visible) return null; - - if (!mainItemHeaderBorder) { - const bindFirstExistingView = (node: ReactNode): [ReactNode, boolean] => { - if (!isValidElement(node)) return [node, false]; - if (node.type === InlineItemHeader) { - const inline = node as ReactElement; - return [cloneElement(inline, { nodeKey: itemHeaderNodeKey }), true]; - } - if (node.type === View) { - const view = node as ReactElement<{ style?: StyleInput }>; - return [ - cloneElement(view, { - ...resolvedPdfFlowProps(resolved), - style: composeStyles(view.props.style, resolved.style), - }), - true, - ]; - } - if (node.type !== Fragment) return [node, false]; - - let bound = false; - const fragment = node as ReactElement<{ children?: ReactNode }>; - const nextChildren = Children.map(fragment.props.children, (child) => { - if (bound) return child; - const [next, didBind] = bindFirstExistingView(child); - bound = didBind; - return next; - }); - return [cloneElement(fragment, {}, nextChildren), bound]; - }; - const [boundChildren] = bindFirstExistingView(children); - return {boundChildren}; - } return ( - +
{children} - +
); }; diff --git a/packages/pdf/src/templates/shared/types.ts b/packages/pdf/src/templates/shared/types.ts index 6f56ed695..8095e997e 100644 --- a/packages/pdf/src/templates/shared/types.ts +++ b/packages/pdf/src/templates/shared/types.ts @@ -28,7 +28,6 @@ export type TemplateFeatures = { sectionTimeline?: boolean; inlineItemHeader?: boolean; stackSidebarItemHeader?: boolean; - mainItemHeaderBorder?: boolean; }; export type SectionTimelineStyleSlots = {