├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── config └── translation.php └── src ├── Commands ├── AddTerms.php ├── CreateJs.php ├── Download.php ├── Scan.php └── Upload.php ├── Exceptions └── POEditorException.php ├── Translation.php └── TranslationServiceProvider.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | composer.lock -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vemcogroup 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Translation 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/vemcogroup/laravel-translation.svg?style=flat-square)](https://packagist.org/packages/vemcogroup/laravel-translation) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/vemcogroup/laravel-translation.svg?style=flat-square)](https://packagist.org/packages/vemcogroup/laravel-translation) 5 | 6 | ## Description 7 | 8 | This package allows you to scan your app for translations and create your *.json file. 9 | 10 | It also allows you to upload your base translation to [poeditor](https://www.poeditor.com). 11 | 12 | ## Installation 13 | 14 | You can install the package via composer: 15 | 16 | ```bash 17 | composer require vemcogroup/laravel-translation 18 | ``` 19 | 20 | The package will automatically register its service provider. 21 | 22 | To publish the config file to `config/translation.php` run: 23 | 24 | ```bash 25 | php artisan vendor:publish --provider="Vemcogroup\Translation\TranslationServiceProvider" 26 | ``` 27 | 28 | This is the default contents of the configuration: 29 | 30 | ```php 31 | return [ 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Base Language 36 | |-------------------------------------------------------------------------- 37 | | 38 | | Here you may specify which of language is your base language. 39 | | The base language select will be created as json file when scanning. 40 | | It will also be the file it reads and uploads to POEditor. 41 | | 42 | */ 43 | 44 | 'base_language' => 'en', 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | Functions 49 | |-------------------------------------------------------------------------- 50 | | 51 | | Here you define an array describing all the function names to scan files for. 52 | | 53 | */ 54 | 55 | 'functions' => ['__'], 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Excluded directories 60 | |-------------------------------------------------------------------------- 61 | | 62 | | Here you define which directories are excluded from scan. 63 | | 64 | */ 65 | 66 | 'excluded_directories' => ['vendor', 'storage', 'public', 'node_modules'], 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Extensions 71 | |-------------------------------------------------------------------------- 72 | | 73 | | Here you define an array describing all the file extensions to scan through. 74 | | 75 | */ 76 | 77 | 'extensions' => ['*.php', '*.vue'], 78 | 79 | /* 80 | |-------------------------------------------------------------------------- 81 | | API Key 82 | |-------------------------------------------------------------------------- 83 | | 84 | | Here you define your API Key for POEditor. 85 | | 86 | | More info: https://poeditor.com/account/api 87 | | 88 | */ 89 | 90 | 'api_key' => env('POEDITOR_API_KEY'), 91 | 92 | /* 93 | |-------------------------------------------------------------------------- 94 | | Project Id 95 | |-------------------------------------------------------------------------- 96 | | 97 | | Here you define the project Id to upload / download from. 98 | | 99 | */ 100 | 101 | 'project_id' => env('POEDITOR_PROJECT_ID'), 102 | ]; 103 | ``` 104 | 105 | If you want to use upload / download to poeditor features, you need to create a your base_language in poeditor. 106 | 107 | ## Usage 108 | 109 | You are now able to use the translation commands scan/upload/download or create-js 110 | 111 | **Scan files** 112 | 113 | To scan your project for translations run this command: 114 | ```bash 115 | php artisan translation:scan {--merge : Whether the job should overwrite or merge new translations keys} 116 | ``` 117 | 118 | The command creates your `base_language` .json file in `/resources/lang` 119 | 120 | **Add terms** 121 | 122 | To only add your terms run this command: 123 | ```bash 124 | php artisan translation:add-terms {--scan : Whether the job should scan before uploading} 125 | ``` 126 | This command doesn't remove unsused terms, so remember *NOT* to run `upload` command afterward. 127 | 128 | 129 | **Upload translations** 130 | 131 | To upload your translation terms to poeditor run this command: 132 | ```bash 133 | php artisan translation:upload {--scan : Whether the job should scan before uploading} 134 | ``` 135 | 136 | You are also able to upload your local translations if you have locale changes 137 | ```bash 138 | php artisan translation:upload {--translations=all : Upload translations for language sv,da,...} 139 | ``` 140 | 141 | 142 | **Download translation languages** 143 | 144 | To download languages from poeditor run this command: 145 | ```bash 146 | php artisan translation:download 147 | ``` 148 | 149 | **Create JS language files** 150 | 151 | To create public JS files run this command: 152 | ```bash 153 | php artisan translation:create-js {--download : Download language files before creating js} 154 | ``` 155 | 156 | You are now able to access all your languages as `window.i18n` from `/public/lang` when you include the .js file 157 | 158 | ````html 159 | 160 | ```` 161 | 162 | **System translations** 163 | 164 | If you want to translate system translations change the terms in eg `/resources/lang/en/auth.php` 165 | 166 | From: 167 | ```php 168 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 169 | ``` 170 | 171 | To 172 | ```php 173 | 'throttle' => __('Too many login attempts. Please try again in :seconds seconds.'), 174 | ``` 175 | 176 | Then it will be scanned and included in the synced terms. 177 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vemcogroup/laravel-translation", 3 | "description": "Translation package for Laravel to scan for localisations and up/download to poeditor", 4 | "keywords": [ 5 | "i18n", 6 | "laravel", 7 | "poeditor", 8 | "translations" 9 | ], 10 | "homepage": "https://github.com/vemcogroup/laravel-translation", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Henrik B Hansen", 15 | "email": "hbh@vemcount.com", 16 | "homepage": "https://www.vemcogroup.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^7.3|^8.0", 22 | "ext-json": "*", 23 | "symfony/finder": "^4.3|^5.0|^6.0|^7.0", 24 | "guzzlehttp/guzzle": "^6.3|^7.0", 25 | "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Vemcogroup\\Translation\\": "src/" 30 | } 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Vemcogroup\\Translation\\TranslationServiceProvider" 39 | ] 40 | } 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true 44 | } 45 | -------------------------------------------------------------------------------- /config/translation.php: -------------------------------------------------------------------------------- 1 | 'en', 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Functions 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you define an array describing all the function names to scan files for. 24 | | 25 | */ 26 | 27 | 'functions' => ['__'], 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Excluded directories 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Here you define which directories are excluded from scan. 35 | | 36 | */ 37 | 38 | 'excluded_directories' => ['vendor', 'storage', 'public', 'node_modules'], 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Output directory 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Here you define which directory to write the created JSON files into. 46 | | 47 | */ 48 | 49 | 'output_directory' => public_path(env('TRANSLATION_OUTPUT_DIRECTORY', 'build/lang')), 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Extensions 54 | |-------------------------------------------------------------------------- 55 | | 56 | | Here you define an array describing all the file extensions to scan through. 57 | | 58 | */ 59 | 60 | 'extensions' => ['*.php', '*.vue'], 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | API Key 65 | |-------------------------------------------------------------------------- 66 | | 67 | | Here you define your API Key for POEditor. 68 | | 69 | | More info: https://poeditor.com/account/api 70 | | 71 | */ 72 | 73 | 'api_key' => env('POEDITOR_API_KEY'), 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Project Id 78 | |-------------------------------------------------------------------------- 79 | | 80 | | Here you define the project Id to upload / download from. 81 | | 82 | */ 83 | 84 | 'project_id' => env('POEDITOR_PROJECT_ID'), 85 | ]; 86 | -------------------------------------------------------------------------------- /src/Commands/AddTerms.php: -------------------------------------------------------------------------------- 1 | info('⬆️ Preparing to upload terms'); 21 | 22 | if ($this->option('scan')) { 23 | $this->call('translation:scan'); 24 | } 25 | 26 | app(Translation::class)->addTerms(); 27 | 28 | $this->info('⬆️ Finished uploading all terms'); 29 | } catch (Exception $e) { 30 | $this->error($e->getMessage()); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Commands/CreateJs.php: -------------------------------------------------------------------------------- 1 | info('Preparing create js files'); 19 | 20 | if ($this->option('download')) { 21 | $this->call('translation:download'); 22 | } 23 | 24 | $files = app(Translation::class)->createJs(); 25 | 26 | $this->info('Finished creating js files, created: ' . $files . ' files'); 27 | } catch (Exception $e) { 28 | $this->error($e->getMessage()); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Commands/Download.php: -------------------------------------------------------------------------------- 1 | info('⬇️ Preparing to download languages'); 18 | 19 | $languages = app(Translation::class)->download(); 20 | 21 | $this->info('⬇️ Finished downloading languages: ' . $languages->implode(', ')); 22 | } catch (Exception $e) { 23 | $this->error($e->getMessage()); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Commands/Scan.php: -------------------------------------------------------------------------------- 1 | info('Preparing to scan code base'); 19 | $this->info('Finding all translation variables'); 20 | $this->info($this->option('merge') ? 'Merging keys' : 'Overwriting keys'); 21 | 22 | $variables = app(Translation::class)->scan($this->option('merge')); 23 | 24 | $this->info('Finished scanning code base, found: ' . $variables . ' variables'); 25 | } catch (Exception $e) { 26 | $this->error($e->getMessage()); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Commands/Upload.php: -------------------------------------------------------------------------------- 1 | info('⬆️ Preparing to upload translations'); 22 | 23 | if ($this->option('scan')) { 24 | $this->call('translation:scan'); 25 | } 26 | 27 | app(Translation::class)->syncTerms(); 28 | 29 | if ($this->hasOption('translations')) { 30 | $language = in_array($this->option('translations'), [null, 'all'], true) ? null : explode(',', $this->option('translations')); 31 | app(Translation::class)->syncTranslations($language); 32 | } 33 | 34 | $this->info('⬆️ Finished uploading all translations'); 35 | } catch (Exception $e) { 36 | $this->error($e->getMessage()); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exceptions/POEditorException.php: -------------------------------------------------------------------------------- 1 | baseLanguage = config('translation.base_language'); 27 | $this->baseFilename = app()->langPath() . DIRECTORY_SEPARATOR . $this->baseLanguage . '.json'; 28 | } 29 | 30 | public function scan($mergeKeys = false): int 31 | { 32 | $allMatches = []; 33 | $finder = new Finder(); 34 | 35 | $finder->in(base_path()) 36 | ->exclude(config('translation.excluded_directories')) 37 | ->name(config('translation.extensions')) 38 | ->followLinks() 39 | ->files(); 40 | 41 | /* 42 | * This pattern is derived from Barryvdh\TranslationManager by Barry vd. Heuvel 43 | * 44 | * https://github.com/barryvdh/laravel-translation-manager/blob/master/src/Manager.php 45 | */ 46 | $functions = config('translation.functions'); 47 | $pattern = 48 | // See https://regex101.com/r/jS5fX0/5 49 | '[^\w]' . // Must not start with any alphanum or _ 50 | '(?)' . // Must not start with -> 51 | '(?:' . implode('|', $functions) . ')' . // Must start with one of the functions 52 | "\(" . // Match opening parentheses 53 | "\s*" . // Allow whitespace chars after the opening parenthese 54 | '(?:' . // Non capturing group 55 | "'(.+)'" . // Match 'text' 56 | '|' . // or 57 | "`(.+)`" . // Match `text` 58 | '|' . // or 59 | "\"(.+)\"" . // Match "text" 60 | ')' . // Close non-capturing group 61 | "\s*" . // Allow whitespace chars before the closing parenthese 62 | "[\),]" // Close parentheses or new parameter 63 | ; 64 | 65 | foreach ($finder as $file) { 66 | if (preg_match_all("/$pattern/siU", $file->getContents(), $matches)) { 67 | unset($matches[0]); 68 | $allMatches[$file->getRelativePathname()] = 69 | array_filter( 70 | array_merge(...$matches), 71 | function ($value) { 72 | return (!is_null($value)) && !empty($value); 73 | } 74 | ); 75 | } 76 | } 77 | 78 | $collapsedKeys = collect($allMatches)->collapse(); 79 | $keys = $collapsedKeys->combine($collapsedKeys); 80 | 81 | if ($mergeKeys) { 82 | $content = $this->getFileContent(); 83 | $keys = $content->union( 84 | $keys->filter(function ($key) use ($content) { 85 | return !$content->has($key); 86 | }) 87 | ); 88 | } 89 | 90 | file_put_contents($this->baseFilename, json_encode($keys->sortKeys(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 91 | 92 | return $keys->count(); 93 | } 94 | 95 | public function createJs(): int 96 | { 97 | $jsLangPath = config('translation.output_directory'); 98 | if (!is_dir($jsLangPath) && !mkdir($jsLangPath, 0777, true)) { 99 | throw POEditorException::unableToCreateJsDirectory($jsLangPath); 100 | } 101 | 102 | $translations = $this->getTranslations(); 103 | $translations->each(function ($content, $language) use ($jsLangPath) { 104 | $content = 'window.i18n = ' . json_encode($content) . ';'; 105 | file_put_contents($jsLangPath . DIRECTORY_SEPARATOR . $language . '.js', $content); 106 | }); 107 | 108 | return $translations->count(); 109 | } 110 | 111 | public function download(): Collection 112 | { 113 | try { 114 | $this->setupPoeditorCredentials(); 115 | $response = $this->query('https://api.poeditor.com/v2/languages/list', [ 116 | 'form_params' => [ 117 | 'api_token' => $this->apiKey, 118 | 'id' => $this->projectId 119 | ] 120 | ], 'POST'); 121 | } catch (Exception $e) { 122 | throw $e; 123 | } 124 | 125 | $languages = collect($response['result']['languages']); 126 | $languages->each(function ($language) { 127 | $response = $this->query('https://api.poeditor.com/v2/projects/export', [ 128 | 'form_params' => [ 129 | 'api_token' => $this->apiKey, 130 | 'id' => $this->projectId, 131 | 'language' => $language['code'], 132 | 'type' => 'key_value_json' 133 | ] 134 | ], 'POST'); 135 | 136 | $content = collect($this->query($response['result']['url'])) 137 | ->mapWithKeys(function ($entry, $key) { 138 | return is_array($entry) ? [trim(array_key_first($entry)) => array_pop($entry)] : [$key => trim($entry)]; 139 | }) 140 | ->sortKeys() 141 | ->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 142 | 143 | file_put_contents(app()->langPath() . DIRECTORY_SEPARATOR . $language['code'] . '.json', $content); 144 | }); 145 | 146 | return $languages->pluck('code'); 147 | } 148 | 149 | public function syncTerms(): void 150 | { 151 | try { 152 | $this->setupPoeditorCredentials(); 153 | $entries = $this->getFileContent() 154 | ->map(function ($value, $key) { 155 | return ['term' => $key]; 156 | }) 157 | ->toJson(); 158 | 159 | $this->query('https://api.poeditor.com/v2/projects/sync', [ 160 | 'form_params' => [ 161 | 'api_token' => $this->apiKey, 162 | 'id' => $this->projectId, 163 | 'data' => $entries, 164 | ] 165 | ], 'POST'); 166 | } catch (Exception $e) { 167 | throw $e; 168 | } 169 | } 170 | 171 | public function addTerms(): void 172 | { 173 | try { 174 | $this->setupPoeditorCredentials(); 175 | $entries = $this->getFileContent() 176 | ->map(function ($value, $key) { 177 | return ['term' => $key]; 178 | }) 179 | ->toJson(); 180 | 181 | $this->query('https://api.poeditor.com/v2/terms/add', [ 182 | 'form_params' => [ 183 | 'api_token' => $this->apiKey, 184 | 'id' => $this->projectId, 185 | 'data' => $entries, 186 | ] 187 | ], 'POST'); 188 | } catch (Exception $e) { 189 | throw $e; 190 | } 191 | } 192 | 193 | public function syncTranslations(?array $languages = null): void 194 | { 195 | try { 196 | $this->setupPoeditorCredentials(); 197 | $translations = $this->getTranslations($languages); 198 | 199 | foreach ($translations as $language => $entries) { 200 | $json = collect($entries) 201 | ->mapToGroups(static function ($value, $key) { 202 | return [[ 203 | 'term' => $key, 204 | 'translation' => [ 205 | 'content' => $value, 206 | ], 207 | ]]; 208 | }) 209 | ->first() 210 | ->toJson(); 211 | 212 | $this->query('https://api.poeditor.com/v2/translations/update', [ 213 | 'form_params' => [ 214 | 'api_token' => $this->apiKey, 215 | 'id' => $this->projectId, 216 | 'language' => $language, 217 | 'data' => $json, 218 | ] 219 | ], 'POST'); 220 | } 221 | } catch (Exception $e) { 222 | throw $e; 223 | } 224 | } 225 | 226 | protected function setupPoeditorCredentials(): void 227 | { 228 | if (!$this->apiKey = config('translation.api_key')) { 229 | throw POEditorException::noApiKey(); 230 | } 231 | 232 | if (!$this->projectId = config('translation.project_id')) { 233 | throw POEditorException::noProjectId(); 234 | } 235 | } 236 | 237 | protected function getFileContent(): Collection 238 | { 239 | return file_exists($this->baseFilename) 240 | ? collect(json_decode(file_get_contents($this->baseFilename), true)) 241 | : collect(); 242 | } 243 | 244 | protected function getTranslations(?array $languages = null): Collection 245 | { 246 | $namePattern = '*.json'; 247 | 248 | if ($languages !== null) { 249 | $namePattern = '/(' . implode('|', $languages) . ').json/'; 250 | } 251 | 252 | return collect(app(Finder::class) 253 | ->in(app()->langPath()) 254 | ->name($namePattern) 255 | ->files()) 256 | ->mapWithKeys(function (SplFileInfo $file) { 257 | return [$file->getBaseName('.json') => json_decode($file->getContents(), true)]; 258 | }); 259 | } 260 | 261 | protected function query($url, $parameters = [], $type = 'GET'): ?array 262 | { 263 | try { 264 | $response = app(Client::class)->request($type, $url, $parameters); 265 | return $this->handleResponse($response); 266 | } catch (POEditorException $e) { 267 | throw POEditorException::communicationError($e->getMessage()); 268 | } catch (GuzzleException $e) { 269 | throw POEditorException::communicationError($e->getMessage()); 270 | } 271 | } 272 | 273 | protected function handleResponse(GuzzleResponse $response) 274 | { 275 | if (!in_array($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_CREATED], true)) { 276 | throw POEditorException::communicationError($response->getBody()->getContents()); 277 | } 278 | 279 | return json_decode($response->getBody()->getContents(), true); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/TranslationServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 17 | __DIR__ . '/../config/translation.php' => config_path('translation.php'), 18 | ], 'config'); 19 | 20 | if ($this->app->runningInConsole()) { 21 | $this->commands([ 22 | Scan::class, 23 | Upload::class, 24 | Download::class, 25 | CreateJs::class, 26 | AddTerms::class, 27 | ]); 28 | } 29 | } 30 | 31 | public function register(): void 32 | { 33 | $this->mergeConfigFrom( 34 | __DIR__ . '/../config/translation.php', 'translation' 35 | ); 36 | 37 | $this->app->singleton(Translation::class, function () { 38 | return new Translation(); 39 | }); 40 | } 41 | } 42 | --------------------------------------------------------------------------------