mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 13:35:13 +10:00
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:
+19
-21
@@ -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,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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,4 +1,3 @@
|
||||
import "./polyfills/map-upsert";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { getRouter } from "./router";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
export type { FeatureFlags } from "./service";
|
||||
export type { FeatureFlags } from "./router";
|
||||
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function makeDrizzleClient() {
|
||||
return drizzle({ client: getPool(), relations });
|
||||
}
|
||||
|
||||
export function createDatabase() {
|
||||
function createDatabase() {
|
||||
if (!globalThis.__drizzle) {
|
||||
globalThis.__drizzle = makeDrizzleClient();
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Vendored
+2
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-2
@@ -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({
|
||||
|
||||
@@ -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
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] };
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
Document,
|
||||
Font,
|
||||
Image,
|
||||
Link,
|
||||
Page,
|
||||
pdf,
|
||||
renderToBuffer,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "#react-pdf-renderer";
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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" }]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "/",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)];
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user