diff --git a/composer.json b/composer.json index 52df06a3..213c9e59 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ } ], "require": { + "eftec/bladeone": "3.52", "gettext/gettext": "^4.8", "mck89/peast": "^1.13.11", "wp-cli/wp-cli": "^2.5" diff --git a/features/makepot.feature b/features/makepot.feature index d619c939..53bcfd4f 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -2911,6 +2911,112 @@ Feature: Generate a POT file of a WordPress project msgid "Bar" """ + @blade + Scenario: Extract strings from a Blade-PHP file in a theme (ignoring domains) + Given an empty foo-theme directory + And a foo-theme/style.css file: + """ + /* + Theme Name: Foo Theme + Theme URI: https://example.com + Description: + Author: + Author URI: + Version: 0.1.0 + License: GPL-2.0+ + Text Domain: foo-theme + */ + """ + And a foo-theme/stuff.blade.php file: + """ + @php + __('Test'); + @endphp + @extends('layouts.app') + + @php(__('Another test.', 'some-other-domain')) + + @section('content') + @include('partials.page-header') + + @if (! have_posts()) + + {!! __('Page not found.', 'foo-theme') !!} + + + {!! get_search_form(false) !!} + @endif + @endsection + """ + + When I try `wp i18n make-pot foo-theme result.pot --ignore-domain --debug` + Then STDOUT should be: + """ + Theme stylesheet detected. + Success: POT file successfully generated! + """ + And the result.pot file should contain: + """ + msgid "Test" + """ + And the result.pot file should contain: + """ + msgid "Page not found." + """ + And the result.pot file should contain: + """ + msgid "Another test." + """ + + @blade + Scenario: Extract strings from a Blade-PHP file in a theme + Given an empty foo-theme directory + And a foo-theme/style.css file: + """ + /* + Theme Name: Foo Theme + Theme URI: https://example.com + Description: + Author: + Author URI: + Version: 0.1.0 + License: GPL-2.0+ + Text Domain: foo-theme + */ + """ + And a foo-theme/stuff.blade.php file: + """ + @php + __('Test'); + @endphp + @extends('layouts.app') + + @php(__('Another test.', 'some-other-domain')) + + @section('content') + @include('partials.page-header') + + @if (! have_posts()) + + {!! __('Page not found.', 'foo-theme') !!} + + + {!! get_search_form(false) !!} + @endif + @endsection + """ + + When I try `wp i18n make-pot foo-theme result.pot --debug` + Then STDOUT should be: + """ + Theme stylesheet detected. + Success: POT file successfully generated! + """ + And the result.pot file should contain: + """ + msgid "Page not found." + """ + Scenario: Custom package name Given an empty example-project directory And a example-project/stuff.php file: diff --git a/src/BladeCodeExtractor.php b/src/BladeCodeExtractor.php new file mode 100644 index 00000000..3f3b7560 --- /dev/null +++ b/src/BladeCodeExtractor.php @@ -0,0 +1,66 @@ + [ 'translators', 'Translators' ], + 'constants' => [], + 'functions' => [ + '__' => 'text_domain', + 'esc_attr__' => 'text_domain', + 'esc_html__' => 'text_domain', + 'esc_xml__' => 'text_domain', + '_e' => 'text_domain', + 'esc_attr_e' => 'text_domain', + 'esc_html_e' => 'text_domain', + 'esc_xml_e' => 'text_domain', + '_x' => 'text_context_domain', + '_ex' => 'text_context_domain', + 'esc_attr_x' => 'text_context_domain', + 'esc_html_x' => 'text_context_domain', + 'esc_xml_x' => 'text_context_domain', + '_n' => 'single_plural_number_domain', + '_nx' => 'single_plural_number_context_domain', + '_n_noop' => 'single_plural_domain', + '_nx_noop' => 'single_plural_context_domain', + + // Compat. + '_' => 'gettext', // Same as 'text_domain'. + + // Deprecated. + '_c' => 'text_domain', + '_nc' => 'single_plural_number_domain', + '__ngettext' => 'single_plural_number_domain', + '__ngettext_noop' => 'single_plural_domain', + ], + ]; + + protected static $functionsScannerClass = 'WP_CLI\I18n\PhpFunctionsScanner'; + + /** + * {@inheritdoc} + */ + public static function fromString( $string, Translations $translations, array $options = [] ) { + WP_CLI::debug( "Parsing file {$options['file']}", 'make-pot' ); + + try { + static::fromStringMultiple( $string, [ $translations ], $options ); + } catch ( Exception $exception ) { + WP_CLI::debug( + sprintf( + 'Could not parse file %1$s: %2$s', + $options['file'], + $exception->getMessage() + ), + 'make-pot' + ); + } + } +} diff --git a/src/BladeGettextExtractor.php b/src/BladeGettextExtractor.php new file mode 100644 index 00000000..a5233534 --- /dev/null +++ b/src/BladeGettextExtractor.php @@ -0,0 +1,51 @@ +withoutComponentTags(); + } + + return $blade_compiler; + } + + /** + * Compiles the Blade template string into a PHP string in one step. + * + * @param string $string Blade string to be compiled to a PHP string + * @return string + */ + protected static function compileBladeToPhp( $string ) { + return static::getBladeCompiler()->compileString( $string ); + } + + /** + * {@inheritdoc} + * + * Note: In the parent PhpCode class fromString() uses fromStringMultiple() (overriden here) + */ + public static function fromStringMultiple( $string, array $translations, array $options = [] ) { + $php_string = static::compileBladeToPhp( $string ); + return parent::fromStringMultiple( $php_string, $translations, $options ); + } +} diff --git a/src/IterableCodeExtractor.php b/src/IterableCodeExtractor.php index 34cfb5d3..5cdb2369 100644 --- a/src/IterableCodeExtractor.php +++ b/src/IterableCodeExtractor.php @@ -238,7 +238,7 @@ static function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions return true; } - if ( ! $file->isFile() || ! in_array( $file->getExtension(), $extensions, true ) ) { + if ( ! $file->isFile() || ! static::file_has_file_extension( $file, $extensions ) ) { return false; } @@ -250,7 +250,7 @@ static function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions foreach ( $files as $file ) { /** @var SplFileInfo $file */ - if ( ! $file->isFile() || ! in_array( $file->getExtension(), $extensions, true ) ) { + if ( ! $file->isFile() || ! static::file_has_file_extension( $file, $extensions ) ) { continue; } @@ -262,6 +262,37 @@ static function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions return $filtered_files; } + /** + * Determines whether the file extension of a file matches any of the given file extensions. + * The end/last part of a multi file extension must also match (`js` of `min.js`). + * + * @param SplFileInfo $file File or directory. + * @param array $extensions List of file extensions to match. + * @return bool Whether the file has a file extension that matches any of the ones in the list. + */ + private static function file_has_file_extension( $file, $extensions ) { + return in_array( $file->getExtension(), $extensions, true ) || + in_array( static::file_get_extension_multi( $file ), $extensions, true ); + } + + /** + * Gets the single- (e.g. `php`) or multi-file extension (e.g. `blade.php`) of a file. + * + * @param SplFileInfo $file File or directory. + * @return string The single- or multi-file extension of the file. + */ + private static function file_get_extension_multi( $file ) { + $file_extension_separator = '.'; + + $filename = $file->getFilename(); + $parts = explode( $file_extension_separator, $filename, 2 ); + if ( count( $parts ) <= 1 ) { + // if ever something goes wrong, fall back to SPL + return $file->getExtension(); + } + return $parts[1]; + } + /** * Trim leading slash from a path. * diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 37794185..0c156402 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -69,6 +69,11 @@ class MakePotCommand extends WP_CLI_Command { */ protected $skip_php = false; + /** + * @var bool + */ + protected $skip_blade = false; + /** * @var bool */ @@ -163,7 +168,7 @@ class MakePotCommand extends WP_CLI_Command { /** * Create a POT file for a WordPress project. * - * Scans PHP and JavaScript files for translatable strings, as well as theme stylesheets and plugin files + * Scans PHP, Blade-PHP and JavaScript files for translatable strings, as well as theme stylesheets and plugin files * if the source directory is detected as either a plugin or theme. * * ## OPTIONS @@ -227,6 +232,9 @@ class MakePotCommand extends WP_CLI_Command { * [--skip-php] * : Skips PHP string extraction. * + * [--skip-blade] + * : Skips Blade-PHP string extraction. + * * [--skip-block-json] * : Skips string extraction from block.json files. * @@ -311,6 +319,7 @@ public function handle_arguments( $args, $assoc_args ) { $this->slug = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) ); $this->skip_js = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js ); $this->skip_php = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php ); + $this->skip_blade = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade ); $this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json ); $this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json ); $this->skip_audit = Utils\get_flag_value( $assoc_args, 'skip-audit', $this->skip_audit ); @@ -609,6 +618,16 @@ protected function extract_strings() { PhpCodeExtractor::fromDirectory( $this->source, $translations, $options ); } + if ( ! $this->skip_blade ) { + $options = [ + 'include' => $this->include, + 'exclude' => $this->exclude, + 'extensions' => [ 'blade.php' ], + 'addReferences' => $this->location, + ]; + BladeCodeExtractor::fromDirectory( $this->source, $translations, $options ); + } + if ( ! $this->skip_js ) { JsCodeExtractor::fromDirectory( $this->source, diff --git a/tests/IterableCodeExtractorTest.php b/tests/IterableCodeExtractorTest.php index f7102a0b..21351218 100644 --- a/tests/IterableCodeExtractorTest.php +++ b/tests/IterableCodeExtractorTest.php @@ -122,10 +122,11 @@ public function test_can_override_exclude_by_include() { } public function test_can_return_all_directory_files_sorted() { - $result = IterableCodeExtractor::getFilesFromDirectory( self::$base, [ '*' ], [], [ 'php', 'js' ] ); + $result = IterableCodeExtractor::getFilesFromDirectory( self::$base, [ '*' ], [], [ 'php', 'blade.php', 'js' ] ); $expected = array( static::$base . 'baz/includes/should_be_included.js', static::$base . 'foo-plugin/foo-plugin.php', + static::$base . 'foo-theme/foo-theme-file.blade.php', static::$base . 'foo/bar/excluded/ignored.js', static::$base . 'foo/bar/foo/bar/foo/bar/deep_directory_also_included.php', static::$base . 'foo/bar/foofoo/included.js', @@ -203,4 +204,66 @@ public function test_can_include_file_in_symlinked_folder() { $expected = static::$base . 'symlinked/includes/should_be_included.js'; $this->assertContains( $expected, $result ); } + + // IterableCodeExtractor::file_get_extension_multi is a private method + protected static function get_method_as_public( $class_name, $method_name ) { + $class = new \ReflectionClass( $class_name ); + $method = $class->getMethod( $method_name ); + $method->setAccessible( true ); + return $method; + } + + protected static function file_get_extension_multi_invoke( $file ) { + $file_get_extension_multi_method = static::get_method_as_public( 'WP_CLI\I18n\IterableCodeExtractor', 'file_get_extension_multi' ); + return $file_get_extension_multi_method->invokeArgs( null, [ $file ] ); + } + + protected static function file_has_file_extension_invoke( $file, $extensions ) { + $file_get_extension_multi_method = static::get_method_as_public( 'WP_CLI\I18n\IterableCodeExtractor', 'file_has_file_extension' ); + return $file_get_extension_multi_method->invokeArgs( null, [ $file, $extensions ] ); + } + + /** + * @dataProvider file_extension_extract_provider + */ + public function test_gets_file_extension_correctly( $rel_input_file, $expected_extension ) { + $this->assertEquals( static::file_get_extension_multi_invoke( new \SplFileObject( self::$base . $rel_input_file ) ), $expected_extension ); + } + + public function file_extension_extract_provider() { + return [ + [ 'foo/bar/foofoo/included.js', 'js' ], + [ 'foo-plugin/foo-plugin.php', 'php' ], + [ 'foo-theme/foo-theme-file.blade.php', 'blade.php' ], + ]; + } + + /** + * @dataProvider file_extensions_matches_provider + */ + public function test_matches_file_extensions_correctly( $rel_input_file, $matching_extensions, $expected_result ) { + $this->assertEquals( static::file_has_file_extension_invoke( new \SplFileObject( self::$base . $rel_input_file ), $matching_extensions ), $expected_result ); + } + + public function file_extensions_matches_provider() { + return [ + [ 'foo/bar/foofoo/included.js', [ 'js' ], true ], + [ 'foo/bar/foofoo/included.js', [ 'js', 'php', 'blade.php' ], true ], + [ 'foo/bar/foofoo/included.js', [ 'php' ], false ], + [ 'foo/bar/foofoo/included.js', [ 'php', 'blade.php' ], false ], + + [ 'foo-plugin/foo-plugin.php', [ 'php', 'js' ], true ], + [ 'foo-plugin/foo-plugin.php', [ 'php', 'blade.php' ], true ], + [ 'foo-plugin/foo-plugin.php', [ 'blade.php', 'js' ], false ], + [ 'foo-plugin/foo-plugin.php', [ 'js', 'blade.php' ], false ], + + [ 'foo-theme/foo-theme-file.blade.php', [ 'php', 'blade.php' ], true ], + [ 'foo-theme/foo-theme-file.blade.php', [ 'blade.php' ], true ], + // the last part of a multi file-extension must also match single file-extensions (e.g. `min.js` matches `js`) + [ 'foo-theme/foo-theme-file.blade.php', [ 'js', 'php' ], true ], + [ 'foo-theme/foo-theme-file.blade.php', [ 'js' ], false ], + [ 'foo/bar/foofoo/minified.min.js', [ 'js', 'json', 'php' ], true ], + [ 'foo/bar/foofoo/minified.min.js', [ 'json', 'php' ], false ], + ]; + } } diff --git a/tests/data/foo-theme/foo-theme-file.blade.php b/tests/data/foo-theme/foo-theme-file.blade.php new file mode 100644 index 00000000..e69de29b