Skip to content

feat(i18n): add en.meta.json generation script#1761

Open
shuuji3 wants to merge 4 commits intonpmx-dev:mainfrom
shuuji3:feat/generate-en-meta-json
Open

feat(i18n): add en.meta.json generation script#1761
shuuji3 wants to merge 4 commits intonpmx-dev:mainfrom
shuuji3:feat/generate-en-meta-json

Conversation

@shuuji3
Copy link
Member

@shuuji3 shuuji3 commented Feb 28, 2026

🔗 Linked issue

#1747

🧭 Context

Currently, there is no mechanism to detect changes in en.json. Previously, we had to choose one of the following approaches when English text needed an update:

  • Using a different key: Even a small change requires re-translation for all other languages.
  • Updating the existing text: It is difficult to track which keys were updated, making it hard to sync with other languages.

This first step records metadata, tracking which commit updated each English key, which allows us to monitor changes in the English locale file effectively.

📚 Description

Implemented the first two tasks for automated en.json change detection:

  • Added a new generation script:
    • This script generates or updates en.meta.json alongside en.json.
    • If en.json is changed (added, removed, or modified) since the previous update, the corresponding values in en.meta.json are updated with the latest commit hash and the new string value.
  • Added a GitHub Actions workflow:
    • The script is triggered when a new commit is pushed to the main branch, only when the commit modifies i18n/locales/en.json.
    • This is needed to record a commit hash in main branch history.
      • I also considered and experimented to add auto-commit during the PR or local branch, but it cannot find a reliable commit hash if PR is squash-merged.
      • It could record PR number but I wanted to be git platform agnostic.

The example auto-commit of en.meta.json can be found in this CI test branch: https://github.com/shuuji3/npmx.dev/commits/feat/generate-en-meta-json-ci-test
(Note: In the test branch, I pushed directly to main branch and lunaria/files/* is included. But they are usually merged along with PR, so they should not be included in auto-commit)

You can also check the Vitest file to see how this script records commit hash and text.

@vercel
Copy link

vercel bot commented Feb 28, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 1, 2026 0:05am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 1, 2026 0:05am
npmx-lunaria Ignored Ignored Mar 1, 2026 0:05am

Request Review

@codecov
Copy link

codecov bot commented Feb 28, 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 Mar 1, 2026

📝 Walkthrough

Walkthrough

This pull request introduces an automated i18n metadata tracking system. It adds a GitHub Actions workflow that triggers on changes to the English locale file, a CLI tool with utility functions to generate and update translation metadata, TypeScript type definitions for metadata structures, and unit tests validating the metadata generation logic. The system tracks commit hashes and timestamps for translation entries in en.meta.json, updating automatically when en.json changes.

Possibly related issues

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly explains the purpose of adding automated en.json change detection through metadata tracking, including the generation script and GitHub Actions workflow implementation.

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

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 3

🧹 Nitpick comments (5)
scripts/tsconfig.json (1)

8-12: Minor configuration inconsistency.

The declaration and declarationMap options have no effect when noEmit: true is set, as no output files are generated. Consider removing these redundant options for clarity.

♻️ Suggested fix
   "skipLibCheck": true,
   "noEmit": true,
   "allowImportingTsExtensions": true,
-  "declaration": true,
-  "types": ["node"],
-  "declarationMap": true
+  "types": ["node"]
scripts/i18n-meta/cli.ts (1)

10-15: Add explicit type guard for array access.

Accessing positionals[0] without a guard is not strictly type-safe. While the current logic works correctly (showing help when undefined), an explicit check aligns better with the coding guidelines.

♻️ Suggested fix
 function main() {
   const { positionals } = parseArgs({ allowPositionals: true })
+  const command = positionals[0]

-  if (positionals[0] !== 'update-en-meta') {
+  if (command !== 'update-en-meta') {
     showHelp()
     return
   }

   updateEnMetaJson()
 }

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

.github/workflows/i18n-meta.yml (1)

38-48: Consider adding error handling for the commit step.

If the pnpm i18n:meta:update-en-meta step fails or exits unexpectedly, the workflow continues to the commit step. Consider adding explicit error handling or using set -e in the shell script.

♻️ Suggested improvement
       - name: ⬆︎ Commit and Push changes
         run: |
+          set -e
           git config --global user.name "github-actions[bot]"
           git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
           git add i18n/locales/en.meta.json
scripts/i18n-meta/update-en-meta-json.ts (1)

23-27: Silent fallback to empty object when no commit hash is available.

If getCurrentCommitHash() returns null or empty, the function silently uses an empty object as metadata. This could mask issues in environments where Git isn't properly configured. Consider logging a warning.

♻️ Suggested improvement
   const currentCommitHash = getCurrentCommitHash()
+  if (!currentCommitHash) {
+    console.warn('⚠️ Could not determine current commit hash – metadata will be empty')
+  }
   const enMetaJson = currentCommitHash
     ? makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)
     : ({} as EnMetaJson)
scripts/i18n-meta/git-utils.ts (1)

41-44: Use order-insensitive comparison for translation change detection.

At Line 44, JSON.stringify(oldObj) !== JSON.stringify(newObj) is key-order sensitive, so equivalent objects with different key order will be treated as changed.

Suggested fix
+function sortKeysRecursively(value: unknown): unknown {
+  if (Array.isArray(value)) {
+    return value.map(sortKeysRecursively)
+  }
+  if (value && typeof value === 'object') {
+    return Object.fromEntries(
+      Object.entries(value as Record<string, unknown>)
+        .sort(([a], [b]) => a.localeCompare(b))
+        .map(([k, v]) => [k, sortKeysRecursively(v)]),
+    )
+  }
+  return value
+}
+
 export function checkTranslationChanges(oldMeta: EnMetaJson, newMeta: EnMetaJson): boolean {
   const oldObj = omitMeta(oldMeta)
   const newObj = omitMeta(newMeta)
-  return JSON.stringify(oldObj) !== JSON.stringify(newObj)
+  return JSON.stringify(sortKeysRecursively(oldObj)) !== JSON.stringify(sortKeysRecursively(newObj))
 }

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f18d64e and 7ce664d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • .github/workflows/i18n-meta.yml
  • package.json
  • scripts/i18n-meta/cli.ts
  • scripts/i18n-meta/git-utils.ts
  • scripts/i18n-meta/types.d.ts
  • scripts/i18n-meta/update-en-meta-json.ts
  • scripts/tsconfig.json
  • test/unit/scripts/generate-en-meta-json.spec.ts
  • vitest.config.ts

Comment on lines 5 to 10
export function git(command: string) {
try {
return execSync(`git ${command}`, { encoding: 'utf-8' }).trim()
} catch {
console.error(`🚨 Git command failed: git ${command}`)
return null
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n scripts/i18n-meta/git-utils.ts

Repository: npmx-dev/npmx.dev

Length of output: 1980


🏁 Script executed:

rg "git\(" --type ts --type tsx -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

rg "git\(" --type ts -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 620


🏁 Script executed:

rg "from.*git-utils" --type ts

Repository: npmx-dev/npmx.dev

Length of output: 125


🏁 Script executed:

cat -n scripts/i18n-meta/update-en-meta-json.ts

Repository: npmx-dev/npmx.dev

Length of output: 2475


🏁 Script executed:

rg "git\(" . --type ts -A1 | grep -v "node_modules"

Repository: npmx-dev/npmx.dev

Length of output: 275


Exported git() function accepts unvalidated shell commands.

Line 7 uses shell interpolation with execSync(\git ${command}`). Although the only current call site passes a hardcoded string, the function is exported as public API; any external consumer could invoke git(untrusted_input), enabling command injection. Use execFileSync` with array arguments instead.

Suggested fix
-import { execSync } from 'node:child_process'
+import { execFileSync } from 'node:child_process'

-export function git(command: string) {
+export function git(args: readonly string[]) {
   try {
-    return execSync(`git ${command}`, { encoding: 'utf-8' }).trim()
+    return execFileSync('git', [...args], { encoding: 'utf-8' }).trim()
   } catch {
-    console.error(`🚨 Git command failed: git ${command}`)
+    console.error(`🚨 Git command failed: git ${args.join(' ')}`)
     return null
   }
 }

 export function getCurrentCommitHash() {
-  return git('rev-parse HEAD')
+  return git(['rev-parse', 'HEAD'])
 }

Comment on lines 51 to 57
return {
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
...content,
} as EnMetaJson
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

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the file
find . -name "git-utils.ts" -path "*/i18n-meta/*" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 93


🏁 Script executed:

# Let's examine the file content
cat -n ./scripts/i18n-meta/git-utils.ts

Repository: npmx-dev/npmx.dev

Length of output: 1980


🏁 Script executed:

# Let's examine the types definition
cat -n ./scripts/i18n-meta/types.d.ts

Repository: npmx-dev/npmx.dev

Length of output: 441


🏁 Script executed:

# Let's search for usage of createUpdatedEnMetaJson to understand how it's called
rg "createUpdatedEnMetaJson" --type ts --type tsx -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Let's search for usage of createUpdatedEnMetaJson without specifying file types
rg "createUpdatedEnMetaJson" -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 995


🏁 Script executed:

# Let's examine the update-en-meta-json.ts file to see full context
cat -n ./scripts/i18n-meta/update-en-meta-json.ts

Repository: npmx-dev/npmx.dev

Length of output: 2475


🏁 Script executed:

# Let's check if there are any tests for this function
find . -name "*.test.*" -o -name "*.spec.*" | xargs grep -l "createUpdatedEnMetaJson" 2>/dev/null || echo "No test files found mentioning this function"

Repository: npmx-dev/npmx.dev

Length of output: 105


Ensure freshly generated $meta is not overwritten by content.

In the current implementation, spreading ...content after $meta allows an existing content.$meta to replace the freshly generated last_updated_commit and updated_at values. Since EnMetaJson permits an optional $meta property, any stale metadata in the input content will override the intended fresh timestamps.

Reorder the object properties so $meta is defined after the spread, ensuring it always takes precedence:

Suggested fix
 export function createUpdatedEnMetaJson(
   commitHash: string | null,
   content: EnMetaJson,
 ): EnMetaJson {
   return {
+    ...content,
     $meta: {
       last_updated_commit: commitHash,
       updated_at: new Date().toISOString(),
     },
-    ...content,
   } as EnMetaJson
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
...content,
} as EnMetaJson
return {
...content,
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
} as EnMetaJson

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


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7ce664d and e6e0701.

📒 Files selected for processing (2)
  • scripts/i18n-meta/update-en-meta-json.ts
  • scripts/i18n-meta/utils.ts

Comment on lines +23 to +37
const currentCommitHash = getCurrentCommitHash()
const enMetaJson = currentCommitHash
? makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)
: ({} as EnMetaJson)

const hasChanges = checkTranslationChanges(oldEnMetaJson, enMetaJson)
if (!hasChanges) {
console.info('ℹ️ No translation changes – en.meta.json left untouched')
return
}

const updatedEnMetaJson = createUpdatedEnMetaJson(currentCommitHash, enMetaJson)

writeFileSync(enMetaJsonPath, JSON.stringify(updatedEnMetaJson, null, 2) + '\n', 'utf-8')
console.log(`✅ Updated en.meta.json – last_updated_commit: ${currentCommitHash}`)
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 | 🔴 Critical

Prevent destructive rewrite when commit hash lookup fails.

If getCurrentCommitHash() returns null, the current flow can still proceed and overwrite en.meta.json with an effectively empty payload. Please fail fast and leave the file untouched when the hash is unavailable.

Proposed fix
 export function updateEnMetaJson() {
   const newEnJson = getNewEnJson(enJsonPath)
   const oldEnMetaJson = getOldEnMetaJson(enMetaJsonPath)

   const currentCommitHash = getCurrentCommitHash()
-  const enMetaJson = currentCommitHash
-    ? makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)
-    : ({} as EnMetaJson)
+  if (!currentCommitHash) {
+    console.error('🚨 Could not resolve current commit hash – en.meta.json left untouched')
+    return
+  }
+
+  const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)

   const hasChanges = checkTranslationChanges(oldEnMetaJson, enMetaJson)
   if (!hasChanges) {
     console.info('ℹ️ No translation changes – en.meta.json left untouched')
     return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentCommitHash = getCurrentCommitHash()
const enMetaJson = currentCommitHash
? makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)
: ({} as EnMetaJson)
const hasChanges = checkTranslationChanges(oldEnMetaJson, enMetaJson)
if (!hasChanges) {
console.info('ℹ️ No translation changes – en.meta.json left untouched')
return
}
const updatedEnMetaJson = createUpdatedEnMetaJson(currentCommitHash, enMetaJson)
writeFileSync(enMetaJsonPath, JSON.stringify(updatedEnMetaJson, null, 2) + '\n', 'utf-8')
console.log(`✅ Updated en.meta.json – last_updated_commit: ${currentCommitHash}`)
const currentCommitHash = getCurrentCommitHash()
if (!currentCommitHash) {
console.error('🚨 Could not resolve current commit hash – en.meta.json left untouched')
return
}
const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, currentCommitHash)
const hasChanges = checkTranslationChanges(oldEnMetaJson, enMetaJson)
if (!hasChanges) {
console.info('ℹ️ No translation changes – en.meta.json left untouched')
return
}
const updatedEnMetaJson = createUpdatedEnMetaJson(currentCommitHash, enMetaJson)
writeFileSync(enMetaJsonPath, JSON.stringify(updatedEnMetaJson, null, 2) + '\n', 'utf-8')
console.log(`✅ Updated en.meta.json – last_updated_commit: ${currentCommitHash}`)

Comment on lines +29 to +39
export function createUpdatedEnMetaJson(
commitHash: string | null,
content: EnMetaJson,
): EnMetaJson {
return {
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
...omitMeta(content),
} as EnMetaJson
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify type declarations and function signatures
rg -n "last_updated_commit|createUpdatedEnMetaJson\(|getCurrentCommitHash\(" \
  scripts/i18n-meta/types.d.ts \
  scripts/i18n-meta/utils.ts \
  scripts/i18n-meta/update-en-meta-json.ts

Repository: npmx-dev/npmx.dev

Length of output: 684


🏁 Script executed:

#!/bin/bash
# Check getCurrentCommitHash return type
echo "=== getCurrentCommitHash function ==="
sed -n '5,15p' scripts/i18n-meta/utils.ts

echo -e "\n=== createUpdatedEnMetaJson function ==="
sed -n '29,40p' scripts/i18n-meta/utils.ts

echo -e "\n=== EnMetaJson type definition ==="
cat scripts/i18n-meta/types.d.ts

echo -e "\n=== How it's called in update-en-meta-json.ts ==="
sed -n '20,40p' scripts/i18n-meta/update-en-meta-json.ts

Repository: npmx-dev/npmx.dev

Length of output: 1768


🏁 Script executed:

#!/bin/bash
# Find and read the git function definition
echo "=== Searching for git() function ==="
rg -n "^export function git|^function git|const git =" scripts/i18n-meta/

echo -e "\n=== Reading git function if in utils.ts ==="
sed -n '1,60p' scripts/i18n-meta/utils.ts | head -30

echo -e "\n=== Checking for git() import or definition elsewhere ==="
rg -n "function git\(" --type ts --type tsx

Repository: npmx-dev/npmx.dev

Length of output: 1164


🏁 Script executed:

#!/bin/bash
# Read the git() function implementation
sed -n '42,60p' scripts/i18n-meta/utils.ts

Repository: npmx-dev/npmx.dev

Length of output: 513


Enforce strict typing for last_updated_commit to match the declared metadata schema.

EnMetaJson declares last_updated_commit as string, but this function accepts string | null and assigns that potentially-null value directly. The type assertion as EnMetaJson silently bypasses the type mismatch, allowing invalid data to be written. The git() function can return null on error, making this path reachable.

Proposed fix
 export function createUpdatedEnMetaJson(
-  commitHash: string | null,
+  commitHash: string,
   content: EnMetaJson,
 ): EnMetaJson {
   return {
     $meta: {
       last_updated_commit: commitHash,
       updated_at: new Date().toISOString(),
     },
     ...omitMeta(content),
-  } as EnMetaJson
+  }
 }

Tighten the parameter type to string and ensure call sites handle null gracefully (e.g. by guarding the call or aborting the operation when the commit hash cannot be resolved).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createUpdatedEnMetaJson(
commitHash: string | null,
content: EnMetaJson,
): EnMetaJson {
return {
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
...omitMeta(content),
} as EnMetaJson
export function createUpdatedEnMetaJson(
commitHash: string,
content: EnMetaJson,
): EnMetaJson {
return {
$meta: {
last_updated_commit: commitHash,
updated_at: new Date().toISOString(),
},
...omitMeta(content),
}
}

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.

1 participant