mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +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
@@ -0,0 +1,228 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { ResumeDocument } from "../document";
|
||||
|
||||
type HostNode = {
|
||||
type: string;
|
||||
style?: unknown;
|
||||
value?: string;
|
||||
children?: HostNode[];
|
||||
};
|
||||
|
||||
const nodeText = (node: HostNode): string =>
|
||||
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
|
||||
|
||||
const mergedStyle = (node: HostNode): Record<string, unknown> =>
|
||||
Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : []));
|
||||
|
||||
const findPath = (node: HostNode, predicate: (candidate: HostNode) => boolean): HostNode[] | undefined => {
|
||||
if (predicate(node)) return [node];
|
||||
for (const child of node.children ?? []) {
|
||||
const path = findPath(child, predicate);
|
||||
if (path) return [node, ...path];
|
||||
}
|
||||
};
|
||||
|
||||
const nodesWithStyle = (node: HostNode, property: string, value: unknown): HostNode[] => [
|
||||
...(mergedStyle(node)[property] === value ? [node] : []),
|
||||
...(node.children ?? []).flatMap((child) => nodesWithStyle(child, property, value)),
|
||||
];
|
||||
|
||||
const buildFixture = (
|
||||
text: string,
|
||||
section: "summary" | "skills" | "experience" | "languages" | "references" = "summary",
|
||||
): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.picture.hidden = true;
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.basics.headline = "Programmer";
|
||||
data.basics.email = "ada@example.com";
|
||||
data.basics.phone = "";
|
||||
data.basics.location = "";
|
||||
data.basics.website = { url: "", label: "" };
|
||||
data.basics.customFields = [];
|
||||
data.summary.content = "<p>Computing pioneer.</p>";
|
||||
data.sections.skills.title = "Expertise";
|
||||
data.sections.skills.items = [
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "brain",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Expert",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
];
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "experience-1",
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Engineer",
|
||||
location: "London",
|
||||
period: "1842",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [
|
||||
{
|
||||
id: "role-1",
|
||||
position: "Architect",
|
||||
period: "1843",
|
||||
description: "<p>Designed the engine.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
data.sections.languages.items = [
|
||||
{ id: "language-1", hidden: false, language: "English", fluency: "Native", level: 0 },
|
||||
];
|
||||
data.sections.references.items = [
|
||||
{
|
||||
id: "reference-1",
|
||||
hidden: false,
|
||||
name: "Charles Babbage",
|
||||
position: "Inventor",
|
||||
phone: "+44 123",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
data.metadata.page.hideSectionIcons = false;
|
||||
data.metadata.page.hideIcons = false;
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${text}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
const renderFixture = async (template: Template, data: ResumeData): Promise<HostNode> => {
|
||||
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
|
||||
const instance = pdf(element);
|
||||
await expect.poll(() => instance.container.document).not.toBeNull();
|
||||
return instance.container.document as HostNode;
|
||||
};
|
||||
|
||||
describe("semantic binding host fidelity", () => {
|
||||
it("attaches region, contact-list, icon-row heading, no-border item-header, and item-icon styles", async () => {
|
||||
const document = await renderFixture(
|
||||
"onyx",
|
||||
buildFixture(
|
||||
`
|
||||
region[region="main"] { background-color: #101010; }
|
||||
contact-list { background-color: #202020; }
|
||||
section-heading { background-color: #303030; }
|
||||
item-header { background-color: #404040; }
|
||||
icon { opacity: 0.25; }
|
||||
`,
|
||||
"skills",
|
||||
),
|
||||
);
|
||||
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
|
||||
const headingPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Expertise");
|
||||
const skillPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "TypeScript");
|
||||
|
||||
expect(emailPath?.some((node) => mergedStyle(node).backgroundColor === "#202020")).toBe(true);
|
||||
expect(headingPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#303030")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mergedStyle(headingPath?.at(-1) as HostNode).backgroundColor).toBeUndefined();
|
||||
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#404040")).toBe(true);
|
||||
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#101010")).toBe(true);
|
||||
expect(nodesWithStyle(document, "opacity", 0.25).some(({ type }) => type === "SVG")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["languages", "English"],
|
||||
["references", "Charles Babbage"],
|
||||
] as const)("keeps direct %s Text siblings outside the item-header contract", async (section, text) => {
|
||||
const document = await renderFixture(
|
||||
"onyx",
|
||||
buildFixture(
|
||||
`
|
||||
item-header { display: none; background-color: #414141; break-before: page; }
|
||||
field[role~="primary-text"] { color: #515151; }
|
||||
`,
|
||||
section,
|
||||
),
|
||||
);
|
||||
const textPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === text);
|
||||
const textNode = textPath?.at(-1);
|
||||
|
||||
expect(textNode).toBeDefined();
|
||||
expect(textNode && mergedStyle(textNode)).toMatchObject({ color: "#515151" });
|
||||
expect(textPath?.some((node) => mergedStyle(node).backgroundColor === "#414141")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds nested experience-role item and item-header styles to their existing Views", async () => {
|
||||
const document = await renderFixture(
|
||||
"onyx",
|
||||
buildFixture(
|
||||
`
|
||||
item[role~="nested-role"] { background-color: #515151; }
|
||||
item[role~="nested-role"] > item-header { border-top-width: 7pt; }
|
||||
`,
|
||||
"experience",
|
||||
),
|
||||
);
|
||||
const rolePath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Architect");
|
||||
|
||||
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#515151")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderTopWidth === 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("binds Rhyhorn's outer contact owner, nested content primitive, link alias, and last-owner alias separately", async () => {
|
||||
const document = await renderFixture(
|
||||
"rhyhorn",
|
||||
buildFixture(`
|
||||
contact-item { background-color: #616161; }
|
||||
template-part[name="contact-item-content"] { color: #717171; }
|
||||
link { text-decoration: none; }
|
||||
contact-item[part~="contact-item-last"] { border-bottom-width: 9pt; }
|
||||
`),
|
||||
);
|
||||
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
|
||||
const link = emailPath?.at(-1);
|
||||
|
||||
expect(link && mergedStyle(link)).toMatchObject({ color: "#717171", textDecoration: "none" });
|
||||
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#616161")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderBottomWidth === 9)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["azurill", 4],
|
||||
["ditgar", 1],
|
||||
["ditto", 3],
|
||||
["gengar", 1],
|
||||
["glalie", 1],
|
||||
["leafish", 3],
|
||||
["meowth", 3],
|
||||
["pikachu", 1],
|
||||
["rhyhorn", 1],
|
||||
["scizor", 1],
|
||||
] as const)("%s attaches every visible primitive template part to an existing host", async (template, expected) => {
|
||||
const section = template === "meowth" ? "experience" : "summary";
|
||||
const document = await renderFixture(template, buildFixture("template-part { opacity: 0.37; }", section));
|
||||
|
||||
expect(nodesWithStyle(document, "opacity", 0.37)).toHaveLength(expected);
|
||||
});
|
||||
|
||||
it("honors primitive template-part visibility on the exact existing host", async () => {
|
||||
const document = await renderFixture(
|
||||
"pikachu",
|
||||
buildFixture('template-part[name="header-divider"] { display: none; }'),
|
||||
);
|
||||
|
||||
expect(nodeText(document)).not.toContain("Ada Lovelace");
|
||||
expect(nodeText(document)).toContain("ada@example.com");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user