i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
1,172 lines (998 loc) • 59.5 kB
JavaScript
#!/usr/bin/env node
/**
* I18NTK INITIALIZATION SCRIPT
*
* This script initializes a new i18n project or adds new languages to an existing one.
* It uses the English (en) locale as the source of truth and generates translation files
* for specified languages with proper structure and __NOT_TRANSLATED__ markers.
*
* Usage:
* node scripts/i18n/01-init-i18n.js
* node scripts/i18n/01-init-i18n.js --languages=de,es,fr,ru
* node scripts/i18n/01-init-i18n.js --source-dir=./src/i18n/locales --target-languages=de,es
*/
const fs = require('fs');
const path = require('path');
const configManager = require('../utils/config-manager');
const SecurityUtils = require('../utils/security');
const AdminAuth = require('../utils/admin-auth');
const { loadTranslations, t } = require('../utils/i18n-helper');
const { detectFramework } = require('../utils/framework-detector');
const { upgradeConfig } = require('../utils/framework-config-templates');
const { getFormatAdapter } = require('../utils/format-manager');
// Ensure UIi18n is available for this initializer class
const UIi18n = require('./i18ntk-ui');
loadTranslations();
const { getUnifiedConfig, parseCommonArgs, displayHelp } = require('../utils/config-helper');
const { showFrameworkWarningOnce } = require('../utils/cli-helper');
const { createPrompt, isInteractive } = require('../utils/prompt-helper');
const { parseConfirmation } = require('../utils/localized-confirm');
const { normalizeReportFormat, writeReportFile } = require('../utils/report-writer');
// Language configurations with native names — comprehensive ISO 639-1 coverage
const LANGUAGE_CONFIG = {
'af': { name: 'Afrikaans', nativeName: 'Afrikaans' }, 'sq': { name: 'Albanian', nativeName: 'Shqip' },
'ar': { name: 'Arabic', nativeName: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629' }, 'hy': { name: 'Armenian', nativeName: '\u0540\u0561\u0575\u0565\u0580\u0565\u0576' },
'az': { name: 'Azerbaijani', nativeName: 'Az\u0259rbaycan' }, 'eu': { name: 'Basque', nativeName: 'Euskara' },
'be': { name: 'Belarusian', nativeName: '\u0411\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f' },
'bn': { name: 'Bengali', nativeName: '\u09ac\u09be\u0982\u09b2\u09be' },
'bs': { name: 'Bosnian', nativeName: 'Bosanski' }, 'bg': { name: 'Bulgarian', nativeName: '\u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438' },
'ca': { name: 'Catalan', nativeName: 'Catal\u00e0' }, 'zh': { name: 'Chinese', nativeName: '\u4e2d\u6587' },
'hr': { name: 'Croatian', nativeName: 'Hrvatski' }, 'cs': { name: 'Czech', nativeName: '\u010ce\u0161tina' },
'da': { name: 'Danish', nativeName: 'Dansk' }, 'nl': { name: 'Dutch', nativeName: 'Nederlands' },
'en': { name: 'English', nativeName: 'English' }, 'et': { name: 'Estonian', nativeName: 'Eesti' },
'fi': { name: 'Finnish', nativeName: 'Suomi' }, 'fr': { name: 'French', nativeName: 'Fran\u00e7ais' },
'ka': { name: 'Georgian', nativeName: '\u10e5\u10d0\u10e0\u10d7\u10e3\u10da\u10d8' },
'de': { name: 'German', nativeName: 'Deutsch' }, 'el': { name: 'Greek', nativeName: '\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac' },
'gu': { name: 'Gujarati', nativeName: '\u0a97\u0ac1\u0a9c\u0ab0\u0abe\u0aa4\u0ac0' },
'he': { name: 'Hebrew', nativeName: '\u05e2\u05d1\u05e8\u05d9\u05ea' },
'hi': { name: 'Hindi', nativeName: '\u0939\u093f\u0928\u094d\u0926\u0940' },
'hu': { name: 'Hungarian', nativeName: 'Magyar' }, 'is': { name: 'Icelandic', nativeName: '\u00cdslenska' },
'id': { name: 'Indonesian', nativeName: 'Bahasa Indonesia' },
'ga': { name: 'Irish', nativeName: 'Gaeilge' }, 'it': { name: 'Italian', nativeName: 'Italiano' },
'ja': { name: 'Japanese', nativeName: '\u65e5\u672c\u8a9e' },
'kk': { name: 'Kazakh', nativeName: '\u049a\u0430\u0437\u0430\u049b' },
'ko': { name: 'Korean', nativeName: '\ud55c\uad6d\uc5b4' },
'lv': { name: 'Latvian', nativeName: 'Latvie\u0161u' }, 'lt': { name: 'Lithuanian', nativeName: 'Lietuvi\u0173' },
'mk': { name: 'Macedonian', nativeName: '\u041c\u0430\u043a\u0435\u0434\u043e\u043d\u0441\u043a\u0438' },
'ms': { name: 'Malay', nativeName: 'Bahasa Melayu' },
'ml': { name: 'Malayalam', nativeName: '\u0d2e\u0d32\u0d2f\u0d3e\u0d33\u0d02' },
'mr': { name: 'Marathi', nativeName: '\u092e\u0930\u093e\u0920\u0940' },
'mn': { name: 'Mongolian', nativeName: '\u041c\u043e\u043d\u0433\u043e\u043b' },
'no': { name: 'Norwegian', nativeName: 'Norsk' },
'fa': { name: 'Persian', nativeName: '\u0641\u0627\u0631\u0633\u06cc' },
'pl': { name: 'Polish', nativeName: 'Polski' }, 'pt': { name: 'Portuguese', nativeName: 'Portugu\u00eas' },
'pa': { name: 'Punjabi', nativeName: '\u0a2a\u0a70\u0a1c\u0a3e\u0a2c\u0a40' },
'ro': { name: 'Romanian', nativeName: 'Rom\u00e2n\u0103' },
'ru': { name: 'Russian', nativeName: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439' },
'sr': { name: 'Serbian', nativeName: '\u0421\u0440\u043f\u0441\u043a\u0438' },
'sk': { name: 'Slovak', nativeName: 'Sloven\u010dina' },
'sl': { name: 'Slovenian', nativeName: 'Sloven\u0161\u010dina' },
'es': { name: 'Spanish', nativeName: 'Espa\u00f1ol' },
'sw': { name: 'Swahili', nativeName: 'Kiswahili' }, 'sv': { name: 'Swedish', nativeName: 'Svenska' },
'tl': { name: 'Filipino', nativeName: 'Filipino' },
'ta': { name: 'Tamil', nativeName: '\u0ba4\u0bae\u0bbf\u0bb4\u0bcd' },
'te': { name: 'Telugu', nativeName: '\u0c24\u0c46\u0c32\u0c41\u0c17\u0c41' },
'th': { name: 'Thai', nativeName: '\u0e44\u0e17\u0e22' },
'tr': { name: 'Turkish', nativeName: 'T\u00fcrk\u00e7e' },
'uk': { name: 'Ukrainian', nativeName: '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430' },
'ur': { name: 'Urdu', nativeName: '\u0627\u0631\u062f\u0648' },
'vi': { name: 'Vietnamese', nativeName: 'Ti\u1ebfng Vi\u1ec7t' },
};
class I18nInitializer {
constructor(config = {}) {
this.ui = new UIi18n();
this.config = {
sourceLanguage: 'en',
excludeFiles: ['.DS_Store', 'Thumbs.db'],
supportedExtensions: ['.json'],
// Default structure: modular (folder per language)
structure: 'modular', // one of: 'single' | 'modular' | 'existing'
perLanguageStructure: {}, // optional map lang -> 'single' | 'modular'
noPrompt: false,
...config
};
this.format = getFormatAdapter(this.config.format);
if (!this.config.supportedExtensions || !this.config.supportedExtensions.length) {
this.config.supportedExtensions = [this.format.extension];
}
this.detectedFramework = detectFramework(process.cwd());
if (this.detectedFramework && !this.config.translationPatterns) {
this.config.translationPatterns = this.detectedFramework.patterns;
}
this.sourceDir = this.config.sourceDir || './locales';
// Source language directory depends on structure
this.sourceLanguageDir = this.config.structure === 'single'
? this.sourceDir
: path.join(this.sourceDir, this.config.sourceLanguage);
// Ensure defaultLanguages is properly initialized from config
if (!this.config.defaultLanguages || !Array.isArray(this.config.defaultLanguages) || this.config.defaultLanguages.length === 0) {
this.config.defaultLanguages = ['en', 'de', 'es', 'fr', 'ru'];
}
// No longer create readline interface here - use CLI helpers
this.rl = null;
this.shouldCloseRL = false;
this.announcedExistingDir = false;
this.promptInstance = null;
}
async applyDetectedFrameworkTemplate() {
const framework = this.detectedFramework?.id;
if (!framework) return null;
const current = configManager.loadConfig();
const result = upgradeConfig(current, framework);
if (!result.needsUpgrade) return result;
await configManager.saveConfig(result.config);
this.config = { ...this.config, ...result.patch };
console.log(`Updated the configuration with the ${result.template.label} template. Existing settings were kept.`);
return result;
}
// Updated checkI18nDependencies method that uses configuration
async checkI18nDependencies(noPrompt = false) {
const packageJsonPath = path.resolve('./package.json');
if (!SecurityUtils.safeExistsSync(packageJsonPath)) {
console.log(t('errors.noPackageJson'));
return true; // Allow to continue without framework
}
try {
const packageJson = JSON.parse(SecurityUtils.safeReadFileSync(packageJsonPath, path.dirname(packageJsonPath), 'utf8'));
// Include peerDependencies in the check
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies
};
const i18nFrameworks = [
'react-i18next', 'vue-i18n', 'i18next', 'next-i18next',
'svelte-i18n', '@nuxtjs/i18n', 'next-intl', 'remix-i18next',
'i18next-remix', 'gatsby-plugin-react-i18next', 'gatsby-plugin-intl',
'sveltekit-i18n', 'astro-i18next', '@astrojs/i18n',
'qwik-speak', 'qwik-i18n', '@solid-primitives/i18n',
'ember-intl', 'react-native-localize', 'expo-localization',
'@ngx-translate/core', '@ionic/angular',
'formatjs', '@lingui/core', 'i18ntk-runtime', 'i18ntk/runtime',
'angular-i18n', '@angular/localize'
];
const installedFrameworks = i18nFrameworks.filter(framework => dependencies[framework]);
if (installedFrameworks.length > 0) {
console.log(t('init.detectedI18nFrameworks', { frameworks: installedFrameworks.join(', ') }));
const cfg = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {});
cfg.framework = cfg.framework || {};
cfg.framework.detected = true;
cfg.framework.installed = installedFrameworks;
if (configManager.saveSettings) {
configManager.saveSettings(cfg);
} else if (configManager.saveConfig) {
configManager.saveConfig(cfg);
}
return true;
} else {
const cfg = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {});
if (cfg.framework) {
cfg.framework.detected = false;
if (configManager.saveSettings) {
configManager.saveSettings(cfg);
} else if (configManager.saveConfig) {
configManager.saveConfig(cfg);
}
}
// Framework detection is now handled by maybePromptFramework in i18ntk-manage.js
// Skip prompting here to avoid double prompts
return true;
}
} catch (error) {
console.log(t('init.errors.packageJsonRead'));
return true; // Allow to continue on error
}
}
// Add the missing promptContinueWithoutI18n method
async promptContinueWithoutI18n(noPrompt = false) {
if (!isInteractive({ noPrompt: noPrompt || this.config.noPrompt })) {
return true;
}
const answer = await this.prompt('\n' + t('init.continueWithoutI18nPrompt'));
return parseConfirmation(answer, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: false });
}
// Add the missing prompt method
async prompt(question) {
if (!this.promptInstance) {
this.promptInstance = createPrompt({ noPrompt: this.config.noPrompt });
}
if (!isInteractive({ noPrompt: this.config.noPrompt })) {
return '';
}
return this.promptInstance.question(question);
}
// Parse command line arguments
parseArgs() {
const args = process.argv.slice(2);
const parsed = {};
args.forEach(arg => {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
if (key === 'languages' || key === 'target-languages') {
parsed.languages = value ? value.split(',').map(l => l.trim()) : [];
} else if (key === 'source-dir') {
parsed.sourceDir = value;
} else if (key === 'source-language') {
parsed.sourceLanguage = value;
} else if (key === 'no-prompt') {
parsed.noPrompt = true;
} else if (key === 'yes') {
parsed.yes = true;
}
}
});
if (!process.stdin.isTTY) {
parsed.yes = true;
}
if (parsed.yes) {
parsed.noPrompt = true;
}
return parsed;
}
// Detect existing translation directories and allow user selection
async detectAndSelectDirectory(skipPrompt = false) {
const possibleLocations = [
'./locales',
'./src/locales',
'./src/i18n/locales',
'./app/locales',
'./public/locales',
'./translations',
'./lang',
'./i18n/locales',
'./assets/locales',
'./client/locales',
'./frontend/locales'
];
const existingLocations = [];
// Check for existing translation directories
for (const location of possibleLocations) {
if (SecurityUtils.safeExistsSync(location)) {
try {
const items = fs.readdirSync(location);
const englishFormats = ['en', 'en-US', 'en-GB', 'english'];
// Check for English directories first
for (const format of englishFormats) {
const englishPath = path.join(location, format);
if (SecurityUtils.safeExistsSync(englishPath) && fs.statSync(englishPath).isDirectory()) {
const englishFiles = fs.readdirSync(englishPath).filter(file => file.endsWith(this.format.extension));
if (englishFiles.length > 0) {
// Found English files, prioritize this
existingLocations.unshift(location);
break;
}
}
}
// Also check for any language directories or format files
const hasLanguageDirs = items.some(item => {
const itemPath = path.join(location, item);
if (fs.statSync(itemPath).isDirectory()) {
return ['en', 'de', 'es', 'fr', 'ru', 'ja', 'zh', 'en-US', 'en-GB'].includes(item);
}
return item.endsWith(this.format.extension);
});
if (hasLanguageDirs && !existingLocations.includes(location)) {
existingLocations.push(location);
}
} catch (error) {
// Continue checking other locations
}
}
}
if (existingLocations.length > 0) {
if (skipPrompt || !process.stdin.isTTY) {
const selectedDir = existingLocations[0];
this.config.sourceDir = selectedDir;
this.sourceDir = path.resolve(selectedDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
const rel = configManager.toRelative(selectedDir);
await configManager.updateConfig({ sourceDir: rel, i18nDir: rel });
return selectedDir;
}
console.log('\n' + t('init.existingDirectoriesFound'));
console.log(t('common.separator'));
// List existing locations
existingLocations.forEach((location, index) => {
console.log(` ${index + 1}. ${location}`);
});
// Add options for new directory and exit
console.log(` ${existingLocations.length + 1}. Create new directory`);
console.log(` 0. Exit`);
let answer;
let selectedIndex;
// Keep asking until we get a valid number
while (true) {
answer = await this.prompt('\n' + t('init.selectDirectoryPrompt') + ' (0-' + (existingLocations.length + 1) + '):');
// Check for exit (0)
if (answer === '0') {
console.log(t('init.initializationCancelled'));
process.exit(0);
}
// Parse the selection
selectedIndex = parseInt(answer) - 1;
// Validate the selection
if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex <= existingLocations.length) {
break;
}
console.log(t('errors.invalidOption', { option: answer }));
}
if (selectedIndex >= 0 && selectedIndex < existingLocations.length) {
const selectedDir = existingLocations[selectedIndex];
if (!this.announcedExistingDir) {
console.log(t('init.usingExistingDirectory', { dir: selectedDir }));
this.announcedExistingDir = true;
}
this.config.sourceDir = selectedDir;
this.sourceDir = path.resolve(selectedDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
const rel = configManager.toRelative(selectedDir);
await configManager.updateConfig({ sourceDir: rel, i18nDir: rel });
return selectedDir;
} else if (selectedIndex === existingLocations.length) {
const newDirName = await this.prompt('\n' + t('init.enterNewDirectoryName') + ': ');
if (newDirName && newDirName.trim()) {
const newDirPath = path.resolve(newDirName.trim());
if (!SecurityUtils.safeExistsSync(newDirPath)) {
fs.mkdirSync(newDirPath, { recursive: true });
console.log(t('init.createdNewDirectory', { dir: newDirPath }));
} else {
console.log(t('init.directoryAlreadyExists', { dir: newDirPath }));
}
const sourceLangDir = path.join(newDirPath, this.config.sourceLanguage);
if (!SecurityUtils.safeExistsSync(sourceLangDir)) {
fs.mkdirSync(sourceLangDir, { recursive: true });
console.log(t('init.createdSourceLanguageDirectory', { dir: sourceLangDir }));
await this.createSampleTranslationFile(sourceLangDir);
}
this.config.sourceDir = newDirPath;
this.sourceDir = newDirPath;
this.sourceLanguageDir = sourceLangDir;
const rel = configManager.toRelative(newDirPath);
await configManager.updateConfig({ sourceDir: rel, i18nDir: rel });
return newDirPath;
} else {
console.log(t('init.invalidDirectoryName'));
return null;
}
}
}
if (skipPrompt || !process.stdin.isTTY) {
return null;
}
return null;
}
// Setup initial directory structure if needed
async setupInitialStructure(skipPrompt = false) {
// First, detect if there are existing translation directories with English files
const usedExisting = await this.detectAndSelectDirectory(skipPrompt);
if (usedExisting) {
console.log(t('init.usingExistingDirectory', { dir: this.sourceDir }));
// When using existing, sourceLanguageDir might already be set to English directory
return;
}
// Validate paths
const validatedSourceDir = SecurityUtils.validatePath(this.sourceDir, process.cwd());
if (!validatedSourceDir) {
throw new Error(t('validate.invalidSourceDirectory', { sourceDir: this.sourceDir }) || `Invalid source directory: ${this.sourceDir}`);
}
// For modular structure, ensure per-language subdirectory exists
let validatedSourceLanguageDir = this.sourceLanguageDir;
if (this.config.structure !== 'single') {
validatedSourceLanguageDir = SecurityUtils.validatePath(this.sourceLanguageDir, process.cwd());
if (!validatedSourceLanguageDir) {
throw new Error(t('validate.invalidSourceLanguageDirectory', { sourceDir: this.sourceLanguageDir }) || `Invalid source language directory: ${this.sourceLanguageDir}`);
}
}
// Create directories if they do not exist
if (!SecurityUtils.safeExistsSync(validatedSourceDir)) {
fs.mkdirSync(validatedSourceDir, { recursive: true });
}
if (this.config.structure !== 'single' && !SecurityUtils.safeExistsSync(validatedSourceLanguageDir)) {
fs.mkdirSync(validatedSourceLanguageDir, { recursive: true });
}
// Create sample translation file if none exist
const englishFiles = (this.config.structure === 'single'
? fs.readdirSync(validatedSourceDir)
: fs.readdirSync(validatedSourceLanguageDir))
.filter(file => file.endsWith(this.format.extension));
if (englishFiles.length === 0) {
await this.createSampleTranslationFile(this.config.structure === 'single' ? validatedSourceDir : validatedSourceLanguageDir);
} else {
// Directory exists, check if we need to create a sample file
const existingFiles = (this.config.structure === 'single' ? fs.readdirSync(validatedSourceDir) : fs.readdirSync(validatedSourceLanguageDir))
.filter(file => file.endsWith(this.format.extension));
if (existingFiles.length === 0) {
// No format files exist, create sample file
await this.createSampleTranslationFile(this.config.structure === 'single' ? validatedSourceDir : validatedSourceLanguageDir);
}
}
const rel = configManager.toRelative(this.sourceDir);
await configManager.updateConfig({ sourceDir: rel, i18nDir: rel });
}
// Create sample translation file with smart naming
async createSampleTranslationFile(validatedSourceLanguageDir) {
const sampleTranslations = {
"common": {
"welcome": "Welcome",
"hello": "Hello",
"goodbye": "Goodbye",
"yes": "Yes",
"no": "No",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading..."
},
"navigation": {
"home": "Home",
"about": "About",
"contact": "Contact",
"settings": "Settings"
}
};
// Determine filename: use common.json if it doesn't exist, otherwise i18ntk-common.json
const commonFilePath = path.join(validatedSourceLanguageDir, `common${this.format.extension}`);
const i18ntkCommonFilePath = path.join(validatedSourceLanguageDir, `i18ntk-common${this.format.extension}`);
let sampleFilePath;
if (!SecurityUtils.safeExistsSync(commonFilePath)) {
sampleFilePath = commonFilePath;
} else {
sampleFilePath = i18ntkCommonFilePath;
}
const validatedSampleFilePath = SecurityUtils.validatePath(sampleFilePath, process.cwd());
if (!validatedSampleFilePath) {
SecurityUtils.logSecurityEvent('Invalid sample file path', 'error', { path: sampleFilePath });
throw new Error(t('validate.invalidSampleFilePath') || 'Invalid sample file path');
}
const success = await SecurityUtils.safeWriteFile(validatedSampleFilePath, this.format.serialize(sampleTranslations), process.cwd());
if (success) {
console.log(t('init.createdSampleTranslationFile', { file: validatedSampleFilePath }));
SecurityUtils.logSecurityEvent('Sample translation file created', 'info', { file: validatedSampleFilePath });
} else {
SecurityUtils.logSecurityEvent('Failed to create sample translation file', 'error', { file: validatedSampleFilePath });
throw new Error(t('validate.failedToCreateSampleTranslationFile') || 'Failed to create sample translation file');
}
}
// Check if source directory and language exist
validateSource() {
if (!SecurityUtils.safeExistsSync(this.sourceDir)) {
throw new Error(t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir)) {
throw new Error(t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceLanguageDir }) || `Source language directory not found: ${this.sourceLanguageDir}`);
}
return true;
}
createBootstrapSourceFile(targetDir) {
try {
if (!SecurityUtils.safeExistsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const sampleName = `common${this.format.extension}`;
const samplePath = path.join(targetDir, sampleName);
if (SecurityUtils.safeExistsSync(samplePath)) {
return sampleName;
}
const sampleContent = {
app: {
title: 'Application',
description: 'Application description'
},
common: {
yes: 'Yes',
no: 'No',
save: 'Save',
cancel: 'Cancel'
}
};
const serializer = typeof this.format?.write === 'function'
? this.format.write.bind(this.format)
: (typeof this.format?.serialize === 'function'
? this.format.serialize.bind(this.format)
: (data) => JSON.stringify(data, null, 2));
const serialized = serializer(sampleContent);
SecurityUtils.safeWriteFileSync(samplePath, `${serialized}\n`, path.dirname(samplePath), 'utf8');
console.log(t('init.createdSampleTranslationFile', { file: samplePath }) || `Created sample translation file: ${samplePath}`);
return sampleName;
} catch {
return null;
}
}
// Get all JSON files from source language directory (supports single/modular)
getSourceFiles() {
try {
if (this.config.structure === 'single') {
if (!SecurityUtils.safeExistsSync(this.sourceDir)) {
throw new Error(t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
const files = fs.readdirSync(this.sourceDir)
.filter(file => file.endsWith(this.format.extension) && !this.config.excludeFiles.includes(file));
if (files.length === 0) {
const sampleFile = this.createBootstrapSourceFile(this.sourceDir);
if (sampleFile) return [sampleFile];
throw new Error(t('validate.noJsonFilesFound', { sourceDir: this.sourceDir }) || `No JSON files found in source directory: ${this.sourceDir}`);
}
return files;
}
if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir)) {
// Try to find English files in parent directory or subdirectories
const parentDir = path.dirname(this.sourceLanguageDir);
if (SecurityUtils.safeExistsSync(parentDir)) {
const subdirs = fs.readdirSync(parentDir).filter(item => {
const fullPath = path.join(parentDir, item);
return fs.statSync(fullPath).isDirectory();
});
// Look for English files in any subdirectory
for (const subdir of subdirs) {
const englishDir = path.join(parentDir, subdir);
if (SecurityUtils.safeExistsSync(englishDir)) {
const files = fs.readdirSync(englishDir);
const formatFiles = files.filter(file =>
file.endsWith(this.format.extension) &&
!this.config.excludeFiles.includes(file)
);
if (formatFiles.length > 0) {
// Found English files, use this directory
this.sourceLanguageDir = englishDir;
return formatFiles;
}
}
}
}
throw new Error(t('validate.noJsonFilesFound', { sourceDir: this.sourceLanguageDir }) || `No JSON files found in source directory: ${this.sourceLanguageDir}`);
}
const files = fs.readdirSync(this.sourceLanguageDir)
.filter(file => {
return file.endsWith(this.format.extension) &&
!this.config.excludeFiles.includes(file);
});
if (files.length === 0) {
const sampleFile = this.createBootstrapSourceFile(this.sourceLanguageDir);
if (sampleFile) return [sampleFile];
throw new Error(t('validate.noJsonFilesFound', { sourceDir: this.sourceLanguageDir }) || `No JSON files found in source directory: ${this.sourceLanguageDir}`);
}
return files;
} catch (error) {
console.warn(t('init.warningCannotReadSourceDir', { dir: this.sourceLanguageDir, error: error.message }));
throw error;
}
}
// Recursively mark all string values with country code markers
markWithCountryCode(obj, countryCode) {
if (typeof obj === 'string') {
return `[${countryCode.toUpperCase()}] ${obj}`;
}
if (Array.isArray(obj)) {
return obj.map(item => this.markWithCountryCode(item, countryCode));
}
if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.markWithCountryCode(value, countryCode);
}
return result;
}
return obj;
}
// Get the structure type for a specific language
getLanguageStructure(language) {
// Check for per-language structure first
if (this.config.perLanguageStructure && this.config.perLanguageStructure[language]) {
return this.config.perLanguageStructure[language];
}
// Fall back to the global structure
return this.config.structure || 'modular';
}
// Create or update a language file securely (supports single/modular)
async createLanguageFile(sourceFile, targetLanguage, sourceContent) {
try {
const sourceFilePath = path.join(this.sourceLanguageDir, sourceFile);
let targetFilePath;
if (this.getLanguageStructure(targetLanguage) === 'single') {
// Single-file per language, write to <lang>.json in sourceDir
const baseName = `${targetLanguage}${this.format.extension}`;
targetFilePath = path.join(this.sourceDir, baseName);
} else {
// Modular: folder per language mirroring source file
const targetDir = path.join(this.sourceDir, targetLanguage);
if (!SecurityUtils.safeExistsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
targetFilePath = path.join(targetDir, sourceFile);
}
// Validate source and target paths
const validatedSourcePath = SecurityUtils.validatePath(sourceFilePath, process.cwd());
const validatedTargetPath = SecurityUtils.validatePath(targetFilePath, process.cwd());
const targetDir = path.dirname(validatedTargetPath);
if (!validatedSourcePath || !validatedTargetPath) {
SecurityUtils.logSecurityEvent('Invalid path detected in createLanguageFile', 'error', {
sourcePath: sourceFilePath,
targetPath: targetFilePath
});
throw new Error(t('validate.invalidFilePathDetected') || 'Invalid file path detected');
}
// Create target directory if it doesn't exist
if (!SecurityUtils.safeExistsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
let targetContent;
// If target file exists, preserve existing translations
if (SecurityUtils.safeExistsSync(validatedTargetPath)) {
try {
const existingContent = await SecurityUtils.safeReadFile(validatedTargetPath, process.cwd());
if (existingContent) {
targetContent = this.mergeTranslations(sourceContent, this.format.read(existingContent), targetLanguage);
} else {
targetContent = this.markWithCountryCode(sourceContent, targetLanguage);
}
} catch (error) {
console.warn(`⚠️ Warning: Could not parse existing file ${validatedTargetPath}, creating new one`);
SecurityUtils.logSecurityEvent('File parse error', 'warn', { file: validatedTargetPath, error: error.message });
targetContent = this.markWithCountryCode(sourceContent, targetLanguage);
}
} else {
targetContent = this.markWithCountryCode(sourceContent, targetLanguage);
}
// Write the file securely
const success = await SecurityUtils.safeWriteFile(validatedTargetPath, this.format.serialize(targetContent), process.cwd());
if (!success) {
SecurityUtils.logSecurityEvent('Failed to write language file', 'error', { file: validatedTargetPath });
throw new Error(t('validate.failedToWriteFile', { filePath: validatedTargetPath }) || `Failed to write file: ${validatedTargetPath}`);
}
SecurityUtils.logSecurityEvent('Language file created/updated', 'info', { file: validatedTargetPath, language: targetLanguage });
return validatedTargetPath;
} catch (error) {
console.error(t('init.errors.initializationFailed', { error: error.message }));
return false;
}
}
// Merge existing translations with new structure using country code markers
mergeTranslations(sourceObj, existingObj, countryCode) {
if (typeof sourceObj === 'string') {
// If existing translation exists and doesn't contain country code marker, keep it
if (typeof existingObj === 'string' &&
!existingObj.startsWith(`[${countryCode.toUpperCase()}]`) &&
existingObj.trim() !== '') {
return existingObj;
}
return this.markWithCountryCode(sourceObj, countryCode);
}
if (Array.isArray(sourceObj)) {
return sourceObj.map((item, index) => {
const existingItem = Array.isArray(existingObj) ? existingObj[index] : undefined;
return this.mergeTranslations(item, existingItem, countryCode);
});
}
if (sourceObj && typeof sourceObj === 'object') {
const result = {};
for (const [key, value] of Object.entries(sourceObj)) {
const existingValue = existingObj && typeof existingObj === 'object' ? existingObj[key] : undefined;
result[key] = this.mergeTranslations(value, existingValue, countryCode);
}
return result;
}
return sourceObj;
}
// Get translation statistics
getTranslationStats(obj) {
let total = 0;
let translated = 0;
let missing = 0;
const count = (item) => {
if (typeof item === 'string') {
total++;
const isCountryCodeMarker = /^\[([A-Z]{2})\]/.test(item);
if (isCountryCodeMarker) {
missing++;
} else if (item.trim() !== '') {
translated++;
}
} else if (Array.isArray(item)) {
item.forEach(count);
} else if (item && typeof item === 'object') {
Object.values(item).forEach(count);
}
};
count(obj);
return {
total,
translated,
percentage: total > 0 ? Math.round((translated / total) * 100) : 0,
missing
};
}
// Interactive admin PIN setup
async promptAdminPinSetup() {
const { ask, askHidden, flushStdout } = require('../utils/cli');
console.log('\n' + t('init.adminPinSetupOptional'));
console.log(t('init.adminPinSeparator'));
console.log(t('init.adminPinDescription1'));
console.log(t('init.adminPinDescription2'));
console.log(t('init.adminPinDescription3'));
console.log(t('init.adminPinDescription4'));
await flushStdout();
const enableProtection = await ask('\n' + t('adminPin.setup_prompt'));
if (parseConfirmation(enableProtection, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: false })) {
try {
const adminAuth = new AdminAuth();
await adminAuth.initialize();
let pin = null;
do {
pin = await askHidden(t('init.enterAdminPin'));
if (!/^\d{4}$/.test(pin)) {
console.log(t('init.pinMustBe4Digits'));
pin = null;
continue;
}
const confirm = await askHidden(t('init.confirmAdminPin'));
if (pin !== confirm) {
console.log(t('init.pinMismatch'));
pin = null;
}
} while (!pin);
const saved = await adminAuth.setupPin(pin);
if (saved) {
await configManager.updateConfig({
security: {
adminPinEnabled: true,
adminPinPromptOnInit: true,
pinProtection: { enabled: true }
}
});
console.log(t('init.adminPinSetupSuccess'));
} else {
console.error(t('init.errorSettingUpAdminPin', { error: 'Failed to save PIN' }));
}
} catch (error) {
console.error(t('init.errorSettingUpAdminPin', { error: error.message }));
console.log(t('init.continuingWithoutAdminPin'));
}
} else {
console.log(t('init.skippingAdminPinSetup'));
}
}
async promptBackupConfiguration(skipPrompt = false) {
const defaultBackupConfig = { enabled: false, maxBackups: 1, location: './i18ntk-backups' };
if (skipPrompt || !isInteractive()) {
return null;
}
const { ask } = require('../utils/cli');
console.log('\n' + t('init.backup.title'));
console.log(t('init.backup.description'));
const enableAnswer = await ask(t('init.backup.enablePrompt'));
const enabled = parseConfirmation(enableAnswer, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: false });
if (!enabled) {
return defaultBackupConfig;
}
const keepAnswer = await ask(t('init.backup.keepPrompt'));
const parsedKeep = parseInt(String(keepAnswer || '').trim(), 10);
const maxBackups = Number.isInteger(parsedKeep) ? Math.min(Math.max(parsedKeep, 1), 3) : 1;
return { enabled: true, maxBackups, location: './i18ntk-backups' };
}
// Interactive language selection
async selectLanguages(skipPrompt = false) {
if (skipPrompt || !process.stdin.isTTY) {
return this.config.defaultLanguages;
}
const { ask } = require('../utils/cli');
console.log('\n' + t('init.languageSelectionTitle'));
console.log(t('common.separator'));
console.log(t('init.available'));
Object.entries(LANGUAGE_CONFIG).forEach(([code, config], index) => {
console.log(` ${(index + 1).toString().padStart(2)}. ${code} - ${config.name} (${config.nativeName})`);
});
console.log('\n' + t('init.defaultLanguages', { languages: this.config.defaultLanguages.join(', ') }));
const answer = await ask('\n' + t('init.enterLanguageCodes'));
if (answer.trim() === '') {
return this.config.defaultLanguages;
}
const selectedLanguages = answer.split(',').map(lang => lang.trim().toLowerCase());
const validLanguages = selectedLanguages.filter(lang => LANGUAGE_CONFIG[lang]);
const invalidLanguages = selectedLanguages.filter(lang => !LANGUAGE_CONFIG[lang]);
if (invalidLanguages.length > 0) {
console.warn(t('init.warningInvalidLanguageCodes', { languages: invalidLanguages.join(', ') }));
}
return validLanguages.length > 0 ? validLanguages : this.config.defaultLanguages;
}
// Interactive setup configuration (internationalized)
async promptSetupConfiguration(skipPrompt = false) {
if (skipPrompt || !process.stdin.isTTY) {
return { structure: 'modular', duplicateStructure: true };
}
const { ask } = require('../utils/cli');
console.log('\n' + t('init.setup.title'));
console.log(t('common.separator'));
// Determine recommended option
const recommended = ' (recommended)';
console.log(t('init.setup.question'));
console.log(' 1. ' + t('init.setup.opt_single') + (this.config.structure === 'single' ? recommended : ''));
console.log(' 2. ' + t('init.setup.opt_modular') + (this.config.structure !== 'single' ? recommended : ''));
console.log(' 3. ' + t('init.setup.opt_existing'));
const structureChoice = await ask('\n' + t('init.setup.choice_prompt'));
let structure = 'modular';
if (structureChoice === '1') structure = 'single';
else if (structureChoice === '2') structure = 'modular';
else structure = 'existing';
let duplicateStructure = true;
let perLanguage = [];
if (structure !== 'existing') {
const duplicateChoice = await ask('\n' + t('init.setup.apply_all_prompt'));
duplicateStructure = parseConfirmation(duplicateChoice, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: true });
if (!duplicateStructure) {
// Prompt for languages to include/exclude
console.log(t('init.setup.per_language_intro'));
const available = Object.keys(LANGUAGE_CONFIG).join(', ');
console.log(t('init.setup.available_languages', { languages: available }));
const includeAns = await ask(t('init.setup.include_prompt'));
perLanguage = includeAns.split(',').map(l => l.trim().toLowerCase()).filter(Boolean);
}
}
return { structure, duplicateStructure, perLanguage };
}
// Enhanced initialization with dependency checking
async initialize(hasI18n = true, args = {}) {
console.log(t('init.initializingProject'));
// Preserve noPrompt setting in config
this.config.noPrompt = args.noPrompt || this.config.noPrompt;
if (!hasI18n) {
console.log(t('init.warningProceedingWithoutFramework'));
console.log(t('init.translationFilesCreatedWarning'));
}
// Get setup configuration
const setupConfig = await this.promptSetupConfiguration(args.noPrompt);
// Handle directory selection and structure setup
const selectedDir = await this.detectAndSelectDirectory(args.noPrompt);
if (selectedDir) {
this.config.sourceDir = selectedDir;
this.sourceDir = path.resolve(selectedDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
if (!this.announcedExistingDir) {
console.log(t('init.usingExistingDirectory', { dir: selectedDir }));
this.announcedExistingDir = true;
}
} else {
await this.setupInitialStructure(args.noPrompt);
}
// Validate source
this.validateSource();
// Prompt for admin PIN setup if not already configured
const securitySettings = configManager.getConfig().security || {};
if (!securitySettings.adminPinEnabled && securitySettings.adminPinPromptOnInit !== false && !args.noPrompt) {
const { flushStdout } = require('../utils/cli');
await flushStdout();
await this.promptAdminPinSetup();
}
const backupSettings = await this.promptBackupConfiguration(args.noPrompt);
if (backupSettings) {
await configManager.updateConfig({
backup: {
...(this.config.backup || {}),
...backupSettings
}
});
this.config.backup = { ...(this.config.backup || {}), ...backupSettings };
} else if (!this.config.backup) {
this.config.backup = { enabled: false, maxBackups: 1, location: './i18ntk-backups' };
}
// Get target languages - use args.languages if provided
let targetLanguages = args.languages || await this.selectLanguages(args.noPrompt);
// Ensure targetLanguages is always an array
targetLanguages = Array.isArray(targetLanguages) ? targetLanguages : [];
if (targetLanguages.length === 0) {
console.log(t('init.noTargetLanguagesSpecified'));
return;
}
console.log('\n' + t('init.targetLanguages', { languages: targetLanguages.map(lang => `${lang} (${LANGUAGE_CONFIG[lang]?.name || 'Unknown'})`).join(', ') }));
// Get source files
const sourceFiles = this.getSourceFiles();
console.log('\n' + t('init.foundSourceFiles', { count: sourceFiles.length }));
// Process each language
const results = {};
for (const targetLanguage of targetLanguages) {
console.log('\n' + t('init.processingLanguage', { language: targetLanguage, name: LANGUAGE_CONFIG[targetLanguage]?.name || 'Unknown' }));
const languageResults = {
files: [],
totalStats: { total: 0, translated: 0, missing: 0 }
};
for (const sourceFile of sourceFiles) {
const sourceFilePath = path.join(this.sourceLanguageDir, sourceFile);
const validatedSourceFilePath = SecurityUtils.validatePath(sourceFilePath, process.cwd());
if (!validatedSourceFilePath) {
SecurityUtils.logSecurityEvent('Invalid source file path', 'error', { path: sourceFilePath });
continue;
}
const sourceContentRaw = await SecurityUtils.safeReadFile(validatedSourceFilePath, process.cwd());
if (!sourceContentRaw) {
SecurityUtils.logSecurityEvent('Failed to read source file', 'error', { file: validatedSourceFilePath });
continue;
}
const sourceContent = this.format.read(sourceContentRaw);
const targetFilePath = await this.createLanguageFile(sourceFile, targetLanguage, sourceContent);
// Get stats for this file
const targetContentRaw = await SecurityUtils.safeReadFile(targetFilePath, process.cwd());
if (!targetContentRaw) {
SecurityUtils.logSecurityEvent('Failed to read target file for stats', 'error', { file: targetFilePath });
continue;
}
const targetContent = this.format.read(targetContentRaw);
const stats = this.getTranslationStats(targetContent);
languageResults.files.push({
name: sourceFile,
path: targetFilePath,
stats
});
// Add to total stats
languageResults.totalStats.total += stats.total;
languageResults.totalStats.translated += stats.translated;
languageResults.totalStats.missing += stats.missing;
console.log(t('init.fileProcessingResult', { file: sourceFile, translated: stats.translated, total: stats.total, percentage: stats.percentage }));
}
// Calculate overall percentage
languageResults.totalStats.percentage = languageResults.totalStats.total > 0
? Math.round((languageResults.totalStats.translated / languageResults.totalStats.total) * 100)
: 0;
results[targetLanguage] = languageResults;
console.log(t('init.overallProgress', { translated: languageResults.totalStats.translated, total: languageResults.totalStats.total, percentage: languageResults.totalStats.percentage }));
}
// Generate and display completion summary
await this.generateCompletionSummary(results, targetLanguages);
console.log('\n' + t('init.initializationCompletedSuccessfully'));
console.log('\n' + t('init.nextStepsTitle'));
console.log(t('init.nextStep1'));
console.log(t('init.nextStep2'));
console.log(t('init.nextStep3'));
}
// Generate completion summary with proper error handling
async generateCompletionSummary(results, targetLanguages) {
return await this.generateLocalizedCompletionSummary(results, targetLanguages);
}
async generateLocalizedCompletionSummary(results, targetLanguages) {
try {
console.log('\n' + '='.repeat(50));
console.log(t('init.initializationSummaryTitle'));
console.log(t('common.separator'));
let totalChanges = 0;
let languagesProcessed = 0;
let missingKeysAdded = 0;
Object.entries(results || {}).forEach(([lang, data]) => {
if (!data || typeof data !== 'object') return;
const langName = LANGUAGE_CONFIG[lang]?.name || 'Unknown';
const stats = data.totalStats || { total: 0, translated: 0, percentage: 0, missing: 0 };
const statusIcon = stats.percentage === 100 ? '✅' : stats.percentage >= 80 ? '🟡' : '🔴';
console.log(t('init.languageSummary', {
icon: statusIcon,
name: langName,
code: lang,
percentage: stats.percentage || 0,
}));
if (Array.isArray(data.files)) {
console.log(t('init.languageFiles', { count: data.files.length }));
}
console.log(t('init.languageKeys', { translated: stats.translated || 0, total: stats.total || 0 }));
console.log(t('init.languageMissing', { count: stats.missing || 0 }));
totalChanges += (stats.translated || 0) + (stats.missing || 0);
languagesProcessed += 1;
missingKeysAdded += stats.missing || 0;
});
console.log('\n' + t('init.completionSummaryTitle'));
console.log(t('common.separator'));
console.log(t('init.totalChanges', { count: totalChanges }));
console.log(t('init.languagesProcessed', { count: languagesProcessed }));
console.log(t('init.missingKeysAdded', { count: missingKeysAdded }));
if (process.stdin.isTTY && !this.config?.noPrompt) {
const { ask } = require('../utils/cli');
const generateReport = await ask('\n' + t('init.reportPrompt'));
if (parseConfirmation(generateReport, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: true })) {
await this.generateDetailedReport(results, targetLanguages);
}
}
} catch (error) {
console.error('\n' + t('init.completionError', { error: error.message }));
console.log(t('init.completionSummaryBasicTitle'));
console.log(t('common.separator'));
console.log(t('init.languagesProcessed', { count: Object.keys(results || {}).length }));
}
}
// Generate detailed report
async generateDetailedReport(results, targetLanguages) {
try {
const outputDir = this.con