mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* feat(mobile): add UI localization infrastructure Mobile follows the phone's language (or an in-app choice under Settings > Appearance) across the same 11 languages as desktop, switching live like the theme does. - i18next with desktop's translate(key, fallback, options) call shape; catalogs in mobile/src/i18n/locales load lazily, English fallbacks stay inline - Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules - OTA pages get the shell's language through an optional bridge init field - iOS permission prompts localized; iOS and Android 13+ per-app language pickers - verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI * fix(mobile): mount pages after the newest language request; tighten localization gates - Wait for the latest page language activation, not a superseded one - Keep literal fallbacks when a key also has a dynamic one - Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes * feat(mobile): repaint themed components on language changes Components that read the theme already subscribe for repaint; a language switch repaints them too, so labels built by helpers during render follow it. RPC recordings load the real i18next and JSON catalogs. * fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync Handlers and effects may call translate() when they run. sync:mobile-localization now removes keys no code references from every catalog. * fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test * chore(mobile): refresh the localization coverage baseline * fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune Also let anonymous default-exported screens count as components, flag any plural family a locale started, and mark JSON modules as ES modules in the recording loader. * fix(mobile): treat async and named local callbacks as handlers in the translate() gate * chore(mobile): refresh the localization coverage baseline * chore(mobile): refresh the localization coverage baseline --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
155 lines
5.4 KiB
JavaScript
155 lines
5.4 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import { collectLocalizationReferences } from './verify-localization-catalog.mjs'
|
|
import { readLocalizationSurface } from './localization-surfaces.mjs'
|
|
|
|
// i18next resolves CLDR plural suffixes only from catalog, never defaultValue.
|
|
const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/
|
|
|
|
function flattenCatalogEntries(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)) {
|
|
flattenCatalogEntries(child, prefix ? `${prefix}.${key}` : key, entries)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// Retain catalog entries when plural-suffixed, lacking literal defaults or differing from any call-site default.
|
|
// Catalog wins over defaults; only entries identical at every literal-default call site are redundant in the boot bundle.
|
|
export function collectRuntimeRequiredKeys(catalogEntries, references) {
|
|
const literalFallbacksByKey = new Map()
|
|
const dynamicFallbackKeys = new Set()
|
|
|
|
for (const reference of references) {
|
|
if (typeof reference.fallback === 'string') {
|
|
const fallbacks = literalFallbacksByKey.get(reference.key) ?? new Set()
|
|
fallbacks.add(reference.fallback)
|
|
literalFallbacksByKey.set(reference.key, fallbacks)
|
|
continue
|
|
}
|
|
dynamicFallbackKeys.add(reference.key)
|
|
}
|
|
|
|
const required = new Set()
|
|
for (const [key, value] of catalogEntries) {
|
|
if (PLURAL_SUFFIX_RE.test(key)) {
|
|
required.add(key)
|
|
continue
|
|
}
|
|
const fallbacks = literalFallbacksByKey.get(key)
|
|
if (!fallbacks || dynamicFallbackKeys.has(key)) {
|
|
required.add(key)
|
|
continue
|
|
}
|
|
if (fallbacks.size !== 1 || !fallbacks.has(value)) {
|
|
required.add(key)
|
|
}
|
|
}
|
|
return required
|
|
}
|
|
|
|
export function buildRuntimeRequiredCatalog(catalogEntries, requiredKeys) {
|
|
const catalog = {}
|
|
for (const key of [...requiredKeys].sort()) {
|
|
const parts = key.split('.')
|
|
let cursor = catalog
|
|
for (const part of parts.slice(0, -1)) {
|
|
cursor[part] ??= {}
|
|
cursor = cursor[part]
|
|
}
|
|
cursor[parts.at(-1)] = catalogEntries.get(key)
|
|
}
|
|
return catalog
|
|
}
|
|
|
|
// Require all needed entries and agreement with en.json, not exact subset equality.
|
|
// Formerly required extras only cost bytes; allowing them avoids unrelated key additions invalidating open branches.
|
|
export function collectRuntimeRequiredCatalogProblems(catalogEntries, requiredKeys, shipped) {
|
|
const missing = [...requiredKeys].filter((key) => !shipped.has(key)).sort()
|
|
const contradicting = [...shipped.keys()]
|
|
.filter((key) => catalogEntries.get(key) !== shipped.get(key))
|
|
.sort()
|
|
const superfluous = [...shipped.keys()].filter(
|
|
(key) => !requiredKeys.has(key) && catalogEntries.has(key)
|
|
)
|
|
return { missing, contradicting, superfluous }
|
|
}
|
|
|
|
function reportKeys(label, keys) {
|
|
console.error(`${label}:`)
|
|
for (const key of keys.slice(0, 20)) {
|
|
console.error(` ${key}`)
|
|
}
|
|
if (keys.length > 20) {
|
|
console.error(` ...and ${keys.length - 20} more`)
|
|
}
|
|
}
|
|
|
|
export async function main(root = process.cwd(), argv = process.argv.slice(2), sharedReferences) {
|
|
const fix = argv.includes('--fix')
|
|
const surface = readLocalizationSurface(argv)
|
|
const catalogEntries = flattenCatalogEntries(
|
|
JSON.parse(await fs.readFile(path.join(root, surface.localesDir, 'en.json'), 'utf8'))
|
|
)
|
|
const references =
|
|
sharedReferences ?? (await collectLocalizationReferences(root, surface.sourceRoots))
|
|
const requiredKeys = collectRuntimeRequiredKeys(catalogEntries, references)
|
|
const outputPath = path.join(root, surface.runtimeRequiredPath)
|
|
const outputLabel = path.relative(root, outputPath).split(path.sep).join('/')
|
|
|
|
if (fix) {
|
|
const catalog = buildRuntimeRequiredCatalog(catalogEntries, requiredKeys)
|
|
await fs.writeFile(outputPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
|
|
console.log(
|
|
`Wrote ${requiredKeys.size} of ${catalogEntries.size} English entries to ${outputLabel}.`
|
|
)
|
|
return 0
|
|
}
|
|
|
|
const shipped = flattenCatalogEntries(JSON.parse(await fs.readFile(outputPath, 'utf8')))
|
|
const { missing, contradicting, superfluous } = collectRuntimeRequiredCatalogProblems(
|
|
catalogEntries,
|
|
requiredKeys,
|
|
shipped
|
|
)
|
|
|
|
if (missing.length > 0 || contradicting.length > 0) {
|
|
console.error(`${outputLabel} no longer covers en.json.`)
|
|
console.error('')
|
|
if (missing.length > 0) {
|
|
reportKeys(
|
|
'Entries i18next cannot rebuild from a call site default, but that are not shipped',
|
|
missing
|
|
)
|
|
}
|
|
if (contradicting.length > 0) {
|
|
reportKeys('Shipped entries whose text disagrees with en.json', contradicting)
|
|
}
|
|
console.error('')
|
|
console.error(`Run \`${surface.syncRuntimeCatalogCommand}\` to regenerate it.`)
|
|
return 1
|
|
}
|
|
|
|
const superfluousNote =
|
|
superfluous.length > 0
|
|
? `, ${superfluous.length} shipped entry/entries are no longer required (harmless; sync to drop them).`
|
|
: '.'
|
|
console.log(
|
|
`${outputLabel} covers en.json: ${requiredKeys.size} of ${catalogEntries.size} English entries must ship in the boot bundle${superfluousNote}`
|
|
)
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|