diff --git a/packages/schema/src/resume/data.test.ts b/packages/schema/src/resume/data.test.ts index 7329a9c15..6c1bfa392 100644 --- a/packages/schema/src/resume/data.test.ts +++ b/packages/schema/src/resume/data.test.ts @@ -318,11 +318,75 @@ describe("summarySchema", () => { }); describe("styleRulesSchema", () => { + const educationHeadingRule = { + id: "education-heading", + label: "Education heading", + enabled: true, + target: { scope: "sectionType", sectionType: "education" }, + slots: { heading: { fontSize: 20 } }, + }; + 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("preserves valid rules when another rule has an invalid style intent", () => { + expect( + styleRulesSchema.parse([ + educationHeadingRule, + { + id: "experience-heading", + label: "Experience heading", + enabled: true, + target: { scope: "sectionType", sectionType: "experience" }, + slots: { heading: { fontSize: -1 } }, + }, + ]), + ).toEqual([educationHeadingRule]); + }); + + it("drops invalid style fields without dropping the whole rule", () => { + expect( + styleRulesSchema.parse([ + { + id: "partial-heading", + label: "Partial heading", + enabled: true, + target: { scope: "global" }, + slots: { heading: { color: "rgba(0, 0, 0, 1)", fontSize: -1 } }, + }, + ]), + ).toEqual([ + { + id: "partial-heading", + label: "Partial heading", + enabled: true, + target: { scope: "global" }, + slots: { heading: { color: "rgba(0, 0, 0, 1)" } }, + }, + ]); + }); + + it("falls back to an empty rule list for non-array persisted values", () => { + expect(styleRulesSchema.parse({})).toEqual([]); + }); + + it("drops rules with unknown slot keys", () => { + expect( + styleRulesSchema.parse([ + { + id: "unknown-slot", + label: "Unknown slot", + enabled: true, + target: { scope: "global" }, + slots: { richListItemMarker: { color: "rgba(0, 0, 0, 1)" } }, + }, + educationHeadingRule, + ]), + ).toEqual([educationHeadingRule]); + }); + it("accepts global, section-type, and section-id targets", () => { const rules = [ { diff --git a/packages/schema/src/resume/data.ts b/packages/schema/src/resume/data.ts index e992c7da5..e7603cb87 100644 --- a/packages/schema/src/resume/data.ts +++ b/packages/schema/src/resume/data.ts @@ -569,7 +569,47 @@ export const styleRuleSchema = z.strictObject({ slots: styleRuleSlotsSchema.describe("The semantic style slots configured by this rule."), }); -export const styleRulesSchema = z.array(styleRuleSchema).catch([]); +const filterStyleIntent = (intent: unknown): StyleIntent | undefined => { + const styleIntentShape = styleIntentSchema.shape; + if (typeof intent !== "object" || intent === null) return undefined; + const filteredIntent = Object.entries(intent).filter(([key, value]) => { + const fieldSchema = styleIntentSchema.shape[key as keyof typeof styleIntentShape]; + if (!fieldSchema) return false; + return fieldSchema.safeParse(value).success; + }); + return filteredIntent.length > 0 ? (Object.fromEntries(filteredIntent) as StyleIntent) : undefined; +}; + +export const styleRulesSchema = z + .array(z.unknown()) + .transform((arr) => + arr + .map((item) => { + const base = z + .strictObject({ + id: z.string().min(1), + label: z.string().catch(""), + enabled: z.boolean().catch(true), + target: styleRuleTargetSchema, + slots: z.partialRecord(styleSlotSchema, z.unknown()), + }) + .safeParse(item); + + if (!base.success) return undefined; + + const cleanedSlots = Object.fromEntries( + Object.entries(base.data.slots) + .map(([slot, intent]) => [slot, filterStyleIntent(intent)]) + .filter((entry): entry is [string, StyleIntent] => entry[1] !== undefined), + ); + + if (Object.keys(cleanedSlots).length === 0) return undefined; + + return { ...base.data, slots: cleanedSlots }; + }) + .filter((rule): rule is StyleRule => rule !== undefined), + ) + .catch([]); export type StyleRule = z.infer; export type StyleRuleTarget = z.infer;