├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── translator.php ├── database ├── factories │ └── .gitkeep └── migrations │ └── .gitkeep ├── pint.json ├── resources └── views │ └── .gitkeep └── src ├── Caches └── SearchCodeCache.php ├── Collections ├── JsonTranslations.php ├── PhpTranslations.php └── Translations.php ├── Commands ├── AddLocaleCommand.php ├── ClearCacheCommand.php ├── DeadCommand.php ├── ExportCommand.php ├── ImportCommand.php ├── LocalesCommand.php ├── MissingCommand.php ├── ProofreadCommand.php ├── SortCommand.php ├── TranslatorCommand.php └── UntranslatedCommand.php ├── Contracts └── ValidateLocales.php ├── Drivers ├── Driver.php ├── JsonDriver.php └── PhpDriver.php ├── Exceptions ├── TranslatorException.php └── TranslatorServiceException.php ├── Facades └── Translator.php ├── Services ├── Exporter │ ├── CsvExporterService.php │ └── ExporterInterface.php ├── Proofread │ ├── OpenAiService.php │ └── ProofreadServiceInterface.php ├── SearchCode │ ├── PhpParserService.php │ └── SearchCodeServiceInterface.php └── Translate │ ├── OpenAiService.php │ └── TranslateServiceInterface.php ├── Support └── LocaleValidator.php ├── Translator.php └── TranslatorServiceProvider.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-translator` will be documented in this file. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The Prosperity Public License 3.0.0 2 | 3 | Contributor: Quentin Gabriele 4 | 5 | Source Code: [GitHub](https://github.com/ElegantEngineeringTech/laravel-translator) 6 | 7 | ## Purpose 8 | 9 | This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days. 10 | 11 | ## Agreement 12 | 13 | In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow. 14 | 15 | ## Notices 16 | 17 | Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above. 18 | 19 | ## Commercial Trial 20 | 21 | Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person. 22 | 23 | ## Contributions Back 24 | 25 | Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose. 26 | 27 | ## Personal Uses 28 | 29 | Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose. 30 | 31 | ## Noncommercial Organizations 32 | 33 | Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding. 34 | 35 | ## Defense 36 | 37 | Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent. 38 | 39 | ## Copyright 40 | 41 | The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it. 42 | 43 | ## Patent 44 | 45 | The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license. 46 | 47 | ## Reliability 48 | 49 | The contributor can't revoke this license. 50 | 51 | ## Excuse 52 | 53 | You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule. 54 | 55 | ## No Liability 56 | 57 | **_As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim._** 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # All-in-One Translations Manager for Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/elegantly/laravel-translator.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-translator) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-translator/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-translator/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-translator/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-translator/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/elegantly/laravel-translator.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-translator) 7 | 8 | ![Laravel Translator](https://repository-images.githubusercontent.com/816339762/eefcad09-87ad-484e-bcc4-5759303dc4b6) 9 | 10 | Easily manage all your Laravel translation strings with powerful features: 11 | 12 | - **Translate** strings into other languages using OpenAI, Claude, Gemini or custom services. 13 | - **Proofread** translations to fix grammar and syntax automatically (using OpenAI, Claude, Gemini or custom service). 14 | - **Find missing** translation strings across locales. 15 | - **Detect unused** translation keys in your codebase. 16 | - **Sort** translations in natural order. 17 | - **Import & Export** translations in a CSV file. 18 | 19 | --- 20 | 21 | ## Try Laratranslate – A Powerful UI for Managing Translations 22 | 23 | [![Laratranslate](https://elegantly.dev/assets/laratranslate/opengraph.jpg)](https://elegantly.dev/laratranslate/) 24 | 25 | --- 26 | 27 | # Table of Contents 28 | 29 | 1. [How does it work?](#how-does-it-work) 30 | 1. [Installation](#installation) 31 | 1. [Configuring the Driver](#configuring-the-driver) 32 | 1. [Configuring the Locales](#configuring-the-locales) 33 | - [Automatic Detection](#automatic-detection) 34 | - [Manual Setup](#manual-setup) 35 | 1. [Configuring the Code Scanner](#configuring-the-code-scanner) 36 | - [Requirements](#requirements) 37 | - [Included Paths](#included-paths) 38 | - [Excluded Paths](#excluded-paths) 39 | - [Ignored Translation Keys](#ignored-translation-keys) 40 | 1. [Sorting and Formatting](#sorting-and-formatting) 41 | - [CLI Commands](#cli-commands) 42 | - [Using Code](#using-code) 43 | 1. [Automatic Translation](#automatic-translation) 44 | - [Configuring OpenAI](#configuring-openai) 45 | - [Using Claude](#using-claude) 46 | - [CLI Translation](#cli-translation) 47 | - [Programmatic Translation](#programmatic-translation) 48 | 1. [Proofreading Translations](#proofreading-translations) 49 | - [CLI Proofreading](#cli-proofreading) 50 | - [Programmatic Proofreading](#programmatic-proofreading) 51 | 1. [Identifying Untranslated Translations](#identifying-untranslated-translations) 52 | - [CLI Usage](#cli-usage) 53 | - [Programmatic Usage](#programmatic-usage) 54 | 1. [Detecting Missing Translations](#detecting-missing-translations) 55 | - [CLI Usage](#cli-usage-1) 56 | - [Programmatic Usage](#programmatic-usage-1) 57 | 1. [Detecting Dead Translations](#detecting-dead-translations) 58 | - [CLI Usage](#cli-usage-2) 59 | - [Programmatic Usage](#programmatic-usage-2) 60 | 1. [Export to a CSV](#export-to-a-csv) 61 | - [CLI Usage](#cli-usage-3) 62 | - [Programmatic Usage](#programmatic-usage-3) 63 | 1. [Import from a CSV](#import-from-a-csv) 64 | - [CLI Usage](#cli-usage-4) 65 | - [Programmatic Usage](#programmatic-usage-4) 66 | 67 | ## How does it work? 68 | 69 | This package will directly modify your translation files like `/lang/en/messages.php` or `/lang/fr.json` for example. 70 | 71 | Both `PHP` and `JSON` files are supported. 72 | 73 | Advanced features like dead translations detection will scan your entire codebase to find unused translation strings. 74 | 75 | ## Installation 76 | 77 | Install the package via Composer: 78 | 79 | ```bash 80 | composer require elegantly/laravel-translator --dev 81 | ``` 82 | 83 | Add the following line to your `.gitignore` file: 84 | 85 | ``` 86 | storage/.translator.cache 87 | ``` 88 | 89 | Publish the configuration file: 90 | 91 | ```bash 92 | php artisan vendor:publish --tag="translator-config" 93 | ``` 94 | 95 | --- 96 | 97 | ## Configuring the Driver 98 | 99 | This package uses a driver-based architecture. By default, it supports two standard drivers: PHP and JSON. 100 | 101 | - Use the `PHP` driver if you store your translation strings in `.php` files, such as `/lang/en/message.php`. 102 | - Use the `JSON` driver if you store your translation strings in `.json` files, such as `/lang/fr.json`. 103 | 104 | You can also create custom drivers for alternative storage methods, such as a database. 105 | 106 | Set the default driver in the configuration file: 107 | 108 | ```php 109 | use Elegantly\Translator\Drivers\PhpDriver; 110 | 111 | return [ 112 | /** 113 | * Possible values: 'php', 'json', or a class-string implementing Driver. 114 | */ 115 | 'driver' => PhpDriver::class, 116 | 117 | // ... 118 | ]; 119 | ``` 120 | 121 | > [!NOTE] 122 | > All features are supported in both the PHP and JSON drivers. 123 | 124 | ## Configuring the Locales 125 | 126 | ### Automatic Detection 127 | 128 | By default, this package will attempt to determine the locales defined in your application by scanning your `lang` directory. 129 | 130 | You can customize this behavior in the configuration file. 131 | 132 | ```php 133 | use Elegantly\Translator\Support\LocaleValidator; 134 | 135 | return [ 136 | // ... 137 | 'locales' => LocaleValidator::class, 138 | // ... 139 | ]; 140 | ``` 141 | 142 | ### Manual Setup 143 | 144 | To set the locales manually, use the following configuration: 145 | 146 | ```php 147 | return [ 148 | // ... 149 | 'locales' => ['en', 'fr', 'es'], 150 | // ... 151 | ]; 152 | ``` 153 | 154 | --- 155 | 156 | ## Configuring the Code Scanner 157 | 158 | Service: `searchcode`. 159 | 160 | Features: 161 | 162 | - [Detecting Missing Translations](#detecting-missing-translations) 163 | - [Detecting Dead Translations](#detecting-dead-translations) 164 | 165 | Both the detection of dead and missing translations rely on scanning your code. 166 | 167 | - **Missing translations** are keys found in your codebase but missing in translation files. 168 | - **Dead translations** are keys defined in your translation files but unused in your codebase. 169 | 170 | ### Requirements 171 | 172 | At the moment, this package can only scan the following files: 173 | 174 | - `.php` 175 | - `.blade.php` 176 | 177 | > [!NOTE] 178 | > If you use a React or Vue frontend, it would not be able to scan those files, making this feature irrelevant. 179 | 180 | The default detector uses `nikic/php-parser` to scan all your `.php` files, including the Blade ones. 181 | 182 | In order to be able to detect your keys, you will have to use one of the following Laravel function: 183 | 184 | - `__(...)`, 185 | - `trans(...)` 186 | - `trans_choice(...)` 187 | - `\Illuminate\Support\Facades\Lang::get(...)` 188 | - `\Illuminate\Support\Facades\Lang::has(...)` 189 | - `\Illuminate\Support\Facades\Lang::hasForLocale(...)` 190 | - `\Illuminate\Support\Facades\Lang::choice(...)` 191 | - `app('translator')->get(...)` 192 | - `app('translator')->has(...)` 193 | - `app('translator')->hasForLocale(...)` 194 | - `app('translator')->choice(...)` 195 | 196 | Or one of the following Laravel Blade directive: 197 | 198 | - `@lang(...)` 199 | 200 | Here is some example of do's and don'ts: 201 | 202 | ```php 203 | __('messages.home.title'); // ✅ 'messages.home.title' is detected 204 | 205 | foreach(__('messages.welcome.lines') as $line){ 206 | // ✅ 'messages.welcome.lines' and all of its children are detected. 207 | } 208 | 209 | $key = 'messages.home.title'; 210 | __($key); // ❌ no key is detected 211 | ``` 212 | 213 | ### Included Paths 214 | 215 | Specify paths to scan for translation keys. By default, both `.php` and `.blade.php` files are supported. 216 | 217 | ```php 218 | return [ 219 | 'searchcode' => [ 220 | 'paths' => [ 221 | app_path(), 222 | resource_path(), 223 | ], 224 | ], 225 | ]; 226 | ``` 227 | 228 | ### Excluded Paths 229 | 230 | Exclude irrelevant paths for optimized scanning, such as test files or unrelated directories. 231 | 232 | ```php 233 | return [ 234 | 'searchcode' => [ 235 | 'excluded_paths' => [ 236 | 'tests' 237 | ], 238 | ], 239 | ]; 240 | ``` 241 | 242 | ### Ignored Translation Keys 243 | 244 | Ignore specific translation keys: 245 | 246 | ```php 247 | return [ 248 | 'searchcode' => [ 249 | 'ignored_translations' => [ 250 | 'countries', // Ignore keys starting with 'countries'. 251 | ], 252 | ], 253 | ]; 254 | ``` 255 | 256 | --- 257 | 258 | ## Sorting and Formatting 259 | 260 | ### CLI Commands 261 | 262 | Sort translations with the default driver: 263 | 264 | ```bash 265 | php artisan translator:sort 266 | ``` 267 | 268 | Specify a driver for sorting: 269 | 270 | ```bash 271 | php artisan translator:sort --driver=json 272 | ``` 273 | 274 | ### Using Code 275 | 276 | Sort translations programmatically with the default driver: 277 | 278 | ```php 279 | use Elegantly\Translator\Facades\Translator; 280 | 281 | Translator::sortTranslations(locale: 'fr'); 282 | ``` 283 | 284 | Specify a driver: 285 | 286 | ```php 287 | Translator::driver('json')->sortTranslations(locale: 'fr'); 288 | ``` 289 | 290 | --- 291 | 292 | ## Automatic Translation 293 | 294 | Service: `translate`. 295 | 296 | Before translating, configure a translation service. The package supports: 297 | 298 | - **OpenAI** 299 | - Any model compatible with the OpenAI SDK 300 | 301 | Custom translation services can also be implemented. 302 | 303 | ### Configuring OpenAI 304 | 305 | Define your OpenAI credentials in the configuration file or via environment variables: 306 | 307 | ```php 308 | return [ 309 | // ... 310 | 311 | 'services' => [ 312 | 'openai' => [ 313 | 'key' => env('OPENAI_API_KEY'), 314 | 'organization' => env('OPENAI_ORGANIZATION'), 315 | 'request_timeout' => env('OPENAI_REQUEST_TIMEOUT'), 316 | 'base_uri' => env('OPENAI_BASE_URI'), 317 | 'project' => env('OPENAI_PROJECT'), 318 | ], 319 | ], 320 | 321 | // ... 322 | ]; 323 | ``` 324 | 325 | ### Using Claude 326 | 327 | Anthropic offers an [API compatible with the OpenAI SDK](https://docs.anthropic.com/en/api/openai-sdk). To integrate Claude using this SDK, you simply need to update the `base_uri` to point to Anthropic's endpoint and use your Anthropic API key. 328 | 329 | Here’s a sample configuration in PHP: 330 | 331 | ```php 332 | return [ 333 | // ... 334 | 335 | 'services' => [ 336 | 'openai' => [ 337 | 'key' => env('ANTHROPIC_API_KEY'), 338 | 'base_uri' => 'https://api.anthropic.com/v1', 339 | ], 340 | ], 341 | 342 | // ... 343 | ]; 344 | ``` 345 | 346 | > 💡 **Note:** Ensure your `ANTHROPIC_API_KEY` is set in your environment variables. 347 | 348 | ### CLI Translation 349 | 350 | Display all keys defined in the source locale (English) but not translated in the target (French): 351 | 352 | ```bash 353 | php artisan translator:untranslated en fr 354 | ``` 355 | 356 | Translate untranslated English strings into French: 357 | 358 | ```bash 359 | php artisan translator:untranslated en fr --translate 360 | ``` 361 | 362 | Translate using a specific driver: 363 | 364 | ```bash 365 | php artisan translator:untranslated en fr --translate --driver=json 366 | ``` 367 | 368 | Add a new locale (French) with their translations from a source (English): 369 | 370 | ```bash 371 | php artisan translator:add-locale fr en --translate 372 | ``` 373 | 374 | ### Programmatic Translation 375 | 376 | Translate translations programmatically with the default driver: 377 | 378 | ```php 379 | Translator::translateTranslations( 380 | source: 'en', 381 | target: 'fr', 382 | keys: ['validation.title', ...] 383 | ); 384 | ``` 385 | 386 | Specify a driver for translation: 387 | 388 | ```php 389 | Translator::driver('json')->translateTranslations( 390 | source: 'en', 391 | target: 'fr', 392 | keys: ['My Title', ...] 393 | ); 394 | ``` 395 | 396 | --- 397 | 398 | ## Proofreading Translations 399 | 400 | Service: `proofread`. 401 | 402 | Proofreading corrects the grammar and syntax of your translation strings. 403 | 404 | Currently, OpenAI is the only built-in service, but custom services can be implemented. 405 | 406 | To configure OpenAI, see [Configuring OpenAI](#configuring-openai). 407 | 408 | ### CLI Proofreading 409 | 410 | Proofread all strings in the target locale (English). 411 | 412 | ```bash 413 | php artisan translator:proofread en 414 | ``` 415 | 416 | ### Programmatic Proofreading 417 | 418 | Proofread translations with the default driver: 419 | 420 | ```php 421 | Translator::proofreadTranslations( 422 | locale: 'fr', 423 | keys: ['auth.email', ...] 424 | ); 425 | ``` 426 | 427 | Specify a driver: 428 | 429 | ```php 430 | Translator::driver('json')->proofreadTranslations( 431 | locale: 'fr', 432 | keys: ['My Title', ...] 433 | ); 434 | ``` 435 | 436 | --- 437 | 438 | ## Identifying Untranslated Translations 439 | 440 | Find keys defined in one locale but missing in another. 441 | 442 | ### CLI Usage 443 | 444 | Display all keys defined in the source locale (English) but not in the target locale (French). 445 | 446 | ```bash 447 | php artisan translator:untranslated en fr 448 | ``` 449 | 450 | ### Programmatic Usage 451 | 452 | ```php 453 | Translator::getUntranslatedTranslations(source: 'en', target: 'fr'); 454 | ``` 455 | 456 | --- 457 | 458 | ## Detecting Missing Translations 459 | 460 | Service: `searchcode`. 461 | Configuration: [Configuring the Code Scanner](#configuring-the-code-scanner) 462 | 463 | Missing translations are keys found in your codebase but missing in translation files. 464 | 465 | ### CLI Usage 466 | 467 | Find keys defined in your codebase but missing in your locale (English) using your default driver: 468 | 469 | ```bash 470 | php artisan translator:missing en 471 | ``` 472 | 473 | Specify a driver: 474 | 475 | ```bash 476 | php artisan translator:missing en --driver=json 477 | ``` 478 | 479 | Add the missing keys to your driver: 480 | 481 | ```bash 482 | php artisan translator:missing en --sync 483 | ``` 484 | 485 | ### Programmatic Usage 486 | 487 | ```php 488 | Translator::getMissingTranslations(locale: 'en'); 489 | ``` 490 | 491 | --- 492 | 493 | ## Detecting Dead Translations 494 | 495 | Service: `searchcode`. 496 | Configuration: [Configuring the Code Scanner](#configuring-the-code-scanner) 497 | 498 | Dead translations are keys defined in your locale (English) but unused in your codebase. 499 | 500 | ### CLI Usage 501 | 502 | ```bash 503 | php artisan translator:dead en 504 | ``` 505 | 506 | ### Programmatic Usage 507 | 508 | ```php 509 | Translator::getDeadTranslations(locale: 'fr'); 510 | ``` 511 | 512 | ## Export to a CSV 513 | 514 | Service: `exporter` 515 | 516 | Export all your translation strings to a CSV file in the following format: 517 | 518 | | Key | en | fr | 519 | | ------------------- | ----- | --------- | 520 | | messages.auth.login | Login | Connexion | 521 | 522 | ### CLI Usage 523 | 524 | ```bash 525 | php artisan translator:export /path/to/my/file.csv 526 | ``` 527 | 528 | ### Programmatic Usage 529 | 530 | ```php 531 | $path = Translator::exportTranslations('/path/to/my/file.csv'); 532 | ``` 533 | 534 | ## Import from a CSV 535 | 536 | Service: `exporter` 537 | 538 | Import translation strings from a CSV file. Ensure your CSV follows the format below: 539 | 540 | | Key | en | fr | 541 | | ------------------- | ----- | --------- | 542 | | messages.auth.login | Login | Connexion | 543 | 544 | ### CLI Usage 545 | 546 | ```bash 547 | php artisan translator:import /path/to/my/file.csv 548 | ``` 549 | 550 | ### Programmatic Usage 551 | 552 | ```php 553 | $translations = Translator::importTranslations('/path/to/my/file.csv'); 554 | ``` 555 | 556 | ## Testing 557 | 558 | Run tests using: 559 | 560 | ```bash 561 | composer test 562 | ``` 563 | 564 | --- 565 | 566 | ## Changelog 567 | 568 | See the [CHANGELOG](CHANGELOG.md) for recent updates. 569 | 570 | --- 571 | 572 | ## Contributing 573 | 574 | Check the [CONTRIBUTING](CONTRIBUTING.md) guide for details. 575 | 576 | --- 577 | 578 | ## Security Vulnerabilities 579 | 580 | Report security vulnerabilities via GitHub or email. 581 | 582 | --- 583 | 584 | ## Credits 585 | 586 | - [Quentin Gabriele](https://github.com/QuentinGab) 587 | - [All Contributors](../../contributors) 588 | 589 | --- 590 | 591 | ## License 592 | 593 | This package is licensed under the MIT License. See the [License File](LICENSE.md) for more details. 594 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elegantly/laravel-translator", 3 | "description": "All on one translations management for Laravel", 4 | "keywords": [ 5 | "laravel-translations", 6 | "laravel", 7 | "elegantly", 8 | "ElegantEngineering", 9 | "laravel", 10 | "laravel-translator" 11 | ], 12 | "homepage": "https://github.com/ElegantEngineeringTech/laravel-translator", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Quentin Gabriele", 17 | "email": "quentin.gabriele@gmail.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.2", 23 | "illuminate/contracts": "^10.0||^11.0||^12.0", 24 | "nikic/php-parser": "^5.1", 25 | "openai-php/laravel": "^0.11.0||^0.12.0||^0.13.0", 26 | "spatie/laravel-package-tools": "^1.16", 27 | "spatie/simple-excel": "^3.7", 28 | "symfony/finder": "^6.0||^7.0", 29 | "symfony/intl": "^7.2" 30 | }, 31 | "require-dev": { 32 | "larastan/larastan": "^2.9||^3.0", 33 | "laravel/pint": "^1.14", 34 | "nunomaduro/collision": "^7.10.0||^8.1.1", 35 | "orchestra/testbench": "^8.22.0||^9.0.0||^10.0.0", 36 | "pestphp/pest": "^2.34||^3.0", 37 | "pestphp/pest-plugin-arch": "^2.7||^3.0", 38 | "pestphp/pest-plugin-laravel": "^2.3||^3.0", 39 | "phpstan/extension-installer": "^1.3||^2.0", 40 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 41 | "phpstan/phpstan-phpunit": "^1.3||^2.0" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "Elegantly\\Translator\\": "src/", 46 | "Elegantly\\Translator\\Database\\Factories\\": "database/factories/" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Elegantly\\Translator\\Tests\\": "tests/", 52 | "Workbench\\App\\": "workbench/app/" 53 | } 54 | }, 55 | "scripts": { 56 | "post-autoload-dump": "@composer run prepare", 57 | "clear": "@php vendor/bin/testbench package:purge-laravel-translator --ansi", 58 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 59 | "build": [ 60 | "@composer run prepare", 61 | "@php vendor/bin/testbench workbench:build --ansi" 62 | ], 63 | "start": [ 64 | "Composer\\Config::disableProcessTimeout", 65 | "@composer run build", 66 | "@php vendor/bin/testbench serve" 67 | ], 68 | "analyse": "vendor/bin/phpstan analyse", 69 | "test": "vendor/bin/pest", 70 | "test-coverage": "vendor/bin/pest --coverage", 71 | "format": "vendor/bin/pint" 72 | }, 73 | "config": { 74 | "sort-packages": true, 75 | "allow-plugins": { 76 | "pestphp/pest-plugin": true, 77 | "phpstan/extension-installer": true, 78 | "php-http/discovery": true 79 | } 80 | }, 81 | "extra": { 82 | "laravel": { 83 | "providers": [ 84 | "Elegantly\\Translator\\TranslatorServiceProvider" 85 | ], 86 | "aliases": { 87 | "Translator": "Elegantly\\Translator\\Facades\\Translator" 88 | } 89 | } 90 | }, 91 | "minimum-stability": "dev", 92 | "prefer-stable": true 93 | } 94 | -------------------------------------------------------------------------------- /config/translator.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | 'driver' => PhpDriver::class, 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Language Paths 19 | |-------------------------------------------------------------------------- 20 | | 21 | | This is the path where your translation files are stored. In a standard Laravel installation, you should not need to change it. 22 | | 23 | */ 24 | 'lang_path' => lang_path(), 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Auto Sort Keys 29 | |-------------------------------------------------------------------------- 30 | | 31 | | If set to true, all keys will be sorted automatically after any file manipulation such as 'edit', 'translate', or 'proofread'. 32 | | 33 | */ 34 | 'sort_keys' => false, 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Locales 39 | |-------------------------------------------------------------------------- 40 | | 41 | | If set to an array such as ['en', 'es', 'fr']: 42 | | -> Translator::getLocales() will return this array. 43 | | If set to a class implementing `\Elegantly\Translator\Contracts\ValidateLocales`: 44 | | -> The locales will be those found in the lang directory and filtered according to the class. 45 | | If set to `null`: 46 | | -> The locales will be those found in the lang directory. 47 | | 48 | */ 49 | 'locales' => LocaleValidator::class, 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Third-Party Services 54 | |-------------------------------------------------------------------------- 55 | | 56 | | Define the API keys for your third-party services. These keys are reused for both 'translate' and 'proofread'. 57 | | You can override this configuration and define specific service options, for example, in 'translate.services.openai.key'. 58 | | 59 | */ 60 | 'services' => [ 61 | 'openai' => [ 62 | 'key' => env('OPENAI_API_KEY'), 63 | 'organization' => env('OPENAI_ORGANIZATION'), 64 | 'request_timeout' => env('OPENAI_REQUEST_TIMEOUT'), 65 | 'base_uri' => env('OPENAI_BASE_URL'), 66 | 'project' => env('OPENAI_PROJECT'), 67 | ], 68 | ], 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Translation Service 73 | |-------------------------------------------------------------------------- 74 | | 75 | | These are the services that can be used to translate your strings from one locale to another. 76 | | You can customize their behavior here, or you can define your own service. 77 | | 78 | */ 79 | 'translate' => [ 80 | /** 81 | * Supported: 'openai', 'MyOwnServiceClass::name' 82 | * Define your own service using the class's name: 'MyOwnServiceClass::class' 83 | */ 84 | 'service' => null, 85 | 'services' => [ 86 | 'openai' => [ 87 | 'model' => 'gpt-4.1-mini', 88 | 'prompt' => ' 89 | # Role: 90 | You are a professional copywriter and translator specializing in website content localization. 91 | 92 | # Task: 93 | Translate the provided website copy, which is formatted in JSON, into the target locale: {targetLocale}. 94 | 95 | # Instructions: 96 | - Preserve all JSON keys exactly as they are. Do not modify any key names. 97 | - Translate only the values — adapt the tone, clarity, and cultural relevance of the content to suit the target language while remaining faithful to the original intent. 98 | - Do not modify or escape any HTML tags included in the text. 99 | - Do not alter or escape special characters or emojis. 100 | 101 | # Output Format: 102 | Return a JSON object with the same structure. 103 | ', 104 | ], 105 | ], 106 | ], 107 | 108 | /* 109 | |-------------------------------------------------------------------------- 110 | | Proofreading Service 111 | |-------------------------------------------------------------------------- 112 | | 113 | | These are the services that can be used to proofread your strings. 114 | | You can customize their behavior here, or you can define your own service. 115 | | 116 | */ 117 | 'proofread' => [ 118 | /** 119 | * Supported: 'openai', 'MyOwnServiceClass::name' 120 | * Define your own service using the class's name: 'MyOwnServiceClass::class' 121 | */ 122 | 'service' => null, 123 | 'services' => [ 124 | 'openai' => [ 125 | 'model' => 'gpt-4.1-mini', 126 | 'prompt' => ' 127 | # Role: 128 | You are a professional copywriter specializing in website content. 129 | 130 | # Task: 131 | Correct the grammar and syntax of the provided JSON. 132 | 133 | # Instructions: 134 | - Do not modify any JSON keys — only edit the text values. 135 | - Preserve the original meaning and tone of each sentence. 136 | - Do not escape or alter any HTML tags. 137 | - Do not escape or change special characters or emojis. 138 | 139 | Output Format: 140 | Return a valid JSON object with the corrected text values, keeping the structure and keys unchanged. 141 | ', 142 | ], 143 | ], 144 | ], 145 | 146 | /* 147 | |-------------------------------------------------------------------------- 148 | | Search Code / Dead Code Service 149 | |-------------------------------------------------------------------------- 150 | | 151 | | These are the services that can be used to detect dead translation strings in your codebase. 152 | | You can customize their behavior here, or you can define your own service. 153 | | 154 | */ 155 | 'searchcode' => [ 156 | /** 157 | * Supported: 'php-parser', 'MyOwnServiceClass::name' 158 | */ 159 | 'service' => 'php-parser', 160 | 161 | /** 162 | * Files or directories to include in the dead code scan. 163 | */ 164 | 'paths' => [ 165 | app_path(), 166 | resource_path(), 167 | ], 168 | 169 | /** 170 | * Files or directories to exclude from the dead code scan. 171 | */ 172 | 'excluded_paths' => [], 173 | 174 | /** 175 | * Translation keys to exclude from dead code detection. 176 | * By default, the default Laravel translations are excluded. 177 | */ 178 | 'ignored_translations' => [ 179 | 'auth', 180 | 'pagination', 181 | 'passwords', 182 | 'validation', 183 | ], 184 | 185 | 'services' => [ 186 | 'php-parser' => [ 187 | /** 188 | * To speed up detection, all the results of the scan will be stored in a file. 189 | * Feel free to change the path if needed. 190 | */ 191 | 'cache_path' => storage_path('.translator.cache'), 192 | ], 193 | ], 194 | 195 | ], 196 | 197 | /* 198 | |-------------------------------------------------------------------------- 199 | | Exporter/Importer Service 200 | |-------------------------------------------------------------------------- 201 | | 202 | | These are the services that can be used to export and import your translations. 203 | | You can customize their behavior here, or you can define your own service. 204 | | 205 | */ 206 | 'exporter' => [ 207 | 'service' => CsvExporterService::class, 208 | ], 209 | 210 | ]; 211 | -------------------------------------------------------------------------------- /database/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElegantEngineeringTech/laravel-translator/cfbaf7efa22630239bed0a13f83b77e5df11097a/database/factories/.gitkeep -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElegantEngineeringTech/laravel-translator/cfbaf7efa22630239bed0a13f83b77e5df11097a/database/migrations/.gitkeep -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "declare_strict_types": true, 5 | "explicit_string_variable": true, 6 | "mb_str_functions": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElegantEngineeringTech/laravel-translator/cfbaf7efa22630239bed0a13f83b77e5df11097a/resources/views/.gitkeep -------------------------------------------------------------------------------- /src/Caches/SearchCodeCache.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected ?array $value = null; 22 | 23 | public function __construct(public Filesystem $storage) 24 | { 25 | // 26 | } 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function getValue(): ?array 32 | { 33 | if ($this->value) { 34 | return $this->value; 35 | } 36 | 37 | return $this->load(); 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function load(): ?array 44 | { 45 | if ($this->storage->exists(static::FILENAME)) { 46 | $this->value = include $this->storage->path(static::FILENAME); 47 | } 48 | 49 | return $this->value; 50 | } 51 | 52 | /** 53 | * @return null|array{ created_at: int, translations : string[] } 54 | */ 55 | public function get(string $file): ?array 56 | { 57 | if (! $this->value) { 58 | $this->load(); 59 | } 60 | 61 | return $this->value[$file] ?? null; 62 | } 63 | 64 | /** 65 | * @param string[] $translations 66 | */ 67 | public function put(string $file, $translations): static 68 | { 69 | if (! $this->value) { 70 | $this->load(); 71 | } 72 | 73 | $this->value[$file] = [ 74 | 'created_at' => now()->getTimestamp(), 75 | 'translations' => $translations, 76 | ]; 77 | 78 | $this->store(); 79 | 80 | return $this; 81 | } 82 | 83 | public function flush(): static 84 | { 85 | $this->value = null; 86 | 87 | if ($this->storage->exists(static::FILENAME)) { 88 | $this->storage->delete(static::FILENAME); 89 | } 90 | 91 | return $this; 92 | } 93 | 94 | protected function store(): bool 95 | { 96 | $items = array_map( 97 | function (array $item, string $file) { 98 | return new ArrayItem( 99 | key: new String_($file), 100 | value: new Array_([ 101 | new ArrayItem( 102 | key: new String_('created_at'), 103 | value: new Int_($item['created_at']) 104 | ), 105 | new ArrayItem( 106 | key: new String_('translations'), 107 | value: new Array_(array_map( 108 | fn (string $key) => new ArrayItem(new String_($key)), 109 | $item['translations'] 110 | )) 111 | ), 112 | ]) 113 | ); 114 | }, 115 | $this->value, 116 | array_keys($this->value) 117 | ); 118 | 119 | $node = new Return_(new Array_($items)); 120 | 121 | $prettyPrinter = new \PhpParser\PrettyPrinter\Standard; 122 | 123 | return $this->storage->put( 124 | static::FILENAME, 125 | $prettyPrinter->prettyPrintFile([$node]) 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Collections/JsonTranslations.php: -------------------------------------------------------------------------------- 1 | items); 18 | } 19 | 20 | public function get(string $key): mixed 21 | { 22 | return $this->items[$key] ?? null; 23 | } 24 | 25 | public function set(string $key, null|int|float|string|bool $value): static 26 | { 27 | $items = $this->items; 28 | 29 | $items[$key] = $value; 30 | 31 | return new static($items); 32 | } 33 | 34 | public function dot(): Collection 35 | { 36 | // @phpstan-ignore-next-line 37 | return new Collection($this->items); 38 | } 39 | 40 | public static function undot(Collection|array $items): static 41 | { 42 | $items = $items instanceof Collection ? $items->all() : $items; 43 | 44 | return new static($items); 45 | } 46 | 47 | public function only(array $keys): static 48 | { 49 | return new static( 50 | array_intersect_key($this->items, array_flip((array) $keys)) 51 | ); 52 | } 53 | 54 | public function except(array $keys): static 55 | { 56 | return new static( 57 | array_diff_key($this->items, array_flip((array) $keys)) 58 | ); 59 | } 60 | 61 | public function merge(Translations|array $values): static 62 | { 63 | $values = $values instanceof Translations ? $values->dot()->all() : $values; 64 | 65 | return new static( 66 | array_merge( 67 | $this->items, 68 | $values 69 | ) 70 | ); 71 | } 72 | 73 | public function diff(Translations $translations): static 74 | { 75 | return new static( 76 | array_diff_key( 77 | $this->items, 78 | $translations->all() 79 | ) 80 | ); 81 | } 82 | 83 | public function filter(?callable $callback = null): static 84 | { 85 | if ($callback) { 86 | return new static(array_filter( 87 | $this->items, 88 | $callback, 89 | ARRAY_FILTER_USE_BOTH 90 | )); 91 | } 92 | 93 | return new static(array_filter($this->items)); 94 | 95 | } 96 | 97 | public function map(?callable $callback = null): static 98 | { 99 | return new static( 100 | Arr::map( 101 | $this->items, 102 | $callback 103 | ) 104 | ); 105 | } 106 | 107 | public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static 108 | { 109 | $items = $this->items; 110 | 111 | $descending ? krsort($items, $options) : ksort($items, $options); 112 | 113 | return new static($items); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Collections/PhpTranslations.php: -------------------------------------------------------------------------------- 1 | items) ?? [] 20 | ) 21 | ); 22 | } 23 | 24 | public static function undot(Collection|array $items): static 25 | { 26 | $items = $items instanceof Collection ? $items->all() : $items; 27 | 28 | return new static( 29 | static::unprepareTranslations( 30 | Arr::undot($items) 31 | ) ?? [] 32 | ); 33 | } 34 | 35 | public function get(string $key): mixed 36 | { 37 | return Arr::get($this->items, $key); 38 | } 39 | 40 | public function has(string $key): bool 41 | { 42 | return Arr::has($this->items, $key); 43 | } 44 | 45 | public function set(string $key, null|int|float|string|bool $value): static 46 | { 47 | $items = $this->items; 48 | 49 | Arr::set($items, $key, $value); 50 | 51 | return new static($items); 52 | } 53 | 54 | public function only(array $keys): static 55 | { 56 | $items = []; 57 | 58 | foreach ($keys as $key) { 59 | 60 | if ($this->has($key)) { 61 | Arr::set( 62 | $items, 63 | $key, 64 | $this->get($key) 65 | ); 66 | } 67 | } 68 | 69 | return new static($items); 70 | } 71 | 72 | /** 73 | * @param array> $items 74 | * @param string[] $segments 75 | */ 76 | protected function recursiveForget(array &$items, array $segments): void 77 | { 78 | $segment = array_shift($segments); 79 | 80 | if (! array_key_exists($segment, $items)) { 81 | return; 82 | } 83 | 84 | if (empty($segments)) { 85 | unset($items[$segment]); 86 | } elseif (is_array($items[$segment])) { 87 | $this->recursiveForget($items[$segment], $segments); 88 | 89 | if (empty($items[$segment])) { 90 | unset($items[$segment]); 91 | } 92 | } 93 | 94 | } 95 | 96 | public function except(array $keys): static 97 | { 98 | $items = $this->items; 99 | 100 | foreach ($keys as $key) { 101 | if (array_key_exists($key, $items)) { 102 | unset($items[$key]); 103 | } elseif (str_contains($key, '.')) { 104 | 105 | $this->recursiveForget( 106 | $items, 107 | explode('.', $key) 108 | ); 109 | 110 | } 111 | } 112 | 113 | return new static($items); 114 | } 115 | 116 | /** 117 | * @param array> $items 118 | * @return array> 119 | */ 120 | protected function recursiveFilter(array $items, callable $callback): array 121 | { 122 | /** 123 | * @var array> 124 | */ 125 | $result = []; 126 | 127 | foreach ($items as $key => $value) { 128 | if (is_array($value)) { 129 | if ($subresult = $this->recursiveFilter($value, $callback)) { 130 | $result[$key] = $subresult; 131 | } 132 | } elseif ($callback($value, $key)) { 133 | $result[$key] = $value; 134 | } 135 | } 136 | 137 | return $result; 138 | } 139 | 140 | public function filter(?callable $callback = null): static 141 | { 142 | if ($callback) { 143 | return new static($this->recursiveFilter( 144 | $this->items, 145 | $callback 146 | )); 147 | } 148 | 149 | return new static($this->recursiveFilter( 150 | $this->items, 151 | fn ($value) => (bool) $value 152 | )); 153 | 154 | } 155 | 156 | /** 157 | * @param array> $items 158 | * @return array> 159 | */ 160 | protected function recursiveMap(array $items, callable $callback): array 161 | { 162 | /** 163 | * @var array> 164 | */ 165 | $result = []; 166 | 167 | foreach ($items as $key => $value) { 168 | if (is_array($value)) { 169 | $result[$key] = $this->recursiveMap($value, $callback); 170 | } else { 171 | $result[$key] = $callback($value, $key); 172 | } 173 | } 174 | 175 | return $result; 176 | } 177 | 178 | public function map(?callable $callback = null): static 179 | { 180 | return new static( 181 | $this->recursiveMap( 182 | $this->items, 183 | $callback 184 | ) 185 | ); 186 | } 187 | 188 | public function merge(Translations|array $values): static 189 | { 190 | $values = $values instanceof Translations ? $values->dot()->all() : $values; 191 | 192 | $items = new static($this->items); 193 | 194 | foreach ($values as $key => $value) { 195 | $items = $items->set($key, $value); 196 | } 197 | 198 | return $items; 199 | } 200 | 201 | public function diff(Translations $translations): static 202 | { 203 | return $this->except( 204 | $translations->dot()->keys()->all() 205 | ); 206 | } 207 | 208 | /** 209 | * @param array> $items 210 | * @return array> 211 | */ 212 | protected function recursiveSortKeys(array $items, int $options = SORT_REGULAR, bool $descending = false): array 213 | { 214 | foreach ($items as $key => $value) { 215 | if (is_array($value)) { 216 | $items[$key] = $this->recursiveSortKeys($value, $options, $descending); 217 | } 218 | 219 | if ($descending) { 220 | krsort($items, $options); 221 | } else { 222 | ksort($items, $options); 223 | } 224 | 225 | } 226 | 227 | return $items; 228 | } 229 | 230 | public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static 231 | { 232 | return new static( 233 | $this->recursiveSortKeys($this->items, $options, $descending) 234 | ); 235 | } 236 | 237 | /** 238 | * Dot in translations keys might break the initial array structure 239 | * To prevent that, we encode the dots in unicode 240 | */ 241 | public static function prepareTranslations(mixed $values, bool $escape = false): mixed 242 | { 243 | 244 | if ($escape && is_string($values)) { 245 | return str_replace('.', '.', $values); 246 | } 247 | 248 | if (! is_array($values)) { 249 | return $values; 250 | } 251 | 252 | if (empty($values)) { 253 | return null; 254 | } 255 | 256 | return Arr::mapWithKeys( 257 | $values, 258 | fn ($value, $key) => [ 259 | static::prepareTranslations($key, true) => static::prepareTranslations($value), 260 | ] 261 | ); 262 | } 263 | 264 | /** 265 | * Dot in translations keys might break the initial array structure 266 | * To prevent that, we encode the dots in unicode 267 | */ 268 | public static function unprepareTranslations(mixed $values, bool $unescape = false): mixed 269 | { 270 | if ($unescape && is_string($values)) { 271 | return str_replace('.', '.', $values); 272 | } 273 | 274 | if (! is_array($values)) { 275 | return $values; 276 | } 277 | 278 | if (empty($values)) { 279 | return null; 280 | } 281 | 282 | return Arr::mapWithKeys( 283 | $values, 284 | fn ($value, $key) => [ 285 | static::unprepareTranslations($key, true) => static::unprepareTranslations($value), 286 | ] 287 | ); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Collections/Translations.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | abstract class Translations implements Arrayable, Countable, Jsonable 17 | { 18 | /** 19 | * @var class-string 20 | */ 21 | public string $driver; 22 | 23 | /** 24 | * @var array> 25 | */ 26 | public array $items = []; 27 | 28 | /** 29 | * @param array>|Collection> $items 30 | */ 31 | final public function __construct(array|Collection $items = []) 32 | { 33 | $this->items = $items instanceof Collection ? $items->all() : $items; 34 | } 35 | 36 | abstract public function has(string $key): bool; 37 | 38 | /** 39 | * @return null|scalar|array 40 | */ 41 | abstract public function get(string $key): mixed; 42 | 43 | public function getString(string $key): string 44 | { 45 | $value = $this->get($key); 46 | 47 | if (is_array($value)) { 48 | return ''; 49 | } 50 | 51 | return (string) $value; 52 | } 53 | 54 | abstract public function set(string $key, null|int|float|string|bool $value): static; 55 | 56 | /** 57 | * @return Collection 58 | */ 59 | abstract public function dot(): Collection; 60 | 61 | /** 62 | * @param Collection>|array> $items 63 | */ 64 | abstract public static function undot(Collection|array $items): static; 65 | 66 | /** 67 | * @param array $keys 68 | */ 69 | abstract public function only(array $keys): static; 70 | 71 | /** 72 | * @param array $keys 73 | */ 74 | abstract public function except(array $keys): static; 75 | 76 | /** 77 | * @param Translations|array $values 78 | */ 79 | abstract public function merge(Translations|array $values): static; 80 | 81 | abstract public function diff(Translations $translations): static; 82 | 83 | /** 84 | * @param null|(callable(null|scalar|array, array-key):mixed) $callback 85 | */ 86 | abstract public function filter(?callable $callback = null): static; 87 | 88 | /** 89 | * @param null|(callable(null|scalar|array, array-key):mixed) $callback 90 | */ 91 | abstract public function map(?callable $callback = null): static; 92 | 93 | abstract public function sortKeys(int $options = SORT_REGULAR, bool $descending = false): static; 94 | 95 | public function notBlank(): static 96 | { 97 | return $this->filter( 98 | fn ($value) => ! blank($value) 99 | ); 100 | } 101 | 102 | /** 103 | * @return array> 104 | */ 105 | public function all(): array 106 | { 107 | return $this->items; 108 | } 109 | 110 | /** 111 | * @return array 112 | */ 113 | public function keys(): array 114 | { 115 | return array_keys($this->items); 116 | } 117 | 118 | /** 119 | * @return Collection> 120 | */ 121 | public function collect(): Collection 122 | { 123 | // @phpstan-ignore-next-line 124 | return new Collection($this->items); 125 | } 126 | 127 | /** 128 | * @deprecated Use `dot` method instead 129 | * 130 | * @return Collection 131 | */ 132 | public function toBase(): Collection 133 | { 134 | // @phpstan-ignore-next-line 135 | return $this->dot(); 136 | } 137 | 138 | public function count(): int 139 | { 140 | return count($this->items); 141 | } 142 | 143 | /** 144 | * @return array> 145 | */ 146 | public function toArray(): array 147 | { 148 | return $this->items; 149 | } 150 | 151 | public function toJson($options = 0): string 152 | { 153 | return json_encode($this->toArray(), $options) ?: ''; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Commands/AddLocaleCommand.php: -------------------------------------------------------------------------------- 1 | argument('locale'); 26 | /** @var string $source */ 27 | $source = $this->argument('source'); 28 | $translate = (bool) $this->option('translate'); 29 | 30 | $translator = $this->getTranslator(); 31 | 32 | $locales = $translator->getLocales(); 33 | 34 | intro('Using driver: '.$translator->driver::class); 35 | 36 | if (in_array($locale, $locales)) { 37 | info("{$locale} already exists."); 38 | 39 | return self::SUCCESS; 40 | } 41 | 42 | $translations = $translator->getTranslations($source); 43 | 44 | $count = $translations->count(); 45 | 46 | $translator->saveTranslations( 47 | $locale, 48 | $translations->map(fn () => null) 49 | ); 50 | 51 | info("{$locale} added with {$count} keys."); 52 | 53 | if ($translate) { 54 | $translated = spin(function () use ($translator, $source, $locale, $translations) { 55 | 56 | return $translator->translateTranslations( 57 | source: $source, 58 | target: $locale, 59 | keys: $translations->dot()->keys()->toArray() 60 | ); 61 | 62 | }, "Translating the {$count} missing translations from '{$source}' to '{$locale}'"); 63 | 64 | table( 65 | headers: ['Key', "Source {$source}", "Target {$locale}"], 66 | rows: $translated 67 | ->dot() 68 | ->map(function ($value, $key) use ($translations) { 69 | return [ 70 | (string) $key, 71 | (string) str($translations->getString($key))->limit(25), 72 | (string) str((string) $value)->limit(25), 73 | ]; 74 | })->toArray() 75 | ); 76 | } 77 | 78 | return self::SUCCESS; 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function promptForMissingArgumentsUsing(): array 85 | { 86 | return [ 87 | 'locale' => function () { 88 | return text( 89 | label: 'What locale would you like to add?', 90 | hint: implode(', ', $this->getLocales()).' already existen.', 91 | required: true, 92 | ); 93 | }, 94 | 'source' => function () { 95 | return select( 96 | label: 'What is the locale of reference?', 97 | options: $this->getLocales(), 98 | default: config('app.locale'), 99 | required: true, 100 | ); 101 | }, 102 | ]; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Commands/ClearCacheCommand.php: -------------------------------------------------------------------------------- 1 | components->info('Translator cache cleared.'); 21 | 22 | return self::SUCCESS; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Commands/DeadCommand.php: -------------------------------------------------------------------------------- 1 | argument('locale'); 26 | $sync = (bool) $this->option('sync'); 27 | 28 | $translator = $this->getTranslator(); 29 | 30 | $dead = $translator->getDeadTranslations($locale); 31 | 32 | intro('Using driver: '.$translator->driver::class); 33 | 34 | note(count($dead).' dead translations keys detected.'); 35 | 36 | table( 37 | headers: ['Key', "Translation {$locale}"], 38 | rows: $dead 39 | ->dot() 40 | ->map(function ($value, $key) { 41 | return [ 42 | $key, 43 | str((string) $value)->limit(50)->value(), 44 | ]; 45 | }) 46 | ->values() 47 | ->all() 48 | ); 49 | 50 | if ($sync) { 51 | 52 | $translator->deleteTranslations( 53 | locale: $locale, 54 | keys: $dead->dot()->keys()->all() 55 | ); 56 | 57 | note(count($dead).' dead translations deleted from the driver.'); 58 | 59 | } 60 | 61 | return self::SUCCESS; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Commands/ExportCommand.php: -------------------------------------------------------------------------------- 1 | argument('path'); 25 | 26 | $translator = $this->getTranslator(); 27 | 28 | intro('Using driver: '.$translator->driver::class); 29 | 30 | $translator->exportTranslations($path); 31 | 32 | info("Translations sucessfully exported here: {$path}"); 33 | 34 | return self::SUCCESS; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/ImportCommand.php: -------------------------------------------------------------------------------- 1 | argument('path'); 25 | 26 | $translator = $this->getTranslator(); 27 | 28 | intro('Using driver: '.$translator->driver::class); 29 | 30 | $imported = $translator->importTranslations($path); 31 | 32 | info('Translations sucessfully imported.'); 33 | 34 | return self::SUCCESS; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/LocalesCommand.php: -------------------------------------------------------------------------------- 1 | getTranslator(); 22 | 23 | $locales = $translator->getLocales(); 24 | 25 | intro('Using driver: '.$translator->driver::class); 26 | 27 | note(count($locales).' locales defined.'); 28 | 29 | info(implode(', ', $locales)); 30 | 31 | return self::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Commands/MissingCommand.php: -------------------------------------------------------------------------------- 1 | after(base_path()); 24 | } 25 | 26 | public function handle(): int 27 | { 28 | /** @var string $locale */ 29 | $locale = $this->argument('locale'); 30 | $sync = (bool) $this->option('sync'); 31 | 32 | /** @var null|Progress $progress */ 33 | $progress = null; 34 | 35 | $translator = $this->getTranslator(); 36 | 37 | $missing = $translator->getMissingTranslations( 38 | $locale, 39 | start: function (int $total) use (&$progress) { 40 | $progress = new Progress('Scanning your codebase', $total); 41 | $progress->start(); 42 | }, 43 | progress: function (string $path) use (&$progress) { 44 | $progress?->hint($this->formatPath($path)); 45 | $progress?->advance(); 46 | }, 47 | end: fn () => $progress?->finish(), 48 | ); 49 | 50 | $count = count($missing); 51 | 52 | intro('Using driver: '.$translator->driver::class); 53 | 54 | note("{$count} missing keys detected."); 55 | 56 | table( 57 | headers: ['Key', 'Count', 'Files'], 58 | rows: collect($missing) 59 | ->map(function ($value, $key) { 60 | return [ 61 | (string) str($key)->limit(20), 62 | (string) $value['count'], 63 | collect($value['files']) 64 | ->map(fn ($file) => $this->formatPath($file)) 65 | ->join("\n"), 66 | ]; 67 | })->values()->all() 68 | ); 69 | 70 | if ($sync) { 71 | 72 | $translator->setTranslations( 73 | locale: $locale, 74 | values: array_map(fn () => null, $missing) 75 | ); 76 | 77 | info("{$count} missing keys added to the driver."); 78 | 79 | } 80 | 81 | return self::SUCCESS; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Commands/ProofreadCommand.php: -------------------------------------------------------------------------------- 1 | argument('locale'); 26 | 27 | $translator = $this->getTranslator(); 28 | 29 | intro('Using driver: '.$translator->driver::class); 30 | 31 | $translations = $translator->getTranslations($locale); 32 | 33 | $proofread = spin( 34 | fn () => $translator->proofreadTranslations( 35 | locale: $locale, 36 | keys: $translations->dot()->keys()->all() 37 | ), 38 | 'Proofreading the translation strings.' 39 | ); 40 | 41 | table( 42 | headers: ['Key', 'Before', 'After'], 43 | rows: $translations 44 | ->dot() 45 | ->map(fn ($value, $key) => [ 46 | (string) $key, 47 | (string) str((string) $value)->limit(25), 48 | (string) str($proofread->getString($key))->limit(25), 49 | ]) 50 | ->all() 51 | ); 52 | 53 | return self::SUCCESS; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Commands/SortCommand.php: -------------------------------------------------------------------------------- 1 | argument('locale'); 25 | 26 | $translator = $this->getTranslator(); 27 | 28 | intro('Using driver: '.$translator->driver::class); 29 | 30 | $tranlations = $translator->sortTranslations($locale); 31 | 32 | table( 33 | headers: ['Key', 'Translation'], 34 | rows: $tranlations 35 | ->dot() 36 | ->map(fn ($value, $key) => [ 37 | (string) $key, 38 | (string) str((string) $value)->limit(50), 39 | ]) 40 | ->all() 41 | ); 42 | 43 | return self::SUCCESS; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/TranslatorCommand.php: -------------------------------------------------------------------------------- 1 | option('driver'); 21 | } 22 | 23 | public function getTranslator(): Translator 24 | { 25 | return \Elegantly\Translator\Facades\Translator::driver( 26 | $this->getDriverName() 27 | ); 28 | } 29 | 30 | public function getDriver(): Driver 31 | { 32 | return TranslatorServiceProvider::getDriverFromConfig( 33 | $this->getDriverName() 34 | ); 35 | } 36 | 37 | /** 38 | * @param array|string $except 39 | * @return array 40 | */ 41 | public function getLocales( 42 | array|string $except = [] 43 | ): array { 44 | return collect($this->getTranslator()->getLocales() ?: [config('app.locale')]) 45 | ->filter(fn ($value) => ! in_array($value, Arr::wrap($except))) 46 | ->values() 47 | ->all(); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function promptForMissingArgumentsUsing(): array 54 | { 55 | return [ 56 | 'locale' => function () { 57 | return select( 58 | label: 'What is the locale of reference?', 59 | options: $this->getLocales(), 60 | default: config('app.locale'), 61 | required: true, 62 | ); 63 | }, 64 | 'source' => function () { 65 | return select( 66 | label: 'What is the locale of reference?', 67 | options: $this->getLocales(), 68 | default: config('app.locale'), 69 | required: true, 70 | ); 71 | }, 72 | 'target' => function () { 73 | return select( 74 | label: 'What is the target locale?', 75 | options: $this->getLocales( 76 | // @phpstan-ignore-next-line 77 | except: $this->argument('source'), 78 | ), 79 | required: true, 80 | ); 81 | }, 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Commands/UntranslatedCommand.php: -------------------------------------------------------------------------------- 1 | argument('source'); 24 | /** @var string $target */ 25 | $target = $this->argument('target'); 26 | $translate = (bool) $this->option('translate'); 27 | 28 | $translator = $this->getTranslator(); 29 | 30 | $missing = $translator->getUntranslatedTranslations($source, $target); 31 | $count = $missing->count(); 32 | 33 | intro('Using driver: '.$translator->driver::class); 34 | 35 | note("{$count} untranslated keys detected."); 36 | 37 | table( 38 | headers: ['Key', "Source {$source}"], 39 | rows: $missing 40 | ->dot() 41 | ->map(fn ($value, $key) => [ 42 | $key, 43 | (string) str((string) $value)->limit(50), 44 | ])->toArray() 45 | ); 46 | 47 | if ($translate) { 48 | $translated = spin(function () use ($translator, $source, $target, $missing) { 49 | 50 | return $translator->translateTranslations( 51 | source: $source, 52 | target: $target, 53 | keys: $missing->dot()->keys()->all() 54 | ); 55 | 56 | }, "Translating the {$count} translations from '{$source}' to '{$target}'"); 57 | 58 | table( 59 | headers: ['Key', "Source {$source}", "Target {$target}"], 60 | rows: $translated 61 | ->dot() 62 | ->map(function ($value, $key) use ($missing) { 63 | return [ 64 | (string) $key, 65 | str($missing->getString($key))->limit(25)->value(), 66 | str((string) $value)->limit(25)->value(), 67 | ]; 68 | }) 69 | ->values() 70 | ->all() 71 | ); 72 | } 73 | 74 | return self::SUCCESS; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Contracts/ValidateLocales.php: -------------------------------------------------------------------------------- 1 | $config 15 | */ 16 | abstract public static function make(array $config = []): static; 17 | 18 | /** 19 | * A unique identifier for the driver 20 | */ 21 | abstract public function getKey(): string; 22 | 23 | /** 24 | * @return static[] 25 | */ 26 | abstract public function getSubDrivers(): array; 27 | 28 | /** 29 | * @return array 30 | */ 31 | abstract public function getLocales(): array; 32 | 33 | abstract public function getTranslations(string $locale): Translations; 34 | 35 | abstract public function saveTranslations(string $locale, Translations $translations): Translations; 36 | 37 | abstract public static function collect(): Translations; 38 | } 39 | -------------------------------------------------------------------------------- /src/Drivers/JsonDriver.php: -------------------------------------------------------------------------------- 1 | 'local', 26 | 'root' => config('translator.lang_path'), 27 | ...$config, 28 | ]) 29 | ); 30 | } 31 | 32 | public function getKey(): string 33 | { 34 | return $this->storage->path(''); 35 | } 36 | 37 | /** 38 | * @return static[] 39 | */ 40 | public function getSubDrivers(): array 41 | { 42 | return collect($this->storage->directories()) 43 | ->flatMap(function (string $directory) { 44 | $subdriver = static::make([ 45 | 'root' => $this->storage->path($directory), 46 | ]); 47 | 48 | return [ 49 | $subdriver, 50 | ...$subdriver->getSubDrivers(), 51 | ]; 52 | }) 53 | ->filter(function ($driver) { 54 | return ! empty($driver->getLocales()); 55 | }) 56 | ->sortBy(fn ($driver) => $driver->getKey()) 57 | ->values() 58 | ->all(); 59 | } 60 | 61 | public function getFilePath(string $locale): string 62 | { 63 | return "{$locale}.json"; 64 | } 65 | 66 | /** 67 | * @return string[] 68 | */ 69 | public function getLocales(): array 70 | { 71 | return collect($this->storage->files()) 72 | ->filter(fn (string $file) => File::extension($file) === 'json') 73 | ->map(fn (string $file) => File::name($file)) 74 | ->sort(SORT_NATURAL) 75 | ->values() 76 | ->toArray(); 77 | } 78 | 79 | public function getTranslations(string $locale): JsonTranslations 80 | { 81 | $path = $this->getFilePath($locale); 82 | 83 | if ($this->storage->exists($path)) { 84 | $content = $this->storage->get($path); 85 | 86 | return new JsonTranslations(json_decode($content, true)); 87 | } 88 | 89 | return new JsonTranslations; 90 | } 91 | 92 | /** 93 | * @template T of JsonTranslations 94 | * 95 | * @param T $translations 96 | * @return T 97 | */ 98 | public function saveTranslations(string $locale, Translations $translations): Translations 99 | { 100 | $this->storage->put( 101 | $this->getFilePath($locale), 102 | $translations->toJson(JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) 103 | ); 104 | 105 | return $translations; 106 | } 107 | 108 | public static function collect(): JsonTranslations 109 | { 110 | return new JsonTranslations; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Drivers/PhpDriver.php: -------------------------------------------------------------------------------- 1 | 'local', 26 | 'root' => config('translator.lang_path'), 27 | ...$config, 28 | ]) 29 | ); 30 | } 31 | 32 | public function getKey(): string 33 | { 34 | return $this->storage->path(''); 35 | } 36 | 37 | /** 38 | * @return static[] 39 | */ 40 | public function getSubDrivers(): array 41 | { 42 | return collect($this->storage->directories()) 43 | ->flatMap(function (string $directory) { 44 | $subdriver = static::make([ 45 | 'root' => $this->storage->path($directory), 46 | ]); 47 | 48 | return [ 49 | $subdriver, 50 | ...$subdriver->getSubDrivers(), 51 | ]; 52 | }) 53 | ->filter(function ($driver) { 54 | return collect($driver->getLocales()) 55 | ->contains(function ($locale) use ($driver) { 56 | return ! empty($driver->getNamespaces($locale)); 57 | }); 58 | }) 59 | ->sortBy(fn ($driver) => $driver->getKey()) 60 | ->values() 61 | ->all(); 62 | } 63 | 64 | public function getFilePath(string $locale, string $namespace): string 65 | { 66 | return "{$locale}/{$namespace}.php"; 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | public function getLocales(): array 73 | { 74 | return collect($this->storage->directories()) 75 | ->sort(SORT_NATURAL) 76 | ->values() 77 | ->all(); 78 | } 79 | 80 | /** 81 | * @return array 82 | */ 83 | public function getNamespaces(string $locale): array 84 | { 85 | return collect($this->storage->files($locale)) 86 | ->filter(fn (string $file) => File::extension($file) === 'php') 87 | ->map(fn (string $file) => File::name($file)) 88 | ->sort(SORT_NATURAL) 89 | ->values() 90 | ->all(); 91 | } 92 | 93 | public function getTranslations(string $locale): PhpTranslations 94 | { 95 | $values = collect($this->getNamespaces($locale)) 96 | ->mapWithKeys(function ($namespace) use ($locale) { 97 | return [$namespace => $this->getTranslationsInNamespace($locale, $namespace)]; 98 | }) 99 | ->all(); 100 | 101 | return new PhpTranslations( 102 | PhpTranslations::prepareTranslations($values) ?? [] 103 | ); 104 | } 105 | 106 | /** 107 | * This function uses eval and not include 108 | * Because using 'include' would cache/compile the code in opcache 109 | * Therefore it would not reflect the changes after the file is edited 110 | * 111 | * @return array 112 | */ 113 | public function getTranslationsInNamespace(string $locale, string $namespace): array 114 | { 115 | 116 | $path = $this->getFilePath($locale, $namespace); 117 | 118 | if ($this->storage->exists($path)) { 119 | $content = $this->storage->get($path); 120 | 121 | return eval( 122 | str($content) 123 | ->after('after('declare(strict_types=1);') 125 | ->value() 126 | ); 127 | } 128 | 129 | return []; 130 | 131 | } 132 | 133 | /** 134 | * @template T of Translations 135 | * 136 | * @param T $translations 137 | * @return T 138 | */ 139 | public function saveTranslations(string $locale, Translations $translations): Translations 140 | { 141 | $items = PhpTranslations::unprepareTranslations($translations->toArray()); 142 | 143 | foreach ($items as $namespace => $values) { 144 | 145 | $this->storage->put( 146 | $this->getFilePath($locale, $namespace), 147 | $this->toFile( 148 | is_array($values) ? $values : [] 149 | ) 150 | ); 151 | 152 | } 153 | 154 | return $translations; 155 | } 156 | 157 | /** 158 | * Write the lines of the inner array of the language file. 159 | * 160 | * @param array $values 161 | */ 162 | public function toFile(array $values): string 163 | { 164 | $content = "recursiveToFile($values); 167 | 168 | $content .= "\n];\n"; 169 | 170 | return $content; 171 | } 172 | 173 | /** 174 | * @param array> $items 175 | */ 176 | public function recursiveToFile( 177 | array $items, 178 | string $prefix = '', 179 | ): string { 180 | 181 | $output = ''; 182 | 183 | foreach ($items as $key => $value) { 184 | 185 | if (is_string($key)) { 186 | $key = str_replace('\"', '"', addslashes($key)); 187 | } 188 | 189 | if (is_array($value)) { 190 | $value = $this->recursiveToFile($value, $prefix.' '); 191 | 192 | if (is_string($key)) { 193 | $output .= "\n{$prefix} '{$key}' => [{$value}\n {$prefix}],"; 194 | } else { 195 | $output .= "\n{$prefix} [{$value}\n {$prefix}],"; 196 | } 197 | } else { 198 | 199 | if (is_string($value)) { 200 | $value = "'".str_replace('\"', '"', addslashes($value))."'"; 201 | } elseif (is_null($value)) { 202 | $value = 'null'; 203 | } elseif (is_bool($value)) { 204 | $value = $value ? 'true' : 'false'; 205 | } else { 206 | $value = (string) $value; 207 | } 208 | 209 | if (is_string($key)) { 210 | $output .= "\n{$prefix} '{$key}' => {$value},"; 211 | } else { 212 | $output .= "\n{$prefix} {$value},"; 213 | } 214 | } 215 | } 216 | 217 | return $output; 218 | } 219 | 220 | public static function collect(): PhpTranslations 221 | { 222 | return new PhpTranslations; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/Exceptions/TranslatorException.php: -------------------------------------------------------------------------------- 1 | getLocales() 21 | * @method static Translations getTranslations(string $locale) 22 | * @method static array getMissingTranslations(string $locale) 23 | * @method static Translations getDeadTranslations(string $locale) 24 | * @method static Translations getUntranslatedTranslations(string $source, string $target) 25 | * @method static Translations setTranslations(string $locale, array $values) 26 | * @method static Translations translateTranslations(string $source, string $target, array $keys) 27 | * @method static Translations proofreadTranslations(string $locale, array $keys, ?ProofreadServiceInterface $service = null) 28 | * @method static Translations deleteTranslations(string $locale, array $keys) 29 | * @method static Translations sortTranslations(string $locale) 30 | * @method static Translations saveTranslations(string $locale, Translations $translations) 31 | * @method static string exportTranslations(string $path, ?ExporterInterface $exporter = null) 32 | * @method static array> importTranslations(string $path, ?ExporterInterface $exporter = null) 33 | * @method static void clearCache() 34 | * 35 | * @see \Elegantly\Translator\Translator 36 | */ 37 | class Translator extends Facade 38 | { 39 | protected static function getFacadeAccessor(): string 40 | { 41 | return \Elegantly\Translator\Translator::class; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Services/Exporter/CsvExporterService.php: -------------------------------------------------------------------------------- 1 | addHeader(['key', ...$locales]); 32 | 33 | /** @var string[] $keys */ 34 | $keys = collect($translationsByLocale) 35 | ->flatMap(fn ($translations) => $translations->dot()->keys()->all()) 36 | ->unique() 37 | ->all(); 38 | 39 | foreach ($keys as $key) { 40 | 41 | $values = array_map(function ($locale) use ($translationsByLocale, $key) { 42 | 43 | $value = $translationsByLocale[$locale]->get($key); 44 | 45 | if (is_array($value)) { 46 | return null; 47 | } 48 | 49 | return $value; 50 | }, $locales); 51 | 52 | $writer->addRow([ 53 | 'key' => $key, 54 | ...$values, 55 | ]); 56 | } 57 | 58 | $writer->close(); 59 | 60 | return $path; 61 | 62 | } 63 | 64 | public function import(string $path): array 65 | { 66 | 67 | /** 68 | * @var array> $translationsByLocale 69 | */ 70 | $translationsByLocale = []; 71 | 72 | $rows = SimpleExcelReader::create($path)->getRows(); 73 | 74 | foreach ($rows as $row) { 75 | 76 | $key = Arr::pull($row, 'key'); 77 | 78 | foreach ($row as $locale => $value) { 79 | 80 | if (! array_key_exists($locale, $translationsByLocale)) { 81 | $translationsByLocale[$locale] = []; 82 | } 83 | 84 | if ($value) { 85 | $translationsByLocale[$locale][$key] = $value; 86 | } 87 | 88 | } 89 | 90 | } 91 | 92 | return $translationsByLocale; 93 | 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Services/Exporter/ExporterInterface.php: -------------------------------------------------------------------------------- 1 | $translationsByLocale 15 | */ 16 | public function export(array $translationsByLocale, string $path): string; 17 | 18 | /** 19 | * @return array> 20 | */ 21 | public function import(string $path): array; 22 | } 23 | -------------------------------------------------------------------------------- /src/Services/Proofread/OpenAiService.php: -------------------------------------------------------------------------------- 1 | withBaseUri($baseUri) 46 | ->withApiKey($apiKey) 47 | ->withOrganization($organization) 48 | ->withProject($project) 49 | ->withHttpHeader('OpenAI-Beta', 'assistants=v2') 50 | ->withHttpClient(new \GuzzleHttp\Client(['timeout' => $timeout])) 51 | ->make(); 52 | } 53 | 54 | public function proofreadAll(array $texts): array 55 | { 56 | return collect($texts) 57 | ->chunk(20) 58 | ->map(function (Collection $chunk) { 59 | $response = $this->client->chat()->create([ 60 | 'model' => $this->model, 61 | 'response_format' => ['type' => 'json_object'], 62 | 'messages' => [ 63 | [ 64 | 'role' => 'system', 65 | 'content' => $this->prompt, 66 | ], 67 | [ 68 | 'role' => 'user', 69 | 'content' => json_encode($chunk), 70 | ], 71 | ], 72 | ]); 73 | 74 | $content = $response->choices[0]->message->content; 75 | $translations = json_decode($content, true); 76 | 77 | return $translations; 78 | }) 79 | ->collapse() 80 | ->toArray(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Services/Proofread/ProofreadServiceInterface.php: -------------------------------------------------------------------------------- 1 | $texts 11 | * @return array 12 | */ 13 | public function proofreadAll(array $texts): array; 14 | 15 | public static function make(): self; 16 | } 17 | -------------------------------------------------------------------------------- /src/Services/SearchCode/PhpParserService.php: -------------------------------------------------------------------------------- 1 | $paths 32 | * @param array $excludedPaths 33 | */ 34 | public function __construct( 35 | public array $paths, 36 | public array $excludedPaths = [], 37 | null|string|Filesystem $cachePath = null, 38 | ) { 39 | if ($cachePath) { 40 | $this->cache = new SearchCodeCache( 41 | storage: is_string($cachePath) ? Storage::build(['driver' => 'local', 'root' => $cachePath]) : $cachePath 42 | ); 43 | } 44 | } 45 | 46 | public static function make(): self 47 | { 48 | return new self( 49 | paths: config('translator.searchcode.paths'), 50 | excludedPaths: config('translator.searchcode.excluded_paths', []), 51 | cachePath: config('translator.searchcode.services.php-parser.cache_path') 52 | ); 53 | } 54 | 55 | public function getCache(): ?SearchCodeCache 56 | { 57 | return $this->cache; 58 | } 59 | 60 | public function finder(): Finder 61 | { 62 | return Finder::create() 63 | ->in($this->paths) 64 | ->notPath($this->excludedPaths) 65 | ->exclude('vendor') 66 | ->exclude('node_modules') 67 | ->ignoreDotFiles(true) 68 | ->ignoreVCS(true) 69 | ->ignoreVCSIgnored(true) 70 | ->ignoreUnreadableDirs(true) 71 | ->name('*.php') 72 | ->followLinks() 73 | ->files(); 74 | } 75 | 76 | public static function isTranslationKeyFromPackage(string $key): bool 77 | { 78 | preg_match( 79 | '/^(?[a-zA-Z0-9-_]+)::(?.+)$/', 80 | $key, 81 | $matches 82 | ); 83 | 84 | return empty($matches); 85 | } 86 | 87 | public static function filterTranslationsKeys(?string $key): bool 88 | { 89 | 90 | if (blank($key)) { 91 | return false; 92 | } 93 | 94 | return static::isTranslationKeyFromPackage($key); 95 | } 96 | 97 | public static function isFunCallTo( 98 | FuncCall $node, 99 | string $function, 100 | string $argName, 101 | int $argPosition, 102 | string $argValue 103 | ): bool { 104 | if ($node->name instanceof Name && $node->name->name !== $function) { 105 | return false; 106 | } 107 | 108 | $args = collect($node->getArgs()); 109 | 110 | if ($args->isEmpty()) { 111 | return false; 112 | } 113 | 114 | $arg = $args->firstWhere('name.name', $argName) ?? $args->get($argPosition); 115 | 116 | if ($arg->value instanceof String_) { 117 | return $arg->value->value === $argValue; 118 | } 119 | 120 | return false; 121 | } 122 | 123 | /** 124 | * @return string[] All translations keys used in the code 125 | */ 126 | public static function scanCode(string $code): array 127 | { 128 | 129 | $parser = (new ParserFactory)->createForHostVersion(); 130 | 131 | $ast = $parser->parse($code); 132 | 133 | $nodeFinder = new NodeFinder; 134 | 135 | /** @var FuncCall[] $results */ 136 | $results = $nodeFinder->find($ast, function (Node $node) { 137 | 138 | if ( 139 | $node instanceof MethodCall && 140 | $node->var instanceof FuncCall && 141 | static::isFunCallTo($node->var, 'app', 'abstract', 0, 'translator') && 142 | $node->name instanceof Identifier 143 | ) { 144 | return in_array($node->name->name, ['get', 'has', 'hasForLocale', 'choice']); 145 | } 146 | 147 | if ( 148 | $node instanceof StaticCall && 149 | $node->class instanceof Name && 150 | $node->class->name === Lang::class && 151 | $node->name instanceof Identifier 152 | ) { 153 | return in_array($node->name->name, ['get', 'has', 'hasForLocale', 'choice']); 154 | } 155 | 156 | if ( 157 | $node instanceof FuncCall && 158 | $node->name instanceof Name 159 | ) { 160 | return in_array($node->name->name, ['__', 'trans', 'trans_choice']); 161 | } 162 | 163 | return false; 164 | }); 165 | 166 | return collect($results) 167 | ->map(function (FuncCall|StaticCall|MethodCall $node) { 168 | $args = collect($node->getArgs()); 169 | $argKey = $args->firstWhere('name.name', 'key') ?? $args->first(); 170 | 171 | $value = $argKey->value; 172 | 173 | $translationKey = $value instanceof String_ ? $value->value : null; 174 | 175 | return $translationKey; 176 | }) 177 | ->filter(fn ($value) => static::filterTranslationsKeys($value)) 178 | ->sort(SORT_NATURAL) 179 | ->values() 180 | ->toArray(); 181 | } 182 | 183 | public function translationsByFiles( 184 | ?Closure $progress = null, 185 | ?Closure $start = null, 186 | ?Closure $end = null, 187 | ): array { 188 | $finder = $this->finder(); 189 | 190 | $total = $finder->count(); 191 | 192 | if ($start) { 193 | $start($total); 194 | } 195 | 196 | $translations = collect($finder) 197 | ->map(function (SplFileInfo $file, string $path) use ($progress) { 198 | if ($progress) { 199 | $progress($path); 200 | } 201 | 202 | $lastModified = $file->getMTime(); 203 | $cachedResult = $this->cache?->get($path); 204 | 205 | if ( 206 | $lastModified && $cachedResult && 207 | $lastModified < $cachedResult['created_at'] 208 | ) { 209 | $translations = $cachedResult['translations']; 210 | } else { 211 | $content = str($file->getFilename())->endsWith('.blade.php') 212 | ? Blade::compileString($file->getContents()) 213 | : $file->getContents(); 214 | 215 | try { 216 | $translations = static::scanCode($content); 217 | } catch (\Throwable $th) { 218 | throw new Exception( 219 | "File can't be parsed: {$file->getPath()}. Your file might contain a syntax error. You can either fix the file or add it to the ignored path.", 220 | code: 422, 221 | previous: $th 222 | ); 223 | } 224 | $this->cache?->put($path, $translations); 225 | } 226 | 227 | return $translations; 228 | }) 229 | ->filter() 230 | ->sortKeys(SORT_NATURAL) 231 | ->toArray(); 232 | 233 | if ($end) { 234 | $end(); 235 | } 236 | 237 | return $translations; 238 | } 239 | 240 | public function filesByTranslations( 241 | ?Closure $progress = null, 242 | ?Closure $start = null, 243 | ?Closure $end = null, 244 | ): array { 245 | $translations = $this->translationsByFiles( 246 | progress: $progress, 247 | start: $start, 248 | end: $end 249 | ); 250 | 251 | $results = []; 252 | 253 | foreach ($translations as $file => $keys) { 254 | foreach ($keys as $key) { 255 | 256 | $results[$key] = [ 257 | 'count' => ($results[$key]['count'] ?? 0) + 1, 258 | 'files' => array_unique([ 259 | ...$results[$key]['files'] ?? [], 260 | $file, 261 | ]), 262 | ]; 263 | } 264 | } 265 | 266 | ksort($results, SORT_NATURAL); 267 | 268 | return $results; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Services/SearchCode/SearchCodeServiceInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function translationsByFiles( 23 | ?Closure $progress = null, 24 | ?Closure $start = null, 25 | ?Closure $end = null, 26 | ): array; 27 | 28 | /** 29 | * @param null|(Closure(string $path):void) $progress 30 | * @param null|(Closure(int $total):void) $start 31 | * @param null|(Closure():void) $end 32 | * @return array 33 | */ 34 | public function filesByTranslations( 35 | ?Closure $progress = null, 36 | ?Closure $start = null, 37 | ?Closure $end = null, 38 | ): array; 39 | } 40 | -------------------------------------------------------------------------------- /src/Services/Translate/OpenAiService.php: -------------------------------------------------------------------------------- 1 | withBaseUri($baseUri) 46 | ->withApiKey($apiKey) 47 | ->withOrganization($organization) 48 | ->withProject($project) 49 | ->withHttpHeader('OpenAI-Beta', 'assistants=v2') 50 | ->withHttpClient(new \GuzzleHttp\Client(['timeout' => $timeout])) 51 | ->make(); 52 | } 53 | 54 | public static function getTimeout(): int 55 | { 56 | return (int) (config('translator.translate.services.openai.request_timeout') ?? config('translator.services.openai.request_timeout') ?? 120); 57 | } 58 | 59 | /** 60 | * @template TValue 61 | * 62 | * @param (Closure():TValue) $callback 63 | * @return TValue 64 | */ 65 | protected function withTemporaryTimeout(int $limit, Closure $callback): mixed 66 | { 67 | $initial = (int) ini_get('max_execution_time'); 68 | 69 | set_time_limit($limit); 70 | 71 | try { 72 | return $callback(); 73 | } catch (\Throwable $th) { 74 | throw $th; 75 | } finally { 76 | set_time_limit($initial); 77 | } 78 | } 79 | 80 | public function translateAll(array $texts, string $targetLocale): array 81 | { 82 | return $this->withTemporaryTimeout( 83 | static::getTimeout(), 84 | function () use ($texts, $targetLocale) { 85 | return collect($texts) 86 | ->chunk(50) 87 | ->map(function ($chunk) use ($targetLocale) { 88 | $response = $this->client->chat()->create([ 89 | 'model' => $this->model, 90 | 'response_format' => ['type' => 'json_object'], 91 | 'messages' => [ 92 | [ 93 | 'role' => 'system', 94 | 'content' => str_replace('{targetLocale}', $targetLocale, $this->prompt), 95 | ], 96 | [ 97 | 'role' => 'user', 98 | 'content' => $chunk->toJson(), 99 | ], 100 | ], 101 | ]); 102 | 103 | $content = $response->choices[0]->message->content; 104 | $translations = json_decode($content, true); 105 | 106 | return $translations; 107 | }) 108 | ->collapse() 109 | ->toArray(); 110 | } 111 | ); 112 | 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Services/Translate/TranslateServiceInterface.php: -------------------------------------------------------------------------------- 1 | $texts 11 | * @return array 12 | */ 13 | public function translateAll(array $texts, string $targetLocale): array; 14 | 15 | public static function make(): self; 16 | } 17 | -------------------------------------------------------------------------------- /src/Support/LocaleValidator.php: -------------------------------------------------------------------------------- 1 | translateService, 33 | proofreadService: $this->proofreadService, 34 | searchcodeService: $this->searchcodeService, 35 | exporter: $this->exporter, 36 | ); 37 | } 38 | 39 | public function withProofreadService(ProofreadServiceInterface $service): static 40 | { 41 | return new static( 42 | driver: $this->driver, 43 | translateService: $this->translateService, 44 | proofreadService: $service, 45 | searchcodeService: $this->searchcodeService, 46 | exporter: $this->exporter 47 | ); 48 | } 49 | 50 | public function withTranslateService(TranslateServiceInterface $service): static 51 | { 52 | return new static( 53 | driver: $this->driver, 54 | translateService: $service, 55 | proofreadService: $this->proofreadService, 56 | searchcodeService: $this->searchcodeService, 57 | exporter: $this->exporter 58 | ); 59 | } 60 | 61 | public function withSearchcodeService(SearchCodeServiceInterface $service): static 62 | { 63 | return new static( 64 | driver: $this->driver, 65 | translateService: $this->translateService, 66 | proofreadService: $this->proofreadService, 67 | searchcodeService: $service, 68 | exporter: $this->exporter 69 | ); 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function getLocales(): array 76 | { 77 | if ($locales = TranslatorServiceProvider::getLocalesFromConfig()) { 78 | return $locales; 79 | } 80 | 81 | if ($validator = TranslatorServiceProvider::getLocaleValidator()) { 82 | return array_values(array_filter( 83 | $this->driver->getLocales(), 84 | fn ($locale) => $validator::make()->isValid($locale), 85 | )); 86 | } 87 | 88 | return $this->driver->getLocales(); 89 | } 90 | 91 | public function getTranslations(string $locale): Translations 92 | { 93 | return $this->driver->getTranslations($locale); 94 | } 95 | 96 | public function collect(): Translations 97 | { 98 | return ($this->driver)::collect(); 99 | } 100 | 101 | /** 102 | * Scan the codebase to find keys not present in the driver 103 | * 104 | * @param null|(Closure(string $path):void) $progress 105 | * @param null|(Closure(int $total):void) $start 106 | * @param null|(Closure():void) $end 107 | * @return array The translations keys defined in the codebase but not defined in the driver 108 | */ 109 | public function getMissingTranslations( 110 | string $locale, 111 | ?Closure $progress = null, 112 | ?Closure $start = null, 113 | ?Closure $end = null, 114 | ): array { 115 | if (! $this->searchcodeService) { 116 | throw TranslatorServiceException::missingSearchcodeService(); 117 | } 118 | 119 | $translations = $this->getTranslations($locale); 120 | 121 | $keys = $this->searchcodeService->filesByTranslations( 122 | progress: $progress, 123 | start: $start, 124 | end: $end 125 | ); 126 | 127 | return collect($keys) 128 | ->filter(function ($value, $key) use ($translations) { 129 | return ! $translations->has($key); 130 | }) 131 | ->all(); 132 | } 133 | 134 | /** 135 | * The translations defined in the driver but not defined in the codebase 136 | */ 137 | public function getDeadTranslations(string $locale): Translations 138 | { 139 | if (! $this->searchcodeService) { 140 | throw TranslatorServiceException::missingSearchcodeService(); 141 | } 142 | 143 | $defined = $this->searchcodeService->filesByTranslations(); 144 | 145 | return $this 146 | ->getTranslations($locale) 147 | ->except(array_keys($defined)); 148 | } 149 | 150 | public function getUntranslatedTranslations( 151 | string $source, 152 | string $target, 153 | ): Translations { 154 | 155 | $sourceTranslations = $this->getTranslations($source)->notBlank(); 156 | $targetTranslations = $this->getTranslations($target)->notBlank(); 157 | 158 | return $sourceTranslations->diff($targetTranslations); 159 | } 160 | 161 | /** 162 | * @param array $values 163 | */ 164 | public function setTranslations( 165 | string $locale, 166 | array $values 167 | ): Translations { 168 | 169 | if (empty($values)) { 170 | return $this->getTranslations($locale); 171 | } 172 | 173 | return $this->transformTranslations( 174 | locale: $locale, 175 | callback: function ($translations) use ($values) { 176 | return $translations->merge($values); 177 | }, 178 | sort: (bool) config('translator.sort_keys'), 179 | ); 180 | } 181 | 182 | public function setTranslation( 183 | string $locale, 184 | string $key, 185 | string|int|float|bool|null $value, 186 | ): Translations { 187 | return $this->setTranslations($locale, [$key => $value]); 188 | } 189 | 190 | /** 191 | * @param array $keys 192 | */ 193 | public function translateTranslations( 194 | string $source, 195 | string $target, 196 | array $keys, 197 | ?TranslateServiceInterface $service = null, 198 | ): Translations { 199 | $service = $service ?? $this->translateService; 200 | 201 | if (! $service) { 202 | throw TranslatorServiceException::missingTranslateService(); 203 | } 204 | 205 | if (empty($keys)) { 206 | return $this->getTranslations($target); 207 | } 208 | 209 | return $this->transformTranslations( 210 | locale: $target, 211 | callback: function ($translations) use ($source, $target, $keys, $service) { 212 | 213 | $sourceTranslations = $this->getTranslations($source) 214 | ->only($keys) 215 | ->notBlank() 216 | ->dot() 217 | ->all(); 218 | 219 | $translatedValues = $service->translateAll( 220 | $sourceTranslations, 221 | $target 222 | ); 223 | 224 | return $translations->merge($translatedValues); 225 | }, 226 | sort: (bool) config('translator.sort_keys'), 227 | )->only($keys); 228 | } 229 | 230 | public function translateTranslation( 231 | string $source, 232 | string $target, 233 | string $key, 234 | ?TranslateServiceInterface $service = null, 235 | ): Translations { 236 | return $this->translateTranslations( 237 | $source, 238 | $target, 239 | [$key], 240 | $service 241 | ); 242 | } 243 | 244 | /** 245 | * @param array $keys 246 | */ 247 | public function proofreadTranslations( 248 | string $locale, 249 | array $keys, 250 | ?ProofreadServiceInterface $service = null, 251 | ): Translations { 252 | $service = $service ?? $this->proofreadService; 253 | 254 | if (! $service) { 255 | throw TranslatorServiceException::missingProofreadService(); 256 | } 257 | 258 | if (empty($keys)) { 259 | return $this->getTranslations($locale); 260 | } 261 | 262 | return $this->transformTranslations( 263 | locale: $locale, 264 | callback: function ($translations) use ($service, $keys) { 265 | 266 | $proofreadValues = $service->proofreadAll( 267 | texts: $translations 268 | ->only($keys) 269 | ->notBlank() 270 | ->dot() 271 | ->all() 272 | ); 273 | 274 | return $translations->merge($proofreadValues); 275 | }, 276 | sort: (bool) config('translator.sort_keys'), 277 | )->only($keys); 278 | } 279 | 280 | public function proofreadTranslation( 281 | string $locale, 282 | string $key, 283 | ?ProofreadServiceInterface $service = null, 284 | ): Translations { 285 | return $this->proofreadTranslations( 286 | $locale, 287 | [$key], 288 | $service 289 | ); 290 | } 291 | 292 | /** 293 | * @param array $keys 294 | */ 295 | public function deleteTranslations( 296 | string $locale, 297 | array $keys, 298 | ): Translations { 299 | return $this->transformTranslations( 300 | $locale, 301 | function ($translations) use ($keys) { 302 | return $translations->except($keys); 303 | } 304 | ); 305 | } 306 | 307 | public function deleteTranslation( 308 | string $locale, 309 | string $key, 310 | ): Translations { 311 | 312 | return $this->deleteTranslations( 313 | $locale, 314 | [$key] 315 | ); 316 | } 317 | 318 | public function sortTranslations( 319 | string $locale, 320 | ): Translations { 321 | 322 | return $this->transformTranslations( 323 | locale: $locale, 324 | callback: fn ($translations) => $translations, 325 | sort: true, 326 | ); 327 | } 328 | 329 | /** 330 | * @param Closure(Translations $translations):Translations $callback 331 | */ 332 | public function transformTranslations( 333 | string $locale, 334 | Closure $callback, 335 | bool $sort = false, 336 | ): Translations { 337 | 338 | $translations = $this->getTranslations($locale); 339 | 340 | $translations = $callback($translations); 341 | 342 | if ($sort) { 343 | $translations = $translations->sortKeys(SORT_NATURAL); 344 | } 345 | 346 | return $this->saveTranslations( 347 | $locale, 348 | $translations 349 | ); 350 | 351 | } 352 | 353 | public function saveTranslations( 354 | string $locale, 355 | Translations $translations, 356 | ): Translations { 357 | 358 | return $this->driver->saveTranslations( 359 | $locale, 360 | $translations 361 | ); 362 | 363 | } 364 | 365 | public function exportTranslations( 366 | string $path, 367 | ?ExporterInterface $exporter = null 368 | ): string { 369 | $exporter = $exporter ?? $this->exporter; 370 | 371 | if (! $exporter) { 372 | throw TranslatorServiceException::missingExporterService(); 373 | } 374 | 375 | $locales = $this->getLocales(); 376 | 377 | $translationsByLocale = collect($locales) 378 | ->mapWithKeys(fn ($locale) => [$locale => $this->getTranslations($locale)]) 379 | ->all(); 380 | 381 | return $exporter->export($translationsByLocale, $path); 382 | 383 | } 384 | 385 | /** 386 | * @return array> 387 | */ 388 | public function importTranslations( 389 | string $path, 390 | ?ExporterInterface $exporter = null 391 | ): array { 392 | $exporter = $exporter ?? $this->exporter; 393 | 394 | if (! $exporter) { 395 | throw TranslatorServiceException::missingExporterService(); 396 | } 397 | 398 | $translationsByLocale = $exporter->import($path); 399 | 400 | foreach ($translationsByLocale as $locale => $values) { 401 | 402 | $this->transformTranslations( 403 | locale: $locale, 404 | callback: function ($translations) use ($values) { 405 | return $translations->merge($values); 406 | }, 407 | sort: (bool) config('translator.sort_keys'), 408 | ); 409 | 410 | } 411 | 412 | return $translationsByLocale; 413 | } 414 | 415 | public function clearCache(): void 416 | { 417 | $this->searchcodeService?->getCache()?->flush(); 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/TranslatorServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-translator') 43 | ->hasConfigFile() 44 | ->hasCommands([ 45 | SortCommand::class, 46 | LocalesCommand::class, 47 | AddLocaleCommand::class, 48 | DeadCommand::class, 49 | MissingCommand::class, 50 | UntranslatedCommand::class, 51 | ProofreadCommand::class, 52 | ClearCacheCommand::class, 53 | ExportCommand::class, 54 | ]); 55 | } 56 | 57 | public function registeringPackage(): void 58 | { 59 | $this->app->scoped(Translator::class, function () { 60 | return new Translator( 61 | driver: static::getDriverFromConfig(), 62 | translateService: static::getTranslateServiceFromConfig(), 63 | proofreadService: static::getProofreadServiceFromConfig(), 64 | searchcodeService: static::getSearchcodeServiceFromConfig(), 65 | exporter: static::getExporterServiceFromConfig(), 66 | ); 67 | }); 68 | } 69 | 70 | public static function getDriverFromConfig(?string $driverName = null): Driver 71 | { 72 | $driver = $driverName ?? config('translator.driver'); 73 | 74 | return match ($driver) { 75 | 'php' => PhpDriver::make(), 76 | 'json' => JsonDriver::make(), 77 | default => $driver::make(), 78 | }; 79 | } 80 | 81 | public static function getTranslateServiceFromConfig(?string $serviceName = null): ?TranslateServiceInterface 82 | { 83 | /** @var string|null $service */ 84 | $service = $serviceName ?? config('translator.translate.service'); 85 | 86 | if (! $service) { 87 | return null; 88 | } 89 | 90 | return match ($service) { 91 | 'openai' => OpenAiService::make(), 92 | default => $service::make(), 93 | }; 94 | } 95 | 96 | public static function getProofreadServiceFromConfig(?string $serviceName = null): ?ProofreadServiceInterface 97 | { 98 | /** @var string|null $service */ 99 | $service = $serviceName ?? config('translator.proofread.service'); 100 | 101 | if (! $service) { 102 | return null; 103 | } 104 | 105 | return match ($service) { 106 | 'openai' => ProofreadOpenAiService::make(), 107 | default => $service::make(), 108 | }; 109 | } 110 | 111 | public static function getSearchcodeServiceFromConfig(?string $serviceName = null): ?SearchCodeServiceInterface 112 | { 113 | /** @var string|null $service */ 114 | $service = $serviceName ?? config('translator.searchcode.service'); 115 | 116 | if (! $service) { 117 | return null; 118 | } 119 | 120 | return match ($service) { 121 | 'php-parser' => PhpParserService::make(), 122 | default => $service::make(), 123 | }; 124 | } 125 | 126 | public static function getExporterServiceFromConfig(?string $serviceName = null): ?ExporterInterface 127 | { 128 | /** @var string|null $service */ 129 | $service = $serviceName ?? config('translator.exporter.service', CsvExporterService::class); 130 | 131 | if (! $service) { 132 | return null; 133 | } 134 | 135 | return match ($service) { 136 | 'csv' => CsvExporterService::make(), 137 | default => $service::make(), 138 | }; 139 | } 140 | 141 | /** 142 | * @return ?array 143 | */ 144 | public static function getLocalesFromConfig(): ?array 145 | { 146 | /** @var array|class-string */ 147 | $locales = config('translator.locales'); 148 | 149 | if (is_array($locales)) { 150 | return $locales; 151 | } 152 | 153 | return null; 154 | } 155 | 156 | /** 157 | * @return null|class-string 158 | */ 159 | public static function getLocaleValidator(): ?string 160 | { 161 | /** @var array|class-string */ 162 | $validator = config('translator.locales', LocaleValidator::class); 163 | 164 | if (is_array($validator)) { 165 | return null; 166 | } 167 | 168 | return $validator; 169 | } 170 | } 171 | --------------------------------------------------------------------------------