diff --git a/apps/server/package.json b/apps/server/package.json index 46bb471e7..1edbd86d0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" } diff --git a/apps/web/src/components/input/url-input.tsx b/apps/web/src/components/input/url-input.tsx index c87508b3f..dca2fded1 100644 --- a/apps/web/src/components/input/url-input.tsx +++ b/apps/web/src/components/input/url-input.tsx @@ -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({ value, onChange, hideLabelBut [onChange, value], ); - const urlValue = useMemo(() => stripPrefix(value.url), [value.url]); + const urlValue = stripPrefix(value.url); return ( diff --git a/apps/web/src/features/settings/authentication/components/password.tsx b/apps/web/src/features/settings/authentication/components/password.tsx index 17374225d..1fe18ccc6 100644 --- a/apps/web/src/features/settings/authentication/components/password.tsx +++ b/apps/web/src/features/settings/authentication/components/password.tsx @@ -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) { diff --git a/apps/web/src/features/settings/authentication/components/social-provider.tsx b/apps/web/src/features/settings/authentication/components/social-provider.tsx index 6c8df288c..e3a4eb5fd 100644 --- a/apps/web/src/features/settings/authentication/components/social-provider.tsx +++ b/apps/web/src/features/settings/authentication/components/social-provider.tsx @@ -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); diff --git a/apps/web/src/features/settings/authentication/components/two-factor.tsx b/apps/web/src/features/settings/authentication/components/two-factor.tsx index 7eca8a633..4c1e16b71 100644 --- a/apps/web/src/features/settings/authentication/components/two-factor.tsx +++ b/apps/web/src/features/settings/authentication/components/two-factor.tsx @@ -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) { diff --git a/apps/web/src/hooks/use-mobile.test.tsx b/apps/web/src/hooks/use-mobile.test.tsx deleted file mode 100644 index 3cc2a5235..000000000 --- a/apps/web/src/hooks/use-mobile.test.tsx +++ /dev/null @@ -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; - __set: (matches: boolean) => void; -}; - -const createMatchMedia = (initialMatches: boolean) => { - const mqlByQuery = new Map(); - - const matchMedia = vi.fn((query: string): FakeMediaQueryList => { - let mql = mqlByQuery.get(query); - if (mql) return mql; - - const listeners = new Set(); - 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); - }); -}); diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx deleted file mode 100644 index 881d95af3..000000000 --- a/apps/web/src/hooks/use-mobile.tsx +++ /dev/null @@ -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, - ); -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 00e770833..9f0563078 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,4 +1,3 @@ -import "./polyfills/map-upsert"; import { RouterProvider } from "@tanstack/react-router"; import ReactDOM from "react-dom/client"; import { getRouter } from "./router"; diff --git a/apps/web/src/polyfills/map-upsert.test.ts b/apps/web/src/polyfills/map-upsert.test.ts deleted file mode 100644 index af34e0ccc..000000000 --- a/apps/web/src/polyfills/map-upsert.test.ts +++ /dev/null @@ -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([ - ["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(); - 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(); - 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); - }); -}); diff --git a/apps/web/src/polyfills/map-upsert.ts b/apps/web/src/polyfills/map-upsert.ts deleted file mode 100644 index 55dab09a5..000000000 --- a/apps/web/src/polyfills/map-upsert.ts +++ /dev/null @@ -1,49 +0,0 @@ -declare global { - interface Map { - getOrInsert(key: K, value: V): V; - getOrInsertComputed(key: K, callbackFn: (key: K) => V): V; - } -} - -const defineMapMethod = >(name: T, value: Map[T]) => { - if (typeof Map.prototype[name] === "function") return; - - Object.defineProperty(Map.prototype, name, { - value, - writable: true, - configurable: true, - }); -}; - -const canonicalizeKeyedCollectionKey = (key: K): K => { - return Object.is(key, -0) ? (0 as K) : key; -}; - -defineMapMethod("getOrInsert", function getOrInsert(this: Map, 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, 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 {}; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 9fc4eb327..fafb43193 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -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"; diff --git a/apps/web/src/routes/_home/-sections/features.tsx b/apps/web/src/routes/_home/-sections/features.tsx index afa623791..c4d805c7b 100644 --- a/apps/web/src/routes/_home/-sections/features.tsx +++ b/apps/web/src/routes/_home/-sections/features.tsx @@ -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 (
diff --git a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts b/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts index b153607fb..78342acac 100644 --- a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts +++ b/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts @@ -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; @@ -102,7 +101,7 @@ type UseBuilderSidebarReturn = { }; export function useBuilderSidebar(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 } = diff --git a/apps/web/src/routes/builder/$resumeId/route.tsx b/apps/web/src/routes/builder/$resumeId/route.tsx index 221cdfb83..cd06e4141 100644 --- a/apps/web/src/routes/builder/$resumeId/route.tsx +++ b/apps/web/src/routes/builder/$resumeId/route.tsx @@ -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(); diff --git a/packages/ai/package.json b/packages/ai/package.json index 6d0b72496..aa9aece51 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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" } } diff --git a/packages/ai/src/resume/sanitize.ts b/packages/ai/src/resume/sanitize.ts index d51774d5f..8ba712edc 100644 --- a/packages/ai/src/resume/sanitize.ts +++ b/packages/ai/src/resume/sanitize.ts @@ -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 { + 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; }; diff --git a/packages/ai/src/tools/patch-proposal.more.test.ts b/packages/ai/src/tools/patch-proposal.more.test.ts index e8fd2c58e..9f6f783cb 100644 --- a/packages/ai/src/tools/patch-proposal.more.test.ts +++ b/packages/ai/src/tools/patch-proposal.more.test.ts @@ -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", () => { diff --git a/packages/ai/src/tools/patch-proposal.test.ts b/packages/ai/src/tools/patch-proposal.test.ts index 27bacf377..4976ac775 100644 --- a/packages/ai/src/tools/patch-proposal.test.ts +++ b/packages/ai/src/tools/patch-proposal.test.ts @@ -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: "

Focused product engineer.

" }], - }); - - 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: "

Focused product engineer.

", - }); - 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, - }); - }); }); diff --git a/packages/ai/src/tools/patch-proposal.ts b/packages/ai/src/tools/patch-proposal.ts index 2a3a192f3..508188aa1 100644 --- a/packages/ai/src/tools/patch-proposal.ts +++ b/packages/ai/src/tools/patch-proposal.ts @@ -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 = { - add: "Add", - copy: "Copy", - move: "Move", - remove: "Remove", - replace: "Replace", - test: "Check", -}; - -const sectionLabels: Record = { - basics: "Basics", - customSections: "Custom sections", - metadata: "Metadata", - picture: "Picture", - sections: "Sections", - summary: "Summary", -}; - -const fieldLabels: Record = { - 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; -export type ResumePatchProposalToolInput = z.infer; -export type ResumePatchProposalToolOutput = z.infer; - -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((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)[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; +type ResumePatchProposalToolInput = z.infer; export function normalizeResumePatchProposals( input: ResumePatchProposalToolInput, diff --git a/packages/ai/src/tools/patch-resume.test.ts b/packages/ai/src/tools/patch-resume.test.ts deleted file mode 100644 index d6cd541a9..000000000 --- a/packages/ai/src/tools/patch-resume.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/ai/src/tools/patch-resume.ts b/packages/ai/src/tools/patch-resume.ts deleted file mode 100644 index 05fab165e..000000000 --- a/packages/ai/src/tools/patch-resume.ts +++ /dev/null @@ -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 }; -} diff --git a/packages/ai/src/tools/resume-tool-contracts.ts b/packages/ai/src/tools/resume-tool-contracts.ts index 1b0fbea3d..0fc5319a6 100644 --- a/packages/ai/src/tools/resume-tool-contracts.ts +++ b/packages/ai/src/tools/resume-tool-contracts.ts @@ -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; -export type ResumePatchOperation = JsonPatchOperation; diff --git a/packages/api/package.json b/packages/api/package.json index 6ed9ed0dd..b1ae99c90 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" } } diff --git a/packages/api/src/features/flags/index.ts b/packages/api/src/features/flags/index.ts index 50dc5cf76..2f2a19038 100644 --- a/packages/api/src/features/flags/index.ts +++ b/packages/api/src/features/flags/index.ts @@ -1 +1 @@ -export type { FeatureFlags } from "./service"; +export type { FeatureFlags } from "./router"; diff --git a/packages/api/src/features/flags/router.ts b/packages/api/src/features/flags/router.ts index 85bd901e1..72f0cb867 100644 --- a/packages/api/src/features/flags/router.ts +++ b/packages/api/src/features/flags/router.ts @@ -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, + }), + ), }; diff --git a/packages/api/src/features/flags/service.test.ts b/packages/api/src/features/flags/service.test.ts deleted file mode 100644 index d9a8c2a3c..000000000 --- a/packages/api/src/features/flags/service.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/api/src/features/flags/service.ts b/packages/api/src/features/flags/service.ts deleted file mode 100644 index 9f56179d7..000000000 --- a/packages/api/src/features/flags/service.ts +++ /dev/null @@ -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, - }), -}; diff --git a/packages/api/src/features/resume/index.ts b/packages/api/src/features/resume/index.ts deleted file mode 100644 index 49f292cd7..000000000 --- a/packages/api/src/features/resume/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { downloadResumePdfProcedure } from "./export"; -export { resumeService } from "./service"; diff --git a/packages/auth/package.json b/packages/auth/package.json index df2ee5cc1..119b68ee3 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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" } } diff --git a/packages/auth/src/functions.test.ts b/packages/auth/src/functions.test.ts deleted file mode 100644 index 26e09de86..000000000 --- a/packages/auth/src/functions.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/auth/src/functions.ts b/packages/auth/src/functions.ts deleted file mode 100644 index 978feb932..000000000 --- a/packages/auth/src/functions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AuthSession } from "./types"; -import { auth } from "./config"; - -export async function getSession(headers: Headers): Promise { - const result = await auth.api.getSession({ headers }); - return result as AuthSession | null; -} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 87a22ba19..db39e0d46 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -20,7 +20,7 @@ function makeDrizzleClient() { return drizzle({ client: getPool(), relations }); } -export function createDatabase() { +function createDatabase() { if (!globalThis.__drizzle) { globalThis.__drizzle = makeDrizzleClient(); } diff --git a/packages/db/src/schema/agent.ts b/packages/db/src/schema/agent.ts index dcad8f432..342bc49f0 100644 --- a/packages/db/src/schema/agent.ts +++ b/packages/db/src/schema/agent.ts @@ -4,7 +4,7 @@ import { generateId } from "@reactive-resume/utils/string"; import { user } from "./auth"; import { resume } from "./resume"; -export type AgentUiMessage = Record; +type AgentUiMessage = Record; type StoredJsonPatchOperation = | { op: "add"; path: string; value: unknown } | { op: "remove"; path: string } diff --git a/packages/env/package.json b/packages/env/package.json index ad3849320..3b6cceea7 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -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" } } diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 3a9a3182f..d97bc1105 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -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({ diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index 8515e28da..e3d08cad1 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -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"], diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 481ca5c9c..037a50adb 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -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; }; -export type FontRecord = StandardFont | WebFont; +type FontRecord = StandardFont | WebFont; const preferredChineseFontFamilies = [ "Noto Sans SC", @@ -64,25 +64,6 @@ const fontDisplayNames: Partial> = { "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 = { 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(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(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(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(", "); -} diff --git a/packages/pdf/package.json b/packages/pdf/package.json index d11f01f6f..d57d39798 100644 --- a/packages/pdf/package.json +++ b/packages/pdf/package.json @@ -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" } } diff --git a/packages/pdf/src/browser.tsx b/packages/pdf/src/browser.tsx index 9fab77381..b87b16c96 100644 --- a/packages/pdf/src/browser.tsx +++ b/packages/pdf/src/browser.tsx @@ -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; diff --git a/packages/pdf/src/context.tsx b/packages/pdf/src/context.tsx index a45f48731..35d6913ea 100644 --- a/packages/pdf/src/context.tsx +++ b/packages/pdf/src/context.tsx @@ -11,7 +11,7 @@ type RenderContextValue = ResumeData & { const RenderContext = createContext(null); -export type RenderProviderProps = { +type RenderProviderProps = { data: ResumeData; resolveSectionTitle?: SectionTitleResolver | undefined; children: ReactNode; diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx index a8829cef1..b1e6a6b83 100644 --- a/packages/pdf/src/document.tsx +++ b/packages/pdf/src/document.tsx @@ -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; -export type ResumeDocumentProps = { +type ResumeDocumentProps = { data: ResumeData; template: Template; resolveSectionTitle?: SectionTitleResolver | undefined; diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts index 6fcb78e9b..e079687f2 100644 --- a/packages/pdf/src/hooks/use-register-fonts.test.ts +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -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: { diff --git a/packages/pdf/src/hooks/use-register-fonts.ts b/packages/pdf/src/hooks/use-register-fonts.ts index 25590c248..e037e2611 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -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 & { +type PdfTypography = Omit & { body: Omit & { fontFamily: string | string[] }; heading: Omit & { fontFamily: string | string[] }; }; diff --git a/packages/pdf/src/renderer.ts b/packages/pdf/src/renderer.ts deleted file mode 100644 index f0b8f2a5e..000000000 --- a/packages/pdf/src/renderer.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - Document, - Font, - Image, - Link, - Page, - pdf, - renderToBuffer, - StyleSheet, - Text, - View, -} from "#react-pdf-renderer"; diff --git a/packages/pdf/src/section-title.ts b/packages/pdf/src/section-title.ts index db14289d7..fb2798b7d 100644 --- a/packages/pdf/src/section-title.ts +++ b/packages/pdf/src/section-title.ts @@ -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"; diff --git a/packages/pdf/src/server.tsx b/packages/pdf/src/server.tsx index c57b9f822..4d8acbb1a 100644 --- a/packages/pdf/src/server.tsx +++ b/packages/pdf/src/server.tsx @@ -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; diff --git a/packages/pdf/src/templates/azurill/AzurillPage.tsx b/packages/pdf/src/templates/azurill/AzurillPage.tsx index 63c2ebd16..65719e2a4 100644 --- a/packages/pdf/src/templates/azurill/AzurillPage.tsx +++ b/packages/pdf/src/templates/azurill/AzurillPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/bronzor/BronzorPage.tsx b/packages/pdf/src/templates/bronzor/BronzorPage.tsx index d564e3f51..82b27211d 100644 --- a/packages/pdf/src/templates/bronzor/BronzorPage.tsx +++ b/packages/pdf/src/templates/bronzor/BronzorPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx index f53126601..71a6e5dfd 100644 --- a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx +++ b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index 166e5ad45..8e6161deb 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -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(); diff --git a/packages/pdf/src/templates/ditto/DittoPage.tsx b/packages/pdf/src/templates/ditto/DittoPage.tsx index 6d3241b99..b0b75de25 100644 --- a/packages/pdf/src/templates/ditto/DittoPage.tsx +++ b/packages/pdf/src/templates/ditto/DittoPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/gengar/GengarPage.tsx b/packages/pdf/src/templates/gengar/GengarPage.tsx index 6cb2074b8..c38989abd 100644 --- a/packages/pdf/src/templates/gengar/GengarPage.tsx +++ b/packages/pdf/src/templates/gengar/GengarPage.tsx @@ -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(); diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index d0e48e140..5c80d64f3 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -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(); diff --git a/packages/pdf/src/templates/index.ts b/packages/pdf/src/templates/index.ts index 5dfe919d8..bad8fb349 100644 --- a/packages/pdf/src/templates/index.ts +++ b/packages/pdf/src/templates/index.ts @@ -34,24 +34,6 @@ export const templatePages: Partial> = { 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, -}; diff --git a/packages/pdf/src/templates/kakuna/KakunaPage.tsx b/packages/pdf/src/templates/kakuna/KakunaPage.tsx index 09733542b..1ce94a747 100644 --- a/packages/pdf/src/templates/kakuna/KakunaPage.tsx +++ b/packages/pdf/src/templates/kakuna/KakunaPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/lapras/LaprasPage.tsx b/packages/pdf/src/templates/lapras/LaprasPage.tsx index 90cca04a8..d5904d0f3 100644 --- a/packages/pdf/src/templates/lapras/LaprasPage.tsx +++ b/packages/pdf/src/templates/lapras/LaprasPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/leafish/LeafishPage.tsx b/packages/pdf/src/templates/leafish/LeafishPage.tsx index b46022d4c..05c887606 100644 --- a/packages/pdf/src/templates/leafish/LeafishPage.tsx +++ b/packages/pdf/src/templates/leafish/LeafishPage.tsx @@ -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(); diff --git a/packages/pdf/src/templates/meowth/MeowthPage.tsx b/packages/pdf/src/templates/meowth/MeowthPage.tsx index 7c9b3a6a7..19557388a 100644 --- a/packages/pdf/src/templates/meowth/MeowthPage.tsx +++ b/packages/pdf/src/templates/meowth/MeowthPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/onyx/OnyxPage.tsx b/packages/pdf/src/templates/onyx/OnyxPage.tsx index cc91b789c..43a19030d 100644 --- a/packages/pdf/src/templates/onyx/OnyxPage.tsx +++ b/packages/pdf/src/templates/onyx/OnyxPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/pikachu/PikachuPage.tsx b/packages/pdf/src/templates/pikachu/PikachuPage.tsx index c606f93f6..0d07e742f 100644 --- a/packages/pdf/src/templates/pikachu/PikachuPage.tsx +++ b/packages/pdf/src/templates/pikachu/PikachuPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx index 9a2ee2c36..00f04faf7 100644 --- a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx +++ b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/scizor/ScizorPage.tsx b/packages/pdf/src/templates/scizor/ScizorPage.tsx index a4eb32cc4..f3e5f72b9 100644 --- a/packages/pdf/src/templates/scizor/ScizorPage.tsx +++ b/packages/pdf/src/templates/scizor/ScizorPage.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/color-helpers.ts b/packages/pdf/src/templates/shared/color-helpers.ts new file mode 100644 index 000000000..e3462eab1 --- /dev/null +++ b/packages/pdf/src/templates/shared/color-helpers.ts @@ -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})`; +}; diff --git a/packages/pdf/src/templates/shared/columns.ts b/packages/pdf/src/templates/shared/columns.ts index 1f7feaee3..c58b6e925 100644 --- a/packages/pdf/src/templates/shared/columns.ts +++ b/packages/pdf/src/templates/shared/columns.ts @@ -16,7 +16,7 @@ type SectionTimelineInput = { columns: unknown; }; -export type SectionItemsLayout = { +type SectionItemsLayout = { columns: number; containerStyle: Style; rowStyle: Style | undefined; diff --git a/packages/pdf/src/templates/shared/contact-item.tsx b/packages/pdf/src/templates/shared/contact-item.tsx index 6a3dc9f42..1ba94b5dd 100644 --- a/packages/pdf/src/templates/shared/contact-item.tsx +++ b/packages/pdf/src/templates/shared/contact-item.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/level-display.tsx b/packages/pdf/src/templates/shared/level-display.tsx index aff4d85cc..e9a745cd1 100644 --- a/packages/pdf/src/templates/shared/level-display.tsx +++ b/packages/pdf/src/templates/shared/level-display.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/metrics.ts b/packages/pdf/src/templates/shared/metrics.ts index d5306e94a..e42b90bdc 100644 --- a/packages/pdf/src/templates/shared/metrics.ts +++ b/packages/pdf/src/templates/shared/metrics.ts @@ -2,7 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; type PageMetricsInput = Pick; -export type TemplateMetrics = { +type TemplateMetrics = { page: { paddingHorizontal: number; paddingVertical: number; diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index a4b1cabbf..4cc3af375 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/rich-text-html.test.ts b/packages/pdf/src/templates/shared/rich-text-html.test.ts index f6c1f2cfc..dd031b2fe 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.test.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.test.ts @@ -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 } }>; diff --git a/packages/pdf/src/templates/shared/rich-text-renderers.ts b/packages/pdf/src/templates/shared/rich-text-renderers.ts index e8a438115..6f9e801d9 100644 --- a/packages/pdf/src/templates/shared/rich-text-renderers.ts +++ b/packages/pdf/src/templates/shared/rich-text-renderers.ts @@ -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, diff --git a/packages/pdf/src/templates/shared/rich-text.tsx b/packages/pdf/src/templates/shared/rich-text.tsx index 21fb552b1..91d35ac92 100644 --- a/packages/pdf/src/templates/shared/rich-text.tsx +++ b/packages/pdf/src/templates/shared/rich-text.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/rtl.ts b/packages/pdf/src/templates/shared/rtl.ts index 0a065d1e5..a716f0d99 100644 --- a/packages/pdf/src/templates/shared/rtl.ts +++ b/packages/pdf/src/templates/shared/rtl.ts @@ -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"; diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx index a35eb75c9..5f6e69a76 100644 --- a/packages/pdf/src/templates/shared/sections.tsx +++ b/packages/pdf/src/templates/shared/sections.tsx @@ -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"; diff --git a/packages/pdf/src/templates/shared/styles.ts b/packages/pdf/src/templates/shared/styles.ts index 4384619cf..8b5fccdf0 100644 --- a/packages/pdf/src/templates/shared/styles.ts +++ b/packages/pdf/src/templates/shared/styles.ts @@ -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; diff --git a/packages/resume/src/patch.test.ts b/packages/resume/src/patch.test.ts index 75186cb54..063e17e40 100644 --- a/packages/resume/src/patch.test.ts +++ b/packages/resume/src/patch.test.ts @@ -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" }]); diff --git a/packages/resume/src/patch.ts b/packages/resume/src/patch.ts index 60c312fbf..6196153d0 100644 --- a/packages/resume/src/patch.ts +++ b/packages/resume/src/patch.ts @@ -20,10 +20,6 @@ export const jsonPatchOperationSchema = z.discriminatedUnion("op", [ export type JsonPatchOperation = z.infer; -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. diff --git a/packages/schema/package.json b/packages/schema/package.json index 79cd91f05..575cc0995 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -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" } } diff --git a/packages/schema/src/page.test.ts b/packages/schema/src/page.test.ts deleted file mode 100644 index 84605eba5..000000000 --- a/packages/schema/src/page.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/packages/schema/src/page.ts b/packages/schema/src/page.ts deleted file mode 100644 index 54a1fc35c..000000000 --- a/packages/schema/src/page.ts +++ /dev/null @@ -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; diff --git a/packages/schema/src/resume/analysis.ts b/packages/schema/src/resume/analysis.ts index 2f1c0d7a7..faad427c1 100644 --- a/packages/schema/src/resume/analysis.ts +++ b/packages/schema/src/resume/analysis.ts @@ -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({ diff --git a/packages/schema/src/resume/data.ts b/packages/schema/src/resume/data.ts index e05ac667e..9842fd89b 100644 --- a/packages/schema/src/resume/data.ts +++ b/packages/schema/src/resume/data.ts @@ -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; -export type SectionData = z.infer[T]; +type SectionData = z.infer[T]; export type SectionItem = SectionData["items"][number]; export const sectionTypeSchema = z.enum([ @@ -373,7 +373,7 @@ export const sectionTypeSchema = z.enum([ export type CustomSectionType = z.infer; -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; -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; -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; -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; -export type Metadata = z.infer; export type LayoutPage = z.infer; export type Typography = z.infer; export type Design = z.infer; @@ -672,7 +671,6 @@ export type EducationSection = z.infer; export type ExperienceSection = z.infer; export type InterestsSection = z.infer; export type LanguagesSection = z.infer; -export type ProfilesSection = z.infer; export type ProjectsSection = z.infer; export type PublicationsSection = z.infer; export type ReferencesSection = z.infer; diff --git a/packages/schema/src/resume/level-display-sizes.ts b/packages/schema/src/resume/level-display-sizes.ts index fc1ddba04..647007b73 100644 --- a/packages/schema/src/resume/level-display-sizes.ts +++ b/packages/schema/src/resume/level-display-sizes.ts @@ -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; }; diff --git a/packages/ui/src/components/badge.test.tsx b/packages/ui/src/components/badge.test.tsx index 8126973b3..b3d0dbc5b 100644 --- a/packages/ui/src/components/badge.test.tsx +++ b/packages/ui/src/components/badge.test.tsx @@ -19,8 +19,6 @@ describe("Badge", () => { ["secondary"], ["destructive"], ["outline"], - ["ghost"], - ["link"], ] as const)("renders variant=%s without throwing", (variant) => { render({variant}); expect(screen.getByText(variant)).toBeInTheDocument(); diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index f07d50433..c5aad09a3 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -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: { diff --git a/packages/ui/src/components/card.test.tsx b/packages/ui/src/components/card.test.tsx deleted file mode 100644 index 60af20513..000000000 --- a/packages/ui/src/components/card.test.tsx +++ /dev/null @@ -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(x); - expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "card"); - }); - - it("defaults size to 'default'", () => { - render(x); - expect(screen.getByTestId("c")).toHaveAttribute("data-size", "default"); - }); - - it("supports size='sm'", () => { - render( - - x - , - ); - expect(screen.getByTestId("c")).toHaveAttribute("data-size", "sm"); - }); - - it("merges custom className", () => { - render( - - x - , - ); - 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( - - child - , - ); - expect(screen.getByTestId("el")).toHaveAttribute("data-slot", slot); - }); - - it("CardHeader merges custom className", () => { - render( - - x - , - ); - expect(screen.getByTestId("h")).toHaveClass("my-class"); - }); - - it("CardTitle merges custom className", () => { - render( - - x - , - ); - expect(screen.getByTestId("t")).toHaveClass("my-class"); - }); - - it("CardFooter merges custom className", () => { - render( - - x - , - ); - expect(screen.getByTestId("f")).toHaveClass("my-class"); - }); -}); - -describe("Card composition", () => { - it("composes a full card", () => { - render( - - - Title - Description - Action - - Content - Footer - , - ); - 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(); - }); -}); diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx deleted file mode 100644 index 8ba360261..000000000 --- a/packages/ui/src/components/card.tsx +++ /dev/null @@ -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 ( -
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 ( -
- ); -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return
; -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return
; -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/packages/ui/src/components/checkbox.test.tsx b/packages/ui/src/components/checkbox.test.tsx deleted file mode 100644 index 58452c669..000000000 --- a/packages/ui/src/components/checkbox.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByTestId("cb")).toHaveAttribute("data-slot", "checkbox"); - }); - - it("defaults to unchecked", () => { - render(); - const cb = screen.getByTestId("cb"); - // base-ui sets aria-checked - expect(cb.getAttribute("aria-checked")).toBe("false"); - }); - - it("renders as checked via defaultChecked", () => { - render(); - expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true"); - }); - - it("can be controlled via checked prop", () => { - render( {}} />); - expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true"); - }); - - it("calls onCheckedChange when toggled", async () => { - const onChange = vi.fn(); - render(); - await userEvent.click(screen.getByTestId("cb")); - expect(onChange).toHaveBeenCalledOnce(); - }); - - it("respects disabled state via aria-disabled (base-ui uses span+role)", () => { - render(); - const cb = screen.getByTestId("cb"); - expect(cb.getAttribute("aria-disabled")).toBe("true"); - expect(cb).toHaveAttribute("data-disabled"); - }); - - it("merges custom className", () => { - render(); - expect(screen.getByTestId("cb")).toHaveClass("my-cb"); - }); -}); diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx deleted file mode 100644 index 97bb484b4..000000000 --- a/packages/ui/src/components/checkbox.tsx +++ /dev/null @@ -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 ( - - - - - - ); -} - -export { Checkbox }; diff --git a/packages/ui/src/components/direction.tsx b/packages/ui/src/components/direction.tsx deleted file mode 100644 index 83cbd0287..000000000 --- a/packages/ui/src/components/direction.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export { - DirectionProvider, - useDirection, -} from "@base-ui/react/direction-provider"; diff --git a/packages/ui/src/components/progress.test.tsx b/packages/ui/src/components/progress.test.tsx deleted file mode 100644 index e23bba956..000000000 --- a/packages/ui/src/components/progress.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByTestId("p")).toHaveAttribute("data-slot", "progress"); - }); - - it("auto-renders track and indicator children", () => { - const { container } = render(); - expect(container.querySelector("[data-slot=progress-track]")).toBeInTheDocument(); - expect(container.querySelector("[data-slot=progress-indicator]")).toBeInTheDocument(); - }); - - it("forwards value attribute via aria-valuenow", () => { - render(); - expect(screen.getByTestId("p")).toHaveAttribute("aria-valuenow", "75"); - }); - - it("supports custom children rendered before the track", () => { - render( - - My Progress - , - ); - expect(screen.getByText("My Progress")).toBeInTheDocument(); - }); -}); - -describe("ProgressLabel", () => { - it("renders with data-slot='progress-label'", () => { - render( - - Label - , - ); - expect(screen.getByText("Label")).toHaveAttribute("data-slot", "progress-label"); - }); -}); - -describe("ProgressValue", () => { - it("renders with data-slot='progress-value'", () => { - render( - - - , - ); - // The value text is rendered by base-ui — just verify the slot exists - const { container } = render( - - - , - ); - expect(container.querySelector("[data-slot=progress-value]")).toBeInTheDocument(); - }); -}); - -describe("ProgressTrack and ProgressIndicator", () => { - it("can be rendered explicitly with custom classes", () => { - const { container } = render( - - - - - , - ); - expect(container.querySelectorAll("[data-slot=progress-track]")).toHaveLength(2); - }); -}); diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx deleted file mode 100644 index 80047e265..000000000 --- a/packages/ui/src/components/progress.tsx +++ /dev/null @@ -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 ( - - {children} - - - - - ); -} - -function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { - return ( - - ); -} - -function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) { - return ( - - ); -} - -function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { - return ( - - ); -} - -function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { - return ( - - ); -} - -export { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue }; diff --git a/packages/ui/src/hooks/use-confirm.test.tsx b/packages/ui/src/hooks/use-confirm.test.tsx deleted file mode 100644 index 47368c385..000000000 --- a/packages/ui/src/hooks/use-confirm.test.tsx +++ /dev/null @@ -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>[1]; -}; - -const ConfirmRunner = ({ onResult, title, options }: ConfirmRunnerProps) => { - const confirm = useConfirm(); - return ( - - ); -}; - -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( - { - /* noop */ - }} - />, - ), - ).toThrow(/useConfirm must be used within/); - }); - - it("renders children inside provider", () => { - render( - -
child
-
, - ); - expect(screen.getByTestId("child")).toBeInTheDocument(); - }); - - it("opens an alert dialog when confirm is called", async () => { - const results: boolean[] = []; - render( - - results.push(v)} /> - , - ); - - 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( - - results.push(v)} /> - , - ); - - 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( - - results.push(v)} /> - , - ); - - 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( - - { - /* noop */ - }} - /> - , - ); - - await userEvent.click(screen.getByText("Trigger")); - expect(await screen.findByText("Yes!")).toBeInTheDocument(); - expect(screen.getByText("Nope")).toBeInTheDocument(); - }); - - it("renders description when provided", async () => { - render( - - { - /* noop */ - }} - /> - , - ); - - 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( - - { - /* noop */ - }} - /> - , - ); - - await userEvent.click(screen.getByText("Trigger")); - expect(await screen.findByText("Confirm")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); - }); -}); diff --git a/packages/ui/src/hooks/use-confirm.tsx b/packages/ui/src/hooks/use-confirm.tsx deleted file mode 100644 index 8d8e3a1d9..000000000 --- a/packages/ui/src/hooks/use-confirm.tsx +++ /dev/null @@ -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; -}; - -type ConfirmDialogProviderProps = { - children: React.ReactNode; -}; - -const ConfirmContext = React.createContext(null); - -export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps) { - const [state, setState] = React.useState({ - open: false, - resolve: null, - title: "", - }); - - const confirm = React.useCallback(async (title: string, options?: ConfirmOptions): Promise => { - return new Promise((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(() => ({ confirm }), [confirm]); - - return ( - - {children} - - !open && handleCancel()}> - - - {state.title} - - {state.description} - - - - - {state.cancelText ?? "Cancel"} - {state.confirmText ?? "Confirm"} - - - - - ); -} - -export function useConfirm() { - const context = React.use(ConfirmContext); - - if (!context) { - throw new Error("useConfirm must be used within a ."); - } - - return context.confirm; -} diff --git a/packages/ui/src/hooks/use-cookie.tsx b/packages/ui/src/hooks/use-cookie.tsx index b10d61e58..1c3442db9 100644 --- a/packages/ui/src/hooks/use-cookie.tsx +++ b/packages/ui/src/hooks/use-cookie.tsx @@ -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: "/", diff --git a/packages/ui/src/hooks/use-prompt.test.tsx b/packages/ui/src/hooks/use-prompt.test.tsx deleted file mode 100644 index dbbfa270a..000000000 --- a/packages/ui/src/hooks/use-prompt.test.tsx +++ /dev/null @@ -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>[1]; -}; - -const PromptRunner = ({ onResult, title, options }: PromptRunnerProps) => { - const prompt = usePrompt(); - return ( - - ); -}; - -describe("usePrompt", () => { - it("throws when used outside PromptDialogProvider", () => { - expect(() => - render( - { - /* noop */ - }} - />, - ), - ).toThrow(/usePrompt must be used within/); - }); - - it("renders children inside provider", () => { - render( - -
child
-
, - ); - expect(screen.getByTestId("child")).toBeInTheDocument(); - }); - - it("opens an alert dialog when prompt is called", async () => { - render( - - { - /* noop */ - }} - /> - , - ); - - 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( - - results.push(v)} /> - , - ); - - 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( - - results.push(v)} /> - , - ); - - 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( - - { - /* noop */ - }} - /> - , - ); - - 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( - - results.push(v)} /> - , - ); - - 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( - - { - /* noop */ - }} - /> - , - ); - - await userEvent.click(screen.getByText("Trigger")); - expect(await screen.findByText("Save")).toBeInTheDocument(); - expect(screen.getByText("Discard")).toBeInTheDocument(); - }); - - it("renders description when provided", async () => { - render( - - { - /* noop */ - }} - /> - , - ); - - await userEvent.click(screen.getByText("Trigger")); - expect(await screen.findByText("Explain why")).toBeInTheDocument(); - }); -}); diff --git a/packages/ui/src/hooks/use-prompt.tsx b/packages/ui/src/hooks/use-prompt.tsx deleted file mode 100644 index 9ee92bb11..000000000 --- a/packages/ui/src/hooks/use-prompt.tsx +++ /dev/null @@ -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, "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; -}; - -type PromptDialogProviderProps = { - children: React.ReactNode; -}; - -const PromptContext = React.createContext(null); - -export function PromptDialogProvider({ children }: PromptDialogProviderProps) { - const inputRef = React.useRef(null); - - const [state, setState] = React.useState({ - 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 => { - return new Promise((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) => { - setState((prev) => ({ ...prev, value: e.target.value })); - }, []); - - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleConfirm(); - }, - [handleConfirm], - ); - - const contextValue = React.useMemo(() => ({ prompt }), [prompt]); - - return ( - - {children} - - !open && handleCancel()}> - - - {state.title} - - {state.description} - - - - - - - {cancelText} - {confirmText} - - - - - ); -} - -export function usePrompt() { - const context = React.use(PromptContext); - - if (!context) { - throw new Error("usePrompt must be used within a ."); - } - - return context.prompt; -} diff --git a/packages/utils/package.json b/packages/utils/package.json index 6e947bf3e..974735050 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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" } } diff --git a/packages/utils/src/field.test.ts b/packages/utils/src/field.test.ts deleted file mode 100644 index f2ffe8fab..000000000 --- a/packages/utils/src/field.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/packages/utils/src/field.ts b/packages/utils/src/field.ts index 2444d2d79..690b00b1e 100644 --- a/packages/utils/src/field.ts +++ b/packages/utils/src/field.ts @@ -1,28 +1,3 @@ -type KeyedField = { - key: TKey; -}; - -type KeyValueMap = Partial>; - -/** - * 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>( - values: KeyValueMap, - ...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(items: T[]) { - return items.filter((item, index) => items.indexOf(item) === index); + return [...new Set(items)]; } diff --git a/packages/utils/src/file.test.ts b/packages/utils/src/file.test.ts index 159dd15c1..2edc6fe05 100644 --- a/packages/utils/src/file.test.ts +++ b/packages/utils/src/file.test.ts @@ -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>; - let revokeObjectURLSpy: ReturnType>; - let originalCreate: typeof URL.createObjectURL; - let originalRevoke: typeof URL.revokeObjectURL; - - beforeEach(() => { - originalFetch = global.fetch; - originalCreate = URL.createObjectURL; - originalRevoke = URL.revokeObjectURL; - createObjectURLSpy = vi.fn(() => "blob:mock-url"); - revokeObjectURLSpy = vi.fn(); - 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"); - }); -}); diff --git a/packages/utils/src/file.ts b/packages/utils/src/file.ts index 6d9a7a549..45bd57071 100644 --- a/packages/utils/src/file.ts +++ b/packages/utils/src/file.ts @@ -19,10 +19,3 @@ export function downloadWithAnchor(blob: Blob, filename: string) { setTimeout(() => URL.revokeObjectURL(url), 5000); } - -export async function downloadFromUrl(url: string, filename: string) { - const response = await fetch(url); - const blob = await response.blob(); - - downloadWithAnchor(blob, filename); -} diff --git a/packages/utils/src/locale.ts b/packages/utils/src/locale.ts index 9e77eee8e..3c2261349 100644 --- a/packages/utils/src/locale.ts +++ b/packages/utils/src/locale.ts @@ -80,7 +80,7 @@ export type Script = "hangul" | "kana" | "han-traditional" | "han-simplified" | // The CJK subset of `Script`. CJK needs extra per-character line breaking that // must NOT be applied to Arabic (cursive, joined letters) or Thai (combining // marks), so callers gate line-breaking on this rather than on `Script`. -export const cjkScripts: readonly Script[] = ["hangul", "kana", "han-traditional", "han-simplified"]; +const cjkScripts: readonly Script[] = ["hangul", "kana", "han-traditional", "han-simplified"]; export function isCjkScript(script: Script): boolean { return cjkScripts.includes(script); diff --git a/packages/utils/src/sanitize.test.ts b/packages/utils/src/sanitize.test.ts deleted file mode 100644 index ae3788e21..000000000 --- a/packages/utils/src/sanitize.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { describe, expect, it } from "vitest"; -import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize"; - -describe("sanitizeHtml", () => { - it("returns empty string for empty input", () => { - expect(sanitizeHtml("")).toBe(""); - }); - - it("preserves allowed tags", () => { - const result = sanitizeHtml("

Hello world

"); - expect(result).toContain("

"); - expect(result).toContain(""); - expect(result).toContain("Hello"); - expect(result).toContain("world"); - }); - - it("strips disallowed tags like "); - expect(result).not.toContain("