Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions .github/scripts/validate-translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';

// ANSI color codes for better output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
};

/**
* Get all JSON keys recursively from an object
* @param {object} obj - The object to extract keys from
* @param {string} prefix - The current path prefix
* @returns {Set<string>} - Set of all nested keys
*/
function getAllKeys(obj, prefix = '') {
const keys = new Set();

for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.add(fullKey);

if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
const nestedKeys = getAllKeys(obj[key], fullKey);
nestedKeys.forEach(k => keys.add(k));
}
}

return keys;
}

/**
* Validate JSON syntax by attempting to parse using Bun's native file API
* @param {string} filePath - Path to the JSON file
* @returns {Promise<object|null>} - Parsed JSON object or null if invalid
*/
async function validateJSONSyntax(filePath) {
try {
// Use Bun.file() API
const bunFile = Bun.file(filePath);
return await bunFile.json();
} catch (error) {
console.error(`${colors.red}✗ ${filePath}: Invalid JSON syntax${colors.reset}`);
console.error(` ${error.message}`);
return null;
}
}

/**
* Compare translation file keys against the reference (en.json)
* @param {Set<string>} referenceKeys - Keys from en.json
* @param {Set<string>} translationKeys - Keys from the translation file
* @param {string} fileName - Name of the translation file
* @returns {object} - Validation result with missing and extra keys
*/
function compareKeys(referenceKeys, translationKeys, fileName) {
const missing = [];
const extra = [];

// Check for missing keys
referenceKeys.forEach(key => {
if (!translationKeys.has(key)) {
missing.push(key);
}
});

// Check for extra keys
translationKeys.forEach(key => {
if (!referenceKeys.has(key)) {
extra.push(key);
}
});

return { missing, extra };
}

/**
* Main validation function
*/
async function validateTranslations() {
const repoRoot = process.cwd();
const referenceFile = join(repoRoot, 'en.json');

console.log(`${colors.blue}=== Translation Validation ===${colors.reset}\n`);

// Validate reference file (en.json)
console.log(`Validating reference file: en.json`);
const referenceData = await validateJSONSyntax(referenceFile);

if (!referenceData) {
console.error(`${colors.red}ERROR: Reference file (en.json) is invalid!${colors.reset}`);
process.exit(1);
}

console.log(`${colors.green}✓ en.json: Valid JSON syntax${colors.reset}\n`);

// Get all keys from reference file
const referenceKeys = getAllKeys(referenceData);
console.log(`Reference file contains ${referenceKeys.size} keys\n`);

// Get all JSON files in the repository using Bun's native API
const entries = readdirSync(repoRoot);
const files = entries
.filter(entry => entry.endsWith('.json') && entry !== 'en.json');

let hasErrors = false;
const results = [];

// Validate each translation file
for (const fileName of files) {
const filePath = join(repoRoot, fileName);
console.log(`Validating: ${fileName}`);

// Validate JSON syntax
const translationData = await validateJSONSyntax(filePath);

if (!translationData) {
hasErrors = true;
results.push({ file: fileName, valid: false, missing: [], extra: [] });
continue;
}

console.log(`${colors.green}✓ ${fileName}: Valid JSON syntax${colors.reset}`);

// Compare keys
const translationKeys = getAllKeys(translationData);
const { missing, extra } = compareKeys(referenceKeys, translationKeys, fileName);

if (missing.length > 0) {
hasErrors = true;
console.log(`${colors.red}✗ ${fileName}: Missing ${missing.length} key(s)${colors.reset}`);
missing.forEach(key => console.log(` - ${key}`));
}

if (extra.length > 0) {
console.log(`${colors.yellow}⚠ ${fileName}: Has ${extra.length} extra key(s) not in en.json${colors.reset}`);
extra.forEach(key => console.log(` - ${key}`));
}

if (missing.length === 0 && extra.length === 0) {
console.log(`${colors.green}✓ ${fileName}: All keys match en.json${colors.reset}`);
}

console.log('');
results.push({ file: fileName, valid: true, missing, extra });
}

// Summary
console.log(`${colors.blue}=== Validation Summary ===${colors.reset}`);
console.log(`Total files validated: ${files.length + 1}`);

const filesWithIssues = results.filter(r => !r.valid || r.missing.length > 0);
const filesWithWarnings = results.filter(r => r.valid && r.missing.length === 0 && r.extra.length > 0);

if (hasErrors) {
console.log(`${colors.red}Files with errors: ${filesWithIssues.length}${colors.reset}`);
filesWithIssues.forEach(r => {
if (!r.valid) {
console.log(` - ${r.file}: Invalid JSON`);
} else if (r.missing.length > 0) {
console.log(` - ${r.file}: ${r.missing.length} missing keys`);
}
});
if (filesWithWarnings.length > 0) {
console.log(`${colors.yellow}Files with warnings: ${filesWithWarnings.length}${colors.reset}`);
filesWithWarnings.forEach(r => {
console.log(` - ${r.file}: ${r.extra.length} extra keys (warnings only)`);
});
}
console.log('');
console.log(`${colors.red}Validation FAILED${colors.reset}`);
process.exit(1);
} else {
console.log(`${colors.green}Files without errors: ${files.length + 1 - filesWithIssues.length}${colors.reset}`);
if (filesWithWarnings.length > 0) {
console.log(`${colors.yellow}Files with warnings: ${filesWithWarnings.length}${colors.reset}`);
}
console.log('');
console.log(`${colors.green}Validation PASSED${colors.reset}`);
process.exit(0);
}
}

// Run validation
await validateTranslations();
53 changes: 53 additions & 0 deletions .github/workflows/validate-translations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Validate Translations

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**.json'
- '.github/workflows/validate-translations.yml'
- '.github/scripts/validate-translations.js'

jobs:
validate:
name: Validate JSON Translations
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Run validation script
run: bun run .github/scripts/validate-translations.js

- name: Comment on PR (on failure)
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '❌ **Translation Validation Failed**\n\nThe JSON validation check has failed. Please review the workflow logs for details on missing or extra keys, or JSON syntax errors.\n\n[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
})

- name: Comment on PR (on success)
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ **Translation Validation Passed**\n\nAll JSON files are valid and have matching keys with en.json!'
})
Loading