mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: add Chinese font options (#2905)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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) {
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button>
|
||||
<Link to="..">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Link to=".." className={buttonVariants()}>
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<z.infer<typeof resumeDataSchema>, "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,
|
||||
|
||||
@@ -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<typeof typographySchema>) {
|
||||
const isMounted = useIsMounted();
|
||||
@@ -17,7 +17,7 @@ export function useWebfonts(typography: z.infer<typeof typographySchema>) {
|
||||
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<typeof typographySchema>) {
|
||||
|
||||
const bodyTypography = typography.body;
|
||||
const headingTypography = typography.heading;
|
||||
const fontWeightsByFamily = new Map<string, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, WebFont>();
|
||||
|
||||
for (const font of webFontList) {
|
||||
webFontMap.set(font.family, font);
|
||||
}
|
||||
|
||||
return webFontMap;
|
||||
}
|
||||
|
||||
const webFontMap: Map<string, WebFont> = 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<SingleComboboxProps, "options">;
|
||||
|
||||
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: <FontDisplay name={font.family} type={font.type} url={"preview" in font ? font.preview : undefined} />,
|
||||
keywords: getFontSearchKeywords(font.family),
|
||||
label: (
|
||||
<FontDisplay
|
||||
family={font.family}
|
||||
label={getFontDisplayName(font.family)}
|
||||
type={font.type}
|
||||
url={"preview" in font ? font.preview : undefined}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="inline">
|
||||
<div ref={containerRef} className="inline-flex items-baseline gap-2">
|
||||
<span
|
||||
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
|
||||
>
|
||||
{name}
|
||||
{label}
|
||||
</span>
|
||||
{label !== family && <span className="text-xs text-muted-foreground">{family}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<RouterOutput["resume"]["getBySlug"], "data"> & { 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") {
|
||||
|
||||
+14
-16
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
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<string, WebFont>(webFontList.map((font) => [font.family, font]));
|
||||
|
||||
const webFontFamilies = new Set(webFontList.map((font) => font.family));
|
||||
const chinesePrioritySet = new Set<string>(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<T>(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<FontWeight>(font.weights);
|
||||
const matchingWeights = unique(preferredWeights).filter(
|
||||
(weight): weight is FontWeight => availableWeights.has(weight as FontWeight),
|
||||
);
|
||||
|
||||
if (matchingWeights.length > 0) return matchingWeights;
|
||||
|
||||
const defaultWeights = ["400", "500", "600", "700"].filter((weight): weight is FontWeight =>
|
||||
availableWeights.has(weight as FontWeight),
|
||||
);
|
||||
if (defaultWeights.length > 0) return defaultWeights.slice(0, 2);
|
||||
|
||||
return font.weights.slice(0, 2);
|
||||
}
|
||||
|
||||
export function buildResumeFontFamily(fontFamily: string) {
|
||||
const category = getFontCategory(fontFamily);
|
||||
const genericFallback = category === "serif" ? "serif" : "sans-serif";
|
||||
|
||||
return unique([
|
||||
fontFamily,
|
||||
...getCjkFallbacksByCategory(category),
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
genericFallback,
|
||||
])
|
||||
.map(toCSSFontFamilyToken)
|
||||
.join(", ");
|
||||
}
|
||||
Reference in New Issue
Block a user