Skip to content

Commit 119b744

Browse files
committed
feat(i18n): add en.meta.json generation script
1 parent 04bd490 commit 119b744

9 files changed

Lines changed: 345 additions & 0 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"i18n:report": "node scripts/find-invalid-translations.ts",
2121
"i18n:report:fix": "node scripts/remove-unused-translations.ts",
2222
"i18n:schema": "node scripts/generate-i18n-schema.ts",
23+
"i18n:meta:update-en-meta": "node scripts/i18n-meta/cli.ts update-en-meta",
2324
"knip": "knip",
2425
"knip:fix": "knip --fix",
2526
"lint": "oxlint && oxfmt --check",
@@ -126,6 +127,7 @@
126127
"@storybook/addon-a11y": "catalog:storybook",
127128
"@storybook/addon-docs": "catalog:storybook",
128129
"@storybook/addon-themes": "catalog:storybook",
130+
"@types/dot-object": "2.1.6",
129131
"@types/node": "24.10.13",
130132
"@types/sanitize-html": "2.16.0",
131133
"@types/semver": "7.7.1",
@@ -136,6 +138,7 @@
136138
"chromatic": "15.1.0",
137139
"defu": "6.1.4",
138140
"devalue": "5.6.3",
141+
"dot-object": "2.1.5",
139142
"eslint-plugin-regexp": "3.0.0",
140143
"fast-check": "4.5.3",
141144
"h3": "1.15.5",

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/i18n-meta/cli.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parseArgs } from 'node:util'
2+
import { updateEnMetaJson } from './update-en-meta-json.ts'
3+
4+
function showHelp() {
5+
const scriptName = process.env.npm_lifecycle_event || 'node scripts/i18n-meta/cli.ts'
6+
console.log(`Usage: pnpm run ${scriptName} update-en-meta`)
7+
}
8+
9+
function main() {
10+
const { positionals } = parseArgs({ allowPositionals: true })
11+
12+
if (positionals[0] !== 'update-en-meta') {
13+
showHelp()
14+
return
15+
}
16+
17+
updateEnMetaJson()
18+
}
19+
20+
main()

scripts/i18n-meta/git-utils.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { execSync } from 'node:child_process'
2+
import * as path from 'node:path'
3+
import * as fs from 'node:fs'
4+
import type { EnJson, EnMetaJson } from './types.d.ts'
5+
6+
export function git(command: string) {
7+
try {
8+
return execSync(`git ${command}`, { encoding: 'utf-8' }).trim()
9+
} catch {
10+
console.error(`🚨 Git command failed: git ${command}`)
11+
return null
12+
}
13+
}
14+
15+
function readJson<T>(filePath: string): T {
16+
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T
17+
}
18+
19+
export function getCurrentCommitHash() {
20+
return git('rev-parse HEAD')
21+
}
22+
23+
export function getPreviousCommitHash(parsedOldEnMetaJson: EnMetaJson) {
24+
const lastCommit = parsedOldEnMetaJson.$meta?.last_updated_commit
25+
if (lastCommit) {
26+
return lastCommit
27+
}
28+
return git('rev-parse HEAD~1')
29+
}
30+
31+
export function getNewEnJson(enJsonPath: string): EnJson {
32+
if (fs.existsSync(enJsonPath)) {
33+
return readJson<EnMetaJson>(enJsonPath)
34+
}
35+
return {} as EnMetaJson
36+
}
37+
38+
export function getOldEnJson(oldEnMeta: EnMetaJson, enJsonPath: string): EnJson {
39+
const previousHash = getPreviousCommitHash(oldEnMeta)
40+
if (previousHash) {
41+
const relativePath = path.relative(process.cwd(), enJsonPath)
42+
const previousContent = git(`show "${previousHash}:${relativePath}"`)
43+
if (previousContent) {
44+
return JSON.parse(previousContent) as EnMetaJson
45+
}
46+
}
47+
return {} as EnMetaJson
48+
}
49+
50+
export function getOldEnMetaJson(enMetaJsonPath: string): EnMetaJson {
51+
if (fs.existsSync(enMetaJsonPath)) {
52+
return readJson<EnMetaJson>(enMetaJsonPath)
53+
}
54+
return {} as EnMetaJson
55+
}
56+
57+
function omitMeta<T extends object>(obj: T): Omit<T, '$meta'> {
58+
const { $meta: _, ...rest } = obj as T & { $meta?: unknown }
59+
return rest
60+
}
61+
62+
export function checkTranslationChanges(oldMeta: EnMetaJson, newMeta: EnMetaJson): boolean {
63+
const oldObj = omitMeta(oldMeta)
64+
const newObj = omitMeta(newMeta)
65+
return JSON.stringify(oldObj) !== JSON.stringify(newObj)
66+
}
67+
68+
export function createUpdatedEnMetaJson(
69+
commitHash: string | null,
70+
content: EnMetaJson,
71+
): EnMetaJson {
72+
return {
73+
$meta: {
74+
last_updated_commit: commitHash,
75+
updated_at: new Date().toISOString(),
76+
},
77+
...content,
78+
} as EnMetaJson
79+
}

scripts/i18n-meta/types.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type EnJson = {
2+
[key: string]: string | EnJson
3+
}
4+
5+
export type MetaEntry = {
6+
text: string
7+
commit: string
8+
}
9+
10+
export type EnMetaJson = {
11+
$meta?: {
12+
last_updated_commit: string
13+
updated_at: string
14+
}
15+
[key: string]: string | MetaEntry | EnMetaJson
16+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { writeFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import dot from 'dot-object'
4+
import {
5+
checkTranslationChanges,
6+
createUpdatedEnMetaJson,
7+
getCurrentCommitHash,
8+
getNewEnJson,
9+
getOldEnJson,
10+
getOldEnMetaJson,
11+
} from './git-utils.ts'
12+
import type { EnJson, EnMetaJson, MetaEntry } from './types.d.ts'
13+
14+
const enJsonPath = resolve('i18n/locales/en.json')
15+
const enMetaJsonPath = resolve('i18n/locales/en.meta.json')
16+
17+
/**
18+
* Update a metadata JSON file for English translations.
19+
*/
20+
export function updateEnMetaJson() {
21+
const newEnJson = getNewEnJson(enJsonPath)
22+
const oldEnMetaJson = getOldEnMetaJson(enMetaJsonPath)
23+
const oldEnJson = getOldEnJson(oldEnMetaJson, enJsonPath)
24+
25+
const currentCommitHash = getCurrentCommitHash()
26+
const enMetaJson = currentCommitHash
27+
? makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, currentCommitHash)
28+
: ({} as EnMetaJson)
29+
30+
const hasChanges = checkTranslationChanges(oldEnMetaJson, enMetaJson)
31+
if (!hasChanges) {
32+
console.info('ℹ️ No translation changes – en.meta.json left untouched')
33+
return
34+
}
35+
36+
const finalMeta = createUpdatedEnMetaJson(currentCommitHash, enMetaJson)
37+
38+
writeFileSync(enMetaJsonPath, JSON.stringify(finalMeta, null, 2) + '\n', 'utf-8')
39+
console.log(`✅ Updated en.meta.json – last_updated_commit: ${currentCommitHash}`)
40+
}
41+
42+
export function makeEnMetaJson(
43+
newEnJson: EnJson,
44+
oldEnJson: EnJson,
45+
oldMetaEnJson: EnMetaJson,
46+
latestCommitHash: string,
47+
): EnMetaJson {
48+
const newFlat = dot.dot(newEnJson) as Record<string, string>
49+
const oldFlat = dot.dot(oldEnJson) as Record<string, string>
50+
const oldMetaFlat = dot.dot(oldMetaEnJson) as Record<string, string>
51+
const metaFlat: Record<string, MetaEntry> = {}
52+
53+
for (const key in newFlat) {
54+
const newText = newFlat[key]
55+
const oldText = oldFlat[key]
56+
57+
if (oldText === newText) {
58+
const oldCommit = oldMetaFlat[`${key}.commit`]
59+
metaFlat[key] = { text: newText, commit: oldCommit ?? latestCommitHash }
60+
} else {
61+
metaFlat[key] = { text: newText, commit: latestCommitHash }
62+
}
63+
}
64+
return dot.object(metaFlat) as EnMetaJson
65+
}

scripts/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "nodenext",
5+
"strict": true,
6+
"esModuleInterop": true,
7+
"skipLibCheck": true,
8+
"noEmit": true,
9+
"allowImportingTsExtensions": true,
10+
"declaration": true,
11+
"types": ["node"],
12+
"declarationMap": true
13+
},
14+
"include": ["**/*.ts"],
15+
"exclude": ["node_modules", "dist"]
16+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { makeEnMetaJson } from '#scripts/i18n-meta/update-en-meta-json'
3+
4+
describe('Create en.meta.json', () => {
5+
it('should handle an empty en.json file', () => {
6+
const oldEnJson = {}
7+
const newEnJson = {}
8+
const oldEnMetaJson = {}
9+
10+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, 'sha-new-12345')
11+
expect(enMetaJson).toEqual({})
12+
})
13+
14+
it('should generate en.meta.json correctly for an initial en.json', () => {
15+
const oldEnJson = {}
16+
const newEnJson = {
17+
tagline: 'npmx - a fast, modern browser for the npm registry',
18+
}
19+
const oldEnMetaJson = {}
20+
21+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, 'sha-new-12345')
22+
expect(enMetaJson).toEqual({
23+
tagline: {
24+
text: 'npmx - a fast, modern browser for the npm registry',
25+
commit: 'sha-new-12345',
26+
},
27+
})
28+
})
29+
30+
it('should update existing keys and add new keys with the latest commit hash', () => {
31+
const oldEnJson = {
32+
name: 'npmx',
33+
tagline: 'npmx - a better browser for the npm registry',
34+
}
35+
const newEnJson = {
36+
name: 'npmx',
37+
tagline: 'npmx - a fast, modern browser for the npm registry',
38+
description: 'Search, browse, and explore packages with a modern interface.',
39+
}
40+
const oldEnMetaJson = {
41+
name: {
42+
tagline: 'npmx',
43+
commit: 'sha-old-12345',
44+
},
45+
tagline: {
46+
tagline: 'npmx - a better browser for the npm registry',
47+
commit: 'sha-old-12345',
48+
},
49+
}
50+
51+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, 'sha-new-12345')
52+
expect(enMetaJson).toEqual({
53+
name: {
54+
text: 'npmx',
55+
commit: 'sha-old-12345',
56+
},
57+
tagline: {
58+
text: 'npmx - a fast, modern browser for the npm registry',
59+
commit: 'sha-new-12345',
60+
},
61+
description: {
62+
text: 'Search, browse, and explore packages with a modern interface.',
63+
commit: 'sha-new-12345',
64+
},
65+
})
66+
})
67+
68+
it('should remove keys that are no longer in en.json', () => {
69+
const oldEnJson = {
70+
tagline: 'npmx - a fast, modern browser for the npm registry',
71+
toBeRemoved: 'This will be gone',
72+
}
73+
const newEnJson = {
74+
tagline: 'npmx - a fast, modern browser for the npm registry',
75+
}
76+
const oldEnMetaJson = {
77+
tagline: {
78+
text: 'npmx - a fast, modern browser for the npm registry',
79+
commit: 'sha-old-12345',
80+
},
81+
toBeRemoved: { text: 'This will be gone', commit: 'sha-old-12345' },
82+
}
83+
84+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, 'sha-new-12345')
85+
expect(enMetaJson).toEqual({
86+
tagline: {
87+
text: 'npmx - a fast, modern browser for the npm registry',
88+
commit: 'sha-old-12345',
89+
},
90+
})
91+
})
92+
93+
it('should handle complex nested structures', () => {
94+
const oldEnJson = {
95+
a: {
96+
b: 'value-b',
97+
},
98+
c: 'value-c',
99+
}
100+
const newEnJson = {
101+
a: {
102+
b: 'updated-value',
103+
},
104+
c: 'value-c',
105+
d: 'added-value',
106+
}
107+
108+
const oldEnMetaJson = {
109+
a: {
110+
b: {
111+
text: 'value-b',
112+
commit: 'sha-old-12345',
113+
},
114+
},
115+
c: {
116+
text: 'value-c',
117+
commit: 'sha-old-12345',
118+
},
119+
d: {
120+
text: 'added-value',
121+
commit: 'sha-new-12345',
122+
},
123+
}
124+
125+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnJson, oldEnMetaJson, 'sha-new-12345')
126+
expect(enMetaJson).toEqual({
127+
a: {
128+
b: { text: 'updated-value', commit: 'sha-new-12345' },
129+
},
130+
c: { text: 'value-c', commit: 'sha-old-12345' },
131+
d: { text: 'added-value', commit: 'sha-new-12345' },
132+
})
133+
})
134+
})

0 commit comments

Comments
 (0)