Skip to content

Commit c6e5e1f

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

9 files changed

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

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

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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 newEnJson = {}
7+
const oldEnMetaJson = {}
8+
9+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnMetaJson, 'sha-new-12345')
10+
expect(enMetaJson).toEqual({})
11+
})
12+
13+
it('should generate en.meta.json correctly for an initial en.json', () => {
14+
const newEnJson = {
15+
tagline: 'npmx - a fast, modern browser for the npm registry',
16+
}
17+
const oldEnMetaJson = {}
18+
19+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnMetaJson, 'sha-new-12345')
20+
expect(enMetaJson).toEqual({
21+
tagline: {
22+
text: 'npmx - a fast, modern browser for the npm registry',
23+
commit: 'sha-new-12345',
24+
},
25+
})
26+
})
27+
28+
it('should update existing keys and add new keys with the latest commit hash', () => {
29+
const newEnJson = {
30+
name: 'npmx',
31+
tagline: 'npmx - a fast, modern browser for the npm registry',
32+
description: 'Search, browse, and explore packages with a modern interface.',
33+
}
34+
const oldEnMetaJson = {
35+
name: {
36+
text: 'npmx',
37+
commit: 'sha-old-12345',
38+
},
39+
tagline: {
40+
text: 'npmx - a better browser for the npm registry',
41+
commit: 'sha-old-12345',
42+
},
43+
}
44+
45+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnMetaJson, 'sha-new-12345')
46+
expect(enMetaJson).toEqual({
47+
name: {
48+
text: 'npmx',
49+
commit: 'sha-old-12345',
50+
},
51+
tagline: {
52+
text: 'npmx - a fast, modern browser for the npm registry',
53+
commit: 'sha-new-12345',
54+
},
55+
description: {
56+
text: 'Search, browse, and explore packages with a modern interface.',
57+
commit: 'sha-new-12345',
58+
},
59+
})
60+
})
61+
62+
it('should remove keys that are no longer in en.json', () => {
63+
const newEnJson = {
64+
tagline: 'npmx - a fast, modern browser for the npm registry',
65+
}
66+
const oldEnMetaJson = {
67+
tagline: {
68+
text: 'npmx - a fast, modern browser for the npm registry',
69+
commit: 'sha-old-12345',
70+
},
71+
toBeRemoved: { text: 'This will be gone', commit: 'sha-old-12345' },
72+
}
73+
74+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnMetaJson, 'sha-new-12345')
75+
expect(enMetaJson).toEqual({
76+
tagline: {
77+
text: 'npmx - a fast, modern browser for the npm registry',
78+
commit: 'sha-old-12345',
79+
},
80+
})
81+
})
82+
83+
it('should handle complex nested structures', () => {
84+
const newEnJson = {
85+
a: {
86+
b: 'updated-value',
87+
},
88+
c: 'value-c',
89+
d: 'added-value',
90+
}
91+
92+
const oldEnMetaJson = {
93+
a: {
94+
b: {
95+
text: 'value-b',
96+
commit: 'sha-old-12345',
97+
},
98+
},
99+
c: {
100+
text: 'value-c',
101+
commit: 'sha-old-12345',
102+
},
103+
d: {
104+
text: 'added-value',
105+
commit: 'sha-new-12345',
106+
},
107+
}
108+
109+
const enMetaJson = makeEnMetaJson(newEnJson, oldEnMetaJson, 'sha-new-12345')
110+
expect(enMetaJson).toEqual({
111+
a: {
112+
b: { text: 'updated-value', commit: 'sha-new-12345' },
113+
},
114+
c: { text: 'value-c', commit: 'sha-old-12345' },
115+
d: { text: 'added-value', commit: 'sha-new-12345' },
116+
})
117+
})
118+
})

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default defineConfig({
1313
alias: {
1414
'#shared': `${rootDir}/shared`,
1515
'#server': `${rootDir}/server`,
16+
'#scripts': `${rootDir}/scripts`,
1617
},
1718
},
1819
test: {

0 commit comments

Comments
 (0)