Merge branch 'main' of github.com:amruthpillai/reactive-resume

This commit is contained in:
Amruth Pillai
2026-06-01 10:31:53 +02:00
19 changed files with 381 additions and 77 deletions
+21
View File
@@ -249,3 +249,24 @@ jobs:
script: |
cd docker
./manage_stack.sh up reactive_resume
- name: Purge Cloudflare cache
if: ${{ needs.mode.outputs.release == 'true' }}
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
echo "$response" | jq .
exit 1
fi
echo "Cloudflare cache purged successfully."
+1
View File
@@ -0,0 +1 @@
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
+2 -1
View File
@@ -4,6 +4,7 @@ import { onError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import router from "@reactive-resume/api/routers";
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
import { appVersion } from "../app-version";
import { getRequestLocale } from "../rpc/locale";
function createRequestClient(request: Request): RouterClient<typeof router> {
@@ -25,7 +26,7 @@ export async function createMcpServer(request: Request) {
const server = new McpServer(
{
name: "reactive-resume",
version: __APP_VERSION__,
version: appVersion,
title: "Reactive Resume",
websiteUrl: "https://rxresu.me",
description:
+2 -1
View File
@@ -8,6 +8,7 @@ import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume
import router from "@reactive-resume/api/routers";
import { env } from "@reactive-resume/env/server";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { appVersion } from "../app-version";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "../rpc/locale";
@@ -44,7 +45,7 @@ export async function handleOpenApi(request: Request) {
const spec = await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version: __APP_VERSION__,
version: appVersion,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
+2 -1
View File
@@ -2,6 +2,7 @@ import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } fr
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
import { appVersion } from "../app-version";
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
@@ -11,7 +12,7 @@ export function handleWellKnownFallback() {
}
export function handleMcpServerCard() {
return Response.json(buildMcpServerCard(__APP_VERSION__), {
return Response.json(buildMcpServerCard(appVersion), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
+2 -1
View File
@@ -1,5 +1,6 @@
import z from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { appVersion } from "../app-version";
export function handleSchemaJson() {
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
@@ -12,7 +13,7 @@ export function handleSchemaJson() {
"Surrogate-Control": "max-age=86400",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "index, follow",
ETag: __APP_VERSION__,
ETag: appVersion,
Vary: "Accept",
},
});
@@ -71,6 +71,21 @@ describe("LevelDisplay", () => {
expect(wrapper.getAttribute("aria-label")).toContain("4");
});
it("applies custom decoration and icon sizes in pixels", () => {
const { container } = render(
<LevelDisplay type="circle" icon="star" level={2} decorationSizePx={18} iconSizePx={20} />,
);
const shapes = container.querySelectorAll("div[data-active]");
expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" });
const { container: iconContainer } = render(
<LevelDisplay type="icon" icon="star" level={2} decorationSizePx={16} />,
);
const icon = iconContainer.querySelector("i");
expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" });
});
it("merges extra className into the wrapper", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
const wrapper = container.firstChild as HTMLElement;
+36 -5
View File
@@ -3,14 +3,35 @@ import type z from "zod";
import { t } from "@lingui/core/macro";
import { cn } from "@reactive-resume/utils/style";
type Props = z.infer<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
type Props = z.infer<typeof levelDesignSchema> &
React.ComponentProps<"div"> & {
level: number;
decorationSizePx?: number | undefined;
iconSizePx?: number | undefined;
};
const LEVEL_ITEM_KEYS = ["level-1", "level-2", "level-3", "level-4", "level-5"] as const;
export function LevelDisplay({ icon, type, level, className, ...props }: Props) {
const defaultDecorationClassName = "size-2.5";
export function LevelDisplay({ icon, type, level, className, decorationSizePx, iconSizePx, ...props }: Props) {
if (level === 0) return null;
if (type === "hidden" || icon === "") return null;
const decorationStyle =
decorationSizePx === undefined
? undefined
: ({ width: decorationSizePx, height: decorationSizePx } satisfies React.CSSProperties);
const resolvedIconSizePx = iconSizePx ?? decorationSizePx;
const iconStyle =
resolvedIconSizePx === undefined
? undefined
: ({
fontSize: resolvedIconSizePx,
width: resolvedIconSizePx,
height: resolvedIconSizePx,
} satisfies React.CSSProperties);
return (
<div
role="img"
@@ -29,8 +50,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
<div
key={itemKey}
data-active={isActive}
style={decorationStyle}
className={cn(
"h-2.5 flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
"flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
decorationSizePx === undefined && "h-2.5",
isActive && "bg-(--page-primary-color)",
)}
/>
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
return (
<i
key={itemKey}
className={cn("ph size-2.5 text-(--page-primary-color)", `ph-${icon}`, !isActive && "opacity-40")}
style={iconStyle}
className={cn(
"ph text-(--page-primary-color)",
resolvedIconSizePx === undefined && defaultDecorationClassName,
`ph-${icon}`,
!isActive && "opacity-40",
)}
/>
);
}
@@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
<div
key={itemKey}
data-active={isActive}
style={decorationStyle}
className={cn(
"size-2.5 border border-(--page-primary-color)",
"border border-(--page-primary-color)",
decorationSizePx === undefined && defaultDecorationClassName,
isActive && "bg-(--page-primary-color)",
type === "circle" && "rounded-full",
type === "rectangle" && "w-7",
@@ -3,6 +3,8 @@ import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, m } from "motion/react";
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
import { resolveStyleRuleFontSize } from "@reactive-resume/schema/resume/style-rules";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Separator } from "@reactive-resume/ui/components/separator";
@@ -276,6 +278,13 @@ function LevelSectionForm() {
const previewType = useStore(form.store, (s) => s.values.type);
const previewIcon = useStore(form.store, (s) => s.values.icon);
const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" });
const levelFontSize = resolveStyleRuleFontSize(resume.data, { slot: "level" });
const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({
bodyFontSize: resume.data.metadata.typography.body.fontSize,
iconFontSize,
levelFontSize,
});
return (
<form
@@ -294,7 +303,14 @@ function LevelSectionForm() {
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
className="flex items-center justify-center rounded-md p-6"
>
<LevelDisplay level={3} type={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
<LevelDisplay
level={3}
type={previewType}
icon={previewIcon}
decorationSizePx={decorationSize}
iconSizePx={levelIconExplicitSize}
className="w-full max-w-[220px] justify-center"
/>
</div>
<div className="flex items-center gap-3">
@@ -323,7 +323,7 @@ const useGlalieTemplate = (): GlalieTemplate => {
width: "100%",
borderWidth: 1,
borderColor: primary,
borderRadius: picture.borderRadius / 4,
borderRadius: 0,
padding: metrics.gapX(0.75),
rowGap: metrics.gapY(0.125),
},
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { parseStyleFontSize, resolveIconSize, resolveStyleFontSize } from "./icon-size";
describe("parseStyleFontSize", () => {
it("parses numeric and px font sizes", () => {
expect(parseStyleFontSize(14)).toBe(14);
expect(parseStyleFontSize("18px")).toBe(18);
expect(parseStyleFontSize("invalid")).toBeUndefined();
});
});
describe("resolveStyleFontSize", () => {
it("returns the last font size across composed style inputs", () => {
expect(resolveStyleFontSize({ fontSize: 10 }, [{ fontSize: 12 }, { fontSize: 16 }])).toBe(16);
});
});
describe("resolveIconSize", () => {
it("prefers an explicit size over custom style font sizes", () => {
expect(
resolveIconSize({
size: 20,
styles: [{ fontSize: 12 }],
}),
).toBe(20);
});
it("falls back to custom style font sizes when size is omitted", () => {
expect(
resolveIconSize({
styles: [{ fontSize: 10 }, { fontSize: 18 }],
}),
).toBe(18);
});
it("allows a template default size to be supplied by the caller", () => {
expect(
resolveIconSize({
styles: [{ fontSize: 12 }],
}) ?? 10,
).toBe(12);
expect(resolveIconSize({ styles: [] }) ?? 10).toBe(10);
});
});
@@ -0,0 +1,36 @@
import type { Style } from "@react-pdf/types";
import type { StyleInput } from "./styles";
import { composeStyles } from "./styles";
const parseFiniteNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const parsePxValue = (value: unknown): number | undefined => {
if (typeof value !== "string" || !value.endsWith("px")) return undefined;
const parsedValue = Number.parseFloat(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
};
export const parseStyleFontSize = (fontSize: Style["fontSize"] | undefined): number | undefined =>
parseFiniteNumber(fontSize) ?? parsePxValue(fontSize);
export const resolveStyleFontSize = (...styles: StyleInput[]): number | undefined => {
for (let index = styles.length - 1; index >= 0; index -= 1) {
for (const style of composeStyles(styles[index]).toReversed()) {
const fontSize = parseStyleFontSize(style.fontSize);
if (fontSize !== undefined) return fontSize;
}
}
return undefined;
};
export const resolveIconSize = (options: {
size?: number | string | undefined;
styles?: StyleInput[];
}): number | string | undefined => {
if (options.size !== undefined) return options.size;
return resolveStyleFontSize(...(options.styles ?? []));
};
@@ -1,8 +1,10 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveStyleFontSize } from "./icon-size";
import { getTemplateMetrics } from "./metrics";
import { Icon } from "./primitives";
import { composeStyles } from "./styles";
@@ -16,14 +18,19 @@ type LevelDisplayProps = {
export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const data = useRender();
const levelDesign = data.metadata.design.level;
const iconSize = data.metadata.typography.body.fontSize - 2;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
const levelContainerStyle = useTemplateStyle("levelContainer");
const levelItemStyle = useTemplateStyle("levelItem");
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
const iconRuleStyle = useSectionStyleRule("icon");
const levelRuleStyle = useSectionStyleRule("level");
const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({
bodyFontSize: data.metadata.typography.body.fontSize,
iconFontSize: resolveStyleFontSize(iconRuleStyle),
levelFontSize: resolveStyleFontSize(levelRuleStyle),
});
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
if (level === 0) return null;
@@ -55,7 +62,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
return (
<Icon
key={itemKey}
size={iconSize + 4}
{...(levelIconExplicitSize === undefined ? {} : { size: levelIconExplicitSize })}
name={levelDesign.icon as IconName}
style={{ opacity: isActive ? 1 : 0.35 }}
/>
@@ -69,7 +76,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
style={composeStyles(
{
flex: 1,
height: iconSize,
height: decorationSize,
borderWidth: 0.75,
borderColor: color,
backgroundColor: isActive ? color : "transparent",
@@ -83,7 +90,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const itemStyle: Style = {};
let borderRadius = 0;
let width: string | number = iconSize;
let width: string | number = decorationSize;
if (levelDesign.type === "rectangle") {
width = 16;
@@ -104,7 +111,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
style={composeStyles(
{
width,
height: iconSize,
height: decorationSize,
borderWidth: 0.75,
borderColor: color,
borderRadius,
@@ -4,6 +4,7 @@ import type { StyleInput } from "./styles";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveIconSize } from "./icon-size";
import { safeTextStyle } from "./safe-text-style";
import { composeLinkStyles, composeStyles } from "./styles";
@@ -64,9 +65,17 @@ export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
);
};
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, size: templateSize, ...iconProps } = useTemplateIconSlot("icon");
const iconRuleStyle = useSectionStyleRule("icon");
const composedStyle = composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style));
const templateIconSize =
typeof templateSize === "number" || typeof templateSize === "string" ? templateSize : undefined;
const resolvedSize =
resolveIconSize({
size: sizeProp,
styles: [iconRuleStyle, asStyleInput(style)],
}) ?? templateIconSize;
if (iconProps.display === "none") return null;
@@ -74,7 +83,8 @@ export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) =
<PhosphorIcon
{...iconProps}
{...props}
style={composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style))}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
style={composedStyle}
/>
);
};
@@ -1,22 +1,17 @@
import type { Style } from "@react-pdf/types";
import type {
CustomSectionType,
ResumeData,
SectionType,
StyleIntent,
StyleSlot,
} from "@reactive-resume/schema/resume/data";
import type { ResumeData, StyleIntent, StyleSlot } from "@reactive-resume/schema/resume/data";
import type { SectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
import { getSectionStyleRuleContext, resolveStyleIntentForSlot } from "@reactive-resume/schema/resume/style-rules";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
export type SectionStyleRuleContext = {
sectionId: string;
sectionType?: CustomSectionType | undefined;
};
export type { SectionStyleRuleContext };
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
slot: StyleSlot;
};
export { getSectionStyleRuleContext };
const spacingProperties = [
"padding",
"paddingTop",
@@ -42,21 +37,6 @@ const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => {
return { min: -72, max: 72 };
};
const builtInSectionTypes = new Set<SectionType>([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const toPdfColor = (value: string) => {
@@ -98,37 +78,6 @@ const toStyle = (intent: StyleIntent | undefined): Style => {
return style;
};
export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => {
if (sectionId === "summary") return { sectionId, sectionType: "summary" };
if (builtInSectionTypes.has(sectionId as SectionType)) {
return { sectionId, sectionType: sectionId as SectionType };
}
const customSection = data.customSections.find((section) => section.id === sectionId);
return { sectionId, sectionType: customSection?.type };
};
export const resolveStyleRuleSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): Style => {
const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => {
if (!rule.enabled) return false;
if (!rule.slots[options.slot]) return false;
if (rule.target.scope === "global") return true;
if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType;
if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId;
return false;
});
const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record<
"global" | "sectionType" | "sectionId",
number
>;
const bySpecificity = [...matchingRules].sort((a, b) => {
return specificity[a.target.scope] - specificity[b.target.scope];
});
return Object.assign({}, ...bySpecificity.map((rule) => toStyle(rule.slots[options.slot])));
return toStyle(resolveStyleIntentForSlot(data, options));
};
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { resolveLevelDisplaySizes } from "./level-display-sizes";
describe("resolveLevelDisplaySizes", () => {
it("uses typography defaults when no custom font sizes are set", () => {
expect(resolveLevelDisplaySizes({ bodyFontSize: 12 })).toEqual({
decorationSize: 10,
levelIconExplicitSize: 14,
});
});
it("uses the global icon font size for decorations and defers icon sizing to icon rules", () => {
expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20 })).toEqual({
decorationSize: 20,
});
});
it("uses the level font size for all level display visuals", () => {
expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20, levelFontSize: 16 })).toEqual({
decorationSize: 16,
levelIconExplicitSize: 16,
});
});
});
@@ -0,0 +1,27 @@
export type ResolveLevelDisplaySizesOptions = {
bodyFontSize: number;
iconFontSize?: number | undefined;
levelFontSize?: number | undefined;
};
export type LevelDisplaySizes = {
decorationSize: number;
levelIconExplicitSize?: number | undefined;
};
export const resolveLevelDisplaySizes = (options: ResolveLevelDisplaySizesOptions): LevelDisplaySizes => {
const defaultDecorationSize = options.bodyFontSize - 2;
const legacyLevelIconSize = defaultDecorationSize + 4;
const decorationSize = options.levelFontSize ?? options.iconFontSize ?? defaultDecorationSize;
if (options.levelFontSize !== undefined) {
return { decorationSize, levelIconExplicitSize: options.levelFontSize };
}
if (options.iconFontSize !== undefined) {
return { decorationSize };
}
return { decorationSize, levelIconExplicitSize: legacyLevelIconSize };
};
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "./default";
import { resolveStyleRuleFontSize } from "./style-rules";
describe("resolveStyleRuleFontSize", () => {
it("returns global icon font sizes without section context", () => {
const data = {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
styleRules: [
{
id: "icon-global",
label: "",
enabled: true,
target: { scope: "global" as const },
slots: { icon: { fontSize: 22 } },
},
],
},
};
expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBe(22);
});
it("does not apply level font sizes to the icon slot", () => {
const data = {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
styleRules: [
{
id: "level-global",
label: "",
enabled: true,
target: { scope: "global" as const },
slots: { level: { fontSize: 18 } },
},
],
},
};
expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBeUndefined();
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { CustomSectionType, ResumeData, SectionType, StyleIntent, StyleSlot } from "./data";
export type SectionStyleRuleContext = {
sectionId: string;
sectionType?: CustomSectionType | undefined;
};
export type ResolveStyleRuleSlotOptions = {
slot: StyleSlot;
sectionId?: string | undefined;
sectionType?: CustomSectionType | undefined;
};
const builtInSectionTypes = new Set<SectionType>([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => {
if (sectionId === "summary") return { sectionId, sectionType: "summary" };
if (builtInSectionTypes.has(sectionId as SectionType)) {
return { sectionId, sectionType: sectionId as SectionType };
}
const customSection = data.customSections.find((section) => section.id === sectionId);
return { sectionId, sectionType: customSection?.type };
};
export const resolveStyleIntentForSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): StyleIntent => {
const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => {
if (!rule.enabled) return false;
if (!rule.slots[options.slot]) return false;
if (rule.target.scope === "global") return true;
if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType;
if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId;
return false;
});
const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record<
"global" | "sectionType" | "sectionId",
number
>;
const bySpecificity = [...matchingRules].sort((a, b) => {
return specificity[a.target.scope] - specificity[b.target.scope];
});
return Object.assign({}, ...bySpecificity.map((rule) => rule.slots[options.slot]));
};
export const resolveStyleRuleFontSize = (
data: ResumeData,
options: ResolveStyleRuleSlotOptions,
): number | undefined => {
const fontSize = resolveStyleIntentForSlot(data, options).fontSize;
if (fontSize === undefined) return undefined;
return clamp(fontSize, 6, 48);
};