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
+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: [],
},
};