refactor: remove dead code, unused exports and redundant dependencies

- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code)
- delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt)
- delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool,
  schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers
- inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough)
- deduplicate template color helpers into shared/color-helpers
- unexport 50+ internal-only symbols, remove dead export-map entries
- replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
This commit is contained in:
Amruth Pillai
2026-07-03 20:03:50 +02:00
parent 2a80e6a1df
commit a4999c04af
107 changed files with 1222 additions and 3894 deletions
+19 -21
View File
@@ -17,17 +17,17 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.1",
"@ai-sdk/google": "^4.0.2",
"@ai-sdk/openai": "^4.0.2",
"@ai-sdk/openai-compatible": "^3.0.1",
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/api-key": "^1.6.22",
"@better-auth/drizzle-adapter": "^1.6.22",
"@ai-sdk/anthropic": "^4.0.7",
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@ai-sdk/openai-compatible": "^3.0.5",
"@aws-sdk/client-s3": "^3.1079.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.22",
"@better-auth/passkey": "^1.6.22",
"@hono/node-server": "^2.0.6",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@hono/node-server": "^2.0.8",
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/client": "^1.14.6",
"@orpc/experimental-ratelimit": "^1.14.6",
@@ -46,29 +46,27 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.4",
"ai": "^7.0.14",
"bcrypt": "^6.0.0",
"better-auth": "1.6.22",
"better-auth": "1.6.23",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.11",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.49.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.12.27",
"jsonrepair": "^3.14.1",
"node-html-parser": "^8.0.3",
"nodemailer": "^9.0.1",
"node-html-parser": "^8.0.4",
"nodemailer": "^9.0.3",
"ollama-ai-provider-v2": "^3.6.0",
"pg": "^8.22.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.7",
"react-email": "^6.6.5",
"react-email": "^6.6.6",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.2",
"sharp": "^0.35.3",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.1",
@@ -76,12 +74,12 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.0.1",
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"tsdown": "^0.22.3",
"tsx": "^4.22.4",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Website } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TagIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
@@ -50,7 +50,7 @@ export function URLInput<TValue extends Website>({ value, onChange, hideLabelBut
[onChange, value],
);
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
const urlValue = stripPrefix(value.url);
return (
<InputGroup>
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
@@ -13,7 +13,7 @@ export function PasswordSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
const hasPassword = hasAccount("credential");
const handleUpdatePassword = useCallback(() => {
if (hasPassword) {
@@ -2,7 +2,7 @@ import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
@@ -18,11 +18,11 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
const { link, unlink } = useAuthProviderActions();
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
const providerName = useMemo(() => name ?? getProviderName(provider), [name, provider]);
const providerIcon = useMemo(() => getProviderIcon(provider), [provider]);
const providerName = name ?? getProviderName(provider);
const providerIcon = getProviderIcon(provider);
const account = useMemo(() => getAccountByProviderId(provider), [getAccountByProviderId, provider]);
const isConnected = useMemo(() => hasAccount(provider), [hasAccount, provider]);
const account = getAccountByProviderId(provider);
const isConnected = hasAccount(provider);
const handleLink = useCallback(async () => {
await link(provider);
@@ -1,7 +1,7 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
@@ -14,8 +14,8 @@ export function TwoFactorSection() {
const { hasAccount } = useAuthAccounts();
const { data: session } = authClient.useSession();
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
const hasTwoFactor = useMemo(() => session?.user.twoFactorEnabled ?? false, [session]);
const hasPassword = hasAccount("credential");
const hasTwoFactor = session?.user.twoFactorEnabled ?? false;
const handleTwoFactorAction = useCallback(() => {
if (hasTwoFactor) {
-95
View File
@@ -1,95 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useIsMobile } from "./use-mobile";
type Listener = (event: { matches: boolean }) => void;
type FakeMediaQueryList = {
matches: boolean;
addEventListener: (type: string, fn: Listener) => void;
removeEventListener: (type: string, fn: Listener) => void;
__listeners: Set<Listener>;
__set: (matches: boolean) => void;
};
const createMatchMedia = (initialMatches: boolean) => {
const mqlByQuery = new Map<string, FakeMediaQueryList>();
const matchMedia = vi.fn((query: string): FakeMediaQueryList => {
let mql = mqlByQuery.get(query);
if (mql) return mql;
const listeners = new Set<Listener>();
mql = {
matches: initialMatches,
addEventListener: (_type, fn) => listeners.add(fn),
removeEventListener: (_type, fn) => listeners.delete(fn),
__listeners: listeners,
__set: (matches: boolean) => {
// biome-ignore lint/style/noNonNullAssertion: This closure only runs after the media query list has been initialized.
mql!.matches = matches;
for (const fn of listeners) fn({ matches });
},
};
mqlByQuery.set(query, mql);
return mql;
});
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: matchMedia,
});
return { matchMedia, getMql: (q: string) => mqlByQuery.get(q) };
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("useIsMobile", () => {
it("returns true when the viewport matches the mobile media query", () => {
createMatchMedia(true);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("returns false when the viewport does not match the mobile media query", () => {
createMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("uses the (max-width: 767px) query", () => {
const { matchMedia } = createMatchMedia(false);
renderHook(() => useIsMobile());
expect(matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
it("updates when the media query change event fires", () => {
const { getMql } = createMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => {
getMql("(max-width: 767px)")?.__set(true);
});
expect(result.current).toBe(true);
});
it("removes the listener on unmount", () => {
const { getMql } = createMatchMedia(false);
const { unmount } = renderHook(() => useIsMobile());
const mql = getMql("(max-width: 767px)");
expect(mql?.__listeners.size).toBe(1);
unmount();
expect(mql?.__listeners.size).toBe(0);
});
});
-25
View File
@@ -1,25 +0,0 @@
import { useRef, useState, useSyncExternalStore } from "react";
const MOBILE_BREAKPOINT = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
export function useIsMobile() {
const [mediaQueryList] = useState(() => (typeof window === "undefined" ? null : window.matchMedia(MOBILE_QUERY)));
const latestMatchesRef = useRef(mediaQueryList?.matches ?? false);
return useSyncExternalStore(
(onStoreChange) => {
if (!mediaQueryList) return () => {};
const handleChange = (event: MediaQueryListEvent | { matches: boolean }) => {
latestMatchesRef.current = event.matches;
onStoreChange();
};
mediaQueryList.addEventListener("change", handleChange);
return () => mediaQueryList.removeEventListener("change", handleChange);
},
() => latestMatchesRef.current,
() => false,
);
}
-1
View File
@@ -1,4 +1,3 @@
import "./polyfills/map-upsert";
import { RouterProvider } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import { getRouter } from "./router";
-118
View File
@@ -1,118 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const originalGetOrInsert = Map.prototype.getOrInsert;
const originalGetOrInsertComputed = Map.prototype.getOrInsertComputed;
const deleteMapUpsertMethods = () => {
Reflect.deleteProperty(Map.prototype, "getOrInsert");
Reflect.deleteProperty(Map.prototype, "getOrInsertComputed");
};
const resetMapUpsertMethods = () => {
if (originalGetOrInsert) {
Object.defineProperty(Map.prototype, "getOrInsert", {
value: originalGetOrInsert,
writable: true,
configurable: true,
});
} else {
Reflect.deleteProperty(Map.prototype, "getOrInsert");
}
if (originalGetOrInsertComputed) {
Object.defineProperty(Map.prototype, "getOrInsertComputed", {
value: originalGetOrInsertComputed,
writable: true,
configurable: true,
});
} else {
Reflect.deleteProperty(Map.prototype, "getOrInsertComputed");
}
};
describe("map upsert polyfill", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
resetMapUpsertMethods();
});
it("defines getOrInsert and getOrInsertComputed when the runtime does not provide them", async () => {
deleteMapUpsertMethods();
await import("./map-upsert");
expect(Map.prototype.getOrInsert).toEqual(expect.any(Function));
expect(Map.prototype.getOrInsertComputed).toEqual(expect.any(Function));
});
it("does not overwrite native map upsert methods", async () => {
const getOrInsert = () => "native";
const getOrInsertComputed = () => "native-computed";
Object.defineProperty(Map.prototype, "getOrInsert", { value: getOrInsert, writable: true, configurable: true });
Object.defineProperty(Map.prototype, "getOrInsertComputed", {
value: getOrInsertComputed,
writable: true,
configurable: true,
});
await import("./map-upsert");
expect(Map.prototype.getOrInsert).toBe(getOrInsert);
expect(Map.prototype.getOrInsertComputed).toBe(getOrInsertComputed);
});
it("returns existing values without overwriting them", async () => {
deleteMapUpsertMethods();
await import("./map-upsert");
const map = new Map<string, string | undefined>([
["value", "existing"],
["undefined", undefined],
]);
const callback = vi.fn(() => "computed");
expect(map.getOrInsert("value", "default")).toBe("existing");
expect(map.getOrInsert("undefined", "default")).toBeUndefined();
expect(map.get("value")).toBe("existing");
expect(map.getOrInsertComputed("value", callback)).toBe("existing");
expect(callback).not.toHaveBeenCalled();
});
it("inserts missing values and computes lazy defaults with the key", async () => {
deleteMapUpsertMethods();
await import("./map-upsert");
const map = new Map<string, string>();
const callback = (key: string) => `${key}-computed`;
expect(map.getOrInsert("value", "default")).toBe("default");
expect(map.get("value")).toBe("default");
expect(map.getOrInsertComputed("lazy", callback)).toBe("lazy-computed");
expect(map.get("lazy")).toBe("lazy-computed");
});
it("validates the computed callback before returning an existing value", async () => {
deleteMapUpsertMethods();
await import("./map-upsert");
const map = new Map([["value", "existing"]]);
expect(() => map.getOrInsertComputed("value", undefined as never)).toThrow(TypeError);
});
it("passes canonical zero to the computed callback", async () => {
deleteMapUpsertMethods();
await import("./map-upsert");
const map = new Map<number, string>();
const callback = vi.fn((key: number) => `${key}-computed`);
map.getOrInsertComputed(-0, callback);
expect(callback).toHaveBeenCalledWith(0);
expect(Object.is(callback.mock.calls[0]?.[0], -0)).toBe(false);
});
});
-49
View File
@@ -1,49 +0,0 @@
declare global {
interface Map<K, V> {
getOrInsert(key: K, value: V): V;
getOrInsertComputed(key: K, callbackFn: (key: K) => V): V;
}
}
const defineMapMethod = <T extends keyof Map<unknown, unknown>>(name: T, value: Map<unknown, unknown>[T]) => {
if (typeof Map.prototype[name] === "function") return;
Object.defineProperty(Map.prototype, name, {
value,
writable: true,
configurable: true,
});
};
const canonicalizeKeyedCollectionKey = <K>(key: K): K => {
return Object.is(key, -0) ? (0 as K) : key;
};
defineMapMethod("getOrInsert", function getOrInsert<K, V>(this: Map<K, V>, key: K, value: V): V {
const canonicalKey = canonicalizeKeyedCollectionKey(key);
if (this.has(canonicalKey)) return this.get(canonicalKey) as V;
this.set(canonicalKey, value);
return value;
});
defineMapMethod("getOrInsertComputed", function getOrInsertComputed<
K,
V,
>(this: Map<K, V>, key: K, callbackFn: (key: K) => V): V {
if (typeof callbackFn !== "function")
throw new TypeError("Map.prototype.getOrInsertComputed callback must be a function");
const canonicalKey = canonicalizeKeyedCollectionKey(key);
if (this.has(canonicalKey)) return this.get(canonicalKey) as V;
const value = callbackFn(canonicalKey);
this.set(canonicalKey, value);
return value;
});
export {};
+1 -1
View File
@@ -5,6 +5,7 @@ import type { Locale } from "@reactive-resume/utils/locale";
import type { QueryClient } from "@tanstack/react-query";
import type { orpc } from "@/libs/orpc/client";
import type { Theme } from "@/libs/theme";
import { DirectionProvider } from "@base-ui/react/direction-provider";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { IconContext } from "@phosphor-icons/react";
@@ -13,7 +14,6 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, HeadContent, Outlet } from "@tanstack/react-router";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
import { useEffect, useMemo } from "react";
import { DirectionProvider } from "@reactive-resume/ui/components/direction";
import { Toaster } from "@reactive-resume/ui/components/sonner";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
@@ -20,7 +20,6 @@ import {
TranslateIcon,
} from "@phosphor-icons/react";
import { m } from "motion/react";
import { useMemo } from "react";
import { cn } from "@reactive-resume/utils/style";
type Feature = {
@@ -167,7 +166,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
}
export function Features() {
const features = useMemo(() => getFeatures(), []);
const features = getFeatures();
return (
<section id="features">
@@ -1,8 +1,7 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import { useCallback, useMemo } from "react";
import { useWindowSize } from "usehooks-ts";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
import { useIsMobile } from "@/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
@@ -102,7 +101,7 @@ type UseBuilderSidebarReturn = {
};
export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (builder: UseBuilderSidebarReturn) => T): T {
const isMobile = useIsMobile();
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const { width } = useWindowSize();
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, expandSize, groupResizeBehavior } =
@@ -6,6 +6,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import Cookies from "js-cookie";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { useMediaQuery } from "usehooks-ts";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import {
useBuilderResumeUpdateSubscription,
@@ -14,7 +15,6 @@ import {
useResumeCleanup,
useResumeStore,
} from "@/features/resume/builder/draft";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { BuilderHeader } from "./-components/header";
@@ -94,7 +94,7 @@ type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
};
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
const isMobile = useIsMobile();
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
+1 -2
View File
@@ -7,7 +7,6 @@
"./types": "./src/types.ts",
"./prompts": "./src/prompts.ts",
"./tools/resume-tool-contracts": "./src/tools/resume-tool-contracts.ts",
"./tools/patch-resume": "./src/tools/patch-resume.ts",
"./tools/patch-proposal": "./src/tools/patch-proposal.ts",
"./resume/extraction-template": "./src/resume/extraction-template.ts",
"./resume/sanitize": "./src/resume/sanitize.ts"
@@ -29,7 +28,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
+6 -3
View File
@@ -4,10 +4,13 @@ import { jsonrepair } from "jsonrepair";
import { flattenError, ZodError } from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { isObject } from "@reactive-resume/utils/sanitize";
import { generateId } from "@reactive-resume/utils/string";
import { buildAiExtractionTemplate } from "./extraction-template";
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const aiExtractionTemplate = buildAiExtractionTemplate();
type SectionKey = keyof typeof sectionRequiredFieldMap;
@@ -24,13 +27,13 @@ type DroppedSectionItemEntry = {
reason: string;
};
export type ResumeSanitizationDiagnostics = {
type ResumeSanitizationDiagnostics = {
coercions: CoercionEntry[];
droppedSectionItems: DroppedSectionItemEntry[];
salvageApplied: boolean;
};
export type ResumeSanitizationResult = {
type ResumeSanitizationResult = {
data: ResumeData;
diagnostics: ResumeSanitizationDiagnostics;
};
@@ -1,40 +1,5 @@
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
buildResumePatchProposalPreview,
normalizeResumePatchProposals,
resumePatchProposalSchema,
} from "./patch-proposal";
describe("buildResumePatchProposalPreview — additional cases", () => {
it("shows the removed value as before with after undefined", () => {
const proposal = resumePatchProposalSchema.parse({
id: "proposal-1",
title: "Remove first profile",
operations: [{ op: "remove", path: "/sections/profiles/items/0" }],
});
const preview = buildResumePatchProposalPreview(sampleResumeData, proposal);
expect(preview.entries[0]).toMatchObject({
operation: "remove",
path: "/sections/profiles/items/0",
after: undefined,
});
expect(preview.entries[0]?.before).toBeDefined();
});
it("titleizes camelCase and hyphenated path segments", () => {
const proposal = resumePatchProposalSchema.parse({
id: "proposal-1",
title: "Update page format",
operations: [{ op: "replace", path: "/metadata/page/format", value: "letter" }],
});
const preview = buildResumePatchProposalPreview(sampleResumeData, proposal);
// Label includes the human-readable section name 'Metadata' and 'Page'.
expect(preview.entries[0]?.label).toMatch(/Metadata/);
});
});
import { normalizeResumePatchProposals } from "./patch-proposal";
describe("normalizeResumePatchProposals", () => {
it("attaches a baseUpdatedAt to every proposal", () => {
+8 -58
View File
@@ -1,19 +1,20 @@
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
buildResumePatchProposalPreview,
normalizeResumePatchProposals,
resumePatchProposalSchema,
resumePatchProposalToolInputSchema,
resumePatchProposalToolOutputSchema,
} from "./patch-proposal";
describe("resume patch proposals", () => {
it("requires at least one operation per proposal", () => {
const result = resumePatchProposalSchema.safeParse({
id: "proposal-1",
title: "Empty proposal",
operations: [],
const result = resumePatchProposalToolOutputSchema.safeParse({
proposals: [
{
id: "proposal-1",
title: "Empty proposal",
operations: [],
},
],
});
expect(result.success).toBe(false);
@@ -50,55 +51,4 @@ describe("resume patch proposals", () => {
expect(result.proposals[0]?.baseUpdatedAt).toBe("2026-05-10T06:38:27.093Z");
});
it("builds readable before/after preview entries", () => {
const proposal = resumePatchProposalSchema.parse({
id: "proposal-1",
title: "Rewrite summary",
operations: [{ op: "replace", path: "/summary/content", value: "<p>Focused product engineer.</p>" }],
});
const preview = buildResumePatchProposalPreview(sampleResumeData, proposal);
expect(preview.title).toBe("Rewrite summary");
expect(preview.entries).toHaveLength(1);
expect(preview.entries[0]).toMatchObject({
operation: "replace",
path: "/summary/content",
label: "Summary content",
after: "<p>Focused product engineer.</p>",
});
expect(preview.entries[0]?.before).toBe(sampleResumeData.summary.content);
});
it("shows appended add values in the after preview", () => {
const profile = {
id: "b2c7f28a-3df7-4b8b-a8a1-8f95a8f522e8",
hidden: false,
network: "Instagram",
username: "dkowalski.games",
icon: "instagram-logo",
iconColor: "",
website: {
url: "https://instagram.com/dkowalski.games",
label: "instagram.com/dkowalski.games",
inlineLink: false,
},
};
const proposal = resumePatchProposalSchema.parse({
id: "proposal-1",
title: "Add social profile",
operations: [{ op: "add", path: "/sections/profiles/items/-", value: profile }],
});
const preview = buildResumePatchProposalPreview(sampleResumeData, proposal);
expect(preview.entries[0]).toMatchObject({
operation: "add",
path: "/sections/profiles/items/-",
label: "Profiles new item",
before: undefined,
after: profile,
});
});
});
+4 -146
View File
@@ -1,45 +1,7 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import z from "zod";
import { applyResumePatches, jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
const jsonPointerToken = /~1|~0/g;
const operationLabels: Record<JsonPatchOperation["op"], string> = {
add: "Add",
copy: "Copy",
move: "Move",
remove: "Remove",
replace: "Replace",
test: "Check",
};
const sectionLabels: Record<string, string> = {
basics: "Basics",
customSections: "Custom sections",
metadata: "Metadata",
picture: "Picture",
sections: "Sections",
summary: "Summary",
};
const fieldLabels: Record<string, string> = {
background: "Background",
basics: "Basics",
company: "Company",
content: "content",
description: "description",
headline: "Headline",
items: "items",
location: "Location",
name: "Name",
primary: "Primary color",
summary: "Summary",
text: "Text color",
title: "title",
};
export const resumePatchProposalSchema = z.object({
const resumePatchProposalSchema = z.object({
id: z.string().min(1),
title: z.string().trim().min(1),
summary: z.string().trim().min(1).optional(),
@@ -61,112 +23,8 @@ export const resumePatchProposalToolOutputSchema = z.object({
proposals: z.array(resumePatchProposalSchema).min(1),
});
export type ResumePatchProposal = z.infer<typeof resumePatchProposalSchema>;
export type ResumePatchProposalToolInput = z.infer<typeof resumePatchProposalToolInputSchema>;
export type ResumePatchProposalToolOutput = z.infer<typeof resumePatchProposalToolOutputSchema>;
export type ResumePatchPreviewEntry = {
operation: JsonPatchOperation["op"];
operationLabel: string;
path: string;
label: string;
before: unknown;
after: unknown;
};
export type ResumePatchProposalPreview = {
id: string;
title: string;
summary?: string;
entries: ResumePatchPreviewEntry[];
};
function decodeJsonPointerPath(path: string): string[] {
if (path === "") return [];
if (!path.startsWith("/")) return [];
return path
.slice(1)
.split("/")
.map((segment) => segment.replace(jsonPointerToken, (token) => (token === "~1" ? "/" : "~")));
}
function getValueAtPath(data: unknown, path: string): unknown {
const segments = decodeJsonPointerPath(path);
return segments.reduce<unknown>((current, segment) => {
if (current == null) return undefined;
if (Array.isArray(current)) {
if (segment === "-") return undefined;
const index = Number(segment);
return Number.isInteger(index) ? current[index] : undefined;
}
if (typeof current !== "object") return undefined;
return (current as Record<string, unknown>)[segment];
}, data);
}
function getSectionLabel(segments: string[]): string {
if (segments[0] === "sections" && segments[1]) return fieldLabels[segments[1]] ?? titleize(segments[1]);
if (segments[0] === "metadata" && segments[1]) return `${sectionLabels.metadata} ${titleize(segments[1])}`;
if (segments[0]) return sectionLabels[segments[0]] ?? titleize(segments[0]);
return "Resume";
}
function titleize(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (char) => char.toUpperCase());
}
function formatPathLabel(operation: JsonPatchOperation): string {
const path = operation.path;
const segments = decodeJsonPointerPath(path);
const section = getSectionLabel(segments);
const field = segments.at(-1);
if (!field) return section;
if (field === "-") return operation.op === "add" ? `${section} new item` : section;
if (/^\d+$/.test(field)) return `${section} item ${Number(field) + 1}`;
if (field === "items") return `${section} items`;
const fieldLabel = fieldLabels[field] ?? titleize(field);
return section === fieldLabel ? section : `${section} ${fieldLabel}`;
}
function getPreviewAfterValue(patchedData: ResumeData, operation: JsonPatchOperation): unknown {
if (operation.op === "remove") return undefined;
if (operation.op === "add" && decodeJsonPointerPath(operation.path).at(-1) === "-") return operation.value;
return getValueAtPath(patchedData, operation.path);
}
export function buildResumePatchProposalPreview(
resumeData: ResumeData,
proposal: ResumePatchProposal,
): ResumePatchProposalPreview {
const patchedData = applyResumePatches(resumeData, proposal.operations);
return {
id: proposal.id,
title: proposal.title,
...(proposal.summary ? { summary: proposal.summary } : {}),
entries: proposal.operations.map((operation) => ({
operation: operation.op,
operationLabel: operationLabels[operation.op],
path: operation.path,
label: formatPathLabel(operation),
before: operation.op === "add" ? undefined : getValueAtPath(resumeData, operation.path),
after: getPreviewAfterValue(patchedData, operation),
})),
};
}
type ResumePatchProposal = z.infer<typeof resumePatchProposalSchema>;
type ResumePatchProposalToolInput = z.infer<typeof resumePatchProposalToolInputSchema>;
export function normalizeResumePatchProposals(
input: ResumePatchProposalToolInput,
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { executePatchResume, patchResumeInputSchema } from "./patch-resume";
describe("patchResumeInputSchema", () => {
it("requires at least one operation", () => {
expect(patchResumeInputSchema.safeParse({ operations: [] }).success).toBe(false);
});
it("accepts a valid replace operation", () => {
const result = patchResumeInputSchema.safeParse({
operations: [{ op: "replace", path: "/basics/name", value: "Jane" }],
});
expect(result.success).toBe(true);
});
it("accepts add and remove operations", () => {
const result = patchResumeInputSchema.safeParse({
operations: [
{ op: "add", path: "/sections/skills/items/-", value: { id: "s1", name: "Go" } },
{ op: "remove", path: "/sections/skills/items/0" },
],
});
expect(result.success).toBe(true);
});
it("rejects unknown op values", () => {
const result = patchResumeInputSchema.safeParse({
operations: [{ op: "do-something", path: "/x", value: 1 }],
});
expect(result.success).toBe(false);
});
});
describe("executePatchResume", () => {
it("returns success and the applied operations on a valid patch", () => {
const ops = [{ op: "replace" as const, path: "/basics/name", value: "Jane Doe" }];
const result = executePatchResume(structuredClone(defaultResumeData), ops);
expect(result.success).toBe(true);
expect(result.appliedOperations).toEqual(ops);
});
it("throws when an operation targets an invalid path", () => {
const ops = [{ op: "replace" as const, path: "/non/existent/path", value: 1 }];
expect(() => executePatchResume(structuredClone(defaultResumeData), ops)).toThrow();
});
it("supports multi-op patches that touch top-level fields", () => {
const ops = [
{ op: "replace" as const, path: "/basics/name", value: "Jane Doe" },
{ op: "replace" as const, path: "/basics/headline", value: "Senior Engineer" },
];
const result = executePatchResume(structuredClone(defaultResumeData), ops);
expect(result.appliedOperations).toEqual(ops);
});
});
-17
View File
@@ -1,17 +0,0 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { applyResumePatches } from "@reactive-resume/resume/patch";
import { resumePatchOperationsInputSchema } from "./resume-tool-contracts";
export const patchResumeInputSchema = resumePatchOperationsInputSchema;
export const patchResumeDescription = `Apply JSON Patch (RFC 6902) operations to modify the user's resume data.
Use this tool whenever the user asks to change, add, or remove content from their resume.
Always generate the minimal set of operations needed. Prefer "replace" for updates, "add" for new content, "remove" for deletions.
Use the special "-" index to append to arrays (e.g. "/sections/experience/items/-").`;
export function executePatchResume(resumeData: ResumeData, operations: JsonPatchOperation[]) {
// Validates operations structurally and against the schema; throws on invalid
applyResumePatches(resumeData, operations);
return { success: true as const, appliedOperations: operations };
}
@@ -1,4 +1,3 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import z from "zod";
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
@@ -6,10 +5,3 @@ export const resumePatchOperationsSchema = z
.array(jsonPatchOperationSchema)
.min(1)
.describe("Array of JSON Patch (RFC 6902) operations to apply to the resume");
export const resumePatchOperationsInputSchema = z.object({
operations: resumePatchOperationsSchema,
});
export type ResumePatchOperationsInput = z.infer<typeof resumePatchOperationsInputSchema>;
export type ResumePatchOperation = JsonPatchOperation;
+9 -10
View File
@@ -6,7 +6,6 @@
"exports": {
"./context": "./src/context.ts",
"./features/flags": "./src/features/flags/index.ts",
"./features/resume": "./src/features/resume/index.ts",
"./features/resume/export": "./src/features/resume/export.ts",
"./features/storage": "./src/features/storage/index.ts",
"./routers": "./src/routers/index.ts"
@@ -19,11 +18,11 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.1",
"@ai-sdk/google": "^4.0.2",
"@ai-sdk/openai": "^4.0.2",
"@ai-sdk/openai-compatible": "^3.0.1",
"@aws-sdk/client-s3": "^3.1075.0",
"@ai-sdk/anthropic": "^4.0.7",
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@ai-sdk/openai-compatible": "^3.0.5",
"@aws-sdk/client-s3": "^3.1079.0",
"@orpc/client": "^1.14.6",
"@orpc/experimental-ratelimit": "^1.14.6",
"@orpc/server": "^1.14.6",
@@ -35,9 +34,9 @@
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"ai": "^7.0.4",
"ai": "^7.0.14",
"bcrypt": "^6.0.0",
"better-auth": "1.6.22",
"better-auth": "1.6.23",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.49.0",
@@ -45,14 +44,14 @@
"ollama-ai-provider-v2": "^3.6.0",
"react": "^19.2.7",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.2",
"sharp": "^0.35.3",
"ts-pattern": "^5.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -1 +1 @@
export type { FeatureFlags } from "./service";
export type { FeatureFlags } from "./router";
+14 -3
View File
@@ -1,7 +1,12 @@
import type { FeatureFlags } from "./service";
import z from "zod";
import { env } from "@reactive-resume/env/server";
import { publicProcedure } from "../../context";
import { flagsService } from "./service";
export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
showSponsors: boolean;
};
export const flagsRouter = {
get: publicProcedure
@@ -22,5 +27,11 @@ export const flagsRouter = {
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
}),
)
.handler((): FeatureFlags => flagsService.getFlags()),
.handler(
(): FeatureFlags => ({
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
showSponsors: env.FLAG_SHOW_SPONSORS,
}),
),
};
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_DISABLE_SIGNUPS: false,
FLAG_DISABLE_EMAIL_AUTH: false,
FLAG_SHOW_SPONSORS: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { flagsService } = await import("./service");
describe("flagsService.getFlags", () => {
it("reads disableSignups + disableEmailAuth from env", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
expect(flagsService.getFlags()).toEqual({
disableSignups: false,
disableEmailAuth: false,
showSponsors: false,
});
});
it("returns disableSignups=true when env flag is set", () => {
envMock.FLAG_DISABLE_SIGNUPS = true;
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
expect(flagsService.getFlags()).toEqual({
disableSignups: true,
disableEmailAuth: false,
showSponsors: false,
});
});
it("returns disableEmailAuth=true when env flag is set", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_DISABLE_EMAIL_AUTH = true;
expect(flagsService.getFlags()).toEqual({
disableSignups: false,
disableEmailAuth: true,
showSponsors: false,
});
});
it("returns showSponsors=true when env flag is set", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
envMock.FLAG_SHOW_SPONSORS = true;
expect(flagsService.getFlags()).toEqual({
disableSignups: false,
disableEmailAuth: false,
showSponsors: true,
});
});
it("reads the latest env values on every call (no stale cache)", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_SHOW_SPONSORS = false;
const before = flagsService.getFlags();
envMock.FLAG_DISABLE_SIGNUPS = true;
envMock.FLAG_SHOW_SPONSORS = true;
const after = flagsService.getFlags();
expect(before.disableSignups).toBe(false);
expect(after.disableSignups).toBe(true);
expect(before.showSponsors).toBe(false);
expect(after.showSponsors).toBe(true);
});
});
@@ -1,15 +0,0 @@
import { env } from "@reactive-resume/env/server";
export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
showSponsors: boolean;
};
export const flagsService = {
getFlags: (): FeatureFlags => ({
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
showSponsors: env.FLAG_SHOW_SPONSORS,
}),
};
@@ -1,2 +0,0 @@
export { downloadResumePdfProcedure } from "./export";
export { resumeService } from "./service";
+6 -7
View File
@@ -5,7 +5,6 @@
"private": true,
"exports": {
"./config": "./src/config.ts",
"./functions": "./src/functions.ts",
"./types": "./src/types.ts"
},
"scripts": {
@@ -16,17 +15,17 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@better-auth/api-key": "^1.6.22",
"@better-auth/drizzle-adapter": "^1.6.22",
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.22",
"@better-auth/passkey": "^1.6.22",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/email": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"bcrypt": "^6.0.0",
"better-auth": "1.6.22",
"better-auth": "1.6.23",
"drizzle-orm": "1.0.0-rc.4",
"jose": "^6.2.3",
"react": "^19.2.7",
@@ -36,7 +35,7 @@
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const authMock = vi.hoisted(() => ({
api: {
getSession: vi.fn(),
},
}));
vi.mock("./config", () => ({ auth: authMock }));
const { getSession } = await import("./functions");
describe("getSession", () => {
it("delegates to auth.api.getSession with the provided request headers", async () => {
const headers = new Headers({ authorization: "Bearer abc" });
authMock.api.getSession.mockResolvedValueOnce({ user: { id: "u1" }, session: { id: "s1" } });
const result = await getSession(headers);
expect(authMock.api.getSession).toHaveBeenCalledWith({ headers });
expect(result).toMatchObject({ user: { id: "u1" } });
});
it("returns null when better-auth returns no session", async () => {
authMock.api.getSession.mockResolvedValueOnce(null);
const result = await getSession(new Headers());
expect(result).toBeNull();
});
});
-7
View File
@@ -1,7 +0,0 @@
import type { AuthSession } from "./types";
import { auth } from "./config";
export async function getSession(headers: Headers): Promise<AuthSession | null> {
const result = await auth.api.getSession({ headers });
return result as AuthSession | null;
}
+1 -1
View File
@@ -20,7 +20,7 @@ function makeDrizzleClient() {
return drizzle({ client: getPool(), relations });
}
export function createDatabase() {
function createDatabase() {
if (!globalThis.__drizzle) {
globalThis.__drizzle = makeDrizzleClient();
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { generateId } from "@reactive-resume/utils/string";
import { user } from "./auth";
import { resume } from "./resume";
export type AgentUiMessage = Record<string, unknown>;
type AgentUiMessage = Record<string, unknown>;
type StoredJsonPatchOperation =
| { op: "add"; path: string; value: unknown }
| { op: "remove"; path: string }
+2 -3
View File
@@ -16,13 +16,12 @@
"dependencies": {
"@reactive-resume/utils": "workspace:*",
"@t3-oss/env-core": "^0.13.11",
"dotenv": "^17.4.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.0.1",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@types/node": "^26.1.0",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { isAbsolute, join } from "node:path";
import { createEnv } from "@t3-oss/env-core";
import { config } from "dotenv";
import { z } from "zod";
import { findWorkspaceRoot } from "@reactive-resume/utils/monorepo.node";
const workspaceRoot = findWorkspaceRoot();
if (workspaceRoot) {
config({ path: join(workspaceRoot, ".env"), quiet: true });
try {
// ponytail: native stand-in for dotenv. loadEnvFile throws when .env is absent
// (e.g. production with injected env), and existing process.env still wins — so just tolerate a miss.
process.loadEnvFile(join(workspaceRoot, ".env"));
} catch {
// no .env file — rely on the ambient process environment
}
}
export const env = createEnv({
-114
View File
@@ -1,13 +1,9 @@
import { describe, expect, it } from "vitest";
import {
buildResumeFontFamily,
fontList,
getFallbackWebFontFamilies,
getFont,
getFontDisplayName,
getFontSearchKeywords,
getLoadableWebFontWeights,
getPdfCjkFallbackFontFamily,
getPdfFallbackFontFamilies,
getWebFont,
getWebFontSource,
@@ -150,20 +146,6 @@ describe("getWebFontSource", () => {
});
});
describe("getPdfCjkFallbackFontFamily", () => {
it("returns Noto Sans SC for sans-serif/standard PDF fonts", () => {
expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC");
});
it("returns Noto Serif SC for serif fonts", () => {
expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC");
});
it("returns null when family already is the CJK fallback", () => {
expect(getPdfCjkFallbackFontFamily("Noto Sans SC")).toBeNull();
});
});
describe("getPdfFallbackFontFamilies", () => {
it("puts the Korean Noto font first for the ko-KR locale (Hangul needs KR, not SC)", () => {
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ko-KR" })).toEqual(["Noto Serif KR", "Noto Serif SC"]);
@@ -234,102 +216,6 @@ describe("getPdfFallbackFontFamilies", () => {
});
});
describe("getFallbackWebFontFamilies", () => {
it("returns empty array for standard PDF fonts", () => {
expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]);
expect(getFallbackWebFontFamilies("Courier")).toEqual([]);
expect(getFallbackWebFontFamilies("Times-Roman")).toEqual([]);
});
it("returns the primary CJK web font for non-standard, non-CJK families", () => {
// e.g. Roboto (sans-serif) → Noto Sans SC fallback
const roboto = getWebFont("Roboto");
if (roboto) {
expect(getFallbackWebFontFamilies("Roboto")).toEqual(["Noto Sans SC"]);
}
});
it("returns empty when family is already its primary CJK fallback", () => {
expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]);
});
});
describe("getLoadableWebFontWeights", () => {
it("returns empty array for unknown fonts", () => {
expect(getLoadableWebFontWeights("definitely-not-a-font", ["400"])).toEqual([]);
});
it("returns matching weights when preferred weights are available", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const result = getLoadableWebFontWeights("Roboto", ["400", "700"]);
for (const weight of result) {
expect(roboto.weights).toContain(weight);
}
}
});
it("falls back to default weights when no preferences match", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const result = getLoadableWebFontWeights("Roboto", ["999"]);
expect(result.length).toBeGreaterThan(0);
}
});
it("deduplicates preferred weights", () => {
const roboto = getWebFont("Roboto");
if (roboto?.weights.includes("400")) {
const result = getLoadableWebFontWeights("Roboto", ["400", "400"]);
expect(result).toEqual(["400"]);
}
});
});
describe("buildResumeFontFamily", () => {
it("wraps the primary family in single quotes", () => {
const result = buildResumeFontFamily("Roboto");
expect(result.startsWith("'Roboto',")).toBe(true);
});
it("includes generic sans-serif fallback by default", () => {
const result = buildResumeFontFamily("Roboto");
expect(result.endsWith("sans-serif")).toBe(true);
});
it("uses serif fallback for serif-category fonts", () => {
expect(buildResumeFontFamily("Times-Roman").endsWith("serif")).toBe(true);
});
it("includes system-ui and Segoe UI fallbacks", () => {
const result = buildResumeFontFamily("Roboto");
expect(result).toContain("system-ui");
expect(result).toContain("Segoe UI");
});
it("includes CJK fallbacks for sans-serif fonts", () => {
const result = buildResumeFontFamily("Roboto");
expect(result).toContain("Noto Sans SC");
});
it("includes CJK serif fallbacks for serif fonts", () => {
const result = buildResumeFontFamily("Times-Roman");
expect(result).toContain("Noto Serif SC");
});
it("does not duplicate primary family in fallback list", () => {
const result = buildResumeFontFamily("Noto Sans SC");
// Family should appear once
const occurrences = result.split("Noto Sans SC").length - 1;
expect(occurrences).toBe(1);
});
it("escapes single quotes in family names", () => {
const result = buildResumeFontFamily("Bob's Font");
expect(result).toContain("Bob\\'s Font");
});
});
describe("legacy font compatibility (#2989)", () => {
it.each([
["Times New Roman", "Times-Roman"],
+4 -111
View File
@@ -3,11 +3,11 @@ import { unique } from "@reactive-resume/utils/field";
import { getLocaleScript, isCjkScript } from "@reactive-resume/utils/locale";
import webFontListJSON from "./webfontlist.json";
export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
export type FontWeight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
export type FontFileWeight = FontWeight | `${FontWeight}italic`;
type FontFileWeight = FontWeight | `${FontWeight}italic`;
export type StandardFont = {
type StandardFont = {
type: "standard";
category: FontCategory;
family: string;
@@ -23,7 +23,7 @@ export type WebFont = {
files: Record<FontFileWeight, string>;
};
export type FontRecord = StandardFont | WebFont;
type FontRecord = StandardFont | WebFont;
const preferredChineseFontFamilies = [
"Noto Sans SC",
@@ -64,25 +64,6 @@ const fontDisplayNames: Partial<Record<string, string>> = {
"ZCOOL QingKe HuangYou": "站酷庆科黄油体",
};
const resumeCjkSansFontFallbacks = [
"Noto Sans SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"SimHei",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
] as const;
const resumeCjkSerifFontFallbacks = [
"Noto Serif SC",
"Songti SC",
"SimSun",
"Source Han Serif SC",
"KaiTi",
"FangSong",
] as const;
// Per-script Noto web font, split by serif/sans category. These match the
// actual writing system: Hangul lives only in the KR fonts, Kana only in JP,
// Arabic glyphs only in the Arabic fonts, etc. — so a Latin or Simplified-
@@ -100,24 +81,6 @@ const scriptFonts: Record<Script, { serif: string; sansSerif: string }> = {
thai: { serif: "Noto Sans Thai", sansSerif: "Noto Sans Thai" },
};
const genericFontFamilies = new Set([
"-apple-system",
"BlinkMacSystemFont",
"cursive",
"emoji",
"fantasy",
"fangsong",
"math",
"monospace",
"sans-serif",
"serif",
"system-ui",
"ui-monospace",
"ui-rounded",
"ui-sans-serif",
"ui-serif",
]);
export const webFontList = webFontListJSON as WebFont[];
export const webFontMap = new Map<string, WebFont>(webFontList.map((font) => [font.family, font]));
export const standardFontList = standardPdfFontList.filter((font) => !webFontMap.has(font.family));
@@ -131,11 +94,6 @@ function orderFonts(fonts: FontRecord[]) {
});
}
function toCSSFontFamilyToken(fontFamily: string) {
if (genericFontFamilies.has(fontFamily)) return fontFamily;
return `'${fontFamily.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}
export const fontList = orderFonts([...standardFontList, ...webFontList]);
for (const font of fontList) {
@@ -182,15 +140,6 @@ export function getFontSearchKeywords(family: string) {
);
}
function getCjkFallbacksByCategory(category: FontCategory | null) {
return category === "serif" ? resumeCjkSerifFontFallbacks : resumeCjkSansFontFallbacks;
}
function getPrimaryCjkWebFont(family: string) {
const category = getFontCategory(family);
return category === "serif" ? "Noto Serif SC" : "Noto Sans SC";
}
function getScriptFont(script: Script, category: FontCategory | null) {
const variants = scriptFonts[script];
return category === "serif" ? variants.serif : variants.sansSerif;
@@ -216,13 +165,6 @@ export function sortFontWeights<T extends string>(fontWeights: T[]): T[] {
return [...fontWeights].sort((a, b) => Number(a) - Number(b));
}
export function getFallbackWebFontFamilies(family: string) {
if (isStandardPdfFontFamily(family)) return [];
const fallback = getPrimaryCjkWebFont(family);
return fallback === family ? [] : [fallback];
}
/**
* Returns an ordered stack of Noto web fonts to register as glyph-level
* fallbacks for PDF rendering. react-pdf resolves the font per-codepoint
@@ -252,52 +194,3 @@ export function getPdfFallbackFontFamilies(
.filter((candidate) => candidate !== family)
.filter((candidate) => Boolean(getWebFont(candidate)));
}
/**
* Back-compat single-font resolver kept for the public `@reactive-resume/fonts`
* surface: returns the Simplified Chinese fallback (Noto Sans/Serif SC) for a
* family, or `null` when the family already is that fallback.
*/
export function getPdfCjkFallbackFontFamily(family: string): string | null {
const fallback = getPrimaryCjkWebFont(family);
if (fallback === family) return null;
if (!getWebFont(fallback)) return null;
return fallback;
}
export function getLoadableWebFontWeights(family: string, preferredWeights: string[]) {
const font = webFontMap.get(family);
if (!font) return [];
const availableWeights = new Set<FontWeight>(font.weights);
const matchingWeights = unique(preferredWeights).filter((weight): weight is FontWeight =>
availableWeights.has(weight as FontWeight),
);
if (matchingWeights.length > 0) return matchingWeights;
const defaultWeights = ["400", "500", "600", "700"].filter((weight): weight is FontWeight =>
availableWeights.has(weight as FontWeight),
);
if (defaultWeights.length > 0) return defaultWeights.slice(0, 2);
return font.weights.slice(0, 2);
}
export function buildResumeFontFamily(fontFamily: string) {
const category = getFontCategory(fontFamily);
const genericFallback = category === "serif" ? "serif" : "sans-serif";
return unique([
fontFamily,
...getCjkFallbacksByCategory(category),
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
genericFallback,
])
.map(toCSSFontFamilyToken)
.join(", ");
}
+3 -5
View File
@@ -5,11 +5,9 @@
"private": true,
"exports": {
"./browser": "./src/browser.tsx",
"./context": "./src/context.tsx",
"./document": "./src/document.tsx",
"./section-title": "./src/section-title.ts",
"./server": "./src/server.tsx",
"./templates": "./src/templates/index.ts"
"./server": "./src/server.tsx"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
@@ -27,7 +25,7 @@
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"cjk-regex": "^3.4.0",
"node-html-parser": "^8.0.3",
"node-html-parser": "^8.0.4",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.7",
"react-pdf-html": "^2.1.5",
@@ -37,7 +35,7 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -2,8 +2,8 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { SectionTitleResolver } from "./section-title";
import { createElement } from "react";
import { pdf } from "#react-pdf-renderer";
import { ResumeDocument } from "./document";
import { pdf } from "./renderer";
type CreateResumePdfBlobOptions = {
data: ResumeData;
+1 -1
View File
@@ -11,7 +11,7 @@ type RenderContextValue = ResumeData & {
const RenderContext = createContext<RenderContextValue | null>(null);
export type RenderProviderProps = {
type RenderProviderProps = {
data: ResumeData;
resolveSectionTitle?: SectionTitleResolver | undefined;
children: ReactNode;
+2 -2
View File
@@ -4,9 +4,9 @@ import type { Locale } from "@reactive-resume/utils/locale";
import type { ComponentType } from "react";
import type { SectionTitleResolver } from "./section-title";
import { useMemo } from "react";
import { Document } from "#react-pdf-renderer";
import { RenderProvider } from "./context";
import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts";
import { Document } from "./renderer";
import { getTemplatePage } from "./templates";
export type TemplatePageProps = {
@@ -16,7 +16,7 @@ export type TemplatePageProps = {
export type TemplatePage = ComponentType<TemplatePageProps>;
export type ResumeDocumentProps = {
type ResumeDocumentProps = {
data: ResumeData;
template: Template;
resolveSectionTitle?: SectionTitleResolver | undefined;
@@ -2,7 +2,7 @@ import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getWebFontSource } from "@reactive-resume/fonts";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Font } from "../renderer";
import { Font } from "#react-pdf-renderer";
const typography = {
body: {
+2 -2
View File
@@ -11,7 +11,7 @@ import {
sortFontWeights,
} from "@reactive-resume/fonts";
import { isCJKLocale } from "@reactive-resume/utils/locale";
import { Font } from "../renderer";
import { Font } from "#react-pdf-renderer";
type FontWeightRange = {
lowest: number;
@@ -26,7 +26,7 @@ const preferredFallbackFontWeights = ["400", "700", "600", "500"] satisfies Font
// `fontFamily` is widened to `string | string[]` so react-pdf can do
// glyph-level font fallback for CJK characters (#2986).
export type PdfTypography = Omit<Typography, "body" | "heading"> & {
type PdfTypography = Omit<Typography, "body" | "heading"> & {
body: Omit<Typography["body"], "fontFamily"> & { fontFamily: string | string[] };
heading: Omit<Typography["heading"], "fontFamily"> & { fontFamily: string | string[] };
};
-12
View File
@@ -1,12 +0,0 @@
export {
Document,
Font,
Image,
Link,
Page,
pdf,
renderToBuffer,
StyleSheet,
Text,
View,
} from "#react-pdf-renderer";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
export type SectionTitleResolverInput = {
type SectionTitleResolverInput = {
sectionId: string;
locale: string;
sectionKind: "summary" | "builtin" | "custom";
+1 -1
View File
@@ -2,8 +2,8 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { SectionTitleResolver } from "./section-title";
import { createElement } from "react";
import { renderToBuffer } from "#react-pdf-renderer";
import { ResumeDocument } from "./document";
import { renderToBuffer } from "./renderer";
type CreateResumePdfFileOptions = {
data: ResumeData;
@@ -9,8 +9,8 @@ import type {
} from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -2,9 +2,10 @@ import type { Style } from "@react-pdf/types";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { getPrimaryTint } from "../shared/color-helpers";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { getFeaturedSummaryLayout } from "../shared/featured-summary";
@@ -161,16 +162,6 @@ const Header = ({ styles, colors }: DitgarHeaderProps) => {
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useDitgarTemplate = (): DitgarTemplate => {
const { picture, metadata, rtl } = useRender();
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -2,9 +2,10 @@ import type { Style } from "@react-pdf/types";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Fragment, useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { getPrimaryTint } from "../shared/color-helpers";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { getFeaturedSummaryLayout } from "../shared/featured-summary";
@@ -158,16 +159,6 @@ const Header = ({ styles, colors }: GengarHeaderProps) => {
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useGengarTemplate = (): GengarTemplate => {
const { picture, metadata, rtl } = useRender();
@@ -2,9 +2,10 @@ import type { Style } from "@react-pdf/types";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { getPrimaryTint } from "../shared/color-helpers";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -138,16 +139,6 @@ const Header = ({ styles }: GlalieHeaderProps) => {
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useGlalieTemplate = (): GlalieTemplate => {
const { picture, metadata, rtl } = useRender();
+1 -19
View File
@@ -34,24 +34,6 @@ export const templatePages: Partial<Record<Template, TemplatePage>> = {
scizor: ScizorPage,
};
export const defaultTemplatePage = AzurillPage;
const defaultTemplatePage = AzurillPage;
export const getTemplatePage = (template: Template): TemplatePage => templatePages[template] ?? defaultTemplatePage;
export {
AzurillPage,
BronzorPage,
ChikoritaPage,
DitgarPage,
DittoPage,
GengarPage,
GlaliePage,
KakunaPage,
LaprasPage,
LeafishPage,
MeowthPage,
OnyxPage,
PikachuPage,
RhyhornPage,
ScizorPage,
};
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -2,9 +2,10 @@ import type { Style } from "@react-pdf/types";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { getPrimaryTint as getPrimaryAlpha } from "../shared/color-helpers";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -134,16 +135,6 @@ const Header = ({ styles }: LeafishHeaderProps) => {
);
};
const getPrimaryAlpha = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useLeafishTemplate = (): LeafishTemplate => {
const { picture, metadata, rtl } = useRender();
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
+1 -1
View File
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -4,8 +4,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -3,8 +3,8 @@ import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { Image, Page, StyleSheet, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Image, Page, StyleSheet, View } from "../../renderer";
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
@@ -0,0 +1,12 @@
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
/** Returns the primary color as an `rgba()` string at the given opacity, falling back to a hex color. */
export const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
+1 -1
View File
@@ -16,7 +16,7 @@ type SectionTimelineInput = {
columns: unknown;
};
export type SectionItemsLayout = {
type SectionItemsLayout = {
columns: number;
containerStyle: Style;
rowStyle: Style | undefined;
@@ -1,7 +1,7 @@
import type { Style } from "@react-pdf/types";
import type { CustomField } from "@reactive-resume/schema/resume/data";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { View } from "../../renderer";
import { View } from "#react-pdf-renderer";
import { getCustomFieldLinkUrl, getWebsiteDisplayText } from "./contact";
import { Icon, Link, Text } from "./primitives";
@@ -1,8 +1,8 @@
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 { View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveStyleFontSize } from "./icon-size";
import { getTemplateMetrics } from "./metrics";
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
type PageMetricsInput = Pick<ResumeData["metadata"]["page"], "gapX" | "gapY" | "marginX" | "marginY">;
export type TemplateMetrics = {
type TemplateMetrics = {
page: {
paddingHorizontal: number;
paddingVertical: number;
@@ -2,8 +2,8 @@ import type { Style } from "@react-pdf/types";
import type { ComponentProps } from "react";
import type { StyleInput } from "./styles";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { Link as PdfLink, Text as PdfText, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
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";
@@ -1,7 +1,7 @@
import type { ReactElement } from "react";
import { describe, expect, it } from "vitest";
import { renderHtml } from "react-pdf-html";
import { Text as PdfText } from "../../renderer";
import { Text as PdfText } from "#react-pdf-renderer";
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
type PdfElement = ReactElement<{ children?: unknown; element?: { tag: string } }>;
@@ -1,7 +1,7 @@
import type { Style } from "@react-pdf/types";
import type { ReactNode } from "react";
import { createElement } from "react";
import { Text as PdfText } from "../../renderer";
import { Text as PdfText } from "#react-pdf-renderer";
import {
getRichTextEdgeTrimStyle,
isRichTextElementInsideListItem,
@@ -2,8 +2,8 @@ import type { Style } from "@react-pdf/types";
import type { ReactElement, ReactNode } from "react";
import { cloneElement, isValidElement } from "react";
import { Html } from "react-pdf-html";
import { Text as PdfText, View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { Text as PdfText, View } from "../../renderer";
import { useSectionStyleRule, useTemplateStyle } from "./context";
import { convertPseudoBulletParagraphs, normalizeRichTextHtml } from "./rich-text-html";
import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Style } from "@react-pdf/types";
export type RtlStyleHelpers = {
type RtlStyleHelpers = {
rtl: boolean;
pageDirection: "ltr" | "rtl";
row: "row" | "row-reverse";
@@ -21,8 +21,8 @@ import type { StyleInput, TemplatePlacement } from "./styles";
import type { CustomItemSection, ItemSection } from "./types";
import { Children, createContext, isValidElement, use } from "react";
import { match } from "ts-pattern";
import { View } from "#react-pdf-renderer";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { getResumeSectionIcon } from "../../section-icon";
import { getResumeSectionTitle } from "../../section-title";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
+1 -1
View File
@@ -33,7 +33,7 @@ export const mergeLinkStyles = (options: LinkStyleOptions = {}, ...styles: Style
export const headerNameLineHeight = 1.3;
export type ResolvePlacementColorOptions = {
type ResolvePlacementColorOptions = {
placement: TemplatePlacement;
defaultForeground: string;
sidebarForeground?: string | undefined;
+1 -25
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, createResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
import { applyResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
describe("jsonPatchOperationSchema", () => {
it("validates add op", () => {
@@ -54,30 +54,6 @@ describe("jsonPatchOperationSchema", () => {
});
});
describe("createResumePatches", () => {
it("returns empty array when documents are equal", () => {
const patches = createResumePatches(defaultResumeData, defaultResumeData);
expect(patches).toEqual([]);
});
it("emits a replace op when a scalar field changes", () => {
const next = { ...defaultResumeData, basics: { ...defaultResumeData.basics, name: "Alice" } };
const patches = createResumePatches(defaultResumeData, next);
expect(patches.some((p) => p.op === "replace" && p.path === "/basics/name")).toBe(true);
});
it("captures multiple changes in distinct operations", () => {
const next = {
...defaultResumeData,
basics: { ...defaultResumeData.basics, name: "Alice", email: "alice@example.com" },
};
const patches = createResumePatches(defaultResumeData, next);
expect(patches.length).toBeGreaterThanOrEqual(2);
});
});
describe("applyResumePatches", () => {
it("applies a single replace op", () => {
const result = applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
-4
View File
@@ -20,10 +20,6 @@ export const jsonPatchOperationSchema = z.discriminatedUnion("op", [
export type JsonPatchOperation = z.infer<typeof jsonPatchOperationSchema>;
export function createResumePatches(previous: ResumeData, next: ResumeData): JsonPatchOperation[] {
return z.array(jsonPatchOperationSchema).parse(jsonpatch.compare(previous, next));
}
/**
* A structured error thrown when a JSON Patch operation fails.
* Contains only the relevant details -- never the full document tree.
+1 -2
View File
@@ -5,7 +5,6 @@
"private": true,
"exports": {
"./icons": "./src/icons.ts",
"./page": "./src/page.ts",
"./resume/*": "./src/resume/*.ts",
"./schema.json": "./schema.json",
"./templates": "./src/templates.ts"
@@ -22,7 +21,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { pageDimensionsAsMillimeters, pageDimensionsAsPixels } from "./page";
describe("pageDimensionsAsPixels", () => {
it("defines a4 dimensions matching ISO 216 at ~96 DPI", () => {
expect(pageDimensionsAsPixels.a4).toEqual({ width: 794, height: 1123 });
});
it("defines US letter dimensions", () => {
expect(pageDimensionsAsPixels.letter).toEqual({ width: 816, height: 1056 });
});
it("defines free-form dimensions same as a4", () => {
expect(pageDimensionsAsPixels["free-form"]).toEqual({ width: 794, height: 1123 });
});
it("uses portrait orientation (height > width) for all formats", () => {
expect(pageDimensionsAsPixels.a4.height).toBeGreaterThan(pageDimensionsAsPixels.a4.width);
expect(pageDimensionsAsPixels.letter.height).toBeGreaterThan(pageDimensionsAsPixels.letter.width);
});
});
describe("pageDimensionsAsMillimeters", () => {
it("defines a4 dimensions in millimeters per ISO 216", () => {
expect(pageDimensionsAsMillimeters.a4).toEqual({ width: "210mm", height: "297mm" });
});
it("defines letter dimensions in millimeters", () => {
expect(pageDimensionsAsMillimeters.letter).toEqual({ width: "216mm", height: "279mm" });
});
it("appends 'mm' unit suffix to all values", () => {
for (const [, dims] of Object.entries(pageDimensionsAsMillimeters)) {
expect(dims.width).toMatch(/mm$/);
expect(dims.height).toMatch(/mm$/);
}
});
it("provides all three formats (a4, letter, free-form)", () => {
expect(Object.keys(pageDimensionsAsMillimeters).sort()).toEqual(["a4", "free-form", "letter"]);
});
});
-29
View File
@@ -1,29 +0,0 @@
export const pageDimensionsAsPixels = {
a4: {
width: 794,
height: 1123,
},
letter: {
width: 816,
height: 1056,
},
"free-form": {
width: 794,
height: 1123,
},
} as const;
export const pageDimensionsAsMillimeters = {
a4: {
width: "210mm",
height: "297mm",
},
letter: {
width: "216mm",
height: "279mm",
},
"free-form": {
width: "210mm",
height: "297mm",
},
} as const;
+2 -23
View File
@@ -1,12 +1,12 @@
import z from "zod";
export const analysisDimensionSchema = z.object({
const analysisDimensionSchema = z.object({
dimension: z.string().min(1),
score: z.number().int().min(0).max(100),
rationale: z.string().min(1),
});
export const analysisSuggestionSchema = z.object({
const analysisSuggestionSchema = z.object({
title: z.string().min(1),
impact: z.enum(["high", "medium", "low"]),
why: z.string().min(1),
@@ -21,27 +21,6 @@ export const resumeAnalysisSchema = z.object({
strengths: z.array(z.string().min(1)).max(10),
});
export const resumeAnalysisOutputSchema = z.object({
overallScore: z.number(),
scorecard: z.array(
z.object({
dimension: z.string(),
score: z.number(),
rationale: z.string(),
}),
),
suggestions: z.array(
z.object({
title: z.string(),
impact: z.enum(["high", "medium", "low"]),
why: z.string(),
exampleRewrite: z.string().nullable(),
copyPrompt: z.string(),
}),
),
strengths: z.array(z.string()),
});
export const storedResumeAnalysisSchema = resumeAnalysisSchema.extend({
updatedAt: z.coerce.date(),
modelMeta: z.object({
+29 -31
View File
@@ -1,13 +1,13 @@
import z from "zod";
import { templateSchema } from "../templates";
export const iconSchema = z
const iconSchema = z
.string()
.describe(
"The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available.",
);
export const iconColorSchema = z
const iconColorSchema = z
.string()
.catch("")
.describe(
@@ -19,7 +19,7 @@ export const websiteSchema = z.object({
label: z.string().describe("The label to display for the URL. Leave blank to display the URL as-is."),
});
export const itemWebsiteSchema = websiteSchema
const itemWebsiteSchema = websiteSchema
.extend({
inlineLink: z
.boolean()
@@ -105,7 +105,7 @@ export const summarySchema = z.object({
content: z.string().describe("The content of the summary of the resume. This should be a HTML-formatted string."),
});
export const baseItemSchema = z.object({
const baseItemSchema = z.object({
id: z.string().describe("The unique identifier for the item. Usually generated as a UUID."),
hidden: z.boolean().describe("Whether to hide the item from the resume."),
});
@@ -143,7 +143,7 @@ export const educationItemSchema = baseItemSchema.extend({
description: z.string().describe("The description of the education. This should be a HTML-formatted string."),
});
export const roleItemSchema = z.object({
const roleItemSchema = z.object({
id: z.string().describe("The unique identifier for the role. Usually generated as a UUID."),
position: z.string().describe("The position or job title for this role."),
period: z.string().describe("The period of time this role was held."),
@@ -287,55 +287,55 @@ export const baseSectionSchema = z.object({
hidden: z.boolean().describe("Whether to hide the section from the resume."),
});
export const awardsSectionSchema = baseSectionSchema.extend({
const awardsSectionSchema = baseSectionSchema.extend({
items: z.array(awardItemSchema).describe("The items to display in the awards section."),
});
export const certificationsSectionSchema = baseSectionSchema.extend({
const certificationsSectionSchema = baseSectionSchema.extend({
items: z.array(certificationItemSchema).describe("The items to display in the certifications section."),
});
export const educationSectionSchema = baseSectionSchema.extend({
const educationSectionSchema = baseSectionSchema.extend({
items: z.array(educationItemSchema).describe("The items to display in the education section."),
});
export const experienceSectionSchema = baseSectionSchema.extend({
const experienceSectionSchema = baseSectionSchema.extend({
items: z.array(experienceItemSchema).describe("The items to display in the experience section."),
});
export const interestsSectionSchema = baseSectionSchema.extend({
const interestsSectionSchema = baseSectionSchema.extend({
items: z.array(interestItemSchema).describe("The items to display in the interests section."),
});
export const languagesSectionSchema = baseSectionSchema.extend({
const languagesSectionSchema = baseSectionSchema.extend({
items: z.array(languageItemSchema).describe("The items to display in the languages section."),
});
export const profilesSectionSchema = baseSectionSchema.extend({
const profilesSectionSchema = baseSectionSchema.extend({
items: z.array(profileItemSchema).describe("The items to display in the profiles section."),
});
export const projectsSectionSchema = baseSectionSchema.extend({
const projectsSectionSchema = baseSectionSchema.extend({
items: z.array(projectItemSchema).describe("The items to display in the projects section."),
});
export const publicationsSectionSchema = baseSectionSchema.extend({
const publicationsSectionSchema = baseSectionSchema.extend({
items: z.array(publicationItemSchema).describe("The items to display in the publications section."),
});
export const referencesSectionSchema = baseSectionSchema.extend({
const referencesSectionSchema = baseSectionSchema.extend({
items: z.array(referenceItemSchema).describe("The items to display in the references section."),
});
export const skillsSectionSchema = baseSectionSchema.extend({
const skillsSectionSchema = baseSectionSchema.extend({
items: z.array(skillItemSchema).describe("The items to display in the skills section."),
});
export const volunteerSectionSchema = baseSectionSchema.extend({
const volunteerSectionSchema = baseSectionSchema.extend({
items: z.array(volunteerItemSchema).describe("The items to display in the volunteer section."),
});
export const sectionsSchema = z.object({
const sectionsSchema = z.object({
profiles: profilesSectionSchema.describe("The section to display the profiles of the author."),
experience: experienceSectionSchema.describe("The section to display the experience of the author."),
education: educationSectionSchema.describe("The section to display the education of the author."),
@@ -351,7 +351,7 @@ export const sectionsSchema = z.object({
});
export type SectionType = keyof z.infer<typeof sectionsSchema>;
export type SectionData<T extends SectionType = SectionType> = z.infer<typeof sectionsSchema>[T];
type SectionData<T extends SectionType = SectionType> = z.infer<typeof sectionsSchema>[T];
export type SectionItem<T extends SectionType = SectionType> = SectionData<T>["items"][number];
export const sectionTypeSchema = z.enum([
@@ -373,7 +373,7 @@ export const sectionTypeSchema = z.enum([
export type CustomSectionType = z.infer<typeof sectionTypeSchema>;
export const customSectionItemSchema = z.union([
const customSectionItemSchema = z.union([
// coverLetterItemSchema must come before summaryItemSchema because both have 'content',
// but coverLetterItemSchema also requires 'recipient'. If summaryItemSchema is first,
// cover letter items will match it and lose the 'recipient' field.
@@ -407,11 +407,11 @@ export const customSectionSchema = baseSectionSchema.extend({
export type CustomSection = z.infer<typeof customSectionSchema>;
export const customSectionsSchema = z.array(customSectionSchema);
const customSectionsSchema = z.array(customSectionSchema);
export const fontWeightSchema = z.enum(["100", "200", "300", "400", "500", "600", "700", "800", "900"]);
const fontWeightSchema = z.enum(["100", "200", "300", "400", "500", "600", "700", "800", "900"]);
export const typographyItemSchema = z.object({
const typographyItemSchema = z.object({
fontFamily: z.string().describe("The family of the font to use. Must be a supported resume font."),
fontWeights: z
.array(fontWeightSchema)
@@ -428,7 +428,7 @@ export const typographyItemSchema = z.object({
.describe("The line height of the font to use, defined as a multiplier of the font size (e.g. 1.5 for 1.5x)."),
});
export const pageLayoutSchema = z.object({
const pageLayoutSchema = z.object({
fullWidth: z
.boolean()
.describe(
@@ -498,7 +498,7 @@ export const colorDesignSchema = z.object({
),
});
export const designSchema = z.object({
const designSchema = z.object({
level: levelDesignSchema,
colors: colorDesignSchema,
});
@@ -508,7 +508,7 @@ export const typographySchema = z.object({
heading: typographyItemSchema.describe("The typography for the headings of the resume."),
});
export const styleSlotSchema = z.enum([
const styleSlotSchema = z.enum([
"section",
"heading",
"item",
@@ -528,7 +528,7 @@ export const styleSlotSchema = z.enum([
export type StyleSlot = z.infer<typeof styleSlotSchema>;
export const styleIntentSchema = z
const styleIntentSchema = z
.strictObject({
color: z.string().optional(),
backgroundColor: z.string().optional(),
@@ -563,7 +563,7 @@ export const styleIntentSchema = z
export type StyleIntent = z.infer<typeof styleIntentSchema>;
export const styleRuleSlotsSchema = z
const styleRuleSlotsSchema = z
.strictObject({
section: styleIntentSchema.optional(),
heading: styleIntentSchema.optional(),
@@ -585,7 +585,7 @@ export const styleRuleSlotsSchema = z
message: "At least one style slot must be configured.",
});
export const styleRuleTargetSchema = z.discriminatedUnion("scope", [
const styleRuleTargetSchema = z.discriminatedUnion("scope", [
z.strictObject({ scope: z.literal("global") }),
z.strictObject({ scope: z.literal("sectionType"), sectionType: sectionTypeSchema }),
z.strictObject({ scope: z.literal("sectionId"), sectionId: z.string().min(1) }),
@@ -646,7 +646,6 @@ export const resumeDataSchema = z.looseObject({
});
export type ResumeData = z.infer<typeof resumeDataSchema>;
export type Metadata = z.infer<typeof metadataSchema>;
export type LayoutPage = z.infer<typeof pageLayoutSchema>;
export type Typography = z.infer<typeof typographySchema>;
export type Design = z.infer<typeof designSchema>;
@@ -672,7 +671,6 @@ export type EducationSection = z.infer<typeof educationSectionSchema>;
export type ExperienceSection = z.infer<typeof experienceSectionSchema>;
export type InterestsSection = z.infer<typeof interestsSectionSchema>;
export type LanguagesSection = z.infer<typeof languagesSectionSchema>;
export type ProfilesSection = z.infer<typeof profilesSectionSchema>;
export type ProjectsSection = z.infer<typeof projectsSectionSchema>;
export type PublicationsSection = z.infer<typeof publicationsSectionSchema>;
export type ReferencesSection = z.infer<typeof referencesSectionSchema>;
@@ -1,10 +1,10 @@
export type ResolveLevelDisplaySizesOptions = {
type ResolveLevelDisplaySizesOptions = {
bodyFontSize: number;
iconFontSize?: number | undefined;
levelFontSize?: number | undefined;
};
export type LevelDisplaySizes = {
type LevelDisplaySizes = {
decorationSize: number;
levelIconExplicitSize?: number | undefined;
};
@@ -19,8 +19,6 @@ describe("Badge", () => {
["secondary"],
["destructive"],
["outline"],
["ghost"],
["link"],
] as const)("renders variant=%s without throwing", (variant) => {
render(<Badge variant={variant}>{variant}</Badge>);
expect(screen.getByText(variant)).toBeInTheDocument();
-2
View File
@@ -14,8 +14,6 @@ const badgeVariants = cva(
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
-99
View File
@@ -1,99 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
describe("Card", () => {
it("renders with data-slot='card'", () => {
render(<Card data-testid="c">x</Card>);
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "card");
});
it("defaults size to 'default'", () => {
render(<Card data-testid="c">x</Card>);
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "default");
});
it("supports size='sm'", () => {
render(
<Card data-testid="c" size="sm">
x
</Card>,
);
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "sm");
});
it("merges custom className", () => {
render(
<Card data-testid="c" className="my-custom">
x
</Card>,
);
expect(screen.getByTestId("c")).toHaveClass("my-custom");
});
});
describe("Card subcomponents", () => {
it.each([
["CardHeader", CardHeader, "card-header"],
["CardTitle", CardTitle, "card-title"],
["CardDescription", CardDescription, "card-description"],
["CardAction", CardAction, "card-action"],
["CardContent", CardContent, "card-content"],
["CardFooter", CardFooter, "card-footer"],
] as const)("%s renders with data-slot='%s'", (_name, Component, slot) => {
render(
<Component data-testid="el">
<span>child</span>
</Component>,
);
expect(screen.getByTestId("el")).toHaveAttribute("data-slot", slot);
});
it("CardHeader merges custom className", () => {
render(
<CardHeader data-testid="h" className="my-class">
x
</CardHeader>,
);
expect(screen.getByTestId("h")).toHaveClass("my-class");
});
it("CardTitle merges custom className", () => {
render(
<CardTitle data-testid="t" className="my-class">
x
</CardTitle>,
);
expect(screen.getByTestId("t")).toHaveClass("my-class");
});
it("CardFooter merges custom className", () => {
render(
<CardFooter data-testid="f" className="my-class">
x
</CardFooter>,
);
expect(screen.getByTestId("f")).toHaveClass("my-class");
});
});
describe("Card composition", () => {
it("composes a full card", () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>Action</CardAction>
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(screen.getByText("Action")).toBeInTheDocument();
expect(screen.getByText("Content")).toBeInTheDocument();
expect(screen.getByText("Footer")).toBeInTheDocument();
});
});
-69
View File
@@ -1,69 +0,0 @@
import type * as React from "react";
import { cn } from "@reactive-resume/utils/style";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-card-foreground text-sm ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 has-data-[slot=card-footer]:pb-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading font-medium text-base leading-snug group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-4 group-data-[size=sm]/card:px-3", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", className)}
{...props}
/>
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -1,47 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Checkbox } from "./checkbox";
describe("Checkbox", () => {
it("renders with data-slot='checkbox'", () => {
render(<Checkbox data-testid="cb" />);
expect(screen.getByTestId("cb")).toHaveAttribute("data-slot", "checkbox");
});
it("defaults to unchecked", () => {
render(<Checkbox data-testid="cb" />);
const cb = screen.getByTestId("cb");
// base-ui sets aria-checked
expect(cb.getAttribute("aria-checked")).toBe("false");
});
it("renders as checked via defaultChecked", () => {
render(<Checkbox data-testid="cb" defaultChecked />);
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
});
it("can be controlled via checked prop", () => {
render(<Checkbox data-testid="cb" checked onCheckedChange={() => {}} />);
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
});
it("calls onCheckedChange when toggled", async () => {
const onChange = vi.fn();
render(<Checkbox data-testid="cb" onCheckedChange={onChange} />);
await userEvent.click(screen.getByTestId("cb"));
expect(onChange).toHaveBeenCalledOnce();
});
it("respects disabled state via aria-disabled (base-ui uses span+role)", () => {
render(<Checkbox data-testid="cb" disabled />);
const cb = screen.getByTestId("cb");
expect(cb.getAttribute("aria-disabled")).toBe("true");
expect(cb).toHaveAttribute("data-disabled");
});
it("merges custom className", () => {
render(<Checkbox data-testid="cb" className="my-cb" />);
expect(screen.getByTestId("cb")).toHaveClass("my-cb");
});
});
-25
View File
@@ -1,25 +0,0 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { CheckIcon } from "@phosphor-icons/react";
import { cn } from "@reactive-resume/utils/style";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 group-has-disabled/field:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
-4
View File
@@ -1,4 +0,0 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
@@ -1,71 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue } from "./progress";
describe("Progress", () => {
it("renders with data-slot='progress'", () => {
render(<Progress data-testid="p" value={50} />);
expect(screen.getByTestId("p")).toHaveAttribute("data-slot", "progress");
});
it("auto-renders track and indicator children", () => {
const { container } = render(<Progress value={50} data-testid="p" />);
expect(container.querySelector("[data-slot=progress-track]")).toBeInTheDocument();
expect(container.querySelector("[data-slot=progress-indicator]")).toBeInTheDocument();
});
it("forwards value attribute via aria-valuenow", () => {
render(<Progress data-testid="p" value={75} />);
expect(screen.getByTestId("p")).toHaveAttribute("aria-valuenow", "75");
});
it("supports custom children rendered before the track", () => {
render(
<Progress data-testid="p" value={50}>
<ProgressLabel>My Progress</ProgressLabel>
</Progress>,
);
expect(screen.getByText("My Progress")).toBeInTheDocument();
});
});
describe("ProgressLabel", () => {
it("renders with data-slot='progress-label'", () => {
render(
<Progress value={50}>
<ProgressLabel>Label</ProgressLabel>
</Progress>,
);
expect(screen.getByText("Label")).toHaveAttribute("data-slot", "progress-label");
});
});
describe("ProgressValue", () => {
it("renders with data-slot='progress-value'", () => {
render(
<Progress value={50}>
<ProgressValue />
</Progress>,
);
// The value text is rendered by base-ui — just verify the slot exists
const { container } = render(
<Progress value={50}>
<ProgressValue data-testid="v" />
</Progress>,
);
expect(container.querySelector("[data-slot=progress-value]")).toBeInTheDocument();
});
});
describe("ProgressTrack and ProgressIndicator", () => {
it("can be rendered explicitly with custom classes", () => {
const { container } = render(
<Progress value={25}>
<ProgressTrack className="track-custom">
<ProgressIndicator className="ind-custom" />
</ProgressTrack>
</Progress>,
);
expect(container.querySelectorAll("[data-slot=progress-track]")).toHaveLength(2);
});
});
-56
View File
@@ -1,56 +0,0 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@reactive-resume/utils/style";
function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn("relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", className)}
data-slot="progress-track"
{...props}
/>
);
}
function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
);
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label className={cn("font-medium text-sm", className)} data-slot="progress-label" {...props} />
);
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn("ms-auto text-muted-foreground text-sm tabular-nums", className)}
data-slot="progress-value"
{...props}
/>
);
}
export { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue };
-152
View File
@@ -1,152 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
type ConfirmRunnerProps = {
onResult: (v: boolean) => void;
title: string;
options?: Parameters<ReturnType<typeof useConfirm>>[1];
};
const ConfirmRunner = ({ onResult, title, options }: ConfirmRunnerProps) => {
const confirm = useConfirm();
return (
<button
type="button"
onClick={async () => {
const result = await confirm(title, options);
onResult(result);
}}
>
Trigger
</button>
);
};
describe("useConfirm", () => {
it("throws when used outside ConfirmDialogProvider", () => {
// React 19 logs the error rather than throwing — we use renderHook+ErrorBoundary alt:
// Use the hook in a component and check that React surfaces the throw.
expect(() =>
render(
<ConfirmRunner
title="x"
onResult={() => {
/* noop */
}}
/>,
),
).toThrow(/useConfirm must be used within/);
});
it("renders children inside provider", () => {
render(
<ConfirmDialogProvider>
<div data-testid="child">child</div>
</ConfirmDialogProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("opens an alert dialog when confirm is called", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Delete it?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Delete it?")).toBeInTheDocument();
});
it("resolves true when confirm is clicked", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Sure?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Sure?");
await userEvent.click(screen.getByText("Confirm"));
// Wait for promise to resolve
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([true]);
});
it("resolves false when cancel is clicked", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Cancel?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Cancel?");
await userEvent.click(screen.getByText("Cancel"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([false]);
});
it("renders custom confirmText and cancelText", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
options={{ confirmText: "Yes!", cancelText: "Nope" }}
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Yes!")).toBeInTheDocument();
expect(screen.getByText("Nope")).toBeInTheDocument();
});
it("renders description when provided", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
options={{ description: "This will permanently delete the record." }}
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("This will permanently delete the record.")).toBeInTheDocument();
});
it("falls back to 'Confirm' / 'Cancel' default labels", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Confirm")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
});
-101
View File
@@ -1,101 +0,0 @@
import * as React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
import { cn } from "@reactive-resume/utils/style";
interface ConfirmOptions {
description?: string;
confirmText?: string;
cancelText?: string;
}
interface ConfirmState extends ConfirmOptions {
open: boolean;
title: string;
resolve: ((value: boolean) => void) | null;
}
type ConfirmContextType = {
confirm: (title: string, options?: ConfirmOptions) => Promise<boolean>;
};
type ConfirmDialogProviderProps = {
children: React.ReactNode;
};
const ConfirmContext = React.createContext<ConfirmContextType | null>(null);
export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps) {
const [state, setState] = React.useState<ConfirmState>({
open: false,
resolve: null,
title: "",
});
const confirm = React.useCallback(async (title: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setState({
open: true,
resolve,
title,
...(options?.description !== undefined ? { description: options.description } : {}),
...(options?.confirmText !== undefined ? { confirmText: options.confirmText } : {}),
...(options?.cancelText !== undefined ? { cancelText: options.cancelText } : {}),
});
});
}, []);
const handleConfirm = React.useCallback(() => {
if (state.resolve) state.resolve(true);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const handleCancel = React.useCallback(() => {
if (state.resolve) state.resolve(false);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const contextValue = React.useMemo<ConfirmContextType>(() => ({ confirm }), [confirm]);
return (
<ConfirmContext.Provider value={contextValue}>
{children}
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription className={cn(!state.description && "sr-only")}>
{state.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{state.cancelText ?? "Cancel"}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>{state.confirmText ?? "Confirm"}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const context = React.use(ConfirmContext);
if (!context) {
throw new Error("useConfirm must be used within a <ConfirmDialogProvider />.");
}
return context.confirm;
}
+2 -2
View File
@@ -11,10 +11,10 @@ type SharedCookieOptions = {
secure?: CookieAttributes["secure"];
};
export type UseCookieOptions = SharedCookieOptions & {
type UseCookieOptions = SharedCookieOptions & {
expires?: CookieAttributes["expires"];
};
export type UseCookieRemoveOptions = SharedCookieOptions;
type UseCookieRemoveOptions = SharedCookieOptions;
export const DEFAULT_COOKIE_ATTRIBUTES = {
path: "/",
-175
View File
@@ -1,175 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { PromptDialogProvider, usePrompt } from "./use-prompt";
type PromptRunnerProps = {
onResult: (v: string | null) => void;
title: string;
options?: Parameters<ReturnType<typeof usePrompt>>[1];
};
const PromptRunner = ({ onResult, title, options }: PromptRunnerProps) => {
const prompt = usePrompt();
return (
<button
type="button"
onClick={async () => {
const result = await prompt(title, options);
onResult(result);
}}
>
Trigger
</button>
);
};
describe("usePrompt", () => {
it("throws when used outside PromptDialogProvider", () => {
expect(() =>
render(
<PromptRunner
title="x"
onResult={() => {
/* noop */
}}
/>,
),
).toThrow(/usePrompt must be used within/);
});
it("renders children inside provider", () => {
render(
<PromptDialogProvider>
<div data-testid="child">child</div>
</PromptDialogProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("opens an alert dialog when prompt is called", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="Enter name"
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Enter name")).toBeInTheDocument();
});
it("resolves with input value when confirm clicked", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="Name?" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Name?");
const input = screen.getByRole("textbox");
await userEvent.type(input, "Alice");
await userEvent.click(screen.getByText("Confirm"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual(["Alice"]);
});
it("resolves null when cancel is clicked", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="x" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("x");
await userEvent.click(screen.getByText("Cancel"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([null]);
});
it("uses defaultValue as initial input value", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ defaultValue: "default-name" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
const input = await screen.findByRole("textbox");
expect(input).toHaveValue("default-name");
});
it("submits on Enter key", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="x" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
const input = await screen.findByRole("textbox");
await userEvent.type(input, "via-enter{Enter}");
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual(["via-enter"]);
});
it("renders custom confirmText and cancelText", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ confirmText: "Save", cancelText: "Discard" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Save")).toBeInTheDocument();
expect(screen.getByText("Discard")).toBeInTheDocument();
});
it("renders description when provided", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ description: "Explain why" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Explain why")).toBeInTheDocument();
});
});
-144
View File
@@ -1,144 +0,0 @@
import * as React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { cn } from "@reactive-resume/utils/style";
type PromptOptions = {
description?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
inputProps?: Omit<React.ComponentProps<typeof Input>, "value" | "onChange" | "onKeyDown">;
};
type PromptState = PromptOptions & {
open: boolean;
title: string;
value: string;
resolve: ((value: string | null) => void) | null;
};
type PromptContextType = {
prompt: (title: string, options?: PromptOptions) => Promise<string | null>;
};
type PromptDialogProviderProps = {
children: React.ReactNode;
};
const PromptContext = React.createContext<PromptContextType | null>(null);
export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [state, setState] = React.useState<PromptState>({
open: false,
resolve: null,
title: "",
value: "",
});
const cancelText = state.cancelText ?? "Cancel";
const confirmText = state.confirmText ?? "Confirm";
React.useEffect(() => {
if (!state.open) return;
const timeoutId = window.setTimeout(() => {
if (!inputRef.current) return;
inputRef.current.focus();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [state.open]);
const prompt = React.useCallback(async (title: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
setState({
open: true,
resolve,
title,
value: options?.defaultValue ?? "",
...(options?.description !== undefined ? { description: options.description } : {}),
...(options?.defaultValue !== undefined ? { defaultValue: options.defaultValue } : {}),
...(options?.confirmText !== undefined ? { confirmText: options.confirmText } : {}),
...(options?.cancelText !== undefined ? { cancelText: options.cancelText } : {}),
...(options?.inputProps !== undefined ? { inputProps: options.inputProps } : {}),
});
});
}, []);
const handleConfirm = React.useCallback(() => {
if (state.resolve) state.resolve(state.value);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve, state.value]);
const handleCancel = React.useCallback(() => {
if (state.resolve) state.resolve(null);
setState((prev) => ({ ...prev, open: false, resolve: null }));
}, [state.resolve]);
const handleValueChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setState((prev) => ({ ...prev, value: e.target.value }));
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleConfirm();
},
[handleConfirm],
);
const contextValue = React.useMemo<PromptContextType>(() => ({ prompt }), [prompt]);
return (
<PromptContext.Provider value={contextValue}>
{children}
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription className={cn(!state.description && "sr-only")}>
{state.description}
</AlertDialogDescription>
</AlertDialogHeader>
<Input
ref={inputRef}
value={state.value}
onKeyDown={handleKeyDown}
onChange={handleValueChange}
{...state.inputProps}
/>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PromptContext.Provider>
);
}
export function usePrompt() {
const context = React.use(PromptContext);
if (!context) {
throw new Error("usePrompt must be used within a <PromptDialogProvider />.");
}
return context.prompt;
}
+2 -4
View File
@@ -13,7 +13,6 @@
"./locale": "./src/locale.ts",
"./monorepo.node": "./src/monorepo.node.ts",
"./rate-limit": "./src/rate-limit.ts",
"./sanitize": "./src/sanitize.ts",
"./string": "./src/string.ts",
"./style": "./src/style.ts",
"./url": "./src/url.ts",
@@ -30,7 +29,6 @@
"@sindresorhus/slugify": "^3.0.0",
"@uiw/color-convert": "^2.10.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.11",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.1",
@@ -38,8 +36,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.0.1",
"@typescript/native-preview": "7.0.0-dev.20260628.1",
"@types/node": "^26.1.0",
"@typescript/native-preview": "7.0.0-dev.20260703.1",
"typescript": "^6.0.3"
}
}
-70
View File
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterFieldValues } from "./field";
describe("filterFieldValues", () => {
it("returns map of fields whose values are non-empty", () => {
const fields = [{ key: "name" as const }, { key: "email" as const }];
const result = filterFieldValues({ name: "Alice", email: "alice@example.com" }, ...fields);
expect(result.size).toBe(2);
expect(result.get("name")).toEqual({ key: "name" });
expect(result.get("email")).toEqual({ key: "email" });
});
it("filters out fields with empty string values", () => {
const fields = [{ key: "name" as const }, { key: "email" as const }];
const result = filterFieldValues({ name: "Alice", email: "" }, ...fields);
expect(result.size).toBe(1);
expect(result.has("email")).toBe(false);
expect(result.has("name")).toBe(true);
});
it("filters out fields with whitespace-only values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: " " }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with null values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: null }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with undefined values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: undefined }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with missing keys", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues<"name", { key: "name" }>({}, ...fields);
expect(result.size).toBe(0);
});
it("preserves additional field properties on output", () => {
const fields = [{ key: "name" as const, label: "Name", icon: "person" }];
const result = filterFieldValues({ name: "Alice" }, ...fields);
expect(result.get("name")).toEqual({ key: "name", label: "Name", icon: "person" });
});
it("returns empty map when no fields supplied", () => {
const result = filterFieldValues({ name: "Alice" });
expect(result.size).toBe(0);
});
it("preserves field order in iteration", () => {
const fields = [{ key: "a" as const }, { key: "b" as const }, { key: "c" as const }];
const result = filterFieldValues({ a: "1", b: "2", c: "3" }, ...fields);
const keys = [...result.keys()];
expect(keys).toEqual(["a", "b", "c"]);
});
});
+1 -26
View File
@@ -1,28 +1,3 @@
type KeyedField<TKey extends string> = {
key: TKey;
};
type KeyValueMap<TKey extends string> = Partial<Record<TKey, string | null | undefined>>;
/**
* Filters keyed field descriptors to only those whose corresponding value in the given
* values object is a non-empty string.
*
* This is useful for rendering or processing only the fields with actual, non-blank content,
* e.g., when displaying optional sections in a UI or assembling objects with present values.
*
* @param values - An object mapping keys to string values (which may be empty, null, or undefined)
* @param fields - Field descriptors with { key: TKey }
* @returns An array of fields whose corresponding values[key] is a non-empty string
*/
export function filterFieldValues<TKey extends string, TField extends KeyedField<TKey>>(
values: KeyValueMap<TKey>,
...fields: TField[]
) {
const filteredFields = fields.filter((field) => Boolean(values[field.key]?.trim()));
return new Map(filteredFields.map((field) => [field.key, field] as const));
}
export function unique<T>(items: T[]) {
return items.filter((item, index) => items.indexOf(item) === index);
return [...new Set(items)];
}
+1 -43
View File
@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file";
import { downloadWithAnchor, generateFilename } from "./file";
describe("generateFilename", () => {
it("slugifies the prefix without extension", () => {
@@ -88,45 +88,3 @@ describe("downloadWithAnchor", () => {
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
});
});
describe("downloadFromUrl", () => {
let originalFetch: typeof global.fetch;
let createObjectURLSpy: ReturnType<typeof vi.fn<typeof URL.createObjectURL>>;
let revokeObjectURLSpy: ReturnType<typeof vi.fn<typeof URL.revokeObjectURL>>;
let originalCreate: typeof URL.createObjectURL;
let originalRevoke: typeof URL.revokeObjectURL;
beforeEach(() => {
originalFetch = global.fetch;
originalCreate = URL.createObjectURL;
originalRevoke = URL.revokeObjectURL;
createObjectURLSpy = vi.fn<typeof URL.createObjectURL>(() => "blob:mock-url");
revokeObjectURLSpy = vi.fn<typeof URL.revokeObjectURL>();
URL.createObjectURL = createObjectURLSpy;
URL.revokeObjectURL = revokeObjectURLSpy;
});
afterEach(() => {
global.fetch = originalFetch;
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
});
it("fetches the URL and downloads the resulting blob", async () => {
const mockBlob = new Blob(["data"], { type: "application/pdf" });
global.fetch = vi.fn(() =>
Promise.resolve(new Response(mockBlob, { status: 200 })),
) as unknown as typeof global.fetch;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await downloadFromUrl("https://example.com/file.pdf", "file.pdf");
expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.pdf");
expect(createObjectURLSpy).toHaveBeenCalled();
});
it("propagates fetch errors", async () => {
global.fetch = vi.fn(() => Promise.reject(new Error("network down"))) as unknown as typeof global.fetch;
await expect(downloadFromUrl("https://example.com", "x.pdf")).rejects.toThrow("network down");
});
});

Some files were not shown because too many files have changed in this diff Show More