#!/usr/bin/env tsx import { readFileSync, readdirSync, existsSync } from "fs"; import { join, dirname, basename, extname, resolve } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Security: Validate that file path is within the i18n directory function validateFilePath(filePath: string): string { const resolvedPath = resolve(filePath); const resolvedBaseDir = resolve(__dirname); if (!resolvedPath.startsWith(resolvedBaseDir)) { throw new Error(`Path traversal attempt detected: ${filePath}`); } // Ensure it's a JSON file if (extname(resolvedPath) !== ".json") { throw new Error(`Invalid file type: ${filePath}`); } return resolvedPath; } // Extract placeholders from a translation string (e.g., "{title}", "{soc}", etc.) function extractPlaceholders(text: string): string[] { const matches = text.match(/\{[^}]+\}/g); if (!matches) return []; // Remove duplicates and sort const unique = [...new Set(matches)]; return unique.sort(); } // Flatten nested translation object into dot-notation keys function flattenObject(obj: any, prefix = ""): Record { return Object.entries(obj).reduce( (acc, [key, val]) => { const path = prefix ? `${prefix}.${key}` : key; if (val && typeof val === "object") { Object.assign(acc, flattenObject(val, path)); } else if (typeof val === "string") { acc[path] = val; } return acc; }, {} as Record ); } // Compare placeholder arrays using Set equality for better performance function placeholdersMatch(a: string[], b: string[]): boolean { const setA = new Set(a); const setB = new Set(b); if (setA.size !== setB.size) return false; for (const placeholder of setA) { if (!setB.has(placeholder)) return false; } return true; } // Load and parse JSON translation file safely function loadTranslationFile(filePath: string): Record { const validatedPath = validateFilePath(filePath); const content = readFileSync(validatedPath, "utf-8"); const parsed = JSON.parse(content); return flattenObject(parsed); } // Get all translation files in the directory safely function getTranslationFiles(): string[] { const files = readdirSync(__dirname) .filter((file) => { // Only allow valid filename characters and JSON extension return /^[a-zA-Z0-9_-]+\.json$/.test(file); }) .map((file) => validateFilePath(join(__dirname, file))); return files; } // Main validation function function validateTranslations(): void { console.log("šŸ” Checking translation placeholder consistency..."); const sourceFile = validateFilePath(join(__dirname, "en.json")); if (!existsSync(sourceFile)) { console.error(`āŒ Source file not found: ${sourceFile}`); process.exit(1); } const sourceFlat = loadTranslationFile(sourceFile); const translationFiles = getTranslationFiles().filter((file) => basename(file) !== "en.json"); if (translationFiles.length === 0) { console.log("ā„¹ļø No translation files to check."); process.exit(0); } const allErrors: Array<{ file: string; key: string; expected: string[]; found: string[] }> = []; for (const file of translationFiles) { const lang = basename(file, ".json"); const targetFlat = loadTranslationFile(file); for (const [key, sourceStr] of Object.entries(sourceFlat)) { const sourcePlaceholders = extractPlaceholders(sourceStr); // Only check if source has placeholders if (sourcePlaceholders.length === 0) continue; // Skip if translation doesn't exist at all (fallback to English is fine) if (!(key in targetFlat)) continue; const targetPlaceholders = extractPlaceholders(targetFlat[key]); if (!placeholdersMatch(sourcePlaceholders, targetPlaceholders)) { allErrors.push({ file: lang, key, expected: sourcePlaceholders, found: targetPlaceholders, }); } } } // Group errors by file and output const errorsByFile = new Map(); for (const error of allErrors) { if (!errorsByFile.has(error.file)) { errorsByFile.set(error.file, []); } errorsByFile.get(error.file)!.push(error); } for (const [lang, errors] of errorsByFile) { console.log(`\nšŸ“„ i18n/${lang}.json (${errors.length} errors)`); for (const error of errors) { console.log(` ${error.key}`); console.log(` expected: ${error.expected.join(", ")}`); console.log(` found: ${error.found.join(", ") || "(none)"}`); } } if (allErrors.length > 0) { console.log(`\nšŸ’” ${allErrors.length} placeholder errors found.`); process.exit(1); } else { console.log("āœ… All translations have correct placeholders!"); process.exit(0); } } // Run the validation validateTranslations();