mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 08:54:05 +10:00
feat: implement style rules
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -150,5 +150,6 @@ export const defaultResumeData: ResumeData = {
|
||||
},
|
||||
},
|
||||
notes: "",
|
||||
styleRules: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -580,5 +580,6 @@ export const sampleResumeData: ResumeData = {
|
||||
},
|
||||
},
|
||||
notes: "",
|
||||
styleRules: [],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user