diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index af30acee4..88a66a97f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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." diff --git a/apps/server/src/app-version.ts b/apps/server/src/app-version.ts new file mode 100644 index 000000000..375c5bb8e --- /dev/null +++ b/apps/server/src/app-version.ts @@ -0,0 +1 @@ +export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__; diff --git a/apps/server/src/mcp/server.ts b/apps/server/src/mcp/server.ts index 3e19f8556..80e3a1735 100644 --- a/apps/server/src/mcp/server.ts +++ b/apps/server/src/mcp/server.ts @@ -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 { @@ -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: diff --git a/apps/server/src/openapi/handler.ts b/apps/server/src/openapi/handler.ts index fd51663ad..a1e031abd 100644 --- a/apps/server/src/openapi/handler.ts +++ b/apps/server/src/openapi/handler.ts @@ -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" }, diff --git a/apps/server/src/openapi/metadata.ts b/apps/server/src/openapi/metadata.ts index 2e4cfc72d..a100133bc 100644 --- a/apps/server/src/openapi/metadata.ts +++ b/apps/server/src/openapi/metadata.ts @@ -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", diff --git a/apps/server/src/static/schema.ts b/apps/server/src/static/schema.ts index 2c3113e24..cef0552a8 100644 --- a/apps/server/src/static/schema.ts +++ b/apps/server/src/static/schema.ts @@ -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", }, }); diff --git a/apps/web/src/components/level/display.test.tsx b/apps/web/src/components/level/display.test.tsx index 6209137ae..894d2400d 100644 --- a/apps/web/src/components/level/display.test.tsx +++ b/apps/web/src/components/level/display.test.tsx @@ -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( + , + ); + + const shapes = container.querySelectorAll("div[data-active]"); + expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" }); + + const { container: iconContainer } = render( + , + ); + const icon = iconContainer.querySelector("i"); + expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" }); + }); + it("merges extra className into the wrapper", () => { const { container } = render(); const wrapper = container.firstChild as HTMLElement; diff --git a/apps/web/src/components/level/display.tsx b/apps/web/src/components/level/display.tsx index 774fb9396..13d42b5a8 100644 --- a/apps/web/src/components/level/display.tsx +++ b/apps/web/src/components/level/display.tsx @@ -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 & React.ComponentProps<"div"> & { level: number }; +type Props = z.infer & + 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 (
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props) return ( ); } @@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
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 (
- +
diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index 379776e6c..d0e48e140 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -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), }, diff --git a/packages/pdf/src/templates/shared/icon-size.test.ts b/packages/pdf/src/templates/shared/icon-size.test.ts new file mode 100644 index 000000000..338f79618 --- /dev/null +++ b/packages/pdf/src/templates/shared/icon-size.test.ts @@ -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); + }); +}); diff --git a/packages/pdf/src/templates/shared/icon-size.ts b/packages/pdf/src/templates/shared/icon-size.ts new file mode 100644 index 000000000..8929934ff --- /dev/null +++ b/packages/pdf/src/templates/shared/icon-size.ts @@ -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 ?? [])); +}; diff --git a/packages/pdf/src/templates/shared/level-display.tsx b/packages/pdf/src/templates/shared/level-display.tsx index 7472c265e..aff4d85cc 100644 --- a/packages/pdf/src/templates/shared/level-display.tsx +++ b/packages/pdf/src/templates/shared/level-display.tsx @@ -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 ( @@ -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, diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index b3ded8590..655049192 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -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) => { ); }; -export const Icon = ({ style, ...props }: ComponentProps) => { - const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon"); +export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps) => { + 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) = ); }; diff --git a/packages/pdf/src/templates/shared/style-rules.ts b/packages/pdf/src/templates/shared/style-rules.ts index 1d946b65f..dd411c775 100644 --- a/packages/pdf/src/templates/shared/style-rules.ts +++ b/packages/pdf/src/templates/shared/style-rules.ts @@ -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([ - "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)); }; diff --git a/packages/schema/src/resume/level-display-sizes.test.ts b/packages/schema/src/resume/level-display-sizes.test.ts new file mode 100644 index 000000000..148f36ca0 --- /dev/null +++ b/packages/schema/src/resume/level-display-sizes.test.ts @@ -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, + }); + }); +}); diff --git a/packages/schema/src/resume/level-display-sizes.ts b/packages/schema/src/resume/level-display-sizes.ts new file mode 100644 index 000000000..fc1ddba04 --- /dev/null +++ b/packages/schema/src/resume/level-display-sizes.ts @@ -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 }; +}; diff --git a/packages/schema/src/resume/style-rules.test.ts b/packages/schema/src/resume/style-rules.test.ts new file mode 100644 index 000000000..65df3e083 --- /dev/null +++ b/packages/schema/src/resume/style-rules.test.ts @@ -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(); + }); +}); diff --git a/packages/schema/src/resume/style-rules.ts b/packages/schema/src/resume/style-rules.ts new file mode 100644 index 000000000..0cdb5c84e --- /dev/null +++ b/packages/schema/src/resume/style-rules.ts @@ -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([ + "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); +};