chore: complete clean Agent Desk source snapshot
This commit is contained in:
57
web/i18n/config.test.mjs
Normal file
57
web/i18n/config.test.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadConfig() {
|
||||
const source = await readFile(new URL("./config.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "config.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("normalizes supported locale aliases", async () => {
|
||||
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
|
||||
|
||||
assert.equal(DEFAULT_LOCALE, "en-US")
|
||||
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
|
||||
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
|
||||
assert.equal(normalizeLocale("zh"), "zh-CN")
|
||||
assert.equal(normalizeLocale("en-US"), "en-US")
|
||||
assert.equal(normalizeLocale("en_US"), "en-US")
|
||||
assert.equal(normalizeLocale("en"), "en-US")
|
||||
assert.equal(normalizeLocale("ru-RU"), "ru-RU")
|
||||
assert.equal(normalizeLocale("ru_ru"), "ru-RU")
|
||||
assert.equal(normalizeLocale("RU"), "ru-RU")
|
||||
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
|
||||
})
|
||||
|
||||
test("lists every supported locale in SUPPORTED_LOCALES", async () => {
|
||||
const { SUPPORTED_LOCALES } = await loadConfig()
|
||||
|
||||
assert.deepEqual([...SUPPORTED_LOCALES], ["zh-CN", "en-US", "ru-RU"])
|
||||
})
|
||||
|
||||
test("reads the configured locale without browser language detection", async () => {
|
||||
const { configureLocale, readStoredLocale } = await loadConfig()
|
||||
|
||||
assert.equal(readStoredLocale(), "en-US")
|
||||
|
||||
configureLocale("en-US")
|
||||
assert.equal(readStoredLocale(), "en-US")
|
||||
|
||||
configureLocale("ru-RU")
|
||||
assert.equal(readStoredLocale(), "ru-RU")
|
||||
})
|
||||
51
web/i18n/config.ts
Normal file
51
web/i18n/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ru-RU"] as const
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
export const DEFAULT_LOCALE: AppLocale = "en-US"
|
||||
|
||||
const LOCALE_ALIASES: Record<string, AppLocale> = {
|
||||
zh: "zh-CN",
|
||||
"zh-cn": "zh-CN",
|
||||
zh_cn: "zh-CN",
|
||||
"zh-hans": "zh-CN",
|
||||
en: "en-US",
|
||||
"en-us": "en-US",
|
||||
en_us: "en-US",
|
||||
ru: "ru-RU",
|
||||
"ru-ru": "ru-RU",
|
||||
ru_ru: "ru-RU",
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): AppLocale {
|
||||
return normalizeSupportedLocale(value) ?? DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
function normalizeSupportedLocale(
|
||||
value: string | null | undefined
|
||||
): AppLocale | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const key = value.trim().toLowerCase()
|
||||
return LOCALE_ALIASES[key] ?? null
|
||||
}
|
||||
|
||||
export function isSupportedLocale(
|
||||
value: string | null | undefined
|
||||
): value is AppLocale {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
return SUPPORTED_LOCALES.includes(value as AppLocale)
|
||||
}
|
||||
|
||||
let configuredLocale: AppLocale = DEFAULT_LOCALE
|
||||
|
||||
export function readStoredLocale(): AppLocale {
|
||||
return configuredLocale
|
||||
}
|
||||
|
||||
export function configureLocale(locale: string | null | undefined): AppLocale {
|
||||
configuredLocale = normalizeLocale(locale)
|
||||
return configuredLocale
|
||||
}
|
||||
91
web/i18n/legal-messages.test.mjs
Normal file
91
web/i18n/legal-messages.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ru-RU"]
|
||||
|
||||
async function loadMessages(locale) {
|
||||
const source = await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")
|
||||
return JSON.parse(source)
|
||||
}
|
||||
|
||||
function flatten(source, prefix = "", target = {}) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
flatten(value, path, target)
|
||||
continue
|
||||
}
|
||||
target[path] = value
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return ((String(value).match(/\{(\w+)\}/g) ?? []).slice().sort().join("|"))
|
||||
}
|
||||
|
||||
test("legal pages have localized document content", async () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const messages = await loadMessages(locale)
|
||||
|
||||
assert.equal(typeof messages.legal.terms.title, "string")
|
||||
assert.equal(typeof messages.legal.privacy.title, "string")
|
||||
assert.ok(messages.legal.terms.sections.length >= 6)
|
||||
assert.ok(messages.legal.privacy.sections.length >= 6)
|
||||
|
||||
for (const page of [messages.legal.terms, messages.legal.privacy]) {
|
||||
assert.equal(typeof page.updatedAt, "string")
|
||||
assert.equal(typeof page.relatedLabel, "string")
|
||||
assert.equal(typeof page.relatedLink, "string")
|
||||
for (const section of page.sections) {
|
||||
assert.equal(typeof section.title, "string")
|
||||
assert.equal(typeof section.body, "string")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("every locale bundle defines the same keys as the default locale", async () => {
|
||||
const base = flatten(await loadMessages("zh-CN"))
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const messages = flatten(await loadMessages(locale))
|
||||
|
||||
for (const key of Object.keys(base)) {
|
||||
assert.ok(key in messages, `${locale}.json is missing key ${key}`)
|
||||
}
|
||||
for (const key of Object.keys(messages)) {
|
||||
assert.ok(key in base, `${locale}.json has extra key ${key}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("every locale keeps the same interpolation placeholders", async () => {
|
||||
const base = flatten(await loadMessages("zh-CN"))
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const messages = flatten(await loadMessages(locale))
|
||||
|
||||
for (const [key, value] of Object.entries(base)) {
|
||||
if (typeof value !== "string" || Array.isArray(value)) {
|
||||
continue
|
||||
}
|
||||
assert.equal(
|
||||
placeholders(messages[key]),
|
||||
placeholders(value),
|
||||
`${locale}.json key ${key} placeholders differ`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("locale labels exist for every supported locale", async () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const messages = await loadMessages(locale)
|
||||
|
||||
for (const supported of SUPPORTED_LOCALES) {
|
||||
assert.equal(typeof messages.locale[supported], "string")
|
||||
}
|
||||
}
|
||||
})
|
||||
174
web/i18n/message-integrity.test.mjs
Normal file
174
web/i18n/message-integrity.test.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { readFile, readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const WEB_ROOT = new URL("../", import.meta.url)
|
||||
const LOCALES = ["zh-CN", "en-US", "ru-RU"]
|
||||
const SOURCE_DIRS = ["app", "components", "lib"]
|
||||
const SKIP_DIRS = new Set(["node_modules", ".next", "out", "public"])
|
||||
|
||||
async function collectSourceFiles(dir) {
|
||||
const entries = await readdir(new URL(dir, WEB_ROOT), { withFileTypes: true })
|
||||
const files = []
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) files.push(...(await collectSourceFiles(path.posix.join(dir, entry.name))))
|
||||
continue
|
||||
}
|
||||
if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.mjs$/.test(entry.name)) {
|
||||
files.push(path.posix.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds duplicate keys inside any JSON object. JSON.parse keeps only the last
|
||||
* occurrence, so a repeated top-level key silently deletes every sibling of the
|
||||
* block that shadowed it - that is how the workflowRun namespace lost 39 keys.
|
||||
*/
|
||||
function findDuplicateKeys(text) {
|
||||
const stack = []
|
||||
const trail = []
|
||||
const duplicates = []
|
||||
let index = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
let stringStart = -1
|
||||
let expectingKey = false
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index]
|
||||
|
||||
if (inString) {
|
||||
if (escaped) escaped = false
|
||||
else if (char === "\\") escaped = true
|
||||
else if (char === '"') {
|
||||
inString = false
|
||||
const raw = text.slice(stringStart + 1, index)
|
||||
const top = stack[stack.length - 1]
|
||||
if (expectingKey && top && !top.isArray) {
|
||||
if (top.keys.has(raw)) duplicates.push([...trail, raw].join("."))
|
||||
else top.keys.add(raw)
|
||||
expectingKey = false
|
||||
}
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true
|
||||
escaped = false
|
||||
stringStart = index
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (char === "{") {
|
||||
stack.push({ isArray: false, keys: new Set() })
|
||||
trail.push("<object>")
|
||||
expectingKey = true
|
||||
} else if (char === "[") {
|
||||
stack.push({ isArray: true, keys: new Set() })
|
||||
trail.push("<array>")
|
||||
expectingKey = false
|
||||
} else if (char === "}" || char === "]") {
|
||||
stack.pop()
|
||||
trail.pop()
|
||||
expectingKey = false
|
||||
} else if (char === ",") {
|
||||
const top = stack[stack.length - 1]
|
||||
expectingKey = Boolean(top) && !top.isArray
|
||||
} else if (char === ":") {
|
||||
expectingKey = false
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
return duplicates
|
||||
}
|
||||
|
||||
test("no message bundle declares the same key twice", async () => {
|
||||
for (const locale of LOCALES) {
|
||||
const source = await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8")
|
||||
assert.deepEqual(findDuplicateKeys(source), [], `${locale}.json has duplicate keys`)
|
||||
}
|
||||
})
|
||||
|
||||
// Keys the UI resolves through t()/translateMessage(), plus the data-driven
|
||||
// titleKey-style props. Scanning only t() calls misses lib/navigation.tsx,
|
||||
// which is where the reported sidebar gaps lived.
|
||||
const KEY_PATTERNS = [
|
||||
/\b(?:t|translateCurrentMessage|translateMessage)\(\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
|
||||
/\b(?:titleKey|labelKey|descriptionKey|textKey|placeholderKey|ariaLabelKey|tooltipKey):\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
|
||||
]
|
||||
|
||||
test("every key the UI asks for exists in every locale", async () => {
|
||||
const bundles = {}
|
||||
for (const locale of LOCALES) {
|
||||
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
|
||||
}
|
||||
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
|
||||
|
||||
const files = (await Promise.all(SOURCE_DIRS.map((dir) => collectSourceFiles(dir)))).flat()
|
||||
const missing = []
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(new URL(file, WEB_ROOT), "utf8")
|
||||
for (const pattern of KEY_PATTERNS) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
for (const locale of LOCALES) {
|
||||
if (lookup(bundles[locale], match[1]) === undefined) {
|
||||
missing.push(`${file}: ${match[1]} (missing in ${locale})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual([...new Set(missing)], [])
|
||||
})
|
||||
|
||||
// Template keys such as `supportPublic.comment.sort.${sort}` cannot be resolved
|
||||
// by scanning source, so their known value sets are pinned here.
|
||||
const DYNAMIC_KEYS = {
|
||||
"supportPublic.comment.sort": ["default", "hot", "latest"],
|
||||
"supportPublic.help": ["previous", "next"],
|
||||
supportChat: ["connecting", "connected", "disconnected"],
|
||||
agentTeamSchedule: ["weekdayMon", "weekdayTue", "weekdayWed", "weekdayThu", "weekdayFri", "weekdaySat", "weekdaySun"],
|
||||
supportHelpWorkbench: [
|
||||
"publishedAction",
|
||||
"publishedConfirmTitle",
|
||||
"publishedConfirmDescription",
|
||||
"publishedSuccess",
|
||||
"draftAction",
|
||||
"draftConfirmTitle",
|
||||
"draftConfirmDescription",
|
||||
"draftSuccess",
|
||||
"hiddenAction",
|
||||
"hiddenConfirmTitle",
|
||||
"hiddenConfirmDescription",
|
||||
"hiddenSuccess",
|
||||
],
|
||||
}
|
||||
|
||||
test("every dynamically composed key variant exists in every locale", async () => {
|
||||
const bundles = {}
|
||||
for (const locale of LOCALES) {
|
||||
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
|
||||
}
|
||||
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
|
||||
|
||||
const missing = []
|
||||
for (const [prefix, variants] of Object.entries(DYNAMIC_KEYS)) {
|
||||
for (const variant of variants) {
|
||||
for (const locale of LOCALES) {
|
||||
if (lookup(bundles[locale], `${prefix}.${variant}`) === undefined) {
|
||||
missing.push(`${prefix}.${variant} (missing in ${locale})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, [])
|
||||
})
|
||||
53
web/i18n/messages.ts
Normal file
53
web/i18n/messages.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DEFAULT_LOCALE, type AppLocale, readStoredLocale } from "@/i18n/config"
|
||||
import enUSMessages from "@/messages/en-US.json"
|
||||
import ruRUMessages from "@/messages/ru-RU.json"
|
||||
import zhCNMessages from "@/messages/zh-CN.json"
|
||||
|
||||
const messages = {
|
||||
"zh-CN": zhCNMessages,
|
||||
"en-US": enUSMessages,
|
||||
"ru-RU": ruRUMessages,
|
||||
} satisfies Record<AppLocale, typeof zhCNMessages>
|
||||
|
||||
export function translateMessage(
|
||||
locale: AppLocale,
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
const value = getMessageValue(messages[locale], key)
|
||||
if (typeof value === "string") {
|
||||
return formatMessage(value, values)
|
||||
}
|
||||
const fallback = getMessageValue(messages[DEFAULT_LOCALE], key)
|
||||
return typeof fallback === "string" ? formatMessage(fallback, values) : key
|
||||
}
|
||||
|
||||
export function translateCurrentMessage(
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
return translateMessage(readStoredLocale(), key, values)
|
||||
}
|
||||
|
||||
function getMessageValue(source: unknown, key: string): unknown {
|
||||
let current = source
|
||||
for (const part of key.split(".")) {
|
||||
if (!current || typeof current !== "object" || !(part in current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function formatMessage(
|
||||
message: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
if (!values) {
|
||||
return message
|
||||
}
|
||||
return message.replace(/\{(\w+)\}/g, (match, key) =>
|
||||
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
|
||||
)
|
||||
}
|
||||
87
web/i18n/provider.tsx
Normal file
87
web/i18n/provider.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
type AppLocale,
|
||||
configureLocale,
|
||||
} from "@/i18n/config"
|
||||
import { translateMessage } from "@/i18n/messages"
|
||||
import { fetchPublicConfig } from "@/lib/api/config"
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: AppLocale
|
||||
setLocale: (locale: AppLocale) => void
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue>({
|
||||
locale: DEFAULT_LOCALE,
|
||||
setLocale: () => {},
|
||||
t: (key) => key,
|
||||
})
|
||||
|
||||
export function AppI18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<AppLocale>(DEFAULT_LOCALE)
|
||||
const [isLocaleReady, setIsLocaleReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetchPublicConfig()
|
||||
.then((config) => configureLocale(config.language))
|
||||
.catch(() => configureLocale(DEFAULT_LOCALE))
|
||||
.then((configuredLocale) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setLocaleState(configuredLocale)
|
||||
document.documentElement.lang = configuredLocale
|
||||
document.title = translateMessage(configuredLocale, "app.metadataTitle")
|
||||
setIsLocaleReady(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = translateMessage(locale, "app.metadataTitle")
|
||||
}, [locale])
|
||||
|
||||
const value = useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
t: (key, values) => translateMessage(locale, key, values),
|
||||
setLocale: () => {},
|
||||
}),
|
||||
[locale]
|
||||
)
|
||||
|
||||
if (!isLocaleReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={value}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
return useContext(LocaleContext)
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(LocaleContext).t
|
||||
}
|
||||
Reference in New Issue
Block a user