i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
199 lines (182 loc) • 7.58 kB
JavaScript
const SecurityUtils = require('../utils/security');
const fs = require('fs');
const path = require('path');
const packageJson = require('../package.json');
const { getUnifiedConfig, parseCommonArgs, displayHelp } = require('../utils/config-helper');
const SetupEnforcer = require('../utils/setup-enforcer');
// Ensure setup is complete before running (only for standalone execution)
if (require.main === module) {
(async () => {
try {
await SetupEnforcer.checkSetupCompleteAsync();
} catch (error) {
console.error('Setup check failed:', error.message);
process.exit(1);
}
})();
}
const ExitCodes = require('../utils/exit-codes');
function hasBOM(content) {
return content.charCodeAt(0) === 0xFEFF;
}
function collectPluralKeys(obj, prefix = '', set = new Set()) {
for (const [key, value] of Object.entries(obj || {})) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
const keys = Object.keys(value);
if (keys.some(k => pluralForms.includes(k))) {
set.add(fullKey);
}
collectPluralKeys(value, fullKey, set);
}
}
return set;
}
function compareTypes(src, tgt, prefix = '', issues = []) {
for (const key of Object.keys(src)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (!(key in tgt)) continue;
const sVal = src[key];
const tVal = tgt[key];
if (sVal && typeof sVal === 'object' && !Array.isArray(sVal) && tVal && typeof tVal === 'object' && !Array.isArray(tVal)) {
compareTypes(sVal, tVal, fullKey, issues);
} else if (typeof sVal !== typeof tVal) {
issues.push(fullKey);
}
}
return issues;
}
// Export the I18nDoctor class for module usage
class I18nDoctor {
async run(options = {}) {
const args = parseCommonArgs(process.argv.slice(2));
if (args.help) {
displayHelp('i18ntk-doctor');
process.exit(0);
}
const config = await getUnifiedConfig('doctor', args);
const dirs = {
projectRoot: config.projectRoot,
sourceDir: config.sourceDir,
i18nDir: config.i18nDir,
outputDir: config.outputDir,
};
let exitCode = ExitCodes.SUCCESS;
const issues = [];
console.log('i18ntk doctor');
for (const [name, dir] of Object.entries(dirs)) {
const rel = path.relative(config.projectRoot, dir);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
issues.push(`path traversal detected: ${dir}`);
exitCode = Math.max(exitCode, ExitCodes.SECURITY_VIOLATION);
continue;
}
const exists = SecurityUtils.safeExistsSync(dir);
console.log(`${name}: ${dir} ${exists ? '✅' : '❌'}`);
if (!exists) {
if (name !== 'outputDir') {
issues.push(`Missing directory: ${dir}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
continue;
}
try {
fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
} catch (e) {
issues.push(`Permission issue: ${dir}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
}
const pkgVersion = packageJson.version;
if (config.version && config.version !== pkgVersion) {
issues.push(`Config version mismatch: ${config.version} != ${pkgVersion}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
const sourceLang = config.sourceLanguage || 'en';
// Only check languages that actually have locale directories in the project
const srcDir = path.join(config.i18nDir, sourceLang);
const srcFiles = SecurityUtils.safeExistsSync(srcDir) ? fs.readdirSync(srcDir).filter(f => f.endsWith('.json')) : [];
// Auto-detect available languages from the i18n dir instead of relying on defaultLanguages config
const availableLangs = new Set();
if (SecurityUtils.safeExistsSync(config.i18nDir)) {
const i18nEntries = fs.readdirSync(config.i18nDir, { withFileTypes: true });
for (const entry of i18nEntries) {
if (entry.isDirectory() && entry.name !== sourceLang && !entry.name.startsWith('.')) {
availableLangs.add(entry.name);
}
}
}
// Only check languages auto-detected from the i18n directory structure.
// defaultLanguages config is a hint for setup/translate, not a doctor requirement.
const languages = [...availableLangs];
for (const lang of languages) {
const langDir = path.join(config.i18nDir, lang);
if (!SecurityUtils.safeExistsSync(langDir)) {
issues.push(`Missing locale directory: ${lang}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
continue;
}
const files = fs.readdirSync(langDir).filter(f => f.endsWith('.json'));
for (const file of files) {
if (!srcFiles.includes(file)) {
issues.push(`Dangling namespace file: ${lang}/${file}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
const srcPath = path.join(srcDir, file);
const tgtPath = path.join(langDir, file);
if (!SecurityUtils.safeExistsSync(srcPath) || !SecurityUtils.safeExistsSync(tgtPath)) continue;
const srcContent = SecurityUtils.safeReadFileSync(srcPath, path.dirname(srcPath), 'utf8');
const tgtContent = SecurityUtils.safeReadFileSync(tgtPath, path.dirname(tgtPath), 'utf8');
if (hasBOM(srcContent) || hasBOM(tgtContent)) {
issues.push(`BOM detected in ${lang}/${file}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
let srcJson, tgtJson;
try {
srcJson = JSON.parse(srcContent.replace(/^\uFEFF/, ''));
} catch (e) {
issues.push(`Invalid JSON in source ${file}: ${e.message}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
continue;
}
try {
tgtJson = JSON.parse(tgtContent.replace(/^\uFEFF/, ''));
} catch (e) {
issues.push(`Invalid JSON in ${lang}/${file}: ${e.message}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
continue;
}
const srcPlurals = collectPluralKeys(srcJson);
const tgtPlurals = collectPluralKeys(tgtJson);
for (const key of srcPlurals) {
if (!tgtPlurals.has(key)) {
issues.push(`Inconsistent plural forms in ${lang}/${file}: missing ${key}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
}
}
const typeMismatches = compareTypes(srcJson, tgtJson);
typeMismatches.forEach(k => {
issues.push(`Type mismatch for key ${k} in ${lang}/${file}`);
exitCode = Math.max(exitCode, ExitCodes.CONFIG_ERROR);
});
}
}
if (issues.length > 0) {
console.log('\nIssues found:');
issues.forEach(i => console.log(` - ${i}`));
}
// Return the result instead of exiting for modular usage
if (require.main === module) {
process.exit(exitCode);
}
return { success: exitCode === ExitCodes.SUCCESS, issues, exitCode };
}
}
// Only run as standalone script if called directly
if (require.main === module) {
const doctor = new I18nDoctor();
doctor.run();
}
module.exports = I18nDoctor;