├── .gitignore ├── phpstan.neon ├── tests ├── bootstrap.php └── Unit │ └── Services │ ├── Finders │ └── LanguagesFinderTest.php │ ├── Generators │ ├── JsonFileGeneratorTest.php │ └── PhpFileGeneratorTest.php │ ├── MakeTranslationFileServiceTest.php │ └── TranslationsFixerTest.php ├── src ├── Exceptions │ └── FallbackLanguageFileNotExistsException.php ├── LaravelTranslationGeneratorServiceProvider.php ├── Services │ ├── Finders │ │ ├── LanguagesFinder.php │ │ └── TranslationFilesFinder.php │ ├── MakeTranslationFileService.php │ ├── PackagesTranslationsService.php │ ├── TranslationsFixer.php │ └── Generators │ │ ├── JsonFileGenerator.php │ │ ├── TranslationGenerator.php │ │ └── PhpFileGenerator.php └── Console │ └── Commands │ ├── MakeTranslationFileCommand.php │ └── MakeTranslationCommand.php ├── phpunit.xml ├── LICENSE.md ├── .github └── workflows │ └── ci.yml ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .idea 4 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-mockery/extension.neon 3 | 4 | parameters: 5 | paths: 6 | - src 7 | - tests 8 | 9 | level: 5 10 | 11 | ignoreErrors: 12 | - '#Function config not found#' -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 14 | $this->commands([ 15 | MakeTranslationCommand::class, 16 | MakeTranslationFileCommand::class, 17 | ]); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | tests/Unit 12 | 13 | 14 | 15 | 16 | src 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Services/Finders/LanguagesFinder.php: -------------------------------------------------------------------------------- 1 | filter( 14 | fn (string $directory) => $this->filterDirectory($directory) 15 | )->mapWithKeys( 16 | fn (string $directory) => [$directory => $directory] 17 | ); 18 | } 19 | 20 | private function filterDirectory(string $directory): bool 21 | { 22 | return ! in_array($directory, self::IGNORED_DIRECTORIES) && is_dir(lang_path($directory)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Services/MakeTranslationFileService.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 19 | $languages = $this->languagesFinder->getAvailableLanguages(); 20 | } 21 | 22 | $languages->each( 23 | fn (string $lang) => $this->generateFile($name, $lang) 24 | ); 25 | } 26 | 27 | private function generateFile(string $name, string $lang): void 28 | { 29 | $path = lang_path("$lang/$name.php"); 30 | 31 | file_put_contents($path, $this->phpFileGenerator->parseContent()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Krystian Zaręba 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | php: [8.2, 8.3, 8.4, 8.5] 16 | 17 | name: Test (PHP ${{ matrix.php }}) 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Setup PHP 23 | uses: shivammathur/setup-php@v2 24 | with: 25 | php-version: ${{ matrix.php }} 26 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, json 27 | coverage: none 28 | 29 | - name: Validate composer.json and composer.lock 30 | run: composer validate 31 | 32 | - name: Cache Composer packages 33 | id: composer-cache 34 | uses: actions/cache@v4 35 | with: 36 | path: vendor 37 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-php- 40 | 41 | - name: Install dependencies 42 | run: composer install --prefer-dist --no-progress 43 | 44 | - name: Run Laravel Pint 45 | run: ./vendor/bin/pint --test 46 | 47 | - name: Run PHPStan 48 | run: ./vendor/bin/phpstan analyse 49 | 50 | - name: Run PHPUnit tests 51 | run: ./vendor/bin/phpunit -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "krzar/laravel-translation-generator", 3 | "description": "Translations generator for laravel apps.", 4 | "keywords": [ 5 | "laravel", 6 | "translations", 7 | "translations-generator" 8 | ], 9 | "license": "MIT", 10 | "version": "v4.0.1", 11 | "authors": [ 12 | { 13 | "name": "Krystian Zaręba" 14 | } 15 | ], 16 | "autoload": { 17 | "psr-4": { 18 | "Krzar\\LaravelTranslationGenerator\\": "src/" 19 | } 20 | }, 21 | "autoload-dev": { 22 | "psr-4": { 23 | "Krzar\\LaravelTranslationGenerator\\Tests\\": "tests/" 24 | } 25 | }, 26 | "require": { 27 | "php": "^8.2", 28 | "illuminate/console": "^11.0|^12.0", 29 | "illuminate/support": "^11.0|^12.0", 30 | "illuminate/collections": "^11.0|^12.0", 31 | "illuminate/container": "^11.0|^12.0", 32 | "laravel/prompts": "^0.1.11|^12.0", 33 | "ext-json": "*" 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Krzar\\LaravelTranslationGenerator\\LaravelTranslationGeneratorServiceProvider" 39 | ] 40 | } 41 | }, 42 | "require-dev": { 43 | "laravel/pint": "^1.4", 44 | "phpunit/phpunit": "^11.0", 45 | "mockery/mockery": "^1.6", 46 | "phpstan/phpstan": "^2.1", 47 | "phpstan/phpstan-mockery": "^2.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Services/Finders/TranslationFilesFinder.php: -------------------------------------------------------------------------------- 1 | filter(fn (string $file) => Str::endsWith($file, self::PHP_EXT)); 24 | } 25 | 26 | return collect(); 27 | } 28 | 29 | public static function jsonFile(string $lang, ?string $package = null): string 30 | { 31 | if ($package) { 32 | return lang_path("vendor/$package/$lang".self::JSON_EXT); 33 | } 34 | 35 | return lang_path($lang.self::JSON_EXT); 36 | } 37 | 38 | public static function jsonFiles(?string $package = null): Collection 39 | { 40 | $path = $package ? lang_path("vendor/$package") : lang_path(); 41 | 42 | if (file_exists($path)) { 43 | return collect(scandir($path))->filter(fn (string $file) => Str::endsWith($file, self::JSON_EXT)); 44 | } 45 | 46 | return collect(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Services/PackagesTranslationsService.php: -------------------------------------------------------------------------------- 1 | filter( 20 | fn (string $fileName) => $fileName !== '.' && $fileName !== '..' 21 | ); 22 | 23 | return $packages->count() > 0 ? $packages : null; 24 | } 25 | 26 | public function getPhpTranslationsFiles(string $fallback): Collection 27 | { 28 | return $this->findPackages()->flatMap( 29 | fn (string $package) => $this->getPackagePhpTranslationsFiles($package, $fallback) 30 | ); 31 | } 32 | 33 | private function getPackagePhpTranslationsFiles(string $package, string $fallback): Collection 34 | { 35 | $path = lang_path(sprintf('%s/%s/%s', self::VENDOR_PATH, $package, $fallback)); 36 | 37 | return collect(scandir($path))->filter( 38 | fn (string $fileName) => $fileName !== '.' && $fileName !== '..' 39 | )->map(fn (string $fileName) => sprintf( 40 | '%s/%s/%s/%s', 41 | self::VENDOR_PATH, 42 | $package, 43 | $fallback, 44 | $fileName 45 | )); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Services/TranslationsFixer.php: -------------------------------------------------------------------------------- 1 | map( 12 | fn (string|array $value) => is_string($value) ? '' : self::fixToEmpty(collect($value)) 13 | ); 14 | } 15 | 16 | public static function fixToOtherTranslations( 17 | Collection $translations, 18 | Collection $otherTranslations, 19 | bool $clearIfNotExists = false 20 | ): Collection { 21 | return $translations->map(fn (string|array $value, string $key) => self::fixToOtherTranslationSingle( 22 | $value, 23 | $otherTranslations->get($key), 24 | $clearIfNotExists 25 | )); 26 | } 27 | 28 | public static function fixToOtherTranslationSingle( 29 | string|array $translation, 30 | string|array|null $otherTranslation, 31 | bool $clearIfNotExists = false 32 | ): string|Collection { 33 | if (is_string($translation)) { 34 | if ($otherTranslation !== null) { 35 | return is_array($otherTranslation) ? collect($otherTranslation) : $otherTranslation; 36 | } 37 | 38 | return $clearIfNotExists ? '' : $translation; 39 | } 40 | 41 | return self::fixToOtherTranslations( 42 | collect($translation), 43 | collect($translation) 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Services/Generators/JsonFileGenerator.php: -------------------------------------------------------------------------------- 1 | filesNotExists()) { 14 | return; 15 | } 16 | 17 | $this->generateSingle(); 18 | 19 | if ($this->generatePackagesTranslations) { 20 | $this->generatePackagesFiles(); 21 | } 22 | } 23 | 24 | protected function getTranslations(string $locale): ?Collection 25 | { 26 | $path = TranslationFilesFinder::jsonFile($locale, $this->currentPackage); 27 | 28 | return file_exists($path) ? collect(json_decode(file_get_contents($path), true)) : null; 29 | } 30 | 31 | protected function putToFile(Collection $translations): void 32 | { 33 | $targetPath = TranslationFilesFinder::jsonFile($this->lang, $this->currentPackage); 34 | $content = json_encode($translations, JSON_PRETTY_PRINT); 35 | 36 | file_put_contents($targetPath, $content); 37 | } 38 | 39 | /** 40 | * @throws FallbackLanguageFileNotExistsException 41 | */ 42 | private function generatePackagesFiles(): void 43 | { 44 | $this->packagesTranslationsService->findPackages()->each(function (string $package) { 45 | $this->currentPackage = $package; 46 | $this->generateSingle(); 47 | }); 48 | } 49 | 50 | private function filesNotExists(): bool 51 | { 52 | return TranslationFilesFinder::jsonFiles($this->currentPackage)->isEmpty(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeTranslationFileCommand.php: -------------------------------------------------------------------------------- 1 | getFileName(); 30 | $languages = $this->getLanguages(); 31 | 32 | $this->makeTranslationFileService->generate($fileName, $languages); 33 | 34 | info("Translation file '$fileName.php' has been created for given languages."); 35 | 36 | return self::SUCCESS; 37 | } 38 | 39 | private function getFileName(): string 40 | { 41 | $fileName = $this->argument('name'); 42 | 43 | if (empty($fileName)) { 44 | $fileName = text('Enter translation file name'); 45 | } 46 | 47 | return $fileName; 48 | } 49 | 50 | private function getLanguages(): Collection 51 | { 52 | $availableLanguages = $this->languagesFinder->getAvailableLanguages(); 53 | 54 | return collect(multiselect( 55 | label: 'Select languages for which you want to create translation file.', 56 | options: $availableLanguages, 57 | hint: 'If you want to select all languages, just click Enter.' 58 | )); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Services/Generators/TranslationGenerator.php: -------------------------------------------------------------------------------- 1 | packagesTranslationsService = new PackagesTranslationsService; 26 | } 27 | 28 | /** 29 | * @throws FallbackLanguageFileNotExistsException 30 | */ 31 | protected function generateSingle(): void 32 | { 33 | $translations = $this->getTranslations($this->fallback); 34 | 35 | if ($translations === null) { 36 | throw new FallbackLanguageFileNotExistsException( 37 | $this->fallback, 38 | $this->currentFileName ?: "$this->fallback.json" 39 | ); 40 | } 41 | 42 | $currentTranslations = $this->getTranslations($this->lang); 43 | 44 | if (! $this->overwrite && $currentTranslations) { 45 | $translations = TranslationsFixer::fixToOtherTranslations( 46 | $translations, 47 | $currentTranslations, 48 | $this->clearValues 49 | ); 50 | } elseif ($this->clearValues) { 51 | $translations = TranslationsFixer::fixToEmpty($translations); 52 | } 53 | 54 | $this->putToFile($translations); 55 | } 56 | 57 | /** 58 | * @throws FallbackLanguageFileNotExistsException 59 | */ 60 | abstract public function generate(): void; 61 | 62 | abstract protected function putToFile(Collection $translations): void; 63 | 64 | abstract protected function getTranslations(string $locale): ?Collection; 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Translation Generator 2 | ![GitHub License](https://img.shields.io/github/license/krzar/laravel-translation-generator) 3 | ![GitHub Release](https://img.shields.io/github/v/release/krzar/laravel-translation-generator?cacheSeconds=300) 4 | ![GitHub last commit](https://img.shields.io/github/last-commit/krzar/laravel-translation-generator?cacheSeconds=300) 5 | ![GitHub branch check runs](https://img.shields.io/github/check-runs/krzar/laravel-translation-generator/master?cacheSeconds=300) 6 | 7 | This package allows you to: 8 | - Generate translation files for the specified language, 9 | - Generate new translation files for each language, 10 | - Completing missing keys for translations 11 | 12 | ## Requirements 13 | 14 | | Laravel | PHP | Package | Supported | 15 | |:-----------:|:----:|:-------:|:------------------:| 16 | | From 11.x | 8.2+ | 4.x | :white_check_mark: | 17 | | From 10.x | 8.1+ | 3.x | :x: | 18 | | 6.x to 10.x | 8.0+ | 2.x | :x: | 19 | 20 | ## Installation 21 | 22 | ```bash 23 | composer require krzar/laravel-translation-generator 24 | ``` 25 | 26 | ## Usage 27 | 28 | > [!NOTE] 29 | > Remember, this package supports Laravel Prompts. So you can skip commands arguments and just answer the questions. 30 | 31 | ### Generate new translation 32 | 33 | Generate new translation files for `es` language. 34 | ```bash 35 | php artisan make:translation es 36 | ``` 37 | 38 | If the file exists, it will be completed with the missing keys. 39 | 40 | Files and keys will be copied based on the fallback locale specified in the app configuration. 41 | 42 | You can change fallback locale. 43 | 44 | **If you have published any translations from other packages the command will ask you if you want to generate 45 | new language translations for them as well.** 46 | 47 | ```bash 48 | php artisan make:translation es --fallback=de 49 | ``` 50 | 51 | You can also overwrite all values if file currently exists. 52 | 53 | ```bash 54 | php artisan make:translation es --overwrite 55 | ``` 56 | 57 | All values will be copied from fallback locale by default. 58 | If you want to clear every translation value you can use clear-values option. 59 | 60 | ```bash 61 | php artisan make:translation es --clear-values 62 | ``` 63 | 64 | This will clear values only for new keys, to clear everything, combine two options. 65 | 66 | ```bash 67 | php artisan make:translation es --clear-values --overwrite 68 | ``` 69 | 70 | ### Generate new translation file 71 | 72 | Generate new php translation file for every language. 73 | 74 | ```bash 75 | php artisan make:translation-file common 76 | ``` 77 | 78 | This will generate new php file `common.php` in every language folder (except packages translations folders). 79 | -------------------------------------------------------------------------------- /src/Services/Generators/PhpFileGenerator.php: -------------------------------------------------------------------------------- 1 | generateFiles(); 17 | 18 | if ($this->generatePackagesTranslations) { 19 | $this->generatePackagesFiles(); 20 | } 21 | } 22 | 23 | public function parseContent(?Collection $translations = null): string 24 | { 25 | return sprintf( 26 | 'translationsToString($translations) : '' 30 | ); 31 | } 32 | 33 | /** 34 | * @throws FallbackLanguageFileNotExistsException 35 | */ 36 | private function generatePackagesFiles(): void 37 | { 38 | $this->packagesTranslationsService->findPackages()->each(function (string $package) { 39 | $this->currentPackage = $package; 40 | $this->generateFiles(); 41 | }); 42 | } 43 | 44 | /** 45 | * @throws FallbackLanguageFileNotExistsException 46 | */ 47 | private function generateFiles(): void 48 | { 49 | $this->setTargetPath(); 50 | 51 | TranslationFilesFinder::phpFiles($this->fallback, $this->currentPackage)->each(function (string $fileName) { 52 | $this->currentFileName = $fileName; 53 | 54 | $this->generateSingle(); 55 | }); 56 | } 57 | 58 | private function setTargetPath(): void 59 | { 60 | $this->targetPath = lang_path( 61 | $this->currentPackage ? "vendor/$this->currentPackage/$this->lang" : $this->lang 62 | ); 63 | 64 | if (! file_exists($this->targetPath)) { 65 | mkdir($this->targetPath); 66 | } 67 | } 68 | 69 | private function translationsToString(Collection $translations, int $level = 1): string 70 | { 71 | $tabs = sprintf("%'\t{$level}s", ''); 72 | 73 | return $translations->reduce(function (string $string, mixed $value, string $key) use ($level, $tabs) { 74 | if (is_string($value)) { 75 | $toAppend = sprintf("$tabs\"%s\" => \"%s\",\n", $key, $value); 76 | } else { 77 | $toAppend = sprintf( 78 | "$tabs\"%s\" => [\n%s$tabs],\n", 79 | $key, 80 | $this->translationsToString(collect($value), $level + 1) 81 | ); 82 | } 83 | 84 | return "{$string}{$toAppend}"; 85 | }, ''); 86 | } 87 | 88 | protected function getTranslations(string $locale): ?Collection 89 | { 90 | $path = $this->currentPackage ? "$this->currentPackage::$this->currentFileName" : $this->currentFileName; 91 | 92 | $translations = Lang::get(str_replace('.php', '', $path), [], $locale); 93 | 94 | return $translations !== '' ? collect($translations) : null; 95 | } 96 | 97 | protected function putToFile(Collection $translations): void 98 | { 99 | $targetPath = "$this->targetPath/$this->currentFileName"; 100 | $content = $this->parseContent($translations); 101 | 102 | file_put_contents($targetPath, $content); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/Unit/Services/Finders/LanguagesFinderTest.php: -------------------------------------------------------------------------------- 1 | tempLangPath = sys_get_temp_dir().'/test_lang_'.uniqid(); 20 | mkdir($this->tempLangPath); 21 | 22 | $GLOBALS['tempLangPath'] = $this->tempLangPath; 23 | 24 | $this->finder = new LanguagesFinder; 25 | } 26 | 27 | protected function tearDown(): void 28 | { 29 | if (is_dir($this->tempLangPath)) { 30 | $this->removeDirectory($this->tempLangPath); 31 | } 32 | parent::tearDown(); 33 | } 34 | 35 | #[Test] 36 | public function finds_existing_language_directories(): void 37 | { 38 | mkdir($this->tempLangPath.'/en'); 39 | mkdir($this->tempLangPath.'/pl'); 40 | mkdir($this->tempLangPath.'/de'); 41 | 42 | file_put_contents($this->tempLangPath.'/en/messages.php', 'tempLangPath.'/pl/messages.php', 'tempLangPath.'/de/messages.php', 'finder->getAvailableLanguages(); 47 | 48 | $this->assertCount(3, $result); 49 | $this->assertTrue($result->offsetExists('en')); 50 | $this->assertTrue($result->offsetExists('pl')); 51 | $this->assertTrue($result->offsetExists('de')); 52 | } 53 | 54 | #[Test] 55 | public function ignores_system_directories(): void 56 | { 57 | mkdir($this->tempLangPath.'/en'); 58 | file_put_contents($this->tempLangPath.'/en/messages.php', 'tempLangPath.'/some_file.txt'); 61 | 62 | $result = $this->finder->getAvailableLanguages(); 63 | 64 | $this->assertCount(1, $result); 65 | $this->assertTrue($result->offsetExists('en')); 66 | $this->assertFalse($result->contains('some_file.txt')); 67 | } 68 | 69 | #[Test] 70 | public function returns_empty_collection_when_no_language_directories(): void 71 | { 72 | touch($this->tempLangPath.'/some_file.txt'); 73 | 74 | $result = $this->finder->getAvailableLanguages(); 75 | 76 | $this->assertCount(0, $result); 77 | } 78 | 79 | #[Test] 80 | public function keys_and_values_are_identical(): void 81 | { 82 | mkdir($this->tempLangPath.'/en'); 83 | mkdir($this->tempLangPath.'/pl'); 84 | file_put_contents($this->tempLangPath.'/en/messages.php', 'tempLangPath.'/pl/messages.php', 'finder->getAvailableLanguages(); 88 | 89 | $result->each(function ($value, $key) { 90 | $this->assertEquals($key, $value, "Key '$key' should equal value '$value'"); 91 | }); 92 | } 93 | 94 | private function removeDirectory(string $dir): void 95 | { 96 | if (! is_dir($dir)) { 97 | return; 98 | } 99 | 100 | $files = array_diff(scandir($dir), ['.', '..']); 101 | foreach ($files as $file) { 102 | $path = $dir.DIRECTORY_SEPARATOR.$file; 103 | is_dir($path) ? $this->removeDirectory($path) : unlink($path); 104 | } 105 | rmdir($dir); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Unit/Services/Generators/JsonFileGeneratorTest.php: -------------------------------------------------------------------------------- 1 | tempLangPath = sys_get_temp_dir().'/test_lang_'.uniqid(); 18 | mkdir($this->tempLangPath); 19 | 20 | $GLOBALS['tempLangPath'] = $this->tempLangPath; 21 | 22 | } 23 | 24 | protected function tearDown(): void 25 | { 26 | if (is_dir($this->tempLangPath)) { 27 | $this->removeDirectory($this->tempLangPath); 28 | } 29 | parent::tearDown(); 30 | } 31 | 32 | #[Test] 33 | public function generates_json_translation_file_from_fallback(): void 34 | { 35 | 36 | $fallbackTranslations = [ 37 | 'welcome' => 'Welcome', 38 | 'goodbye' => 'Goodbye', 39 | ]; 40 | 41 | file_put_contents( 42 | $this->tempLangPath.'/pl.json', 43 | json_encode($fallbackTranslations, JSON_PRETTY_PRINT) 44 | ); 45 | 46 | $generator = new JsonFileGenerator('en', 'pl', false, false, false); 47 | $generator->generate(); 48 | 49 | $this->assertFileExists($this->tempLangPath.'/en.json'); 50 | 51 | $generatedContent = json_decode( 52 | file_get_contents($this->tempLangPath.'/en.json'), 53 | true 54 | ); 55 | 56 | $this->assertEquals($fallbackTranslations, $generatedContent); 57 | } 58 | 59 | #[Test] 60 | public function skips_generation_when_no_json_files_exist(): void 61 | { 62 | 63 | $generator = new JsonFileGenerator('en', 'pl', false, false, false); 64 | $generator->generate(); 65 | 66 | $this->assertFileDoesNotExist($this->tempLangPath.'/en.json'); 67 | } 68 | 69 | #[Test] 70 | public function merges_translations_when_target_file_exists(): void 71 | { 72 | 73 | $fallbackTranslations = [ 74 | 'welcome' => 'Welcome', 75 | 'goodbye' => 'Goodbye', 76 | 'new_key' => 'New value', 77 | ]; 78 | 79 | $existingTranslations = [ 80 | 'welcome' => 'Hello', 81 | 'existing' => 'Existing value', 82 | ]; 83 | 84 | file_put_contents( 85 | $this->tempLangPath.'/pl.json', 86 | json_encode($fallbackTranslations, JSON_PRETTY_PRINT) 87 | ); 88 | 89 | file_put_contents( 90 | $this->tempLangPath.'/en.json', 91 | json_encode($existingTranslations, JSON_PRETTY_PRINT) 92 | ); 93 | 94 | $generator = new JsonFileGenerator('en', 'pl', false, false, false); 95 | $generator->generate(); 96 | 97 | $generatedContent = json_decode( 98 | file_get_contents($this->tempLangPath.'/en.json'), 99 | true 100 | ); 101 | 102 | $this->assertEquals('Hello', $generatedContent['welcome']); 103 | $this->assertArrayNotHasKey('existing', $generatedContent); 104 | $this->assertEquals('New value', $generatedContent['new_key']); 105 | $this->assertEquals('Goodbye', $generatedContent['goodbye']); 106 | } 107 | 108 | private function removeDirectory(string $dir): void 109 | { 110 | if (! is_dir($dir)) { 111 | return; 112 | } 113 | 114 | $files = array_diff(scandir($dir), ['.', '..']); 115 | foreach ($files as $file) { 116 | $path = $dir.DIRECTORY_SEPARATOR.$file; 117 | is_dir($path) ? $this->removeDirectory($path) : unlink($path); 118 | } 119 | rmdir($dir); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/Unit/Services/MakeTranslationFileServiceTest.php: -------------------------------------------------------------------------------- 1 | tempLangPath = sys_get_temp_dir().'/test_lang_'.uniqid(); 28 | mkdir($this->tempLangPath); 29 | 30 | $GLOBALS['tempLangPath'] = $this->tempLangPath; 31 | 32 | $this->phpFileGenerator = Mockery::mock(PhpFileGenerator::class); 33 | $this->languagesFinder = Mockery::mock(LanguagesFinder::class); 34 | 35 | $this->service = new MakeTranslationFileService( 36 | $this->phpFileGenerator, 37 | $this->languagesFinder 38 | ); 39 | } 40 | 41 | protected function tearDown(): void 42 | { 43 | if (is_dir($this->tempLangPath)) { 44 | $this->removeDirectory($this->tempLangPath); 45 | } 46 | Mockery::close(); 47 | parent::tearDown(); 48 | } 49 | 50 | #[Test] 51 | public function generates_files_for_provided_languages(): void 52 | { 53 | $languages = collect(['en', 'pl', 'de']); 54 | $fileName = 'messages'; 55 | $fileContent = 'tempLangPath.'/en'); 58 | mkdir($this->tempLangPath.'/pl'); 59 | mkdir($this->tempLangPath.'/de'); 60 | 61 | $this->phpFileGenerator->shouldReceive('parseContent') 62 | ->times(3) 63 | ->andReturn($fileContent); 64 | 65 | $this->service->generate($fileName, $languages); 66 | 67 | $this->assertFileExists($this->tempLangPath.'/en/messages.php'); 68 | $this->assertFileExists($this->tempLangPath.'/pl/messages.php'); 69 | $this->assertFileExists($this->tempLangPath.'/de/messages.php'); 70 | 71 | $this->assertEquals($fileContent, file_get_contents($this->tempLangPath.'/en/messages.php')); 72 | } 73 | 74 | #[Test] 75 | public function uses_all_available_languages_when_empty_collection_provided(): void 76 | { 77 | $availableLanguages = collect(['fr', 'es']); 78 | $fileName = 'common'; 79 | $fileContent = 'tempLangPath.'/fr'); 82 | mkdir($this->tempLangPath.'/es'); 83 | 84 | $this->languagesFinder->shouldReceive('getAvailableLanguages') 85 | ->once() 86 | ->andReturn($availableLanguages); 87 | 88 | $this->phpFileGenerator->shouldReceive('parseContent') 89 | ->times(2) 90 | ->andReturn($fileContent); 91 | 92 | $this->service->generate($fileName, collect()); 93 | 94 | $this->assertFileExists($this->tempLangPath.'/fr/common.php'); 95 | $this->assertFileExists($this->tempLangPath.'/es/common.php'); 96 | } 97 | 98 | #[Test] 99 | public function creates_file_with_correct_content(): void 100 | { 101 | $languages = collect(['en']); 102 | $fileName = 'validation'; 103 | $expectedContent = " 'Field is required'];"; 104 | 105 | mkdir($this->tempLangPath.'/en'); 106 | 107 | $this->phpFileGenerator->shouldReceive('parseContent') 108 | ->once() 109 | ->andReturn($expectedContent); 110 | 111 | $this->service->generate($fileName, $languages); 112 | 113 | $actualContent = file_get_contents($this->tempLangPath.'/en/validation.php'); 114 | $this->assertEquals($expectedContent, $actualContent); 115 | } 116 | 117 | private function removeDirectory(string $dir): void 118 | { 119 | if (! is_dir($dir)) { 120 | return; 121 | } 122 | 123 | $files = array_diff(scandir($dir), ['.', '..']); 124 | foreach ($files as $file) { 125 | $path = $dir.DIRECTORY_SEPARATOR.$file; 126 | is_dir($path) ? $this->removeDirectory($path) : unlink($path); 127 | } 128 | rmdir($dir); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeTranslationCommand.php: -------------------------------------------------------------------------------- 1 | > 24 | */ 25 | private const GENERATORS = [ 26 | PhpFileGenerator::class, 27 | JsonFileGenerator::class, 28 | ]; 29 | 30 | public function __construct( 31 | private readonly PackagesTranslationsService $packagesTranslationsService 32 | ) { 33 | parent::__construct(); 34 | } 35 | 36 | public function handle(): int 37 | { 38 | $lang = $this->getLang(); 39 | $fallback = $this->getFallback(); 40 | $overwrite = $this->getOverwrite(); 41 | $clearValues = $this->getClearValues(); 42 | 43 | $generatePackagesTranslations = $this->generatePackagesTranslations(); 44 | 45 | foreach (self::GENERATORS as $generatorClass) { 46 | $generator = new $generatorClass( 47 | $lang, 48 | $fallback, 49 | $overwrite, 50 | $clearValues, 51 | $generatePackagesTranslations 52 | ); 53 | 54 | try { 55 | $generator->generate(); 56 | } catch (FallbackLanguageFileNotExistsException $e) { 57 | $this->error($e->getMessage()); 58 | 59 | return self::FAILURE; 60 | } 61 | } 62 | 63 | info("Translations for '$lang' language has been created."); 64 | 65 | return self::SUCCESS; 66 | } 67 | 68 | private function generatePackagesTranslations(): bool 69 | { 70 | $packages = $this->packagesTranslationsService->findPackages(); 71 | 72 | if ($packages) { 73 | info('Translation files were found for the following packages:'); 74 | 75 | $packages->each(function (string $package) { 76 | $this->line("- $package"); 77 | }); 78 | 79 | return confirm('Do you want to generate files for packages as well?'); 80 | } 81 | 82 | return false; 83 | } 84 | 85 | private function getLang(): string 86 | { 87 | $lang = $this->argument('lang'); 88 | 89 | if (! $lang) { 90 | $lang = text( 91 | label: 'Enter language code', 92 | placeholder: 'Example: en, pl', 93 | required: true 94 | ); 95 | } 96 | 97 | return $lang; 98 | } 99 | 100 | private function getFallback(): string 101 | { 102 | $fallback = $this->option('fallback'); 103 | 104 | if (! $fallback) { 105 | $fallback = text( 106 | label: 'Enter fallback language code', 107 | placeholder: 'Example: en, pl', 108 | default: config('app.fallback_locale') 109 | ); 110 | } 111 | 112 | return $fallback; 113 | } 114 | 115 | private function getOverwrite(): bool 116 | { 117 | $overwrite = $this->option('overwrite'); 118 | 119 | if ($overwrite === false) { 120 | $overwrite = confirm( 121 | label: 'Do you want to overwrite existing translations?', 122 | default: false, 123 | yes: 'Yes, overwrite', 124 | no: 'No, skip' 125 | ); 126 | } 127 | 128 | return $overwrite; 129 | } 130 | 131 | private function getClearValues(): bool 132 | { 133 | $clearValues = $this->option('clear-values'); 134 | 135 | if ($clearValues === false) { 136 | $clearValues = confirm( 137 | label: 'Do you want to clear existing translations?', 138 | default: false, 139 | yes: 'Yes, clear', 140 | no: 'No, skip' 141 | ); 142 | } 143 | 144 | return $clearValues; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/Unit/Services/Generators/PhpFileGeneratorTest.php: -------------------------------------------------------------------------------- 1 | parseContent($translations); 19 | 20 | $this->assertEquals($expected, $result); 21 | } 22 | 23 | public static function parseContentDataProvider(): array 24 | { 25 | return [ 26 | 'null translations' => [ 27 | 'translations' => null, 28 | 'expected' => " [ 31 | 'translations' => collect([]), 32 | 'expected' => " [ 35 | 'translations' => collect(['hello' => 'cześć', 'goodbye' => 'do widzenia']), 36 | 'expected' => " \"cześć\",\n\t\"goodbye\" => \"do widzenia\",\n];", 37 | ], 38 | 'nested translations' => [ 39 | 'translations' => collect([ 40 | 'auth' => [ 41 | 'failed' => 'Błędne dane logowania', 42 | 'throttle' => 'Za dużo prób logowania', 43 | ], 44 | 'simple' => 'prosta wartość', 45 | ]), 46 | 'expected' => " [\n\t\t\"failed\" => \"Błędne dane logowania\",\n\t\t\"throttle\" => \"Za dużo prób logowania\",\n\t],\n\t\"simple\" => \"prosta wartość\",\n];", 47 | ], 48 | 'deeply nested translations' => [ 49 | 'translations' => collect([ 50 | 'level1' => [ 51 | 'level2' => [ 52 | 'level3' => 'deep value', 53 | ], 54 | ], 55 | ]), 56 | 'expected' => " [\n\t\t\"level2\" => [\n\t\t\t\"level3\" => \"deep value\",\n\t\t],\n\t],\n];", 57 | ], 58 | ]; 59 | } 60 | 61 | #[Test] 62 | #[DataProvider('translationsWithSpecialCharactersDataProvider')] 63 | public function parse_content_handles_special_characters(Collection $translations, string $expected): void 64 | { 65 | $generator = new PhpFileGenerator('en', 'pl'); 66 | $result = $generator->parseContent($translations); 67 | 68 | $this->assertEquals($expected, $result); 69 | } 70 | 71 | public static function translationsWithSpecialCharactersDataProvider(): array 72 | { 73 | return [ 74 | 'quotes in values' => [ 75 | 'translations' => collect(['message' => 'He said "Hello"']), 76 | 'expected' => " \"He said \"Hello\"\",\n];", 77 | ], 78 | 'special characters' => [ 79 | 'translations' => collect(['special' => 'ąęćńłśżź']), 80 | 'expected' => " \"ąęćńłśżź\",\n];", 81 | ], 82 | 'newlines in values' => [ 83 | 'translations' => collect(['multiline' => "Line 1\nLine 2"]), 84 | 'expected' => " \"Line 1\nLine 2\",\n];", 85 | ], 86 | ]; 87 | } 88 | 89 | #[Test] 90 | public function parse_content_with_empty_nested_array(): void 91 | { 92 | $generator = new PhpFileGenerator('en', 'pl'); 93 | $translations = collect([ 94 | 'empty_section' => [], 95 | 'filled_section' => ['key' => 'value'], 96 | ]); 97 | 98 | $result = $generator->parseContent($translations); 99 | 100 | $expected = " [\n\t],\n\t\"filled_section\" => [\n\t\t\"key\" => \"value\",\n\t],\n];"; 101 | 102 | $this->assertEquals($expected, $result); 103 | } 104 | 105 | #[Test] 106 | public function parse_content_maintains_key_order(): void 107 | { 108 | $generator = new PhpFileGenerator('en', 'pl'); 109 | $translations = collect([ 110 | 'z_key' => 'last', 111 | 'a_key' => 'first', 112 | 'middle_key' => 'middle', 113 | ]); 114 | 115 | $result = $generator->parseContent($translations); 116 | 117 | $this->assertStringContainsString('z_key', $result); 118 | $this->assertStringContainsString('a_key', $result); 119 | $this->assertStringContainsString('middle_key', $result); 120 | 121 | $posZ = strpos($result, 'z_key'); 122 | $posA = strpos($result, 'a_key'); 123 | $posMiddle = strpos($result, 'middle_key'); 124 | 125 | $this->assertLessThan($posA, $posZ); 126 | $this->assertLessThan($posMiddle, $posA); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Unit/Services/TranslationsFixerTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected->toArray(), $result->toArray()); 20 | } 21 | 22 | public static function fixToEmptyDataProvider(): array 23 | { 24 | return [ 25 | 'simple string values' => [ 26 | 'input' => collect(['hello' => 'world', 'foo' => 'bar']), 27 | 'expected' => collect(['hello' => '', 'foo' => '']), 28 | ], 29 | 'nested array values' => [ 30 | 'input' => collect([ 31 | 'auth' => [ 32 | 'failed' => 'Login failed', 33 | 'throttle' => 'Too many attempts', 34 | ], 35 | 'simple' => 'value', 36 | ]), 37 | 'expected' => collect([ 38 | 'auth' => collect([ 39 | 'failed' => '', 40 | 'throttle' => '', 41 | ]), 42 | 'simple' => '', 43 | ]), 44 | ], 45 | 'deeply nested values' => [ 46 | 'input' => collect([ 47 | 'level1' => [ 48 | 'level2' => [ 49 | 'level3' => 'deep value', 50 | ], 51 | ], 52 | ]), 53 | 'expected' => collect([ 54 | 'level1' => collect([ 55 | 'level2' => collect([ 56 | 'level3' => '', 57 | ]), 58 | ]), 59 | ]), 60 | ], 61 | 'empty collection' => [ 62 | 'input' => collect([]), 63 | 'expected' => collect([]), 64 | ], 65 | ]; 66 | } 67 | 68 | #[Test] 69 | #[DataProvider('fixToOtherTranslationsDataProvider')] 70 | public function fix_to_other_translations_works_correctly( 71 | Collection $translations, 72 | Collection $otherTranslations, 73 | bool $clearIfNotExists, 74 | Collection $expected 75 | ): void { 76 | $result = TranslationsFixer::fixToOtherTranslations( 77 | $translations, 78 | $otherTranslations, 79 | $clearIfNotExists 80 | ); 81 | 82 | $this->assertEquals($expected->toArray(), $result->toArray()); 83 | } 84 | 85 | public static function fixToOtherTranslationsDataProvider(): array 86 | { 87 | return [ 88 | 'merge existing translations without clearing' => [ 89 | 'translations' => collect(['hello' => 'world', 'new' => 'value']), 90 | 'otherTranslations' => collect(['hello' => 'existing']), 91 | 'clearIfNotExists' => false, 92 | 'expected' => collect(['hello' => 'existing', 'new' => 'value']), 93 | ], 94 | 'merge existing translations with clearing' => [ 95 | 'translations' => collect(['hello' => 'world', 'new' => 'value']), 96 | 'otherTranslations' => collect(['hello' => 'existing']), 97 | 'clearIfNotExists' => true, 98 | 'expected' => collect(['hello' => 'existing', 'new' => '']), 99 | ], 100 | 'all keys exist in other translations' => [ 101 | 'translations' => collect(['hello' => 'world', 'goodbye' => 'bye']), 102 | 'otherTranslations' => collect(['hello' => 'existing hello', 'goodbye' => 'existing goodbye']), 103 | 'clearIfNotExists' => false, 104 | 'expected' => collect(['hello' => 'existing hello', 'goodbye' => 'existing goodbye']), 105 | ], 106 | 'no matching keys' => [ 107 | 'translations' => collect(['hello' => 'world', 'goodbye' => 'bye']), 108 | 'otherTranslations' => collect(['different' => 'key']), 109 | 'clearIfNotExists' => false, 110 | 'expected' => collect(['hello' => 'world', 'goodbye' => 'bye']), 111 | ], 112 | 'no matching keys with clearing' => [ 113 | 'translations' => collect(['hello' => 'world', 'goodbye' => 'bye']), 114 | 'otherTranslations' => collect(['different' => 'key']), 115 | 'clearIfNotExists' => true, 116 | 'expected' => collect(['hello' => '', 'goodbye' => '']), 117 | ], 118 | ]; 119 | } 120 | 121 | #[Test] 122 | #[DataProvider('fixToOtherTranslationSingleDataProvider')] 123 | public function fix_to_other_translation_single_works_correctly( 124 | string|array $translation, 125 | string|array|null $otherTranslation, 126 | bool $clearIfNotExists, 127 | string|array|Collection $expected 128 | ): void { 129 | $result = TranslationsFixer::fixToOtherTranslationSingle( 130 | $translation, 131 | $otherTranslation, 132 | $clearIfNotExists 133 | ); 134 | 135 | if ($expected instanceof Collection) { 136 | $this->assertEquals($expected->toArray(), $result->toArray()); 137 | } else { 138 | $this->assertEquals($expected, $result); 139 | } 140 | } 141 | 142 | public static function fixToOtherTranslationSingleDataProvider(): array 143 | { 144 | return [ 145 | 'string translation with other translation exists' => [ 146 | 'translation' => 'original', 147 | 'otherTranslation' => 'replacement', 148 | 'clearIfNotExists' => false, 149 | 'expected' => 'replacement', 150 | ], 151 | 'string translation with other translation null, no clear' => [ 152 | 'translation' => 'original', 153 | 'otherTranslation' => null, 154 | 'clearIfNotExists' => false, 155 | 'expected' => 'original', 156 | ], 157 | 'string translation with other translation null, with clear' => [ 158 | 'translation' => 'original', 159 | 'otherTranslation' => null, 160 | 'clearIfNotExists' => true, 161 | 'expected' => '', 162 | ], 163 | 'array translation returns collection' => [ 164 | 'translation' => ['key' => 'value'], 165 | 'otherTranslation' => null, 166 | 'clearIfNotExists' => false, 167 | 'expected' => collect(['key' => 'value']), 168 | ], 169 | 'string with array other translation' => [ 170 | 'translation' => 'original', 171 | 'otherTranslation' => ['nested' => 'value'], 172 | 'clearIfNotExists' => false, 173 | 'expected' => collect(['nested' => 'value']), 174 | ], 175 | ]; 176 | } 177 | 178 | #[Test] 179 | public function fix_to_empty_handles_empty_collection(): void 180 | { 181 | $result = TranslationsFixer::fixToEmpty(collect()); 182 | 183 | $this->assertTrue($result->isEmpty()); 184 | } 185 | 186 | #[Test] 187 | public function fix_to_other_translations_handles_empty_collections(): void 188 | { 189 | $result = TranslationsFixer::fixToOtherTranslations( 190 | collect(), 191 | collect(), 192 | false 193 | ); 194 | 195 | $this->assertTrue($result->isEmpty()); 196 | } 197 | 198 | #[Test] 199 | public function fix_to_other_translation_single_handles_empty_string(): void 200 | { 201 | $result = TranslationsFixer::fixToOtherTranslationSingle('', null, false); 202 | 203 | $this->assertEquals('', $result); 204 | } 205 | 206 | #[Test] 207 | public function fix_to_other_translation_single_handles_empty_array(): void 208 | { 209 | $result = TranslationsFixer::fixToOtherTranslationSingle([], null, false); 210 | 211 | $this->assertInstanceOf(Collection::class, $result); 212 | $this->assertTrue($result->isEmpty()); 213 | } 214 | } 215 | --------------------------------------------------------------------------------