fix(mobile): close localization audit gaps

This commit is contained in:
Brennan Benson
2026-08-01 07:43:05 -07:00
parent fb139ef64c
commit b476dec522
27 changed files with 692 additions and 181 deletions
+18 -48
View File
@@ -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:
+1 -1
View File
@@ -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>')
})
+4
View File
@@ -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"
+17 -4
View File
@@ -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}}"
}
}
+16 -2
View File
@@ -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}}"
}
}
+12 -1
View File
@@ -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}}"
}
}
+12 -1
View File
@@ -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}}"
}
}
+37
View File
@@ -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', ['スキップ済み', 'ブランチを公開しています...']],
+5
View File
@@ -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)
})
})
+23 -14
View File
@@ -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])
}
+18 -1
View File
@@ -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(
+4 -1
View File
@@ -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')
})
}
}
: {
+2 -2
View File
@@ -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">