fix(cjk): resolve hyphenation callback with cjk content in resume

This commit is contained in:
Amruth Pillai
2026-05-11 22:04:45 +02:00
parent 0a8fe05653
commit 62b0a1d533
27 changed files with 486 additions and 525 deletions
+7 -7
View File
@@ -19,7 +19,7 @@
"@ai-sdk/react": "^3.0.179",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.10",
"@better-auth/infra": "^0.2.6",
"@better-auth/infra": "^0.2.7",
"@better-auth/oauth-provider": "^1.6.10",
"@better-auth/passkey": "^1.6.10",
"@dnd-kit/core": "^6.3.1",
@@ -50,10 +50,10 @@
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/react-start": "^1.167.65",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "^1.169.8",
"@tanstack/react-router-ssr-query": "^1.166.18",
"@tanstack/react-start": "^1.167.71",
"@tiptap/extension-color": "^3.23.1",
"@tiptap/extension-highlight": "^3.23.1",
"@tiptap/extension-table": "^3.23.1",
@@ -98,12 +98,12 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-macros": "^3.1.0",
"nitro": "3.0.260429-beta",
"typescript": "^6.0.3",
"vite": "^8.0.11",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import type { Locale } from "@/libs/locale";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
+2 -61
View File
@@ -1,70 +1,11 @@
import type { MessageDescriptor, Messages } from "@lingui/core";
import type { Locale } from "@reactive-resume/utils/locale";
import { i18n } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { createIsomorphicFn, createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import Cookies from "js-cookie";
import z from "zod";
const localeSchema = z.union([
z.literal("af-ZA"),
z.literal("am-ET"),
z.literal("ar-SA"),
z.literal("az-AZ"),
z.literal("bg-BG"),
z.literal("bn-BD"),
z.literal("ca-ES"),
z.literal("cs-CZ"),
z.literal("da-DK"),
z.literal("de-DE"),
z.literal("el-GR"),
z.literal("en-US"),
z.literal("en-GB"),
z.literal("es-ES"),
z.literal("fa-IR"),
z.literal("fi-FI"),
z.literal("fr-FR"),
z.literal("he-IL"),
z.literal("hi-IN"),
z.literal("hu-HU"),
z.literal("id-ID"),
z.literal("it-IT"),
z.literal("ja-JP"),
z.literal("km-KH"),
z.literal("kn-IN"),
z.literal("ko-KR"),
z.literal("lt-LT"),
z.literal("lv-LV"),
z.literal("ml-IN"),
z.literal("mr-IN"),
z.literal("ms-MY"),
z.literal("ne-NP"),
z.literal("nl-NL"),
z.literal("no-NO"),
z.literal("or-IN"),
z.literal("pl-PL"),
z.literal("pt-BR"),
z.literal("pt-PT"),
z.literal("ro-RO"),
z.literal("ru-RU"),
z.literal("sk-SK"),
z.literal("sl-SI"),
z.literal("sq-AL"),
z.literal("sr-SP"),
z.literal("sv-SE"),
z.literal("ta-IN"),
z.literal("te-IN"),
z.literal("th-TH"),
z.literal("tr-TR"),
z.literal("uk-UA"),
z.literal("uz-UZ"),
z.literal("vi-VN"),
z.literal("zh-CN"),
z.literal("zh-TW"),
z.literal("zu-ZA"),
]);
export type Locale = z.infer<typeof localeSchema>;
import { localeSchema } from "@reactive-resume/utils/locale";
const storageKey = "locale";
const defaultLocale: Locale = "en-US";
+1 -1
View File
@@ -1,8 +1,8 @@
import type { IconProps } from "@phosphor-icons/react";
import type { FeatureFlags } from "@reactive-resume/api/services/flags";
import type { AuthSession } from "@reactive-resume/auth/types";
import type { Locale } from "@reactive-resume/utils/locale";
import type { QueryClient } from "@tanstack/react-query";
import type { Locale } from "@/libs/locale";
import type { orpc } from "@/libs/orpc/client";
import type { Theme } from "@/libs/theme";
import { i18n } from "@lingui/core";
+3 -3
View File
@@ -13,7 +13,7 @@
"type": "git",
"url": "https://github.com/amruthpillai/reactive-resume.git"
},
"packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38",
"packageManager": "pnpm@11.1.0+sha512.0c44e842e5686b2c061a81adda8b2258bd8818e9704b2cf2c63d56b931a7b2e910092e085027003b96ca3911ab56a07f6df5abaed2be9925034cdd686a535b14",
"workspaces": [
"apps/*",
"packages/*"
@@ -45,13 +45,13 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.2",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.6",
"happy-dom": "^20.9.0",
"knip": "^6.12.2",
"lefthook": "^2.1.6",
"npm-check-updates": "^22.1.1",
"turbo": "^2.9.12",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
"vitest": "^4.1.6"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -32,7 +32,7 @@
"@reactive-resume/env": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"@tanstack/react-start": "^1.167.71",
"ai": "^6.0.177",
"bcrypt": "^6.0.0",
"better-auth": "1.6.10",
@@ -48,7 +48,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+3 -3
View File
@@ -18,14 +18,14 @@
"dependencies": {
"@better-auth/api-key": "^1.6.10",
"@better-auth/drizzle-adapter": "^1.6.10",
"@better-auth/infra": "^0.2.6",
"@better-auth/infra": "^0.2.7",
"@better-auth/oauth-provider": "^1.6.10",
"@better-auth/passkey": "^1.6.10",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/email": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"@tanstack/react-start": "^1.167.71",
"bcrypt": "^6.0.0",
"better-auth": "1.6.10",
"drizzle-orm": "1.0.0-beta.22",
@@ -37,7 +37,7 @@
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"drizzle-kit": "1.0.0-beta.22",
"typescript": "^6.0.3"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.6.2",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+4 -1
View File
@@ -14,9 +14,12 @@
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@reactive-resume/utils": "workspace:*"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+1 -4
View File
@@ -1,3 +1,4 @@
import { unique } from "@reactive-resume/utils/field";
import webFontListJSON from "./webfontlist.json";
export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
@@ -105,10 +106,6 @@ export const standardFontList = standardPdfFontList.filter((font) => !webFontMap
const fontMap = new Map<string, FontRecord>();
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
function unique<T>(items: T[]) {
return items.filter((item, index) => items.indexOf(item) === index);
}
function orderFonts(fonts: FontRecord[]) {
return [...fonts].sort((a, b) => {
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+14 -3
View File
@@ -1,11 +1,12 @@
import type { LayoutPage, ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ComponentType } from "react";
import type { SectionTitleResolver } from "./section-title";
import { Document } from "@react-pdf/renderer";
import { useMemo } from "react";
import { RenderProvider } from "./context";
import { registerFonts } from "./hooks/use-register-fonts";
import { registerFonts, resumeContentContainsCJK } from "./hooks/use-register-fonts";
import { getTemplatePage } from "./templates";
export type TemplatePageProps = {
@@ -23,7 +24,12 @@ export type ResumeDocumentProps = {
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
const TemplatePageComponent = getTemplatePage(template);
const typography = registerFonts(data.metadata.typography) as Typography;
const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]);
const typography = registerFonts(
data.metadata.typography,
data.metadata.page.locale as Locale,
hasCjkContent,
) as Typography;
// `registerFonts` widens `fontFamily` to `string | string[]` for CJK
// fallback (#2986); the cast carries that wider runtime value through
@@ -33,9 +39,14 @@ export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDo
return (
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle}>
<Document
title={`${resumeData.basics.name} Resume`}
pageMode="useNone"
creationDate={new Date()}
producer="Reactive Resume"
title={resumeData.basics.name}
author={resumeData.basics.name}
creator={resumeData.basics.name}
subject={resumeData.basics.headline}
language={resumeData.metadata.page.locale}
>
{resumeData.metadata.layout.pages.map((page, index) => (
<TemplatePageComponent key={index} page={page} pageIndex={index} />
@@ -1,7 +1,8 @@
import type { Typography } from "@reactive-resume/schema/resume/data";
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Font } from "@react-pdf/renderer";
import { getWebFontSource } from "@reactive-resume/fonts";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const typography = {
body: {
@@ -48,7 +49,7 @@ describe("registerFonts", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography);
const pdfTypography = registerFonts(typography, "zh-CN");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
@@ -76,7 +77,7 @@ describe("registerFonts", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
registerFonts(cjkTypography);
registerFonts(cjkTypography, "zh-CN");
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -88,17 +89,93 @@ describe("registerFonts", () => {
);
});
it("skips CJK PDF fallbacks for Latin locale and Latin content", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US");
expect(pdfTypography.body.fontFamily).toBe("IBM Plex Serif");
expect(pdfTypography.heading.fontFamily).toBe("IBM Plex Serif");
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("registers CJK PDF fallbacks for Latin locale when resume content contains CJK text", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US", true);
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("uses CJK line breaking for Latin locale when resume content contains CJK text", async () => {
const registerHyphenationSpy = vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
registerFonts(typography, "en-US", true);
const hyphenationCallback = registerHyphenationSpy.mock.calls.at(-1)?.[0];
expect(hyphenationCallback?.("翠翠红红处处")).toEqual(["翠", "", "翠", "", "红", "", "红", "", "处", "", "处", ""]);
expect(hyphenationCallback?.("Reactive")).toEqual([
"R",
"",
"e",
"",
"a",
"",
"c",
"",
"t",
"",
"i",
"",
"v",
"",
"e",
"",
]);
});
it("returns typography with font weights sorted ascending", async () => {
vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts({
const baseTypography = {
...typography,
body: { ...typography.body, fontFamily: "Source Sans 3", fontWeights: ["800", "600", "400"] },
heading: { ...typography.heading, fontFamily: "Source Sans 3", fontWeights: ["900", "500"] },
});
} satisfies Typography;
const pdfTypography = registerFonts(baseTypography, "en-US");
expect(pdfTypography.body.fontWeights).toEqual(["400", "600", "800"]);
expect(pdfTypography.heading.fontWeights).toEqual(["500", "900"]);
});
});
describe("resumeContentContainsCJK", () => {
it("detects CJK letters in summary content", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
const data = {
...defaultResumeData,
summary: { ...defaultResumeData.summary, content: "<p>翠翠红红处处莺莺燕燕</p>" },
} satisfies ResumeData;
expect(resumeContentContainsCJK(data)).toBe(true);
});
it("does not scan private metadata notes", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
const data = {
...defaultResumeData,
metadata: { ...defaultResumeData.metadata, notes: "中文" },
} satisfies ResumeData;
expect(resumeContentContainsCJK(data)).toBe(false);
});
});
+32 -7
View File
@@ -1,7 +1,8 @@
import type { FontWeight } from "@reactive-resume/fonts";
import type { Typography } from "@reactive-resume/schema/resume/data";
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import { Font } from "@react-pdf/renderer";
import { all as cjk } from "cjk-regex";
import { letters as cjkLetters } from "cjk-regex";
import {
getFont,
getPdfCjkFallbackFontFamily,
@@ -9,6 +10,7 @@ import {
isStandardPdfFontFamily,
sortFontWeights,
} from "@reactive-resume/fonts";
import { isCJKLocale } from "@reactive-resume/utils/locale";
type FontWeightRange = {
lowest: number;
@@ -17,6 +19,7 @@ type FontWeightRange = {
const registeredFontVariants = new Set<string>();
const fallbackFontFamily = "IBM Plex Serif";
const cjkLetterRegex = cjkLetters().toRegExp();
// `fontFamily` is widened to `string | string[]` so react-pdf can do
// glyph-level font fallback for CJK characters (#2986).
@@ -65,9 +68,32 @@ const resolvePdfTypography = (typography: Typography): Typography => {
};
};
export const registerFonts = (typography: Typography): PdfTypography => {
const containsCjkLetters = (value: unknown): boolean => {
if (typeof value === "string") return cjkLetterRegex.test(value);
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(containsCjkLetters);
return Object.values(value as Record<string, unknown>).some(containsCjkLetters);
};
export const resumeContentContainsCJK = (data: ResumeData): boolean => {
return containsCjkLetters({
basics: data.basics,
summary: data.summary,
sections: data.sections,
customSections: data.customSections,
});
};
export const registerFonts = (typography: Typography, locale: Locale, hasCjkContent = false): PdfTypography => {
const needsCjkTextSupport = isCJKLocale(locale) || hasCjkContent;
Font.registerHyphenationCallback((word) => {
if (cjk().toRegExp().test(word)) return word.split("").flatMap((l) => [l, ""]);
if (needsCjkTextSupport) {
if (word === " ") return ["\u200C "];
return [...word].flatMap((l) => [l, ""]);
}
return [word];
});
@@ -102,8 +128,8 @@ export const registerFonts = (typography: Typography): PdfTypography => {
// Register a CJK fallback so textkit can substitute per-codepoint for
// characters the primary font lacks (#2986). One weight per style is
// enough — substitution is per-codepoint, not per-weight.
const bodyCjkFallback = getPdfCjkFallbackFontFamily(bodyFontFamily);
const headingCjkFallback = getPdfCjkFallbackFontFamily(headingFontFamily);
const bodyCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(bodyFontFamily) : null;
const headingCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(headingFontFamily) : null;
if (bodyCjkFallback) {
registerFont(bodyCjkFallback, 400, false);
@@ -126,7 +152,6 @@ export const registerFonts = (typography: Typography): PdfTypography => {
: headingFontFamily;
return {
...pdfTypography,
body: { ...pdfTypography.body, fontFamily: bodyStack },
heading: { ...pdfTypography.heading, fontFamily: headingStack },
};
@@ -10,7 +10,7 @@ import { composeStyles } from "./styles";
export const LevelDisplay = ({ level }: { level: number }) => {
const data = useRender();
const levelDesign = data.metadata.design.level;
const iconSize = data.metadata.typography.body.fontSize - 4;
const iconSize = data.metadata.typography.body.fontSize - 2;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
const levelContainerStyle = useTemplateStyle("levelContainer");
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"@reactive-resume/config": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"drizzle-orm": "1.0.0-beta.22",
"pg": "^8.20.0",
"tsx": "^4.21.0"
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
+1 -1
View File
@@ -45,7 +45,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.6.2",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
"typescript": "^6.0.3"
}
}
+4
View File
@@ -22,3 +22,7 @@ export function filterFieldValues<TKey extends string, TField extends KeyedField
const filteredFields = fields.filter((field) => Boolean(values[field.key]?.trim()));
return new Map(filteredFields.map((field) => [field.key, field] as const));
}
export function unique<T>(items: T[]) {
return items.filter((item, index) => items.indexOf(item) === index);
}
+2 -2
View File
@@ -12,8 +12,8 @@ describe("isLocale", () => {
expect(isLocale("en-US")).toBe(true);
});
it("returns true for any non-empty string (no validation of locale shape)", () => {
expect(isLocale("xyz")).toBe(true);
it("returns false for unsupported locale string", () => {
expect(isLocale("xyz")).toBe(false);
});
it("returns false for empty string", () => {
+66 -3
View File
@@ -1,8 +1,71 @@
// Minimal locale type — the full lingui wiring lives in apps/web (Phase 13).
export type Locale = string;
import z from "zod";
export const localeSchema = z.union([
z.literal("af-ZA"),
z.literal("am-ET"),
z.literal("ar-SA"),
z.literal("az-AZ"),
z.literal("bg-BG"),
z.literal("bn-BD"),
z.literal("ca-ES"),
z.literal("cs-CZ"),
z.literal("da-DK"),
z.literal("de-DE"),
z.literal("el-GR"),
z.literal("en-US"),
z.literal("en-GB"),
z.literal("es-ES"),
z.literal("fa-IR"),
z.literal("fi-FI"),
z.literal("fr-FR"),
z.literal("he-IL"),
z.literal("hi-IN"),
z.literal("hu-HU"),
z.literal("id-ID"),
z.literal("it-IT"),
z.literal("ja-JP"),
z.literal("km-KH"),
z.literal("kn-IN"),
z.literal("ko-KR"),
z.literal("lt-LT"),
z.literal("lv-LV"),
z.literal("ml-IN"),
z.literal("mr-IN"),
z.literal("ms-MY"),
z.literal("ne-NP"),
z.literal("nl-NL"),
z.literal("no-NO"),
z.literal("or-IN"),
z.literal("pl-PL"),
z.literal("pt-BR"),
z.literal("pt-PT"),
z.literal("ro-RO"),
z.literal("ru-RU"),
z.literal("sk-SK"),
z.literal("sl-SI"),
z.literal("sq-AL"),
z.literal("sr-SP"),
z.literal("sv-SE"),
z.literal("ta-IN"),
z.literal("te-IN"),
z.literal("th-TH"),
z.literal("tr-TR"),
z.literal("uk-UA"),
z.literal("uz-UZ"),
z.literal("vi-VN"),
z.literal("zh-CN"),
z.literal("zh-TW"),
z.literal("zu-ZA"),
]);
export type Locale = z.infer<typeof localeSchema>;
export const defaultLocale: Locale = "en-US";
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && value.length > 0;
return localeSchema.safeParse(value).success;
}
export function isCJKLocale(locale: Locale): boolean {
return locale === "zh-CN" || locale === "zh-TW" || locale === "ja-JP" || locale === "ko-KR";
}
+251 -411
View File
File diff suppressed because it is too large Load Diff