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
+7 -7
View File
@@ -22,10 +22,10 @@
"@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",
"@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",
"@hono/node-server": "^2.0.4",
@@ -52,7 +52,7 @@
"better-auth": "1.6.11",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.5",
"dompurify": "^3.4.6",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
@@ -61,12 +61,12 @@
"hono": "^4.12.23",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^8.0.8",
"nodemailer": "^8.0.9",
"ollama-ai-provider-v2": "^3.5.1",
"pg": "^8.21.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.6",
"react-email": "^6.3.3",
"react-email": "^6.4.0",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
@@ -80,8 +80,8 @@
"@types/node": "^25.9.1",
"@types/pg": "^8.20.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",
"tsdown": "^0.22.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3",
+4 -4
View File
@@ -20,7 +20,7 @@
"@ai-sdk/react": "^3.0.193",
"@base-ui/react": "^1.5.0",
"@better-auth/api-key": "^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",
"@dnd-kit/core": "^6.3.1",
@@ -44,7 +44,7 @@
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-form": "^1.32.1",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
@@ -96,11 +96,11 @@
"@types/pg": "^8.20.0",
"@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",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"react-doctor": "^0.2.6",
"react-doctor": "^0.2.9",
"typescript": "^6.0.3",
"vite": "^8.0.14"
}
+5
View File
@@ -21,6 +21,7 @@ import {
LayoutIcon,
MessengerLogoIcon,
NotepadIcon,
PaintBrushBroadIcon,
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
@@ -44,6 +45,7 @@ export type RightSidebarSection =
| "layout"
| "typography"
| "design"
| "styles"
| "page"
| "notes"
| "sharing"
@@ -78,6 +80,7 @@ export const rightSidebarSections: RightSidebarSection[] = [
"layout",
"typography",
"design",
"styles",
"page",
"notes",
"sharing",
@@ -116,6 +119,7 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("layout", () => t`Layout`)
.with("typography", () => t`Typography`)
.with("design", () => t`Design`)
.with("styles", () => t`Custom Styles`)
.with("page", () => t`Page`)
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
@@ -159,6 +163,7 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("layout", () => <LayoutIcon {...iconProps} />)
.with("typography", () => <TextTIcon {...iconProps} />)
.with("design", () => <PaletteIcon {...iconProps} />)
.with("styles", () => <PaintBrushBroadIcon {...iconProps} />)
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
@@ -17,6 +17,7 @@ import { PageSectionBuilder } from "./sections/page";
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { StylesSectionBuilder } from "./sections/styles";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
@@ -26,6 +27,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <StylesSectionBuilder />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
@@ -0,0 +1,322 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { isValidElement } from "react";
const updateResumeData = vi.hoisted(() => vi.fn());
const styleRules = vi.hoisted(() => [
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
},
]);
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: {
metadata: { styleRules },
sections: {
experience: { title: "Experience" },
skills: { title: "Skills" },
},
customSections: [{ id: "custom-1", title: "Open Source", type: "projects" }],
},
}),
useUpdateResumeData: () => updateResumeData,
}));
const { StylesSectionBuilder } = await import("./styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
updateResumeData.mockClear();
});
const renderStyles = () =>
render(
<I18nProvider i18n={i18n}>
<StylesSectionBuilder />
</I18nProvider>,
);
describe("StylesSectionBuilder", () => {
beforeEach(() => {
styleRules.splice(0, styleRules.length);
styleRules.push({
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
});
});
it("renders structured style rule controls", () => {
renderStyles();
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
expect(screen.getByLabelText("Text Color")).toBeInTheDocument();
expect(screen.getByLabelText("Text Decoration Color")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Color" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Text" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Spacing" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Border" })).toBeInTheDocument();
expect(screen.getByLabelText("Font Style")).toBeInTheDocument();
expect(screen.getByLabelText("Line Height")).toBeInTheDocument();
expect(screen.getByLabelText("Letter Spacing")).toBeInTheDocument();
expect(screen.getByLabelText("Text Decoration")).toBeInTheDocument();
expect(screen.getByLabelText("Decoration Style")).toBeInTheDocument();
expect(screen.getByLabelText("Text Align")).toBeInTheDocument();
expect(screen.getByLabelText("Text Transform")).toBeInTheDocument();
expect(screen.getByLabelText("Opacity")).toBeInTheDocument();
expect(screen.getByLabelText("Margin Top")).toBeInTheDocument();
expect(screen.getByLabelText("Margin Right")).toBeInTheDocument();
expect(screen.getByLabelText("Margin Bottom")).toBeInTheDocument();
expect(screen.getByLabelText("Margin Left")).toBeInTheDocument();
expect(screen.getByLabelText("Row Gap")).toBeInTheDocument();
expect(screen.getByLabelText("Column Gap")).toBeInTheDocument();
expect(screen.getByLabelText("Border Style")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Section heading" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "List" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "List item content" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Bullet or number" })).not.toBeInTheDocument();
});
it("labels the empty font weight option as default", () => {
renderStyles();
const fontWeightSelect = screen.getByLabelText("Font Weight");
expect(within(fontWeightSelect).getByRole("option", { name: "Default" })).toBeInTheDocument();
expect(within(fontWeightSelect).queryByRole("option", { name: "Template default" })).not.toBeInTheDocument();
});
it("renames the sidebar entry and uses a distinct icon from design", () => {
const designIcon = getSectionIcon("design");
const stylesIcon = getSectionIcon("styles");
expect(getSectionTitle("styles")).toBe("Custom Styles");
expect(isValidElement(designIcon)).toBe(true);
expect(isValidElement(stylesIcon)).toBe(true);
expect(isValidElement(designIcon) && isValidElement(stylesIcon) && designIcon.type !== stylesIcon.type).toBe(true);
});
it("upserts one style rule for the selected target and slot", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
fireEvent.change(screen.getByLabelText("Text Color"), { target: { value: "rgba(220, 38, 38, 1)" } });
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
const draft = { metadata: { styleRules: [] } };
recipe(draft);
expect(draft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
},
]);
});
it("stores padding as per-side values", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
expect(screen.queryByLabelText("Padding")).not.toBeInTheDocument();
expect(screen.getByLabelText("Padding Top")).toBeInTheDocument();
expect(screen.getByLabelText("Padding Right")).toBeInTheDocument();
expect(screen.getByLabelText("Padding Bottom")).toBeInTheDocument();
expect(screen.getByLabelText("Padding Left")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } });
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
const draft = { metadata: { styleRules: [] } };
recipe(draft);
expect(draft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { paddingTop: 12 } },
},
]);
});
it("stores text decoration intent", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
fireEvent.change(screen.getByLabelText("Text Decoration"), { target: { value: "underline" } });
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
const draft = { metadata: { styleRules: [] } };
recipe(draft);
expect(draft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { textDecoration: "underline" } },
},
]);
});
it("stores margin and gap intent", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
fireEvent.change(screen.getByLabelText("Margin Bottom"), { target: { value: "-10" } });
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "-6" } });
expect(updateResumeData).toHaveBeenCalledTimes(2);
const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
metadata: { styleRules: unknown[] };
}) => void;
const marginDraft = { metadata: { styleRules: [] } };
marginRecipe(marginDraft);
expect(marginDraft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { marginBottom: -10 } },
},
]);
const gapRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
const gapDraft = { metadata: { styleRules: [] } };
gapRecipe(gapDraft);
expect(gapDraft.metadata.styleRules).toEqual([
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { rowGap: -6 } },
},
]);
});
it("stores list slot rules for rich text lists", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
fireEvent.change(screen.getByLabelText("Style Slot"), { target: { value: "richList" } });
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "8" } });
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
const draft = { metadata: { styleRules: [] } };
recipe(draft);
expect(draft.metadata.styleRules).toEqual([
{
id: "style-global-richList",
label: "All sections: List",
enabled: true,
target: { scope: "global" },
slots: { richList: { rowGap: 8 } },
},
]);
});
it("can reset the selected style rule", () => {
renderStyles();
fireEvent.click(screen.getByRole("button", { name: "Reset Style" }));
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
metadata: { styleRules: { id: string }[] };
}) => void;
const draft = {
metadata: {
styleRules: [
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(0, 0, 0, 1)" } },
},
{
id: "style-global-section",
label: "All sections: Section container",
enabled: true,
target: { scope: "global" },
slots: { section: { padding: 4 } },
},
],
},
};
recipe(draft);
expect(draft.metadata.styleRules.map((rule) => rule.id)).toEqual(["style-global-section"]);
});
it("lists applied style rules and toggles individual rules", () => {
renderStyles();
expect(screen.getByText("Applied Rules")).toBeInTheDocument();
expect(screen.getByText("All sections: Section heading")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Toggle All sections: Section heading"));
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
metadata: { styleRules: { id: string; enabled: boolean }[] };
}) => void;
const draft = { metadata: { styleRules: [{ id: "style-global-heading", enabled: true }] } };
recipe(draft);
expect(draft.metadata.styleRules[0]?.enabled).toBe(false);
});
it("opens a manage rules dialog with editable rule fields", () => {
renderStyles();
fireEvent.click(screen.getByRole("button", { name: "Manage Rules" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Manage Style Rules")).toBeInTheDocument();
expect(screen.getByLabelText("Rule Label")).toHaveValue("All sections: Section heading");
expect(screen.getByLabelText("Dialog Text Color")).toHaveValue("rgba(220, 38, 38, 1)");
expect(screen.getByLabelText("Dialog Padding Top")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Right")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Bottom")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Left")).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
# Structured Style Rules for React PDF
Reactive Resume no longer renders final PDFs through browser HTML/CSS, so end-user custom CSS would create a misleading contract: React PDF accepts style objects on renderer components, not arbitrary browser selectors. Resume appearance customization is therefore modeled as structured Style Rules in resume metadata, targeting semantic section and rich-text slots that the PDF package translates into safe React PDF styles.
This preserves user-controlled section styling while keeping PDF rendering portable across preview, export, public resume views, templates, and server generation. Raw CSS, raw React PDF style objects, section-item targeting, and direct PDF-preview selection are intentionally out of v1; they can be revisited only if the product needs that power enough to accept the additional validation and layout-breakage risk.
+1 -1
View File
@@ -52,7 +52,7 @@
"knip": "^6.14.2",
"lefthook": "^2.1.8",
"npm-check-updates": "^22.2.1",
"turbo": "^2.9.14",
"turbo": "^2.9.15",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
+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"
}
}
+716 -306
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@
"@reactive-resume/config": "workspace:*",
"@reactive-resume/env": "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-orm": "1.0.0-rc.3",
"pg": "^8.21.0",
"tsx": "^4.22.3"