├── .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 | [](https://packagist.org/packages/vemcogroup/laravel-translation)
4 | [](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 |
--------------------------------------------------------------------------------