mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
Merge branch 'main' of github.com:amruthpillai/reactive-resume
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user