Skip to content
40 changes: 28 additions & 12 deletions lunaria/prepare-json-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,19 @@ export async function prepareJsonFiles() {
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) {
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
} else {
return await loadJsonFile(json)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const firstFile = files[0]
Expand All @@ -65,8 +63,26 @@ async function mergeLocale(locale: LocaleObject) {
deepCopy(currentSource, source)
}

return source
}

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) {
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',
Comment on lines 89 to +92

Copy link
Copy Markdown
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',

)
}
144 changes: 128 additions & 16 deletions scripts/compare-translations.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable no-console */
import process from 'node:process'
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'

const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url))
const REFERENCE_FILE_NAME = 'en.json'
Expand All @@ -17,13 +19,107 @@ const COLORS = {
} as const

type NestedObject = { [key: string]: unknown }
interface LocaleInfo {
filePath: string
locale: string
lang: string
country?: string
forCountry?: boolean
mergeLocale?: boolean
}

const contries = new Map<string, Map<string, LocaleInfo>>()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 }
}
Comment thread
userquin marked this conversation as resolved.
Outdated

const populateLocaleCountries = (): void => {
for (const lang of Object.keys(countryLocaleVariants)) {
const variants = countryLocaleVariants[lang]
for (const variant of variants) {
if (!contries.has(lang)) {
contries.set(lang, new Map())
}
if (variant.country) {
contries.get(lang)!.set(lang, extractLocalInfo(lang, true))
contries.get(lang)!.set(variant.code, extractLocalInfo(variant.code, true, true))
} else {
contries.get(lang)!.set(variant.code, extractLocalInfo(variant.code, false, true))
}
}
}
}

const loadJson = (filePath: string): NestedObject => {
/**
* 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 = contries.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
Copy Markdown
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 = currentLocales.find(l => l.code === 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

type SyncStats = {
Expand Down Expand Up @@ -51,7 +147,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)

Expand Down Expand Up @@ -91,13 +193,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 @@ -115,7 +218,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 @@ -124,7 +231,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 @@ -152,7 +259,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 @@ -164,7 +271,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 @@ -232,20 +339,25 @@ 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)
}
}

Expand Down
Loading