mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(mobile): close localization audit gaps
This commit is contained in:
@@ -16,6 +16,11 @@ import {
|
||||
collectMobileTranslationBindings,
|
||||
isMobileTranslationCall
|
||||
} from './mobile-localization-translation-bindings.mjs'
|
||||
import {
|
||||
findNewLocalizationCandidates,
|
||||
findStaleLocalizationAllowlistEntries,
|
||||
formatStaleLocalizationAllowlistEntries
|
||||
} from './localization-allowlist-drift.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
@@ -550,58 +555,12 @@ function parseArgs(argv) {
|
||||
return options
|
||||
}
|
||||
|
||||
function candidateSignature(candidate) {
|
||||
return JSON.stringify({
|
||||
filePath: candidate.filePath,
|
||||
kind: candidate.kind,
|
||||
text: candidate.text,
|
||||
dynamic: candidate.dynamic
|
||||
})
|
||||
}
|
||||
|
||||
function countBySignature(reports) {
|
||||
const counts = new Map()
|
||||
for (const report of reports) {
|
||||
const signature = candidateSignature(report)
|
||||
counts.set(signature, (counts.get(signature) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
async function readAllowlist(root, allowlistPath) {
|
||||
const absolutePath = path.resolve(root, allowlistPath)
|
||||
const raw = await fs.readFile(absolutePath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function findNewCandidates(reports, allowlist) {
|
||||
const allowedCounts = new Map(
|
||||
allowlist.map((entry) => [
|
||||
JSON.stringify({
|
||||
filePath: entry.filePath,
|
||||
kind: entry.kind,
|
||||
text: entry.text,
|
||||
dynamic: entry.dynamic
|
||||
}),
|
||||
entry.count
|
||||
])
|
||||
)
|
||||
const seenCounts = countBySignature(reports)
|
||||
const newCandidates = []
|
||||
|
||||
for (const report of reports) {
|
||||
const signature = candidateSignature(report)
|
||||
const seenCount = seenCounts.get(signature) ?? 0
|
||||
const allowedCount = allowedCounts.get(signature) ?? 0
|
||||
if (seenCount > allowedCount) {
|
||||
newCandidates.push(report)
|
||||
seenCounts.set(signature, seenCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return newCandidates
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv)
|
||||
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
|
||||
@@ -614,8 +573,11 @@ export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
const allowlist = await readAllowlist(root, options.allowlistPath)
|
||||
const newCandidates = findNewCandidates(reports, allowlist)
|
||||
const sourcePrefix = normalizePath(root, absoluteSourceRoot)
|
||||
const allowlist = (await readAllowlist(root, options.allowlistPath)).filter(
|
||||
(entry) => entry.filePath === sourcePrefix || entry.filePath.startsWith(`${sourcePrefix}/`)
|
||||
)
|
||||
const newCandidates = findNewLocalizationCandidates(reports, allowlist)
|
||||
if (newCandidates.length > 0) {
|
||||
console.error('New unlocalized UI strings were found.')
|
||||
console.error('Localize them or add a reviewed exclusion to the localization allowlist.')
|
||||
@@ -623,6 +585,14 @@ export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
||||
console.error(formatReports(root, newCandidates))
|
||||
return 1
|
||||
}
|
||||
const staleEntries = findStaleLocalizationAllowlistEntries(reports, allowlist)
|
||||
if (staleEntries.length > 0) {
|
||||
console.error('Stale localization allowlist entries were found.')
|
||||
console.error('Remove entries or lower counts that no longer match source candidates.')
|
||||
console.error('')
|
||||
console.error(formatStaleLocalizationAllowlistEntries(staleEntries))
|
||||
return 1
|
||||
}
|
||||
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
function candidateSignature(candidate) {
|
||||
return JSON.stringify({
|
||||
filePath: candidate.filePath,
|
||||
kind: candidate.kind,
|
||||
text: candidate.text,
|
||||
dynamic: candidate.dynamic
|
||||
})
|
||||
}
|
||||
|
||||
function countBySignature(candidates) {
|
||||
const counts = new Map()
|
||||
for (const candidate of candidates) {
|
||||
const signature = candidateSignature(candidate)
|
||||
counts.set(signature, (counts.get(signature) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function findNewLocalizationCandidates(reports, allowlist) {
|
||||
const allowedCounts = new Map(allowlist.map((entry) => [candidateSignature(entry), entry.count]))
|
||||
const seenCounts = countBySignature(reports)
|
||||
const newCandidates = []
|
||||
|
||||
for (const report of reports) {
|
||||
const signature = candidateSignature(report)
|
||||
const seenCount = seenCounts.get(signature) ?? 0
|
||||
const allowedCount = allowedCounts.get(signature) ?? 0
|
||||
if (seenCount > allowedCount) {
|
||||
newCandidates.push(report)
|
||||
seenCounts.set(signature, seenCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return newCandidates
|
||||
}
|
||||
|
||||
export function findStaleLocalizationAllowlistEntries(reports, allowlist) {
|
||||
const seenCounts = countBySignature(reports)
|
||||
return allowlist.flatMap((entry) => {
|
||||
const seenCount = seenCounts.get(candidateSignature(entry)) ?? 0
|
||||
return seenCount < entry.count ? [{ ...entry, seenCount }] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function formatStaleLocalizationAllowlistEntries(entries) {
|
||||
return entries
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.filePath} ${entry.kind}: ${JSON.stringify(entry.text)} (allowlisted ${entry.count}, found ${entry.seenCount})`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/
|
||||
const USER_VISIBLE_ATTRIBUTE_RE =
|
||||
/\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi
|
||||
const USER_VISIBLE_PROMPT_START_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*/g
|
||||
@@ -183,6 +182,14 @@ function insertedHtmlArgumentEnd(source, start) {
|
||||
cursor = skipQuotedString(source, cursor)
|
||||
continue
|
||||
}
|
||||
if (source[cursor] === '`') {
|
||||
cursor = templateLiteralEnd(source, cursor)
|
||||
continue
|
||||
}
|
||||
if (source[cursor] === '\\' && source[cursor + 1] === '`') {
|
||||
cursor = escapedTemplateLiteralEnd(source, cursor)
|
||||
continue
|
||||
}
|
||||
if (source[cursor] === '(') {
|
||||
depth += 1
|
||||
}
|
||||
@@ -211,15 +218,22 @@ function collectInsertedHtmlCandidates(expression, baseOffset, candidates) {
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
const escapedTemplate = expression[cursor] === '\\' && expression[cursor + 1] === '`'
|
||||
if (expression[cursor] === '`' || escapedTemplate) {
|
||||
const end = escapedTemplate
|
||||
? escapedTemplateLiteralEnd(expression, cursor)
|
||||
: templateLiteralEnd(expression, cursor)
|
||||
const delimiterWidth = escapedTemplate ? 2 : 1
|
||||
const markup = expression.slice(cursor + delimiterWidth, end - delimiterWidth)
|
||||
collectMarkupFragmentCandidates(markup, baseOffset + cursor + delimiterWidth, candidates)
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) {
|
||||
if (!EMBEDDED_DOCUMENT_FILE_RE.test(filePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const sourceKind =
|
||||
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
const sourceFile = ts.createSourceFile(
|
||||
|
||||
@@ -41,6 +41,7 @@ const USER_VISIBLE_OBJECT_KEYS = new Set([
|
||||
'placeholder',
|
||||
'subject',
|
||||
'subtitle',
|
||||
'successToast',
|
||||
'title',
|
||||
'toggleDescription',
|
||||
'tooltip'
|
||||
@@ -112,6 +113,7 @@ const USER_VISIBLE_VARIABLE_SUFFIX_RE =
|
||||
const USER_VISIBLE_RETURN_FUNCTIONS = new Set([
|
||||
'accessibilityLabelForLine',
|
||||
'autoRestoreSummary',
|
||||
'commentAuthor',
|
||||
'commentSourceLabel',
|
||||
'composerLabel',
|
||||
'describeScope',
|
||||
@@ -460,7 +462,20 @@ export function classifyMobileStringNode(node, userVisibleErrorSource) {
|
||||
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
|
||||
return undefined
|
||||
}
|
||||
if (isAssignedToRenderedVariable(node)) {
|
||||
if (
|
||||
isAssignedToRenderedVariable(node, (expression) => {
|
||||
if (isRenderedJsxExpression(expression)) {
|
||||
return true
|
||||
}
|
||||
const attributeName = ancestorJsxAttributeName(expression)
|
||||
return Boolean(
|
||||
attributeName &&
|
||||
(USER_VISIBLE_JSX_ATTRIBUTES.has(attributeName) ||
|
||||
MOBILE_USER_VISIBLE_JSX_ATTRIBUTES.has(attributeName) ||
|
||||
MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(attributeName))
|
||||
)
|
||||
})
|
||||
) {
|
||||
return 'rendered-variable'
|
||||
}
|
||||
if (isComparisonOperand(node)) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
collectLocalizationCandidates,
|
||||
main as auditLocalizationCoverage
|
||||
} from './audit-localization-coverage.mjs'
|
||||
|
||||
describe('mobile-localization-candidate-rules', () => {
|
||||
it('finds grammatical fragments returned by repositoryCount', () => {
|
||||
@@ -61,6 +68,28 @@ export const HTML = \`<!doctype html>
|
||||
])
|
||||
})
|
||||
|
||||
it('finds arbitrary doctype documents and template-literal insertHTML copy', () => {
|
||||
const source = [
|
||||
'export const HTML = `<!doctype html>',
|
||||
'<main>Diagram controls</main>',
|
||||
'<script>',
|
||||
"document.execCommand('insertHTML', false, \\`<p>Retry ${name}</p><p>Follow up</p>\\`);",
|
||||
'</script>',
|
||||
'`'
|
||||
].join('\n')
|
||||
const candidates = collectLocalizationCandidates(
|
||||
'/repo/mobile/src/components/pr-sidebar/MermaidDiagram.tsx',
|
||||
source,
|
||||
'/repo'
|
||||
)
|
||||
|
||||
expect(candidates.map(({ dynamic, text }) => ({ dynamic, text }))).toEqual([
|
||||
{ dynamic: false, text: 'Diagram controls' },
|
||||
{ dynamic: true, text: 'Retry' },
|
||||
{ dynamic: false, text: 'Follow up' }
|
||||
])
|
||||
})
|
||||
|
||||
it('finds literals assigned to variables that later render in JSX', () => {
|
||||
const source = `
|
||||
export function Example({ alternate }) {
|
||||
@@ -87,6 +116,56 @@ export function Example({ alternate }) {
|
||||
])
|
||||
})
|
||||
|
||||
it('finds literals assigned to variables rendered through user-visible JSX attributes', () => {
|
||||
const source = `
|
||||
export function Example() {
|
||||
const copy = 'Save changes'
|
||||
const className = 'not-visible-copy'
|
||||
return <Button title={copy} className={className} />
|
||||
}
|
||||
`
|
||||
const candidates = collectLocalizationCandidates(
|
||||
'/repo/mobile/src/components/Example.tsx',
|
||||
source,
|
||||
'/repo'
|
||||
)
|
||||
|
||||
expect(candidates.map((candidate) => candidate.text)).toEqual(['Save changes'])
|
||||
})
|
||||
|
||||
it('finds unlocalized success-toast object copy', () => {
|
||||
const source = `
|
||||
export const launch = {
|
||||
options: { successToast: 'Quick command inserted' }
|
||||
}
|
||||
`
|
||||
const candidates = collectLocalizationCandidates(
|
||||
'/repo/mobile/src/terminal/quick-commands.ts',
|
||||
source,
|
||||
'/repo'
|
||||
)
|
||||
|
||||
expect(candidates.map((candidate) => candidate.text)).toEqual(['Quick command inserted'])
|
||||
})
|
||||
|
||||
it('finds unlocalized fallbacks returned by commentAuthor', () => {
|
||||
const source = `
|
||||
function commentAuthor(comment) {
|
||||
return comment.author ?? 'unknown'
|
||||
}
|
||||
export function Example({ comment }) {
|
||||
return <Text>{commentAuthor(comment)}</Text>
|
||||
}
|
||||
`
|
||||
const candidates = collectLocalizationCandidates(
|
||||
'/repo/mobile/app/h/[hostId]/tasks.tsx',
|
||||
source,
|
||||
'/repo'
|
||||
)
|
||||
|
||||
expect(candidates.map((candidate) => candidate.text)).toEqual(['unknown'])
|
||||
})
|
||||
|
||||
it('finds user-visible subject fallbacks in returned rows', () => {
|
||||
const source = `
|
||||
export function toRow(item) {
|
||||
@@ -118,4 +197,43 @@ export function Example(translateMobile) {
|
||||
|
||||
expect(candidates.map((candidate) => candidate.text)).toEqual(['Unlocalized shadowed copy'])
|
||||
})
|
||||
|
||||
it('rejects stale allowlist entries before their approval can be reused', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-allowlist-'))
|
||||
const sourceDirectory = path.join(root, 'mobile', 'src')
|
||||
const configDirectory = path.join(root, 'config')
|
||||
mkdirSync(sourceDirectory, { recursive: true })
|
||||
mkdirSync(configDirectory, { recursive: true })
|
||||
writeFileSync(path.join(sourceDirectory, 'Example.tsx'), "export const value = 'internal'\n")
|
||||
writeFileSync(
|
||||
path.join(configDirectory, 'allowlist.json'),
|
||||
`${JSON.stringify([
|
||||
{
|
||||
filePath: 'mobile/src/Example.tsx',
|
||||
kind: 'jsx-text',
|
||||
text: 'Removed approved copy',
|
||||
dynamic: false,
|
||||
count: 1
|
||||
}
|
||||
])}\n`
|
||||
)
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
auditLocalizationCoverage(root, [
|
||||
'--check',
|
||||
'--source-root',
|
||||
'mobile/src',
|
||||
'--allowlist',
|
||||
'config/allowlist.json'
|
||||
])
|
||||
).resolves.toBe(1)
|
||||
expect(error.mock.calls.flat().join('\n')).toContain(
|
||||
'Stale localization allowlist entries were found.'
|
||||
)
|
||||
} finally {
|
||||
error.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ function assignedVariableName(node) {
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function isAssignedToRenderedVariable(node) {
|
||||
export function isAssignedToRenderedVariable(node, isRenderedExpression = isRenderedJsxExpression) {
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return false
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function isAssignedToRenderedVariable(node) {
|
||||
if (rendered) {
|
||||
return
|
||||
}
|
||||
if (ts.isIdentifier(current) && current.text === name && isRenderedJsxExpression(current)) {
|
||||
if (ts.isIdentifier(current) && current.text === name && isRenderedExpression(current)) {
|
||||
rendered = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,10 +17,23 @@ function bindingNames(name, names = []) {
|
||||
return names
|
||||
}
|
||||
|
||||
function lexicalScope(node, sourceFile) {
|
||||
function isLexicalScope(node) {
|
||||
return (
|
||||
ts.isSourceFile(node) ||
|
||||
ts.isFunctionLike(node) ||
|
||||
ts.isBlock(node) ||
|
||||
ts.isCaseBlock(node) ||
|
||||
ts.isCatchClause(node) ||
|
||||
ts.isForStatement(node) ||
|
||||
ts.isForInStatement(node) ||
|
||||
ts.isForOfStatement(node)
|
||||
)
|
||||
}
|
||||
|
||||
function nearestScope(node, sourceFile, predicate = isLexicalScope) {
|
||||
let current = node.parent
|
||||
while (current && current !== sourceFile) {
|
||||
if (ts.isBlock(current) || ts.isSourceFile(current) || ts.isFunctionLike(current)) {
|
||||
if (predicate(current)) {
|
||||
return current
|
||||
}
|
||||
current = current.parent
|
||||
@@ -29,29 +42,37 @@ function lexicalScope(node, sourceFile) {
|
||||
}
|
||||
|
||||
function functionScope(node, sourceFile) {
|
||||
let current = node.parent
|
||||
while (current && current !== sourceFile) {
|
||||
if (ts.isFunctionLike(current)) {
|
||||
return current
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return sourceFile
|
||||
return nearestScope(node, sourceFile, ts.isFunctionLike)
|
||||
}
|
||||
|
||||
function addDeclarations(sourceFile) {
|
||||
function collectDeclarations(sourceFile) {
|
||||
const declarations = new Map()
|
||||
|
||||
function add(scope, name) {
|
||||
const names = declarations.get(scope) ?? new Set()
|
||||
names.add(name)
|
||||
declarations.set(scope, names)
|
||||
function add(scope, name, declaration) {
|
||||
const byName = declarations.get(scope) ?? new Map()
|
||||
const matches = byName.get(name) ?? []
|
||||
matches.push(declaration)
|
||||
byName.set(name, matches)
|
||||
declarations.set(scope, byName)
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isParameter(node)) {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const clause = node.importClause
|
||||
if (clause?.name) {
|
||||
add(sourceFile, clause.name.text, clause.name)
|
||||
}
|
||||
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
||||
add(sourceFile, clause.namedBindings.name.text, clause.namedBindings)
|
||||
}
|
||||
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const element of clause.namedBindings.elements) {
|
||||
add(sourceFile, element.name.text, element)
|
||||
}
|
||||
}
|
||||
} else if (ts.isParameter(node)) {
|
||||
for (const name of bindingNames(node.name)) {
|
||||
add(node.parent, name)
|
||||
add(node.parent, name, node)
|
||||
}
|
||||
} else if (ts.isVariableDeclaration(node)) {
|
||||
const declarationList = node.parent
|
||||
@@ -59,15 +80,17 @@ function addDeclarations(sourceFile) {
|
||||
ts.isVariableDeclarationList(declarationList) &&
|
||||
(declarationList.flags & ts.NodeFlags.BlockScoped) === 0
|
||||
? functionScope(node, sourceFile)
|
||||
: lexicalScope(node, sourceFile)
|
||||
: nearestScope(node, sourceFile)
|
||||
for (const name of bindingNames(node.name)) {
|
||||
add(scope, name)
|
||||
add(scope, name, node)
|
||||
}
|
||||
} else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
||||
add(lexicalScope(node, sourceFile), node.name.text)
|
||||
add(nearestScope(node, sourceFile), node.name.text, node)
|
||||
} else if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) {
|
||||
add(node, node.name.text, node)
|
||||
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
|
||||
for (const name of bindingNames(node.variableDeclaration.name)) {
|
||||
add(node, name)
|
||||
add(node, name, node.variableDeclaration)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
@@ -77,24 +100,46 @@ function addDeclarations(sourceFile) {
|
||||
return declarations
|
||||
}
|
||||
|
||||
function isShadowed(identifier, sourceFile, declarations) {
|
||||
function resolveBinding(identifier, sourceFile, declarations) {
|
||||
let current = identifier.parent
|
||||
while (current && current !== sourceFile) {
|
||||
if (declarations.get(current)?.has(identifier.text)) {
|
||||
return true
|
||||
while (current) {
|
||||
const matches = declarations.get(current)?.get(identifier.text)
|
||||
if (matches?.length) {
|
||||
return matches[0]
|
||||
}
|
||||
if (current === sourceFile) {
|
||||
break
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isEnglishFixedTranslator(node, instanceNames, sourceFile, declarations) {
|
||||
function resolvesTo(identifier, bindings) {
|
||||
return bindings.resolveBinding(identifier)
|
||||
}
|
||||
|
||||
function isNamespaceMember(node, memberName, bindings) {
|
||||
return (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
node.name.text === memberName &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
bindings.namespaceBindings.has(resolvesTo(node.expression, bindings))
|
||||
)
|
||||
}
|
||||
|
||||
function isMobileI18nInstance(node, bindings) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return bindings.instanceBindings.has(resolvesTo(node, bindings))
|
||||
}
|
||||
return isNamespaceMember(node, 'mobileI18n', bindings)
|
||||
}
|
||||
|
||||
function isEnglishFixedTranslator(node, bindings) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
instanceNames.has(node.expression.expression.text) &&
|
||||
!isShadowed(node.expression.expression, sourceFile, declarations) &&
|
||||
isMobileI18nInstance(node.expression.expression, bindings) &&
|
||||
node.expression.name.text === 'getFixedT' &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteralLike(node.arguments[0]) &&
|
||||
@@ -102,12 +147,17 @@ function isEnglishFixedTranslator(node, instanceNames, sourceFile, declarations)
|
||||
)
|
||||
}
|
||||
|
||||
function mobileTranslatorPrefix(node, factoryNames, sourceFile, declarations) {
|
||||
function isTranslatorFactory(node, bindings) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return bindings.factoryBindings.has(resolvesTo(node, bindings))
|
||||
}
|
||||
return isNamespaceMember(node, 'createMobileTranslator', bindings)
|
||||
}
|
||||
|
||||
function mobileTranslatorPrefix(node, bindings) {
|
||||
if (
|
||||
!ts.isCallExpression(node) ||
|
||||
!ts.isIdentifier(node.expression) ||
|
||||
!factoryNames.has(node.expression.text) ||
|
||||
isShadowed(node.expression, sourceFile, declarations) ||
|
||||
!isTranslatorFactory(node.expression, bindings) ||
|
||||
node.arguments.length !== 1 ||
|
||||
!ts.isStringLiteralLike(node.arguments[0])
|
||||
) {
|
||||
@@ -116,13 +166,51 @@ function mobileTranslatorPrefix(node, factoryNames, sourceFile, declarations) {
|
||||
return node.arguments[0].text
|
||||
}
|
||||
|
||||
function directTranslatorPrefix(callee, bindings) {
|
||||
if (ts.isIdentifier(callee)) {
|
||||
const binding = resolvesTo(callee, bindings)
|
||||
if (bindings.translatorBindings.has(binding) || bindings.fixedTranslatorBindings.has(binding)) {
|
||||
return ''
|
||||
}
|
||||
return bindings.prefixedTranslatorBindings.get(binding)
|
||||
}
|
||||
if (isNamespaceMember(callee, 't', bindings)) {
|
||||
return ''
|
||||
}
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === 't' &&
|
||||
isMobileI18nInstance(callee.expression, bindings)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function collectMobileTranslationBindings(sourceFile) {
|
||||
const declarations = collectDeclarations(sourceFile)
|
||||
const translatorBindings = new Set()
|
||||
const factoryBindings = new Set()
|
||||
const instanceBindings = new Set()
|
||||
const namespaceBindings = new Set()
|
||||
const fixedTranslatorBindings = new Set()
|
||||
const prefixedTranslatorBindings = new Map()
|
||||
const translatorNames = new Set()
|
||||
const translatorFactoryNames = new Set()
|
||||
const instanceNames = new Set()
|
||||
const fixedTranslatorNames = new Set()
|
||||
const prefixedTranslatorNames = new Map()
|
||||
const declarations = addDeclarations(sourceFile)
|
||||
const bindings = {
|
||||
declarations,
|
||||
factoryBindings,
|
||||
fixedTranslatorBindings,
|
||||
fixedTranslatorNames,
|
||||
instanceBindings,
|
||||
namespaceBindings,
|
||||
prefixedTranslatorBindings,
|
||||
prefixedTranslatorNames,
|
||||
resolveBinding: (identifier) => resolveBinding(identifier, sourceFile, declarations),
|
||||
translatorBindings,
|
||||
translatorNames
|
||||
}
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
@@ -132,68 +220,52 @@ export function collectMobileTranslationBindings(sourceFile) {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
for (const element of statement.importClause?.namedBindings?.elements ?? []) {
|
||||
const namedBindings = statement.importClause?.namedBindings
|
||||
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
||||
namespaceBindings.add(namedBindings)
|
||||
continue
|
||||
}
|
||||
if (!namedBindings || !ts.isNamedImports(namedBindings)) {
|
||||
continue
|
||||
}
|
||||
for (const element of namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text
|
||||
if (importedName === 't') {
|
||||
translatorBindings.add(element)
|
||||
translatorNames.add(element.name.text)
|
||||
} else if (importedName === 'createMobileTranslator') {
|
||||
translatorFactoryNames.add(element.name.text)
|
||||
factoryBindings.add(element)
|
||||
} else if (importedName === 'mobileI18n') {
|
||||
instanceNames.add(element.name.text)
|
||||
instanceBindings.add(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFixed(node) {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer &&
|
||||
isEnglishFixedTranslator(node.initializer, instanceNames, sourceFile, declarations)
|
||||
) {
|
||||
fixedTranslatorNames.add(node.name.text)
|
||||
}
|
||||
function collectLocalTranslators(node) {
|
||||
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
||||
const prefix = mobileTranslatorPrefix(
|
||||
node.initializer,
|
||||
translatorFactoryNames,
|
||||
sourceFile,
|
||||
declarations
|
||||
)
|
||||
const binding = bindings.resolveBinding(node.name)
|
||||
if (isEnglishFixedTranslator(node.initializer, bindings)) {
|
||||
fixedTranslatorBindings.add(binding)
|
||||
fixedTranslatorNames.add(node.name.text)
|
||||
}
|
||||
const prefix = mobileTranslatorPrefix(node.initializer, bindings)
|
||||
if (prefix !== undefined) {
|
||||
prefixedTranslatorBindings.set(binding, prefix)
|
||||
prefixedTranslatorNames.set(node.name.text, prefix)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, collectFixed)
|
||||
ts.forEachChild(node, collectLocalTranslators)
|
||||
}
|
||||
collectFixed(sourceFile)
|
||||
collectLocalTranslators(sourceFile)
|
||||
|
||||
return {
|
||||
declarations,
|
||||
fixedTranslatorNames,
|
||||
instanceNames,
|
||||
prefixedTranslatorNames,
|
||||
translatorNames
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
export function mobileTranslationCallPrefix(call, sourceFile, bindings) {
|
||||
const callee = call.expression
|
||||
if (ts.isIdentifier(callee)) {
|
||||
if (isShadowed(callee, sourceFile, bindings.declarations)) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
bindings.translatorNames.has(callee.text) ||
|
||||
bindings.fixedTranslatorNames.has(callee.text)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return bindings.prefixedTranslatorNames.get(callee.text)
|
||||
}
|
||||
return isEnglishFixedTranslator(callee, bindings.instanceNames, sourceFile, bindings.declarations)
|
||||
? ''
|
||||
: undefined
|
||||
export function mobileTranslationCallPrefix(call, _sourceFile, bindings) {
|
||||
return (
|
||||
directTranslatorPrefix(call.expression, bindings) ??
|
||||
(isEnglishFixedTranslator(call.expression, bindings) ? '' : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMobileTranslationCall(call, sourceFile, bindings) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
mobileTranslationCallPrefix
|
||||
} from './mobile-localization-translation-bindings.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')]
|
||||
const LOCALES_RELATIVE_DIR = path.join('mobile', 'src', 'i18n', 'locales')
|
||||
const MOBILE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
|
||||
@@ -258,15 +258,16 @@ function verifyProtectedLiterals(englishValue, localeValue, locale, key) {
|
||||
return issues
|
||||
}
|
||||
|
||||
function isLanguageNeutralValue(value) {
|
||||
function isLanguageNeutralValue(value, locale) {
|
||||
const withoutPlaceholders = value.replace(PLACEHOLDER_RE, '').trim()
|
||||
return (
|
||||
LANGUAGE_NEUTRAL_VALUES.has(value) ||
|
||||
/^(?:Alt|Ctrl|Shift)(?:\+(?:[A-Z]|Tab))?$/.test(value) ||
|
||||
/^(?:Del|Enter|Esc|Ins|PgDn|PgUp|Tab)$/.test(value) ||
|
||||
/^(?:F|H)\d{1,2}$/.test(value) ||
|
||||
/^(?:https?:\/\/|orca:\/\/|lin_api_)/.test(value) ||
|
||||
/^(?:https?:\/\/|orca:\/\/|lin_api_)\S+$/.test(value) ||
|
||||
/^(?:npm run dev|Linux: sudo ufw allow 6768|src\/renderer packages\/ui)$/.test(value) ||
|
||||
(locale === 'es' && /^(?:commit|commits)$/.test(withoutPlaceholders)) ||
|
||||
withoutPlaceholders === '' ||
|
||||
withoutPlaceholders === '..HEAD' ||
|
||||
withoutPlaceholders === '×'
|
||||
@@ -395,7 +396,7 @@ function verifyLocaleEntries(englishEntries, locale, localeEntries) {
|
||||
}
|
||||
issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key))
|
||||
issues.push(...verifyTerminology(englishValue, localeValue, locale, key))
|
||||
if (localeValue === englishValue && !isLanguageNeutralValue(englishValue)) {
|
||||
if (localeValue === englishValue && !isLanguageNeutralValue(englishValue, locale)) {
|
||||
issues.push(`${locale}.json copies English instead of using fallback: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,13 @@ function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
|
||||
function makeProject({
|
||||
sourceText,
|
||||
sourceFileName = 'Example.tsx',
|
||||
catalogs,
|
||||
nativeCatalogs,
|
||||
appConfig
|
||||
}) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-mobile-localization-'))
|
||||
const appDirectory = path.join(root, 'mobile', 'app')
|
||||
const sourceDirectory = path.join(root, 'mobile', 'src')
|
||||
@@ -61,7 +67,7 @@ function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
|
||||
mkdirSync(appDirectory, { recursive: true })
|
||||
mkdirSync(localeDirectory, { recursive: true })
|
||||
mkdirSync(nativeLocaleDirectory, { recursive: true })
|
||||
writeFileSync(path.join(appDirectory, 'Example.tsx'), sourceText, 'utf8')
|
||||
writeFileSync(path.join(appDirectory, sourceFileName), sourceText, 'utf8')
|
||||
writeJson(path.join(root, 'mobile', 'app.json'), appConfig ?? defaultAppConfig())
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
@@ -148,6 +154,55 @@ export function local(translate) {
|
||||
expect(calls.map((call) => call.keys)).toEqual([['example.known']])
|
||||
})
|
||||
|
||||
it('tracks function-local prefixed and fixed translators by binding', () => {
|
||||
const sourceText = `
|
||||
import { createMobileTranslator, mobileI18n } from '@/i18n/mobile-i18n'
|
||||
export function labels() {
|
||||
const tr = createMobileTranslator('example')
|
||||
const fixed = mobileI18n.getFixedT('en')
|
||||
return [tr('known'), fixed('example.fixed')]
|
||||
}
|
||||
`
|
||||
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
|
||||
|
||||
expect(calls.map((call) => call.keys)).toEqual([['example.known'], ['example.fixed']])
|
||||
})
|
||||
|
||||
it('tracks instance and namespace translation calls', () => {
|
||||
const sourceText = `
|
||||
import { mobileI18n } from '@/i18n/mobile-i18n'
|
||||
import * as i18n from '@/i18n/mobile-i18n'
|
||||
const tr = i18n.createMobileTranslator('example')
|
||||
export const labels = [
|
||||
mobileI18n.t('example.instance'),
|
||||
i18n.t('example.namespace'),
|
||||
i18n.mobileI18n.t('example.namespaceInstance'),
|
||||
tr('prefixed')
|
||||
]
|
||||
`
|
||||
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
|
||||
|
||||
expect(calls.map((call) => call.keys)).toEqual([
|
||||
['example.instance'],
|
||||
['example.namespace'],
|
||||
['example.namespaceInstance'],
|
||||
['example.prefixed']
|
||||
])
|
||||
})
|
||||
|
||||
it('limits for-loop and catch bindings to their lexical scopes', () => {
|
||||
const sourceText = `
|
||||
import { t } from '@/i18n/mobile-i18n'
|
||||
for (const t of callbacks) t('local-loop-call')
|
||||
export const afterLoop = t('example.afterLoop')
|
||||
try { run() } catch (t) { t('local-catch-call') }
|
||||
export const afterCatch = t('example.afterCatch')
|
||||
`
|
||||
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
|
||||
|
||||
expect(calls.map((call) => call.keys)).toEqual([['example.afterLoop'], ['example.afterCatch']])
|
||||
})
|
||||
|
||||
it('ignores unrelated local t functions', () => {
|
||||
const calls = collectMobileTranslationCalls(
|
||||
'/repo/mobile/app/Example.tsx',
|
||||
@@ -221,6 +276,45 @@ export function local(translate) {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat URL-prefixed prose as language-neutral', async () => {
|
||||
const root = makeProject({
|
||||
sourceText:
|
||||
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.pairingHint')\n",
|
||||
catalogs: {
|
||||
en: { example: { pairingHint: 'orca://pair?code=... or paste the code' } },
|
||||
ja: { example: { pairingHint: 'orca://pair?code=... or paste the code' } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(await runFailedVerification(root)).toContain(
|
||||
'ja.json copies English instead of using fallback: example.pairingHint'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows Spanish to preserve the Git term commit', async () => {
|
||||
const root = makeProject({
|
||||
sourceText:
|
||||
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.commitCount', { commitCount: 2 })\n",
|
||||
catalogs: {
|
||||
en: { example: { commitCount: '{{commitCount}} commits' } },
|
||||
es: { example: { commitCount: '{{commitCount}} commits' } }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('verifies translation calls in .mts source files', async () => {
|
||||
const root = makeProject({
|
||||
sourceFileName: 'Example.mts',
|
||||
sourceText:
|
||||
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.missing')\n",
|
||||
catalogs: { en: { example: { known: 'Known' } } }
|
||||
})
|
||||
|
||||
expect(await runFailedVerification(root)).toContain('missing English key: example.missing')
|
||||
})
|
||||
|
||||
it('enforces Git and agent terminology', async () => {
|
||||
const root = makeProject({
|
||||
sourceText:
|
||||
|
||||
@@ -1631,7 +1631,7 @@ function isGitHubPrMergeBlocked(item: Extract<TaskItem, { provider: 'github' }>)
|
||||
}
|
||||
|
||||
function commentAuthor(comment: DetailComment): string {
|
||||
return comment.author ?? comment.user?.displayName ?? 'unknown'
|
||||
return comment.author ?? comment.user?.displayName ?? tr('unknown')
|
||||
}
|
||||
|
||||
function commentDate(value: string | undefined): string {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendCodexResetCleanupWarning } from './codex-reset-cleanup-warning'
|
||||
|
||||
describe('appendCodexResetCleanupWarning', () => {
|
||||
it('separates a retained-journal warning from the outcome copy', () => {
|
||||
expect(appendCodexResetCleanupWarning('Usage refreshed.', 'Retry record retained.')).toBe(
|
||||
'Usage refreshed.\n\nRetry record retained.'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves outcome copy unchanged without a warning', () => {
|
||||
expect(appendCodexResetCleanupWarning('Usage refreshed.', '')).toBe('Usage refreshed.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function appendCodexResetCleanupWarning(message: string, warning: string): string {
|
||||
return warning ? `${message}\n\n${warning}` : message
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy'
|
||||
import { t } from '../i18n/mobile-i18n'
|
||||
import { getActiveMobileUiLanguageTag, t } from '../i18n/mobile-i18n'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
@@ -9,7 +9,7 @@ export function escapeInjectedJavaScriptString(value: string): string {
|
||||
|
||||
export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<html lang="${getActiveMobileUiLanguageTag()}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
requestCodexResetCredit
|
||||
} from './codex-reset-credit'
|
||||
import { useCodexResetCreditCapability } from './codex-reset-credit-capability'
|
||||
import { appendCodexResetCleanupWarning } from './codex-reset-cleanup-warning'
|
||||
import { t } from '@/i18n/mobile-i18n'
|
||||
|
||||
function describeScope(snapshot: AccountsSnapshot, scope: CodexResetCreditExpectedScope): string {
|
||||
@@ -77,9 +78,10 @@ export function useCodexResetCreditAction({
|
||||
: ''
|
||||
Alert.alert(
|
||||
t('useCodexResetCreditAction.reset'),
|
||||
t('useCodexResetCreditAction.account', {
|
||||
cleanupWarning: cleanupWarning
|
||||
})
|
||||
appendCodexResetCleanupWarning(
|
||||
t('useCodexResetCreditAction.account', { cleanupWarning: '' }),
|
||||
cleanupWarning
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -87,7 +89,7 @@ export function useCodexResetCreditAction({
|
||||
const cleanupWarning = result.attemptJournalRetained
|
||||
? t('useCodexResetCreditAction.host')
|
||||
: ''
|
||||
Alert.alert(copy.title, `${copy.message}${cleanupWarning}`)
|
||||
Alert.alert(copy.title, appendCodexResetCleanupWarning(copy.message, cleanupWarning))
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t('useCodexResetCreditAction.could'),
|
||||
|
||||
@@ -25,12 +25,14 @@ describe('embedded WebView copy', () => {
|
||||
await mobileI18n.changeLanguage('es')
|
||||
|
||||
const editorHtml = buildMobileRichMarkdownEditorHtml()
|
||||
expect(editorHtml).toContain('<html lang="es">')
|
||||
expect(editorHtml).toContain('data-placeholder="Empieza a escribir..."')
|
||||
expect(editorHtml).toContain('window.prompt("URL del enlace")')
|
||||
expect(editorHtml).toContain('window.prompt("URL de la imagen")')
|
||||
expect(editorHtml).toContain(`+ "Tarea" +`)
|
||||
|
||||
const terminalHtml = buildTerminalWebViewHtml()
|
||||
expect(terminalHtml).toContain('<html lang="es">')
|
||||
expect(terminalHtml).toContain('id="sel-menu-copy">Copiar</button>')
|
||||
expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo</button>')
|
||||
})
|
||||
|
||||
@@ -1041,6 +1041,10 @@
|
||||
"commitCountCommits": "{{commitCount}} commits",
|
||||
"vs": "vs {{baseRef}}"
|
||||
},
|
||||
"mobileQuickCommandLaunch": {
|
||||
"quickCommand": "Quick command",
|
||||
"inserted": "{{commandLabel}} inserted"
|
||||
},
|
||||
"mobileCommitMessageAi": {
|
||||
"failed": "Failed to generate commit message",
|
||||
"no": "No commit message generated"
|
||||
|
||||
@@ -496,7 +496,10 @@
|
||||
"medium": "Medio",
|
||||
"high": "Alto",
|
||||
"urgent": "Urgente",
|
||||
"selected": "Seleccionado"
|
||||
"selected": "Seleccionado",
|
||||
"todo": "Tarea pendiente de {{todoTarget}}",
|
||||
"iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}} d",
|
||||
"action": "{{actionVerb}} {{taskKindLabel}}"
|
||||
},
|
||||
"accounts": {
|
||||
"add": "Agregue o vuelva a autenticar cuentas desde Configuración de escritorio → Cuentas.",
|
||||
@@ -930,11 +933,16 @@
|
||||
},
|
||||
"mobileBranchCompare": {
|
||||
"committed": "Commits en la rama",
|
||||
"changedFileCountFile": "Archivo {{changedFileCount}}",
|
||||
"changedFileCountFiles": "Archivos {{changedFileCount}}",
|
||||
"commitCountCommits": "{{commitCount}} se compromete",
|
||||
"changedFileCountFile": "{{changedFileCount}} archivo",
|
||||
"changedFileCountFiles": "{{changedFileCount}} archivos",
|
||||
"commitCountCommit": "{{commitCount}} commit",
|
||||
"commitCountCommits": "{{commitCount}} commits",
|
||||
"vs": "frente a {{baseRef}}"
|
||||
},
|
||||
"mobileQuickCommandLaunch": {
|
||||
"quickCommand": "Comando rápido",
|
||||
"inserted": "{{commandLabel}} insertado"
|
||||
},
|
||||
"mobileCommitMessageAi": {
|
||||
"failed": "No se pudo generar el mensaje de commit",
|
||||
"no": "No se generó ningún mensaje de commit"
|
||||
@@ -2486,5 +2494,10 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"metaFallback": "Vista previa"
|
||||
},
|
||||
"hostedReview": {
|
||||
"mergeConfirm": "¿{{action}} {{target}} #{{number}}?",
|
||||
"stateChangeConfirm": "¿{{action}} {{target}} #{{number}}?",
|
||||
"stateChangeTitle": "{{action}} {{target}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +508,9 @@
|
||||
"medium": "中くらい",
|
||||
"high": "高い",
|
||||
"urgent": "緊急",
|
||||
"selected": "選択済み"
|
||||
"selected": "選択済み",
|
||||
"iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}}日間",
|
||||
"action": "{{taskKindLabel}}を{{actionVerb}}"
|
||||
},
|
||||
"accounts": {
|
||||
"host": "ホスト",
|
||||
@@ -948,7 +950,14 @@
|
||||
"mobileBranchCompare": {
|
||||
"committed": "ブランチに Commit",
|
||||
"changedFileCountFile": "{{changedFileCount}} ファイル",
|
||||
"changedFileCountFiles": "{{changedFileCount}} ファイル"
|
||||
"changedFileCountFiles": "{{changedFileCount}} ファイル",
|
||||
"commitCountCommit": "{{commitCount}} コミット",
|
||||
"commitCountCommits": "{{commitCount}} コミット",
|
||||
"vs": "{{baseRef}} と比較"
|
||||
},
|
||||
"mobileQuickCommandLaunch": {
|
||||
"quickCommand": "クイックコマンド",
|
||||
"inserted": "{{commandLabel}}を挿入しました"
|
||||
},
|
||||
"mobileCommitMessageAi": {
|
||||
"failed": "commit メッセージの生成に失敗しました",
|
||||
@@ -2527,5 +2536,10 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"metaFallback": "プレビュー"
|
||||
},
|
||||
"hostedReview": {
|
||||
"mergeConfirm": "{{target}} #{{number}} を{{action}}しますか?",
|
||||
"stateChangeConfirm": "{{target}} #{{number}} を{{action}}?",
|
||||
"stateChangeTitle": "{{target}}を{{action}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +508,9 @@
|
||||
"medium": "중간",
|
||||
"high": "높음",
|
||||
"urgent": "긴급",
|
||||
"selected": "선택된"
|
||||
"selected": "선택된",
|
||||
"iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}}일",
|
||||
"action": "{{taskKindLabel}} {{actionVerb}}"
|
||||
},
|
||||
"accounts": {
|
||||
"host": "호스트",
|
||||
@@ -948,6 +950,10 @@
|
||||
"changedFileCountFiles": "{{changedFileCount}} 파일",
|
||||
"vs": "대 {{baseRef}}"
|
||||
},
|
||||
"mobileQuickCommandLaunch": {
|
||||
"quickCommand": "빠른 명령",
|
||||
"inserted": "{{commandLabel}} 삽입됨"
|
||||
},
|
||||
"mobileCommitMessageAi": {
|
||||
"failed": "commit 메시지를 생성하지 못했습니다.",
|
||||
"no": "commit 메시지가 생성되지 않았습니다."
|
||||
@@ -2526,5 +2532,10 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"metaFallback": "미리보기"
|
||||
},
|
||||
"hostedReview": {
|
||||
"mergeConfirm": "{{target}} #{{number}} {{action}}?",
|
||||
"stateChangeConfirm": "{{target}} #{{number}} {{action}}?",
|
||||
"stateChangeTitle": "{{target}} {{action}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +507,9 @@
|
||||
"medium": "中等的",
|
||||
"high": "高的",
|
||||
"urgent": "紧迫的",
|
||||
"selected": "已选择"
|
||||
"selected": "已选择",
|
||||
"iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}} 天",
|
||||
"action": "{{actionVerb}}{{taskKindLabel}}"
|
||||
},
|
||||
"accounts": {
|
||||
"host": "主机",
|
||||
@@ -953,6 +955,10 @@
|
||||
"changedFileCountFiles": "{{changedFileCount}} 文件",
|
||||
"vs": "与 {{baseRef}}"
|
||||
},
|
||||
"mobileQuickCommandLaunch": {
|
||||
"quickCommand": "快捷命令",
|
||||
"inserted": "已插入{{commandLabel}}"
|
||||
},
|
||||
"mobileCommitMessageAi": {
|
||||
"failed": "无法生成 commit 消息",
|
||||
"no": "没有生成 commit 消息"
|
||||
@@ -2538,5 +2544,10 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"metaFallback": "预览"
|
||||
},
|
||||
"hostedReview": {
|
||||
"mergeConfirm": "{{action}}{{target}} #{{number}}?",
|
||||
"stateChangeConfirm": "{{action}}{{target}} #{{number}}?",
|
||||
"stateChangeTitle": "{{action}}{{target}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import appConfig from '../../app.json'
|
||||
|
||||
import {
|
||||
createMobileTranslator,
|
||||
getActiveMobileUiLanguageTag,
|
||||
mobileI18n,
|
||||
normalizeMobileUiLocale,
|
||||
selectPreferredMobileUiLocale,
|
||||
@@ -55,6 +56,12 @@ describe('mobile i18n', () => {
|
||||
expect(createMobileTranslator('task')('gitHub')).toBe('GitHub')
|
||||
})
|
||||
|
||||
it('exposes the effective BCP 47 language tag for embedded documents', async () => {
|
||||
await mobileI18n.changeLanguage('zh')
|
||||
|
||||
expect(getActiveMobileUiLanguageTag()).toBe('zh-Hans')
|
||||
})
|
||||
|
||||
it('enables localized native metadata on iOS', () => {
|
||||
expect(appConfig.expo.ios.infoPlist.CFBundleAllowMixedLocalizations).toBe(true)
|
||||
})
|
||||
@@ -142,6 +149,36 @@ describe('mobile i18n', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Japanese composite action grammar in the active locale', async () => {
|
||||
await mobileI18n.changeLanguage('ja')
|
||||
|
||||
expect([
|
||||
t('hostedReview.mergeConfirm', {
|
||||
action: t('task.merge'),
|
||||
target: t('task.pr'),
|
||||
number: 42
|
||||
}),
|
||||
t('hostedReview.stateChangeTitle', {
|
||||
action: t('task.close'),
|
||||
target: t('task.pullRequest')
|
||||
}),
|
||||
t('task.action', {
|
||||
actionVerb: t('task.close'),
|
||||
taskKindLabel: t('task.pullRequestMessage')
|
||||
})
|
||||
]).toEqual(['PR #42 をマージしますか?', 'PRを閉じる', 'PRを閉じる'])
|
||||
})
|
||||
|
||||
it('renders Spanish branch counts with correct noun order and Git terminology', async () => {
|
||||
await mobileI18n.changeLanguage('es')
|
||||
|
||||
expect([
|
||||
t('mobileBranchCompare.changedFileCountFiles', { changedFileCount: 2 }),
|
||||
t('mobileBranchCompare.commitCountCommit', { commitCount: 1 }),
|
||||
t('mobileBranchCompare.commitCountCommits', { commitCount: 2 })
|
||||
]).toEqual(['2 archivos', '1 commit', '2 commits'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['es', ['Omitido', 'Publicando rama...']],
|
||||
['ja', ['スキップ済み', 'ブランチを公開しています...']],
|
||||
|
||||
@@ -76,6 +76,11 @@ export function t(key: string, options?: TOptions): string {
|
||||
return mobileI18n.t(key, options)
|
||||
}
|
||||
|
||||
export function getActiveMobileUiLanguageTag(): string {
|
||||
const locale = normalizeMobileUiLocale(mobileI18n.language)
|
||||
return locale === 'zh' ? 'zh-Hans' : locale
|
||||
}
|
||||
|
||||
export function createMobileTranslator(keyPrefix: string) {
|
||||
return (key: string, options?: TOptions): string => t(`${keyPrefix}.${key}`, options)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const { locales, reloadAppAsync } = vi.hoisted(() => ({
|
||||
locales: [{ languageTag: 'es-MX' }],
|
||||
const { localeState, reloadAppAsync } = vi.hoisted(() => ({
|
||||
localeState: { current: [{ languageTag: 'es-MX' }] },
|
||||
reloadAppAsync: vi.fn<() => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('expo', () => ({ reloadAppAsync }))
|
||||
vi.mock('expo-localization', () => ({
|
||||
getLocales: () => [{ languageTag: 'en' }],
|
||||
useLocales: () => locales
|
||||
useLocales: () => localeState.current
|
||||
}))
|
||||
|
||||
import { useMobileLocaleReload } from './use-mobile-locale-reload'
|
||||
@@ -28,6 +28,7 @@ describe('useMobileLocaleReload', () => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
reloadAppAsync.mockReset()
|
||||
localeState.current = [{ languageTag: 'es-MX' }]
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
@@ -56,4 +57,33 @@ describe('useMobileLocaleReload', () => {
|
||||
})
|
||||
expect(reloadAppAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries a rejected request after locale preferences change while it is pending', async () => {
|
||||
let rejectReload!: (error: Error) => void
|
||||
reloadAppAsync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectReload = reject
|
||||
})
|
||||
)
|
||||
reloadAppAsync.mockResolvedValueOnce(undefined)
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
expect(reloadAppAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
localeState.current = [{ languageTag: 'ja-JP' }]
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
rejectReload(new Error('reload unavailable'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
|
||||
expect(reloadAppAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,28 @@ const LOCALE_RELOAD_RETRY_MS = 1_000
|
||||
export function useMobileLocaleReload(): void {
|
||||
const locales = useLocales()
|
||||
const reloadRequestedRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const [retryPending, setRetryPending] = useState(false)
|
||||
const [retryVersion, setRetryVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!retryPending) {
|
||||
return
|
||||
}
|
||||
const retryTimer = setTimeout(() => {
|
||||
setRetryPending(false)
|
||||
setRetryVersion((current) => current + 1)
|
||||
}, LOCALE_RELOAD_RETRY_MS)
|
||||
return () => clearTimeout(retryTimer)
|
||||
}, [retryPending])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
reloadRequestedRef.current ||
|
||||
@@ -21,23 +41,12 @@ export function useMobileLocaleReload(): void {
|
||||
return
|
||||
}
|
||||
reloadRequestedRef.current = true
|
||||
let active = true
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
void reloadAppAsync('Mobile locale changed').catch(() => {
|
||||
if (!active) {
|
||||
reloadRequestedRef.current = false
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
reloadRequestedRef.current = false
|
||||
retryTimer = setTimeout(
|
||||
() => setRetryVersion((current) => current + 1),
|
||||
LOCALE_RELOAD_RETRY_MS
|
||||
)
|
||||
setRetryPending(true)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}
|
||||
}, [locales, retryVersion])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import { mobileI18n } from '../i18n/mobile-i18n'
|
||||
import {
|
||||
buildMobileQuickCommandLaunch,
|
||||
getQuickCommandDisplayPreview,
|
||||
@@ -7,6 +8,12 @@ import {
|
||||
supportsMobileQuickCommands
|
||||
} from './quick-commands'
|
||||
|
||||
const INITIAL_LOCALE = mobileI18n.language
|
||||
|
||||
afterEach(async () => {
|
||||
await mobileI18n.changeLanguage(INITIAL_LOCALE)
|
||||
})
|
||||
|
||||
function command(overrides: Partial<TerminalQuickCommand> = {}): TerminalQuickCommand {
|
||||
return {
|
||||
id: 'command',
|
||||
@@ -58,6 +65,16 @@ describe('mobile quick-command launch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('localizes the unsubmitted-command confirmation', async () => {
|
||||
await mobileI18n.changeLanguage('es')
|
||||
|
||||
expect(buildMobileQuickCommandLaunch(command({ label: '', appendEnter: false }))).toMatchObject(
|
||||
{
|
||||
options: { successToast: 'Comando rápido insertado' }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('injects supported agent prompts into the host-built launch command', () => {
|
||||
expect(
|
||||
buildMobileQuickCommandLaunch(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type TerminalQuickCommandMutation
|
||||
} from '../../../src/shared/terminal-quick-commands'
|
||||
import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { t } from '../i18n/mobile-i18n'
|
||||
import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents'
|
||||
|
||||
// Reuse the canonical desktop quick-command logic (pure, no heavy deps) so
|
||||
@@ -66,7 +67,9 @@ export function buildMobileQuickCommandLaunch(
|
||||
options: {
|
||||
initialPrompt: command.command,
|
||||
enter: false,
|
||||
successToast: `${command.label.trim() || 'Quick command'} inserted`
|
||||
successToast: t('mobileQuickCommandLaunch.inserted', {
|
||||
commandLabel: command.label.trim() || t('mobileQuickCommandLaunch.quickCommand')
|
||||
})
|
||||
}
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy'
|
||||
import { t } from '../i18n/mobile-i18n'
|
||||
import { getActiveMobileUiLanguageTag, t } from '../i18n/mobile-i18n'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
@@ -52,7 +52,7 @@ export const MOBILE_TERMINAL_CARET_OPTIONS = {
|
||||
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
|
||||
export function buildTerminalWebViewHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="${getActiveMobileUiLanguageTag()}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
|
||||
Reference in New Issue
Block a user