feat(i18n): add en.meta.json generation script#1761
feat(i18n): add en.meta.json generation script#1761shuuji3 wants to merge 4 commits intonpmx-dev:mainfrom
en.meta.json generation script#1761Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
ec2a5b0 to
7e980e9
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
da9f64a to
68b0110
Compare
40ae5b6 to
4b13bdf
Compare
4b13bdf to
8f1acd7
Compare
📝 WalkthroughWalkthroughThis 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
🚥 Pre-merge checks | ✅ 1✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
scripts/tsconfig.json (1)
8-12: Minor configuration inconsistency.The
declarationanddeclarationMapoptions have no effect whennoEmit: trueis 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-metastep fails or exits unexpectedly, the workflow continues to the commit step. Consider adding explicit error handling or usingset -ein 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.jsonscripts/i18n-meta/update-en-meta-json.ts (1)
23-27: Silent fallback to empty object when no commit hash is available.If
getCurrentCommitHash()returnsnullor 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (9)
.github/workflows/i18n-meta.ymlpackage.jsonscripts/i18n-meta/cli.tsscripts/i18n-meta/git-utils.tsscripts/i18n-meta/types.d.tsscripts/i18n-meta/update-en-meta-json.tsscripts/tsconfig.jsontest/unit/scripts/generate-en-meta-json.spec.tsvitest.config.ts
scripts/i18n-meta/git-utils.ts
Outdated
| export function git(command: string) { | ||
| try { | ||
| return execSync(`git ${command}`, { encoding: 'utf-8' }).trim() | ||
| } catch { | ||
| console.error(`🚨 Git command failed: git ${command}`) | ||
| return null |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n scripts/i18n-meta/git-utils.tsRepository: npmx-dev/npmx.dev
Length of output: 1980
🏁 Script executed:
rg "git\(" --type ts --type tsx -B2 -A2Repository: npmx-dev/npmx.dev
Length of output: 88
🏁 Script executed:
rg "git\(" --type ts -B2 -A2Repository: npmx-dev/npmx.dev
Length of output: 620
🏁 Script executed:
rg "from.*git-utils" --type tsRepository: npmx-dev/npmx.dev
Length of output: 125
🏁 Script executed:
cat -n scripts/i18n-meta/update-en-meta-json.tsRepository: 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'])
}| return { | ||
| $meta: { | ||
| last_updated_commit: commitHash, | ||
| updated_at: new Date().toISOString(), | ||
| }, | ||
| ...content, | ||
| } as EnMetaJson |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and examine the file
find . -name "git-utils.ts" -path "*/i18n-meta/*" | head -20Repository: npmx-dev/npmx.dev
Length of output: 93
🏁 Script executed:
# Let's examine the file content
cat -n ./scripts/i18n-meta/git-utils.tsRepository: npmx-dev/npmx.dev
Length of output: 1980
🏁 Script executed:
# Let's examine the types definition
cat -n ./scripts/i18n-meta/types.d.tsRepository: 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 2Repository: 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 2Repository: 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.tsRepository: 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.
| 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 |
| 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}`) |
There was a problem hiding this comment.
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.
| 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}`) |
| export function createUpdatedEnMetaJson( | ||
| commitHash: string | null, | ||
| content: EnMetaJson, | ||
| ): EnMetaJson { | ||
| return { | ||
| $meta: { | ||
| last_updated_commit: commitHash, | ||
| updated_at: new Date().toISOString(), | ||
| }, | ||
| ...omitMeta(content), | ||
| } as EnMetaJson |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.tsRepository: 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 tsxRepository: 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.tsRepository: 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.
| 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), | |
| } | |
| } |
🔗 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: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.jsonchange detection:en.meta.jsonalongsideen.json.en.jsonis changed (added, removed, or modified) since the previous update, the corresponding values inen.meta.jsonare updated with the latest commit hash and the new string value.mainbranch, only when the commit modifiesi18n/locales/en.json.mainbranch history.The example auto-commit of
en.meta.jsoncan 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
mainbranch andlunaria/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.