JFIFxxC      C  " }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbrlaravel-translatable-string-exporter/composer.json000064400000002410150364332160016540 0ustar00{ "name": "kkomelin/laravel-translatable-string-exporter", "description": "Translatable String Exporter for Laravel", "keywords": [ "laravel", "translations", "translation", "export", "exporter", "json", "localization" ], "license": "MIT", "authors": [ { "name": "Konstantin Komelin", "email": "konstantin.komelin@gmail.com" } ], "require": { "php": "^8.0", "ext-json": "*", "illuminate/support": "^8|^9|^10.0", "illuminate/translation": "^8|^9|^10.0", "symfony/finder": "^5|^6" }, "require-dev": { "nunomaduro/larastan": "^1.0|^2.0", "phpunit/phpunit": "^9.0", "orchestra/testbench": "^6.0|^7.0|^8.0" }, "autoload": { "psr-4": { "KKomelin\\TranslatableStringExporter\\": "src/" } }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "extra": { "laravel": { "providers": [ "KKomelin\\TranslatableStringExporter\\Providers\\ExporterServiceProvider" ] } }, "config": { "sort-packages": true }, "minimum-stability": "stable" } laravel-translatable-string-exporter/tests/ExporterTest.php000064400000046124150364332160020353 0ustar00removeJsonLanguageFiles(); $this->createTestView("{{ __('name') }}"); $this->artisan('translatable:export', ['lang' => 'bg,es']) ->expectsOutput('Translatable strings have been extracted and written to the bg.json file.') ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $this->assertFileExists($this->getTranslationFilePath('bg')); $this->assertFileExists($this->getTranslationFilePath('es')); $bg_content = $this->getTranslationFileContent('bg'); $es_content = $this->getTranslationFileContent('es'); $this->assertEquals(['name' => 'name'], $bg_content); $this->assertEquals(['name' => 'name'], $es_content); } public function testTranslationSorting() { $this->removeJsonLanguageFiles(); $source = [ 'name3', 'name2', 'name1', ]; $template_strings = array_map(function ($translatable_string) { return "{{ __('" . $translatable_string . "') }}"; }, $source); $this->createTestView(implode(' ', $template_strings)); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $expected = [ 'name1' => 'name1', 'name2' => 'name2', 'name3' => 'name3', ]; $actual = $this->getTranslationFileContent('es'); $this->assertEquals($expected, $actual); } public function testTranslationFunctionNames() { $this->removeJsonLanguageFiles(); $view = "{{ __('name__') }} " . "@lang('name_lang') " . "{{ _t('name_t') }} " . "{{ __('name__space_end' ) }} " . "@lang( 'name_lang_space_start') " . "{{ _t( 'name_t_space_both' ) }} " . "{{ _t( 'name_t_double_space' ) }}"; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'name__' => 'name__', 'name_lang' => 'name_lang', 'name_t' => 'name_t', 'name__space_end' => 'name__space_end', 'name_lang_space_start' => 'name_lang_space_start', 'name_t_space_both' => 'name_t_space_both', 'name_t_double_space' => 'name_t_double_space', ]; $this->assertEquals($expected, $actual); } public function testQuotationMarkEscapingPR52() { $this->removeJsonLanguageFiles(); $view = <<createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'He said \"WOW\".' => 'He said \"WOW\".', 'We\'re amazing!' => 'We\'re amazing!', "You're pretty great!" => "You're pretty great!", 'You\"re pretty great!' => 'You\"re pretty great!', 'Therefore, we automatically look for columns named something like \"Last name\", \"First name\", \"E-mail\" etc.' => 'Therefore, we automatically look for columns named something like \"Last name\", \"First name\", \"E-mail\" etc.', ]; $this->assertEquals($expected, $actual); } public function testMultiLineSupportDisabled() { $this->removeJsonLanguageFiles(); $view = "{{ __('single line') }} " . "{{ __('translation.keys') }} " . // Verify legacy behaviours: // 1) Strings including escaped newlines (\n) are processed "{{ __('escaped\\nnewline') }}" . // 2) Strings including un-escaped newlines are ignored. "{{ __(\"ignored\nmultiple\nline\nstring\") }}"; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'single line' => 'single line', 'translation.keys' => 'translation.keys', 'escaped\\nnewline' => 'escaped\\nnewline', ]; $this->assertEquals($expected, $actual); } public function testMultiLineSupportEnabled() { $this->app['config']->set('laravel-translatable-string-exporter.allow-newlines', true); $this->removeJsonLanguageFiles(); $view = "{{ __('single line') }} " . "{{ __('translation.keys') }} " . // No change to 1st legacy behaviour: // 1) Strings including escaped newlines (\n) are processed "{{ __('escaped\\nnewline') }}" . // Un-escaped newlines are now also processed. // 2) Strings including un-escaped newlines are ignored. "{{ __(\"detected\nmultiple\nline\nstring\") }}" . // test whether strings which have new line between function and string are also detected "{{ __(\n\"string between new line\"\n) }}"; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'single line' => 'single line', 'translation.keys' => 'translation.keys', 'escaped\nnewline' => 'escaped\nnewline', "detected\nmultiple\nline\nstring" => "detected\nmultiple\nline\nstring", "string between new line" => "string between new line", ]; $this->assertEquals($expected, $actual); } public function testNewLineParametersIssue57() { $this->removeJsonLanguageFiles(); $view = << "variable", "var2" => "another variable"] )); pushGenericFeedback( __("This is some generic key with a :var1 and :var2 in it 2", ["var1" => "variable", "var2" => "another variable"] )); EOD; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'This is some generic key with a :var1 and :var2 in it 1' => 'This is some generic key with a :var1 and :var2 in it 1', 'This is some generic key with a :var1 and :var2 in it 2' => 'This is some generic key with a :var1 and :var2 in it 2', ]; $this->assertEquals($expected, $actual); } public function testNewLineParametersIssue45() { $this->removeJsonLanguageFiles(); $view = <<createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'A required parameter ("%s") was not found.' => 'A required parameter ("%s") was not found.', ]; $this->assertEquals($expected, $actual); } public function testUpdatingTranslations() { $this->removeJsonLanguageFiles(); // Create a translation file ourselves. $existing_translations = ['name1_en' => 'name2_es']; $content = json_encode($existing_translations); $this->writeToTranslationFile('es', $content); // 1. Now create a test view with the same translatable string. $this->createTestView("{{ __('name1_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); // Since the translatable string from view matches the existing translation, we don't override it. $expected = $existing_translations; $this->assertEquals($expected, $actual); // 2. Now let's add a new translation to the view. $this->createTestView("{{ __('name1_en') . __('name2_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); // Since the translatable string from view is not yet translated, then we simply add it to the translation file. $expected = $existing_translations + ['name2_en' => 'name2_en']; $this->assertEquals($expected, $actual); // 3. Next let's remove the first translatable string from the view. $this->createTestView("{{ __('name2_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); // All translations which are not found in views should be deleted from translation files. $expected = ['name2_en' => 'name2_en']; $this->assertEquals($expected, $actual); } public function testPersistentTranslations() { $this->removeJsonLanguageFiles(); // 1. Create a translation file ourselves. $existing_translations = [ 'name1_en' => 'name1_es', 'name2_en' => 'name2_es', 'name3_en' => 'name3_es', ]; $content = json_encode($existing_translations); $this->writeToTranslationFile('es', $content); // 2. Create a file with the keys of any strings which should persist even if they are not contained in the views. $persistentContent = json_encode(['name2_en']); $this->writeToTranslationFile(Exporter::PERSISTENT_STRINGS_FILENAME_WO_EXT, $persistentContent); // 3. Create a test view only containing one of the non-persistent strings, and a new string. $this->createTestView("{{ Existing string: __('name1_en') New string: __('name4_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); // The missing, non-persistent, strings should be removed. The rest should remain. $expected = [ 'name1_en' => 'name1_es', 'name2_en' => 'name2_es', 'name4_en' => 'name4_en', ]; $this->assertEquals($expected, $actual); } public function testAddingPersistentStringsToExport() { $this->app['config']->set( 'laravel-translatable-string-exporter.add-persistent-strings-to-translations', true ); $this->removeJsonLanguageFiles(); // 1. Create a translation file ourselves. $existing_translations = [ 'name1_en' => 'name1_es', 'name2_en' => 'name2_es', 'name3_en' => 'name3_es', ]; $content = json_encode($existing_translations); $this->writeToTranslationFile('es', $content); // 2. Create a file with the keys of any strings which should persist // even if they are not contained in the views. $persistentContent = json_encode(['name3_en', 'name5_en']); $this->writeToTranslationFile(Exporter::PERSISTENT_STRINGS_FILENAME_WO_EXT, $persistentContent); // 3. Create a test view only containing a new string and a string that is also in persistent strings. $this->createTestView("{{ __('name1_en') . __('name2_en') . __('name3_en') . __('name4_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); // The new and persistent strings should be added. The rest should remain. $expected = array_merge($existing_translations, [ 'name4_en' => 'name4_en', 'name5_en' => 'name5_en', ]); $this->assertEquals($expected, $actual); } public function testIgnoreTranslationKeysEnabled() { $this->app['config']->set('laravel-translatable-string-exporter.exclude-translation-keys', true); $this->removeJsonLanguageFiles(); $view = "{{ __('text to translate') }} " . "{{ __('string with a dot.') }} " . "{{ __('string with a dot. in the middle') }} " . "{{ __('menu.unknown') }} " . "{{ __('menu.submenu1') }} " . "{{ __('menu.submenu1.item1') }} " . "{{ __('menu.item1') }} "; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'text to translate' => 'text to translate', 'string with a dot.' => 'string with a dot.', 'string with a dot. in the middle' => 'string with a dot. in the middle', 'menu.unknown' => 'menu.unknown', 'menu.submenu1' => 'menu.submenu1', ]; $this->assertEquals($expected, $actual); } public function testPuttingUntranslatedStringsToTop() { $this->app['config']->set( 'laravel-translatable-string-exporter.put-untranslated-strings-at-the-top', true ); $this->removeJsonLanguageFiles(); // 1. Create a translation file with all srings translated. $existing_translations = [ 'name1_en' => 'name1_es', 'name2_en' => 'name2_es', 'name3_en' => 'name3_es', ]; $content = json_encode($existing_translations); $this->writeToTranslationFile('es', $content); // 2. [Sorting disabled] Create a test view with translated and untranslated strings. $this->app['config']->set( 'laravel-translatable-string-exporter.sort-keys', false ); $this->createTestView("{{ __('name1_en') . __('name2_en') . __('name3_en') . __('name5_en') . __('name4_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = array_merge([ 'name5_en' => 'name5_en', 'name4_en' => 'name4_en', ], $existing_translations); // Check that arrays are equivalent taking into account element order. $this->assertTrue($expected === $actual, 'Expected and actual arrays are not equivalent.'); // 3. [Sorting enabled] Create a test view with translated and untranslated strings. $this->app['config']->set( 'laravel-translatable-string-exporter.sort-keys', true ); $this->createTestView("{{ __('name1_en') . __('name2_en') . __('name3_en') . __('name5_en') . __('name4_en') }}"); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = array_merge([ 'name4_en' => 'name4_en', 'name5_en' => 'name5_en', ], $existing_translations); // Check that arrays are equivalent taking into account element order. $this->assertTrue($expected === $actual, 'Expected and actual arrays are not equivalent.'); } public function testSettingAFunctionToTransform() { $this->app['config']->set('laravel-translatable-string-exporter.functions.aFunction', fn ($s) => \strtoupper(\str_replace(["-","_"], " ", $s))); $this->removeJsonLanguageFiles(); $view = "{{ aFunction('text-to-translate') }}"; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'TEXT TO TRANSLATE' => 'TEXT TO TRANSLATE', ]; $this->assertEquals($expected, $actual); } public function testSettingACallableToTransform() { $this->app['config']->set('laravel-translatable-string-exporter.functions.staticMethod', [Transformer::class, 'staticMethod']); $this->app['config']->set('laravel-translatable-string-exporter.functions.publicMethod', [new Transformer(), 'publicMethod']); $this->removeJsonLanguageFiles(); $view = "{{ staticMethod('static-text-to-translate') }} {{ publicMethod('public-text-to-translate') }}"; $this->createTestView($view); $this->artisan('translatable:export', ['lang' => 'es']) ->expectsOutput('Translatable strings have been extracted and written to the es.json file.') ->assertExitCode(0); $actual = $this->getTranslationFileContent('es'); $expected = [ 'STATIC TEXT TO TRANSLATE' => 'STATIC TEXT TO TRANSLATE', 'PUBLIC TEXT TO TRANSLATE' => 'PUBLIC TEXT TO TRANSLATE', ]; $this->assertEquals($expected, $actual); } } laravel-translatable-string-exporter/tests/__fixtures/resources/views/ignored/index.blade.php000064400000000033150364332160027012 0ustar00{{ __('ignored string') }} laravel-translatable-string-exporter/tests/__fixtures/resources/views/.gitignore000064400000000060150364332160024465 0ustar00* !.gitignore !ignored !ignored/index.blade.php laravel-translatable-string-exporter/tests/__fixtures/resources/lang/es/menu.php000064400000000157150364332160024354 0ustar00 'Item 1', 'submenu1' => [ 'item1' => 'Submenu 1: Item 1', ], ]; laravel-translatable-string-exporter/tests/__fixtures/resources/lang/.gitignore000064400000000033150364332160024251 0ustar00* !.gitignore !es/menu.php laravel-translatable-string-exporter/tests/__fixtures/lang/.gitignore000064400000000033150364332160022237 0ustar00* !.gitignore !es/menu.php laravel-translatable-string-exporter/tests/__fixtures/classes/Transformer.php000064400000000451150364332160024002 0ustar00setBasePath(__DIR__ . DIRECTORY_SEPARATOR . '__fixtures'); $app['config']->set('laravel-translatable-string-exporter.directories', [ 'resources', ]); $app['config']->set('laravel-translatable-string-exporter.excluded-directories', [ 'views/ignored', ]); $app['config']->set('laravel-translatable-string-exporter.sort-keys', true); $app['config']->set('laravel-translatable-string-exporter.functions', [ '__', '_t', '@lang', ]); } protected function removeJsonLanguageFiles() { $path = $this->getTranslationFilePath('*'); $files = glob($path); // get all file names foreach ($files as $file) { // iterate files if (is_file($file)) { unlink($file); // delete file } } } protected function createTestView($content) { file_put_contents(resource_path('views/index.blade.php'), $content); } protected function getTranslationFilePath($language) { return function_exists('lang_path') ? lang_path("$language.json") : resource_path("lang/$language.json"); } protected function getTranslationFileContent($language) { $path = $this->getTranslationFilePath($language); $content = file_get_contents($path); return json_decode($content, true); } protected function writeToTranslationFile($language, $content) { $path = $this->getTranslationFilePath($language); file_put_contents($path, $content); } } laravel-translatable-string-exporter/tests/UntranslatedStringFinderTest.php000064400000003050150364332160023515 0ustar00removeJsonLanguageFiles(); $language = 'fr'; $command = $this->artisan('translatable:inspect-translations', [ 'lang' => $language, ]) ->expectsOutput('Did not find ' . $language . '.json file. Use --export-first option.'); $command->assertExitCode(Command::FAILURE); } public function testExportAndInspect() { $this->removeJsonLanguageFiles(); $source = [ 'name3', 'name2', 'name1', ]; $template_strings = array_map(function ($translatable_string) { return "{{ __('" . $translatable_string . "') }}"; }, $source); $this->createTestView(implode(' ', $template_strings)); $language = 'es'; $command = $this->artisan('translatable:inspect-translations', [ 'lang' => $language, '--export-first' => true, ]) ->expectsOutput( 'Found ' . count($source) . ' untranslated ' . Str::plural('string', count($source)) . ' in the ' . $language . '.json file:' ); $expected = array_reverse($source); foreach ($expected as $str) { $command->expectsOutput($str); } $command->assertExitCode(Command::SUCCESS); } } laravel-translatable-string-exporter/.php_cs.dist.php000064400000002451150364332160017030 0ustar00in([ __DIR__ . '/src', __DIR__ . '/tests', ]) ->name('*.php') ->notName('*.blade.php') ->ignoreDotFiles(true) ->ignoreVCS(true); return (new PhpCsFixer\Config()) ->setRules([ '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'no_unused_imports' => true, 'not_operator_with_successor_space' => true, 'trailing_comma_in_multiline' => true, 'phpdoc_scalar' => true, 'unary_operator_spaces' => true, 'binary_operator_spaces' => true, 'blank_line_before_statement' => [ 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], ], 'phpdoc_single_line_var_spacing' => true, 'phpdoc_var_without_name' => true, 'class_attributes_separation' => [ 'elements' => [ 'method' => 'one', ], ], 'method_argument_space' => [ 'on_multiline' => 'ensure_fully_multiline', 'keep_multiple_spaces_after_comma' => true, ], 'single_trait_insert_per_statement' => true, 'visibility_required' => true, ]) ->setFinder($finder); laravel-translatable-string-exporter/src/Core/CodeParser.php000064400000005026150364332160020243 0ustar00(?:(?![^\\\]\2).)+.)\2\s*[\),]/u'; /** * Function-specific search patterns. * * @var array */ protected $patterns = []; /** * Parser constructor. * * @return void */ public function __construct() { $this->functions = config( 'laravel-translatable-string-exporter.functions', [ '__', '_t', '@lang', ] ); foreach ($this->functions as $key => $value) { if (\is_numeric($key)) { $func = $value; $callable = null; } else { $func = $key; $callable = $value; } $pattern_key = str_replace('[FUNCTIONS]', $func, $this->basePattern); if (config('laravel-translatable-string-exporter.allow-newlines', false)) { $pattern_key .= 's'; } $this->patterns[$pattern_key] = $callable; } } /** * Parse a file in order to find translatable strings. * * @param \Symfony\Component\Finder\SplFileInfo $file * @return array */ public function parse(SplFileInfo $file) { $strings = []; foreach ($this->patterns as $pattern => $func) { preg_match_all($pattern, $file->getContents(), $matches); foreach ($matches['string'] as $string) { if (\is_null($func)) { $strings[] = $string; } elseif (\is_callable($func)) { $strings[] = call_user_func($func, $string); } } } // Remove duplicates. $strings = array_unique($strings); return $this->clean($strings); } /** * Provide extra clean up step * Used for instances of {{ __('We\'re amazing!') }} * Without clean up: We\'re amazing! * With clean up: We're amazing! * * @param array $strings * @return array */ public function clean(array $strings) { return array_map(function ($string) { return str_replace('\\\'', '\'', $string); }, $strings); } } laravel-translatable-string-exporter/src/Core/FileFinder.php000064400000003422150364332160020221 0ustar00directories = config( 'laravel-translatable-string-exporter.directories', [ 'app', 'resources', ] ); $this->excludedDirectories = config( 'laravel-translatable-string-exporter.excluded-directories', [] ); $this->patterns = config( 'laravel-translatable-string-exporter.patterns', [ '*.php', '*.js', ] ); } /** * Find all files that can contain translatable strings. * * @return \Symfony\Component\Finder\Finder|null */ public function find() { $path = base_path(); $directories = $this->directories; array_walk($directories, function (&$item) use ($path) { $item = $path . DIRECTORY_SEPARATOR . $item; }); $excludedDirectories = $this->excludedDirectories; $finder = new Finder(); $finder = $finder->in($directories); $finder = $finder->exclude($excludedDirectories); foreach ($this->patterns as $pattern) { $finder->name($pattern); } return $finder->files(); } } laravel-translatable-string-exporter/src/Core/UntranslatedStringFinder.php000064400000001554150364332160023201 0ustar00finder = new FileFinder(); $this->parser = new CodeParser(); } /** * Extract translatable strings from the project files. * * @return array */ public function extract() { $strings = []; $files = $this->finder->find(); foreach ($files as $file) { $strings = array_merge($strings, $this->parser->parse($file)); } return $this->formatArray($strings); } /** * Convert an array of extracted strings to an associative array where each string becomes key and value. * * @param array $strings * @return array */ protected function formatArray(array $strings) { $result = []; foreach ($strings as $string) { $result[$string] = $string; } return $result; } } laravel-translatable-string-exporter/src/Core/Exporter.php000064400000017052150364332160020026 0ustar00extractor = new StringExtractor(); } /** * Export translatable strings to the language file. * * @param string $language * @return void */ public function export(string $language) { $language_path = IO::languageFilePath($language); // Extract source strings from the project directories. $new_strings = $this->extractor->extract(); // Read existing translation file for the chosen language. $existing_strings = IO::readTranslationFile($language_path); // Get the persistent strings. $persistent_strings_path = IO::languageFilePath(self::PERSISTENT_STRINGS_FILENAME_WO_EXT); $persistent_strings = IO::readTranslationFile($persistent_strings_path); // Add persistent strings to the export if enabled. $new_strings = $this->addPersistentStringsIfEnabled($new_strings, $persistent_strings); // Merge old an new translations preserving existing translations and persistent strings. $resulting_strings = $this->mergeStrings($new_strings, $existing_strings, $persistent_strings); // Exclude translation keys if enabled through the config. $resulting_strings = $this->excludeTranslationKeysIfEnabled($resulting_strings, $language); // Wisely sort the translations if enabled through the config. $sorted_strings = $this->advancedSortIfEnabled($resulting_strings); // Prepare JSON string and dump it to the translation file. $content = JSON::jsonEncode($sorted_strings); IO::write($content, $language_path); } /** * Merge two arrays of translations preserving existing translations and persistent strings. * * @param array $existing_strings * @param array $new_strings * @param array $persistent_strings * @return array */ protected function mergeStrings(array $new_strings, array $existing_strings, array $persistent_strings) { $merged_strings = array_merge($new_strings, $existing_strings); return $this->arrayFilterByKey($merged_strings, function ($key) use ($persistent_strings, $new_strings) { return in_array($key, $persistent_strings) || array_key_exists($key, $new_strings); }); } /** * Sort the translation strings alphabetically by their original strings (keys) * if the corresponding option is enabled through the package config. * * @param array $strings * @return array */ protected function sortIfEnabled(array $strings) { if (config('laravel-translatable-string-exporter.sort-keys', false)) { return Arr::sort($strings, function ($value, $key) { return strtolower($key); }); } return $strings; } /** * Add keys from the persistent-strings file to new strings array. * * @param array $new_strings * @param array $persistent_strings * @return array */ protected function addPersistentStringsIfEnabled(array $new_strings, array $persistent_strings) { if (config('laravel-translatable-string-exporter.add-persistent-strings-to-translations', false)) { $new_strings = array_merge( array_combine($persistent_strings, $persistent_strings), $new_strings ); } return $new_strings; } /** * Exclude Laravel translation keys from the array * if they have corresponding translations in the given language. * * @param array $translatable_strings * @param string $language * @return array|mixed */ protected function excludeTranslationKeysIfEnabled(array $translatable_strings, string $language) { if (config('laravel-translatable-string-exporter.exclude-translation-keys', false)) { foreach ($translatable_strings as $key => $value) { if ($this->isTranslationKey($key, $language)) { unset($translatable_strings[$key]); } } } return $translatable_strings; } /** * Wisely sort translatable strings if this option is enabled through the config. * If it's requested (through the config) to put untranslated strings * at the top of the translation file, then untranslated and translated strings * are sorted separately. * * @param array $translatable_strings * @return array */ protected function advancedSortIfEnabled(array $translatable_strings) { // If it's necessary to put untranslated strings at the top. if (config('laravel-translatable-string-exporter.put-untranslated-strings-at-the-top', false)) { $translated = []; $untranslated = []; foreach ($translatable_strings as $key => $value) { if ($key === $value) { $untranslated[$key] = $value; continue; } $translated[$key] = $value; } $translated = $this->sortIfEnabled($translated); $untranslated = $this->sortIfEnabled($untranslated); return array_merge($untranslated, $translated); } // Sort the translations if enabled through the config. return $this->sortIfEnabled($translatable_strings); } /** * Filtering an array by its keys using a callback. * * @param array $array * The array to iterate over. * @param callable $callback * The callback function to use. * * @return array * The filtered array. */ private function arrayFilterByKey($array, $callback) { return array_filter($array, $callback, ARRAY_FILTER_USE_KEY); } /** * Check if the given translatable string is a translation key and has a translation. * The translation keys are ignored if the corresponding option is set through the config. * * @param string $key * @param string $locale * @return bool */ private function isTranslationKey(string $key, string $locale) { $dot_position = strpos($key, '.'); // Ignore string without dots. if ($dot_position === false) { return false; } // Ignore strings where the dot is at the end of a string // because it's a normal sentence. if ($dot_position === (strlen($key) - 1)) { return false; } $segments = explode('.', $key); // Everything but last segment determines a group. $key = array_pop($segments); $group = implode('.', $segments); $translations = Lang::get($group, [], $locale); // If the received translation is an array, the initial translation key is not full, // so we consider it wrong. return isset($translations[$key]) && ! is_array($translations[$key]); } } laravel-translatable-string-exporter/src/Console/InspectTranslationsCommand.php000064400000005514150364332160024236 0ustar00argument('lang'); $export_first = $this->option('export-first'); if ($export_first) { $this->exporter->export($language); $this->info('Translatable strings have been extracted and written to the ' . $language . '.json file.'); } // Find untranslated strings in the given language file. $untranslated_strings = $this->finder->find($language); if ($untranslated_strings === false) { $this->info('Did not find ' . $language . '.json file. Use --export-first option.'); return static::FAILURE; } if (empty($untranslated_strings)) { $this->info('Did not find any untranslated strings in the ' . $language . '.json file.'); return static::FAILURE; } $count_untranslated = count($untranslated_strings); // Display untranslated strings. $this->info( 'Found ' . $count_untranslated . ' untranslated ' . Str::plural('string', $count_untranslated) . ' in the ' . $language . '.json file:' ); foreach ($untranslated_strings as $untranslated_string) { $this->info($untranslated_string); } return static::SUCCESS; } /** * Get the console command arguments. * * @return array */ protected function getArguments() { return [ [ 'lang', InputArgument::REQUIRED, 'A language code for which untranslated strings are detected, e.g. "es".', ], ]; } } laravel-translatable-string-exporter/src/Console/ExportCommand.php000064400000003153150364332160021505 0ustar00argument('lang')); foreach ($languages as $language) { $this->exporter->export($language); $this->info('Translatable strings have been extracted and written to the ' . $language . '.json file.'); } return static::SUCCESS; } /** * Get the console command arguments. * * @return array */ protected function getArguments() { return [ [ 'lang', InputArgument::REQUIRED, 'A language code or a comma-separated list of language codes for which the translatable strings are extracted, e.g. "es" or "es,bg,de".', ], ]; } } laravel-translatable-string-exporter/src/Providers/ExporterServiceProvider.php000064400000005170150364332160024145 0ustar00app->singleton('translatable-string-exporter-exporter', function ($app) { return $app->make(Exporter::class); }); $this->app->singleton('command.translatable-string-exporter-exporter.export', function ($app) { return new ExportCommand($app['translatable-string-exporter-exporter']); }); $this->commands('command.translatable-string-exporter-exporter.export'); // Inspect translations command. $this->app->singleton('translatable-string-exporter-inspect-translations', function ($app) { return $app->make(UntranslatedStringFinder::class); }); $this->app->singleton('command.translatable-string-exporter-inspect-translations.inspect-translations', function ($app) { return new InspectTranslationsCommand( $app['translatable-string-exporter-exporter'], $app['translatable-string-exporter-inspect-translations'] ); }); $this->commands('command.translatable-string-exporter-inspect-translations.inspect-translations'); } /** * Get the services provided by the provider. * * @return array */ public function provides() { return [ 'translatable-string-exporter-exporter', 'command.translatable-string-exporter-exporter.export', 'translatable-string-exporter-inspect-translations', 'command.translatable-string-exporter-inspect-translations.inspect-translations', ]; } /** * Perform post-registration booting of services. * * @return void */ public function boot() { $this->publishes([ __DIR__.'/../../config/laravel-translatable-string-exporter.php' => config_path('laravel-translatable-string-exporter.php'), ]); } } laravel-translatable-string-exporter/README.md000064400000010411150364332160015275 0ustar00# Translatable String Exporter for Laravel [![Tests Status Badge](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/run-tests.yml/badge.svg)](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/run-tests.yml) [![PHPStan Status Badge](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/phpstan.yml.yml/badge.svg)](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/phpstan.yml.yml) [![Code Styles Check Badge](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/php-cs-fixer.yml/badge.svg)](https://github.com/kkomelin/laravel-translatable-string-exporter/actions/workflows/php-cs-fixer.yml) You can use `__('Translate me')` or `@lang('Translate me')` with translations in JSON files to translate strings. Translatable String Exporter is aimed to collect all translatable strings of an application and create corresponding translation files in JSON format to simplify the process of translation. ## Versions | Package | PHP | | ---------- | ------------ | | `<=1.15.1` | `5.6` | | `>1.15.1` | `^7.2\|^8.0` | | `>1.18.0` | `^8.0` | _Even though we drop support for PHP versions in minor releases, Composer ensures that users with previous versions of PHP don't get not-yet-supported PHP code._ ## Installation _Normally, it's enough to install the package as a development dependency._ ```bash composer require kkomelin/laravel-translatable-string-exporter --dev ``` ## Configuration To change [project defaults](https://github.com/kkomelin/laravel-translatable-string-exporter/wiki/Configuration-and-Project-Defaults), use the following command to create a configuration file in your `config/` folder and make necessary changes in there: ```bash php artisan vendor:publish --provider="KKomelin\TranslatableStringExporter\Providers\ExporterServiceProvider" ``` ## Usage ### Export translatable strings ```bash php artisan translatable:export ``` Where `` is a language code or a comma-separated list of language codes. For example: ```bash php artisan translatable:export es php artisan translatable:export es,bg,de ``` The command with the `"es,bg,de"` parameter passed will create `es.json`, `bg.json`, `de.json` files with translatable strings or update the existing files in the `lang/` folder of your project. ### Find untranslated strings in a language file (command) To inspect an existing language file (find untranslated strings), use this command: ```bash php artisan translatable:inspect-translations fr ``` The command only supports inspecting one language at a time. To export translatable strings for a language and then inspect translations in it, use the following command: ```bash php artisan translatable:inspect-translations fr --export-first ``` ### Find untranslated strings in a language file (IDE) An alternative way to find untranslated strings in your language files is to search for entries with the same string for original and translated. You can do this in most editors using a regular expression. In PhpStorm and VSCode, you can use this pattern: `"([^"]*)": "\1"` ### Persistent strings Some strings are not included in the export, because they are being dynamically generated. For example: `{{ __(sprintf('Dear customer, your order has been %s', $orderStatus)) }}` Where `$orderStatus` can be `'approved'`, `'paid'`, `'cancelled'` and so on. In this case, you can add the strings to the `.json` file manually. For example: ``` ..., "Dear customer, your order has been approved": "Dear customer, your order has been approved", "Dear customer, your order has been paid": "Dear customer, your order has been paid", ... ``` In order for those, manually added, strings not to get removed the next time you run the export command, you should add them to a json file named `persistent-strings.json`. For example: ``` [ ..., "Dear customer, your order has been approved", "Dear customer, your order has been paid", ... ] ``` ## License & Copyright [MIT](https://github.com/kkomelin/laravel-translatable-string-exporter/blob/master/LICENSE), (c) 2017-present Konstantin Komelin and [contributors](https://github.com/kkomelin/laravel-translatable-string-exporter/graphs/contributors) laravel-translatable-string-exporter/.github/workflows/php-cs-fixer.yml000064400000000676150364332160022457 0ustar00name: Code Styles on: [push] jobs: php-cs-fixer: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Run PHP CS Fixer uses: docker://oskarstark/php-cs-fixer-ga with: args: --config=.php_cs.dist.php --allow-risky=yes - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: Fix styling laravel-translatable-string-exporter/.github/workflows/phpstan.yml.yml000064400000001065150364332160022420 0ustar00name: PHPStan on: push: paths: - '**.php' - 'phpstan.neon.dist' pull_request: paths: - '**.php' - 'phpstan.neon.dist' jobs: phpstan: name: phpstan runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.0' coverage: none - name: Install composer dependencies uses: ramsey/composer-install@v2 - name: Run PHPStan run: ./vendor/bin/phpstan --error-format=github laravel-translatable-string-exporter/.github/workflows/run-tests.yml000064400000002551150364332160022110 0ustar00name: Tests on: push: branches: [master] pull_request: branches: [master] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: true matrix: php: [8.0, 8.1, 8.2] laravel: [8.*, 9.*, 10.*] include: - laravel: 8.* testbench: ^6.0 larastan: ^1.0 - laravel: 9.* testbench: ^7.0 larastan: ^2.0 - laravel: 10.* testbench: ^8.0 larastan: ^2.0 exclude: - php: 8.0 laravel: 10.* - php: 8.2 laravel: 8.* name: P${{ matrix.php }} - L${{ matrix.laravel }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo coverage: none - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nunomaduro/larastan:${{ matrix.larastan }}" --no-interaction --no-update composer update --prefer-dist --no-interaction - name: Execute tests run: vendor/bin/phpunit laravel-translatable-string-exporter/config/laravel-translatable-string-exporter.php000064400000004150150364332160025251 0ustar00 [ 'app', 'resources', ], // Directories to exclude from search. // // Please note, these directories should be relative to the ones listed in 'directories'. // For example, if you have 'resources' in 'directories', then to ignore the 'resources/ignored' directory, // you need to add 'ignored' to the 'excluded-directories' list. 'excluded-directories'=> [ ], // File Patterns to search for. 'patterns'=> [ '*.php', '*.js', ], // Indicates whether new lines are allowed in translations. 'allow-newlines' => false, // Translation function names or a custom transform function. // Example of a custom transform function: // 'transform' => fn ($s) => \strtoupper(\str_replace(["-","_"], " ", $s)) // If your function name contains $ escape it using \$ . 'functions'=> [ '__', '_t', '@lang', ], // Indicates whether you need to sort the translations alphabetically // by original strings (keys). // It helps navigate a translation file and detect possible duplicates. 'sort-keys' => true, // Indicates whether keys from the persistent-strings file should be also added // to translation files automatically on export if they don't yet exist there. 'add-persistent-strings-to-translations' => false, // Indicates whether it's necessary to exclude Laravel translation keys // from the resulting language file if they have corresponding translations // in the given language. // This option allows correctly combine two translation approaches: // Laravel translation keys (PHP) and translatable strings (JSON). 'exclude-translation-keys' => false, // Indicates whether you need to put untranslated strings // at the top of a translation file. // The criterion of whether a string is untranslated is // if its key and value are equivalent. // If sorting is enabled, untranslated and translated strings are sorted separately. 'put-untranslated-strings-at-the-top' => false, ]; laravel-translatable-string-exporter/.editorconfig000064400000000334150364332160016476 0ustar00root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.{yml,yaml}] indent_size = 2 laravel-translatable-string-exporter/LICENSE000064400000002114150364332160015024 0ustar00MIT License Copyright (c) 2017-present Konstantin Komelin and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. laravel-translatable-string-exporter/.gitignore000064400000000121150364332160016003 0ustar00.idea/ vendor/ composer.lock .DS_Store .phpunit.result.cache .php-cs-fixer.cache laravel-translatable-string-exporter/phpstan.neon.dist000064400000000154150364332160017321 0ustar00parameters: level: 4 paths: - src - config checkMissingIterableValueType: false laravel-translatable-string-exporter/phpunit.xml000064400000001101150364332160016223 0ustar00 ./tests