Skip to content

Comments

chore: update compare-translations.ts logic#1063

Merged
danielroe merged 10 commits intonpmx-dev:mainfrom
userquin:update-compare-translations-script
Feb 7, 2026
Merged

chore: update compare-translations.ts logic#1063
danielroe merged 10 commits intonpmx-dev:mainfrom
userquin:update-compare-translations-script

Conversation

@userquin
Copy link
Member

@userquin userquin commented Feb 6, 2026

This PR includes:

  • merge json files for country variants when loading json
  • export mergeLocaleObject utility from lunaria/prepare-json-files.ts and use it
  • protect new locales files: will prevent adding new locales without the country in the file name when not registered at country variants in config/i18n.ts
  • protect new locales for country variants not registered at country variants in config/i18n.ts

Context: check autofix in this PR #1036

/cc @shuuji3

@vercel
Copy link

vercel bot commented Feb 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 7, 2026 9:46am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Feb 7, 2026 9:46am
npmx-lunaria Ignored Ignored Feb 7, 2026 9:46am

Request Review

@codecov
Copy link

codecov bot commented Feb 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

prepare-json-files.ts: adds exported mergeLocaleObject(locale: LocaleObject, copy = false) that returns merged object or handles copy-only flows; prepareJsonFiles now explicitly returns Promise; mergeLocale calls mergeLocaleObject and writes the resulting source when present; internal NestedObject alias added and helpers loadJsonFile and getFileName were moved and remain non-exported. scripts/compare-translations.ts: refactored to async/ESM, introduces LocaleInfo and per-locale maps, adds extractLocalInfo, populateLocaleCountries, checkCountryVariant and checkJsonName, makes loadJson async (optionally using mergeLocaleObject), converts processLocale/run* to async, and omits empty nested objects during sync/fix.

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description accurately reflects the changeset, detailing the merging of JSON files for country variants, exporting the mergeLocaleObject utility, and protecting new locale files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lunaria/prepare-json-files.ts (2)

40-61: ⚠️ Potential issue | 🔴 Critical

mergeLocaleObject returns undefined for single-file locales — root cause of the pipeline failure.

Two issues in this function:

  1. Returns undefined for single-file locales (lines 44–46): The function copies the file as a side effect but returns nothing, which causes the TypeError [ERR_INVALID_ARG_TYPE] crash in mergeLocale (line 73) and will also break loadJson in compare-translations.ts (line 110).
  2. fs.cp side effect (line 45): A function named mergeLocaleObject that's exported as a pure data utility should not perform file-system writes. The copy belongs in the wrapper mergeLocale.
🐛 Proposed fix — return parsed JSON for all paths; move copy to mergeLocale
 export async function mergeLocaleObject(locale: LocaleObject) {
   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
+    return await loadJsonFile(json)
   }
 
   const firstFile = files[0]
   if (!firstFile) return
   const source = await loadJsonFile(getFileName(firstFile))
   let currentSource: unknown
   for (let i = 1; i < files.length; i++) {
     const file = files[i]
     if (!file) continue
     currentSource = await loadJsonFile(getFileName(file))
     deepCopy(currentSource, source)
   }
 
   return source
 }

71-77: ⚠️ Potential issue | 🔴 Critical

mergeLocale does not guard against undefined from mergeLocaleObject.

Even after fixing the upstream function, early-return paths (e.g. no files at all) can still yield undefined. JSON.stringify(undefined) produces the JS value undefined, which fs.writeFile rejects with ERR_INVALID_ARG_TYPE. Add a guard here.

🛡️ Proposed fix
 async function mergeLocale(locale: LocaleObject) {
   const source = await mergeLocaleObject(locale)
+  if (source === undefined) return
   await fs.writeFile(
     path.resolve(`${destFolder}/${locale.code}.json`),
     JSON.stringify(source, null, 2),
   )
 }
🧹 Nitpick comments (3)
scripts/compare-translations.ts (3)

33-41: Unchecked array destructuring from split.

locale.split('-') returns string[], so both lang and country are string | undefined at runtime (depending on noUncheckedIndexedAccess). lang is assigned to LocaleInfo.lang: string without a guard. While split always returns at least one element in practice, the coding guidelines require explicit checks when accessing array values by index.

🛡️ Proposed fix
 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 parts = locale.split('-')
+  const lang = parts[0]
+  if (!lang) {
+    throw new Error(`Invalid locale file name: "${locale}"`)
+  }
+  const country = parts[1]
+  return { filePath, locale, lang, country, forCountry, mergeLocale }
 }

As per coding guidelines, **/*.{ts,tsx,vue}: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".


138-144: delete result[key] is a no-op — key was never assigned to result.

At this point in the loop, result[key] has not yet been set, so delete result[key] does nothing. A continue (or simply not assigning) would express the intent more clearly.

♻️ Suggested simplification
       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 {
+      if (Object.keys(data).length > 0) {
         result[key] = data
       }

330-342: populateLocaleCountries() is called after loading the reference — consider calling it earlier.

Currently the reference content is loaded at line 332 via loadJson, which internally calls checkJsonNamecheckCountryVariant → reads from contries. Because the reference LocaleInfo has mergeLocale: undefined (falsy), it bypasses the merge path and the contries lookup doesn't affect it. However, this ordering is fragile — if the reference loading ever switches to the merge path, the contries map will be empty.

Moving populateLocaleCountries() before loadJson would make the initialisation order explicit and safe.

♻️ Suggested reordering
 const run = async (): Promise<void> => {
   const referenceFilePath = join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME)
+
+  populateLocaleCountries()
+
   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('--'))
 
-  populateLocaleCountries()
-
   if (targetLocale) {

@userquin
Copy link
Member Author

userquin commented Feb 6, 2026

somethings wrong with cs-CZ.json file => wrong logic, we use fs.cp to copy the file, fixed

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (6)
lunaria/prepare-json-files.ts (2)

40-67: Implicit return type is Promise<void | unknown> — consider narrowing.

mergeLocaleObject can return undefined (lines 46, 49), a parsed JSON object (line 51), or the merged source (line 66). The callers in compare-translations.ts cast the result with as NestedObject. An explicit return type (e.g. Promise<Record<string, unknown> | undefined>) would make the contract clearer and help catch misuse at compile time.


69-71: loadJsonFile returns any from JSON.parse.

This is a minor type-safety gap. Returning unknown (or Record<string, unknown>) instead of the implicit any would propagate stricter checks to all callers.

♻️ Proposed fix
-async function loadJsonFile(name: string) {
-  return JSON.parse(await fs.readFile(path.resolve(`${localesFolder}/${name}`), 'utf8'))
+async function loadJsonFile(name: string): Promise<Record<string, unknown>> {
+  return JSON.parse(await fs.readFile(path.resolve(`${localesFolder}/${name}`), 'utf8')) as Record<string, unknown>
 }

As per coding guidelines, **/*.{ts,tsx,vue}: "Ensure you write strictly type-safe code".

scripts/compare-translations.ts (4)

43-58: extractLocalInfo is called with locale codes, not file paths.

Lines 51–54 pass bare strings like "en" or "en-US" as the filePath argument to extractLocalInfo. The filePath field in the resulting LocaleInfo then holds a locale code rather than an actual path. This doesn't cause a runtime bug today because filePath from these map entries is never read, but it's semantically misleading and fragile if anyone later relies on contries entries having a real filePath.

Consider either renaming the parameter / field to something like localeOrPath, or passing a proper path.


150-156: delete result[key] on line 153 is a no-op — simplify the branch.

At this point result[key] has never been assigned, so delete result[key] does nothing. The intent (skip empty nested objects) is already achieved by only assigning in the else branch. Simplifying removes dead code and makes the intent clearer.

♻️ Proposed fix
       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 {
+      if (Object.keys(data).length > 0) {
         result[key] = data
       }

288-288: Prefer optional chaining over ! assertion on results[0].

The ternary guard ensures results.length > 0, but optional chaining is more idiomatic and equally concise:

♻️ Proposed fix
-  const referenceKeysCount = results.length > 0 ? results[0]!.referenceKeys.length : 0
+  const referenceKeysCount = results[0]?.referenceKeys.length ?? 0

As per coding guidelines, **/*.{ts,tsx,vue}: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".


342-361: Reference locale LocaleInfo hard-codes locale: 'en' — fragile coupling.

The reference file is en.json (line 10, REFERENCE_FILE_NAME), but the LocaleInfo at line 347–348 hard-codes locale: 'en' and lang: 'en' independently. If REFERENCE_FILE_NAME ever changes, these literals would silently drift. Consider deriving both values from the constant:

const referenceLocale = basename(REFERENCE_FILE_NAME, '.json')

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
lunaria/prepare-json-files.ts (2)

40-43: Return type Promise<void | unknown> collapses to Promise<unknown> — consider a narrower signature.

void | unknown is equivalent to unknown in TypeScript's type system, so the void conveys no extra compile-time safety. Callers (like mergeLocale) rely on a falsy check to distinguish "no result" from "has data", which works at runtime but isn't enforced by the type.

A more descriptive return type would make the contract clearer:

♻️ Suggested improvement
 export async function mergeLocaleObject(
   locale: LocaleObject,
   copy = false,
-): Promise<void | unknown> {
+): Promise<Record<string, unknown> | undefined> {

This would require a small cast/assertion when returning the result of loadJsonFile / building source, but it gives callers proper type narrowing with if (source).


80-91: mergeLocale silently succeeds when mergeLocaleObject returns a falsy-but-valid value.

The guard if (!source) on Line 82 would also skip writing if source happened to be an empty object {} — wait, no, !{} is false, so an empty object would pass. The actual concern is that any falsy value (e.g. 0, "", false) returned from mergeLocaleObject would be skipped. In practice this won't happen because JSON locale files always parse to objects, but a === undefined check would be more precise and intention-revealing.

♻️ Suggested tightening
   const source = await mergeLocaleObject(locale, true)
-  if (!source) {
+  if (source === undefined) {
     return
   }

@shuuji3
Copy link
Member

shuuji3 commented Feb 6, 2026

I think there are two issues.

  1. az.json caused some error. It looks like we need to adjust i18n.ts?
> pnpm i18n:check:fix

> npmx@0.0.0 i18n:check:fix /home/shuuji3/dev/npmx.dev
> node scripts/compare-translations.ts --fix

Error: Invalid locale file "az", it should be included at "countryLocaleVariants" in config/i18n.ts, or change the name to include country name "az-<country-name>"
 ELIFECYCLE  Command failed with exit code 1.
  1. I tested pnpm i18n:check:fix with the following json files.

en.json

{
  "parent1": "parent1 from en.json",
  "parent2": {
    "child1": "child1 from en.json",
    "child2": "child2 from en.json"
  }
}

es.json

{
  "parent1": "parent1 from es.json",
  "parent2": {
    "child1": "child1 from es.json"
  }
}

es-419.json

{
  "parent2": {
    "child2": "child2 from es-419.json"
  }
}
> pnpm i18n:check:fix

> npmx@0.0.0 i18n:check:fix /home/shuuji3/dev/npmx.dev
> node scripts/compare-translations.ts --fix

=== Translation Audit (with --fix) ===
Reference: en.json (3 keys)
Checking 23 locale(s)...

--- es.json ---

 ADDED MISSING KEYS (with EN placeholder)
  - parent2.child2

=== Summary ===
  Added missing keys (EN placeholder): 1

and it modified like this:

locales/es.json

{
  "parent1": "parent1 from es.json",
  "parent2": {
    "child1": "child1 from es.json",
    "child2": "EN TEXT TO REPLACE: child2 from en.json"
  }
}

locales/es-419.json

{
  "parent1": "parent1 from es.json",
  "parent2": {
    "child1": "child1 from es.json",
    "child2": "child2 from es-419.json"
  }
}

The first es.json is correct but I think we should't insert parent1 and child1 into es-419.json here (parent2.child2 only). Is my understanding correct?

@shuuji3
Copy link
Member

shuuji3 commented Feb 6, 2026

I thought I also tried build:lunaria and saw the correct results. But now I end up with beeing blocked by the error same as I previous saw 🥲

> pnpm build:lunaria

> npmx@0.0.0 build:lunaria /home/shuuji3/dev/npmx.dev
> node ./lunaria/lunaria.ts


 ERROR  The file lunaria/files/en-US.json is being tracked but no commits have been found. Ensure all tracked files in your working branch are committed before running Lunaria.                                     1:27:33 AM

 ELIFECYCLE  Command failed with exit code 1.

It seems that some script removed a newline at EOF only for these five files under lunaria/files/:

screenshot of code editor - showing 5 files changed under lunaria/files - newline removed in diff pane

@shuuji3
Copy link
Member

shuuji3 commented Feb 6, 2026

compare-translation.ts is a bit complicated. I wonder if we can add simple tests for this script with the simple json like the above. 🤔

@userquin
Copy link
Member Author

userquin commented Feb 6, 2026

@shuuji3 I need to merge main, I fixed az.json this morning...

@userquin
Copy link
Member Author

userquin commented Feb 6, 2026

The first es.json is correct but I think we should't insert parent1 and child1 into es-419.json here (parent2.child2 only). Is my understanding correct?

You don't need to change es-419.json since vue-i18n will do it for us: get es.json as source, get next file and merge into source the entries, source now is es-419: check countryLocaleVariants const; here we need to exclude those files => check the loadJson at compare-translations.ts; all missing entries at es-419 will be copied from es.json => we need to prevent running --fix in locales at countryLocaleVariants with country option not set, --fix should apply to files country files (the key in the countryLocaleVariants, and locales with <language>-<country> at locales const)

Right now if you run --fix with es-419, you will add all missing entries and this is wrong.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/compare-translations.ts (1)

194-217: ⚠️ Potential issue | 🟠 Major

Country variant files will be overwritten with merged (bloated) content during --fix.

For a locale like es-419 where mergeLocale is true, loadJson returns the merged content of es.json + es-419.json. After syncLocaleData, the result contains all base-language keys (from es.json) that match the reference. writeFileSync on line 213 then writes this entire merged result back to es-419.json, bloating it with keys that should only live in es.json.

This matches the bug shuuji3 reported in the PR comments. The forCountry flag is populated in checkCountryVariant (lines 76, 79) but never consumed anywhere in the processing or write path — it appears the guard to skip or differently handle country variant files during --fix is missing.

Suggested approach

One option: skip writing back to country-variant files during --fix in runAllLocales, or filter them out entirely. For example, in processLocale:

   const newContent = syncLocaleData(referenceContent, targetContent, stats, fix)

+  // Don't rewrite country variant files — they should only contain overrides
+  if (localeInfo.mergeLocale) {
+    return stats
+  }
+
   // Write if there are removals (always) or we are in fix mode
   if (stats.extra.length > 0 || fix) {
     writeFileSync(filePath, JSON.stringify(newContent, null, 2) + '\n', 'utf-8')
   }

This would still report missing/extra keys for country variants without modifying their files. The exact behaviour depends on what you intend for runSingleLocale targeting a variant directly.

🧹 Nitpick comments (3)
scripts/compare-translations.ts (3)

148-154: delete result[key] is a no-op — the key was never assigned.

When Object.keys(data).length === 0, result[key] hasn't been set yet, so the delete does nothing. Simplify by just not assigning:

Suggested fix
       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 {
+      if (Object.keys(data).length > 0) {
         result[key] = data
       }

37-56: forCountry is populated but never read anywhere in the file.

populateLocaleCountries and checkCountryVariant set forCountry on LocaleInfo, but no downstream code checks it. If it's intended to distinguish the "primary country" entry (e.g. es-ES with country: true) from other variants, it needs to be consumed — likely in the write guard discussed above. Otherwise it's dead state that adds confusion.


340-347: Reference locale bypasses checkCountryVariant — intentional but worth a comment.

The en reference is loaded with a hand-crafted LocaleInfo that skips checkJsonName / checkCountryVariant. This is correct (the reference shouldn't be validated the same way), but a brief inline comment would clarify intent for future readers.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/compare-translations.ts (1)

211-231: ⚠️ Potential issue | 🔴 Critical

Prevent --fix from writing merged base keys into country-variant files.

When --fix mode is used on locale variants like es-419.json, the script loads the merged content (combining es.json with the variant's overrides) and then writes the full merged tree back to the variant file. This corrupts the file structure—es-419.json should only contain translation overrides, not a complete copy of the base language content. Skip writes for mergeLocale-backed variants by checking localeInfo.mergeLocale before writing, or pass fix && !localeInfo.mergeLocale to prevent modifying variant files.

Comment on lines 89 to +92
await fs.writeFile(
path.resolve(`${destFolder}/${locale.code}.json`),
JSON.stringify(source, null, 2),
'utf-8',
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',

Comment on lines +80 to +103
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)
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}`,

@danielroe danielroe added this pull request to the merge queue Feb 7, 2026
Merged via the queue into npmx-dev:main with commit d731543 Feb 7, 2026
17 checks passed
@userquin userquin deleted the update-compare-translations-script branch February 7, 2026 14:55
whitep4nth3r pushed a commit that referenced this pull request Feb 7, 2026
Co-authored-by: Daniel Roe <daniel@roe.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants