feat(locale): validate locales against CLDR in CLI and tests (#6826)

This commit is contained in:
Benjamin Canac
2026-08-11 16:45:23 +02:00
committed by GitHub
parent 21db429682
commit 387ce9a49e
5 changed files with 164 additions and 14 deletions
+40 -11
View File
@@ -11,16 +11,14 @@ export default defineCommand({
},
args: {
code: {
description: 'Locale code to create. For example: en.',
description: 'Locale code to create. For example: en or en_gb.',
required: true
},
name: {
description: 'Locale name to create. For example: English.',
required: true
description: 'Locale name in its own language. Defaults to the CLDR name for the code.'
},
dir: {
description: 'Locale direction. For example: rtl.',
default: 'ltr'
description: 'Locale direction. Defaults to the CLDR direction for the code.'
}
},
async setup({ args }) {
@@ -36,13 +34,44 @@ export default defineCommand({
process.exit(1)
}
if (!['ltr', 'rtl'].includes(args.dir)) {
consola.error(`🚨 Direction ${args.dir} not supported!`)
if (!args.code.match(/^[a-z]{2,3}(?:_(?:[a-z]{2}|\d{3}))?$/)) {
consola.error(`🚨 ${args.code} is not a valid locale code!\nExample: en, en_gb or es_419`)
process.exit(1)
}
if (!args.code.match(/^[a-z]{2}(?:_[a-z]{2,4})?$/)) {
consola.error(`🚨 ${args.code} is not a valid locale code!\nExample: en or en_us`)
const code = normalizeLocale(args.code)
const language = code.split('-')[0]
// `Intl.DisplayNames` echoes the input back when the code is unknown to CLDR
if (new Intl.DisplayNames(['en'], { type: 'language' }).of(language) === language) {
consola.error(`🚨 ${language} is not a known ISO 639 language code!\nFor example, the code for Tajik is tg, not tj.`)
process.exit(1)
}
const region = code.split('-')[1]
if (region && new Intl.DisplayNames(['en'], { type: 'region' }).of(region) === region) {
consola.error(`🚨 ${region} is not a known region code!\nExample: en_gb or pt_br`)
process.exit(1)
}
let name = args.name
if (!name) {
const cldrName = new Intl.DisplayNames([code], { type: 'language', languageDisplay: 'standard' }).of(code)
name = cldrName.charAt(0).toLocaleUpperCase(code) + cldrName.slice(1)
consola.info(`🌍 Using CLDR name for ${code}: ${name}`)
}
let dir = args.dir
if (!dir) {
const intlLocale = new Intl.Locale(code)
dir = (intlLocale.getTextInfo?.() ?? intlLocale.textInfo)?.direction ?? 'ltr'
if (dir === 'rtl') {
consola.info(`🌍 Using CLDR direction for ${code}: ${dir}`)
}
}
if (!['ltr', 'rtl'].includes(dir)) {
consola.error(`🚨 Direction ${dir} not supported!`)
process.exit(1)
}
@@ -55,8 +84,8 @@ export default defineCommand({
await fsp.copyFile(originLocaleFilePath, newLocaleFilePath)
const localeFile = await fsp.readFile(newLocaleFilePath, 'utf-8')
const rewrittenLocaleFile = localeFile
.replace(/name: '(.*)',/, `name: '${args.name}',`)
.replace(/code: '(.*)',/, `code: '${normalizeLocale(args.code)}',${(args.dir && args.dir !== 'ltr') ? `\n dir: '${args.dir}',` : ''}`)
.replace(/name: '(.*)',/, `name: '${name}',`)
.replace(/code: '(.*)',/, `code: '${code}',${dir !== 'ltr' ? `\n dir: '${dir}',` : ''}`)
await fsp.writeFile(newLocaleFilePath, rewrittenLocaleFile)
consola.success(`🪄 Generated ${newLocaleFilePath}`)
@@ -91,7 +91,7 @@ function getEmojiFlag(locale: string): string {
<ProseTip>
You can use the <ProseCode>nuxt-ui</ProseCode> CLI to create a new locale:
<ProsePre language="bash">nuxt-ui make locale --code "en" --name "English"</ProsePre>
<ProsePre language="bash">{{ 'nuxt-ui make locale --code <code>' }}</ProsePre>
</ProseTip>
</div>
</template>
@@ -112,7 +112,15 @@ When creating a new component, the CLI will automatically generate all the neces
You can create new locales using the following command:
```sh
nuxt-ui make locale --code <code> --name <name>
nuxt-ui make locale --code <code>
```
The locale name and direction are filled in from [CLDR](https://cldr.unicode.org/) and the code is validated against it. You can override them with the `--name` and `--dir` flags.
Once you have translated the messages, you can check your locale stays consistent with the others (message keys, placeholders, name and direction) by running:
```sh
pnpm test locale --run
```
::note{to="/docs/getting-started/integrations/i18n/nuxt#supported-languages"}
+113
View File
@@ -0,0 +1,113 @@
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect } from 'vitest'
import * as locales from '../src/runtime/locale'
import en from '../src/runtime/locale/en'
import type { Locale, Messages } from '../src/runtime/types/locale'
const localeDir = resolve(process.cwd(), 'src/runtime/locale')
const files = readdirSync(localeDir).filter(file => file.endsWith('.ts') && file !== 'index.ts').map(file => file.replace(/\.ts$/, ''))
const entries = Object.entries(locales) as [string, Locale<Messages>][]
// Deliberate deviations from the CLDR own-language name, kept because they are
// clearer in a language picker (e.g. `简体中文` over `中文(中国)`).
const nameExceptions: Record<string, string> = {
az: 'Azərbaycanca',
ckb: 'کوردی',
fa_ir: 'فارسی',
id: 'Bahasa Indonesia',
kk: 'Қазақша',
km: 'ភាសាខ្មែរ',
nb_no: 'Norsk Bokmål',
ug_cn: 'ئۇيغۇرچە',
uz: 'Oʻzbek',
zh_cn: '简体中文',
zh_tw: '繁體中文'
}
function normalizeLocale(locale: string): string {
if (locale.includes('_')) {
return locale.split('_').map((part, index) => index === 0 ? part.toLowerCase() : part.toUpperCase()).join('-')
}
return locale.toLowerCase()
}
function flatten(messages: object, prefix = '', result: Record<string, string> = {}): Record<string, string> {
for (const [key, value] of Object.entries(messages)) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object') {
flatten(value, path, result)
} else {
result[path] = value
}
}
return result
}
function placeholders(message: string): string {
return [...message.matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort().join(',')
}
const enMessages = flatten(en.messages)
describe('locales', () => {
it('exports every locale file from the index under its filename', () => {
expect(Object.keys(locales).sort()).toEqual(files.sort())
})
it('keeps the index exports sorted alphabetically', () => {
const lines = readFileSync(`${localeDir}/index.ts`, 'utf-8').trim().split('\n')
expect(lines).toEqual([...lines].sort())
})
it('has a unique code per locale', () => {
const codes = entries.map(([, locale]) => locale.code)
expect(new Set(codes).size).toBe(codes.length)
})
describe.each(entries)('%s', (key, locale) => {
it('has a code matching its filename', () => {
expect(locale.code).toBe(normalizeLocale(key))
})
it('has a code known to CLDR', () => {
const language = locale.code.split('-')[0]!
// `Intl.DisplayNames` echoes the input back when the code is unknown
expect(new Intl.DisplayNames(['en'], { type: 'language' }).of(language)).not.toBe(language)
})
it('has the exact same message keys as en', () => {
expect(Object.keys(flatten(locale.messages)).sort()).toEqual(Object.keys(enMessages).sort())
})
it('has no empty message', () => {
for (const [path, message] of Object.entries(flatten(locale.messages))) {
expect(message?.trim(), path).toBeTruthy()
}
})
it('has the same placeholders as en in every message', () => {
const messages = flatten(locale.messages)
for (const [path, message] of Object.entries(enMessages)) {
expect(placeholders(messages[path]!), path).toBe(placeholders(message))
}
})
it('has the direction CLDR expects', () => {
const intlLocale = new Intl.Locale(locale.code) as Intl.Locale & { getTextInfo?: () => { direction: string }, textInfo?: { direction: string } }
const direction = intlLocale.getTextInfo?.().direction ?? intlLocale.textInfo?.direction ?? 'ltr'
expect(locale.dir).toBe(direction)
})
it('is named after the CLDR own-language name', () => {
if (nameExceptions[key]) {
expect(locale.name).toBe(nameExceptions[key])
return
}
const names = ['standard', 'dialect'].map(languageDisplay =>
new Intl.DisplayNames([locale.code], { type: 'language', languageDisplay: languageDisplay as Intl.DisplayNamesOptions['languageDisplay'] }).of(locale.code)?.toLowerCase()
)
expect(names, `CLDR names for ${locale.code}`).toContain(locale.name.toLowerCase())
})
})
})
+1 -1
View File
@@ -47,7 +47,7 @@ export default defineConfig({
name: 'vue',
environment: 'happy-dom',
dir: './test',
include: ['components/**.spec.ts', 'composables/**.spec.ts', 'utils/**/**.spec.ts'],
include: ['components/**.spec.ts', 'composables/**.spec.ts', 'utils/**/**.spec.ts', 'locale.spec.ts'],
benchmark: { include: ['bench/**/*.bench.ts'] },
setupFiles: ['./test/utils/setup.ts']
},