+
- {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(", ");
+}