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; showHeading?: boolean; }; type ItemSectionProps = { sectionId?: string; sectionData?: ItemSection; }; type CoverLetterSectionProps = { section: CustomItemSection; }; 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({ 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 ( {visibleRuns.map(({ field, value, prefix = "", suffix = "" }, index) => ( {index === 0 ? "" : separator} {prefix} {value} {suffix} ))} ); }; 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 = (section: ItemSection, 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 ( {showHeading && ( {sectionTitle} )} {children} ); } // With icon: wrap in a flex row container that inherits the heading's border/decoration return ( {showHeading && sectionHeadingVisible && ( {sectionTitle} )} {children} ); }; 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 ( {rows.map((row, rowIndex) => ( {row} {SECTION_ITEM_PLACEHOLDER_KEYS.slice(0, layout.columns - row.length).map((placeholderKey) => ( ))} ))} ); } return ( {resolvedChildren} ); } return ( {resolvedChildren} ); }; 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 (
{children}
); } return (
{children}
); }; /** * 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: ( {leading} ), }, { nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-middle") ?? "inline-item-header-middle", value: ( {middle} ), }, { nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-trailing") ?? "inline-item-header-trailing", value: ( {trailing} ), }, ]; return {projectRenderedChildren(renderedChildKeys, parts)}; }; 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 ( {children} ); }; /** * 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 (
{children}
); }; const ItemTitle = ({ children, website, field, bold = true }: ItemTitleProps) => { const inlineWebsiteUrl = getInlineItemWebsiteUrl(website); const style = use(ItemHeaderRowNowrapContext) ? nowrapItemTitleStyle : wrappingItemTitleStyle; const title = bold ? ( {children} ) : ( {children} ); if (!inlineWebsiteUrl) return title; return ( {title} ); }; const ItemWebsiteLink = ({ website }: ItemWebsiteLinkProps) => { if (!shouldRenderSeparateItemWebsite(website)) return null; return ( {getWebsiteDisplayText(website)} ); }; const SummarySection = ({ showHeading = true }: SummarySectionProps = {}) => { const data = useRender(); const { summary } = data; if (!isVisibleSummary(summary)) return null; return ( {summary.content} ); }; const CustomSummarySection = ({ section, showHeading = true }: CustomSummarySectionProps) => { const items = getVisibleItems(section); if (items.length === 0) return null; return ( {items.map((item) => ( {item.content} ))} ); }; const ProfileSection = ({ sectionId = "profiles", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.network} {item.username} ))} ); }; 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: (
{role.position} {role.period} {role.description}
), })); const entries = [ ...(headerExists && headerNodeKey ? [{ nodeKey: headerNodeKey, value: {header} }] : []), ...roleEntries, ...(descriptionExists && descriptionNodeKey ? [ { nodeKey: descriptionNodeKey, value: ( {item.description} ), }, ] : []), ...(websiteExists && websiteNodeKey ? [ { nodeKey: websiteNodeKey, value: ( ), }, ] : []), ]; return <>{projectRenderedChildren(renderedChildKeys, entries)}; }; const ExperienceSection = ({ sectionId = "experience", sectionData }: ItemSectionProps = {}) => { 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 ( {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 = () => ( ) : null } middle={ {item.company} } trailing={ {item.period} } /> ); const renderSplitHeader = () => ( <> {item.company} {hasSplitRowText(headerLocation) && ( )} {(hasPosition || hasSplitRowText(headerPeriod)) && ( {hasPosition && {item.position}} {hasSplitRowText(headerPeriod) && ( )} )} ); return ( {inlineItemHeader ? renderInlineHeader() : renderSplitHeader()} } splitRowStyle={splitRowStyle} alignEndStyle={alignEndStyle} /> ); })} ); }; 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: {header} }] : []), ...(gradeRowExists && gradeRowNodeKey ? [ { nodeKey: gradeRowNodeKey, value: ( ), }, ] : []), ...(descriptionExists && descriptionNodeKey ? [ { nodeKey: descriptionNodeKey, value: ( {item.description} ), }, ] : []), ...(websiteExists && websiteNodeKey ? [ { nodeKey: websiteNodeKey, value: ( ), }, ] : []), ]; return <>{projectRenderedChildren(renderedChildKeys, entries)}; }; const EducationSection = ({ sectionId = "education", sectionData }: ItemSectionProps = {}) => { 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 ( {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 = () => ( ) : null } middle={ {item.school} } trailing={ {item.period} } /> ); const renderSplitHeader = () => ( <> {item.school} {(hasDegreeOrGrade || hasLocationOrPeriod) && ( )} {(hasArea || (hasDegreeOrGrade && hasLocationOrPeriod)) && ( {hasArea && {item.area}} {hasDegreeOrGrade && hasLocationOrPeriod && ( )} )} ); return ( {inlineItemHeader ? renderInlineHeader() : renderSplitHeader()} } /> ); })} ); }; const ProjectsSection = ({ sectionId = "projects", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.name} {item.period} {item.description} ))} ); }; const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.name} {hasSplitRowText(item.proficiency) && {item.proficiency}} {item.keywords.join(", ")} ))} ); }; const LanguagesSection = ({ sectionId = "languages", sectionData }: ItemSectionProps = {}) => { const data = useRender(); const languages = sectionData ?? data.sections.languages; const items = getVisibleItems(languages, "languages"); if (items.length === 0) return null; return ( {items.map((item) => ( {item.language} {item.fluency} ))} ); }; const InterestsSection = ({ sectionId = "interests", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.name} {item.keywords.join(", ")} ))} ); }; const AwardsSection = ({ sectionId = "awards", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.title} {item.date} {item.awarder} {item.description} ))} ); }; const CertificationsSection = ({ sectionId = "certifications", sectionData, }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.title} {item.date} {item.issuer} {item.description} ))} ); }; const PublicationsSection = ({ sectionId = "publications", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => ( {item.title} {item.date} {item.publisher} {item.description} ))} ); }; const VolunteerSection = ({ sectionId = "volunteer", sectionData }: ItemSectionProps = {}) => { 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 ( {items.map((item) => { return ( {inlineItemHeader ? ( {item.location} : null } middle={ {item.organization} } trailing={ {item.period} } /> ) : ( <> {item.organization} {hasSplitRowText(item.period) && ( {item.period} )} {item.location} )} {item.description} ); })} ); }; const ReferencesSection = ({ sectionId = "references", sectionData }: ItemSectionProps = {}) => { const data = useRender(); const references = sectionData ?? data.sections.references; const items = getVisibleItems(references, "references"); if (items.length === 0) return null; return ( {items.map((item) => ( {item.name} {hasSplitRowText(item.position) && {item.position}} {hasSplitRowText(item.phone) && {item.phone}} {item.description} ))} ); }; const CoverLetterSection = ({ section }: CoverLetterSectionProps) => { const items = getVisibleItems(section); if (items.length === 0) return null; return ( {items.map((item) => ( {item.recipient} {item.content} ))} ); }; 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 gives compile-time exhaustiveness without ts-pattern const customSectionMap = { summary: () => ( } showHeading={showHeading} /> ), profiles: () => ( } /> ), experience: () => ( } /> ), education: () => ( } /> ), projects: () => ( } /> ), skills: () => } />, languages: () => ( } /> ), interests: () => ( } /> ), awards: () => } />, certifications: () => ( } /> ), publications: () => ( } /> ), volunteer: () => ( } /> ), references: () => ( } /> ), "cover-letter": () => } />, } satisfies Record 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: () => , profiles: () => , experience: () => , education: () => , projects: () => , skills: () => , languages: () => , interests: () => , awards: () => , certifications: () => , publications: () => , volunteer: () => , references: () => , } satisfies Record, () => ReactNode>; const render = (builtInSectionMap as Record ReactNode) | undefined>)[section]; return ( {render ? render() : } ); };