* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
@@ -0,0 +1,79 @@
import type { Style } from "@react-pdf/types";
import type { TemplatePlacement } from "./styles";
const MIN_SECTION_COLUMNS = 1;
const MAX_SECTION_COLUMNS = 6;
type SectionItemsLayoutInput = {
columns: unknown;
rowGap: number;
columnGap: number;
};
type SectionTimelineInput = {
sectionTimeline: boolean;
placement: TemplatePlacement;
columns: unknown;
};
export type SectionItemsLayout = {
columns: number;
containerStyle: Style;
rowStyle: Style | undefined;
itemStyle: Style | undefined;
isGrid: boolean;
};
const normalizeSectionColumns = (columns: unknown): number => {
if (typeof columns !== "number" || !Number.isFinite(columns) || !Number.isInteger(columns))
return MIN_SECTION_COLUMNS;
return Math.min(MAX_SECTION_COLUMNS, Math.max(MIN_SECTION_COLUMNS, columns));
};
export const getSectionItemsLayout = ({ columns, rowGap, columnGap }: SectionItemsLayoutInput): SectionItemsLayout => {
const normalizedColumns = normalizeSectionColumns(columns);
if (normalizedColumns === 1) {
return {
columns: normalizedColumns,
containerStyle: { rowGap },
rowStyle: undefined,
itemStyle: undefined,
isGrid: false,
};
}
return {
columns: normalizedColumns,
containerStyle: { rowGap },
rowStyle: {
flexDirection: "row",
columnGap,
},
itemStyle: {
flexBasis: 0,
flexGrow: 1,
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
},
isGrid: true,
};
};
export const getSectionItemRows = <T>(items: T[], columns: unknown): T[][] => {
const normalizedColumns = normalizeSectionColumns(columns);
const rows: T[][] = [];
for (let index = 0; index < items.length; index += normalizedColumns) {
rows.push(items.slice(index, index + normalizedColumns));
}
return rows;
};
export const shouldUseSectionTimeline = ({ sectionTimeline, placement, columns }: SectionTimelineInput): boolean => {
return sectionTimeline && placement === "main" && normalizeSectionColumns(columns) === 1;
};
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import type { StyleInput, TemplatePlacement } from "./styles";
import type {
SectionTimelineStyleSlots,
TemplateColorRoles,
TemplateFeatureStyleSlots,
TemplateFeatures,
TemplateIconProps,
TemplateIconSlot,
TemplateStyleSlot,
TemplateStyleSlots,
} from "./types";
import { createContext, useContext } from "react";
type TemplateContextValue = {
styles: TemplateStyleSlots;
featureStyles: TemplateFeatureStyleSlots;
colors: TemplateColorRoles;
features: TemplateFeatures;
};
type TemplateProviderProps = Omit<TemplateContextValue, "featureStyles" | "features" | "sectionTitleFallbacks"> & {
featureStyles?: TemplateFeatureStyleSlots;
features?: TemplateFeatures;
children: ReactNode;
};
const TemplateContext = createContext<TemplateContextValue | null>(null);
const TemplatePlacementContext = createContext<TemplatePlacement>("main");
const resolveStyleSlot = (slot: TemplateStyleSlot | undefined, context: TemplateStyleContextValue): StyleInput => {
if (!slot) return undefined;
if (typeof slot === "function") return slot(context);
return slot;
};
const resolveIconSlot = (
slot: TemplateIconSlot | undefined,
context: TemplateStyleContextValue,
): Partial<TemplateIconProps> => {
if (!slot) return {};
if (typeof slot === "function") return slot(context);
return slot;
};
type TemplateStyleContextValue = {
placement: TemplatePlacement;
colors: TemplateColorRoles;
};
export const TemplateProvider = ({
styles,
featureStyles = {},
colors,
features = {},
children,
}: TemplateProviderProps) => {
return (
<TemplateContext.Provider value={{ styles, featureStyles, colors, features }}>{children}</TemplateContext.Provider>
);
};
export const TemplatePlacementProvider = ({
placement,
children,
}: {
placement: TemplatePlacement;
children: ReactNode;
}) => {
return <TemplatePlacementContext.Provider value={placement}>{children}</TemplatePlacementContext.Provider>;
};
const useTemplateContext = () => {
const context = useContext(TemplateContext);
if (!context) throw new Error("useTemplateContext must be called inside a <TemplateProvider>.");
return context;
};
export const useTemplateFeature = (feature: keyof TemplateFeatures): boolean => {
const { features } = useTemplateContext();
return features[feature] ?? false;
};
export const useTemplatePlacement = () => useContext(TemplatePlacementContext);
const useTemplateStyleContext = (): TemplateStyleContextValue => {
const { colors } = useTemplateContext();
const placement = useTemplatePlacement();
return { placement, colors };
};
export const useTemplateStyle = (slot: keyof TemplateStyleSlots): StyleInput => {
const { styles } = useTemplateContext();
const context = useTemplateStyleContext();
return resolveStyleSlot(styles[slot] as TemplateStyleSlot | undefined, context);
};
export const useTemplateFeatureStyle = (
feature: keyof TemplateFeatureStyleSlots,
slot: keyof SectionTimelineStyleSlots,
): StyleInput => {
const { featureStyles } = useTemplateContext();
const context = useTemplateStyleContext();
const slots = featureStyles[feature];
return resolveStyleSlot(slots?.[slot] as TemplateStyleSlot | undefined, context);
};
export const useTemplateIconSlot = (slot: "icon") => {
const { styles } = useTemplateContext();
const context = useTemplateStyleContext();
return resolveIconSlot(styles[slot], context);
};
@@ -0,0 +1,56 @@
import type { Summary } from "@reactive-resume/schema/resume/data";
type HiddenItem = {
hidden: boolean;
};
type ItemSectionLike<T extends HiddenItem = HiddenItem> = {
hidden: boolean;
items: T[];
};
type FilterableData = {
summary: Pick<Summary, "hidden" | "content">;
sections: Partial<Record<string, ItemSectionLike>>;
customSections: Array<ItemSectionLike & { id: string }>;
};
const isItemSection = (section: unknown): section is ItemSectionLike => {
return typeof section === "object" && section !== null && "items" in section;
};
const isSummarySection = (section: unknown): section is Summary => {
return typeof section === "object" && section !== null && "content" in section;
};
export const filterItems = <T extends HiddenItem>(items: T[]): T[] => {
return items.filter((item) => !item.hidden);
};
export const hasVisibleItems = (section: ItemSectionLike): boolean => {
return !section.hidden && filterItems(section.items).length > 0;
};
export const isVisibleSummary = (summary: Pick<Summary, "hidden" | "content">): boolean => {
return !summary.hidden && summary.content.trim().length > 0;
};
const getSectionForFiltering = (sectionId: string, data: FilterableData) => {
if (sectionId === "summary") return data.summary;
return data.sections[sectionId] ?? data.customSections.find((section) => section.id === sectionId);
};
export const isSectionVisible = (sectionId: string, data: FilterableData): boolean => {
const section = getSectionForFiltering(sectionId, data);
if (!section) return false;
if (isSummarySection(section)) return isVisibleSummary(section);
if (isItemSection(section)) return hasVisibleItems(section);
return false;
};
export const filterSections = (sectionIds: string[], data: FilterableData): string[] => {
return sectionIds.filter((sectionId) => isSectionVisible(sectionId, data));
};
@@ -0,0 +1,114 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { View } from "@react-pdf/renderer";
import { useRender } from "../../context";
import { useTemplateIconSlot, useTemplateStyle } from "./context";
import { getTemplateMetrics } from "./metrics";
import { Icon } from "./primitives";
import { composeStyles } from "./styles";
export const LevelDisplay = ({ level }: { level: number }) => {
const data = useRender();
const levelDesign = data.metadata.design.level;
const iconSize = data.metadata.typography.body.fontSize - 4;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
const levelContainerStyle = useTemplateStyle("levelContainer");
const levelItemStyle = useTemplateStyle("levelItem");
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
if (level === 0) return null;
if (levelDesign.type === "hidden") return null;
if (levelDesign.type === "icon" && levelDesign.icon === "") return null;
let gap = metrics.gapX(1 / 3);
if (levelDesign.type === "progress-bar") {
gap = 0;
}
if (levelDesign.type === "rectangle-full") {
gap = metrics.gapX(0.5);
}
return (
<View
style={composeStyles(
{ flexDirection: "row", alignItems: "center", marginTop: 2, columnGap: gap },
levelContainerStyle,
)}
>
{Array.from({ length: 5 }).map((_, index) => {
const isActive = index < level;
if (levelDesign.type === "icon") {
return (
<Icon
key={index}
size={iconSize + 4}
name={levelDesign.icon as IconName}
style={{ opacity: isActive ? 1 : 0.35 }}
/>
);
}
if (levelDesign.type === "progress-bar") {
return (
<View
key={index}
style={composeStyles(
{
flex: 1,
height: iconSize,
borderWidth: 0.75,
borderColor: color,
backgroundColor: isActive ? color : "transparent",
},
levelItemStyle,
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
)}
/>
);
}
const itemStyle: Style = {};
let borderRadius = 0;
let width: string | number = iconSize;
if (levelDesign.type === "rectangle") {
width = 16;
}
if (levelDesign.type === "rectangle-full") {
width = "auto";
itemStyle.flex = 1;
}
if (levelDesign.type === "circle") {
borderRadius = 999;
}
return (
<View
key={index}
style={composeStyles(
{
width,
height: iconSize,
borderWidth: 0.75,
borderColor: color,
borderRadius,
backgroundColor: isActive ? color : "transparent",
...itemStyle,
},
levelItemStyle,
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
)}
/>
);
})}
</View>
);
};
@@ -0,0 +1,9 @@
import { Small } from "./primitives";
export const MetaLine = ({ children }: { children: Array<string | undefined> }) => {
const content = children.filter(Boolean).join(" • ");
if (!content) return null;
return <Small>{content}</Small>;
};
@@ -0,0 +1,31 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
type PageMetricsInput = Pick<ResumeData["metadata"]["page"], "gapX" | "gapY" | "marginX" | "marginY">;
export type TemplateMetrics = {
page: {
paddingHorizontal: number;
paddingVertical: number;
};
headerGap: number;
columnGap: number;
sectionGap: number;
itemGapX: number;
itemGapY: number;
gapX: (factor: number) => number;
gapY: (factor: number) => number;
};
export const getTemplateMetrics = ({ gapX, gapY, marginX, marginY }: PageMetricsInput): TemplateMetrics => ({
page: {
paddingHorizontal: marginX,
paddingVertical: marginY,
},
headerGap: marginY,
columnGap: marginX,
sectionGap: marginY,
itemGapX: gapX,
itemGapY: gapY,
gapX: (factor) => gapX * factor,
gapY: (factor) => gapY * factor,
});
@@ -0,0 +1,4 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
export const getTemplatePageSize = (format: ResumeData["metadata"]["page"]["format"]) =>
format === "letter" ? "LETTER" : "A4";
@@ -0,0 +1,3 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
export const hasTemplatePicture = (picture: ResumeData["picture"]) => !picture.hidden && picture.url.trim() !== "";
@@ -0,0 +1,62 @@
import type { Style } from "@react-pdf/types";
import type { ComponentProps } from "react";
import type { StyleInput } from "./styles";
import { Link as PdfLink, Text as PdfText, View } from "@react-pdf/renderer";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { useTemplateIconSlot, useTemplateStyle } from "./context";
import { composeStyles } from "./styles";
const asStyleInput = (style: unknown): StyleInput => style as StyleInput;
export const safeTextStyle = {
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
overflow: "hidden",
} satisfies Style;
export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
const divStyle = useTemplateStyle("div");
return <View style={composeStyles(divStyle, style as Style | Style[] | undefined)} {...props} />;
};
export const Text = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
return <PdfText style={composeStyles(textStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const headingStyle = useTemplateStyle("heading");
return <PdfText style={composeStyles(headingStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
const linkStyle = useTemplateStyle("link");
return <PdfLink style={composeStyles(linkStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const smallStyle = useTemplateStyle("small");
return <PdfText style={composeStyles(textStyle, smallStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const boldStyle = useTemplateStyle("bold");
return <PdfText style={composeStyles(textStyle, boldStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
if (iconProps.display === "none") return null;
return <PhosphorIcon {...iconProps} {...props} style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))} />;
};
@@ -0,0 +1,47 @@
import type { Style } from "@react-pdf/types";
import { Text as PdfText, View } from "@react-pdf/renderer";
import { Html } from "react-pdf-html";
import { useTemplateStyle } from "./context";
import { safeTextStyle } from "./primitives";
import { composeStyles, mergeStyles } from "./styles";
export const RichText = ({ children }: { children: string }) => {
const boldStyle = useTemplateStyle("bold");
const linkStyle = useTemplateStyle("link");
const richParagraphStyle = useTemplateStyle("richParagraph");
const richListItemRowStyle = useTemplateStyle("richListItemRow");
const richListItemMarkerStyle = useTemplateStyle("richListItemMarker");
const richListItemContentStyle = useTemplateStyle("richListItemContent");
if (!children.trim()) return null;
return (
<Html
resetStyles
renderers={{
b: ({ children }) => <PdfText style={composeStyles(boldStyle, safeTextStyle)}>{children}</PdfText>,
li: ({ element, style, children }) => {
const list = element.closest("ol, ul");
const isOrderedList = list?.rawTagName === "ol" || element.parentNode.tag === "ol";
const marker = isOrderedList ? `${element.indexOfType + 1}.` : "•";
const itemStyle = Array.isArray(style) ? (style as Style[]) : [style as Style | undefined];
return (
<View style={composeStyles(richListItemRowStyle)}>
<PdfText style={composeStyles(richListItemMarkerStyle)}>{marker}</PdfText>
<PdfText style={composeStyles(richListItemContentStyle, ...itemStyle, safeTextStyle)}>{children}</PdfText>
</View>
);
},
}}
stylesheet={{
b: mergeStyles(boldStyle, safeTextStyle),
strong: mergeStyles(boldStyle, safeTextStyle),
p: mergeStyles(richParagraphStyle, safeTextStyle),
a: mergeStyles(linkStyle, { textDecoration: "underline" }, safeTextStyle),
}}
>
{children}
</Html>
);
};
@@ -0,0 +1,901 @@
import type { Style } from "@react-pdf/types";
import type {
AwardItem,
CertificationItem,
CoverLetterItem,
EducationItem,
ExperienceItem,
InterestItem,
LanguageItem,
ProfileItem,
ProjectItem,
PublicationItem,
ReferenceItem,
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 { StyleInput, TemplatePlacement } from "./styles";
import type { CustomItemSection, ItemSection } from "./types";
import { View } from "@react-pdf/renderer";
import { Children, createContext, useContext } from "react";
import { match } from "ts-pattern";
import { useRender } from "../../context";
import { getResumeSectionTitle } from "../../section-title";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
import {
TemplatePlacementProvider,
useTemplateFeature,
useTemplateFeatureStyle,
useTemplatePlacement,
useTemplateStyle,
} from "./context";
import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
import { LevelDisplay } from "./level-display";
import { MetaLine } from "./meta-line";
import { getTemplateMetrics } from "./metrics";
import { Bold, Div, Heading, Icon, Link, Small, Text } from "./primitives";
import { RichText } from "./rich-text";
import { composeStyles } from "./styles";
type SectionItemsContextValue = {
itemStyle: StyleInput;
useTimeline: boolean;
};
const SectionItemsContext = createContext<SectionItemsContextValue>({ itemStyle: undefined, useTimeline: false });
const useSectionItemsContext = () => useContext(SectionItemsContext);
const getVisibleItems = <T extends { hidden: boolean }>(section: ItemSection<T>): T[] => {
if (!hasVisibleItems(section)) return [];
return filterItems(section.items);
};
const SectionShell = ({
sectionId,
title,
showHeading = true,
children,
}: {
sectionId: string;
title: string;
showHeading?: boolean;
children: ReactNode;
}) => {
const data = useRender();
const sectionStyle = useTemplateStyle("section");
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
return (
<View style={composeStyles(sectionStyle)}>
{showHeading && <Heading style={composeStyles(sectionHeadingStyle)}>{sectionTitle}</Heading>}
{children}
</View>
);
};
const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?: number }) => {
const data = useRender();
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 };
if (!useTimeline) {
if (layout.isGrid) {
const rows = getSectionItemRows(Children.toArray(children), layout.columns);
return (
<SectionItemsContext.Provider value={context}>
<View style={composeStyles(layout.containerStyle, sectionItemsStyle)}>
{rows.map((row, rowIndex) => (
<View key={rowIndex} style={composeStyles(layout.rowStyle)}>
{row}
{Array.from({ length: layout.columns - row.length }, (_, index) => (
<View key={`placeholder-${index}`} style={composeStyles(layout.itemStyle)} />
))}
</View>
))}
</View>
</SectionItemsContext.Provider>
);
}
return (
<SectionItemsContext.Provider value={context}>
<View style={composeStyles(layout.containerStyle, sectionItemsStyle)}>{children}</View>
</SectionItemsContext.Provider>
);
}
return (
<SectionItemsContext.Provider value={context}>
<View style={composeStyles(layout.containerStyle, sectionItemsStyle, timelineItemsStyle)}>
<View style={composeStyles(timelineLineStyle)} />
{children}
</View>
</SectionItemsContext.Provider>
);
};
const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleInput }) => {
const { itemStyle: sectionItemStyle, useTimeline } = useSectionItemsContext();
const itemStyle = useTemplateStyle("item");
const timelineItemStyle = useTemplateFeatureStyle("sectionTimeline", "item");
const timelineMarkerStyle = useTemplateFeatureStyle("sectionTimeline", "marker");
const timelineDotStyle = useTemplateFeatureStyle("sectionTimeline", "dot");
const timelineContentStyle = useTemplateFeatureStyle("sectionTimeline", "content");
if (!useTimeline) {
return <Div style={composeStyles(itemStyle, sectionItemStyle, style)}>{children}</Div>;
}
return (
<View style={composeStyles(timelineItemStyle)}>
<View style={composeStyles(timelineMarkerStyle)}>
<View style={composeStyles(timelineDotStyle)} />
</View>
<Div style={composeStyles(itemStyle, timelineContentStyle, style)}>{children}</Div>
</View>
);
};
const InlineItemHeader = ({
leading,
middle,
trailing,
}: {
leading?: ReactNode;
middle?: ReactNode;
trailing?: ReactNode;
}) => {
const inlineItemHeaderStyle = useTemplateStyle("inlineItemHeader");
const leadingStyle = useTemplateStyle("inlineItemHeaderLeading");
const middleStyle = useTemplateStyle("inlineItemHeaderMiddle");
const trailingStyle = useTemplateStyle("inlineItemHeaderTrailing");
return (
<View style={composeStyles(inlineItemHeaderStyle)}>
<View style={composeStyles(leadingStyle)}>{leading}</View>
<View style={composeStyles(middleStyle)}>{middle}</View>
<View style={composeStyles(trailingStyle)}>{trailing}</View>
</View>
);
};
const stackedSidebarSplitRowStyle = {
flexDirection: "column",
alignItems: "flex-start",
} satisfies Style;
const useSectionSplitRowStyle = () => {
const splitRowStyle = useTemplateStyle("splitRow");
const placement = useTemplatePlacement();
const stackSidebarItemHeader = useTemplateFeature("stackSidebarItemHeader");
return composeStyles(
splitRowStyle,
stackSidebarItemHeader && placement === "sidebar" ? stackedSidebarSplitRowStyle : undefined,
);
};
const SectionItemHeader = ({ children }: { children: ReactNode }) => {
const mainItemHeaderBorder = useTemplateFeature("mainItemHeaderBorder");
const sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader");
if (!mainItemHeaderBorder) return <>{children}</>;
return <View style={composeStyles(sectionItemHeaderStyle)}>{children}</View>;
};
const SummarySection = ({ showHeading = true }: { showHeading?: boolean } = {}) => {
const data = useRender();
const { summary } = data;
if (!isVisibleSummary(summary)) return null;
return (
<SectionShell sectionId="summary" title={summary.title} showHeading={showHeading}>
<SectionItems>
<SectionItem>
<RichText>{summary.content}</RichText>
</SectionItem>
</SectionItems>
</SectionShell>
);
};
const CustomSummarySection = ({
section,
showHeading = true,
}: {
section: CustomItemSection<SummaryItem>;
showHeading?: boolean;
}) => {
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}>
<RichText>{item.content}</RichText>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const ProfileSection = ({
sectionId = "profiles",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<ProfileItem>;
} = {}) => {
const data = useRender();
const profiles = sectionData ?? data.sections.profiles;
const items = getVisibleItems(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}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold>{item.network}</Bold>
</View>
</SectionItemHeader>
<Link src={item.website.url}>
<Text>{item.username}</Text>
</Link>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const ExperienceSection = ({
sectionId = "experience",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<ExperienceItem>;
} = {}) => {
const data = useRender();
const experience = sectionData ?? data.sections.experience;
const items = getVisibleItems(experience);
const splitRowStyle = useSectionSplitRowStyle();
const alignRightStyle = useTemplateStyle("alignRight");
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 renderInlineHeader = () => (
<InlineItemHeader
leading={
hasPosition || hasLocation ? (
<Text>
{hasPosition ? item.position : ""}
{hasPosition && hasLocation ? " " : ""}
{hasLocation ? `(${item.location})` : ""}
</Text>
) : null
}
middle={<Bold>{item.company}</Bold>}
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
/>
);
const renderSplitHeader = () => (
<>
<View style={composeStyles(splitRowStyle)}>
<Bold>{item.company}</Bold>
<Text style={composeStyles(alignRightStyle)}>{item.location}</Text>
</View>
{item.roles.length === 0 && (
<View style={composeStyles(splitRowStyle)}>
<Text>{item.position}</Text>
<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>
</View>
)}
</>
);
return (
<SectionItem key={item.id}>
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
{item.roles.length > 0 && <MetaLine>{[item.period]}</MetaLine>}
{item.roles.map((role) => (
<View key={role.id}>
<View style={composeStyles(splitRowStyle)}>
<Text>{role.position}</Text>
<Text style={composeStyles(alignRightStyle)}>{role.period}</Text>
</View>
<RichText>{role.description}</RichText>
</View>
))}
{item.roles.length === 0 && <RichText>{item.description}</RichText>}
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
);
})}
</SectionItems>
</SectionShell>
);
};
const EducationSection = ({
sectionId = "education",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<EducationItem>;
} = {}) => {
const data = useRender();
const education = sectionData ?? data.sections.education;
const items = getVisibleItems(education);
const splitRowStyle = useSectionSplitRowStyle();
const alignRightStyle = useTemplateStyle("alignRight");
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 degreeAndGrade = [item.degree, item.grade].filter(Boolean).join(" • ");
const locationAndPeriod = [item.location, item.period].filter(Boolean).join(" • ");
const gradeAndLocation = [item.grade, item.location].filter(Boolean).join(" • ");
const hasArea = Boolean(item.area.trim());
const hasDegree = Boolean(item.degree.trim());
const renderInlineHeader = () => (
<>
<InlineItemHeader
leading={
hasArea || hasDegree ? (
<Text>
{hasArea ? item.area : ""}
{hasArea && hasDegree ? " " : ""}
{hasDegree ? `(${item.degree})` : ""}
</Text>
) : null
}
middle={<Bold>{item.school}</Bold>}
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
/>
{gradeAndLocation && <Text>{gradeAndLocation}</Text>}
</>
);
const renderSplitHeader = () => (
<>
<View style={composeStyles(splitRowStyle)}>
<Bold>{item.school}</Bold>
<Text style={composeStyles(alignRightStyle)}>{degreeAndGrade}</Text>
</View>
<View style={composeStyles(splitRowStyle)}>
<Text>{item.area}</Text>
<Text style={composeStyles(alignRightStyle)}>{locationAndPeriod}</Text>
</View>
</>
);
return (
<SectionItem key={item.id}>
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
);
})}
</SectionItems>
</SectionShell>
);
};
const ProjectsSection = ({
sectionId = "projects",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<ProjectItem>;
} = {}) => {
const data = useRender();
const projects = sectionData ?? data.sections.projects;
const items = getVisibleItems(projects);
const splitRowStyle = useSectionSplitRowStyle();
const alignRightStyle = useTemplateStyle("alignRight");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={projects.title}>
<SectionItems columns={projects.columns}>
{items.map((item) => (
<SectionItem key={item.id}>
<SectionItemHeader>
<View style={composeStyles(splitRowStyle)}>
<Bold>{item.name}</Bold>
<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>
</View>
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const SkillsSection = ({
sectionId = "skills",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<SkillItem>;
} = {}) => {
const data = useRender();
const skills = sectionData ?? data.sections.skills;
const items = getVisibleItems(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} style={{ rowGap: metrics.gapY(0.25) }}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold>{item.name}</Bold>
</View>
</SectionItemHeader>
<View>
<Text>{item.proficiency}</Text>
<Small>{item.keywords.join(", ")}</Small>
</View>
<LevelDisplay level={item.level} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const LanguagesSection = ({
sectionId = "languages",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<LanguageItem>;
} = {}) => {
const data = useRender();
const languages = sectionData ?? data.sections.languages;
const items = getVisibleItems(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}>
<SectionItemHeader>
<Bold>{item.language}</Bold>
<Text>{item.fluency}</Text>
</SectionItemHeader>
<LevelDisplay level={item.level} />
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const InterestsSection = ({
sectionId = "interests",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<InterestItem>;
} = {}) => {
const data = useRender();
const interests = sectionData ?? data.sections.interests;
const items = getVisibleItems(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}>
<SectionItemHeader>
<View style={composeStyles(inlineStyle)}>
<Icon name={item.icon as IconName} />
<Bold>{item.name}</Bold>
</View>
</SectionItemHeader>
<Small>{item.keywords.join(", ")}</Small>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const AwardsSection = ({
sectionId = "awards",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<AwardItem>;
} = {}) => {
const data = useRender();
const awards = sectionData ?? data.sections.awards;
const items = getVisibleItems(awards);
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={awards.title}>
<SectionItems columns={awards.columns}>
{items.map((item) => (
<SectionItem key={item.id}>
<SectionItemHeader>
<Bold>{item.title}</Bold>
<Text>{item.awarder}</Text>
<Small>{item.date}</Small>
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CertificationsSection = ({
sectionId = "certifications",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<CertificationItem>;
} = {}) => {
const data = useRender();
const certifications = sectionData ?? data.sections.certifications;
const items = getVisibleItems(certifications);
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={certifications.title}>
<SectionItems columns={certifications.columns}>
{items.map((item) => (
<SectionItem key={item.id}>
<SectionItemHeader>
<Bold>{item.title}</Bold>
<Text>{item.issuer}</Text>
<Small>{item.date}</Small>
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const PublicationsSection = ({
sectionId = "publications",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<PublicationItem>;
} = {}) => {
const data = useRender();
const publications = sectionData ?? data.sections.publications;
const items = getVisibleItems(publications);
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={publications.title}>
<SectionItems columns={publications.columns}>
{items.map((item) => (
<SectionItem key={item.id}>
<SectionItemHeader>
<Bold>{item.title}</Bold>
<Text>{item.publisher}</Text>
<Small>{item.date}</Small>
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const VolunteerSection = ({
sectionId = "volunteer",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<VolunteerItem>;
} = {}) => {
const data = useRender();
const volunteer = sectionData ?? data.sections.volunteer;
const items = getVisibleItems(volunteer);
const splitRowStyle = useSectionSplitRowStyle();
const alignRightStyle = useTemplateStyle("alignRight");
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
if (items.length === 0) return null;
return (
<SectionShell sectionId={sectionId} title={volunteer.title}>
<SectionItems columns={volunteer.columns}>
{items.map((item) => (
<SectionItem key={item.id}>
<SectionItemHeader>
{inlineItemHeader ? (
<InlineItemHeader
leading={<Text>{item.location}</Text>}
middle={<Bold>{item.organization}</Bold>}
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
/>
) : (
<>
<View style={composeStyles(splitRowStyle)}>
<Bold>{item.organization}</Bold>
<Text style={composeStyles(alignRightStyle)}>{item.location}</Text>
</View>
<MetaLine>{[item.period]}</MetaLine>
</>
)}
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const ReferencesSection = ({
sectionId = "references",
sectionData,
}: {
sectionId?: string;
sectionData?: ItemSection<ReferenceItem>;
} = {}) => {
const data = useRender();
const references = sectionData ?? data.sections.references;
const items = getVisibleItems(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}>
<SectionItemHeader>
<Bold>{item.name}</Bold>
<Text>{item.position}</Text>
<MetaLine>{[item.phone]}</MetaLine>
</SectionItemHeader>
<RichText>{item.description}</RichText>
{item.website.url && (
<Link src={item.website.url}>
<Small>{item.website.label || item.website.url}</Small>
</Link>
)}
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CoverLetterSection = ({ section }: { section: CustomItemSection<CoverLetterItem> }) => {
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}>
<RichText>{item.recipient}</RichText>
<RichText>{item.content}</RichText>
</SectionItem>
))}
</SectionItems>
</SectionShell>
);
};
const CustomSection = ({ sectionId, showHeading = true }: { sectionId: string; showHeading?: boolean }) => {
const data = useRender();
const customSection = data.customSections.find((section) => section.id === sectionId);
if (!customSection) return null;
return match(customSection.type)
.with("summary", () => (
<CustomSummarySection section={customSection as CustomItemSection<SummaryItem>} showHeading={showHeading} />
))
.with("profiles", () => (
<ProfileSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProfileItem>} />
))
.with("experience", () => (
<ExperienceSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ExperienceItem>} />
))
.with("education", () => (
<EducationSection sectionId={sectionId} sectionData={customSection as CustomItemSection<EducationItem>} />
))
.with("projects", () => (
<ProjectsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProjectItem>} />
))
.with("skills", () => (
<SkillsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<SkillItem>} />
))
.with("languages", () => (
<LanguagesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<LanguageItem>} />
))
.with("interests", () => (
<InterestsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<InterestItem>} />
))
.with("awards", () => (
<AwardsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<AwardItem>} />
))
.with("certifications", () => (
<CertificationsSection
sectionId={sectionId}
sectionData={customSection as CustomItemSection<CertificationItem>}
/>
))
.with("publications", () => (
<PublicationsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<PublicationItem>} />
))
.with("volunteer", () => (
<VolunteerSection sectionId={sectionId} sectionData={customSection as CustomItemSection<VolunteerItem>} />
))
.with("references", () => (
<ReferencesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ReferenceItem>} />
))
.with("cover-letter", () => <CoverLetterSection section={customSection as CustomItemSection<CoverLetterItem>} />)
.exhaustive();
};
export const Section = ({
section,
placement,
showHeading = true,
}: {
section: string;
placement: TemplatePlacement;
showHeading?: boolean;
}) => {
const data = useRender();
if (!isSectionVisible(section, data)) return null;
return (
<TemplatePlacementProvider placement={placement}>
{match(section)
.with("summary", () => <SummarySection showHeading={showHeading} />)
.with("profiles", () => <ProfileSection />)
.with("experience", () => <ExperienceSection />)
.with("education", () => <EducationSection />)
.with("projects", () => <ProjectsSection />)
.with("skills", () => <SkillsSection />)
.with("languages", () => <LanguagesSection />)
.with("interests", () => <InterestsSection />)
.with("awards", () => <AwardsSection />)
.with("certifications", () => <CertificationsSection />)
.with("publications", () => <PublicationsSection />)
.with("volunteer", () => <VolunteerSection />)
.with("references", () => <ReferencesSection />)
.otherwise(() => (
<CustomSection sectionId={section} showHeading={showHeading} />
))}
</TemplatePlacementProvider>
);
};
@@ -0,0 +1,32 @@
import type { Style } from "@react-pdf/types";
export type TemplatePlacement = "main" | "sidebar";
export type StyleInput = Style | Style[] | null | undefined;
export const composeStyles = (...styles: StyleInput[]): Style[] => {
return styles.flatMap((style) => {
if (!style) return [];
if (Array.isArray(style)) return style.filter(Boolean);
return [style];
});
};
export const mergeStyles = (...styles: StyleInput[]): Style => Object.assign({}, ...composeStyles(...styles));
export type ResolvePlacementColorOptions = {
placement: TemplatePlacement;
defaultForeground: string;
sidebarForeground?: string | undefined;
};
export const resolvePlacementColor = ({
placement,
defaultForeground,
sidebarForeground,
}: ResolvePlacementColorOptions) => {
if (placement === "sidebar" && sidebarForeground) return sidebarForeground;
return defaultForeground;
};
@@ -0,0 +1,85 @@
import type { CustomSection as SchemaCustomSection } from "@reactive-resume/schema/resume/data";
import type { Icon } from "phosphor-icons-react-pdf/dynamic";
import type { ComponentProps } from "react";
import type { StyleInput, TemplatePlacement } from "./styles";
export type TemplateColorRoles = {
foreground: string;
background: string;
primary: string;
sidebarForeground?: string | undefined;
sidebarBackground?: string | undefined;
};
export type TemplateStyleContext = {
placement: TemplatePlacement;
colors: TemplateColorRoles;
};
export type TemplateStyleSlot = StyleInput | ((context: TemplateStyleContext) => StyleInput);
export type TemplateIconProps = Omit<ComponentProps<typeof Icon>, "name">;
export type TemplateIconSlot =
| Partial<TemplateIconProps>
| ((context: TemplateStyleContext) => Partial<TemplateIconProps>);
export type TemplateFeatures = {
sectionTimeline?: boolean;
inlineItemHeader?: boolean;
stackSidebarItemHeader?: boolean;
mainItemHeaderBorder?: boolean;
};
export type SectionTimelineStyleSlots = {
items?: TemplateStyleSlot;
line?: TemplateStyleSlot;
item?: TemplateStyleSlot;
marker?: TemplateStyleSlot;
dot?: TemplateStyleSlot;
content?: TemplateStyleSlot;
};
export type TemplateFeatureStyleSlots = {
sectionTimeline?: SectionTimelineStyleSlots;
};
export type TemplateStyleSlots = {
page?: TemplateStyleSlot;
text?: TemplateStyleSlot;
heading?: TemplateStyleSlot;
div?: TemplateStyleSlot;
inline?: TemplateStyleSlot;
link?: TemplateStyleSlot;
small?: TemplateStyleSlot;
bold?: TemplateStyleSlot;
richParagraph?: TemplateStyleSlot;
richListItemRow?: TemplateStyleSlot;
richListItemMarker?: TemplateStyleSlot;
richListItemContent?: TemplateStyleSlot;
splitRow?: TemplateStyleSlot;
alignRight?: TemplateStyleSlot;
inlineItemHeader?: TemplateStyleSlot;
inlineItemHeaderLeading?: TemplateStyleSlot;
inlineItemHeaderMiddle?: TemplateStyleSlot;
inlineItemHeaderTrailing?: TemplateStyleSlot;
sectionItemHeader?: TemplateStyleSlot;
section?: TemplateStyleSlot;
sectionHeading?: TemplateStyleSlot;
sectionItems?: TemplateStyleSlot;
item?: TemplateStyleSlot;
levelContainer?: TemplateStyleSlot;
levelItem?: TemplateStyleSlot;
levelItemActive?: TemplateStyleSlot;
levelItemInactive?: TemplateStyleSlot;
icon?: TemplateIconSlot;
};
export type ItemSection<T> = {
title: string;
columns: number;
hidden: boolean;
items: T[];
};
export type CustomItemSection<T> = Omit<SchemaCustomSection, "items"> & ItemSection<T>;