mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
Moves ~600 strings in the root screens, settings, pairing, onboarding, accounts, agent history, diagnostics, connection status, home and host workspace lists to the translation API, with all ten translations. - Connection verdicts carry an explicit `dialing` flag instead of matching the "…" ending of an English label - Update-failure copy and connection-path accessibility labels are built from data, not by editing translated text - Workspace status names stay the host's labels, as on desktop; transport errors stay English - The stale-key check keeps a plural family while any of its forms is referenced Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
|
import ts from 'typescript-api'
|
|
|
|
import { main as verifyRuntimeCatalog } from './generate-runtime-required-english-catalog.mjs'
|
|
import { LOCALIZATION_SURFACES } from './localization-surfaces.mjs'
|
|
import {
|
|
collectLocalizationReferences,
|
|
collectSourceFiles,
|
|
main as verifyCatalog
|
|
} from './verify-localization-catalog.mjs'
|
|
import {
|
|
collectKeyLiterals,
|
|
compareExtraction,
|
|
findUnreferencedKeys
|
|
} from './verify-localization-extraction.mjs'
|
|
|
|
const SURFACE = LOCALIZATION_SURFACES.mobile
|
|
const APP_CONFIG_PATH = path.join('mobile', 'app.json')
|
|
const ANDROID_LOCALE_PLUGIN = './plugins/android-locale-config.js'
|
|
const PLURAL_POLYFILL_PATH = path.join('mobile', 'src', 'i18n', 'plural-rules-polyfill.ts')
|
|
// The runtime owns the subscription that every other caller must go through.
|
|
const I18N_RUNTIME_DIR = 'mobile/src/i18n/'
|
|
const UI_LOCALE_SOURCE_PATH = path.join('src', 'shared', 'ui-locale.ts')
|
|
// Apple lproj and Android locale_config name these by script/region; the rest match the UI locale id.
|
|
const NATIVE_LOCALE_TAGS = { zh: 'zh-Hans', pt: 'pt-BR' }
|
|
const PLURAL_SUFFIX_RE = /^(.*)_(zero|one|two|few|many|other)$/
|
|
const FUNCTION_LIKE_KINDS = new Set([
|
|
ts.SyntaxKind.FunctionDeclaration,
|
|
ts.SyntaxKind.FunctionExpression,
|
|
ts.SyntaxKind.ArrowFunction,
|
|
ts.SyntaxKind.MethodDeclaration,
|
|
ts.SyntaxKind.Constructor,
|
|
ts.SyntaxKind.GetAccessor,
|
|
ts.SyntaxKind.SetAccessor
|
|
])
|
|
|
|
function normalizePath(root, filePath) {
|
|
return path.relative(root, filePath).split(path.sep).join('/')
|
|
}
|
|
|
|
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
|
if (typeof value === 'string') {
|
|
entries.set(prefix, value)
|
|
return entries
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return entries
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function nestCatalog(entries) {
|
|
const catalog = {}
|
|
for (const [key, value] of entries) {
|
|
const parts = key.split('.')
|
|
let cursor = catalog
|
|
for (const part of parts.slice(0, -1)) {
|
|
cursor[part] ??= {}
|
|
cursor = cursor[part]
|
|
}
|
|
cursor[parts.at(-1)] = value
|
|
}
|
|
return catalog
|
|
}
|
|
|
|
async function readJson(root, relativePath) {
|
|
return JSON.parse(await fs.readFile(path.join(root, relativePath), 'utf8'))
|
|
}
|
|
|
|
async function readSupportedUiLocales(root) {
|
|
const source = await fs.readFile(path.join(root, UI_LOCALE_SOURCE_PATH), 'utf8')
|
|
const list = /SUPPORTED_UI_LOCALES = \[([^\]]*)\]/.exec(source)?.[1] ?? ''
|
|
return [...list.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
|
}
|
|
|
|
function report(problems, heading, lines) {
|
|
if (lines.length === 0) {
|
|
return
|
|
}
|
|
problems.push(`${heading}\n${lines.map((line) => ` ${line}`).join('\n')}`)
|
|
}
|
|
|
|
// Extraction here is the call-site scan itself: every literal `t`/`translate` key and fallback.
|
|
export function collectExtractionProblems(references, englishCatalog, keyEvidence) {
|
|
const extracted = new Map()
|
|
for (const reference of references) {
|
|
// A dynamic fallback elsewhere must not hide a literal one that drifted from en.json.
|
|
if (typeof reference.fallback === 'string') {
|
|
extracted.set(reference.key, reference.fallback)
|
|
} else if (!extracted.has(reference.key)) {
|
|
extracted.set(reference.key, '')
|
|
}
|
|
}
|
|
const result = compareExtraction(nestCatalog(extracted), englishCatalog)
|
|
// A family is live when any form is named: a counted `items_one` call still resolves `items_other`.
|
|
const familyOf = (key) => PLURAL_SUFFIX_RE.exec(key)?.[1]
|
|
const liveFamilies = new Set(
|
|
[...result.extracted.keys(), ...keyEvidence.literals].map(familyOf).filter(Boolean)
|
|
)
|
|
const unreferenced = findUnreferencedKeys(result.orphans, result.extracted, keyEvidence).filter(
|
|
(key) => !liveFamilies.has(familyOf(key))
|
|
)
|
|
return {
|
|
fallbackDrift: result.fallbackDrift.map(
|
|
(key) =>
|
|
`${key}\n en.json: ${JSON.stringify(result.english.get(key))}\n source: ${JSON.stringify(result.extracted.get(key))}`
|
|
),
|
|
unreferenced
|
|
}
|
|
}
|
|
|
|
// A locale that translates a plural family must supply each of its CLDR categories, or counts fall back to English.
|
|
export function collectIncompletePluralFamilies(englishEntries, localeEntries, locale) {
|
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories
|
|
const families = new Set()
|
|
for (const key of englishEntries.keys()) {
|
|
const plural = PLURAL_SUFFIX_RE.exec(key)
|
|
if (plural) {
|
|
families.add(plural[1])
|
|
}
|
|
}
|
|
const incomplete = []
|
|
for (const family of families) {
|
|
const supplied = [...localeEntries.keys()].some(
|
|
(key) => PLURAL_SUFFIX_RE.exec(key)?.[1] === family
|
|
)
|
|
const translated = categories.filter((category) => localeEntries.has(`${family}_${category}`))
|
|
if (supplied && translated.length < categories.length) {
|
|
const missing = categories.filter((category) => !localeEntries.has(`${family}_${category}`))
|
|
incomplete.push(`${family}: missing ${missing.map((category) => `_${category}`).join(', ')}`)
|
|
}
|
|
}
|
|
return incomplete
|
|
}
|
|
|
|
function functionName(node) {
|
|
if (node.name && ts.isIdentifier(node.name)) {
|
|
return node.name.text
|
|
}
|
|
const isDefaultExport =
|
|
ts.isExportAssignment(node.parent) ||
|
|
(node.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
if (isDefaultExport) {
|
|
// `export default function () {}` in a .tsx file is a screen component.
|
|
return 'DefaultExport'
|
|
}
|
|
let current = node.parent
|
|
// Unwrap memo(...)/forwardRef(...) so `const Row = memo(() => ...)` is still the component Row.
|
|
while (current && ts.isCallExpression(current)) {
|
|
current = current.parent
|
|
}
|
|
if (current && ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) {
|
|
return current.name.text
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function isComponentOrHook(name, isTsx) {
|
|
return name !== undefined && (/^use[A-Z0-9]/.test(name) || (isTsx && /^[A-Z]/.test(name)))
|
|
}
|
|
|
|
const DEFERRED_CALLEES = new Set([
|
|
'useCallback',
|
|
'useEffect',
|
|
'useLayoutEffect',
|
|
'useInsertionEffect',
|
|
'setTimeout',
|
|
'requestAnimationFrame',
|
|
'queueMicrotask',
|
|
'then',
|
|
'catch',
|
|
'finally'
|
|
])
|
|
|
|
// Handlers, effects and async continuations run after render, reading the language current then.
|
|
function runsAfterRender(fn) {
|
|
if (fn.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) {
|
|
return true
|
|
}
|
|
const parent = fn.parent
|
|
if (ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent)) {
|
|
return /^on[A-Z]/.test(parent.parent.name.getText())
|
|
}
|
|
// Named local functions are callbacks unless they are render helpers (`renderRow`).
|
|
const name = ts.isFunctionDeclaration(fn)
|
|
? fn.name?.text
|
|
: (ts.isPropertyAssignment(parent) || ts.isVariableDeclaration(parent)) &&
|
|
ts.isIdentifier(parent.name)
|
|
? parent.name.text
|
|
: undefined
|
|
if (name !== undefined) {
|
|
return !/^render/i.test(name)
|
|
}
|
|
if (ts.isCallExpression(parent) && parent.arguments.includes(fn)) {
|
|
const callee = ts.isPropertyAccessExpression(parent.expression)
|
|
? parent.expression.name.text
|
|
: ts.isIdentifier(parent.expression)
|
|
? parent.expression.text
|
|
: ''
|
|
return DEFERRED_CALLEES.has(callee)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Render-time code inside a component or hook: walk out through nested callbacks (map, memo) until
|
|
// a handler/effect boundary or the component itself.
|
|
function rendersInComponentOrHook(functions, isTsx) {
|
|
for (const fn of functions) {
|
|
if (isComponentOrHook(functionName(fn), isTsx)) {
|
|
return true
|
|
}
|
|
if (runsAfterRender(fn)) {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Live switching needs subscribed renders: render bodies and memos of components and hooks take `t`
|
|
// from useMobileTranslation() (handlers and effects may call translate() when they run),
|
|
// and nothing translates at import time, before the saved language is active.
|
|
export function collectLiveSwitchViolations(filePath, sourceText) {
|
|
const isTsx = filePath.endsWith('.tsx')
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
isTsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
)
|
|
const violations = []
|
|
|
|
function visit(node) {
|
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
const callee = node.expression.text
|
|
if (callee === 'translate' || callee === 't') {
|
|
const functions = []
|
|
for (let current = node.parent; current; current = current.parent) {
|
|
if (FUNCTION_LIKE_KINDS.has(current.kind)) {
|
|
functions.push(current)
|
|
}
|
|
}
|
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
const location = `${filePath}:${line + 1}`
|
|
if (functions.length === 0) {
|
|
violations.push(
|
|
`${location} translates at module scope; translate when rendering instead`
|
|
)
|
|
} else if (
|
|
callee === 'translate' &&
|
|
!filePath.startsWith(I18N_RUNTIME_DIR) &&
|
|
rendersInComponentOrHook(functions, isTsx)
|
|
) {
|
|
violations.push(
|
|
`${location} calls translate() in a component or hook; use \`const t = useMobileTranslation()\``
|
|
)
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return violations
|
|
}
|
|
|
|
async function collectNativeLocaleProblems(root, supportedLocales) {
|
|
const problems = []
|
|
const expo = (await readJson(root, APP_CONFIG_PATH)).expo
|
|
const expected = supportedLocales.map((locale) => NATIVE_LOCALE_TAGS[locale] ?? locale).sort()
|
|
const pluginLocales = (name) =>
|
|
expo.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === name)?.[1]
|
|
const declared = {
|
|
'expo.locales': Object.keys(expo.locales ?? {}),
|
|
'expo-localization supportedLocales.ios':
|
|
pluginLocales('expo-localization')?.supportedLocales?.ios ?? [],
|
|
[ANDROID_LOCALE_PLUGIN]: pluginLocales(ANDROID_LOCALE_PLUGIN)?.locales ?? []
|
|
}
|
|
for (const [where, tags] of Object.entries(declared)) {
|
|
if ([...tags].sort().join() !== expected.join()) {
|
|
problems.push(`${where} lists ${tags.join(', ')}; expected ${expected.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
const english = await readJson(root, path.join('mobile', expo.locales.en))
|
|
const englishKeys = Object.keys(english.ios ?? {}).sort()
|
|
for (const [tag, file] of Object.entries(expo.locales ?? {})) {
|
|
const strings = (await readJson(root, path.join('mobile', file))).ios ?? {}
|
|
if (Object.keys(strings).sort().join() !== englishKeys.join()) {
|
|
problems.push(`${file} must define exactly ${englishKeys.join(', ')}`)
|
|
}
|
|
for (const [key, value] of Object.entries(strings)) {
|
|
// Expo writes InfoPlist.strings without escaping.
|
|
if (typeof value !== 'string' || !value.trim() || /["\\\n]/.test(value)) {
|
|
problems.push(`${file} ${key} must be one line of text without " or \\ (${tag})`)
|
|
}
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
async function collectPolyfillProblems(root, supportedLocales) {
|
|
const source = await fs.readFile(path.join(root, PLURAL_POLYFILL_PATH), 'utf8')
|
|
const languages = new Set(supportedLocales.map((locale) => locale.split('-')[0]))
|
|
return [...languages]
|
|
.filter((language) => !source.includes(`locale-data/${language}.js'`))
|
|
.map((language) => `${PLURAL_POLYFILL_PATH} does not load plural rules for ${language}`)
|
|
}
|
|
|
|
// --fix drops keys no code names any more from every catalog, plural siblings included.
|
|
async function pruneCatalogKeys(root, keys) {
|
|
// Locale-only plural forms (few, many) go only when no English form of the family is still used.
|
|
const english = flattenCatalog(
|
|
JSON.parse(await fs.readFile(path.join(root, SURFACE.localesDir, 'en.json'), 'utf8'))
|
|
)
|
|
const familyOf = (key) => PLURAL_SUFFIX_RE.exec(key)?.[1]
|
|
const families = new Set(
|
|
keys
|
|
.map(familyOf)
|
|
.filter(
|
|
(family) =>
|
|
family &&
|
|
[...english.keys()].every((key) => familyOf(key) !== family || keys.includes(key))
|
|
)
|
|
)
|
|
const doomed = (key) =>
|
|
keys.includes(key) || families.has(PLURAL_SUFFIX_RE.exec(key)?.[1] ?? '\0')
|
|
const dir = path.join(root, SURFACE.localesDir)
|
|
for (const name of (await fs.readdir(dir)).filter((file) => file.endsWith('.json'))) {
|
|
const entries = flattenCatalog(JSON.parse(await fs.readFile(path.join(dir, name), 'utf8')))
|
|
const kept = new Map([...entries].filter(([key]) => !doomed(key)))
|
|
if (kept.size !== entries.size) {
|
|
await fs.writeFile(path.join(dir, name), `${JSON.stringify(nestCatalog(kept), null, 2)}\n`)
|
|
}
|
|
}
|
|
console.log(`Removed ${keys.length} unreferenced key(s) from the mobile catalogs.`)
|
|
}
|
|
|
|
async function scanSources(root) {
|
|
const files = []
|
|
for (const sourceRoot of SURFACE.sourceRoots) {
|
|
files.push(...(await collectSourceFiles(root, path.join(root, sourceRoot))))
|
|
}
|
|
const keyEvidence = { literals: new Set(), dynamicPrefixes: new Set() }
|
|
const liveSwitchViolations = []
|
|
for (const file of files) {
|
|
const text = await fs.readFile(file, 'utf8')
|
|
collectKeyLiterals(text, keyEvidence)
|
|
liveSwitchViolations.push(...collectLiveSwitchViolations(normalizePath(root, file), text))
|
|
}
|
|
return { keyEvidence, liveSwitchViolations }
|
|
}
|
|
|
|
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
|
const fix = argv.includes('--fix')
|
|
const references = await collectLocalizationReferences(root, SURFACE.sourceRoots)
|
|
const { keyEvidence, liveSwitchViolations } = await scanSources(root)
|
|
if (fix) {
|
|
const stale = collectExtractionProblems(
|
|
references,
|
|
await readJson(root, path.join(SURFACE.localesDir, 'en.json')),
|
|
keyEvidence
|
|
).unreferenced
|
|
if (stale.length > 0) {
|
|
await pruneCatalogKeys(root, stale)
|
|
}
|
|
}
|
|
// Shared catalog rules first; --fix adds new English declarations before the checks below read en.json.
|
|
const catalogExit = await verifyCatalog(
|
|
root,
|
|
{ fix, pluginCatalogs: [], surface: SURFACE },
|
|
references
|
|
)
|
|
const runtimeExit = await verifyRuntimeCatalog(
|
|
root,
|
|
['--surface', 'mobile', ...(fix ? ['--fix'] : [])],
|
|
references
|
|
)
|
|
|
|
const problems = []
|
|
const supportedLocales = await readSupportedUiLocales(root)
|
|
const englishCatalog = await readJson(root, path.join(SURFACE.localesDir, 'en.json'))
|
|
const englishEntries = flattenCatalog(englishCatalog)
|
|
|
|
const { fallbackDrift, unreferenced } = collectExtractionProblems(
|
|
references,
|
|
englishCatalog,
|
|
keyEvidence
|
|
)
|
|
report(
|
|
problems,
|
|
'Inline fallbacks differ from mobile en.json. The catalog value is what renders: update en.json and every locale, or use a new key.',
|
|
fallbackDrift
|
|
)
|
|
report(
|
|
problems,
|
|
'Mobile catalog keys no source references. Run `pnpm sync:mobile-localization` to delete them from every catalog.',
|
|
unreferenced
|
|
)
|
|
report(problems, 'Translations that cannot follow a language switch:', liveSwitchViolations)
|
|
|
|
const catalogFiles = (await fs.readdir(path.join(root, SURFACE.localesDir)))
|
|
.filter((name) => name.endsWith('.json'))
|
|
.map((name) => name.replace(/\.json$/, ''))
|
|
report(problems, `${SURFACE.localesDir} must hold exactly one catalog per supported UI locale:`, [
|
|
...supportedLocales
|
|
.filter((locale) => !catalogFiles.includes(locale))
|
|
.map((l) => `missing ${l}`),
|
|
...catalogFiles.filter((locale) => !supportedLocales.includes(locale)).map((l) => `extra ${l}`)
|
|
])
|
|
for (const locale of supportedLocales.filter((name) => name !== 'en')) {
|
|
const localeEntries = flattenCatalog(
|
|
await readJson(root, path.join(SURFACE.localesDir, `${locale}.json`))
|
|
)
|
|
report(
|
|
problems,
|
|
`${locale}.json plural families without every ${locale} CLDR category:`,
|
|
collectIncompletePluralFamilies(englishEntries, localeEntries, locale)
|
|
)
|
|
report(
|
|
problems,
|
|
`${locale}.json has empty translations; omit the key to fall back to English:`,
|
|
[...localeEntries].filter(([, value]) => !value.trim()).map(([key]) => key)
|
|
)
|
|
}
|
|
report(
|
|
problems,
|
|
'Native locale configuration:',
|
|
await collectNativeLocaleProblems(root, supportedLocales)
|
|
)
|
|
report(problems, 'Plural rules polyfill:', await collectPolyfillProblems(root, supportedLocales))
|
|
|
|
for (const problem of problems) {
|
|
console.error(`${problem}\n`)
|
|
}
|
|
if (problems.length === 0 && catalogExit === 0 && runtimeExit === 0) {
|
|
console.log(
|
|
`Mobile localization verified: ${references.length} call sites, ${englishEntries.size} English entries.`
|
|
)
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|