scripts/i18n_sentence_case.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
// Converts Title Case UI labels in messages/en.json to sentence case
// ("Delete Account" -> "Delete account"). Full sentences are left alone.
// Usage: bun scripts/i18n_sentence_case.mjs (dry run, prints changes)
// bun scripts/i18n_sentence_case.mjs --write (rewrites messages/en.json)
import { readFileSync, writeFileSync } from 'node:fs'
const FILE = new URL('../messages/en.json', import.meta.url)
// Capitalized words that are proper nouns: brands, products, plan names, dates, languages.
// All-caps (API, SSO, 2FA) and mixed-case (iOS, GitHub, WebView) words are always kept.
const KEEP_WORDS = new Set([
'Capgo', 'Capacitor', 'Android', 'Apple', 'Google', 'Discord', 'Slack', 'Stripe', 'Expo', 'Supabase',
'Cloudflare', 'Ionic', 'Xcode', 'Fastlane', 'Cursor', 'Claude', 'Copilot', 'Windsurf', 'Neovim',
'Lovable', 'Bolt', 'Replit', 'Builder', 'Enterprise', 'Maker', 'Estonia',
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
'January', 'February', 'March', 'April', 'June', 'July', 'August', 'September', 'October', 'November', 'December',
'English', 'French', 'German', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Chinese', 'Korean',
])
// Multi-word names kept verbatim. Longer phrases first: the regex takes the first match.
const KEEP_PHRASES = [
'Google Play Store', 'App Store Connect', 'App Store', 'Play Store', 'Google Play', 'App ID', 'Live Updates', 'Standard Webhooks',
'Capgo Builder', 'Capgo Assistant', 'Capgo Cloud', 'Capgo Updater',
]
// Values that are lists of names or intentionally capitalized.
const SKIP_KEYS = new Set([
'organization-onboarding-development-environment-option-ai_assistant-desc',
'organization-onboarding-development-environment-option-hand_coded-desc',
'organization-onboarding-development-environment-option-hosted_builder-desc',
'billing-region-option-ee-desc',
'updater-failures-note-native-link',
'webhook-test-success',
'notification-provider-secret-ref-placeholder',
])
const MASK = new RegExp(`${KEEP_PHRASES.join('|')}|\\{[^}]*\\}|\`[^\`]*\`|<[^>]*>`, 'g')
function isLabel(value) {
// Placeholders, code and HTML don't count as words. Not a sanitizer: output is only inspected.
const words = value.split(/\{[^}]*\}|`[^`]*`|<[^>]*>/).join(' ').match(/[A-Z][\w'.-]*/gi) ?? []
const rest = words.slice(1)
const caps = rest.filter(w => /^[A-Z]/.test(w))
// Sentences ("Click Save. Then Restart") are not labels.
return words.length >= 2 && words.length <= 10 && !/[.!?]\s/.test(value) && caps.length > 0 && caps.length >= rest.length / 2
}
export function sentenceCase(value) {
const masked = []
const out = value
.replace(MASK, (m) => {
masked.push(m)
return `\0${masked.length - 1}\0`
})
.replace(/[\p{L}\d']+/gu, (word, offset, str) => {
if (!/^\p{Lu}[\p{Ll}']+$/u.test(word) || KEEP_WORDS.has(word.replace(/'s$/, '')))
return word
const before = str.slice(0, offset)
// First word of the string or of a segment ("Warning: Bundles", "Bundle → Channel") stays capitalized.
if (!/[\p{L}\d\0]/u.test(before) || /[.!?:·→\n]\s*$/.test(before))
return word
return word.toLowerCase()
})
return out.replace(/\0(\d+)\0/g, (_, i) => masked[Number(i)])
}
if (import.meta.main) {
const messages = JSON.parse(readFileSync(FILE, 'utf8'))
let changed = 0
for (const [key, value] of Object.entries(messages)) {
if (key.startsWith('$') || SKIP_KEYS.has(key) || !isLabel(value))
continue
const next = sentenceCase(value)
if (next !== value) {
changed++
console.log(`${key}: ${JSON.stringify(value)} -> ${JSON.stringify(next)}`)
messages[key] = next
}
}
console.log(`${changed} values changed`)
if (process.argv.includes('--write'))
writeFileSync(FILE, `${JSON.stringify(messages, null, 2)}\n`)
}