From d09ad2cdc0b6a0db41a4d1ce547c9d2393cdcf72 Mon Sep 17 00:00:00 2001 From: Lihan YANG Date: Fri, 29 May 2026 06:13:45 +0800 Subject: [PATCH 1/4] Urgent fix server app version dev (#3117) * fix(server): avoid app version global in MCP dev * fix(server): use runtime-safe app version metadata --- apps/server/src/app-version.ts | 1 + apps/server/src/mcp/server.ts | 3 ++- apps/server/src/openapi/handler.ts | 3 ++- apps/server/src/openapi/metadata.ts | 3 ++- apps/server/src/static/schema.ts | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/app-version.ts 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 f026fd017..a8d0064bc 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", }, }); From c1d11236ae88655ea506aaea7c05dad5ad3d634c Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 29 May 2026 00:22:51 +0200 Subject: [PATCH 2/4] fix(pdf): keep Glalie contact list border box square (#3121) The decorative border around contact items must not inherit picture border radius. Set contactList borderRadius to 0. Fixes amruthpillai/reactive-resume#3119 Co-authored-by: Cursor Agent --- packages/pdf/src/templates/glalie/GlaliePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), }, From 1414fecade600e429f26acca4ae0e7c9f21bf07d Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 29 May 2026 00:41:21 +0200 Subject: [PATCH 3/4] fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and * fix(pdf): apply custom style fontSize to icon and level indicator sizes Map fontSize from Icon and Level Indicator custom style slots to Phosphor icon size and level indicator dimensions, since react-pdf icons ignore fontSize in favor of the size prop. * fix: separate global icon and scoped level indicator font sizes Icon slot fontSize now drives all resume icons plus level display decorations. Level indicator fontSize overrides only within level display. Shared sizing logic lives in schema; design sidebar preview uses global rules. --------- Co-authored-by: Cursor Agent --- .../web/src/components/level/display.test.tsx | 15 ++++ apps/web/src/components/level/display.tsx | 41 ++++++++-- .../-sidebar/right/sections/design.tsx | 18 ++++- .../src/templates/shared/icon-size.test.ts | 44 +++++++++++ .../pdf/src/templates/shared/icon-size.ts | 36 +++++++++ .../src/templates/shared/level-display.tsx | 17 +++-- .../pdf/src/templates/shared/primitives.tsx | 16 +++- .../pdf/src/templates/shared/style-rules.ts | 65 ++-------------- .../src/resume/level-display-sizes.test.ts | 24 ++++++ .../schema/src/resume/level-display-sizes.ts | 27 +++++++ .../schema/src/resume/style-rules.test.ts | 45 +++++++++++ packages/schema/src/resume/style-rules.ts | 74 +++++++++++++++++++ 12 files changed, 350 insertions(+), 72 deletions(-) create mode 100644 packages/pdf/src/templates/shared/icon-size.test.ts create mode 100644 packages/pdf/src/templates/shared/icon-size.ts create mode 100644 packages/schema/src/resume/level-display-sizes.test.ts create mode 100644 packages/schema/src/resume/level-display-sizes.ts create mode 100644 packages/schema/src/resume/style-rules.test.ts create mode 100644 packages/schema/src/resume/style-rules.ts 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/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); +}; From 6852f586eaf3eb69c7d6260c9ea8386b17136476 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 29 May 2026 01:44:30 +0200 Subject: [PATCH 4/4] ci: purge Cloudflare cache after release Docker image deploy (#3122) * ci: purge Cloudflare cache after release Docker image deploy Co-authored-by: Amruth Pillai * ci: add timeout and retries to Cloudflare cache purge Co-authored-by: Amruth Pillai --------- Co-authored-by: Cursor Agent --- .github/workflows/docker-build.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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."