mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 07:42: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,266 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ResumeData, StyleIntent, StyleRule, StyleSlot } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { getSectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { resolveStyleRuleSlot } from "../templates/shared/style-rules";
|
||||
|
||||
export type LegacyStyleConversion = {
|
||||
source: StylesheetSource;
|
||||
sanitizedRules: readonly StyleRule[];
|
||||
};
|
||||
|
||||
const styleSlots = [
|
||||
"section",
|
||||
"heading",
|
||||
"item",
|
||||
"text",
|
||||
"secondaryText",
|
||||
"link",
|
||||
"icon",
|
||||
"level",
|
||||
"richParagraph",
|
||||
"richList",
|
||||
"richListItemRow",
|
||||
"richListItemContent",
|
||||
"richLink",
|
||||
"richBold",
|
||||
"richMark",
|
||||
] as const satisfies readonly StyleSlot[];
|
||||
|
||||
const lengthProperties = new Set<keyof StyleIntent>([
|
||||
"fontSize",
|
||||
"letterSpacing",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"marginTop",
|
||||
"marginRight",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"borderWidth",
|
||||
"borderRadius",
|
||||
]);
|
||||
|
||||
const slotSelectors = {
|
||||
section: "",
|
||||
heading: " > section-heading",
|
||||
item: " > section-items > item",
|
||||
text: ' field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])',
|
||||
secondaryText: ' field[name="keywords"]',
|
||||
link: " item > link",
|
||||
icon: " icon",
|
||||
level: " level",
|
||||
richParagraph: " rich-text paragraph",
|
||||
richList: " rich-text list",
|
||||
richListItemRow: " rich-text list-item",
|
||||
richListItemContent: " rich-text list-item-content",
|
||||
richLink: " rich-text link",
|
||||
richBold: " rich-text strong",
|
||||
richMark: " rich-text mark",
|
||||
} as const satisfies Record<StyleSlot, string>;
|
||||
|
||||
const selectorForSlot = (base: string, slot: StyleSlot): string => {
|
||||
if (slot === "link") {
|
||||
return [
|
||||
`${base} item > link`,
|
||||
`${base} item > template-part > link`,
|
||||
`${base} item-header > link`,
|
||||
`${base} item-header > template-part > link`,
|
||||
].join(",\n");
|
||||
}
|
||||
return `${base}${slotSelectors[slot]}`;
|
||||
};
|
||||
|
||||
const ruleBaseSelector = (rule: StyleRule): string => {
|
||||
if (rule.target.scope === "sectionType") return `section[type=${escapeCssString(rule.target.sectionType)}]`;
|
||||
if (rule.target.scope === "sectionId") return `section[id=${escapeCssString(rule.target.sectionId)}]`;
|
||||
return "section";
|
||||
};
|
||||
|
||||
const resolvedRuleStyle = (data: ResumeData, rule: StyleRule, slot: StyleSlot): Style => {
|
||||
const isolated = {
|
||||
...data,
|
||||
metadata: { ...data.metadata, styleRules: [rule] },
|
||||
};
|
||||
const options =
|
||||
rule.target.scope === "sectionType"
|
||||
? { slot, sectionId: "", sectionType: rule.target.sectionType }
|
||||
: rule.target.scope === "sectionId"
|
||||
? { slot, ...getSectionStyleRuleContext(data, rule.target.sectionId) }
|
||||
: { slot, sectionId: "" };
|
||||
return resolveStyleRuleSlot(isolated, options);
|
||||
};
|
||||
|
||||
const declarationsFromStyle = (style: Style): Record<string, string | number> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(style).flatMap(([property, value]) => {
|
||||
if (value === undefined) return [];
|
||||
const converted =
|
||||
typeof value === "number" && lengthProperties.has(property as keyof StyleIntent) ? `${value}pt` : value;
|
||||
return [[property, converted]];
|
||||
}),
|
||||
);
|
||||
|
||||
const serializeBlock = (
|
||||
selector: string,
|
||||
declarations: Readonly<Record<string, string | number>>,
|
||||
comment?: string,
|
||||
): string =>
|
||||
serializeGeneratedStylesheet({
|
||||
languageVersion: 1,
|
||||
blocks: [{ selector, declarations, ...(comment === undefined ? {} : { comment }) }],
|
||||
})
|
||||
.replace(/^@version 1;\n\n/, "")
|
||||
.trimEnd();
|
||||
|
||||
const appliesToAwards = (data: ResumeData, rule: StyleRule): boolean => {
|
||||
if (rule.target.scope === "global") return true;
|
||||
if (rule.target.scope === "sectionType") return rule.target.sectionType === "awards";
|
||||
return getSectionStyleRuleContext(data, rule.target.sectionId).sectionType === "awards";
|
||||
};
|
||||
|
||||
const textWeightSelector = (data: ResumeData, rule: StyleRule, base: string): string => {
|
||||
const selectors = [
|
||||
`${base} field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]):not([role~="primary-text"])`,
|
||||
`${base} item[role~="nested-role"] > item-header > field[name="position"]`,
|
||||
];
|
||||
if (appliesToAwards(data, rule)) selectors.push(`${base} field[name="title"]`);
|
||||
return selectors.join(",\n");
|
||||
};
|
||||
|
||||
const scizorBoldColorSelector = (data: ResumeData, rule: StyleRule, base: string): string | undefined => {
|
||||
const sectionType =
|
||||
rule.target.scope === "sectionType"
|
||||
? rule.target.sectionType
|
||||
: rule.target.scope === "sectionId"
|
||||
? getSectionStyleRuleContext(data, rule.target.sectionId).sectionType
|
||||
: undefined;
|
||||
if (sectionType === "awards") return;
|
||||
|
||||
const scopedBase =
|
||||
rule.target.scope === "global"
|
||||
? 'resume[template="scizor"] section:not([type="awards"])'
|
||||
: `resume[template="scizor"] ${base}`;
|
||||
return ["company", "school", "name", "language", "network", "organization", "title"]
|
||||
.map((name) => `${scopedBase}${slotSelectors.text}[role~="primary-text"][name=${escapeCssString(name)}]`)
|
||||
.join(",\n");
|
||||
};
|
||||
|
||||
const levelDecorationDeclarations = (
|
||||
data: ResumeData,
|
||||
fontSize: string | number | undefined,
|
||||
): Record<string, string | number> => {
|
||||
if (fontSize === undefined) return {};
|
||||
const type = data.metadata.design.level.type;
|
||||
if (type === "progress-bar" || type === "rectangle" || type === "rectangle-full") return { height: fontSize };
|
||||
return { width: fontSize, height: fontSize };
|
||||
};
|
||||
|
||||
const activeSlotChunks = (data: ResumeData, rule: StyleRule, slot: StyleSlot): string[] => {
|
||||
const base = ruleBaseSelector(rule);
|
||||
const selector = selectorForSlot(base, slot);
|
||||
const declarations = declarationsFromStyle(resolvedRuleStyle(data, rule, slot));
|
||||
const combinedTextDeclarations = slot === "text" ? { ...declarations } : undefined;
|
||||
const chunks: string[] = [];
|
||||
const comments: string[] = [];
|
||||
|
||||
if ((slot === "link" || slot === "richLink") && "textDecoration" in declarations) {
|
||||
delete declarations.textDecoration;
|
||||
comments.push("No effect in legacy rendering: text-decoration is owned by the builder link preference.");
|
||||
}
|
||||
|
||||
if (slot === "text" && "fontWeight" in declarations) {
|
||||
const { fontWeight } = declarations;
|
||||
delete declarations.fontWeight;
|
||||
if (Object.keys(declarations).length > 0)
|
||||
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
|
||||
chunks.push(serializeBlock(textWeightSelector(data, rule, base), { fontWeight }, rule.label || rule.id));
|
||||
comments.push(
|
||||
"Legacy Bold hosts keep the template bold weight; award titles remain the explicit unbold exception.",
|
||||
);
|
||||
} else {
|
||||
if (slot === "icon" && declarations.fontSize !== undefined) {
|
||||
declarations.width = declarations.fontSize;
|
||||
declarations.height = declarations.fontSize;
|
||||
}
|
||||
if (Object.keys(declarations).length > 0)
|
||||
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
|
||||
}
|
||||
|
||||
if (combinedTextDeclarations && Object.keys(combinedTextDeclarations).length > 0) {
|
||||
chunks.push(
|
||||
serializeBlock(`${base} combined-text`, combinedTextDeclarations, `${rule.label || rule.id}: combined text host`),
|
||||
);
|
||||
}
|
||||
|
||||
if (slot === "text" && declarations.color !== undefined) {
|
||||
const restorationSelector = scizorBoldColorSelector(data, rule, base);
|
||||
if (restorationSelector) {
|
||||
chunks.push(
|
||||
serializeBlock(
|
||||
restorationSelector,
|
||||
{ color: rgbaStringToHex(data.metadata.design.colors.text) },
|
||||
`${rule.label || rule.id}: Scizor Bold final color`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (slot === "level" && declarations.fontSize !== undefined) {
|
||||
const geometry = levelDecorationDeclarations(data, declarations.fontSize);
|
||||
if (Object.keys(geometry).length > 0) {
|
||||
chunks.push(serializeBlock(`${base} level > icon`, geometry, `${rule.label || rule.id}: level decorations`));
|
||||
}
|
||||
}
|
||||
|
||||
return [...chunks, ...comments.map((comment) => `/* ${escapeCssComment(comment)} */`)];
|
||||
};
|
||||
|
||||
const disabledRuleChunk = (data: ResumeData, rule: StyleRule): string => {
|
||||
const blocks = styleSlots.flatMap((slot) => {
|
||||
if (!rule.slots[slot]) return [];
|
||||
const declarations = declarationsFromStyle(resolvedRuleStyle(data, { ...rule, enabled: true }, slot));
|
||||
if (Object.keys(declarations).length === 0) return [];
|
||||
const base = ruleBaseSelector(rule);
|
||||
return [
|
||||
serializeBlock(selectorForSlot(base, slot), declarations),
|
||||
...(slot === "text" ? [serializeBlock(`${base} combined-text`, declarations)] : []),
|
||||
];
|
||||
});
|
||||
const label = escapeCssComment(rule.label || rule.id);
|
||||
const body = escapeCssComment(blocks.join("\n\n"));
|
||||
return `/* Disabled legacy rule: ${label}${body ? `\n${body}` : ""}\n*/`;
|
||||
};
|
||||
|
||||
export function convertLegacyStyleRules(data: ResumeData): LegacyStyleConversion {
|
||||
const sanitizedRules = styleRulesSchema.parse(data.metadata.styleRules ?? []);
|
||||
const sanitizedData = {
|
||||
...data,
|
||||
metadata: { ...data.metadata, styleRules: sanitizedRules },
|
||||
};
|
||||
const scopePrecedence = { global: 0, sectionType: 1, sectionId: 2 } as const;
|
||||
const orderedRules = sanitizedRules
|
||||
.map((rule, index) => ({ rule, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
scopePrecedence[left.rule.target.scope] - scopePrecedence[right.rule.target.scope] || left.index - right.index,
|
||||
)
|
||||
.map(({ rule }) => rule);
|
||||
const chunks = orderedRules.flatMap((rule) => {
|
||||
if (!rule.enabled) return [disabledRuleChunk(sanitizedData, rule)];
|
||||
return styleSlots.flatMap((slot) => (rule.slots[slot] ? activeSlotChunks(sanitizedData, rule, slot) : []));
|
||||
});
|
||||
const text = `@version 1;\n${chunks.length > 0 ? `\n${chunks.join("\n\n")}\n` : ""}`;
|
||||
|
||||
return {
|
||||
source: { languageVersion: 1, text },
|
||||
sanitizedRules,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user