diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..15eff2aa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh + +set -eu + +if git diff --cached --quiet -- 'public/locales/*/translations.json'; then + exit 0 +fi + +snapshot_dir=$(mktemp -d) +trap 'rm -rf "$snapshot_dir"' EXIT + +node scripts/git-staged-snapshot.mjs "$(pwd)" "$snapshot_dir" +pnpm run check:i18n -- --root="$snapshot_dir" --base-ref=HEAD --staged diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..4e555e81 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,24 @@ +#!/bin/sh + +set -eu + +locale_changed=0 +base_ref='' +while read -r local_ref local_oid remote_ref remote_oid; do + if [ "$local_oid" = "0000000000000000000000000000000000000000" ]; then + continue + fi + if [ "$remote_oid" = "0000000000000000000000000000000000000000" ]; then + if git diff-tree --no-commit-id --name-only -r "$local_oid" -- 'public/locales/*/translations.json' | grep -q .; then + locale_changed=1 + base_ref=$(git merge-base "$local_oid" origin/main 2>/dev/null || git rev-parse "$local_oid^") + fi + elif ! git diff --quiet "$remote_oid..$local_oid" -- 'public/locales/*/translations.json'; then + locale_changed=1 + base_ref=$remote_oid + fi +done + +if [ "$locale_changed" -eq 1 ]; then + pnpm run check:i18n -- --base-ref="$base_ref" +fi diff --git a/.github/workflows/translation-consistency.yml b/.github/workflows/translation-consistency.yml new file mode 100644 index 00000000..b814fcaf --- /dev/null +++ b/.github/workflows/translation-consistency.yml @@ -0,0 +1,20 @@ +name: Translation consistency + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run check:i18n -- --all diff --git a/package.json b/package.json index 720745ee..a2ecc9c7 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "packageManager": "pnpm@10.33.0", "scripts": { + "prepare": "node scripts/install-git-hooks.mjs", "dev": "pnpm run predev && next dev", "build": "pnpm run prebuild && next build", "start": "next start", @@ -12,6 +13,7 @@ "e2e:mock-api": "ts-node --project ./tsconfig.node.json ./tests/e2e/mock-api.ts", "lint": "eslint .", "lint-fix": "eslint . --fix", + "check:i18n": "node ./scripts/check-translations.mjs", "predev": "ts-node --project ./tsconfig.node.json ./scripts/prebuild.tsx", "prebuild": "cross-env NODE_ENV=production ts-node --project ./tsconfig.node.json ./scripts/prebuild.tsx" }, diff --git a/scripts/check-translations.mjs b/scripts/check-translations.mjs new file mode 100755 index 00000000..9fb0b89c --- /dev/null +++ b/scripts/check-translations.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const REFERENCE_LOCALE = 'en-US'; + +function flattenKeys(value, prefix = '', keys = new Set()) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + keys.add(prefix); + return keys; + } + for (const [key, child] of Object.entries(value)) { + flattenKeys(child, prefix ? `${prefix}.${key}` : key, keys); + } + return keys; +} + +function readTranslation(filePath) { + try { + return flattenKeys(JSON.parse(readFileSync(filePath, 'utf8'))); + } catch (error) { + throw new Error(`${filePath} is not valid JSON: ${error.message}`); + } +} + +function changedFiles(baseRef, root, staged) { + if (!baseRef) return []; + try { + const diffArgs = staged ? ['diff', '--cached', '--name-only', baseRef] : ['diff', '--name-only', baseRef]; + return execFileSync('git', [...diffArgs, '--', 'public/locales/*/translations.json'], { + cwd: root, + encoding: 'utf8', + }) + .trim() + .split('\n') + .filter(Boolean); + } catch (error) { + throw new Error(`Unable to inspect translation changes against ${baseRef}: ${error.message}`); + } +} + +function diffKeys(referenceKeys, localeKeys) { + return { + missing: [...referenceKeys].filter((key) => !localeKeys.has(key)).sort(), + extra: [...localeKeys].filter((key) => !referenceKeys.has(key)).sort(), + }; +} + +export function runCheck(root = process.cwd(), baseRef, staged = false, repoRoot = process.cwd(), allLocales = false) { + const localesRoot = path.join(root, 'public/locales'); + const errors = []; + + if (!existsSync(localesRoot) || !statSync(localesRoot).isDirectory()) { + return ['public/locales directory does not exist.']; + } + + const localeDirs = readdirSync(localesRoot) + .filter((entry) => statSync(path.join(localesRoot, entry)).isDirectory()) + .sort(); + + if (!localeDirs.includes(REFERENCE_LOCALE)) { + errors.push(`Reference locale "${REFERENCE_LOCALE}" is missing from public/locales.`); + return errors; + } + + const referencePath = path.join(localesRoot, REFERENCE_LOCALE, 'translations.json'); + if (!existsSync(referencePath)) { + errors.push(`${referencePath} is missing.`); + return errors; + } + + try { + readTranslation(referencePath); + } catch (error) { + errors.push(error.message); + return errors; + } + + const localeKeysByName = new Map(); + for (const locale of localeDirs) { + const filePath = path.join(localesRoot, locale, 'translations.json'); + if (!existsSync(filePath)) { + errors.push(`${filePath} is missing.`); + continue; + } + + let localeKeys; + try { + localeKeys = readTranslation(filePath); + } catch (error) { + errors.push(error.message); + continue; + } + localeKeysByName.set(locale, localeKeys); + } + + if (allLocales) { + console.log(`✅ ${REFERENCE_LOCALE} is the reference locale.`); + const referenceKeys = localeKeysByName.get(REFERENCE_LOCALE); + if (referenceKeys) { + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + const localeKeys = localeKeysByName.get(locale); + if (!localeKeys) { + console.log(`❌ ${locale} could not be checked because translations.json is missing or invalid.`); + continue; + } + const { missing, extra } = diffKeys(referenceKeys, localeKeys); + if (missing.length === 0 && extra.length === 0) { + console.log(`✅ ${locale} matches ${REFERENCE_LOCALE}.`); + continue; + } + console.log(`❌ ${locale} does not match ${REFERENCE_LOCALE}.`); + if (missing.length > 0) { + errors.push(`public/locales/${locale}/translations.json is missing ${missing.length} key(s) from ${REFERENCE_LOCALE}:\n ${missing.join('\n ')}`); + } + if (extra.length > 0) { + errors.push(`public/locales/${locale}/translations.json has ${extra.length} key(s) not present in ${REFERENCE_LOCALE}:\n ${extra.join('\n ')}`); + } + } + } + } + + if (baseRef && !allLocales) { + let files; + try { + files = changedFiles(baseRef, repoRoot, staged); + } catch (error) { + errors.push(error.message); + return errors; + } + + for (const relativePath of files) { + const locale = relativePath.split('/')[2]; + if (locale === REFERENCE_LOCALE) continue; + + const filePath = path.join(root, relativePath); + const newKeys = existsSync(filePath) ? readTranslation(filePath) : new Set(); + const referenceKeys = localeKeysByName.get(REFERENCE_LOCALE); + if (!referenceKeys) continue; + const { missing, extra } = diffKeys(referenceKeys, newKeys); + + if (missing.length > 0) { + errors.push(`${relativePath} is missing ${missing.length} key(s) from ${REFERENCE_LOCALE}:\n ${missing.join('\n ')}`); + } + if (extra.length > 0) { + errors.push(`${relativePath} has ${extra.length} key(s) not present in ${REFERENCE_LOCALE}:\n ${extra.join('\n ')}`); + } + } + } + + return errors; +} + +function main() { + const rootArg = process.argv.find((arg) => arg.startsWith('--root=')); + const baseRefArg = process.argv.find((arg) => arg.startsWith('--base-ref=')); + const staged = process.argv.includes('--staged'); + const allLocales = process.argv.includes('--all'); + const repoRoot = process.cwd(); + const root = rootArg ? path.resolve(rootArg.slice('--root='.length)) : process.cwd(); + const baseRef = baseRefArg?.slice('--base-ref='.length) || resolveDefaultBaseRef(repoRoot); + const errors = runCheck(root, baseRef, staged, repoRoot, allLocales); + + if (errors.length > 0) { + console.error('Translation consistency check failed.'); + for (const error of errors) console.error(`\n${error}`); + process.exitCode = 1; + return; + } + console.log(`Translation consistency check passed for ${REFERENCE_LOCALE} and all locale files.`); +} + +function resolveDefaultBaseRef(root) { + for (const candidate of ['origin/main', 'HEAD^']) { + try { + return execFileSync('git', ['rev-parse', '--verify', candidate], { cwd: root, encoding: 'utf8' }).trim(); + } catch { + // Try the next available base. + } + } + return undefined; +} + +const scriptPath = process.argv[1] && path.resolve(process.argv[1]); +if (scriptPath === path.resolve(fileURLToPath(import.meta.url))) main(); diff --git a/scripts/git-staged-snapshot.mjs b/scripts/git-staged-snapshot.mjs new file mode 100755 index 00000000..1921529e --- /dev/null +++ b/scripts/git-staged-snapshot.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const [repoRoot, destination] = process.argv.slice(2); +if (!repoRoot || !destination) { + console.error('Usage: git-staged-snapshot.mjs '); + process.exit(1); +} + +mkdirSync(destination, { recursive: true }); +const prefix = destination.endsWith(path.sep) ? destination : `${destination}${path.sep}`; +execFileSync('git', ['checkout-index', '-a', '-f', `--prefix=${prefix}`], { + cwd: repoRoot, + stdio: 'inherit', +}); diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100755 index 00000000..d89b5f9c --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; + +try { + execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }); + execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' }); +} catch { + // Package installation is also used outside a Git checkout. +}