diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..1ecf39a1b --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,98 @@ +{ + "name": "Reactive Resume", + "short_name": "Reactive Resume", + "description": "A free and open-source resume builder.", + "start_url": "/?source=pwa", + "display": "standalone", + "background_color": "#09090B", + "theme_color": "#09090B", + "lang": "en", + "scope": "/", + "id": "/?source=pwa", + "orientation": "portrait", + "icons": [ + { "src": "favicon.ico", "sizes": "128x128", "type": "image/x-icon" }, + { "src": "pwa-64x64.png", "sizes": "64x64", "type": "image/png" }, + { "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "screenshots": [ + { + "src": "screenshots/web/1-landing-page.webp", + "sizes": "1920x1080 any", + "type": "image/webp", + "form_factor": "wide", + "label": "Landing Page" + }, + { + "src": "screenshots/web/2-resume-dashboard.webp", + "sizes": "1920x1080 any", + "type": "image/webp", + "form_factor": "wide", + "label": "Resume Dashboard" + }, + { + "src": "screenshots/web/3-builder-screen.webp", + "sizes": "1920x1080 any", + "type": "image/webp", + "form_factor": "wide", + "label": "Builder Screen" + }, + { + "src": "screenshots/web/4-template-gallery.webp", + "sizes": "1920x1080 any", + "type": "image/webp", + "form_factor": "wide", + "label": "Template Gallery" + }, + { + "src": "screenshots/mobile/1-landing-page.webp", + "sizes": "1284x2778 any", + "type": "image/webp", + "form_factor": "narrow", + "label": "Landing Page" + }, + { + "src": "screenshots/mobile/2-resume-dashboard.webp", + "sizes": "1284x2778 any", + "type": "image/webp", + "form_factor": "narrow", + "label": "Resume Dashboard" + }, + { + "src": "screenshots/mobile/3-builder-screen.webp", + "sizes": "1284x2778 any", + "type": "image/webp", + "form_factor": "narrow", + "label": "Builder Screen" + }, + { + "src": "screenshots/mobile/4-template-gallery.webp", + "sizes": "1284x2778 any", + "type": "image/webp", + "form_factor": "narrow", + "label": "Template Gallery" + } + ], + "categories": [ + "ai", + "builder", + "business", + "career", + "cv", + "editor", + "free", + "generator", + "job-search", + "multilingual", + "open-source", + "privacy", + "productivity", + "resume", + "self-hosted", + "templates", + "utilities", + "writing" + ] +} diff --git a/src/components/layout/not-found-screen.tsx b/src/components/layout/not-found-screen.tsx index 0fec70486..f75390fcd 100644 --- a/src/components/layout/not-found-screen.tsx +++ b/src/components/layout/not-found-screen.tsx @@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"; import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react"; import { Link, type NotFoundRouteProps } from "@tanstack/react-router"; -import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { BrandIcon } from "../ui/brand-icon"; @@ -20,12 +20,10 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) { {routeId} - + + + Go Back + ); } diff --git a/src/components/resume/hooks/use-css-variables.tsx b/src/components/resume/hooks/use-css-variables.tsx index ecdadc7aa..b638f64c1 100644 --- a/src/components/resume/hooks/use-css-variables.tsx +++ b/src/components/resume/hooks/use-css-variables.tsx @@ -5,6 +5,7 @@ import { useMemo } from "react"; import type { resumeDataSchema } from "@/schema/resume/data"; import { pageDimensionsAsMillimeters } from "@/schema/page"; +import { buildResumeFontFamily } from "@/utils/fonts"; type UseCssVariablesProps = Pick, "picture" | "metadata">; @@ -36,12 +37,12 @@ export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) => "--page-text-color": metadata.design.colors.text, "--page-primary-color": metadata.design.colors.primary, "--page-background-color": metadata.design.colors.background, - "--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, + "--page-body-font-family": buildResumeFontFamily(metadata.typography.body.fontFamily), "--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight, "--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight, "--page-body-font-size": metadata.typography.body.fontSize, "--page-body-line-height": metadata.typography.body.lineHeight, - "--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, + "--page-heading-font-family": buildResumeFontFamily(metadata.typography.heading.fontFamily), "--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight, "--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight, "--page-heading-font-size": metadata.typography.heading.fontSize, diff --git a/src/components/resume/hooks/use-webfonts.tsx b/src/components/resume/hooks/use-webfonts.tsx index b60734bdc..c220e9ea5 100644 --- a/src/components/resume/hooks/use-webfonts.tsx +++ b/src/components/resume/hooks/use-webfonts.tsx @@ -5,7 +5,7 @@ import { useIsMounted } from "usehooks-ts"; import type { typographySchema } from "@/schema/resume/data"; -import webfontlist from "@/components/typography/webfontlist.json"; +import { getFallbackWebFontFamilies, getLoadableWebFontWeights, webFontMap } from "@/utils/fonts"; export function useWebfonts(typography: z.infer) { const isMounted = useIsMounted(); @@ -17,7 +17,7 @@ export function useWebfonts(typography: z.infer) { if (body) body.setAttribute("data-wf-loaded", "false"); async function loadFont(family: string, weights: string[]) { - const font = webfontlist.find((font) => font.family === family); + const font = webFontMap.get(family); if (!font) return; type FontUrl = { url: string; weight: string; style: "italic" | "normal" }; @@ -49,11 +49,35 @@ export function useWebfonts(typography: z.infer) { const bodyTypography = typography.body; const headingTypography = typography.heading; + const fontWeightsByFamily = new Map>(); - void Promise.allSettled([ - loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights), - loadFont(headingTypography.fontFamily, headingTypography.fontWeights), - ]).then(() => { + const addFontLoadPlan = (family: string, weights: string[]) => { + const loadableWeights = getLoadableWebFontWeights(family, weights); + if (loadableWeights.length === 0) return; + + const existingWeights = fontWeightsByFamily.get(family) ?? new Set(); + + for (const weight of loadableWeights) { + existingWeights.add(weight); + } + + fontWeightsByFamily.set(family, existingWeights); + }; + + addFontLoadPlan(bodyTypography.fontFamily, bodyTypography.fontWeights); + addFontLoadPlan(headingTypography.fontFamily, headingTypography.fontWeights); + + for (const fallbackFamily of getFallbackWebFontFamilies(bodyTypography.fontFamily)) { + addFontLoadPlan(fallbackFamily, bodyTypography.fontWeights); + } + + for (const fallbackFamily of getFallbackWebFontFamilies(headingTypography.fontFamily)) { + addFontLoadPlan(fallbackFamily, headingTypography.fontWeights); + } + + void Promise.all( + Array.from(fontWeightsByFamily.entries()).map(([family, weights]) => loadFont(family, Array.from(weights))), + ).then(() => { if (isMounted() && body) body.setAttribute("data-wf-loaded", "true"); }); diff --git a/src/components/typography/combobox.tsx b/src/components/typography/combobox.tsx index 0d1352138..5b9ab61e2 100644 --- a/src/components/typography/combobox.tsx +++ b/src/components/typography/combobox.tsx @@ -1,46 +1,22 @@ import { useMemo } from "react"; +import { + fontList, + getFont, + getFontDisplayName, + getFontSearchKeywords, + localFontList, + webFontMap, +} from "@/utils/fonts"; import { cn } from "@/utils/style"; -import type { LocalFont, WebFont } from "./types"; - import { Combobox, type MultiComboboxProps, type SingleComboboxProps } from "../ui/combobox"; import { FontDisplay } from "./font-display"; -import webFontListJSON from "./webfontlist.json"; type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900"; -const localFontList = [ - { type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] }, - { type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] }, - { type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] }, - { type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] }, - { type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] }, - { type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] }, - { type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] }, -] as LocalFont[]; - -const webFontList = webFontListJSON as WebFont[]; - -function buildWebFontMap() { - const webFontMap = new Map(); - - for (const font of webFontList) { - webFontMap.set(font.family, font); - } - - return webFontMap; -} - -const webFontMap: Map = buildWebFontMap(); - export function getNextWeights(fontFamily: string): Weight[] | null { - const fontData = webFontMap.get(fontFamily); + const fontData = getFont(fontFamily); if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null; const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[]; @@ -67,10 +43,17 @@ type FontFamilyComboboxProps = Omit; export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) { const options = useMemo(() => { - return [...webFontList, ...localFontList].map((font: LocalFont | WebFont) => ({ + return fontList.map((font) => ({ value: font.family, - keywords: [font.family], - label: , + keywords: getFontSearchKeywords(font.family), + label: ( + + ), })); }, []); diff --git a/src/components/typography/font-display.tsx b/src/components/typography/font-display.tsx index 20dc7a164..e3476c920 100644 --- a/src/components/typography/font-display.tsx +++ b/src/components/typography/font-display.tsx @@ -4,15 +4,16 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/utils/style"; interface FontDisplayProps { - name: string; - url?: string; + family: string; + label: string; type: "local" | "web"; + url?: string; } const loadedFonts = new Set(); -export function FontDisplay({ name, url, type }: FontDisplayProps) { - const previewName = type === "local" ? name : `${name} Preview`; +export function FontDisplay({ family, label, type, url }: FontDisplayProps) { + const previewName = type === "local" ? family : `${family} Preview`; const containerRef = useRef(null); const [isLoaded, setIsLoaded] = useState(() => type === "local" || loadedFonts.has(previewName)); @@ -31,13 +32,14 @@ export function FontDisplay({ name, url, type }: FontDisplayProps) { }, [isInView, isLoaded, previewName, url]); return ( -
+
- {name} + {label} + {label !== family && {family}}
); } diff --git a/src/integrations/orpc/router/ai.ts b/src/integrations/orpc/router/ai.ts index 4409246a0..9cebc3183 100644 --- a/src/integrations/orpc/router/ai.ts +++ b/src/integrations/orpc/router/ai.ts @@ -1,7 +1,6 @@ import { ORPCError } from "@orpc/client"; import { type } from "@orpc/server"; import { AISDKError, type UIMessage } from "ai"; -import { OllamaError } from "ai-sdk-ollama"; import z, { flattenError, ZodError } from "zod"; import { jobResultSchema } from "@/schema/jobs"; @@ -21,7 +20,7 @@ function isInvalidAiBaseUrlError(error: unknown): boolean { } function isAiProviderGatewayError(error: unknown): boolean { - return error instanceof AISDKError || error instanceof OllamaError; + return error instanceof AISDKError; } function throwAiProviderGatewayError(): never { @@ -111,7 +110,7 @@ export const aiRouter = { return await aiService.parsePdf(input); } catch (error) { if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (error instanceof AISDKError) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) throwResumeStructureError(error); throw error; @@ -155,7 +154,7 @@ export const aiRouter = { return await aiService.parseDocx(input); } catch (error) { if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (error instanceof AISDKError) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) throwResumeStructureError(error); diff --git a/src/routes/$username/$slug.tsx b/src/routes/$username/$slug.tsx index 3733ac19b..f7db7fa85 100644 --- a/src/routes/$username/$slug.tsx +++ b/src/routes/$username/$slug.tsx @@ -1,4 +1,3 @@ -import { t } from "@lingui/core/macro"; import { ORPCError } from "@orpc/client"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; @@ -16,9 +15,7 @@ type LoaderData = Omit & { data: Re export const Route = createFileRoute("/$username/$slug")({ component: RouteComponent, - loader: async ({ context, params, ...rest }) => { - console.log("$username/$slug loader", JSON.stringify({ params, context, rest }, null, 2)); - + loader: async ({ context, params }) => { const { username, slug } = params; const resume = await context.queryClient.ensureQueryData( orpc.resume.getBySlug.queryOptions({ input: { username, slug } }), @@ -27,19 +24,7 @@ export const Route = createFileRoute("/$username/$slug")({ return { resume: resume as LoaderData }; }, head: ({ loaderData }) => ({ - meta: [ - { - title: loaderData - ? `${loaderData.resume.name} - ${t({ - comment: "Brand name suffix in browser tab title for public resume pages", - message: "Reactive Resume", - })}` - : t({ - comment: "Browser tab title before the public resume finishes loading", - message: "Reactive Resume", - }), - }, - ], + meta: [{ title: loaderData ? `${loaderData.resume.name} - Reactive Resume` : "Reactive Resume" }], }), onError: (error) => { if (error instanceof ORPCError && error.code === "NEED_PASSWORD") { diff --git a/src/server.ts b/src/server.ts index 928de472c..a2269f3d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,43 +3,41 @@ import { FastResponse } from "srvx"; globalThis.Response = FastResponse; -function setIfMissing(headers: Headers, key: string, value: string) { - if (headers.has(key)) return; - headers.set(key, value); +const fontSrc = "'self' https://cdn.jsdelivr.net https://fonts.gstatic.com"; +const scriptSrc = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"; +const styleSrc = "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net"; + +function setIfAbsent(headers: Headers, key: string, value: string) { + if (!headers.has(key)) headers.set(key, value); } -const allowedWebfontOrigins = ["https://cdn.jsdelivr.net", "https://fonts.gstatic.com"] as const; - -const fontSrcWithWebfonts = ["'self'", ...allowedWebfontOrigins].join(" "); - export default createServerEntry({ async fetch(request) { const response = await handler.fetch(request); - const headers = new Headers(response.headers); const contentType = headers.get("content-type") ?? ""; if (request.url.includes("/printer/")) { headers.set( - "Content-Security-Policy", - `default-src 'self'; img-src 'self' data:; font-src ${fontSrcWithWebfonts}; style-src 'self' 'unsafe-inline'; connect-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self';`, + "Content-Security-Policy-Report-Only", + `default-src 'self'; img-src 'self' data:; font-src ${fontSrc}; style-src ${styleSrc}; connect-src 'self'; script-src ${scriptSrc}; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self';`, ); } if (contentType.includes("text/html")) { - setIfMissing(headers, "Cross-Origin-Opener-Policy", "same-origin"); - setIfMissing(headers, "Cross-Origin-Resource-Policy", "same-site"); - setIfMissing( + setIfAbsent(headers, "Cross-Origin-Opener-Policy", "same-origin"); + setIfAbsent(headers, "Cross-Origin-Resource-Policy", "same-site"); + setIfAbsent( headers, - "Content-Security-Policy", - `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src ${fontSrcWithWebfonts} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`, + "Content-Security-Policy-Report-Only", + `default-src 'self'; script-src ${scriptSrc}; worker-src 'self' blob:; style-src ${styleSrc}; img-src 'self' data: blob: https:; font-src ${fontSrc} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`, ); } return new Response(response.body, { + headers, status: response.status, statusText: response.statusText, - headers, }); }, }); diff --git a/src/utils/fonts.test.ts b/src/utils/fonts.test.ts new file mode 100644 index 000000000..77001569b --- /dev/null +++ b/src/utils/fonts.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildResumeFontFamily, + fontList, + getFallbackWebFontFamilies, + getFontDisplayName, + getLoadableWebFontWeights, + localFontList, +} from "./fonts"; + +function splitFontStack(fontStack: string) { + return fontStack.split(", ").map((font) => font.replace(/^'(.*)'$/, "$1")); +} + +describe("fontList", () => { + it("sorts font families alphabetically by display label", () => { + const labels = fontList.map((font) => getFontDisplayName(font.family)); + const sortedLabels = [...labels].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + + expect(labels).toEqual(sortedLabels); + }); +}); + +describe("localFontList", () => { + it("includes common local Chinese font families", () => { + expect(localFontList.some((font) => font.family === "PingFang SC")).toBe(true); + expect(localFontList.some((font) => font.family === "Microsoft YaHei")).toBe(true); + expect(localFontList.some((font) => font.family === "KaiTi")).toBe(true); + expect(localFontList.some((font) => font.family === "FangSong")).toBe(true); + }); +}); + +describe("getFontDisplayName", () => { + it("returns Chinese display labels for curated Chinese fonts", () => { + expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体"); + expect(getFontDisplayName("Microsoft YaHei")).toBe("微软雅黑"); + expect(getFontDisplayName("ZCOOL QingKe HuangYou")).toBe("站酷庆科黄油体"); + }); +}); + +describe("buildResumeFontFamily", () => { + it("uses serif-oriented Chinese fallbacks for serif body fonts", () => { + const fonts = splitFontStack(buildResumeFontFamily("IBM Plex Serif")); + + expect(fonts.slice(0, 5)).toEqual([ + "IBM Plex Serif", + "Noto Serif SC", + "Songti SC", + "SimSun", + "Source Han Serif SC", + ]); + expect(fonts.at(-1)).toBe("serif"); + }); + + it("uses sans-oriented Chinese fallbacks for sans body fonts", () => { + const fonts = splitFontStack(buildResumeFontFamily("Inter")); + + expect(fonts.slice(0, 5)).toEqual([ + "Inter", + "Noto Sans SC", + "PingFang SC", + "Hiragino Sans GB", + "Microsoft YaHei", + ]); + expect(fonts.at(-1)).toBe("sans-serif"); + }); +}); + +describe("getFallbackWebFontFamilies", () => { + it("loads Noto Serif SC as the web fallback for serif fonts", () => { + expect(getFallbackWebFontFamilies("IBM Plex Serif")).toEqual(["Noto Serif SC"]); + }); + + it("does not duplicate the selected web fallback font", () => { + expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]); + }); +}); + +describe("getLoadableWebFontWeights", () => { + it("returns matching weights when the webfont provides them", () => { + expect(getLoadableWebFontWeights("Noto Sans SC", ["400", "700"])).toEqual(["400", "700"]); + }); + + it("falls back to the nearest supported weight when needed", () => { + expect(getLoadableWebFontWeights("ZCOOL QingKe HuangYou", ["600", "700"])).toEqual(["400"]); + }); +}); diff --git a/src/utils/fonts.ts b/src/utils/fonts.ts new file mode 100644 index 000000000..31dd25ef4 --- /dev/null +++ b/src/utils/fonts.ts @@ -0,0 +1,204 @@ +import type { LocalFont, WebFont } from "@/components/typography/types"; +import webFontListJSON from "@/components/typography/webfontlist.json"; + +type FontCategory = LocalFont["category"]; +type FontWeight = LocalFont["weights"][number]; +export type FontRecord = LocalFont | WebFont; + +const preferredChineseFontFamilies = [ + "Noto Sans SC", + "Noto Serif SC", + "PingFang SC", + "Microsoft YaHei", + "Source Han Sans SC", + "Source Han Serif SC", + "Songti SC", + "SimSun", + "SimHei", + "KaiTi", + "FangSong", + "ZCOOL QingKe HuangYou", +] as const; + +const baseLocalFontList = [ + { type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] }, + { type: "local", category: "sans-serif", family: "PingFang SC", weights: ["300", "400", "500", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Hiragino Sans GB", weights: ["300", "400", "500", "600", "700"] }, + { type: "local", category: "sans-serif", family: "Microsoft YaHei", weights: ["400", "700"] }, + { type: "local", category: "sans-serif", family: "SimHei", weights: ["400", "700"] }, + { type: "local", category: "sans-serif", family: "Source Han Sans SC", weights: ["400", "500", "700"] }, + { type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "Songti SC", weights: ["400", "600", "700"] }, + { type: "local", category: "serif", family: "SimSun", weights: ["400", "700"] }, + { type: "local", category: "serif", family: "KaiTi", weights: ["400", "700"] }, + { type: "local", category: "serif", family: "FangSong", weights: ["400", "700"] }, + { type: "local", category: "serif", family: "Source Han Serif SC", weights: ["400", "600", "700"] }, +] satisfies LocalFont[]; + +const fontDisplayNames: Partial> = { + FangSong: "仿宋", + "Hiragino Sans GB": "冬青黑体简体中文", + KaiTi: "楷体", + "Microsoft YaHei": "微软雅黑", + "Noto Sans SC": "思源黑体", + "Noto Sans TC": "思源黑体(繁中)", + "Noto Serif SC": "思源宋体", + "Noto Serif TC": "思源宋体(繁中)", + "PingFang SC": "苹方", + SimHei: "黑体", + SimSun: "宋体", + "Songti SC": "华文宋体", + "Source Han Sans SC": "思源黑体(本地)", + "Source Han Serif SC": "思源宋体(本地)", + "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; + +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])); + +const webFontFamilies = new Set(webFontList.map((font) => font.family)); +const chinesePrioritySet = new Set(preferredChineseFontFamilies); + +export const localFontList = baseLocalFontList.filter((font) => !webFontFamilies.has(font.family)); + +function orderFonts(fonts: FontRecord[]) { + return [...fonts].sort((a, b) => { + const aLabel = getFontDisplayName(a.family); + const bLabel = getFontDisplayName(b.family); + const labelComparison = aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); + + if (labelComparison !== 0) return labelComparison; + + return a.family.localeCompare(b.family, undefined, { sensitivity: "base" }); + }); +} + +export const fontList = orderFonts([...webFontList, ...localFontList]); + +const fontMap = new Map(fontList.map((font) => [font.family, font])); + +function unique(items: T[]) { + return items.filter((item, index) => items.indexOf(item) === index); +} + +function toCSSFontFamilyToken(fontFamily: string) { + if (genericFontFamilies.has(fontFamily)) return fontFamily; + return `'${fontFamily.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; +} + +export function getFont(family: string) { + return fontMap.get(family); +} + +export function getFontCategory(family: string): FontCategory | null { + return getFont(family)?.category ?? null; +} + +export function getFontDisplayName(family: string) { + return fontDisplayNames[family] ?? family; +} + +export function getFontSearchKeywords(family: string) { + return unique( + [family, fontDisplayNames[family], chinesePrioritySet.has(family) ? "中文" : undefined].filter( + (keyword): keyword is string => Boolean(keyword), + ), + ); +} + +function getCjkFallbacksByCategory(category: FontCategory | null) { + return category === "serif" ? resumeCjkSerifFontFallbacks : resumeCjkSansFontFallbacks; +} + +export function getPrimaryCjkWebFont(family: string) { + const category = getFontCategory(family); + return category === "serif" ? "Noto Serif SC" : "Noto Sans SC"; +} + +export function getFallbackWebFontFamilies(family: string) { + const fallback = getPrimaryCjkWebFont(family); + return fallback === family ? [] : [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(", "); +}