├── .gitignore ├── src ├── Exceptions │ └── POEditorException.php ├── Commands │ ├── Download.php │ ├── AddTerms.php │ ├── CreateJs.php │ ├── Scan.php │ └── Upload.php ├── TranslationServiceProvider.php └── Translation.php ├── LICENSE.md ├── composer.json ├── config └── translation.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | composer.lock -------------------------------------------------------------------------------- /src/Exceptions/POEditorException.php: -------------------------------------------------------------------------------- 1 | info('⬇️ Preparing to download languages'); 19 | 20 | $languages = app(Translation::class)->download($this->option('skip-trimming')); 21 | 22 | $this->info('⬇️ Finished downloading languages: ' . $languages->implode(', ')); 23 | } catch (Exception $e) { 24 | $this->error($e->getMessage()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 {--skip-trimming : Whether translation trimming should be skipped} 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 | -------------------------------------------------------------------------------- /src/Translation.php: -------------------------------------------------------------------------------- 1 | baseLanguage = config('translation.base_language'); 28 | $this->baseFilename = app()->langPath() . DIRECTORY_SEPARATOR . $this->baseLanguage . '.json'; 29 | } 30 | 31 | public function scan($mergeKeys = false): int 32 | { 33 | $allMatches = []; 34 | $finder = new Finder(); 35 | 36 | $finder->in(base_path()) 37 | ->exclude(config('translation.excluded_directories')) 38 | ->name(config('translation.extensions')) 39 | ->followLinks() 40 | ->files(); 41 | 42 | /* 43 | * This pattern is derived from Barryvdh\TranslationManager by Barry vd. Heuvel 44 | * 45 | * https://github.com/barryvdh/laravel-translation-manager/blob/master/src/Manager.php 46 | */ 47 | $functions = config('translation.functions'); 48 | $pattern = 49 | // See https://regex101.com/r/jS5fX0/5 50 | '[^\w]' . // Must not start with any alphanum or _ 51 | '(?)' . // Must not start with -> 52 | '(?:' . implode('|', $functions) . ')' . // Must start with one of the functions 53 | "\(" . // Match opening parentheses 54 | "\s*" . // Allow whitespace chars after the opening parenthese 55 | '(?:' . // Non capturing group 56 | "'(.+)'" . // Match 'text' 57 | '|' . // or 58 | "`(.+)`" . // Match `text` 59 | '|' . // or 60 | "\"(.+)\"" . // Match "text" 61 | ')' . // Close non-capturing group 62 | "\s*" . // Allow whitespace chars before the closing parenthese 63 | "[\),]" // Close parentheses or new parameter 64 | ; 65 | 66 | foreach ($finder as $file) { 67 | if (preg_match_all("/$pattern/siU", $file->getContents(), $matches)) { 68 | unset($matches[0]); 69 | $allMatches[$file->getRelativePathname()] = 70 | array_filter( 71 | array_merge(...$matches), 72 | function ($value) { 73 | return (!is_null($value)) && !empty($value); 74 | } 75 | ); 76 | } 77 | } 78 | 79 | $collapsedKeys = collect($allMatches)->collapse(); 80 | $keys = $collapsedKeys->combine($collapsedKeys); 81 | 82 | if ($mergeKeys) { 83 | $content = $this->getFileContent(); 84 | $keys = $content->union( 85 | $keys->filter(function ($key) use ($content) { 86 | return !$content->has($key); 87 | }) 88 | ); 89 | } 90 | 91 | file_put_contents($this->baseFilename, json_encode($keys->sortKeys(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 92 | 93 | return $keys->count(); 94 | } 95 | 96 | public function createJs(): int 97 | { 98 | $jsLangPath = config('translation.output_directory'); 99 | if (!is_dir($jsLangPath) && !mkdir($jsLangPath, 0777, true)) { 100 | throw POEditorException::unableToCreateJsDirectory($jsLangPath); 101 | } 102 | 103 | $translations = $this->getTranslations(); 104 | $translations->each(function ($content, $language) use ($jsLangPath) { 105 | $content = 'window.i18n = ' . json_encode($content) . ';'; 106 | file_put_contents($jsLangPath . DIRECTORY_SEPARATOR . $language . '.js', $content); 107 | }); 108 | 109 | return $translations->count(); 110 | } 111 | 112 | public function download($skipTrimming = false): Collection 113 | { 114 | try { 115 | $this->setupPoeditorCredentials(); 116 | $response = $this->query('https://api.poeditor.com/v2/languages/list', [ 117 | 'form_params' => [ 118 | 'api_token' => $this->apiKey, 119 | 'id' => $this->projectId 120 | ] 121 | ], 'POST'); 122 | } catch (Exception $e) { 123 | throw $e; 124 | } 125 | 126 | $languages = collect($response['result']['languages']); 127 | 128 | $this->skipTrimming = $skipTrimming; 129 | 130 | $languages->each(function ($language) { 131 | $response = $this->query('https://api.poeditor.com/v2/projects/export', [ 132 | 'form_params' => [ 133 | 'api_token' => $this->apiKey, 134 | 'id' => $this->projectId, 135 | 'language' => $language['code'], 136 | 'type' => 'key_value_json' 137 | ] 138 | ], 'POST'); 139 | 140 | $content = collect($this->query($response['result']['url'])) 141 | ->mapWithKeys(function ($entry, $key) { 142 | if ($this->skipTrimming) { 143 | return is_array($entry) ? [array_key_first($entry) => array_pop($entry)] : [$key => $entry]; 144 | } 145 | return is_array($entry) ? [trim(array_key_first($entry)) => array_pop($entry)] : [$key => trim($entry)]; 146 | }) 147 | ->sortKeys() 148 | ->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 149 | 150 | file_put_contents(app()->langPath() . DIRECTORY_SEPARATOR . $language['code'] . '.json', $content); 151 | }); 152 | 153 | return $languages->pluck('code'); 154 | } 155 | 156 | public function syncTerms(): void 157 | { 158 | try { 159 | $this->setupPoeditorCredentials(); 160 | $entries = $this->getFileContent() 161 | ->map(function ($value, $key) { 162 | return ['term' => $key]; 163 | }) 164 | ->toJson(); 165 | 166 | $this->query('https://api.poeditor.com/v2/projects/sync', [ 167 | 'form_params' => [ 168 | 'api_token' => $this->apiKey, 169 | 'id' => $this->projectId, 170 | 'data' => $entries, 171 | ] 172 | ], 'POST'); 173 | } catch (Exception $e) { 174 | throw $e; 175 | } 176 | } 177 | 178 | public function addTerms(): void 179 | { 180 | try { 181 | $this->setupPoeditorCredentials(); 182 | $entries = $this->getFileContent() 183 | ->map(function ($value, $key) { 184 | return ['term' => $key]; 185 | }) 186 | ->toJson(); 187 | 188 | $this->query('https://api.poeditor.com/v2/terms/add', [ 189 | 'form_params' => [ 190 | 'api_token' => $this->apiKey, 191 | 'id' => $this->projectId, 192 | 'data' => $entries, 193 | ] 194 | ], 'POST'); 195 | } catch (Exception $e) { 196 | throw $e; 197 | } 198 | } 199 | 200 | public function syncTranslations(?array $languages = null): void 201 | { 202 | try { 203 | $this->setupPoeditorCredentials(); 204 | $translations = $this->getTranslations($languages); 205 | 206 | foreach ($translations as $language => $entries) { 207 | $json = collect($entries) 208 | ->mapToGroups(static function ($value, $key) { 209 | return [[ 210 | 'term' => $key, 211 | 'translation' => [ 212 | 'content' => $value, 213 | ], 214 | ]]; 215 | }) 216 | ->first() 217 | ->toJson(); 218 | 219 | $this->query('https://api.poeditor.com/v2/translations/update', [ 220 | 'form_params' => [ 221 | 'api_token' => $this->apiKey, 222 | 'id' => $this->projectId, 223 | 'language' => $language, 224 | 'data' => $json, 225 | ] 226 | ], 'POST'); 227 | } 228 | } catch (Exception $e) { 229 | throw $e; 230 | } 231 | } 232 | 233 | protected function setupPoeditorCredentials(): void 234 | { 235 | if (!$this->apiKey = config('translation.api_key')) { 236 | throw POEditorException::noApiKey(); 237 | } 238 | 239 | if (!$this->projectId = config('translation.project_id')) { 240 | throw POEditorException::noProjectId(); 241 | } 242 | } 243 | 244 | protected function getFileContent(): Collection 245 | { 246 | return file_exists($this->baseFilename) 247 | ? collect(json_decode(file_get_contents($this->baseFilename), true)) 248 | : collect(); 249 | } 250 | 251 | protected function getTranslations(?array $languages = null): Collection 252 | { 253 | $namePattern = '*.json'; 254 | 255 | if ($languages !== null) { 256 | $namePattern = '/(' . implode('|', $languages) . ').json/'; 257 | } 258 | 259 | return collect(app(Finder::class) 260 | ->in(app()->langPath()) 261 | ->name($namePattern) 262 | ->files()) 263 | ->mapWithKeys(function (SplFileInfo $file) { 264 | return [$file->getBaseName('.json') => json_decode($file->getContents(), true)]; 265 | }); 266 | } 267 | 268 | protected function query($url, $parameters = [], $type = 'GET'): ?array 269 | { 270 | try { 271 | $response = app(Client::class)->request($type, $url, $parameters); 272 | return $this->handleResponse($response); 273 | } catch (POEditorException $e) { 274 | throw POEditorException::communicationError($e->getMessage()); 275 | } catch (GuzzleException $e) { 276 | throw POEditorException::communicationError($e->getMessage()); 277 | } 278 | } 279 | 280 | protected function handleResponse(GuzzleResponse $response) 281 | { 282 | if (!in_array($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_CREATED], true)) { 283 | throw POEditorException::communicationError($response->getBody()->getContents()); 284 | } 285 | 286 | return json_decode($response->getBody()->getContents(), true); 287 | } 288 | } 289 | --------------------------------------------------------------------------------