feat: implement style rules

This commit is contained in:
Amruth Pillai
2026-05-27 10:57:33 +02:00
parent 7bff6644d8
commit b04eef1479
41 changed files with 2995 additions and 396 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+3 -3
View File
@@ -23,7 +23,7 @@
"@ai-sdk/google": "^3.0.79",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1053.0",
"@aws-sdk/client-s3": "^3.1054.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/server": "^1.14.3",
@@ -41,7 +41,7 @@
"drizzle-orm": "1.0.0-rc.3",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.47.0",
"ioredis": "^5.10.1",
"ioredis": "^5.11.0",
"ollama-ai-provider-v2": "^3.5.1",
"react": "^19.2.6",
"resumable-stream": "^2.2.12",
@@ -52,7 +52,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
"dependencies": {
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@reactive-resume/db": "workspace:*",
@@ -36,7 +36,7 @@
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"drizzle-kit": "1.0.0-rc.3",
"typescript": "^6.0.3"
}
+1 -1
View File
@@ -20,7 +20,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+5 -5
View File
@@ -18,17 +18,17 @@
},
"dependencies": {
"@reactive-resume/env": "workspace:*",
"nodemailer": "^8.0.8",
"nodemailer": "^8.0.9",
"react": "^19.2.6",
"react-email": "^6.3.3"
"react-email": "^6.4.0"
},
"devDependencies": {
"@react-email/ui": "^6.3.3",
"@react-email/ui": "^6.4.0",
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"react-doctor": "^0.2.6",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"react-doctor": "^0.2.9",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.9.1",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
@@ -762,6 +762,7 @@ export class ReactiveResumeV4JSONImporter {
},
},
notes: v4Data.metadata.notes ?? "",
styleRules: [],
},
};
+1 -1
View File
@@ -29,7 +29,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
+2 -2
View File
@@ -38,8 +38,8 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"react-doctor": "^0.2.6",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"react-doctor": "^0.2.9",
"typescript": "^6.0.3"
}
}
@@ -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 });
});
});
+47 -19
View File
@@ -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>
+31 -21
View File
@@ -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])));
};
+1 -1
View File
@@ -21,7 +21,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}
+107
View File
@@ -8,6 +8,8 @@ import {
pictureSchema,
resumeDataSchema,
skillItemSchema,
styleRuleSchema,
styleRulesSchema,
websiteSchema,
} from "./data";
import { defaultResumeData } from "./default";
@@ -270,3 +272,108 @@ describe("pageSchema", () => {
}
});
});
describe("styleRulesSchema", () => {
it("defaults to an empty rule list on default resume metadata", () => {
expect(defaultResumeData.metadata.styleRules).toEqual([]);
expect(styleRulesSchema.safeParse(defaultResumeData.metadata.styleRules).success).toBe(true);
});
it("accepts global, section-type, and section-id targets", () => {
const rules = [
{
id: "global-headings",
label: "Global headings",
enabled: true,
target: { scope: "global" },
slots: {
heading: {
color: "rgba(220, 38, 38, 1)",
fontWeight: "700",
fontStyle: "italic",
lineHeight: 1.35,
letterSpacing: -0.5,
textDecoration: "underline",
textDecorationColor: "rgba(220, 38, 38, 1)",
textDecorationStyle: "dotted",
textAlign: "center",
textTransform: "uppercase",
opacity: 0.85,
},
},
},
{
id: "experience-items",
label: "Experience items",
enabled: true,
target: { scope: "sectionType", sectionType: "experience" },
slots: {
item: {
backgroundColor: "rgba(245, 245, 245, 1)",
padding: -6,
marginBottom: -4,
rowGap: -2,
borderStyle: "dashed",
},
},
},
{
id: "custom-section",
label: "Custom section",
enabled: true,
target: { scope: "sectionId", sectionId: "94ddf90f-46ef-4b0a-9a99-2ed118af52dd" },
slots: { richList: { rowGap: 6 } },
},
];
expect(styleRulesSchema.safeParse(rules).success).toBe(true);
});
it("rejects section-item targets for v1", () => {
expect(
styleRuleSchema.safeParse({
id: "item-style",
label: "Item Style",
enabled: true,
target: { scope: "sectionItem", sectionId: "experience", itemId: "item-1" },
slots: { item: { color: "rgba(0, 0, 0, 1)" } },
}).success,
).toBe(false);
});
it("rejects unsupported raw layout properties", () => {
expect(
styleRuleSchema.safeParse({
id: "unsafe",
label: "Unsafe",
enabled: true,
target: { scope: "global" },
slots: { section: { position: "absolute" } },
}).success,
).toBe(false);
});
it("rejects the removed rich-text marker slot", () => {
expect(
styleRuleSchema.safeParse({
id: "marker",
label: "Marker",
enabled: true,
target: { scope: "global" },
slots: { richListItemMarker: { color: "rgba(21, 93, 252, 1)" } },
}).success,
).toBe(false);
});
it("rejects unsafe visible style values outside the v1 ranges", () => {
expect(
styleRuleSchema.safeParse({
id: "unsafe-visible",
label: "Unsafe Visible",
enabled: true,
target: { scope: "global" },
slots: { heading: { opacity: 2, lineHeight: 10, letterSpacing: -17 } },
}).success,
).toBe(false);
});
});
+99
View File
@@ -491,6 +491,102 @@ export const typographySchema = z.object({
heading: typographyItemSchema.describe("The typography for the headings of the resume."),
});
export const styleSlotSchema = z.enum([
"section",
"heading",
"item",
"text",
"secondaryText",
"link",
"icon",
"level",
"richParagraph",
"richList",
"richListItemRow",
"richListItemContent",
"richLink",
"richBold",
"richMark",
]);
export type StyleSlot = z.infer<typeof styleSlotSchema>;
export const styleIntentSchema = z
.strictObject({
color: z.string().optional(),
backgroundColor: z.string().optional(),
borderColor: z.string().optional(),
textDecorationColor: z.string().optional(),
opacity: z.number().min(0).max(1).optional(),
fontSize: z.number().min(6).max(48).optional(),
fontWeight: fontWeightSchema.optional(),
fontStyle: z.enum(["normal", "italic"]).optional(),
lineHeight: z.number().min(0.5).max(4).optional(),
letterSpacing: z.number().min(-16).max(16).optional(),
textDecoration: z.enum(["none", "underline", "line-through"]).optional(),
textDecorationStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
textAlign: z.enum(["left", "center", "right", "justify"]).optional(),
textTransform: z.enum(["none", "uppercase", "lowercase", "capitalize"]).optional(),
padding: z.number().min(-72).max(72).optional(),
paddingTop: z.number().min(-72).max(72).optional(),
paddingRight: z.number().min(-72).max(72).optional(),
paddingBottom: z.number().min(-72).max(72).optional(),
paddingLeft: z.number().min(-72).max(72).optional(),
marginTop: z.number().min(-72).max(72).optional(),
marginRight: z.number().min(-72).max(72).optional(),
marginBottom: z.number().min(-72).max(72).optional(),
marginLeft: z.number().min(-72).max(72).optional(),
rowGap: z.number().min(-72).max(72).optional(),
columnGap: z.number().min(-72).max(72).optional(),
borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
borderWidth: z.number().min(0).optional(),
borderRadius: z.number().min(0).optional(),
})
.describe("Constrained visual style intent that can be safely translated to React PDF styles.");
export type StyleIntent = z.infer<typeof styleIntentSchema>;
export const styleRuleSlotsSchema = z
.strictObject({
section: styleIntentSchema.optional(),
heading: styleIntentSchema.optional(),
item: styleIntentSchema.optional(),
text: styleIntentSchema.optional(),
secondaryText: styleIntentSchema.optional(),
link: styleIntentSchema.optional(),
icon: styleIntentSchema.optional(),
level: styleIntentSchema.optional(),
richParagraph: styleIntentSchema.optional(),
richList: styleIntentSchema.optional(),
richListItemRow: styleIntentSchema.optional(),
richListItemContent: styleIntentSchema.optional(),
richLink: styleIntentSchema.optional(),
richBold: styleIntentSchema.optional(),
richMark: styleIntentSchema.optional(),
})
.refine((slots) => Object.values(slots).some(Boolean), {
message: "At least one style slot must be configured.",
});
export const styleRuleTargetSchema = z.discriminatedUnion("scope", [
z.strictObject({ scope: z.literal("global") }),
z.strictObject({ scope: z.literal("sectionType"), sectionType: sectionTypeSchema }),
z.strictObject({ scope: z.literal("sectionId"), sectionId: z.string().min(1) }),
]);
export const styleRuleSchema = z.strictObject({
id: z.string().min(1).describe("Unique identifier for this style rule."),
label: z.string().catch("").describe("Human-readable label for this style rule."),
enabled: z.boolean().catch(true).describe("Whether this style rule should affect PDF rendering."),
target: styleRuleTargetSchema.describe("The resume content this style rule applies to."),
slots: styleRuleSlotsSchema.describe("The semantic style slots configured by this rule."),
});
export const styleRulesSchema = z.array(styleRuleSchema).catch([]);
export type StyleRule = z.infer<typeof styleRuleSchema>;
export type StyleRuleTarget = z.infer<typeof styleRuleTargetSchema>;
export const metadataSchema = z.object({
template: templateSchema
.catch("onyx")
@@ -512,6 +608,9 @@ export const metadataSchema = z.object({
.describe(
"Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string.",
),
styleRules: styleRulesSchema.describe(
"Structured style rules that target semantic resume sections and slots for React PDF rendering.",
),
});
export const resumeDataSchema = z.looseObject({
+1
View File
@@ -150,5 +150,6 @@ export const defaultResumeData: ResumeData = {
},
},
notes: "",
styleRules: [],
},
};
+1
View File
@@ -580,5 +580,6 @@ export const sampleResumeData: ResumeData = {
},
},
notes: "",
styleRules: [],
},
};
+3 -3
View File
@@ -31,7 +31,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-resizable-panels": "^4.11.2",
"shadcn": "^4.8.0",
"shadcn": "^4.8.1",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
},
@@ -42,9 +42,9 @@
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"postcss": "^8.5.15",
"react-doctor": "^0.2.6",
"react-doctor": "^0.2.9",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
}
+2 -2
View File
@@ -30,7 +30,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@uiw/color-convert": "^2.10.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.5",
"dompurify": "^3.4.6",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.0",
@@ -39,7 +39,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.9.1",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"typescript": "^6.0.3"
}
}