mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 22:32:17 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -1,13 +1,18 @@
|
||||
import type { CustomSectionType } from "./data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
baseSectionSchema,
|
||||
basicsSchema,
|
||||
customFieldSchema,
|
||||
customSectionItemDefinitionByType,
|
||||
customSectionSchema,
|
||||
experienceItemSchema,
|
||||
layoutSchema,
|
||||
pageSchema,
|
||||
parseResumeData,
|
||||
pictureSchema,
|
||||
resumeDataSchema,
|
||||
sectionTypeSchema,
|
||||
skillItemSchema,
|
||||
styleRuleSchema,
|
||||
styleRulesSchema,
|
||||
@@ -16,6 +21,86 @@ import {
|
||||
} from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
|
||||
const representativeCustomSectionItemByType = {
|
||||
summary: { id: "summary-item", hidden: false, content: "<p>Summary</p>" },
|
||||
profiles: { id: "profile-item", hidden: false, icon: "", network: "GitHub", username: "ada" },
|
||||
experience: {
|
||||
id: "experience-item",
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Programmer",
|
||||
location: "London",
|
||||
period: "1842–1843",
|
||||
description: "<p>Wrote the first algorithm.</p>",
|
||||
},
|
||||
education: {
|
||||
id: "education-item",
|
||||
hidden: false,
|
||||
school: "University of London",
|
||||
degree: "Mathematics",
|
||||
area: "Mathematics",
|
||||
grade: "",
|
||||
location: "London",
|
||||
period: "1830",
|
||||
description: "",
|
||||
},
|
||||
projects: { id: "project-item", hidden: false, name: "Bernoulli Notes", period: "1843", description: "" },
|
||||
skills: { id: "skill-item", hidden: false, icon: "", name: "Mathematics", proficiency: "Expert" },
|
||||
languages: { id: "language-item", hidden: false, language: "English", fluency: "Native" },
|
||||
interests: { id: "interest-item", hidden: false, icon: "", name: "Poetry" },
|
||||
awards: { id: "award-item", hidden: false, title: "Medal", awarder: "Society", date: "1843", description: "" },
|
||||
certifications: {
|
||||
id: "certification-item",
|
||||
hidden: false,
|
||||
title: "Certificate",
|
||||
issuer: "Society",
|
||||
date: "1843",
|
||||
description: "",
|
||||
},
|
||||
publications: {
|
||||
id: "publication-item",
|
||||
hidden: false,
|
||||
title: "Notes",
|
||||
publisher: "Scientific Memoirs",
|
||||
date: "1843",
|
||||
description: "",
|
||||
},
|
||||
volunteer: {
|
||||
id: "volunteer-item",
|
||||
hidden: false,
|
||||
organization: "Society",
|
||||
location: "London",
|
||||
period: "1843",
|
||||
description: "",
|
||||
},
|
||||
references: {
|
||||
id: "reference-item",
|
||||
hidden: false,
|
||||
name: "Charles Babbage",
|
||||
position: "Inventor",
|
||||
phone: "",
|
||||
description: "",
|
||||
},
|
||||
"cover-letter": {
|
||||
id: "cover-letter-item",
|
||||
hidden: false,
|
||||
recipient: "<p>Charles Babbage</p>",
|
||||
content: "<p>Dear Charles,</p>",
|
||||
},
|
||||
} as const satisfies Record<CustomSectionType, Record<string, unknown>>;
|
||||
|
||||
const customSectionFixture = (type: CustomSectionType, item: Record<string, unknown>) => ({
|
||||
id: `custom-${type}`,
|
||||
type,
|
||||
title: "Custom section",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [item],
|
||||
});
|
||||
|
||||
describe("resumeDataSchema", () => {
|
||||
it("validates the default resume", () => {
|
||||
expect(resumeDataSchema.safeParse(defaultResumeData).success).toBe(true);
|
||||
@@ -29,6 +114,107 @@ describe("resumeDataSchema", () => {
|
||||
const partial = { ...defaultResumeData, basics: undefined };
|
||||
expect(resumeDataSchema.safeParse(partial).success).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves cover-letter fields when parsing the overlapping content shape", () => {
|
||||
const result = customSectionSchema.parse({
|
||||
id: "cover-letter",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "item", hidden: false, recipient: "Ada Lovelace", content: "<p>Hello</p>" }],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({ recipient: "Ada Lovelace", content: "<p>Hello</p>" });
|
||||
});
|
||||
|
||||
it("accepts overlapping item fields when the selected renderer requirements are satisfied", () => {
|
||||
const section = customSectionFixture("experience", {
|
||||
...representativeCustomSectionItemByType.experience,
|
||||
content: "<p>Also valid summary content</p>",
|
||||
});
|
||||
|
||||
expect(customSectionSchema.parse(section).items[0]).toMatchObject({
|
||||
content: "<p>Also valid summary content</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a renderer-unsafe custom section before PDF or DOCX dispatch", () => {
|
||||
const rendererUnsafeSection = customSectionFixture("experience", representativeCustomSectionItemByType.summary);
|
||||
|
||||
expect(customSectionSchema.safeParse(rendererUnsafeSection).success).toBe(false);
|
||||
expect(
|
||||
resumeDataSchema.safeParse({
|
||||
...defaultResumeData,
|
||||
customSections: [rendererUnsafeSection],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns renderer-safe normalized data without losing compatible overlapping fields", () => {
|
||||
const data = {
|
||||
...structuredClone(defaultResumeData),
|
||||
customSections: [
|
||||
customSectionFixture("experience", {
|
||||
...representativeCustomSectionItemByType.experience,
|
||||
content: "<p>Preserve this overlapping field</p>",
|
||||
}),
|
||||
],
|
||||
};
|
||||
const parsed = parseResumeData(data);
|
||||
|
||||
expect(parsed.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Preserve this overlapping field</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("customSectionItemDefinitionByType", () => {
|
||||
it("maps every custom section type to its named item schema", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
sectionTypeSchema.options.map((type) => [type, customSectionItemDefinitionByType[type].schemaName]),
|
||||
),
|
||||
).toEqual({
|
||||
summary: "summaryItemSchema",
|
||||
profiles: "profileItemSchema",
|
||||
experience: "experienceItemSchema",
|
||||
education: "educationItemSchema",
|
||||
projects: "projectItemSchema",
|
||||
skills: "skillItemSchema",
|
||||
languages: "languageItemSchema",
|
||||
interests: "interestItemSchema",
|
||||
awards: "awardItemSchema",
|
||||
certifications: "certificationItemSchema",
|
||||
publications: "publicationItemSchema",
|
||||
volunteer: "volunteerItemSchema",
|
||||
references: "referenceItemSchema",
|
||||
"cover-letter": "coverLetterItemSchema",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(sectionTypeSchema.options)("accepts the representative %s item shape", (type) => {
|
||||
const section = customSectionFixture(type, representativeCustomSectionItemByType[type]);
|
||||
|
||||
expect(customSectionSchema.safeParse(section).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(sectionTypeSchema.options)("rejects an item shape that does not match %s", (type) => {
|
||||
const mismatchedItem =
|
||||
type === "summary"
|
||||
? representativeCustomSectionItemByType.experience
|
||||
: representativeCustomSectionItemByType.summary;
|
||||
const section = customSectionFixture(type, mismatchedItem);
|
||||
|
||||
expect(customSectionSchema.safeParse(section).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("websiteSchema", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { templateSchema } from "../templates";
|
||||
import { semanticStylesheetSchema } from "./stylesheet";
|
||||
|
||||
const iconSchema = z
|
||||
.string()
|
||||
@@ -358,38 +359,47 @@ export const sectionTypeSchema = z.enum([
|
||||
|
||||
export type CustomSectionType = z.infer<typeof sectionTypeSchema>;
|
||||
|
||||
const customSectionItemSchema = z.union([
|
||||
// coverLetterItemSchema must come before summaryItemSchema because both have 'content',
|
||||
// but coverLetterItemSchema also requires 'recipient'. If summaryItemSchema is first,
|
||||
// cover letter items will match it and lose the 'recipient' field.
|
||||
coverLetterItemSchema,
|
||||
summaryItemSchema,
|
||||
profileItemSchema,
|
||||
experienceItemSchema,
|
||||
educationItemSchema,
|
||||
projectItemSchema,
|
||||
skillItemSchema,
|
||||
languageItemSchema,
|
||||
interestItemSchema,
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
publicationItemSchema,
|
||||
volunteerItemSchema,
|
||||
referenceItemSchema,
|
||||
// Correlation protects renderer requirements; it does not make otherwise-overlapping item shapes exclusive.
|
||||
// Keep cover-letter before summary so the overlapping content shapes retain their established precedence.
|
||||
export const customSectionItemDefinitionByType = {
|
||||
"cover-letter": { schemaName: "coverLetterItemSchema", schema: coverLetterItemSchema.catchall(z.any()) },
|
||||
summary: { schemaName: "summaryItemSchema", schema: summaryItemSchema.catchall(z.any()) },
|
||||
profiles: { schemaName: "profileItemSchema", schema: profileItemSchema.catchall(z.any()) },
|
||||
experience: { schemaName: "experienceItemSchema", schema: experienceItemSchema.catchall(z.any()) },
|
||||
education: { schemaName: "educationItemSchema", schema: educationItemSchema.catchall(z.any()) },
|
||||
projects: { schemaName: "projectItemSchema", schema: projectItemSchema.catchall(z.any()) },
|
||||
skills: { schemaName: "skillItemSchema", schema: skillItemSchema.catchall(z.any()) },
|
||||
languages: { schemaName: "languageItemSchema", schema: languageItemSchema.catchall(z.any()) },
|
||||
interests: { schemaName: "interestItemSchema", schema: interestItemSchema.catchall(z.any()) },
|
||||
awards: { schemaName: "awardItemSchema", schema: awardItemSchema.catchall(z.any()) },
|
||||
certifications: { schemaName: "certificationItemSchema", schema: certificationItemSchema.catchall(z.any()) },
|
||||
publications: { schemaName: "publicationItemSchema", schema: publicationItemSchema.catchall(z.any()) },
|
||||
volunteer: { schemaName: "volunteerItemSchema", schema: volunteerItemSchema.catchall(z.any()) },
|
||||
references: { schemaName: "referenceItemSchema", schema: referenceItemSchema.catchall(z.any()) },
|
||||
} as const satisfies Record<CustomSectionType, { schemaName: string; schema: z.ZodType }>;
|
||||
|
||||
export type CustomSectionItem = z.infer<(typeof customSectionItemDefinitionByType)[CustomSectionType]["schema"]>;
|
||||
|
||||
const customSectionSchemaOptions = Object.entries(customSectionItemDefinitionByType).map(([type, { schema }]) =>
|
||||
baseSectionSchema.extend({
|
||||
id: z.string().describe("The unique identifier for the custom section. Usually generated as a UUID."),
|
||||
type: z
|
||||
.literal(type as CustomSectionType)
|
||||
.describe("The type of items this custom section contains. Determines which item schema and form fields to use."),
|
||||
items: z
|
||||
.array(schema)
|
||||
.describe("The items to display in the custom section. Items follow the schema of the section type."),
|
||||
}),
|
||||
);
|
||||
|
||||
const [firstCustomSectionSchema, ...remainingCustomSectionSchemas] = customSectionSchemaOptions;
|
||||
if (!firstCustomSectionSchema) throw new Error("At least one custom section schema is required.");
|
||||
|
||||
export const customSectionSchema = z.discriminatedUnion("type", [
|
||||
firstCustomSectionSchema,
|
||||
...remainingCustomSectionSchemas,
|
||||
]);
|
||||
|
||||
export type CustomSectionItem = z.infer<typeof customSectionItemSchema>;
|
||||
|
||||
export const customSectionSchema = baseSectionSchema.extend({
|
||||
id: z.string().describe("The unique identifier for the custom section. Usually generated as a UUID."),
|
||||
type: sectionTypeSchema.describe(
|
||||
"The type of items this custom section contains. Determines which item schema and form fields to use.",
|
||||
),
|
||||
items: z
|
||||
.array(customSectionItemSchema)
|
||||
.describe("The items to display in the custom section. Items follow the schema of the section type."),
|
||||
});
|
||||
|
||||
export type CustomSection = z.infer<typeof customSectionSchema>;
|
||||
|
||||
const customSectionsSchema = z.array(customSectionSchema);
|
||||
@@ -638,6 +648,7 @@ export const metadataSchema = z.object({
|
||||
styleRules: styleRulesSchema.describe(
|
||||
"Structured style rules that target semantic resume sections and slots for React PDF rendering.",
|
||||
),
|
||||
stylesheet: semanticStylesheetSchema.optional(),
|
||||
});
|
||||
|
||||
export const resumeDataSchema = z.looseObject({
|
||||
@@ -656,6 +667,9 @@ export const resumeDataSchema = z.looseObject({
|
||||
});
|
||||
|
||||
export type ResumeData = z.infer<typeof resumeDataSchema>;
|
||||
|
||||
export const parseResumeData = (data: unknown): ResumeData => resumeDataSchema.parse(data);
|
||||
|
||||
export type LayoutPage = z.infer<typeof pageLayoutSchema>;
|
||||
export type Typography = z.infer<typeof typographySchema>;
|
||||
export type Design = z.infer<typeof designSchema>;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { sectionTypeSchema } from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
import { createCustomSectionItemJsonSchemas, createResumeDataJsonSchema } from "./json-schema";
|
||||
|
||||
describe("createResumeDataJsonSchema", () => {
|
||||
it("describes accepted ResumeData input even when the Zod schema contains transforms", () => {
|
||||
const schema = createResumeDataJsonSchema();
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
required: ["picture", "basics", "summary", "sections", "customSections", "metadata"],
|
||||
properties: {
|
||||
picture: { type: "object" },
|
||||
basics: { type: "object" },
|
||||
sections: { type: "object" },
|
||||
metadata: { type: "object" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the custom-section type and item correlation", () => {
|
||||
const generatedSchema = z.fromJSONSchema(createResumeDataJsonSchema());
|
||||
const valid = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-summary",
|
||||
type: "summary",
|
||||
title: "Summary",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Summary</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const mismatched = {
|
||||
...valid,
|
||||
customSections: valid.customSections.map((section) => ({ ...section, type: "experience" })),
|
||||
};
|
||||
|
||||
expect(generatedSchema.safeParse(valid).success).toBe(true);
|
||||
expect(generatedSchema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCustomSectionItemJsonSchemas", () => {
|
||||
it("emits every type-keyed item schema with representative required shapes", () => {
|
||||
const schemas = createCustomSectionItemJsonSchemas();
|
||||
|
||||
expect(Object.keys(schemas)).toEqual(sectionTypeSchema.options);
|
||||
expect(schemas.summary).toMatchObject({
|
||||
schemaName: "summaryItemSchema",
|
||||
schema: { required: ["id", "hidden", "content"] },
|
||||
});
|
||||
expect(schemas.experience).toMatchObject({
|
||||
schemaName: "experienceItemSchema",
|
||||
schema: {
|
||||
required: ["id", "hidden", "company", "position", "location", "period", "description"],
|
||||
},
|
||||
});
|
||||
expect(schemas["cover-letter"]).toMatchObject({
|
||||
schemaName: "coverLetterItemSchema",
|
||||
schema: { required: ["id", "hidden", "recipient", "content"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CustomSectionType } from "./data";
|
||||
import z from "zod";
|
||||
import { customSectionItemDefinitionByType, resumeDataSchema, sectionTypeSchema } from "./data";
|
||||
|
||||
const toInputJsonSchema = (schema: z.ZodType) =>
|
||||
z.toJSONSchema(schema, {
|
||||
io: "input",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
|
||||
export function createResumeDataJsonSchema() {
|
||||
return toInputJsonSchema(resumeDataSchema);
|
||||
}
|
||||
|
||||
export function createCustomSectionItemJsonSchemas() {
|
||||
return Object.fromEntries(
|
||||
sectionTypeSchema.options.map((type) => {
|
||||
const { schemaName, schema } = customSectionItemDefinitionByType[type];
|
||||
return [type, { schemaName, schema: toInputJsonSchema(schema) }];
|
||||
}),
|
||||
) as Record<
|
||||
CustomSectionType,
|
||||
{
|
||||
schemaName: (typeof customSectionItemDefinitionByType)[CustomSectionType]["schemaName"];
|
||||
schema: ReturnType<typeof toInputJsonSchema>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resumeDataSchema } from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
import { semanticStylesheetSchema, stylesheetSourceSchema } from "./stylesheet";
|
||||
|
||||
describe("semanticStylesheetSchema", () => {
|
||||
it("preserves separate editable and applied sources", () => {
|
||||
const result = semanticStylesheetSchema.parse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nsection {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nsection { color: red; }\n" },
|
||||
});
|
||||
|
||||
expect(result.source.text).toContain("section {");
|
||||
expect(result.applied.text).toContain("color: red");
|
||||
});
|
||||
|
||||
it("keeps resumes without a stylesheet valid for legacy rendering", () => {
|
||||
expect(resumeDataSchema.parse(defaultResumeData).metadata.stylesheet).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-positive language versions", () => {
|
||||
expect(
|
||||
semanticStylesheetSchema.safeParse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 0, text: "" },
|
||||
applied: { languageVersion: 1, text: "" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown stylesheet fields", () => {
|
||||
expect(
|
||||
semanticStylesheetSchema.safeParse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "" },
|
||||
applied: { languageVersion: 1, text: "" },
|
||||
unknown: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown stylesheet source fields", () => {
|
||||
expect(stylesheetSourceSchema.safeParse({ languageVersion: 1, text: "", unknown: true }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const EMPTY_SEMANTIC_CSS_SOURCE = "@version 1;\n";
|
||||
|
||||
export const stylesheetSourceSchema = z.strictObject({
|
||||
languageVersion: z.number().int().positive(),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const semanticStylesheetSchema = z.strictObject({
|
||||
mode: z.enum(["legacy", "semantic"]),
|
||||
source: stylesheetSourceSchema,
|
||||
applied: stylesheetSourceSchema,
|
||||
});
|
||||
|
||||
export type StylesheetSource = z.infer<typeof stylesheetSourceSchema>;
|
||||
export type SemanticStylesheet = z.infer<typeof semanticStylesheetSchema>;
|
||||
export type StylesheetMode = SemanticStylesheet["mode"];
|
||||
Reference in New Issue
Block a user