mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
Merge branch 'main' of github.com:amruthpillai/reactive-resume
This commit is contained in:
@@ -249,3 +249,24 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
cd docker
|
cd docker
|
||||||
./manage_stack.sh up reactive_resume
|
./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 { createRouterClient } from "@orpc/server";
|
||||||
import router from "@reactive-resume/api/routers";
|
import router from "@reactive-resume/api/routers";
|
||||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||||
|
import { appVersion } from "../app-version";
|
||||||
import { getRequestLocale } from "../rpc/locale";
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
|
||||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||||
@@ -25,7 +26,7 @@ export async function createMcpServer(request: Request) {
|
|||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: "reactive-resume",
|
name: "reactive-resume",
|
||||||
version: __APP_VERSION__,
|
version: appVersion,
|
||||||
title: "Reactive Resume",
|
title: "Reactive Resume",
|
||||||
websiteUrl: "https://rxresu.me",
|
websiteUrl: "https://rxresu.me",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume
|
|||||||
import router from "@reactive-resume/api/routers";
|
import router from "@reactive-resume/api/routers";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { appVersion } from "../app-version";
|
||||||
import { mergeResponseHeaders } from "../http/headers";
|
import { mergeResponseHeaders } from "../http/headers";
|
||||||
import { getRequestLocale } from "../rpc/locale";
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export async function handleOpenApi(request: Request) {
|
|||||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||||
info: {
|
info: {
|
||||||
title: "Reactive Resume",
|
title: "Reactive Resume",
|
||||||
version: __APP_VERSION__,
|
version: appVersion,
|
||||||
description: "Reactive Resume API",
|
description: "Reactive Resume API",
|
||||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
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" },
|
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 { auth } from "@reactive-resume/auth/config";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||||
|
import { appVersion } from "../app-version";
|
||||||
|
|
||||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||||
@@ -11,7 +12,7 @@ export function handleWellKnownFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleMcpServerCard() {
|
export function handleMcpServerCard() {
|
||||||
return Response.json(buildMcpServerCard(__APP_VERSION__), {
|
return Response.json(buildMcpServerCard(appVersion), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { appVersion } from "../app-version";
|
||||||
|
|
||||||
export function handleSchemaJson() {
|
export function handleSchemaJson() {
|
||||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||||
@@ -12,7 +13,7 @@ export function handleSchemaJson() {
|
|||||||
"Surrogate-Control": "max-age=86400",
|
"Surrogate-Control": "max-age=86400",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Robots-Tag": "index, follow",
|
"X-Robots-Tag": "index, follow",
|
||||||
ETag: __APP_VERSION__,
|
ETag: appVersion,
|
||||||
Vary: "Accept",
|
Vary: "Accept",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ describe("LevelDisplay", () => {
|
|||||||
expect(wrapper.getAttribute("aria-label")).toContain("4");
|
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", () => {
|
it("merges extra className into the wrapper", () => {
|
||||||
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
|
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
|
||||||
const wrapper = container.firstChild as HTMLElement;
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
|||||||
@@ -3,14 +3,35 @@ import type z from "zod";
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { cn } from "@reactive-resume/utils/style";
|
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;
|
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 (level === 0) return null;
|
||||||
if (type === "hidden" || icon === "") 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
@@ -29,8 +50,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
|||||||
<div
|
<div
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
|
style={decorationStyle}
|
||||||
className={cn(
|
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)",
|
isActive && "bg-(--page-primary-color)",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
|||||||
return (
|
return (
|
||||||
<i
|
<i
|
||||||
key={itemKey}
|
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
|
<div
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
|
style={decorationStyle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-2.5 border border-(--page-primary-color)",
|
"border border-(--page-primary-color)",
|
||||||
|
decorationSizePx === undefined && defaultDecorationClassName,
|
||||||
isActive && "bg-(--page-primary-color)",
|
isActive && "bg-(--page-primary-color)",
|
||||||
type === "circle" && "rounded-full",
|
type === "circle" && "rounded-full",
|
||||||
type === "rectangle" && "w-7",
|
type === "rectangle" && "w-7",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import { useStore } from "@tanstack/react-form";
|
import { useStore } from "@tanstack/react-form";
|
||||||
import { AnimatePresence, m } from "motion/react";
|
import { AnimatePresence, m } from "motion/react";
|
||||||
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
|
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 { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||||
import { Input } from "@reactive-resume/ui/components/input";
|
import { Input } from "@reactive-resume/ui/components/input";
|
||||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||||
@@ -276,6 +278,13 @@ function LevelSectionForm() {
|
|||||||
|
|
||||||
const previewType = useStore(form.store, (s) => s.values.type);
|
const previewType = useStore(form.store, (s) => s.values.type);
|
||||||
const previewIcon = useStore(form.store, (s) => s.values.icon);
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -294,7 +303,14 @@ function LevelSectionForm() {
|
|||||||
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
||||||
className="flex items-center justify-center rounded-md p-6"
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: primary,
|
borderColor: primary,
|
||||||
borderRadius: picture.borderRadius / 4,
|
borderRadius: 0,
|
||||||
padding: metrics.gapX(0.75),
|
padding: metrics.gapX(0.75),
|
||||||
rowGap: metrics.gapY(0.125),
|
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 { Style } from "@react-pdf/types";
|
||||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||||
|
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
|
||||||
import { useRender } from "../../context";
|
import { useRender } from "../../context";
|
||||||
import { View } from "../../renderer";
|
import { View } from "../../renderer";
|
||||||
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||||
|
import { resolveStyleFontSize } from "./icon-size";
|
||||||
import { getTemplateMetrics } from "./metrics";
|
import { getTemplateMetrics } from "./metrics";
|
||||||
import { Icon } from "./primitives";
|
import { Icon } from "./primitives";
|
||||||
import { composeStyles } from "./styles";
|
import { composeStyles } from "./styles";
|
||||||
@@ -16,14 +18,19 @@ type LevelDisplayProps = {
|
|||||||
export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
||||||
const data = useRender();
|
const data = useRender();
|
||||||
const levelDesign = data.metadata.design.level;
|
const levelDesign = data.metadata.design.level;
|
||||||
const iconSize = data.metadata.typography.body.fontSize - 2;
|
|
||||||
const metrics = getTemplateMetrics(data.metadata.page);
|
const metrics = getTemplateMetrics(data.metadata.page);
|
||||||
const iconProps = useTemplateIconSlot("icon");
|
const iconProps = useTemplateIconSlot("icon");
|
||||||
const levelContainerStyle = useTemplateStyle("levelContainer");
|
const levelContainerStyle = useTemplateStyle("levelContainer");
|
||||||
const levelItemStyle = useTemplateStyle("levelItem");
|
const levelItemStyle = useTemplateStyle("levelItem");
|
||||||
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
|
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
|
||||||
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
|
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
|
||||||
|
const iconRuleStyle = useSectionStyleRule("icon");
|
||||||
const levelRuleStyle = useSectionStyleRule("level");
|
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";
|
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
|
||||||
|
|
||||||
if (level === 0) return null;
|
if (level === 0) return null;
|
||||||
@@ -55,7 +62,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
|||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
size={iconSize + 4}
|
{...(levelIconExplicitSize === undefined ? {} : { size: levelIconExplicitSize })}
|
||||||
name={levelDesign.icon as IconName}
|
name={levelDesign.icon as IconName}
|
||||||
style={{ opacity: isActive ? 1 : 0.35 }}
|
style={{ opacity: isActive ? 1 : 0.35 }}
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +76,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
|||||||
style={composeStyles(
|
style={composeStyles(
|
||||||
{
|
{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: iconSize,
|
height: decorationSize,
|
||||||
borderWidth: 0.75,
|
borderWidth: 0.75,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: isActive ? color : "transparent",
|
backgroundColor: isActive ? color : "transparent",
|
||||||
@@ -83,7 +90,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
|||||||
|
|
||||||
const itemStyle: Style = {};
|
const itemStyle: Style = {};
|
||||||
let borderRadius = 0;
|
let borderRadius = 0;
|
||||||
let width: string | number = iconSize;
|
let width: string | number = decorationSize;
|
||||||
|
|
||||||
if (levelDesign.type === "rectangle") {
|
if (levelDesign.type === "rectangle") {
|
||||||
width = 16;
|
width = 16;
|
||||||
@@ -104,7 +111,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
|
|||||||
style={composeStyles(
|
style={composeStyles(
|
||||||
{
|
{
|
||||||
width,
|
width,
|
||||||
height: iconSize,
|
height: decorationSize,
|
||||||
borderWidth: 0.75,
|
borderWidth: 0.75,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { StyleInput } from "./styles";
|
|||||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||||
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
|
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
|
||||||
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||||
|
import { resolveIconSize } from "./icon-size";
|
||||||
import { safeTextStyle } from "./safe-text-style";
|
import { safeTextStyle } from "./safe-text-style";
|
||||||
import { composeLinkStyles, composeStyles } from "./styles";
|
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>) => {
|
export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
|
||||||
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
|
const { style: iconStyle, size: templateSize, ...iconProps } = useTemplateIconSlot("icon");
|
||||||
const iconRuleStyle = useSectionStyleRule("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;
|
if (iconProps.display === "none") return null;
|
||||||
|
|
||||||
@@ -74,7 +83,8 @@ export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) =
|
|||||||
<PhosphorIcon
|
<PhosphorIcon
|
||||||
{...iconProps}
|
{...iconProps}
|
||||||
{...props}
|
{...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 { Style } from "@react-pdf/types";
|
||||||
import type {
|
import type { ResumeData, StyleIntent, StyleSlot } from "@reactive-resume/schema/resume/data";
|
||||||
CustomSectionType,
|
import type { SectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
|
||||||
ResumeData,
|
import { getSectionStyleRuleContext, resolveStyleIntentForSlot } from "@reactive-resume/schema/resume/style-rules";
|
||||||
SectionType,
|
|
||||||
StyleIntent,
|
|
||||||
StyleSlot,
|
|
||||||
} from "@reactive-resume/schema/resume/data";
|
|
||||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||||
|
|
||||||
export type SectionStyleRuleContext = {
|
export type { SectionStyleRuleContext };
|
||||||
sectionId: string;
|
|
||||||
sectionType?: CustomSectionType | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
|
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
|
||||||
slot: StyleSlot;
|
slot: StyleSlot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getSectionStyleRuleContext };
|
||||||
|
|
||||||
const spacingProperties = [
|
const spacingProperties = [
|
||||||
"padding",
|
"padding",
|
||||||
"paddingTop",
|
"paddingTop",
|
||||||
@@ -42,21 +37,6 @@ const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => {
|
|||||||
return { min: -72, max: 72 };
|
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 clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
const toPdfColor = (value: string) => {
|
const toPdfColor = (value: string) => {
|
||||||
@@ -98,37 +78,6 @@ const toStyle = (intent: StyleIntent | undefined): Style => {
|
|||||||
return 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 => {
|
export const resolveStyleRuleSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): Style => {
|
||||||
const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => {
|
return toStyle(resolveStyleIntentForSlot(data, options));
|
||||||
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])));
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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