diff --git a/.github/workflows/i18n-meta.yml b/.github/workflows/i18n-meta.yml new file mode 100644 index 000000000..ab836e238 --- /dev/null +++ b/.github/workflows/i18n-meta.yml @@ -0,0 +1,48 @@ +name: i18n-meta + +on: + push: + branches: + - main + paths: + - 'i18n/locales/en.json' + +permissions: + contents: write + +jobs: + update: + name: 🌐 Update en.meta.json + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: lts/* + + - uses: pnpm/action-setup@1e1c8eafbd745f64b1ef30a7d7ed7965034c486c # 1e1c8eafbd745f64b1ef30a7d7ed7965034c486c + name: 🟧 Install pnpm + with: + cache: true + + - name: đŸ“Ļ Install dependencies + run: pnpm install + + - name: 🌐 Update i18n metadata + run: pnpm i18n:meta:update-en-meta + + - name: âŦ†ī¸Ž Commit and Push changes + run: | + 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 + if ! git diff --cached --quiet; then + git commit -m 'chore(i18n): update `en.meta.json` [skip ci]' + git push + else + echo "No changes in en.meta.json, skipping commit." + fi diff --git a/package.json b/package.json index 440cfcd54..22dfa26f6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "i18n:report": "node scripts/find-invalid-translations.ts", "i18n:report:fix": "node scripts/remove-unused-translations.ts", "i18n:schema": "node scripts/generate-i18n-schema.ts", + "i18n:meta:update-en-meta": "node scripts/i18n-meta/cli.ts update-en-meta", "knip": "knip", "knip:fix": "knip --fix", "lint": "oxlint && oxfmt --check", @@ -130,6 +131,7 @@ "@storybook/addon-a11y": "catalog:storybook", "@storybook/addon-docs": "catalog:storybook", "@storybook/addon-themes": "catalog:storybook", + "@types/dot-object": "2.1.6", "@types/node": "24.10.13", "@types/sanitize-html": "2.16.0", "@types/semver": "7.7.1", @@ -140,6 +142,7 @@ "chromatic": "15.1.0", "defu": "6.1.4", "devalue": "5.6.3", + "dot-object": "2.1.5", "eslint-plugin-regexp": "3.0.0", "fast-check": "4.5.3", "h3": "1.15.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e69566f8..bb98e65f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: '@storybook/addon-themes': specifier: catalog:storybook version: 10.2.13(storybook@10.2.13(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@types/dot-object': + specifier: 2.1.6 + version: 2.1.6 '@types/node': specifier: 24.10.13 version: 24.10.13 @@ -289,6 +292,9 @@ importers: devalue: specifier: 5.6.3 version: 5.6.3 + dot-object: + specifier: 2.1.5 + version: 2.1.5 eslint-plugin-regexp: specifier: 3.0.0 version: 3.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -4679,6 +4685,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dot-object@2.1.6': + resolution: {integrity: sha512-G1e4SNPOuO72ZXv7wz/W2x29CzQtpxko3G9hBiHqGg/AvFIKoArCs2nbc/WPXnnUkO+1dmvX9WQCyj5gIlAzZg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -15495,6 +15504,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dot-object@2.1.6': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 diff --git a/scripts/i18n-meta/cli.ts b/scripts/i18n-meta/cli.ts new file mode 100644 index 000000000..f77631ba9 --- /dev/null +++ b/scripts/i18n-meta/cli.ts @@ -0,0 +1,20 @@ +import { parseArgs } from 'node:util' +import { updateEnMetaJson } from './update-en-meta-json.ts' + +function showHelp() { + const scriptName = process.env.npm_lifecycle_event || 'node scripts/i18n-meta/cli.ts' + console.log(`Usage: pnpm run ${scriptName} update-en-meta`) +} + +function main() { + const { positionals } = parseArgs({ allowPositionals: true }) + + if (positionals[0] !== 'update-en-meta') { + showHelp() + return + } + + updateEnMetaJson() +} + +main() diff --git a/scripts/i18n-meta/types.d.ts b/scripts/i18n-meta/types.d.ts new file mode 100644 index 000000000..7f94375f2 --- /dev/null +++ b/scripts/i18n-meta/types.d.ts @@ -0,0 +1,16 @@ +export type EnJson = { + [key: string]: string | EnJson +} + +export type MetaEntry = { + text: string + commit: string +} + +export type EnMetaJson = { + $meta?: { + last_updated_commit: string + updated_at: string + } + [key: string]: string | MetaEntry | EnMetaJson +} diff --git a/scripts/i18n-meta/update-en-meta-json.ts b/scripts/i18n-meta/update-en-meta-json.ts new file mode 100644 index 000000000..67e3ceb82 --- /dev/null +++ b/scripts/i18n-meta/update-en-meta-json.ts @@ -0,0 +1,64 @@ +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import dot from 'dot-object' +import { + checkTranslationChanges, + createUpdatedEnMetaJson, + getCurrentCommitHash, + getNewEnJson, + getOldEnMetaJson, +} from './utils.ts' +import type { EnJson, EnMetaJson, MetaEntry } from './types.d.ts' + +const enJsonPath = resolve('i18n/locales/en.json') +const enMetaJsonPath = resolve('i18n/locales/en.meta.json') + +/** + * Update a metadata JSON file for English translations. + */ +export function updateEnMetaJson() { + const newEnJson = getNewEnJson(enJsonPath) + const oldEnMetaJson = getOldEnMetaJson(enMetaJsonPath) + + const currentCommitHash = getCurrentCommitHash() + if (!currentCommitHash) { + console.error('❌ Commit hash missing. Skipping update to protect existing metadata.') + process.exit(1) + } + 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 makeEnMetaJson( + oldMetaEnJson: EnMetaJson, + newEnJson: EnJson, + latestCommitHash: string, +): EnMetaJson { + const newFlat = dot.dot(newEnJson) as Record + const oldMetaFlat = dot.dot(oldMetaEnJson) as Record + const metaFlat: Record = {} + + for (const key in newFlat) { + const newText = newFlat[key] + + const lastSeenText = oldMetaFlat[`${key}.text`] + const lastCommit = oldMetaFlat[`${key}.commit`] + + if (newText === lastSeenText) { + metaFlat[key] = { text: newText, commit: lastCommit ?? latestCommitHash } + } else { + metaFlat[key] = { text: newText, commit: latestCommitHash } + } + } + return dot.object(metaFlat) as EnMetaJson +} diff --git a/scripts/i18n-meta/utils.ts b/scripts/i18n-meta/utils.ts new file mode 100644 index 000000000..119ac5449 --- /dev/null +++ b/scripts/i18n-meta/utils.ts @@ -0,0 +1,55 @@ +import { execSync } from 'node:child_process' +import * as fs from 'node:fs' +import type { EnJson, EnMetaJson } from './types.d.ts' + +export function getCurrentCommitHash() { + return git('rev-parse HEAD') +} + +export function getNewEnJson(enJsonPath: string): EnJson { + if (fs.existsSync(enJsonPath)) { + return readJson(enJsonPath) + } + return {} as EnJson +} + +export function getOldEnMetaJson(enMetaJsonPath: string): EnMetaJson { + if (fs.existsSync(enMetaJsonPath)) { + return readJson(enMetaJsonPath) + } + return {} as EnMetaJson +} + +export function checkTranslationChanges(oldMeta: EnMetaJson, newMeta: EnMetaJson): boolean { + const oldObj = omitMeta(oldMeta) + const newObj = omitMeta(newMeta) + return JSON.stringify(oldObj) !== JSON.stringify(newObj) +} + +export function createUpdatedEnMetaJson(commitHash: string, content: EnMetaJson): EnMetaJson { + return { + $meta: { + last_updated_commit: commitHash, + updated_at: new Date().toISOString(), + }, + ...omitMeta(content), + } +} + +function git(command: string) { + try { + return execSync(`git ${command}`, { encoding: 'utf-8' }).trim() + } catch { + console.error(`🚨 Git command failed: git ${command}`) + return null + } +} + +function readJson(filePath: string): T { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T +} + +function omitMeta(obj: T): Omit { + const { $meta: _, ...rest } = obj as T & { $meta?: unknown } + return rest +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..a2412111e --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "declaration": true, + "types": ["node"], + "declarationMap": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/test/unit/scripts/generate-en-meta-json.spec.ts b/test/unit/scripts/generate-en-meta-json.spec.ts new file mode 100644 index 000000000..f044cb1db --- /dev/null +++ b/test/unit/scripts/generate-en-meta-json.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { makeEnMetaJson } from '#scripts/i18n-meta/update-en-meta-json' + +describe('Create en.meta.json', () => { + it('should handle an empty en.json file', () => { + const oldEnMetaJson = {} + const newEnJson = {} + + const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, 'sha-new-12345') + expect(enMetaJson).toEqual({}) + }) + + it('should generate en.meta.json correctly for an initial en.json', () => { + const oldEnMetaJson = {} + const newEnJson = { + tagline: 'npmx - a fast, modern browser for the npm registry', + } + + const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, 'sha-new-12345') + expect(enMetaJson).toEqual({ + tagline: { + text: 'npmx - a fast, modern browser for the npm registry', + commit: 'sha-new-12345', + }, + }) + }) + + it('should update existing keys and add new keys with the latest commit hash', () => { + const oldEnMetaJson = { + name: { + text: 'npmx', + commit: 'sha-old-12345', + }, + tagline: { + text: 'npmx - a better browser for the npm registry', + commit: 'sha-old-12345', + }, + } + const newEnJson = { + name: 'npmx', + tagline: 'npmx - a fast, modern browser for the npm registry', + description: 'Search, browse, and explore packages with a modern interface.', + } + + const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, 'sha-new-12345') + expect(enMetaJson).toEqual({ + name: { + text: 'npmx', + commit: 'sha-old-12345', + }, + tagline: { + text: 'npmx - a fast, modern browser for the npm registry', + commit: 'sha-new-12345', + }, + description: { + text: 'Search, browse, and explore packages with a modern interface.', + commit: 'sha-new-12345', + }, + }) + }) + + it('should remove keys that are no longer in en.json', () => { + const oldEnMetaJson = { + tagline: { + text: 'npmx - a fast, modern browser for the npm registry', + commit: 'sha-old-12345', + }, + toBeRemoved: { text: 'This will be gone', commit: 'sha-old-12345' }, + } + const newEnJson = { + tagline: 'npmx - a fast, modern browser for the npm registry', + } + + const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, 'sha-new-12345') + expect(enMetaJson).toEqual({ + tagline: { + text: 'npmx - a fast, modern browser for the npm registry', + commit: 'sha-old-12345', + }, + }) + }) + + it('should handle complex nested structures', () => { + const oldEnMetaJson = { + a: { + b: { + text: 'value-b', + commit: 'sha-old-12345', + }, + }, + c: { + text: 'value-c', + commit: 'sha-old-12345', + }, + d: { + text: 'added-value', + commit: 'sha-new-12345', + }, + } + const newEnJson = { + a: { + b: 'updated-value', + }, + c: 'value-c', + d: 'added-value', + } + + const enMetaJson = makeEnMetaJson(oldEnMetaJson, newEnJson, 'sha-new-12345') + expect(enMetaJson).toEqual({ + a: { + b: { text: 'updated-value', commit: 'sha-new-12345' }, + }, + c: { text: 'value-c', commit: 'sha-old-12345' }, + d: { text: 'added-value', commit: 'sha-new-12345' }, + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index ba3986903..b28e34c46 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ alias: { '#shared': `${rootDir}/shared`, '#server': `${rootDir}/server`, + '#scripts': `${rootDir}/scripts`, }, }, test: {