├── .gitignore ├── .phpunit.cache └── test-results ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── texts.php ├── phpunit.xml ├── phpunit.xml.bak ├── src ├── Commands │ ├── MakeTranslatorCommand.php │ ├── ScanTranslationsCommand.php │ └── stubs │ │ └── translator.stub ├── Contracts │ └── TranslatorInterface.php ├── LaratextServiceProvider.php ├── Text.php ├── Translator.php ├── Translators │ ├── GoogleTranslator.php │ └── OpenAITranslator.php └── helpers.php └── tests ├── TestCase.php └── Unit ├── GoogleTranslatorTest.php ├── OpenAITranslatorTest.php ├── ScanTranslationsCommandTest.php └── TextTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .phpunit.result.cache -------------------------------------------------------------------------------- /.phpunit.cache/test-results: -------------------------------------------------------------------------------- 1 | {"version":1,"defects":{"ScanTranslationsCommandTest::it_scans_and_writes_translations_to_file":7},"times":{"GoogleTranslatorTest::it_translates_text_to_multiple_languages":0.052,"GoogleTranslatorTest::it_translates_many_texts_to_multiple_languages":0.001,"OpenAITranslatorTest::it_translates_text_to_multiple_languages":0.001,"OpenAITranslatorTest::it_translates_many_texts_to_multiple_languages":0,"ScanTranslationsCommandTest::it_scans_and_writes_translations_to_file":0.135,"TextTest::it_returns_default_value_if_translation_missing":0.002,"TextTest::it_returns_helper_function_value_if_translation_missing":0,"TextTest::it_renders_blade_directive_with_default_value":0.007,"TextTest::it_returns_existing_translation_from_file":0.002}} -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Edu Lazaro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laratext for Laravel 2 | 3 |

4 | Total Downloads 5 | Latest Stable Version 6 |

7 | 8 | 9 | ## Introduction 10 | 11 | Laratext is a Laravel package designed to manage and auto-translate your application's text strings. In laravel, when using the `__` gettext helper method you specify the translation or the key. Both options have issues. If you specify the key, the file becomes difficult to read, as you don't know what's there. If you specify the text, your translations will break if you change a single character. With Laratext you specify both the key and the text, making it useful and readable. 12 | 13 | It also allows you to seamlessly integrate translation services (like OpenAI or Google Translate) into your Laravel application to automatically translate missing translation keys across multiple languages. 14 | 15 | It includes these features: 16 | 17 | * Simplifies working with language files in Laravel. 18 | * Auto-translate missing translation keys to multiple languages. 19 | * Supports multiple translation services (e.g., OpenAI, Google Translate). 20 | * Easy-to-use Blade directive (@text) and helper functions (text()). 21 | * Commands to scan and update translation files. 22 | 23 | ## Installation 24 | 25 | Execute the following command in your Laravel root project directory: 26 | 27 | ```bash 28 | composer require edulazaro/laratext 29 | ``` 30 | 31 | To publish the configuration run: 32 | 33 | ```bash 34 | php artisan vendor:publish --tag="texts" 35 | ``` 36 | 37 | Or if for some reason it does not work: 38 | 39 | ```bash 40 | php artisan vendor:publish --provider="EduLazaro\Laratext\LaratextServiceProvider" --tag="texts" 41 | ``` 42 | 43 | This will generate the `texts.php` configuration file in the `config` folder. 44 | 45 | ## Configuration 46 | 47 | The `texts.php` configuration file contains all the settings for the package, including API keys for translation services, supported languages, and more. 48 | 49 | Example of the configuration (`config/texts.php`): 50 | 51 | ```php 52 | return [ 53 | // Default Translator 54 | 'default_translator' => EduLazaro\Laratext\Translators\OpenAITranslator::class, 55 | 56 | // Translator Services 57 | 'translators' => [ 58 | 'openai' => EduLazaro\Laratext\Translators\OpenAITranslator::class, 59 | 'google' => EduLazaro\Laratext\Translators\GoogleTranslator::class, 60 | ], 61 | 62 | // OpenAI Configuration 63 | 'openai' => [ 64 | 'api_key' => env('OPENAI_API_KEY'), 65 | 'model' => env('OPENAI_MODEL', 'gpt-3.5-turbo'), 66 | 'timeout' => 10, 67 | 'retries' => 3, 68 | ], 69 | 70 | // Google Translator Configuration 71 | 'google' => [ 72 | 'api_key' => env('GOOGLE_TRANSLATOR_API_KEY'), 73 | 'timeout' => 10, 74 | 'retries' => 3, 75 | ], 76 | 77 | // List the supported languages for translations. 78 | 'languages' => [ 79 | 'en' => 'English', 80 | 'es' => 'Spanish', 81 | 'fr' => 'French', 82 | ], 83 | ]; 84 | ``` 85 | 86 | This configuration allows you to define your translation services, API keys, and the supported languages in your Laravel application. 87 | 88 | This is an example of the `.env`: 89 | 90 | ``` 91 | OPENAI_API_KEY=your_openai_api_key 92 | GOOGLE_TRANSLATOR_API_KEY=your_google_api_key 93 | ``` 94 | 95 | ## Usage 96 | 97 | Here is how you can use the blade directive and the `text` function: 98 | 99 | Use the `text()` helper function to fetch translations within your PHP code. 100 | 101 | ```php 102 | text('key_name', 'default_value'); 103 | ``` 104 | 105 | Use the `@text` Blade directive to fetch translations within your views. 106 | 107 | ```php 108 | @text('key_name', 'default_value') 109 | ``` 110 | 111 | ## Scanning Translations 112 | 113 | You can use the `laratext:scan` command to scan your project files for missing translation keys and optionally translate them into multiple languages. 114 | 115 | ```php 116 | php artisan laratext:scan --write --lang=es --translator=google 117 | ``` 118 | 119 | These are the command Options: 120 | 121 | * `--write`: Write the missing keys to the language files. 122 | * `--lang`: Target a specific language for translation (e.g., es for Spanish). 123 | * `--dry` Perform a dry run (do not write). 124 | * `--diff`: Show the diff of the changes made. 125 | * `--translator`: Specify the translator service to use (e.g., openai or google). 126 | 127 | 128 | ## Creating translators 129 | 130 | To create a custom translator, you need to implement the `TranslatorInterface`. This will define the structure and method that will handle the translation. 131 | 132 | To facilitate the creation of custom translators, you can create a `make:translator` command that will generate the required files for a new translator class. 133 | 134 | To create a translator run: 135 | 136 | ```bash 137 | php artisan make:translator BeautifulTranslator 138 | ``` 139 | 140 | This will create the `BeautifulTranslator.php` file in the `app/Translators` directory: 141 | 142 | ```php 143 | namespace App\Translators; 144 | 145 | use EduLazaro\Laratext\Contracts\TranslatorInterface; 146 | 147 | class BeautifulTranslator implements TranslatorInterface 148 | { 149 | public function translate(string $text, string $from, array $to): array 150 | { 151 | // TODO: Implement your translation logic here. 152 | 153 | $results = []; 154 | 155 | foreach ($to as $language) { 156 | $results[$language] = $text; // Dummy return same text 157 | } 158 | 159 | return $results; 160 | } 161 | } 162 | ``` 163 | 164 | The `translate` method, which translates a single string into one or more target languages, is required: 165 | 166 | ``` 167 | translate(string $text, string $from, array $to): array 168 | ``` 169 | 170 | Optionally, you can implement the `translateMany` method to translate multiple texts in batch, which can improve performance when supported by the translation API: 171 | 172 | ``` 173 | translateMany(array $texts, string $from, array $to): array 174 | ``` 175 | 176 | If `translateMany` is not implemented, only single-string translations (translate) will be available for batch processing. For full support, both methods are recommended, so there are less requests and create a cost effective solution. 177 | 178 | ## License 179 | 180 | Larakeep is open-sourced software licensed under the [MIT license](LICENSE.md). 181 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edulazaro/laratext", 3 | "description": "Laravel localization package", 4 | "license": "MIT", 5 | "type": "library", 6 | "require": { 7 | "php": "^8.2", 8 | "laravel/framework": ">=10.0", 9 | "guzzlehttp/guzzle": "^7.0" 10 | }, 11 | "autoload": { 12 | "psr-4": { 13 | "EduLazaro\\Laratext\\": "src/" 14 | }, 15 | "files": [ 16 | "src/helpers.php" 17 | ] 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "EduLazaro\\Laratext\\Tests\\": "tests/" 22 | } 23 | }, 24 | "authors": [ 25 | { 26 | "name": "Edu Lazaro", 27 | "email": "edu@edulazaro.com" 28 | } 29 | ], 30 | "extra": { 31 | "laravel": { 32 | "providers": [ 33 | "EduLazaro\\Laratext\\LaratextServiceProvider" 34 | ] 35 | } 36 | }, 37 | "require-dev": { 38 | "phpunit/phpunit": "^10.5", 39 | "orchestra/testbench": "^8.34" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /config/texts.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'openai' => EduLazaro\Laratext\Translators\OpenAITranslator::class, 7 | 'google' => EduLazaro\Laratext\Translators\GoogleTranslator::class, 8 | ], 9 | 10 | /* 11 | |-------------------------------------------------------------------------- 12 | | Default Translator 13 | |-------------------------------------------------------------------------- 14 | | 15 | | This option controls the default translator to use when running the 16 | | translation commands. You can later create other translators 17 | | like DeeplTranslator, GoogleTranslator, etc. 18 | | 19 | */ 20 | 21 | 'default_translator' => 'openai', 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | OpenAI Configuration 26 | |-------------------------------------------------------------------------- 27 | | 28 | | Here you can configure the OpenAI translator service, including your 29 | | API key, preferred model, request timeout, and retry attempts. 30 | | 31 | */ 32 | 33 | 'openai' => [ 34 | 'api_key' => env('OPENAI_API_KEY'), 35 | 'model' => env('OPENAI_MODEL', 'gpt-3.5-turbo'), 36 | 'timeout' => 10, 37 | 'retries' => 3, 38 | ], 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Google Translator Configuration 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Here you can configure the Google Cloud Translation API, including 46 | | your API key, request timeout, and retry attempts. 47 | | 48 | */ 49 | 50 | 'google' => [ 51 | 'api_key' => env('GOOGLE_TRANSLATOR_API_KEY'), 52 | 'timeout' => 10, 53 | 'retries' => 3, 54 | ], 55 | 56 | /* 57 | |-------------------------------------------------------------------------- 58 | | Languages 59 | |-------------------------------------------------------------------------- 60 | | 61 | | Define your supported languages for translation. 62 | | The keys are the language codes, and the values are the readable names. 63 | | 64 | */ 65 | 66 | 'languages' => [ 67 | 'en' => 'English', 68 | 'es' => 'Spanish', 69 | 'fr' => 'French', 70 | ], 71 | ]; 72 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /phpunit.xml.bak: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | tests 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Commands/MakeTranslatorCommand.php: -------------------------------------------------------------------------------- 1 | argument('name')); 17 | 18 | if (!preg_match('/^[A-Z][A-Za-z0-9_]+$/', $name)) { 19 | $this->error('The name must be a valid class name (StudlyCase).'); 20 | return; 21 | } 22 | 23 | $namespace = 'App\\Translators'; 24 | $className = $name; 25 | $path = app_path('Translators/' . $className . '.php'); 26 | 27 | if (file_exists($path)) { 28 | $this->error('❌ Translator already exists!'); 29 | return; 30 | } 31 | 32 | $stubPath = __DIR__ . '/stubs/translator.stub'; 33 | 34 | if (!file_exists($stubPath)) { 35 | $this->error('❌ Stub file not found: ' . $stubPath); 36 | return; 37 | } 38 | 39 | $stub = file_get_contents($stubPath); 40 | 41 | $content = str_replace( 42 | ['{{ namespace }}', '{{ class }}'], 43 | [$namespace, $className], 44 | $stub 45 | ); 46 | 47 | (new Filesystem)->ensureDirectoryExists(app_path('Translators')); 48 | file_put_contents($path, $content); 49 | 50 | $this->info("✅ Translator created successfully at: {$path}"); 51 | $this->info("💡 Tip: Add your translator to the texts.php config to use it!"); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Commands/ScanTranslationsCommand.php: -------------------------------------------------------------------------------- 1 | info('Scanning project for translation keys...'); 31 | 32 | $files = $this->getProjectFiles(); 33 | $texts = $this->extractTextsFromFiles($files); 34 | 35 | if (empty($texts)) { 36 | $this->info('No translation keys found.'); 37 | return; 38 | } 39 | 40 | $this->info('Found ' . count($texts) . ' unique keys.'); 41 | 42 | if ($this->option('dry')) { 43 | return $this->handleDryRun($texts); 44 | } 45 | 46 | $translator = $this->resolveTranslator($this->option('translator')); 47 | 48 | $languages = $this->option('lang') 49 | ? [$this->option('lang')] 50 | : array_keys(config('texts.languages')); 51 | 52 | $defaultLanguage = config('app.locale'); 53 | 54 | // Load existing translations per language 55 | $existingTranslations = []; 56 | foreach ($languages as $lang) { 57 | $path = lang_path("{$lang}.json"); 58 | $existingTranslations[$lang] = file_exists($path) 59 | ? json_decode(file_get_contents($path), true) 60 | : []; 61 | } 62 | 63 | // Determine missing keys per language 64 | $missingTexts = []; 65 | foreach ($texts as $key => $value) { 66 | foreach ($languages as $lang) { 67 | if (!array_key_exists($key, $existingTranslations[$lang])) { 68 | $missingTexts[$key] = $value; 69 | break; 70 | } 71 | } 72 | } 73 | 74 | if (empty($missingTexts)) { 75 | $this->info("No new keys to translate."); 76 | return; 77 | } 78 | 79 | $translations = []; 80 | 81 | if ($translator && method_exists($translator, 'translateMany')) { 82 | $translations = $translator->translateMany($missingTexts, $defaultLanguage, $languages); 83 | } elseif ($translator) { 84 | foreach ($missingTexts as $key => $value) { 85 | $results = $translator->translate($value, $defaultLanguage, $languages); 86 | $translations[$key] = $results; 87 | } 88 | } else { 89 | foreach ($missingTexts as $key => $value) { 90 | $translations[$key] = []; 91 | foreach ($languages as $lang) { 92 | $translations[$key][$lang] = $value; 93 | } 94 | } 95 | } 96 | 97 | foreach ($languages as $lang) { 98 | $path = lang_path("{$lang}.json"); 99 | $current = $existingTranslations[$lang] ?? []; 100 | 101 | foreach ($translations as $key => $langs) { 102 | if (!array_key_exists($key, $current)) { 103 | $current[$key] = $langs[$lang] ?? $key; 104 | } 105 | } 106 | 107 | if ($this->option('diff')) { 108 | $this->info("Diff for {$lang}:"); 109 | foreach ($translations as $key => $langs) { 110 | if (isset($langs[$lang])) { 111 | $this->line("+ \"$key\": \"{$langs[$lang]}\""); 112 | } 113 | } 114 | } 115 | 116 | if ($this->option('write')) { 117 | $directory = dirname($path); 118 | is_dir($directory) || mkdir($directory, 0755, true); 119 | 120 | file_put_contents( 121 | $path, 122 | json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) 123 | ); 124 | 125 | $this->info("Translation file updated: {$path}"); 126 | } else { 127 | $this->info("Run with --write to save changes for {$lang}."); 128 | } 129 | } 130 | 131 | $this->info('All translations processed.'); 132 | } 133 | 134 | /** 135 | * Resolve the translator class from the command option or config. 136 | * 137 | * @param string|null $option 138 | * @return object|null 139 | */ 140 | protected function resolveTranslator(?string $option): ?object 141 | { 142 | if (! $option) { 143 | return null; 144 | } 145 | 146 | $configMap = config('texts.translators', []); 147 | $translatorClass = $configMap[$option] ?? $option; 148 | 149 | return app($translatorClass); 150 | } 151 | 152 | /** 153 | * Extract translation keys from project files. 154 | * 155 | * @param iterable $files 156 | * @return array Key-value pairs of translatable strings. 157 | */ 158 | protected function extractTextsFromFiles(iterable $files): array 159 | { 160 | $keyValuePairs = []; 161 | 162 | foreach ($files as $file) { 163 | $content = file_get_contents($file->getRealPath()); 164 | 165 | preg_match_all("/Text::get\(\s*['\"](.*?)['\"]\s*,\s*['\"](.*?)['\"]/", $content, $matches1); 166 | preg_match_all("/@text\(\s*['\"](.*?)['\"]\s*,\s*['\"](.*?)['\"]/", $content, $matches2); 167 | preg_match_all("/(?)\btext\(\s*['\"](.*?)['\"]\s*,\s*['\"](.*?)['\"]/", $content, $matches3); 168 | 169 | foreach ([$matches1, $matches2, $matches3] as $match) { 170 | foreach ($match[1] as $i => $key) { 171 | $value = $match[2][$i] ?? $key; 172 | $keyValuePairs[$key] = $value; 173 | } 174 | } 175 | } 176 | 177 | return $keyValuePairs; 178 | } 179 | 180 | /** 181 | * Get all PHP and Blade files in the project. 182 | * 183 | * @return Finder 184 | */ 185 | protected function getProjectFiles(): Finder 186 | { 187 | return (new Finder()) 188 | ->in(base_path()) 189 | ->exclude(['vendor', 'node_modules', 'storage', 'bootstrap/cache', 'tests']) 190 | ->name('*.php') 191 | ->name('*.blade.php'); 192 | } 193 | 194 | /** 195 | * Handle a dry run by listing keys that would be added. 196 | * 197 | * @param array $newKeys 198 | * @return void 199 | */ 200 | protected function handleDryRun(array $texts): void 201 | { 202 | $this->info('Dry run: these keys would be added:'); 203 | foreach ($texts as $key => $value) { 204 | $this->line("- $key: $value"); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Commands/stubs/translator.stub: -------------------------------------------------------------------------------- 1 | "; 21 | }); 22 | 23 | $this->publishes([ 24 | __DIR__.'/../config/texts.php' => config_path('texts.php'), 25 | ], 'texts'); 26 | 27 | $this->loadHelpers(); 28 | } 29 | 30 | /** 31 | * Register the helper functions. 32 | * 33 | * @return void 34 | */ 35 | protected function loadHelpers() 36 | { 37 | require_once __DIR__ . '/helpers.php'; 38 | } 39 | 40 | /** 41 | * Register bindings in the container. 42 | * 43 | * @return void 44 | */ 45 | public function register() 46 | { 47 | 48 | $this->commands([ 49 | ScanTranslationsCommand::class, 50 | MakeTranslatorCommand::class, 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Text.php: -------------------------------------------------------------------------------- 1 | originalText] 18 | * @param string $from Source language code 19 | * @param array $to Target language codes 20 | * @return array Result as [key => [lang => translatedText]] 21 | */ 22 | public function batchTranslate(array $texts, string $from, array $to): array 23 | { 24 | $results = []; 25 | $batches = $this->splitByPayloadSize($texts); 26 | 27 | foreach ($batches as $batch) { 28 | $translated = $this->translateMany($batch, $from, $to); 29 | $results = array_merge_recursive($results, $translated); 30 | } 31 | 32 | return $results; 33 | } 34 | 35 | /** 36 | * Split texts into multiple batches based on estimated total character length 37 | * 38 | * @param array $texts 39 | * @return array[] Array of batches: each is [key => text] 40 | */ 41 | protected function splitByPayloadSize(array $texts): array 42 | { 43 | $batches = []; 44 | $currentBatch = []; 45 | $currentLength = 0; 46 | 47 | foreach ($texts as $key => $text) { 48 | $textLength = mb_strlen($text, 'UTF-8'); 49 | 50 | if ($currentLength + $textLength > $this->maxPayloadChars && !empty($currentBatch)) { 51 | $batches[] = $currentBatch; 52 | $currentBatch = []; 53 | $currentLength = 0; 54 | } 55 | 56 | $currentBatch[$key] = $text; 57 | $currentLength += $textLength; 58 | } 59 | 60 | if (!empty($currentBatch)) { 61 | $batches[] = $currentBatch; 62 | } 63 | 64 | return $batches; 65 | } 66 | 67 | /** 68 | * Must be implemented by concrete translators 69 | * Translates a single string into multiple languages 70 | * 71 | * @param string $text 72 | * @param string $from 73 | * @param array $to 74 | * @return array [lang => translatedText] 75 | */ 76 | abstract public function translate(string $text, string $from, array $to): array; 77 | 78 | /** 79 | * Translate a batch of texts. You can override this for optimized batch calls 80 | * Default implementation calls `translate()` for each text 81 | * 82 | * @param array $texts [key => text] 83 | * @param string $from 84 | * @param array $to 85 | * @return array [key => [lang => translation]] 86 | */ 87 | public function translateMany(array $texts, string $from, array $to): array 88 | { 89 | $results = []; 90 | 91 | foreach ($texts as $key => $text) { 92 | $results[$key] = $this->translate($text, $from, $to); 93 | } 94 | 95 | return $results; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Translators/GoogleTranslator.php: -------------------------------------------------------------------------------- 1 | Array of translations indexed by language code. 18 | */ 19 | public function translate(string $text, string $from, array $to): array 20 | { 21 | $results = []; 22 | 23 | foreach ($to as $targetLanguage) { 24 | $results[$targetLanguage] = $this->translateTo($text, $from, $targetLanguage); 25 | } 26 | 27 | return $results; 28 | } 29 | 30 | /** 31 | * Translates multiple strings into multiple target languages. 32 | * 33 | * @param array $texts Array of texts with their corresponding keys. 34 | * @param string $from The source language code (e.g., 'en'). 35 | * @param array $to An array of target language codes (e.g., ['es', 'fr']). 36 | * @return array> Translations indexed by original key and language code. 37 | */ 38 | public function translateMany(array $texts, string $from, array $to): array 39 | { 40 | $apiKey = config('texts.google.api_key'); 41 | $timeout = config('texts.google.timeout', 10); 42 | $maxRetries = config('texts.google.retries', 3); 43 | 44 | $results = []; 45 | 46 | foreach ($to as $targetLanguage) { 47 | // Prepare list of texts 48 | $textValues = array_values($texts); 49 | $textKeys = array_keys($texts); 50 | 51 | $response = Http::timeout($timeout) 52 | ->retry($maxRetries, 10) 53 | ->post("https://translation.googleapis.com/language/translate/v2", [ 54 | 'q' => $textValues, 55 | 'source' => $from, 56 | 'target' => $targetLanguage, 57 | 'format' => 'text', 58 | 'key' => $apiKey, 59 | ]); 60 | 61 | $translated = $response->json('data.translations', []); 62 | 63 | foreach ($translated as $index => $item) { 64 | $key = $textKeys[$index] ?? null; 65 | if ($key) { 66 | $results[$key][$targetLanguage] = $item['translatedText'] ?? ''; 67 | } 68 | } 69 | } 70 | 71 | return $results; 72 | } 73 | 74 | 75 | /** 76 | * Perform the translation request for a single target language. 77 | * 78 | * @param string $text 79 | * @param string $from 80 | * @param string $to 81 | * @return string 82 | */ 83 | protected function translateTo(string $text, string $from, string $to): string 84 | { 85 | $apiKey = config('texts.google.api_key'); 86 | $timeout = config('texts.google.timeout', 10); 87 | $maxRetries = config('texts.google.retries', 3); 88 | 89 | $response = Http::timeout($timeout) 90 | ->retry($maxRetries, 10) 91 | ->post("https://translation.googleapis.com/language/translate/v2", [ 92 | 'q' => $text, 93 | 'source' => $from, 94 | 'target' => $to, 95 | 'format' => 'text', 96 | 'key' => $apiKey, 97 | ]); 98 | 99 | return trim($response->json('data.translations.0.translatedText', '')); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Translators/OpenAITranslator.php: -------------------------------------------------------------------------------- 1 | Array of translations indexed by language code. 19 | */ 20 | public function translate(string $text, string $from, array $to): array 21 | { 22 | $apiKey = config('texts.openai.api_key'); 23 | $model = config('texts.openai.model', 'gpt-3.5-turbo'); 24 | $timeout = config('texts.openai.timeout', 10); 25 | $maxRetries = config('texts.openai.retries', 3); 26 | 27 | // Build instructions for multiple languages 28 | $languagesList = implode(', ', $to); 29 | 30 | $response = Http::withHeaders([ 31 | 'Authorization' => 'Bearer ' . $apiKey, 32 | ]) 33 | ->timeout($timeout) 34 | ->retry($maxRetries, 1000) 35 | ->post('https://api.openai.com/v1/chat/completions', [ 36 | 'model' => $model, 37 | 'messages' => [ 38 | [ 39 | 'role' => 'system', 40 | 'content' => "You are a helpful assistant that translates from {$from} into multiple languages: {$languagesList}. Reply with a JSON object, where each property is the language code and the value is the translated text. Preserve placeholders like :name, :count, or any text wrapped in colons (:) exactly as they are.", 41 | ], 42 | [ 43 | 'role' => 'user', 44 | 'content' => $text, 45 | ], 46 | ], 47 | 'temperature' => 0, 48 | ]); 49 | 50 | $rawContent = trim($response->json('choices.0.message.content', '{}')); 51 | 52 | // Attempt to decode JSON 53 | $translations = json_decode($rawContent, true); 54 | 55 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($translations)) { 56 | throw new RuntimeException("Failed to decode translation response: " . $rawContent); 57 | } 58 | 59 | return $translations; 60 | } 61 | 62 | /** 63 | * Translates multiple strings into multiple target languages. 64 | * 65 | * @param array $texts Array of texts with their corresponding keys. 66 | * @param string $from The source language code (e.g., 'en'). 67 | * @param array $to An array of target language codes (e.g., ['es', 'fr']). 68 | * @return array> Translations indexed by original key and language code. 69 | */ 70 | public function translateMany(array $texts, string $from, array $to): array 71 | { 72 | $apiKey = config('texts.openai.api_key'); 73 | $model = config('texts.openai.model', 'gpt-3.5-turbo'); 74 | $timeout = config('texts.openai.timeout', 10); 75 | $maxRetries = config('texts.openai.retries', 3); 76 | 77 | $languagesList = implode(', ', $to); 78 | 79 | $inputJson = json_encode($texts, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); 80 | 81 | $response = Http::withHeaders([ 82 | 'Authorization' => 'Bearer ' . $apiKey, 83 | ]) 84 | ->timeout($timeout) 85 | ->retry($maxRetries, 1000) 86 | ->post('https://api.openai.com/v1/chat/completions', [ 87 | 'model' => $model, 88 | 'messages' => [ 89 | [ 90 | 'role' => 'system', 91 | 'content' => "You are a helpful assistant that translates JSON key-value pairs from {$from} into multiple languages: {$languagesList}. Reply with a JSON object where each key from the input maps to an object of translations per language. Preserve any placeholder like :name, :count, or any text wrapped in colons (:).", 92 | ], 93 | [ 94 | 'role' => 'user', 95 | 'content' => $inputJson, 96 | ], 97 | ], 98 | 'temperature' => 0, 99 | ]); 100 | 101 | $rawContent = trim($response->json('choices.0.message.content', '{}')); 102 | 103 | $translations = json_decode($rawContent, true); 104 | 105 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($translations)) { 106 | throw new RuntimeException("Failed to decode batch translation response: " . $rawContent); 107 | } 108 | 109 | return $translations; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected function getPackageProviders($app): array 17 | { 18 | return [ 19 | LaratextServiceProvider::class, 20 | ]; 21 | } 22 | 23 | /** 24 | * Define environment setup. 25 | * 26 | * @param \Illuminate\Foundation\Application $app 27 | * @return void 28 | */ 29 | protected function defineEnvironment($app): void 30 | { 31 | $app['config']->set('texts', [ 32 | 'default_translator' => \EduLazaro\Laratext\Translators\OpenAITranslator::class, 33 | 'translators' => [ 34 | 'openai' => \EduLazaro\Laratext\Translators\OpenAITranslator::class, 35 | 'google' => \EduLazaro\Laratext\Translators\GoogleTranslator::class, 36 | ], 37 | 'openai' => [ 38 | 'api_key' => env('OPENAI_API_KEY', 'fake-openai-api-key'), 39 | 'model' => env('OPENAI_MODEL', 'gpt-3.5-turbo'), 40 | 'timeout' => 10, 41 | 'retries' => 3, 42 | ], 43 | 44 | 'google' => [ 45 | 'api_key' => env('GOOGLE_TRANSLATOR_API_KEY', 'fake-google-api-key'), 46 | 'timeout' => 10, 47 | 'retries' => 3, 48 | ], 49 | 50 | 'languages' => [ 51 | 'en' => 'English', 52 | 'es' => 'Spanish', 53 | 'fr' => 'French', 54 | ], 55 | ]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Unit/GoogleTranslatorTest.php: -------------------------------------------------------------------------------- 1 | 'Hola mundo', 17 | 'fr' => 'Bonjour le monde', 18 | ]; 19 | 20 | return Http::response([ 21 | 'data' => [ 22 | 'translations' => [ 23 | ['translatedText' => $translations[$target] ?? 'Unknown'], 24 | ], 25 | ], 26 | ], 200); 27 | }); 28 | 29 | $translator = new GoogleTranslator(); 30 | 31 | $result = $translator->translate('Hello world', 'en', ['es', 'fr']); 32 | 33 | $this->assertEquals([ 34 | 'es' => 'Hola mundo', 35 | 'fr' => 'Bonjour le monde', 36 | ], $result); 37 | 38 | Http::assertSentCount(2); // Optional, to verify 2 calls were made 39 | } 40 | 41 | /** @test */ 42 | public function it_translates_many_texts_to_multiple_languages() 43 | { 44 | Http::fake(function ($request) { 45 | $target = $request['target']; 46 | $texts = $request['q']; 47 | 48 | $translations = [ 49 | 'es' => [ 50 | 'Hello' => 'Hola', 51 | 'World' => 'Mundo', 52 | ], 53 | 'fr' => [ 54 | 'Hello' => 'Salut', 55 | 'World' => 'Monde', 56 | ], 57 | ]; 58 | 59 | $response = [ 60 | 'data' => [ 61 | 'translations' => array_map(fn($text) => [ 62 | 'translatedText' => $translations[$target][$text] ?? 'Unknown' 63 | ], $texts), 64 | ], 65 | ]; 66 | 67 | return Http::response($response, 200); 68 | }); 69 | 70 | $translator = new GoogleTranslator(); 71 | 72 | $texts = [ 73 | 'key1' => 'Hello', 74 | 'key2' => 'World', 75 | ]; 76 | 77 | $result = $translator->translateMany($texts, 'en', ['es', 'fr']); 78 | 79 | $this->assertEquals([ 80 | 'key1' => [ 81 | 'es' => 'Hola', 82 | 'fr' => 'Salut', 83 | ], 84 | 'key2' => [ 85 | 'es' => 'Mundo', 86 | 'fr' => 'Monde', 87 | ], 88 | ], $result); 89 | 90 | Http::assertSentCount(2); // One call per language 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Unit/OpenAITranslatorTest.php: -------------------------------------------------------------------------------- 1 | Http::response([ 14 | 'choices' => [ 15 | [ 16 | 'message' => [ 17 | 'content' => json_encode([ 18 | 'es' => 'Hola mundo', 19 | 'fr' => 'Bonjour le monde', 20 | ]) 21 | ] 22 | ], 23 | ], 24 | ], 200) 25 | ]); 26 | 27 | $translator = new OpenAITranslator(); 28 | 29 | $result = $translator->translate('Hello world', 'en', ['es', 'fr']); 30 | 31 | $this->assertEquals([ 32 | 'es' => 'Hola mundo', 33 | 'fr' => 'Bonjour le monde', 34 | ], $result); 35 | 36 | Http::assertSent(function ($request) { 37 | return $request->url() === 'https://api.openai.com/v1/chat/completions'; 38 | }); 39 | } 40 | 41 | /** @test */ 42 | public function it_translates_many_texts_to_multiple_languages() 43 | { 44 | Http::fake([ 45 | 'api.openai.com/*' => Http::response([ 46 | 'choices' => [ 47 | [ 48 | 'message' => [ 49 | 'content' => json_encode([ 50 | 'key1' => [ 51 | 'es' => 'Hola', 52 | 'fr' => 'Salut', 53 | ], 54 | 'key2' => [ 55 | 'es' => 'Mundo', 56 | 'fr' => 'Monde', 57 | ], 58 | ]), 59 | ], 60 | ], 61 | ], 62 | ], 200) 63 | ]); 64 | 65 | $translator = new OpenAITranslator(); 66 | 67 | $texts = [ 68 | 'key1' => 'Hello', 69 | 'key2' => 'World', 70 | ]; 71 | 72 | $result = $translator->translateMany($texts, 'en', ['es', 'fr']); 73 | 74 | $this->assertEquals([ 75 | 'key1' => [ 76 | 'es' => 'Hola', 77 | 'fr' => 'Salut', 78 | ], 79 | 'key2' => [ 80 | 'es' => 'Mundo', 81 | 'fr' => 'Monde', 82 | ], 83 | ], $result); 84 | 85 | Http::assertSent(function ($request) { 86 | return $request->url() === 'https://api.openai.com/v1/chat/completions'; 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Unit/ScanTranslationsCommandTest.php: -------------------------------------------------------------------------------- 1 | Http::response([ 29 | 'choices' => [ 30 | [ 31 | 'message' => [ 32 | 'content' => json_encode([ 33 | 'pages.home.welcome' => [ 34 | 'en' => 'Welcome', 35 | 'es' => 'Bienvenido', 36 | ] 37 | ]), 38 | ], 39 | ], 40 | ], 41 | ]), 42 | ]); 43 | 44 | $this->artisan('laratext:scan --write --translator=openai') 45 | ->expectsOutput('Scanning project for translation keys...') 46 | ->expectsOutput('Found 1 unique keys.') 47 | ->expectsOutput('Translation file updated: ' . lang_path('en.json')) 48 | ->expectsOutput('Translation file updated: ' . lang_path('es.json')) 49 | ->expectsOutput('All translations processed.') 50 | ->assertExitCode(0); 51 | 52 | $this->assertFileExists(lang_path('en.json')); 53 | $this->assertFileExists(lang_path('es.json')); 54 | 55 | $enContent = json_decode(File::get(lang_path('en.json')), true); 56 | $esContent = json_decode(File::get(lang_path('es.json')), true); 57 | 58 | $this->assertEquals('Welcome', $enContent['pages.home.welcome']); 59 | $this->assertEquals('Bienvenido', $esContent['pages.home.welcome']); 60 | } 61 | 62 | protected function tearDown(): void 63 | { 64 | // Clean up 65 | File::delete(resource_path('views/test.blade.php')); 66 | File::delete(lang_path('en.json')); 67 | File::delete(lang_path('es.json')); 68 | 69 | parent::tearDown(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Unit/TextTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('Default Value', $result); 14 | } 15 | 16 | /** @test */ 17 | public function it_returns_helper_function_value_if_translation_missing() 18 | { 19 | $result = text('non.existent.key', 'Default Helper'); 20 | $this->assertEquals('Default Helper', $result); 21 | } 22 | 23 | /** @test */ 24 | public function it_renders_blade_directive_with_default_value() 25 | { 26 | $blade = "@text('non.existent.key', 'Default Blade')"; 27 | $rendered = Blade::render($blade); 28 | $this->assertStringContainsString('Default Blade', $rendered); 29 | } 30 | 31 | /** @test */ 32 | public function it_returns_existing_translation_from_file() 33 | { 34 | // Prepare a fake translation file 35 | $langFile = lang_path('en.json'); 36 | file_put_contents($langFile, json_encode([ 37 | 'pages.home.welcome' => 'Welcome!', 38 | ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 39 | 40 | $result = Text::get('pages.home.welcome', 'Default Value'); 41 | $this->assertEquals('Welcome!', $result); 42 | 43 | $helperResult = text('pages.home.welcome', 'Default Helper'); 44 | $this->assertEquals('Welcome!', $helperResult); 45 | 46 | $blade = "@text('pages.home.welcome', 'Default Blade')"; 47 | $rendered = Blade::render($blade); 48 | $this->assertStringContainsString('Welcome!', $rendered); 49 | } 50 | } --------------------------------------------------------------------------------