Files
Reactive-Resume/packages/pdf/src/templates/shared/sections.tsx
T
Amruth Pillai 4d53a6d1de fix(stylesheet): apply item-header to every header row on every template (#3357)
`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
2026-08-19 02:24:38 +02:00

1538 lines
50 KiB
TypeScript

import type { Style } from "@react-pdf/types";
import type {
AwardItem,
CertificationItem,
CoverLetterItem,
CustomSectionType,
EducationItem,
ExperienceItem,
InterestItem,
LanguageItem,
ProfileItem,
ProjectItem,
PublicationItem,
ReferenceItem,
ResumeData,
SectionType,
SkillItem,
SummaryItem,
VolunteerItem,
} from "@reactive-resume/schema/resume/data";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
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, createContext, Fragment, isValidElement, use } from "react";
import { View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { getResumeSectionIcon } from "../../section-icon";
import { getResumeSectionTitle } from "../../section-title";
import { resolvedPdfFlowProps, resolvedPdfTextProps } from "../../semantic/adapter";
import {
projectRenderedChildren,
SemanticItemNodeKeyProvider,
SemanticNodeKeyProvider,
useRenderedChildKeys,
useResolvedNode,
useSemanticNodeBindings,
useSemanticNodeExists,
useSemanticNodeKey,
useSemanticNodeVisible,
useSemanticSectionNodeKey,
} from "../../semantic/context";
import { semanticNodeKeys } from "../../semantic/node-keys";
import { ITEM_HEADER_ROW_PART_KEYS } from "../../semantic/shared-parts";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
import { getWebsiteDisplayText } from "./contact";
import {
SectionStyleProvider,
TemplatePlacementProvider,
useSectionStyleRule,
useTemplateFeature,
useTemplateFeatureStyle,
useTemplatePageNodeKey,
useTemplatePlacement,
useTemplateStyle,
} from "./context";
import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
import { LevelDisplay } from "./level-display";
import { getTemplateMetrics } from "./metrics";
import {
Bold,
Div,
Heading,
Icon,
Link,
SectionHeadingIcon,
SemanticTemplatePartView,
Small,
semanticTemplatePartNodeKey,
Text,
} from "./primitives";
import { RichText } from "./rich-text";
import { createRtlStyleHelpers } from "./rtl";
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
import { hasSplitRowText } from "./split-row";
import { getSectionStyleRuleContext } from "./style-rules";
import { composeStyles, mergeStyles } from "./styles";
type SectionItemsContextValue = {
itemStyle: StyleInput;
useTimeline: boolean;
};
type SectionShellProps = {
sectionId: string;
title: string;
showHeading?: boolean;
children: ReactNode;
};
type SectionItemsProps = {
children: ReactNode;
columns?: number;
};
type SectionItemProps = {
itemId: string;
children: ReactNode;
style?: StyleInput;
};
type InlineItemHeaderProps = {
leading?: ReactNode;
middle?: ReactNode;
trailing?: ReactNode;
};
type SectionItemHeaderProps = {
children: ReactNode;
};
type ItemWebsite = {
url: string;
label: string;
inlineLink?: boolean | undefined;
};
type ItemTitleProps = {
children: ReactNode;
website: ItemWebsite;
field: string;
bold?: boolean;
};
type ItemWebsiteLinkProps = {
website: ItemWebsite;
};
type SummarySectionProps = {
showHeading?: boolean;
};
type CustomSummarySectionProps = {
section: CustomItemSection<SummaryItem>;
showHeading?: boolean;
};
type ItemSectionProps<T> = {
sectionId?: string;
sectionData?: ItemSection<T>;
};
type CoverLetterSectionProps = {
section: CustomItemSection<CoverLetterItem>;
};
type CustomSectionProps = {
sectionId: string;
showHeading?: boolean;
};
type SectionProps = {
section: string;
placement: TemplatePlacement;
showHeading?: boolean;
};
type SemanticTextRun = {
field: string;
value: string;
prefix?: string;
suffix?: string;
};
type SemanticTextRunsProps = {
runs: readonly SemanticTextRun[];
separator: string;
host: CombinedTextName;
style?: StyleInput;
nodeKey?: string | undefined;
fieldOwnerNodeKey?: string | undefined;
};
const SectionItemsContext = createContext<SectionItemsContextValue>({ itemStyle: undefined, useTimeline: false });
const SECTION_ITEM_PLACEHOLDER_KEYS = [
"placeholder-1",
"placeholder-2",
"placeholder-3",
"placeholder-4",
"placeholder-5",
"placeholder-6",
] as const;
const defaultSectionHeadingContainerStyle = {
flexDirection: "row",
alignItems: "flex-start",
columnGap: 4,
} satisfies Style;
const getSectionHeadingTextStyle = (...styles: StyleInput[]): Style[] =>
composeStyles(...styles).map(
({
borderBottomWidth: _borderBottomWidth,
borderLeftWidth: _borderLeftWidth,
borderRightWidth: _borderRightWidth,
borderTopWidth: _borderTopWidth,
borderWidth: _borderWidth,
flexGrow: _flexGrow,
flexShrink: _flexShrink,
marginBottom: _marginBottom,
marginLeft: _marginLeft,
marginRight: _marginRight,
marginTop: _marginTop,
paddingBottom: _paddingBottom,
paddingLeft: _paddingLeft,
paddingRight: _paddingRight,
paddingTop: _paddingTop,
width: _width,
...textStyle
}) => textStyle,
);
const useSectionItemsContext = () => use(SectionItemsContext);
export const SemanticTextRuns = ({
runs,
separator,
host,
style,
nodeKey,
fieldOwnerNodeKey,
}: SemanticTextRunsProps) => {
const contextualNodeKey = useSemanticNodeKey();
const fieldParentNodeKey = fieldOwnerNodeKey ?? contextualNodeKey;
const combinedTextOwnerNodeKey =
host === "experience-position-location" || host === "education-area-degree"
? semanticTemplatePartNodeKey(fieldParentNodeKey, "inline-item-header-leading")
: (nodeKey ?? fieldParentNodeKey);
const combinedTextNodeKey = combinedTextOwnerNodeKey
? semanticNodeKeys.combinedText(combinedTextOwnerNodeKey, host)
: undefined;
const ownerResolved = useResolvedNode(nodeKey);
const combinedTextResolved = useResolvedNode(combinedTextNodeKey);
const ownerVisible = useSemanticNodeVisible(nodeKey);
const combinedTextVisible = useSemanticNodeVisible(combinedTextNodeKey);
const renderedChildKeys = useRenderedChildKeys(combinedTextNodeKey);
const { isNodeVisible } = useSemanticNodeBindings();
const entries = runs.flatMap((run) => {
if (!hasSplitRowText(run.value)) return [];
const fieldNodeKey = fieldParentNodeKey ? semanticNodeKeys.field(fieldParentNodeKey, run.field) : run.field;
return isNodeVisible(fieldNodeKey) ? [{ nodeKey: fieldNodeKey, value: run }] : [];
});
const visibleRuns = projectRenderedChildren(renderedChildKeys, entries);
if (!ownerVisible || !combinedTextVisible || visibleRuns.length === 0) return null;
return (
<SemanticNodeKeyProvider nodeKey={fieldParentNodeKey}>
<Text
bindSemanticNode={false}
{...resolvedPdfTextProps(ownerResolved)}
{...resolvedPdfTextProps(combinedTextResolved)}
style={composeStyles(style, ownerResolved.style, combinedTextResolved.style)}
>
{visibleRuns.map(({ field, value, prefix = "", suffix = "" }, index) => (
<Fragment key={field}>
{index === 0 ? "" : separator}
<Text semanticField={field}>
{prefix}
{value}
{suffix}
</Text>
</Fragment>
))}
</Text>
</SemanticNodeKeyProvider>
);
};
const getChildKey = (child: ReactNode, fallbackIndex: number) => {
return isValidElement(child) && child.key !== null ? String(child.key) : `child-${fallbackIndex}`;
};
const getRowKey = (row: ReactNode[], rowIndex: number) => {
const childKeys = row.map(getChildKey).join("|");
return childKeys || `row-${rowIndex}`;
};
const getVisibleItems = <T extends { hidden: boolean }>(section: ItemSection<T>, sectionType?: string): T[] => {
if (!hasVisibleItems(section, sectionType)) return [];
return filterItems(section.items, sectionType);
};
// Resolve the per-section page-break constraints the same way section title/icon are resolved:
// standard sections live under data.sections[id], the summary under data.summary, and custom
// sections are matched by id. Missing flags default to false (older stored resumes lack them).
const getSectionBreaks = (data: ResumeData, sectionId: string): { keepTogether: boolean; startOnNewPage: boolean } => {
if (sectionId === "summary") {
return { keepTogether: data.summary.keepTogether, startOnNewPage: data.summary.startOnNewPage };
}
if (sectionId in data.sections) {
const section = data.sections[sectionId as SectionType];
return { keepTogether: section.keepTogether, startOnNewPage: section.startOnNewPage };
}
const customSection = data.customSections.find((section) => section.id === sectionId);
return {
keepTogether: customSection?.keepTogether ?? false,
startOnNewPage: customSection?.startOnNewPage ?? false,
};
};
const SectionShell = ({ sectionId, title, showHeading = true, children }: SectionShellProps) => {
const data = useRender();
const pageNodeKey = useTemplatePageNodeKey();
const sectionNodeKey = useSemanticSectionNodeKey(pageNodeKey, sectionId);
const resolved = useResolvedNode(sectionNodeKey);
const visible = useSemanticNodeVisible(sectionNodeKey);
const sectionStyle = useTemplateStyle("section");
const sectionRuleStyle = useSectionStyleRule("section");
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
const sectionHeadingContainerStyle = useTemplateStyle("sectionHeadingContainer");
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
const sectionHeadingNodeKey = semanticNodeKeys.sectionHeading(sectionNodeKey);
const sectionHeadingResolved = useResolvedNode(sectionHeadingNodeKey);
const sectionHeadingVisible = useSemanticNodeVisible(sectionHeadingNodeKey);
const sectionIcon = getResumeSectionIcon(data, sectionId);
const showIcon = Boolean(sectionIcon) && !data.metadata.page.hideSectionIcons;
const { keepTogether, startOnNewPage } = getSectionBreaks(data, sectionId);
// wrap={false} keeps the whole section on one page; break forces it onto a fresh page.
// Only set the props when enabled so we never pass undefined (exactOptionalPropertyTypes).
const breakProps: { wrap?: false; break?: true } = {};
// ponytail: react-pdf ceiling — wrap={false} keeps a section together only if it fits on
// one page; a section taller than a full page is clipped, not split. No upgrade path in
// react-pdf, so the layout UI warns users this only works for sections that fit one page.
if (keepTogether) breakProps.wrap = false;
if (startOnNewPage) breakProps.break = true;
const flowProps = { ...breakProps, ...resolvedPdfFlowProps(resolved) };
const resolvedSectionStyle = composeStyles(sectionStyle, sectionRuleStyle, resolved.style);
if (!visible) return null;
if (!showIcon) {
// No icon: render heading exactly as before (no structural change)
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
{children}
</View>
</SemanticNodeKeyProvider>
);
}
// With icon: wrap in a flex row container that inherits the heading's border/decoration
return (
<SemanticNodeKeyProvider nodeKey={sectionNodeKey}>
<View style={resolvedSectionStyle} {...flowProps}>
{showHeading && sectionHeadingVisible && (
<View
{...resolvedPdfFlowProps(sectionHeadingResolved)}
style={composeStyles(
sectionHeadingStyle,
defaultSectionHeadingContainerStyle,
sectionHeadingContainerStyle,
sectionHeadingRuleStyle,
sectionHeadingResolved.style,
)}
>
<SectionHeadingIcon
nodeKey={semanticNodeKeys.icon(sectionHeadingNodeKey, "section")}
name={sectionIcon as IconName}
/>
<Heading
bindSemanticNode={false}
style={getSectionHeadingTextStyle(sectionHeadingStyle, sectionHeadingRuleStyle)}
>
{sectionTitle}
</Heading>
</View>
)}
{children}
</View>
</SemanticNodeKeyProvider>
);
};
const SectionItems = ({ children, columns = 1 }: SectionItemsProps) => {
const data = useRender();
const sectionNodeKey = useSemanticNodeKey();
const sectionItemsNodeKey = sectionNodeKey ? semanticNodeKeys.sectionItems(sectionNodeKey) : undefined;
const resolved = useResolvedNode(sectionItemsNodeKey);
const visible = useSemanticNodeVisible(sectionItemsNodeKey);
const renderedChildKeys = useRenderedChildKeys(sectionItemsNodeKey);
const placement = useTemplatePlacement();
const sectionTimeline = useTemplateFeature("sectionTimeline");
const sectionItemsStyle = useTemplateStyle("sectionItems");
const timelineItemsStyle = useTemplateFeatureStyle("sectionTimeline", "items");
const timelineLineStyle = useTemplateFeatureStyle("sectionTimeline", "line");
const metrics = getTemplateMetrics(data.metadata.page);
const layout = getSectionItemsLayout({
columns,
rowGap: metrics.itemGapY,
columnGap: metrics.itemGapX,
});
const useTimeline = shouldUseSectionTimeline({
sectionTimeline,
placement,
columns: layout.columns,
});
const context = { itemStyle: layout.itemStyle, useTimeline };
const authoredChildren = Children.toArray(children);
const childrenByNodeKey = new Map(
authoredChildren.flatMap((child) => {
if (!isValidElement<{ itemId?: string }>(child) || !child.props.itemId || !sectionItemsNodeKey) return [];
return [[semanticNodeKeys.item(sectionItemsNodeKey, child.props.itemId), child] as const];
}),
);
const resolvedChildren = renderedChildKeys
? renderedChildKeys.flatMap((nodeKey) => {
const child = childrenByNodeKey.get(nodeKey);
return child ? [child] : [];
})
: authoredChildren;
const rtlRowStyle = createRtlStyleHelpers(data.rtl).gridRowStyle;
if (!visible) return null;
if (!useTimeline) {
if (layout.isGrid) {
const rows = getSectionItemRows(resolvedChildren, layout.columns);
return (
<SemanticNodeKeyProvider nodeKey={sectionItemsNodeKey}>
<SectionItemsContext.Provider value={context}>
<View
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(layout.containerStyle, sectionItemsStyle, resolved.style)}
>
{rows.map((row, rowIndex) => (
<View key={getRowKey(row, rowIndex)} style={composeStyles(layout.rowStyle, rtlRowStyle)}>
{row}
{SECTION_ITEM_PLACEHOLDER_KEYS.slice(0, layout.columns - row.length).map((placeholderKey) => (
<View key={placeholderKey} style={composeStyles(layout.itemStyle)} />
))}
</View>
))}
</View>
</SectionItemsContext.Provider>
</SemanticNodeKeyProvider>
);
}
return (
<SemanticNodeKeyProvider nodeKey={sectionItemsNodeKey}>
<SectionItemsContext.Provider value={context}>
<View
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(layout.containerStyle, sectionItemsStyle, resolved.style)}
>
{resolvedChildren}
</View>
</SectionItemsContext.Provider>
</SemanticNodeKeyProvider>
);
}
return (
<SemanticNodeKeyProvider nodeKey={sectionItemsNodeKey}>
<SectionItemsContext.Provider value={context}>
<View
{...resolvedPdfFlowProps(resolved)}
style={composeStyles(layout.containerStyle, sectionItemsStyle, timelineItemsStyle, resolved.style)}
>
<SemanticTemplatePartView
ownerNodeKey={sectionItemsNodeKey}
partKeys={["timeline-line"]}
style={composeStyles(timelineLineStyle)}
/>
{resolvedChildren}
</View>
</SectionItemsContext.Provider>
</SemanticNodeKeyProvider>
);
};
const SectionItem = ({ itemId, children, style }: SectionItemProps) => {
const sectionItemsNodeKey = useSemanticNodeKey();
const itemNodeKey = sectionItemsNodeKey ? semanticNodeKeys.item(sectionItemsNodeKey, itemId) : undefined;
const visible = useSemanticNodeVisible(itemNodeKey);
const { itemStyle: sectionItemStyle, useTimeline } = useSectionItemsContext();
const itemStyle = useTemplateStyle("item");
const itemRuleStyle = useSectionStyleRule("item");
const timelineItemStyle = useTemplateFeatureStyle("sectionTimeline", "item");
const timelineMarkerStyle = useTemplateFeatureStyle("sectionTimeline", "marker");
const timelineDotStyle = useTemplateFeatureStyle("sectionTimeline", "dot");
const timelineContentStyle = useTemplateFeatureStyle("sectionTimeline", "content");
const timelineContentNodeKey = semanticTemplatePartNodeKey(itemNodeKey, "timeline-content");
const timelineContentResolved = useResolvedNode(timelineContentNodeKey);
if (!visible) return null;
if (!useTimeline) {
return (
<SemanticNodeKeyProvider nodeKey={itemNodeKey}>
<Div nodeKey={itemNodeKey} style={composeStyles(itemStyle, itemRuleStyle, sectionItemStyle, style)}>
{children}
</Div>
</SemanticNodeKeyProvider>
);
}
return (
<SemanticNodeKeyProvider nodeKey={itemNodeKey}>
<View style={composeStyles(timelineItemStyle)}>
<SemanticTemplatePartView
ownerNodeKey={itemNodeKey}
partKeys={["timeline-marker"]}
style={composeStyles(timelineMarkerStyle)}
>
<SemanticTemplatePartView
ownerNodeKey={itemNodeKey}
partKeys={["timeline-marker", "timeline-dot"]}
style={composeStyles(timelineDotStyle)}
/>
</SemanticTemplatePartView>
<Div
nodeKey={itemNodeKey}
{...resolvedPdfFlowProps(timelineContentResolved)}
style={composeStyles(itemStyle, itemRuleStyle, timelineContentStyle, style, timelineContentResolved.style)}
>
{children}
</Div>
</View>
</SemanticNodeKeyProvider>
);
};
/**
* 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 nodeKey = useSemanticNodeKey();
const renderedChildKeys = useRenderedChildKeys(nodeKey);
const parts = [
{
nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-leading") ?? "inline-item-header-leading",
value: (
<SemanticTemplatePartView
key="inline-item-header-leading"
ownerNodeKey={nodeKey}
partKeys={["inline-item-header-leading"]}
style={composeStyles(leadingStyle)}
>
{leading}
</SemanticTemplatePartView>
),
},
{
nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-middle") ?? "inline-item-header-middle",
value: (
<SemanticTemplatePartView
key="inline-item-header-middle"
ownerNodeKey={nodeKey}
partKeys={["inline-item-header-middle"]}
style={composeStyles(middleStyle)}
>
{middle}
</SemanticTemplatePartView>
),
},
{
nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-trailing") ?? "inline-item-header-trailing",
value: (
<SemanticTemplatePartView
key="inline-item-header-trailing"
ownerNodeKey={nodeKey}
partKeys={["inline-item-header-trailing"]}
style={composeStyles(trailingStyle)}
>
{trailing}
</SemanticTemplatePartView>
),
},
];
return <View style={composeStyles(inlineItemHeaderStyle)}>{projectRenderedChildren(renderedChildKeys, parts)}</View>;
};
const stackedSidebarSplitRowStyle = {
flexDirection: "column",
alignItems: "flex-start",
} satisfies Style;
const awardTitleDateRowStyle = {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
} satisfies Style;
const useSectionSplitRowStyle = () => {
const placement = useTemplatePlacement();
const splitRowStyle = useTemplateStyle("splitRow");
const stackSidebarItemHeader = useTemplateFeature("stackSidebarItemHeader");
return composeStyles(
splitRowStyle,
stackSidebarItemHeader && placement === "sidebar" ? stackedSidebarSplitRowStyle : undefined,
);
};
type ItemHeaderRowProps = {
children: ReactNode;
style: StyleInput;
};
/**
* React PDF lays a `Text` out once, at the width it is first measured at, and reuses those lines
* afterwards. `flex-wrap: nowrap` asks Yoga to shrink the title so the date fits beside it, and the
* cached lines keep the wider layout, so the title's glyphs run over the date. A zero flex basis
* makes the title's first measurement its final width, so it wraps inside its own box instead.
*/
const nowrapItemTitleStyle = { flexGrow: 1, flexBasis: 0 } satisfies Style;
const wrappingItemTitleStyle = {} satisfies Style;
/** True while rendering inside an item header row a stylesheet turned into a single line. */
const ItemHeaderRowNowrapContext = createContext(false);
/**
* The title/date row inside a section item header, exposed to Semantic CSS as
* `template-part[name="item-header-row"]`.
*
* It has to be addressable on its own: the row carries `flex-wrap: wrap`, so a long title pushes
* the trailing date onto its own line, and `item-header` selects the box around the row rather than
* the row itself. The owning item-header key comes from the surrounding provider.
*/
const ItemHeaderRow = ({ children, style }: ItemHeaderRowProps) => {
const ownerNodeKey = useSemanticNodeKey();
const resolved = useResolvedNode(semanticTemplatePartNodeKey(ownerNodeKey, ...ITEM_HEADER_ROW_PART_KEYS));
// Same order the rendered row composes in, so a template that ships `nowrap` counts too.
const { flexWrap } = mergeStyles(style, resolved.style);
return (
<SemanticTemplatePartView partKeys={ITEM_HEADER_ROW_PART_KEYS} style={composeStyles(style)}>
<ItemHeaderRowNowrapContext.Provider value={flexWrap === "nowrap"}>
{children}
</ItemHeaderRowNowrapContext.Provider>
</SemanticTemplatePartView>
);
};
/**
* 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 sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader");
const exists = useSemanticNodeExists(itemHeaderNodeKey);
if (!exists) return <>{children}</>;
return (
<SemanticNodeKeyProvider nodeKey={itemHeaderNodeKey}>
<Div nodeKey={itemHeaderNodeKey} style={composeStyles(sectionItemHeaderStyle)}>
{children}
</Div>
</SemanticNodeKeyProvider>
);
};
const ItemTitle = ({ children, website, field, bold = true }: ItemTitleProps) => {
const inlineWebsiteUrl = getInlineItemWebsiteUrl(website);
const style = use(ItemHeaderRowNowrapContext) ? nowrapItemTitleStyle : wrappingItemTitleStyle;
const title = bold ? (
<Bold style={style} semanticField={field}>
{children}
</Bold>
) : (
<Text style={style} semanticField={field}>
{children}
</Text>
);
if (!inlineWebsiteUrl) return title;
return (
<Link style={style} semanticRole="inline-website" src={inlineWebsiteUrl}>
{title}
</Link>
);
};
const ItemWebsiteLink = ({ website }: ItemWebsiteLinkProps) => {
if (!shouldRenderSeparateItemWebsite(website)) return null;
return (
<Link semanticRole="website" src={website.url}>
{getWebsiteDisplayText(website)}
</Link>
);
};
const SummarySection = ({ showHeading = true }: SummarySectionProps = {}) => {
const data = useRender();
const { summary } = data;
if (!isVisibleSummary(summary)) return null;
return (
<SectionShell sectionId="summary" title={summary.title} showHeading={showHeading}>
<SectionItems>
<SectionItem itemId="content">
<RichText semanticField="content">{summary.content}</RichText>
</SectionItem>
</SectionItems>
</SectionShell>
);
};
const CustomSummarySection = ({ section, showHeading = true }: CustomSummarySectionProps) => {
const items = getVisibleItems(section);
if (items.length === 0) return null;
return (
<SectionShell sectionId={section.id} title={section.title} showHeading={showHeading}>
<SectionItems columns={section.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<RichText semanticField="content">{item.content}</RichText>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const ProfileSection = ({ sectionId = "profiles", sectionData }: ItemSectionProps<ProfileItem> = {}) => {
const data = useRender();
const profiles = sectionData ?? data.sections.profiles;
const items = getVisibleItems(profiles, "profiles");
const inlineStyle = useTemplateStyle("inline");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={profiles.title}>
<SectionItems columns={profiles.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold semanticField="network">{item.network}</Bold>
</View>
</SectionItemHeader>
<Link semanticRole="profile" src={item.website.url}>
<Text semanticField="username">{item.username}</Text>
</Link>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
type ExperienceItemContentProps = {
item: ExperienceItem;
header: ReactNode;
splitRowStyle: StyleInput;
alignEndStyle: StyleInput;
};
const ExperienceItemContent = ({ item, header, splitRowStyle, alignEndStyle }: ExperienceItemContentProps) => {
const itemNodeKey = useSemanticNodeKey();
const renderedChildKeys = useRenderedChildKeys(itemNodeKey);
const headerNodeKey = itemNodeKey ? semanticNodeKeys.itemHeader(itemNodeKey) : undefined;
const descriptionNodeKey = itemNodeKey ? semanticNodeKeys.field(itemNodeKey, "description") : undefined;
const websiteNodeKey = itemNodeKey ? semanticNodeKeys.link(itemNodeKey, "website") : undefined;
const headerExists = useSemanticNodeExists(headerNodeKey);
const descriptionExists = useSemanticNodeExists(descriptionNodeKey);
const websiteExists = useSemanticNodeExists(websiteNodeKey);
const roleEntries = item.roles.map((role) => ({
nodeKey: itemNodeKey ? semanticNodeKeys.item(itemNodeKey, role.id) : role.id,
value: (
<SemanticItemNodeKeyProvider key={role.id} itemId={role.id}>
<Div bindCurrentNode>
<SectionItemHeader>
<View style={composeStyles(splitRowStyle)}>
<Text semanticField="position">{role.position}</Text>
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{role.period}
</Text>
</View>
</SectionItemHeader>
<RichText semanticField="description">{role.description}</RichText>
</Div>
</SemanticItemNodeKeyProvider>
),
}));
const entries = [
...(headerExists && headerNodeKey
? [{ nodeKey: headerNodeKey, value: <Fragment key={headerNodeKey}>{header}</Fragment> }]
: []),
...roleEntries,
...(descriptionExists && descriptionNodeKey
? [
{
nodeKey: descriptionNodeKey,
value: (
<Fragment key={descriptionNodeKey}>
<RichText semanticField="description">{item.description}</RichText>
</Fragment>
),
},
]
: []),
...(websiteExists && websiteNodeKey
? [
{
nodeKey: websiteNodeKey,
value: (
<Fragment key={websiteNodeKey}>
<ItemWebsiteLink website={item.website} />
</Fragment>
),
},
]
: []),
];
return <>{projectRenderedChildren(renderedChildKeys, entries)}</>;
};
const ExperienceSection = ({ sectionId = "experience", sectionData }: ItemSectionProps<ExperienceItem> = {}) => {
const data = useRender();
const experience = sectionData ?? data.sections.experience;
const items = getVisibleItems(experience, "experience");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={experience.title}>
<SectionItems columns={experience.columns}>
{items.map((item) => {
const hasPosition = Boolean(item.position.trim());
const hasLocation = Boolean(item.location.trim());
const headerLocation = hasLocation ? item.location : item.period;
const headerLocationField = hasLocation ? "location" : "period";
const headerPeriod = hasLocation ? item.period : "";
const renderInlineHeader = () => (
<InlineItemHeader
leading={
hasPosition || hasLocation ? (
<SemanticTextRuns
host="experience-position-location"
separator=" "
runs={[
{ field: "position", value: item.position },
{ field: "location", value: item.location, prefix: "(", suffix: ")" },
]}
/>
) : null
}
middle={
<ItemTitle field="company" website={item.website}>
{item.company}
</ItemTitle>
}
trailing={
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{item.period}
</Text>
}
/>
);
const renderSplitHeader = () => (
<>
<View style={composeStyles(splitRowStyle)}>
<ItemTitle field="company" website={item.website}>
{item.company}
</ItemTitle>
{hasSplitRowText(headerLocation) && (
<SemanticTextRuns
host={headerLocationField === "location" ? "experience-location" : "experience-period"}
separator=""
runs={[{ field: headerLocationField, value: headerLocation }]}
style={composeStyles(alignEndStyle)}
/>
)}
</View>
{(hasPosition || hasSplitRowText(headerPeriod)) && (
<View style={composeStyles(splitRowStyle)}>
{hasPosition && <Text semanticField="position">{item.position}</Text>}
{hasSplitRowText(headerPeriod) && (
<SemanticTextRuns
host="experience-period"
separator=""
runs={[{ field: "period", value: headerPeriod }]}
style={composeStyles(alignEndStyle)}
/>
)}
</View>
)}
</>
);
return (
<SectionItem key={item.id} itemId={item.id}>
<ExperienceItemContent
item={item}
header={
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
}
splitRowStyle={splitRowStyle}
alignEndStyle={alignEndStyle}
/>
</SectionItem>
);
})}
</SectionItems>
</SectionShell>
);
};
type EducationItemContentProps = {
item: EducationItem;
header: ReactNode;
};
const EducationItemContent = ({ item, header }: EducationItemContentProps) => {
const itemNodeKey = useSemanticNodeKey();
const renderedChildKeys = useRenderedChildKeys(itemNodeKey);
const headerNodeKey = itemNodeKey ? semanticNodeKeys.itemHeader(itemNodeKey) : undefined;
const gradeRowNodeKey = semanticTemplatePartNodeKey(itemNodeKey, "education-grade-row");
const descriptionNodeKey = itemNodeKey ? semanticNodeKeys.field(itemNodeKey, "description") : undefined;
const websiteNodeKey = itemNodeKey ? semanticNodeKeys.link(itemNodeKey, "website") : undefined;
const headerExists = useSemanticNodeExists(headerNodeKey);
const gradeRowExists = useSemanticNodeExists(gradeRowNodeKey);
const descriptionExists = useSemanticNodeExists(descriptionNodeKey);
const websiteExists = useSemanticNodeExists(websiteNodeKey);
const entries = [
...(headerExists && headerNodeKey
? [{ nodeKey: headerNodeKey, value: <Fragment key={headerNodeKey}>{header}</Fragment> }]
: []),
...(gradeRowExists && gradeRowNodeKey
? [
{
nodeKey: gradeRowNodeKey,
value: (
<Fragment key={gradeRowNodeKey}>
<SemanticTextRuns
host="education-grade-location"
nodeKey={gradeRowNodeKey}
fieldOwnerNodeKey={headerNodeKey}
separator=" • "
runs={[
{ field: "grade", value: item.grade },
{ field: "location", value: item.location },
]}
/>
</Fragment>
),
},
]
: []),
...(descriptionExists && descriptionNodeKey
? [
{
nodeKey: descriptionNodeKey,
value: (
<Fragment key={descriptionNodeKey}>
<RichText semanticField="description">{item.description}</RichText>
</Fragment>
),
},
]
: []),
...(websiteExists && websiteNodeKey
? [
{
nodeKey: websiteNodeKey,
value: (
<Fragment key={websiteNodeKey}>
<ItemWebsiteLink website={item.website} />
</Fragment>
),
},
]
: []),
];
return <>{projectRenderedChildren(renderedChildKeys, entries)}</>;
};
const EducationSection = ({ sectionId = "education", sectionData }: ItemSectionProps<EducationItem> = {}) => {
const data = useRender();
const education = sectionData ?? data.sections.education;
const items = getVisibleItems(education, "education");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={education.title}>
<SectionItems columns={education.columns}>
{items.map((item) => {
const hasArea = Boolean(item.area.trim());
const hasDegree = Boolean(item.degree.trim());
const hasDegreeOrGrade = hasSplitRowText(item.degree) || hasSplitRowText(item.grade);
const hasLocationOrPeriod = hasSplitRowText(item.location) || hasSplitRowText(item.period);
const renderInlineHeader = () => (
<InlineItemHeader
leading={
hasArea || hasDegree ? (
<SemanticTextRuns
host="education-area-degree"
separator=" "
runs={[
{ field: "area", value: item.area },
{ field: "degree", value: item.degree, prefix: "(", suffix: ")" },
]}
/>
) : null
}
middle={
<ItemTitle field="school" website={item.website}>
{item.school}
</ItemTitle>
}
trailing={
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{item.period}
</Text>
}
/>
);
const renderSplitHeader = () => (
<>
<View style={composeStyles(splitRowStyle)}>
<ItemTitle field="school" website={item.website}>
{item.school}
</ItemTitle>
{(hasDegreeOrGrade || hasLocationOrPeriod) && (
<SemanticTextRuns
host={hasDegreeOrGrade ? "education-degree-grade" : "education-location-period"}
separator=" • "
runs={
hasDegreeOrGrade
? [
{ field: "degree", value: item.degree },
{ field: "grade", value: item.grade },
]
: [
{ field: "location", value: item.location },
{ field: "period", value: item.period },
]
}
style={composeStyles(alignEndStyle)}
/>
)}
</View>
{(hasArea || (hasDegreeOrGrade && hasLocationOrPeriod)) && (
<View style={composeStyles(splitRowStyle)}>
{hasArea && <Text semanticField="area">{item.area}</Text>}
{hasDegreeOrGrade && hasLocationOrPeriod && (
<SemanticTextRuns
host="education-location-period"
separator=" • "
runs={[
{ field: "location", value: item.location },
{ field: "period", value: item.period },
]}
style={composeStyles(alignEndStyle)}
/>
)}
</View>
)}
</>
);
return (
<SectionItem key={item.id} itemId={item.id}>
<EducationItemContent
item={item}
header={
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
}
/>
</SectionItem>
);
})}
</SectionItems>
</SectionShell>
);
};
const ProjectsSection = ({ sectionId = "projects", sectionData }: ItemSectionProps<ProjectItem> = {}) => {
const data = useRender();
const projects = sectionData ?? data.sections.projects;
const items = getVisibleItems(projects, "projects");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={projects.title}>
<SectionItems columns={projects.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<ItemHeaderRow style={composeStyles(splitRowStyle)}>
<ItemTitle field="name" website={item.website}>
{item.name}
</ItemTitle>
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{item.period}
</Text>
</ItemHeaderRow>
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps<SkillItem> = {}) => {
const data = useRender();
const skills = sectionData ?? data.sections.skills;
const items = getVisibleItems(skills, "skills");
const inlineStyle = useTemplateStyle("inline");
const metrics = getTemplateMetrics(data.metadata.page);
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={skills.title}>
<SectionItems columns={skills.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id} style={{ rowGap: metrics.gapY(0.25) }}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold semanticField="name" style={{ flex: 1 }}>
{item.name}
</Bold>
</View>
</SectionItemHeader>
<View>
{hasSplitRowText(item.proficiency) && <Text semanticField="proficiency">{item.proficiency}</Text>}
<Small semanticField="keywords">{item.keywords.join(", ")}</Small>
</View>
<LevelDisplay level={item.level} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const LanguagesSection = ({ sectionId = "languages", sectionData }: ItemSectionProps<LanguageItem> = {}) => {
const data = useRender();
const languages = sectionData ?? data.sections.languages;
const items = getVisibleItems(languages, "languages");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={languages.title}>
<SectionItems columns={languages.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<Bold semanticField="language">{item.language}</Bold>
<Text semanticField="fluency">{item.fluency}</Text>
</SectionItemHeader>
<LevelDisplay level={item.level} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const InterestsSection = ({ sectionId = "interests", sectionData }: ItemSectionProps<InterestItem> = {}) => {
const data = useRender();
const interests = sectionData ?? data.sections.interests;
const items = getVisibleItems(interests, "interests");
const inlineStyle = useTemplateStyle("inline");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={interests.title}>
<SectionItems columns={interests.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold semanticField="name">{item.name}</Bold>
</View>
</SectionItemHeader>
<Small semanticField="keywords">{item.keywords.join(", ")}</Small>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const AwardsSection = ({ sectionId = "awards", sectionData }: ItemSectionProps<AwardItem> = {}) => {
const data = useRender();
const awards = sectionData ?? data.sections.awards;
const items = getVisibleItems(awards, "awards");
const splitRowStyle = useTemplateStyle("splitRow");
const alignEndStyle = useTemplateStyle("alignEnd");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={awards.title}>
<SectionItems columns={awards.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<ItemHeaderRow style={composeStyles(splitRowStyle, awardTitleDateRowStyle)}>
<ItemTitle field="title" website={item.website} bold={false}>
{item.title}
</ItemTitle>
<Text semanticField="date" style={composeStyles(alignEndStyle)}>
{item.date}
</Text>
</ItemHeaderRow>
<Text semanticField="awarder">{item.awarder}</Text>
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CertificationsSection = ({
sectionId = "certifications",
sectionData,
}: ItemSectionProps<CertificationItem> = {}) => {
const data = useRender();
const certifications = sectionData ?? data.sections.certifications;
const items = getVisibleItems(certifications, "certifications");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={certifications.title}>
<SectionItems columns={certifications.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<ItemHeaderRow style={composeStyles(splitRowStyle)}>
<ItemTitle field="title" website={item.website}>
{item.title}
</ItemTitle>
<Text semanticField="date" style={composeStyles(alignEndStyle)}>
{item.date}
</Text>
</ItemHeaderRow>
<Text semanticField="issuer">{item.issuer}</Text>
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const PublicationsSection = ({ sectionId = "publications", sectionData }: ItemSectionProps<PublicationItem> = {}) => {
const data = useRender();
const publications = sectionData ?? data.sections.publications;
const items = getVisibleItems(publications, "publications");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={publications.title}>
<SectionItems columns={publications.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<ItemHeaderRow style={composeStyles(splitRowStyle)}>
<ItemTitle field="title" website={item.website}>
{item.title}
</ItemTitle>
<Text semanticField="date" style={composeStyles(alignEndStyle)}>
{item.date}
</Text>
</ItemHeaderRow>
<Text semanticField="publisher">{item.publisher}</Text>
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const VolunteerSection = ({ sectionId = "volunteer", sectionData }: ItemSectionProps<VolunteerItem> = {}) => {
const data = useRender();
const volunteer = sectionData ?? data.sections.volunteer;
const items = getVisibleItems(volunteer, "volunteer");
const splitRowStyle = useSectionSplitRowStyle();
const alignEndStyle = useTemplateStyle("alignEnd");
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={volunteer.title}>
<SectionItems columns={volunteer.columns}>
{items.map((item) => {
return (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
{inlineItemHeader ? (
<InlineItemHeader
leading={
hasSplitRowText(item.location) ? <Text semanticField="location">{item.location}</Text> : null
}
middle={
<ItemTitle field="organization" website={item.website}>
{item.organization}
</ItemTitle>
}
trailing={
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{item.period}
</Text>
}
/>
) : (
<>
<View style={composeStyles(splitRowStyle)}>
<ItemTitle field="organization" website={item.website}>
{item.organization}
</ItemTitle>
{hasSplitRowText(item.period) && (
<Text semanticField="period" style={composeStyles(alignEndStyle)}>
{item.period}
</Text>
)}
</View>
<Text semanticField="location">{item.location}</Text>
</>
)}
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
);
})}
</SectionItems>
</SectionShell>
);
};
const ReferencesSection = ({ sectionId = "references", sectionData }: ItemSectionProps<ReferenceItem> = {}) => {
const data = useRender();
const references = sectionData ?? data.sections.references;
const items = getVisibleItems(references, "references");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={references.title}>
<SectionItems columns={references.columns}>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<SectionItemHeader>
<ItemTitle field="name" website={item.website}>
{item.name}
</ItemTitle>
{hasSplitRowText(item.position) && <Text semanticField="position">{item.position}</Text>}
{hasSplitRowText(item.phone) && <Text semanticField="phone">{item.phone}</Text>}
</SectionItemHeader>
<RichText semanticField="description">{item.description}</RichText>
<ItemWebsiteLink website={item.website} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CoverLetterSection = ({ section }: CoverLetterSectionProps) => {
const items = getVisibleItems(section);
if (items.length === 0) return null;
return (
<SectionShell sectionId={section.id} title={section.title} showHeading={false}>
<SectionItems>
{items.map((item) => (
<SectionItem key={item.id} itemId={item.id}>
<RichText semanticField="recipient">{item.recipient}</RichText>
<RichText semanticField="content">{item.content}</RichText>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CustomSection = ({ sectionId, showHeading = true }: CustomSectionProps) => {
const data = useRender();
const customSection = data.customSections.find((section) => section.id === sectionId);
if (!customSection) return null;
// ponytail: satisfies Record<CustomSectionType,...> gives compile-time exhaustiveness without ts-pattern
const customSectionMap = {
summary: () => (
<CustomSummarySection section={customSection as CustomItemSection<SummaryItem>} showHeading={showHeading} />
),
profiles: () => (
<ProfileSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProfileItem>} />
),
experience: () => (
<ExperienceSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ExperienceItem>} />
),
education: () => (
<EducationSection sectionId={sectionId} sectionData={customSection as CustomItemSection<EducationItem>} />
),
projects: () => (
<ProjectsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProjectItem>} />
),
skills: () => <SkillsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<SkillItem>} />,
languages: () => (
<LanguagesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<LanguageItem>} />
),
interests: () => (
<InterestsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<InterestItem>} />
),
awards: () => <AwardsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<AwardItem>} />,
certifications: () => (
<CertificationsSection
sectionId={sectionId}
sectionData={customSection as CustomItemSection<CertificationItem>}
/>
),
publications: () => (
<PublicationsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<PublicationItem>} />
),
volunteer: () => (
<VolunteerSection sectionId={sectionId} sectionData={customSection as CustomItemSection<VolunteerItem>} />
),
references: () => (
<ReferencesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ReferenceItem>} />
),
"cover-letter": () => <CoverLetterSection section={customSection as CustomItemSection<CoverLetterItem>} />,
} satisfies Record<CustomSectionType, () => ReactNode>;
return customSectionMap[customSection.type]();
};
export const Section = ({ section, placement, showHeading = true }: SectionProps) => {
const data = useRender();
if (!isSectionVisible(section, data)) return null;
// ponytail: satisfies exhaustiveness over built-in types; custom IDs fall through to CustomSection
const builtInSectionMap = {
summary: () => <SummarySection showHeading={showHeading} />,
profiles: () => <ProfileSection />,
experience: () => <ExperienceSection />,
education: () => <EducationSection />,
projects: () => <ProjectsSection />,
skills: () => <SkillsSection />,
languages: () => <LanguagesSection />,
interests: () => <InterestsSection />,
awards: () => <AwardsSection />,
certifications: () => <CertificationsSection />,
publications: () => <PublicationsSection />,
volunteer: () => <VolunteerSection />,
references: () => <ReferencesSection />,
} satisfies Record<Exclude<CustomSectionType, "cover-letter">, () => ReactNode>;
const render = (builtInSectionMap as Record<string, (() => ReactNode) | undefined>)[section];
return (
<TemplatePlacementProvider placement={placement}>
<SectionStyleProvider context={getSectionStyleRuleContext(data, section)}>
{render ? render() : <CustomSection sectionId={section} showHeading={showHeading} />}
</SectionStyleProvider>
</TemplatePlacementProvider>
);
};