UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

516 lines (469 loc) 21.9 kB
/** * Framework Detection Service * Handles framework detection, i18n directory detection, and framework suggestions * @module services/FrameworkDetectionService */ const path = require('path'); const fs = require('fs'); const SecurityUtils = require('../../../utils/security'); module.exports = class FrameworkDetectionService { constructor(config = {}) { this.config = config; this.settings = null; this.configManager = null; } /** * Initialize the service with required dependencies * @param {Object} configManager - Configuration manager instance */ initialize(configManager) { this.configManager = configManager; this.settings = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {}); } /** * Detect environment and framework from project structure * @returns {Promise<Object>} Object with detectedLanguage and detectedFramework */ async detectEnvironmentAndFramework() { // Defensive check to ensure SecurityUtils is available if (!SecurityUtils) { throw new Error('SecurityUtils is not available. This may indicate a module loading issue.'); } const packageJsonPath = path.join(process.cwd(), 'package.json'); const pyprojectPath = path.join(process.cwd(), 'pyproject.toml'); const requirementsPath = path.join(process.cwd(), 'requirements.txt'); const goModPath = path.join(process.cwd(), 'go.mod'); const pomPath = path.join(process.cwd(), 'pom.xml'); const composerPath = path.join(process.cwd(), 'composer.json'); const cargoTomlPath = path.join(process.cwd(), 'Cargo.toml'); let detectedLanguage = 'generic'; let detectedFramework = 'generic'; if (SecurityUtils.safeExistsSync(packageJsonPath)) { detectedLanguage = 'javascript'; try { const packageJson = JSON.parse(SecurityUtils.safeReadFileSync(packageJsonPath, path.dirname(packageJsonPath), 'utf8')); const deps = { ...(packageJson.dependencies || {}), ...(packageJson.devDependencies || {}), ...(packageJson.peerDependencies || {}) }; // Check for i18ntk-runtime first (check both package names) const hasI18nTkRuntime = deps['i18ntk-runtime'] || deps['i18ntk/runtime']; // Check for common i18n patterns in source code if not found in package.json if (!hasI18nTkRuntime) { const i18nPatterns = [ /i18n\.t\(['\"`]/, /useI18n\(/, /from ['\"]i18ntk[\/\\]runtime['\"]/, /require\(['\"]i18ntk[\/\\]runtime['\"]\)/ ]; const sourceFiles = await this.customGlob(['src/**/*.{js,jsx,ts,tsx}'], { cwd: process.cwd(), ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'] }); for (const file of sourceFiles) { try { const content = await fs.promises.readFile(path.join(process.cwd(), file), 'utf8'); if (i18nPatterns.some(pattern => pattern.test(content))) { detectedFramework = 'i18ntk-runtime'; break; } } catch (e) { // Skip files we can't read continue; } } } else { detectedFramework = 'i18ntk-runtime'; } // Only check other frameworks if i18ntk-runtime wasn't detected if (detectedFramework !== 'i18ntk-runtime') { if (deps.next || deps['next-intl'] || deps['next-i18next']) detectedFramework = 'nextjs'; else if (deps.react || deps['react-dom']) detectedFramework = 'react'; else if (deps['@angular/core']) detectedFramework = 'angular'; else if (deps.vue || deps['vue-router']) detectedFramework = 'vue'; else if (deps.nuxt || deps['@nuxtjs/i18n']) detectedFramework = 'nuxt'; else if (deps.svelte) detectedFramework = 'svelte'; else if (deps.astro || deps['astro-i18next'] || deps['@astrojs/i18n']) detectedFramework = 'astro'; else if (deps['@builder.io/qwik'] || deps['qwik-speak'] || deps['qwik-i18n']) detectedFramework = 'qwik'; else if (deps.gatsby || deps['gatsby-plugin-react-i18next'] || deps['gatsby-plugin-intl']) detectedFramework = 'gatsby'; else if (deps['@remix-run/react'] || deps['remix-i18next'] || deps['i18next-remix']) detectedFramework = 'remix'; else if (deps['solid-js'] || deps['@solid-primitives/i18n']) detectedFramework = 'solid'; else if (deps['ember-source'] || deps['ember-intl']) detectedFramework = 'ember'; else if (deps['react-native'] || deps['react-native-localize']) detectedFramework = 'react-native'; else if (deps['expo-localization'] || deps.expo) detectedFramework = 'expo'; else if (deps['@ionic/angular'] || deps['ionic-react'] || deps['@ionic/vue']) detectedFramework = 'ionic'; else detectedFramework = 'generic'; } } catch (error) { detectedFramework = 'generic'; } } else if (SecurityUtils.safeExistsSync(pyprojectPath) || SecurityUtils.safeExistsSync(requirementsPath)) { detectedLanguage = 'python'; try { if (SecurityUtils.safeExistsSync(requirementsPath)) { const requirements = SecurityUtils.safeReadFileSync(requirementsPath, path.dirname(requirementsPath), 'utf8'); if (requirements.includes('django')) detectedFramework = 'django'; else if (requirements.includes('flask')) detectedFramework = 'flask'; else if (requirements.includes('fastapi')) detectedFramework = 'fastapi'; else detectedFramework = 'generic'; } } catch (error) { detectedFramework = 'generic'; } } else if (SecurityUtils.safeExistsSync(goModPath)) { detectedLanguage = 'go'; detectedFramework = 'generic'; } else if (SecurityUtils.safeExistsSync(pomPath)) { detectedLanguage = 'java'; try { const pomContent = SecurityUtils.safeReadFileSync(pomPath, path.dirname(pomPath), 'utf8'); if (pomContent.includes('spring-boot')) detectedFramework = 'spring-boot'; else if (pomContent.includes('spring')) detectedFramework = 'spring'; else if (pomContent.includes('quarkus')) detectedFramework = 'quarkus'; else detectedFramework = 'generic'; } catch (error) { detectedFramework = 'generic'; } } else if (SecurityUtils.safeExistsSync(composerPath)) { detectedLanguage = 'php'; try { const composer = JSON.parse(SecurityUtils.safeReadFileSync(composerPath, path.dirname(composerPath), 'utf8')); const deps = composer.require || {}; if (deps['laravel/framework']) detectedFramework = 'laravel'; else if (deps['symfony/framework-bundle']) detectedFramework = 'symfony'; else if (deps['wordpress']) detectedFramework = 'wordpress'; else detectedFramework = 'generic'; } catch (error) { detectedFramework = 'generic'; } } else if (SecurityUtils.safeExistsSync(cargoTomlPath)) { detectedLanguage = 'rust'; try { const cargoContent = SecurityUtils.safeReadFileSync(cargoTomlPath, path.dirname(cargoTomlPath), 'utf8'); if (cargoContent.includes('fluent') || cargoContent.includes('fluent-rs')) detectedFramework = 'fluent'; else if (cargoContent.includes('gettext')) detectedFramework = 'gettext-rs'; else detectedFramework = 'generic'; } catch (error) { detectedFramework = 'generic'; } } return { detectedLanguage, detectedFramework }; } /** * Get framework suggestions for a given language * @param {string} language - The programming language * @returns {Array} Array of framework suggestions with name and description */ getFrameworkSuggestions(language) { const suggestions = { javascript: [ { name: 'i18next', description: 'Feature-rich i18n framework for JavaScript' }, { name: 'react-i18next', description: 'React integration for i18next' }, { name: 'next-intl', description: 'Next.js i18n integration' }, { name: 'remix-i18next', description: 'Remix i18n integration' }, { name: 'gatsby-plugin-react-i18next', description: 'Gatsby i18n integration' }, { name: 'vue-i18n', description: 'Vue.js i18n plugin' }, { name: 'Angular i18n', description: 'Built-in Angular i18n' }, { name: 'svelte-i18n', description: 'Svelte i18n library' }, { name: 'astro-i18next', description: 'Astro i18n integration' }, { name: 'qwik-speak', description: 'Qwik i18n library' }, { name: 'solid-i18n', description: 'SolidJS i18n library' }, { name: 'ember-intl', description: 'Ember i18n library' }, { name: 'react-native-localize', description: 'React Native localization' }, { name: 'ionic-angular', description: 'Ionic i18n support' } ], typescript: [ { name: 'i18next', description: 'TypeScript-first i18n framework' }, { name: 'react-i18next', description: 'React + TypeScript integration' }, { name: 'next-intl', description: 'Next.js + TypeScript i18n' }, { name: 'vue-i18n', description: 'Vue.js i18n with TypeScript support' }, { name: 'angular i18n', description: 'Angular i18n with TypeScript' }, { name: 'astro-i18next', description: 'Astro i18n with TypeScript' }, { name: 'qwik-speak', description: 'Qwik i18n with TypeScript' } ], python: [ { name: 'Django i18n', description: 'Built-in Django internationalization' }, { name: 'Flask-Babel', description: 'Babel integration for Flask' }, { name: 'FastAPI i18n', description: 'i18n middleware for FastAPI' } ], java: [ { name: 'Spring i18n', description: 'Spring Framework internationalization' }, { name: 'Spring Boot i18n', description: 'Spring Boot auto-configuration' }, { name: 'Quarkus i18n', description: 'Quarkus internationalization support' } ], go: [ { name: 'go-i18n', description: 'Go i18n library with pluralization' }, { name: 'nicksnyder/go-i18n', description: 'Feature-rich Go i18n' } ], rust: [ { name: 'fluent', description: 'Project Fluent localization for Rust' }, { name: 'fluent-rs', description: 'Rust implementation of Project Fluent' }, { name: 'gettext-rs', description: 'GNU gettext bindings for Rust' } ], php: [ { name: 'Laravel i18n', description: 'Built-in Laravel localization' }, { name: 'Symfony Translation', description: 'Symfony translation component' }, { name: 'WordPress i18n', description: 'WordPress localization functions' } ] }; return suggestions[language] || suggestions.javascript; } /** * Handle framework detection and prompting logic * @param {Object} prompt - Prompt interface for user interaction * @param {Object} cfg - Configuration object * @param {string} currentVersion - Current version of the tool * @returns {Promise<Object>} Updated configuration */ async maybePromptFramework(prompt, cfg, currentVersion) { // Load current settings to check framework configuration let settings = this.settings || (this.configManager.loadSettings ? this.configManager.loadSettings() : (this.configManager.getConfig ? this.configManager.getConfig() : {})); // Ensure framework configuration exists with all required fields if (!settings.framework) { settings.framework = { detected: false, preference: null, prompt: 'always', lastPromptedVersion: null, installed: [], version: '1.0' // Schema version for future compatibility }; // Save the updated settings if (this.configManager.saveSettings) { await this.configManager.saveSettings(settings); } else if (this.configManager.saveConfig) { await this.configManager.saveConfig(settings); } } // Reload settings to ensure we have the latest framework detection results const freshSettings = this.configManager.loadSettings ? this.configManager.loadSettings() : (this.configManager.getConfig ? this.configManager.getConfig() : {}); if (freshSettings.framework) { settings.framework = { ...settings.framework, ...freshSettings.framework }; } // Check if framework is already detected or preference is explicitly set to none if (settings.framework.detected || settings.framework.preference === 'none') { return cfg; } // Check if DNR (Do Not Remind) is active for this version if (settings.framework.prompt === 'suppress' && settings.framework.lastPromptedVersion === currentVersion) { return cfg; } // Reset DNR if version changed if (settings.framework.prompt === 'suppress' && settings.framework.lastPromptedVersion !== currentVersion) { settings.framework.prompt = 'always'; settings.framework.lastPromptedVersion = null; // Save the updated settings if (this.configManager.saveSettings) { await this.configManager.saveSettings(settings); } else if (this.configManager.saveConfig) { await this.configManager.saveConfig(settings); } } // This function is now handled by ensureInitializedOrExit for better flow control return cfg; } /** * Auto-detect i18n directory from common locations only if not configured in settings * @returns {Object} Updated configuration with detected i18n directory */ detectI18nDirectory() { const settings = this.settings || (this.configManager.loadSettings ? this.configManager.loadSettings() : (this.configManager.getConfig ? this.configManager.getConfig() : {})); const projectRoot = path.resolve(settings.projectRoot || this.config.projectRoot || '.'); const fs = require('fs'); // Use per-script directory configuration if available, fallback to global sourceDir const sourceDir = settings.scriptDirectories?.manage || settings.sourceDir; if (sourceDir) { this.config.sourceDir = path.resolve(projectRoot, sourceDir); return; } // Define possible i18n paths for auto-detection const possibleI18nPaths = [ './locales', './src/locales', './src/i18n', './src/i18n/locales', './app/locales', './app/i18n', './public/locales', './assets/locales', './translations', './lang' ]; // Only auto-detect if no settings are configured for (const possiblePath of possibleI18nPaths) { const resolvedPath = path.resolve(projectRoot, possiblePath); if (SecurityUtils.safeExistsSync(resolvedPath)) { // Check if it contains language directories try { const items = fs.readdirSync(resolvedPath); const hasLanguageDirs = items.some(item => { const itemPath = path.join(resolvedPath, item); return fs.statSync(itemPath).isDirectory() && ['en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'sv', 'uk', 'cs', 'tr', 'ru', 'ja', 'ko', 'zh', 'ar', 'hi', 'th', 'vi', 'he', 'el', 'hu'].includes(item); }); if (hasLanguageDirs) { this.config.sourceDir = possiblePath; // Note: Translation function would need to be injected // t('init.autoDetectedI18nDirectory', { path: possiblePath }); break; } } catch (error) { // Continue checking other paths } } } } /** * Check if i18n framework is installed - configuration-based check without prompts * @param {Object} ui - UI instance for translations (optional) * @returns {Promise<boolean>} True if frameworks detected, false otherwise */ async checkI18nDependencies(ui = null) { const packageJsonPath = path.resolve('./package.json'); if (!SecurityUtils.safeExistsSync(packageJsonPath)) { if (ui && ui.t) { console.log(ui.t('errors.noPackageJson')); } else { console.log('No package.json found'); } return false; // Treat as no framework detected } 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', 'next-intl', 'next-i18next', 'remix-i18next', 'i18next-remix', 'gatsby-plugin-react-i18next', 'gatsby-plugin-intl', 'vue-i18n', 'angular-i18n', 'i18next', 'svelte-i18n', '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', '@nuxtjs/i18n', 'formatjs', '@lingui/core', 'i18ntk-runtime', 'i18ntk/runtime' ]; const installedFrameworks = i18nFrameworks.filter(framework => dependencies[framework]); if (installedFrameworks.length > 0) { if (ui && ui.t) { console.log(ui.t('init.detectedFrameworks', { frameworks: installedFrameworks.join(', ') })); } else { console.log(`Detected frameworks: ${installedFrameworks.join(', ')}`); } const cfg = this.settings || (this.configManager.loadSettings ? this.configManager.loadSettings() : (this.configManager.getConfig ? this.configManager.getConfig() : {})); cfg.framework = cfg.framework || {}; cfg.framework.detected = true; cfg.framework.installed = installedFrameworks; if (this.configManager.saveSettings) { this.configManager.saveSettings(cfg); } else if (this.configManager.saveConfig) { this.configManager.saveConfig(cfg); } return true; } else { const cfg = this.settings || (this.configManager.loadSettings ? this.configManager.loadSettings() : (this.configManager.getConfig ? this.configManager.getConfig() : {})); if (cfg.framework) { cfg.framework.detected = false; if (this.configManager.saveSettings) { this.configManager.saveSettings(cfg); } else if (this.configManager.saveConfig) { this.configManager.saveConfig(cfg); } } // Always signal that frameworks were not detected return false; } } catch (error) { // Note: Translation function would need to be injected // console.log(t('init.errors.packageJsonRead')); console.log('Error reading package.json'); return false; // Treat as no framework detected on error } } /** * Custom glob implementation using Node.js built-in modules (zero dependencies) * @param {string[]} patterns - Array of glob patterns * @param {Object} options - Options object with cwd and ignore properties * @returns {Promise<string[]>} Array of matching file paths */ async customGlob(patterns, options = {}) { const cwd = options.cwd || process.cwd(); const ignorePatterns = options.ignore || []; function matchesPattern(filename, pattern) { // Simple pattern matching for **/*.{js,jsx,ts,tsx} style patterns if (pattern.includes('**/*')) { const extensionPart = pattern.split('*.')[1]; if (extensionPart) { const extensions = extensionPart.replace('{', '').replace('}', '').split(','); return extensions.some(ext => filename.endsWith('.' + ext.trim())); } } return filename.includes(pattern.replace('**/', '')); } function shouldIgnore(filePath) { return ignorePatterns.some(pattern => { if (pattern.includes('**/')) { const patternEnd = pattern.replace('**/', ''); return filePath.includes('/' + patternEnd) || filePath.includes('\\' + patternEnd); } return filePath.includes(pattern); }); } function findFiles(dir, results = []) { try { const items = fs.readdirSync(dir); for (const item of items) { const fullPath = path.join(dir, item); const relativePath = path.relative(cwd, fullPath); if (shouldIgnore(relativePath)) { continue; } try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { findFiles(fullPath, results); } else if (stat.isFile()) { // Check if file matches any of our patterns for (const pattern of patterns) { if (matchesPattern(item, pattern)) { results.push(relativePath); break; } } } } catch (error) { // Skip files we can't access continue; } } } catch (error) { // Skip directories we can't access } return results; } return findFiles(cwd); } };