├── .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 |
5 |
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 | }
--------------------------------------------------------------------------------