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))