Skip to content
52 changes: 37 additions & 15 deletions lunaria/prepare-json-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,36 @@ export const locales: [{ label: string; lang: string }, ...{ label: string; lang
})),
]

export async function prepareJsonFiles() {
export async function prepareJsonFiles(): Promise<void> {
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
}
type NestedObject = Record<string, unknown>

async function mergeLocale(locale: LocaleObject) {
export async function mergeLocaleObject(
locale: LocaleObject,
options: { copy?: boolean } = {},
): Promise<NestedObject | undefined> {
const { copy = false } = options
const files = locale.files ?? []
if (locale.file || files.length === 1) {
const json = locale.file ?? (files[0] ? getFileName(files[0]) : undefined)
if (!json) return
await fs.cp(path.resolve(`${localesFolder}/${json}`), path.resolve(`${destFolder}/${json}`))
return
const json =
(locale.file ? getFileName(locale.file) : undefined) ??
(files[0] ? getFileName(files[0]) : undefined)
if (!json) return undefined
if (copy) {
await fs.cp(path.resolve(`${localesFolder}/${json}`), path.resolve(`${destFolder}/${json}`))
return undefined
}

return await loadJsonFile<NestedObject>(json)
}

const firstFile = files[0]
if (!firstFile) return
const source = await loadJsonFile(getFileName(firstFile))
if (!firstFile) return undefined
const source = await loadJsonFile<NestedObject>(getFileName(firstFile))
let currentSource: unknown
for (let i = 1; i < files.length; i++) {
const file = files[i]
Expand All @@ -65,8 +69,26 @@ async function mergeLocale(locale: LocaleObject) {
deepCopy(currentSource, source)
}

return source
}

async function loadJsonFile<T = unknown>(name: string): Promise<T> {
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<void> {
const source = await mergeLocaleObject(locale, { copy: true })
if (!source) {
return
}

await fs.writeFile(
path.resolve(`${destFolder}/${locale.code}.json`),
JSON.stringify(source, null, 2),
'utf-8',
Comment on lines 89 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve trailing newline to avoid dirty lunaria/files.
Line 90 writes JSON without a newline, which strips EOF newlines and can leave tracked files dirty after running the script, triggering build failures that require clean working trees. Please append \n when writing.

🛠️ Suggested fix
-    JSON.stringify(source, null, 2),
+    JSON.stringify(source, null, 2) + '\n',
     'utf-8',

)
}
169 changes: 152 additions & 17 deletions scripts/compare-translations.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,139 @@
/* 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<string, Map<string, LocaleInfo>>()
const availableLocales = new Map<string, LocaleObject>()

function extractLocalInfo(filePath: string): LocaleInfo {
const locale = basename(filePath, '.json')
const [lang, country] = locale.split('-')
return { filePath, locale, lang, country }
}

function createVariantInfo(
code: string,
options: { forCountry: boolean; mergeLocale: boolean },
): LocaleInfo {
const [lang, country] = code.split('-')
return { filePath: '', locale: code, lang, country, ...options }
}

const loadJson = (filePath: string): NestedObject => {
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, createVariantInfo(lang, { forCountry: true, mergeLocale: false }))
countries
.get(lang)!
.set(
variant.code,
createVariantInfo(variant.code, { forCountry: true, mergeLocale: true }),
)
} else {
countries
.get(lang)!
.set(
variant.code,
createVariantInfo(variant.code, { forCountry: false, mergeLocale: 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}-<country-name>"${COLORS.reset}`,
)
process.exit(1)
Comment on lines +80 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the stray quote in the error message.
There’s an extra " before ${COLORS.reset} which leaks into the CLI output.

🛠️ Suggested fix
-          `${COLORS.red}Error: Invalid locale file "${locale}", it should be included at "countryLocaleVariants" in config/i18n.ts"${COLORS.reset}`,
+          `${COLORS.red}Error: Invalid locale file "${locale}", it should be included at "countryLocaleVariants" in config/i18n.ts${COLORS.reset}`,

}
}
}

const checkJsonName = (filePath: string): LocaleInfo => {
const info = extractLocalInfo(filePath)
checkCountryVariant(info)
return info
}

const loadJson = async ({ filePath, mergeLocale, locale }: LocaleInfo): Promise<NestedObject> => {
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
}

type SyncStats = {
Expand Down Expand Up @@ -43,7 +161,14 @@ 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)
// When fixing, empty objects won't occur since missing keys get placeholders.
// Without --fix, keep empty objects to preserve structural parity with the reference.
if (fix && Object.keys(data).length === 0) {
delete result[key]
} else {
result[key] = data
}
} else {
stats.referenceKeys.push(propertyPath)

Expand Down Expand Up @@ -83,13 +208,14 @@ const logSection = (
keys.forEach(key => console.log(` - ${key}`))
}

const processLocale = (
const processLocale = async (
localeFile: string,
referenceContent: NestedObject,
fix = false,
): SyncStats => {
): Promise<SyncStats> => {
const filePath = join(LOCALES_DIRECTORY, localeFile)
const targetContent = loadJson(filePath)
const localeInfo = checkJsonName(filePath)
const targetContent = await loadJson(localeInfo)

const stats: SyncStats = {
missing: [],
Expand All @@ -107,7 +233,11 @@ const processLocale = (
return stats
}

const runSingleLocale = (locale: string, referenceContent: NestedObject, fix = false): void => {
const runSingleLocale = async (
locale: string,
referenceContent: NestedObject,
fix = false,
): Promise<void> => {
const localeFile = locale.endsWith('.json') ? locale : `${locale}.json`
const filePath = join(LOCALES_DIRECTORY, localeFile)

Expand All @@ -116,7 +246,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}`,
Expand Down Expand Up @@ -144,7 +274,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<void> => {
const localeFiles = readdirSync(LOCALES_DIRECTORY).filter(
file => file.endsWith('.json') && file !== REFERENCE_FILE_NAME,
)
Expand All @@ -156,7 +286,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,
Expand Down Expand Up @@ -224,21 +354,26 @@ const runAllLocales = (referenceContent: NestedObject, fix = false): void => {
console.log('')
}

const run = (): void => {
const run = async (): Promise<void> => {
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')
const targetLocale = args.find(arg => !arg.startsWith('--'))

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

run()
await run()
Loading