mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
feat: implement style rules
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { StyleSlot } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionStyleRuleContext } from "./style-rules";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
import type {
|
||||
SectionTimelineStyleSlots,
|
||||
@@ -11,6 +13,8 @@ import type {
|
||||
TemplateStyleSlots,
|
||||
} from "./types";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useRender } from "../../context";
|
||||
import { resolveStyleRuleSlot } from "./style-rules";
|
||||
|
||||
type TemplateContextValue = {
|
||||
styles: TemplateStyleSlots;
|
||||
@@ -27,6 +31,7 @@ type TemplateProviderProps = Omit<TemplateContextValue, "featureStyles" | "featu
|
||||
|
||||
const TemplateContext = createContext<TemplateContextValue | null>(null);
|
||||
const TemplatePlacementContext = createContext<TemplatePlacement>("main");
|
||||
const SectionStyleContext = createContext<SectionStyleRuleContext | null>(null);
|
||||
|
||||
const resolveStyleSlot = (slot: TemplateStyleSlot | undefined, context: TemplateStyleContextValue): StyleInput => {
|
||||
if (!slot) return undefined;
|
||||
@@ -78,6 +83,16 @@ export const TemplatePlacementProvider = ({
|
||||
return <TemplatePlacementContext.Provider value={placement}>{children}</TemplatePlacementContext.Provider>;
|
||||
};
|
||||
|
||||
export const SectionStyleProvider = ({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context: SectionStyleRuleContext;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return <SectionStyleContext.Provider value={context}>{children}</SectionStyleContext.Provider>;
|
||||
};
|
||||
|
||||
const useTemplateContext = () => {
|
||||
const context = use(TemplateContext);
|
||||
|
||||
@@ -108,6 +123,15 @@ export const useTemplateStyle = (slot: keyof TemplateStyleSlots): StyleInput =>
|
||||
return resolveStyleSlot(styles[slot] as TemplateStyleSlot | undefined, context);
|
||||
};
|
||||
|
||||
export const useSectionStyleRule = (slot: StyleSlot): StyleInput => {
|
||||
const data = useRender();
|
||||
const context = use(SectionStyleContext);
|
||||
|
||||
if (!context) return undefined;
|
||||
|
||||
return resolveStyleRuleSlot(data, { ...context, slot });
|
||||
};
|
||||
|
||||
export const useTemplateFeatureStyle = (
|
||||
feature: keyof TemplateFeatureStyleSlots,
|
||||
slot: keyof SectionTimelineStyleSlots,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { useRender } from "../../context";
|
||||
import { View } from "../../renderer";
|
||||
import { useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { getTemplateMetrics } from "./metrics";
|
||||
import { Icon } from "./primitives";
|
||||
import { composeStyles } from "./styles";
|
||||
@@ -19,6 +19,7 @@ export const LevelDisplay = ({ level }: { level: number }) => {
|
||||
const levelItemStyle = useTemplateStyle("levelItem");
|
||||
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
|
||||
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
|
||||
const levelRuleStyle = useSectionStyleRule("level");
|
||||
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
|
||||
|
||||
if (level === 0) return null;
|
||||
@@ -40,6 +41,7 @@ export const LevelDisplay = ({ level }: { level: number }) => {
|
||||
style={composeStyles(
|
||||
{ flexDirection: "row", alignItems: "center", marginTop: 2, columnGap: gap },
|
||||
levelContainerStyle,
|
||||
levelRuleStyle,
|
||||
)}
|
||||
>
|
||||
{LEVEL_ITEM_KEYS.map((itemKey, index) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ComponentProps } from "react";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
|
||||
import { useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
import { composeLinkStyles, composeStyles } from "./styles";
|
||||
|
||||
@@ -17,40 +17,64 @@ export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
|
||||
|
||||
export const Text = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
const textRuleStyle = useSectionStyleRule("text");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
return <PdfText style={composeStyles(textStyle, textRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const headingStyle = useTemplateStyle("heading");
|
||||
const headingRuleStyle = useSectionStyleRule("heading");
|
||||
|
||||
return <PdfText style={composeStyles(headingStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
return (
|
||||
<PdfText style={composeStyles(headingStyle, headingRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
|
||||
const linkStyle = useTemplateStyle("link");
|
||||
const linkRuleStyle = useSectionStyleRule("link");
|
||||
|
||||
return <PdfLink style={composeLinkStyles(linkStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
return <PdfLink style={composeLinkStyles(linkStyle, linkRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
const smallStyle = useTemplateStyle("small");
|
||||
const secondaryTextRuleStyle = useSectionStyleRule("secondaryText");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, smallStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
return (
|
||||
<PdfText
|
||||
style={composeStyles(textStyle, smallStyle, secondaryTextRuleStyle, asStyleInput(style), safeTextStyle)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
const boldStyle = useTemplateStyle("bold");
|
||||
const textRuleStyle = useSectionStyleRule("text");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, boldStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
return (
|
||||
<PdfText
|
||||
style={composeStyles(textStyle, textRuleStyle, boldStyle, asStyleInput(style), safeTextStyle)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
|
||||
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
|
||||
const iconRuleStyle = useSectionStyleRule("icon");
|
||||
|
||||
if (iconProps.display === "none") return null;
|
||||
|
||||
return <PhosphorIcon {...iconProps} {...props} style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))} />;
|
||||
return (
|
||||
<PhosphorIcon
|
||||
{...iconProps}
|
||||
{...props}
|
||||
style={composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,12 @@ describe("normalizeRichTextHtml", () => {
|
||||
expect(normalizeRichTextHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("unwraps single paragraph wrappers inside list items", () => {
|
||||
expect(normalizeRichTextHtml("<ul><li><p>a</p></li><li><p><strong>b</strong></p></li></ul>")).toBe(
|
||||
"<ul><li>a</li><li><strong>b</strong></li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
it("flushes accumulated inlines before block-level tags", () => {
|
||||
expect(normalizeRichTextHtml("loose<ul><li>a</li></ul>")).toBe("<p>loose</p><ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Node } from "node-html-parser";
|
||||
import type { HTMLElement, Node } from "node-html-parser";
|
||||
import { NodeType, parse } from "node-html-parser";
|
||||
|
||||
export const richTextMarkClassName = "rr-pdf-mark";
|
||||
@@ -50,6 +50,23 @@ const normalizeMarkElements = (root: ReturnType<typeof parse>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMeaningfulNode = (node: Node): boolean =>
|
||||
node.nodeType !== NodeType.TEXT_NODE || node.toString().trim().length > 0;
|
||||
|
||||
const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE;
|
||||
|
||||
const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
|
||||
for (const listItem of root.querySelectorAll("li")) {
|
||||
const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode);
|
||||
if (meaningfulChildren.length !== 1) continue;
|
||||
|
||||
const child = meaningfulChildren[0];
|
||||
if (!child || !isElement(child) || getTagName(child) !== "p") continue;
|
||||
|
||||
listItem.innerHTML = child.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
const isInlineNode = (node: Node): boolean => {
|
||||
if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true;
|
||||
if (node.nodeType !== NodeType.ELEMENT_NODE) return false;
|
||||
@@ -98,6 +115,7 @@ export const normalizeRichTextHtml = (html: string): string => {
|
||||
let inlineNodes: string[] = [];
|
||||
|
||||
normalizeMarkElements(root);
|
||||
unwrapSingleParagraphListItems(root);
|
||||
|
||||
const flushInlineNodes = () => {
|
||||
const inlineHtml = inlineNodes.join("").trim();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { richTextMarkClassName } from "./rich-text-html";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
import { mergeLinkStyles, mergeStyles } from "./styles";
|
||||
|
||||
type RichTextProseSpacing = {
|
||||
paragraph: Style;
|
||||
listItem: Style;
|
||||
};
|
||||
|
||||
type CreateRichTextStylesheetOptions = {
|
||||
boldStyle?: StyleInput;
|
||||
linkStyle?: StyleInput;
|
||||
richParagraphStyle?: StyleInput;
|
||||
richParagraphRuleStyle?: StyleInput;
|
||||
richListRuleStyle?: StyleInput;
|
||||
richBoldRuleStyle?: StyleInput;
|
||||
richLinkRuleStyle?: StyleInput;
|
||||
richMarkRuleStyle?: StyleInput;
|
||||
proseSpacing?: RichTextProseSpacing;
|
||||
};
|
||||
|
||||
const richMarkStyle = {
|
||||
backgroundColor: "#ffff00",
|
||||
} satisfies Style;
|
||||
|
||||
const emptyProseSpacing: RichTextProseSpacing = {
|
||||
paragraph: {},
|
||||
listItem: {},
|
||||
};
|
||||
|
||||
export const createRichTextStylesheet = ({
|
||||
boldStyle,
|
||||
linkStyle,
|
||||
richParagraphStyle,
|
||||
richParagraphRuleStyle,
|
||||
richListRuleStyle,
|
||||
richBoldRuleStyle,
|
||||
richLinkRuleStyle,
|
||||
richMarkRuleStyle,
|
||||
proseSpacing = emptyProseSpacing,
|
||||
}: CreateRichTextStylesheetOptions = {}) => ({
|
||||
b: mergeStyles(boldStyle, richBoldRuleStyle, safeTextStyle),
|
||||
strong: mergeStyles(boldStyle, richBoldRuleStyle, safeTextStyle),
|
||||
ul: mergeStyles(richListRuleStyle),
|
||||
ol: mergeStyles(richListRuleStyle),
|
||||
li: mergeStyles(proseSpacing.listItem),
|
||||
[`.${richTextMarkClassName}`]: mergeStyles(richMarkStyle, richMarkRuleStyle, safeTextStyle),
|
||||
p: mergeStyles(richParagraphStyle, richParagraphRuleStyle, safeTextStyle, proseSpacing.paragraph),
|
||||
a: mergeLinkStyles(linkStyle, richLinkRuleStyle, safeTextStyle),
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { parse } from "node-html-parser";
|
||||
import { createElement } from "react";
|
||||
import { normalizeRichTextHtml } from "./rich-text-html";
|
||||
import { renderRichTextParagraph } from "./rich-text-renderers";
|
||||
import { createRichTextStylesheet } from "./rich-text-stylesheet";
|
||||
|
||||
type PdfElement = ReactElement<{ children?: unknown; style?: unknown }>;
|
||||
|
||||
@@ -22,13 +23,13 @@ describe("normalizeRichTextHtml", () => {
|
||||
|
||||
it("preserves existing block rich text", () => {
|
||||
expect(normalizeRichTextHtml("<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>")).toBe(
|
||||
"<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>",
|
||||
"<p>Existing paragraph.</p><ul><li>Existing item.</li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps inline runs around top-level blocks", () => {
|
||||
expect(normalizeRichTextHtml("Intro <strong>text</strong><ul><li><p>Item</p></li></ul>Outro")).toBe(
|
||||
"<p>Intro <strong>text</strong></p><ul><li><p>Item</p></li></ul><p>Outro</p>",
|
||||
"<p>Intro <strong>text</strong></p><ul><li>Item</li></ul><p>Outro</p>",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -50,3 +51,19 @@ describe("renderRichTextParagraph", () => {
|
||||
expect(props.children).toEqual(["Plain ", expect.any(Object), " text"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRichTextStylesheet", () => {
|
||||
it("applies list style rules to unordered and ordered list containers", () => {
|
||||
const stylesheet = createRichTextStylesheet({
|
||||
richListRuleStyle: { rowGap: 8 },
|
||||
proseSpacing: {
|
||||
paragraph: { marginTop: 12, marginBottom: 12 },
|
||||
listItem: { marginTop: 2, marginBottom: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(stylesheet.ul).toEqual({ rowGap: 8 });
|
||||
expect(stylesheet.ol).toEqual({ rowGap: 8 });
|
||||
expect(stylesheet.li).toEqual({ marginTop: 2, marginBottom: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import { cloneElement, isValidElement } from "react";
|
||||
import { Html } from "react-pdf-html";
|
||||
import { useRender } from "../../context";
|
||||
import { Text as PdfText, View } from "../../renderer";
|
||||
import { useTemplateStyle } from "./context";
|
||||
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
import { useSectionStyleRule, useTemplateStyle } from "./context";
|
||||
import { convertPseudoBulletParagraphs, normalizeRichTextHtml } from "./rich-text-html";
|
||||
import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers";
|
||||
import {
|
||||
createRichTextProseSpacing,
|
||||
@@ -14,17 +14,14 @@ import {
|
||||
resolveRichTextBodyLineHeight,
|
||||
stripRichTextVerticalMargins,
|
||||
} from "./rich-text-spacing";
|
||||
import { createRichTextStylesheet } from "./rich-text-stylesheet";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
import { composeStyles, mergeLinkStyles, mergeStyles } from "./styles";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
const richListItemContentStackStyle = {
|
||||
flexDirection: "column",
|
||||
} satisfies Style;
|
||||
|
||||
const richMarkStyle = {
|
||||
backgroundColor: "#ffff00",
|
||||
} satisfies Style;
|
||||
|
||||
// react-pdf textkit reads BiDi base direction from each run's own `direction` attribute
|
||||
// (default "ltr"), and react-pdf-html buckets inline content into styleless inner <Text>
|
||||
// frames — so the rtl style has to be injected onto every descendant, not just a wrapper.
|
||||
@@ -65,7 +62,19 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
const richListItemRowStyle = useTemplateStyle("richListItemRow");
|
||||
const richListItemMarkerStyle = useTemplateStyle("richListItemMarker");
|
||||
const richListItemContentStyle = useTemplateStyle("richListItemContent");
|
||||
const bodyLineHeight = resolveRichTextBodyLineHeight(richParagraphStyle, richListItemContentStyle);
|
||||
const richParagraphRuleStyle = useSectionStyleRule("richParagraph");
|
||||
const richListRuleStyle = useSectionStyleRule("richList");
|
||||
const richListItemRowRuleStyle = useSectionStyleRule("richListItemRow");
|
||||
const richListItemContentRuleStyle = useSectionStyleRule("richListItemContent");
|
||||
const richLinkRuleStyle = useSectionStyleRule("richLink");
|
||||
const richBoldRuleStyle = useSectionStyleRule("richBold");
|
||||
const richMarkRuleStyle = useSectionStyleRule("richMark");
|
||||
const bodyLineHeight = resolveRichTextBodyLineHeight(
|
||||
richParagraphStyle,
|
||||
richParagraphRuleStyle,
|
||||
richListItemContentStyle,
|
||||
richListItemContentRuleStyle,
|
||||
);
|
||||
const proseSpacing = createRichTextProseSpacing(bodyLineHeight);
|
||||
|
||||
const normalized = normalizeRichTextHtml(children);
|
||||
@@ -85,7 +94,9 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
<Html
|
||||
resetStyles
|
||||
renderers={{
|
||||
b: ({ children }) => <PdfText style={composeStyles(boldStyle, safeTextStyle)}>{children}</PdfText>,
|
||||
b: ({ children }) => (
|
||||
<PdfText style={composeStyles(boldStyle, richBoldRuleStyle, safeTextStyle)}>{children}</PdfText>
|
||||
),
|
||||
p: (props) => {
|
||||
const paragraphProps = {
|
||||
...props,
|
||||
@@ -111,7 +122,13 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
const contentNode = rtl ? (
|
||||
<PdfText
|
||||
key="content"
|
||||
style={composeStyles(richListItemContentStyle, contentItemStyles, safeTextStyle, rtlTextWrapStyle)}
|
||||
style={composeStyles(
|
||||
richListItemContentStyle,
|
||||
richListItemContentRuleStyle,
|
||||
contentItemStyles,
|
||||
safeTextStyle,
|
||||
rtlTextWrapStyle,
|
||||
)}
|
||||
>
|
||||
{applyRtlDirectionRecursively(children)}
|
||||
</PdfText>
|
||||
@@ -120,6 +137,7 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
key="content"
|
||||
style={composeStyles(
|
||||
richListItemContentStyle,
|
||||
richListItemContentRuleStyle,
|
||||
contentItemStyles,
|
||||
richListItemContentStackStyle,
|
||||
safeTextStyle,
|
||||
@@ -132,20 +150,30 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
// Yoga ignores `flexDirection`/`direction` on rows inside react-pdf-html's <ul>
|
||||
// (works fine for split-row/contact-list). Swap DOM order to position the marker.
|
||||
return (
|
||||
<View style={composeStyles(richListItemRowStyle, itemStyles, getRichTextEdgeTrimStyle(element))}>
|
||||
<View
|
||||
style={composeStyles(
|
||||
richListItemRowStyle,
|
||||
richListItemRowRuleStyle,
|
||||
itemStyles,
|
||||
getRichTextEdgeTrimStyle(element),
|
||||
)}
|
||||
>
|
||||
{rtl ? [contentNode, markerNode] : [markerNode, contentNode]}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
}}
|
||||
stylesheet={{
|
||||
b: mergeStyles(boldStyle, safeTextStyle),
|
||||
strong: mergeStyles(boldStyle, safeTextStyle),
|
||||
li: mergeStyles(proseSpacing.listItem),
|
||||
[`.${richTextMarkClassName}`]: mergeStyles(richMarkStyle, safeTextStyle),
|
||||
p: mergeStyles(richParagraphStyle, safeTextStyle, proseSpacing.paragraph),
|
||||
a: mergeLinkStyles(linkStyle, safeTextStyle),
|
||||
}}
|
||||
stylesheet={createRichTextStylesheet({
|
||||
boldStyle,
|
||||
linkStyle,
|
||||
richParagraphStyle,
|
||||
richParagraphRuleStyle,
|
||||
richListRuleStyle,
|
||||
richBoldRuleStyle,
|
||||
richLinkRuleStyle,
|
||||
richMarkRuleStyle,
|
||||
proseSpacing,
|
||||
})}
|
||||
>
|
||||
{html}
|
||||
</Html>
|
||||
|
||||
@@ -27,7 +27,9 @@ import { getResumeSectionTitle } from "../../section-title";
|
||||
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
|
||||
import { getWebsiteDisplayText } from "./contact";
|
||||
import {
|
||||
SectionStyleProvider,
|
||||
TemplatePlacementProvider,
|
||||
useSectionStyleRule,
|
||||
useTemplateFeature,
|
||||
useTemplateFeatureStyle,
|
||||
useTemplatePlacement,
|
||||
@@ -41,6 +43,7 @@ import { RichText } from "./rich-text";
|
||||
import { createRtlStyleHelpers } from "./rtl";
|
||||
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
|
||||
import { hasSplitRowText, promoteSplitRowRight } from "./split-row";
|
||||
import { getSectionStyleRuleContext } from "./style-rules";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
type SectionItemsContextValue = {
|
||||
@@ -88,12 +91,16 @@ const SectionShell = ({
|
||||
}) => {
|
||||
const data = useRender();
|
||||
const sectionStyle = useTemplateStyle("section");
|
||||
const sectionRuleStyle = useSectionStyleRule("section");
|
||||
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
|
||||
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
|
||||
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
|
||||
|
||||
return (
|
||||
<View style={composeStyles(sectionStyle)}>
|
||||
{showHeading && <Heading style={composeStyles(sectionHeadingStyle)}>{sectionTitle}</Heading>}
|
||||
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
|
||||
{showHeading && (
|
||||
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
@@ -160,13 +167,14 @@ const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?
|
||||
const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleInput }) => {
|
||||
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");
|
||||
|
||||
if (!useTimeline) {
|
||||
return <Div style={composeStyles(itemStyle, sectionItemStyle, style)}>{children}</Div>;
|
||||
return <Div style={composeStyles(itemStyle, itemRuleStyle, sectionItemStyle, style)}>{children}</Div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -174,7 +182,7 @@ const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleIn
|
||||
<View style={composeStyles(timelineMarkerStyle)}>
|
||||
<View style={composeStyles(timelineDotStyle)} />
|
||||
</View>
|
||||
<Div style={composeStyles(itemStyle, timelineContentStyle, style)}>{children}</Div>
|
||||
<Div style={composeStyles(itemStyle, itemRuleStyle, timelineContentStyle, style)}>{children}</Div>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -924,23 +932,25 @@ export const Section = ({
|
||||
|
||||
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} />
|
||||
))}
|
||||
<SectionStyleProvider context={getSectionStyleRuleContext(data, section)}>
|
||||
{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} />
|
||||
))}
|
||||
</SectionStyleProvider>
|
||||
</TemplatePlacementProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { getSectionStyleRuleContext, resolveStyleRuleSlot } from "./style-rules";
|
||||
|
||||
const createResumeData = (styleRules: ResumeData["metadata"]["styleRules"]): ResumeData => ({
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
styleRules,
|
||||
},
|
||||
});
|
||||
|
||||
describe("resolveStyleRuleSlot", () => {
|
||||
it("applies global, section-type, and section-id rules in specificity order", () => {
|
||||
const data = createResumeData([
|
||||
{
|
||||
id: "global-heading",
|
||||
label: "Global Heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "rgba(0, 0, 0, 1)", fontSize: 12 } },
|
||||
},
|
||||
{
|
||||
id: "experience-heading",
|
||||
label: "Experience Heading",
|
||||
enabled: true,
|
||||
target: { scope: "sectionType", sectionType: "experience" },
|
||||
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
|
||||
},
|
||||
{
|
||||
id: "specific-heading",
|
||||
label: "Specific Heading",
|
||||
enabled: true,
|
||||
target: { scope: "sectionId", sectionId: "experience" },
|
||||
slots: { heading: { fontSize: 15 } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveStyleRuleSlot(data, {
|
||||
sectionId: "experience",
|
||||
sectionType: "experience",
|
||||
slot: "heading",
|
||||
}),
|
||||
).toEqual({ color: "#dc2626", fontSize: 15 });
|
||||
});
|
||||
|
||||
it("ignores disabled rules and non-matching deleted section ids", () => {
|
||||
const data = createResumeData([
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
enabled: false,
|
||||
target: { scope: "global" },
|
||||
slots: { section: { backgroundColor: "rgba(0, 0, 0, 1)" } },
|
||||
},
|
||||
{
|
||||
id: "missing",
|
||||
label: "Missing",
|
||||
enabled: true,
|
||||
target: { scope: "sectionId", sectionId: "missing-section" },
|
||||
slots: { section: { backgroundColor: "rgba(220, 38, 38, 1)" } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveStyleRuleSlot(data, {
|
||||
sectionId: "experience",
|
||||
sectionType: "experience",
|
||||
slot: "section",
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("resolves custom section types for section-type rules", () => {
|
||||
const data = createResumeData([
|
||||
{
|
||||
id: "custom-summary-rich-text",
|
||||
label: "Custom Summary Rich Text",
|
||||
enabled: true,
|
||||
target: { scope: "sectionType", sectionType: "summary" },
|
||||
slots: { richParagraph: { color: "rgba(21, 93, 252, 1)" } },
|
||||
},
|
||||
]);
|
||||
const customSection = {
|
||||
...defaultResumeData.summary,
|
||||
id: "custom-summary",
|
||||
type: "summary" as const,
|
||||
items: [],
|
||||
};
|
||||
const customData = { ...data, customSections: [customSection] };
|
||||
|
||||
expect(getSectionStyleRuleContext(customData, "custom-summary")).toEqual({
|
||||
sectionId: "custom-summary",
|
||||
sectionType: "summary",
|
||||
});
|
||||
expect(
|
||||
resolveStyleRuleSlot(customData, {
|
||||
sectionId: "custom-summary",
|
||||
sectionType: "summary",
|
||||
slot: "richParagraph",
|
||||
}),
|
||||
).toEqual({ color: "#155dfc" });
|
||||
});
|
||||
|
||||
it("clamps unsafe spacing values before returning React PDF styles", () => {
|
||||
const data = createResumeData([
|
||||
{
|
||||
id: "spacing",
|
||||
label: "Spacing",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { item: { padding: 1000, marginTop: -1000, rowGap: -12, borderWidth: -5 } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveStyleRuleSlot(data, {
|
||||
sectionId: "skills",
|
||||
sectionType: "skills",
|
||||
slot: "item",
|
||||
}),
|
||||
).toEqual({ padding: 72, marginTop: -72, rowGap: -12, borderWidth: 0 });
|
||||
});
|
||||
|
||||
it("resolves visible text, opacity, and border style properties", () => {
|
||||
const data = createResumeData([
|
||||
{
|
||||
id: "rich-link-style",
|
||||
label: "Rich Link Style",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: {
|
||||
richLink: {
|
||||
color: "rgba(21, 93, 252, 1)",
|
||||
textDecoration: "underline",
|
||||
textDecorationColor: "rgba(220, 38, 38, 1)",
|
||||
textDecorationStyle: "dashed",
|
||||
fontStyle: "italic",
|
||||
lineHeight: 9,
|
||||
letterSpacing: -100,
|
||||
textAlign: "right",
|
||||
textTransform: "uppercase",
|
||||
opacity: 2,
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveStyleRuleSlot(data, {
|
||||
sectionId: "experience",
|
||||
sectionType: "experience",
|
||||
slot: "richLink",
|
||||
}),
|
||||
).toEqual({
|
||||
color: "#155dfc",
|
||||
textDecoration: "underline",
|
||||
textDecorationColor: "#dc2626",
|
||||
textDecorationStyle: "dashed",
|
||||
fontStyle: "italic",
|
||||
lineHeight: 4,
|
||||
letterSpacing: -16,
|
||||
textAlign: "right",
|
||||
textTransform: "uppercase",
|
||||
opacity: 1,
|
||||
borderStyle: "dotted",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type {
|
||||
CustomSectionType,
|
||||
ResumeData,
|
||||
SectionType,
|
||||
StyleIntent,
|
||||
StyleSlot,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
|
||||
export type SectionStyleRuleContext = {
|
||||
sectionId: string;
|
||||
sectionType?: CustomSectionType | undefined;
|
||||
};
|
||||
|
||||
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
|
||||
slot: StyleSlot;
|
||||
};
|
||||
|
||||
const spacingProperties = [
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"marginTop",
|
||||
"marginRight",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"borderWidth",
|
||||
"borderRadius",
|
||||
] as const;
|
||||
|
||||
const colorProperties = ["color", "backgroundColor", "borderColor", "textDecorationColor"] as const;
|
||||
|
||||
const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => {
|
||||
if (property === "borderWidth" || property === "borderRadius")
|
||||
return { min: 0, max: property === "borderWidth" ? 24 : 72 };
|
||||
|
||||
return { min: -72, max: 72 };
|
||||
};
|
||||
|
||||
const builtInSectionTypes = new Set<SectionType>([
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
]);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const toPdfColor = (value: string) => {
|
||||
try {
|
||||
return rgbaStringToHex(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const toStyle = (intent: StyleIntent | undefined): Style => {
|
||||
if (!intent) return {};
|
||||
|
||||
const style: Style = {};
|
||||
|
||||
for (const property of colorProperties) {
|
||||
const value = intent[property];
|
||||
if (value) style[property] = toPdfColor(value);
|
||||
}
|
||||
|
||||
if (intent.opacity !== undefined) style.opacity = clamp(intent.opacity, 0, 1);
|
||||
if (intent.fontSize !== undefined) style.fontSize = clamp(intent.fontSize, 6, 48);
|
||||
if (intent.fontWeight !== undefined) style.fontWeight = intent.fontWeight;
|
||||
if (intent.fontStyle !== undefined) style.fontStyle = intent.fontStyle;
|
||||
if (intent.lineHeight !== undefined) style.lineHeight = clamp(intent.lineHeight, 0.5, 4);
|
||||
if (intent.letterSpacing !== undefined) style.letterSpacing = clamp(intent.letterSpacing, -16, 16);
|
||||
if (intent.textDecoration !== undefined) style.textDecoration = intent.textDecoration;
|
||||
if (intent.textDecorationStyle !== undefined) style.textDecorationStyle = intent.textDecorationStyle;
|
||||
if (intent.textAlign !== undefined) style.textAlign = intent.textAlign;
|
||||
if (intent.textTransform !== undefined) style.textTransform = intent.textTransform;
|
||||
if (intent.borderStyle !== undefined) style.borderStyle = intent.borderStyle;
|
||||
|
||||
for (const property of spacingProperties) {
|
||||
const value = intent[property];
|
||||
const range = spacingPropertyRange(property);
|
||||
if (value !== undefined) style[property] = clamp(value, range.min, range.max);
|
||||
}
|
||||
|
||||
return style;
|
||||
};
|
||||
|
||||
export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => {
|
||||
if (sectionId === "summary") return { sectionId, sectionType: "summary" };
|
||||
if (builtInSectionTypes.has(sectionId as SectionType)) {
|
||||
return { sectionId, sectionType: sectionId as SectionType };
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((section) => section.id === sectionId);
|
||||
|
||||
return { sectionId, sectionType: customSection?.type };
|
||||
};
|
||||
|
||||
export const resolveStyleRuleSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): Style => {
|
||||
const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => {
|
||||
if (!rule.enabled) return false;
|
||||
if (!rule.slots[options.slot]) return false;
|
||||
|
||||
if (rule.target.scope === "global") return true;
|
||||
if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType;
|
||||
if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record<
|
||||
"global" | "sectionType" | "sectionId",
|
||||
number
|
||||
>;
|
||||
|
||||
const bySpecificity = [...matchingRules].sort((a, b) => {
|
||||
return specificity[a.target.scope] - specificity[b.target.scope];
|
||||
});
|
||||
|
||||
return Object.assign({}, ...bySpecificity.map((rule) => toStyle(rule.slots[options.slot])));
|
||||
};
|
||||
Reference in New Issue
Block a user