diff --git a/lunaria/prepare-json-files.ts b/lunaria/prepare-json-files.ts index 0f962dd37..64e19d5f5 100644 --- a/lunaria/prepare-json-files.ts +++ b/lunaria/prepare-json-files.ts @@ -31,27 +31,28 @@ export const locales: [{ label: string; lang: string }, ...{ label: string; lang })), ] -export async function prepareJsonFiles() { +export async function prepareJsonFiles(): Promise { await fs.rm(destFolder, { recursive: true, force: true }) await fs.mkdir(destFolder) await Promise.all(currentLocales.map(l => mergeLocale(l))) } -async function loadJsonFile(name: string) { - return JSON.parse(await fs.readFile(path.resolve(`${localesFolder}/${name}`), 'utf8')) -} - -function getFileName(file: string | { path: string }): string { - return typeof file === 'string' ? file : file.path -} - -async function mergeLocale(locale: LocaleObject) { +export async function mergeLocaleObject( + locale: LocaleObject, + copy = false, +): Promise { const files = locale.files ?? [] if (locale.file || files.length === 1) { - const json = locale.file ?? (files[0] ? getFileName(files[0]) : undefined) + const json = + (locale.file ? getFileName(locale.file) : undefined) ?? + (files[0] ? getFileName(files[0]) : undefined) if (!json) return - await fs.cp(path.resolve(`${localesFolder}/${json}`), path.resolve(`${destFolder}/${json}`)) - return + if (copy) { + await fs.cp(path.resolve(`${localesFolder}/${json}`), path.resolve(`${destFolder}/${json}`)) + return + } + + return await loadJsonFile(json) } const firstFile = files[0] @@ -65,8 +66,26 @@ async function mergeLocale(locale: LocaleObject) { deepCopy(currentSource, source) } + return source +} + +async function loadJsonFile(name: string): Promise { + return JSON.parse(await fs.readFile(path.resolve(`${localesFolder}/${name}`), 'utf8')) +} + +function getFileName(file: string | { path: string }): string { + return typeof file === 'string' ? file : file.path +} + +async function mergeLocale(locale: LocaleObject): Promise { + const source = await mergeLocaleObject(locale, true) + if (!source) { + return + } + await fs.writeFile( path.resolve(`${destFolder}/${locale.code}.json`), JSON.stringify(source, null, 2), + 'utf-8', ) } diff --git a/scripts/compare-translations.ts b/scripts/compare-translations.ts index 3ba5fc3c6..1c1962026 100644 --- a/scripts/compare-translations.ts +++ b/scripts/compare-translations.ts @@ -1,21 +1,123 @@ /* eslint-disable no-console */ -import process from 'node:process' +import type { LocaleObject } from '@nuxtjs/i18n' +import * as process from 'node:process' import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { countryLocaleVariants, currentLocales } from '../config/i18n.ts' +import { mergeLocaleObject } from '../lunaria/prepare-json-files.ts' import { COLORS } from './utils.ts' const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url)) const REFERENCE_FILE_NAME = 'en.json' type NestedObject = { [key: string]: unknown } +interface LocaleInfo { + filePath: string + locale: string + lang: string + country?: string + forCountry?: boolean + mergeLocale?: boolean +} + +const countries = new Map>() +const availableLocales = new Map() + +const extractLocalInfo = ( + filePath: string, + forCountry: boolean = false, + mergeLocale: boolean = false, +): LocaleInfo => { + const locale = basename(filePath, '.json') + const [lang, country] = locale.split('-') + return { filePath, locale, lang, country, forCountry, mergeLocale } +} + +const populateLocaleCountries = (): void => { + for (const lang of Object.keys(countryLocaleVariants)) { + const variants = countryLocaleVariants[lang] + for (const variant of variants) { + if (!countries.has(lang)) { + countries.set(lang, new Map()) + } + if (variant.country) { + countries.get(lang)!.set(lang, extractLocalInfo(lang, true)) + countries.get(lang)!.set(variant.code, extractLocalInfo(variant.code, true, true)) + } else { + countries.get(lang)!.set(variant.code, extractLocalInfo(variant.code, false, true)) + } + } + } + + for (const localeData of currentLocales) { + availableLocales.set(localeData.code, localeData) + } +} + +/** + * We use ISO 639-1 for the language and ISO 3166-1 for the country (e.g. es-ES), we're preventing here: + * using the language as the JSON file name when there is no country variant. + * + * For example, `az.json` is wrong, should be `az-AZ.json` since it is not included at `countryLocaleVariants`. + */ +const checkCountryVariant = (localeInfo: LocaleInfo): void => { + const { locale, lang, country } = localeInfo + const countryVariant = countries.get(lang) + if (countryVariant) { + if (country) { + const found = countryVariant.get(locale) + if (!found) { + console.error( + `${COLORS.red}Error: Invalid locale file "${locale}", it should be included at "countryLocaleVariants" in config/i18n.ts"${COLORS.reset}`, + ) + process.exit(1) + } + localeInfo.forCountry = found.forCountry + localeInfo.mergeLocale = found.mergeLocale + } else { + localeInfo.forCountry = false + localeInfo.mergeLocale = false + } + } else { + if (!country) { + console.error( + `${COLORS.red}Error: Invalid locale file "${locale}", it should be included at "countryLocaleVariants" in config/i18n.ts, or change the name to include country name "${lang}-"${COLORS.reset}`, + ) + process.exit(1) + } + } +} -const loadJson = (filePath: string): NestedObject => { +const checkJsonName = (filePath: string): LocaleInfo => { + const info = extractLocalInfo(filePath) + checkCountryVariant(info) + return info +} + +const loadJson = async ({ filePath, mergeLocale, locale }: LocaleInfo): Promise => { if (!existsSync(filePath)) { console.error(`${COLORS.red}Error: File not found at ${filePath}${COLORS.reset}`) process.exit(1) } - return JSON.parse(readFileSync(filePath, 'utf-8')) as NestedObject + + if (!mergeLocale) { + return JSON.parse(readFileSync(filePath, 'utf-8')) as NestedObject + } + + const localeObject = availableLocales.get(locale) + if (!localeObject) { + console.error( + `${COLORS.red}Error: Locale "${locale}" not found in currentLocales${COLORS.reset}`, + ) + process.exit(1) + } + const merged = await mergeLocaleObject(localeObject) + if (!merged) { + console.error(`${COLORS.red}Error: Failed to merge locale "${locale}"${COLORS.reset}`) + process.exit(1) + } + return merged as NestedObject } type SyncStats = { @@ -43,7 +145,13 @@ const syncLocaleData = ( if (isNested(refValue)) { const nextTarget = isNested(target[key]) ? target[key] : {} - result[key] = syncLocaleData(refValue, nextTarget, stats, fix, propertyPath) + const data = syncLocaleData(refValue, nextTarget, stats, fix, propertyPath) + // don't add empty objects: --fix will prevent this + if (Object.keys(data).length === 0) { + delete result[key] + } else { + result[key] = data + } } else { stats.referenceKeys.push(propertyPath) @@ -83,13 +191,14 @@ const logSection = ( keys.forEach(key => console.log(` - ${key}`)) } -const processLocale = ( +const processLocale = async ( localeFile: string, referenceContent: NestedObject, fix = false, -): SyncStats => { +): Promise => { const filePath = join(LOCALES_DIRECTORY, localeFile) - const targetContent = loadJson(filePath) + const localeInfo = checkJsonName(filePath) + const targetContent = await loadJson(localeInfo) const stats: SyncStats = { missing: [], @@ -107,7 +216,11 @@ const processLocale = ( return stats } -const runSingleLocale = (locale: string, referenceContent: NestedObject, fix = false): void => { +const runSingleLocale = async ( + locale: string, + referenceContent: NestedObject, + fix = false, +): Promise => { const localeFile = locale.endsWith('.json') ? locale : `${locale}.json` const filePath = join(LOCALES_DIRECTORY, localeFile) @@ -116,7 +229,7 @@ const runSingleLocale = (locale: string, referenceContent: NestedObject, fix = f process.exit(1) } - const { missing, extra, referenceKeys } = processLocale(localeFile, referenceContent, fix) + const { missing, extra, referenceKeys } = await processLocale(localeFile, referenceContent, fix) console.log( `${COLORS.cyan}=== Missing keys for ${localeFile}${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`, @@ -144,7 +257,7 @@ const runSingleLocale = (locale: string, referenceContent: NestedObject, fix = f console.log('') } -const runAllLocales = (referenceContent: NestedObject, fix = false): void => { +const runAllLocales = async (referenceContent: NestedObject, fix = false): Promise => { const localeFiles = readdirSync(LOCALES_DIRECTORY).filter( file => file.endsWith('.json') && file !== REFERENCE_FILE_NAME, ) @@ -156,7 +269,7 @@ const runAllLocales = (referenceContent: NestedObject, fix = false): void => { let totalAdded = 0 for (const localeFile of localeFiles) { - const stats = processLocale(localeFile, referenceContent, fix) + const stats = await processLocale(localeFile, referenceContent, fix) results.push({ file: localeFile, ...stats, @@ -224,9 +337,14 @@ const runAllLocales = (referenceContent: NestedObject, fix = false): void => { console.log('') } -const run = (): void => { +const run = async (): Promise => { + populateLocaleCountries() const referenceFilePath = join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME) - const referenceContent = loadJson(referenceFilePath) + const referenceContent = await loadJson({ + filePath: referenceFilePath, + locale: 'en', + lang: 'en', + }) const args = process.argv.slice(2) const fix = args.includes('--fix') @@ -234,10 +352,10 @@ const run = (): void => { if (targetLocale) { // Single locale mode - runSingleLocale(targetLocale, referenceContent, fix) + await runSingleLocale(targetLocale, referenceContent, fix) } else { // All locales mode: check all and remove extraneous keys - runAllLocales(referenceContent, fix) + await runAllLocales(referenceContent, fix) } }