scripts/generate-translation-contexts.mjs
Rama main · réplica de lectura del repositorio original.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..')
const en = JSON.parse(readFileSync(join(root, 'messages/en.json'), 'utf8'))
const keys = Object.keys(en).filter(key => key !== '$schema').sort()
const keySet = new Set(keys)
function walk(dir, out = []) {
const names = readdirSync(dir).sort()
for (const name of names) {
if (name === 'node_modules' || name === 'dist' || name === '.git')
continue
const path = join(dir, name)
const st = statSync(path)
if (st.isDirectory()) {
walk(path, out)
continue
}
if (/\.(vue|ts|js|tsx|jsx|mjs|cjs)$/.test(name))
out.push(path)
}
return out
}
const files = walk(join(root, 'src'))
const usage = new Map()
function addUsage(key, rel) {
if (!keySet.has(key))
return
if (!usage.has(key))
usage.set(key, new Set())
usage.get(key).add(rel)
}
const directPatterns = [
/(?:\$?t|i18n\.global\.t)\(\s*['"`]([\w.-]+)['"`]/g,
/(?:label|title|placeholder|description|heading|message|text|tooltip|hint|i18nKey|translationKey|name)\s*:\s*['"`]([\w.-]+)['"`]/g,
/(?:label|title|placeholder|description|heading|aria-label|ariaLabel)\s*=\s*['"`]([\w.-]+)['"`]/g,
]
for (const file of files) {
const content = readFileSync(file, 'utf8')
const rel = relative(root, file)
for (const pattern of directPatterns) {
pattern.lastIndex = 0
let match
while ((match = pattern.exec(content)) !== null)
addUsage(match[1], rel)
}
const literalPattern = /['"`]([\w.-]+)['"`]/g
let match
while ((match = literalPattern.exec(content)) !== null) {
const key = match[1]
if (!keySet.has(key))
continue
if (key.length < 3 && key !== 'ok')
continue
addUsage(key, rel)
}
}
function areaFromPath(path) {
const normalized = path
.replace(/^src\//, '')
.replace(/\.(vue|ts|js|tsx|jsx|mjs|cjs)$/, '')
const parts = normalized.split('/')
// Drop the file/component leaf so renames inside the same folder stay stable.
// Root-level files under src/ fall back to "app".
const area = parts.length > 1 ? parts.slice(0, -1).join('/') : ''
return area
.replace(/\[([^\]]+)\]/g, '($1)')
|| 'app'
}
function inferUiRole(text, key) {
const k = key.toLowerCase()
if (k.endsWith('-placeholder') || k.includes('placeholder'))
return 'input placeholder'
if (k.endsWith('-title') || k.endsWith('-heading'))
return 'section or dialog title'
if (k.endsWith('-description') || k.endsWith('-desc') || k.endsWith('-help'))
return 'helper or description text'
if (k.endsWith('-tooltip'))
return 'tooltip'
if (k.startsWith('action-'))
return 'device or update event label in logs/analytics'
if (k.includes('toast') || /\b(success|error|failed|warning)\b/.test(k))
return 'toast or status message'
if (
k.includes('button')
|| k.startsWith('btn-')
|| /^(save|cancel|delete|confirm|create|update|edit|add|remove|enable|disable|continue|back|next|submit|close|done|retry|download|upload|copy|invite|accept|reject)$/i.test(k)
) {
return 'button or action label'
}
if (/\d+-days$/.test(k) || k === 'filters' || k === 'current')
return 'filter or date-range option label'
if (k.includes('empty') || k.startsWith('no-'))
return 'empty state text'
if (text.trim().length <= 24 && !text.trim().includes(' '))
return 'short UI label'
if (text.trim().length <= 40)
return 'UI label'
return 'UI sentence'
}
// Roles the heuristics above can't infer. Keep them here so regenerating doesn't drop them.
const ROLE_OVERRIDES = {
'app-access-inherited': 'app access summary in the members table for org admins, {role} is the app role name',
'load-older': 'button label that loads older audit log entries',
'search': 'search input label or placeholder',
'skip': 'button label to skip an optional step',
'user-not-found': 'error message',
}
function roleFor(key, text) {
// RoleCapabilitiesHint splits these on ";" (a "|" would be read as a vue-i18n plural separator).
if (/^role-cap-.+-(?:can|cannot)$/.test(key))
return 'list of short capability items separated by semicolons (;). Keep each item short and keep the ; separators between items'
return ROLE_OVERRIDES[key] ?? inferUiRole(text, key)
}
function topicFromKey(key) {
return key.replace(/-/g, ' ')
}
function namespaceHint(key) {
const prefixes = [
['role-cap-', 'role capability tooltip in the Capgo web console'],
['2fa-', 'organization/account two-factor authentication'],
['admin-', 'platform admin dashboard'],
['api-key', 'API key management'],
['app-onboarding', 'app onboarding flow'],
['app-', 'application settings or app detail'],
['bundle-', 'bundle management'],
['channel-', 'channel settings'],
['device-', 'device management'],
['org-', 'organization settings'],
['plan-', 'billing/plans'],
['stripe-', 'billing/Stripe'],
['webhook-', 'webhook settings'],
['action-', 'plugin update/action event names'],
['build-', 'native build system'],
['credit', 'usage credits billing'],
['replication-', 'update replication toast/status'],
]
for (const [prefix, hint] of prefixes) {
if (key.startsWith(prefix) || key.includes(prefix))
return hint
}
return 'Capgo web console'
}
function keyFallbackContext(key, text) {
const role = roleFor(key, text)
const area = namespaceHint(key)
return `Used in ${area}. Role: ${role} about "${topicFromKey(key)}". Translate for UI; keep Capgo product names, code, and placeholders unchanged.`
}
const contexts = {}
let withUsage = 0
for (const key of keys) {
const text = en[key]
const filesForKey = usage.get(key)
if (filesForKey && filesForKey.size > 0) {
withUsage += 1
const areas = [...new Set([...filesForKey].map(areaFromPath).sort())].slice(0, 5)
const role = roleFor(key, text)
contexts[key] = `Used in Capgo web console areas: ${areas.join(', ')}. Role: ${role}. Translate for UI; keep Capgo product names, code, and placeholders unchanged.`
}
else {
contexts[key] = keyFallbackContext(key, text)
}
}
const outPath = join(root, 'messages/en.context.json')
writeFileSync(outPath, `${JSON.stringify(contexts, null, 2)}\n`)
console.log(JSON.stringify({
keys: keys.length,
withUsage,
withoutUsage: keys.length - withUsage,
outPath: relative(root, outPath),
samples: {
account: contexts.account,
Current: contexts.Current,
Filters: contexts.Filters,
MAU: contexts.MAU,
'action-app-crash': contexts['action-app-crash'],
'2fa': contexts['2fa'],
},
}, null, 2))