diff --git a/features/makepot.feature b/features/makepot.feature index 9d7bba31..f86d0131 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -3092,6 +3092,69 @@ Feature: Generate a POT file of a WordPress project msgid "Page not found." """ + @blade + Scenario: Extract strings from Blade component prop bindings + 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/components.blade.php file: + """ + @extends('layouts.app') + + @section('content') + + + + + + {!! __('Card content', 'foo-theme') !!} + + + {{ __('Regular echo', 'foo-theme') }} + @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 "Bound prop string" + """ + And the result.pot file should contain: + """ + msgid "No results found" + """ + And the result.pot file should contain: + """ + msgid "Please try a new search" + """ + And the result.pot file should contain: + """ + msgid "Card content" + """ + And the result.pot file should contain: + """ + msgid "Regular echo" + """ + Scenario: Custom package name Given an empty example-project directory And a example-project/stuff.php file: diff --git a/src/BladeGettextExtractor.php b/src/BladeGettextExtractor.php index 58518bd5..4fdd0558 100644 --- a/src/BladeGettextExtractor.php +++ b/src/BladeGettextExtractor.php @@ -39,13 +39,48 @@ protected static function compileBladeToPhp( $text ) { return static::getBladeCompiler()->compileString( $text ); } + /** + * Extracts PHP expressions from Blade component prop bindings. + * + * BladeOne does not compile tags, so bound prop + * expressions like :prop="__('text', 'domain')" are left as-is + * and invisible to the PHP scanner. This method extracts those + * expressions and returns them as PHP code so that any gettext + * function calls within them can be detected. + * + * @param string $text Blade template string. + * @return string PHP code containing the extracted expressions. + */ + protected static function extractComponentPropExpressions( $text ) { + $php = ''; + + // Match opening (and self-closing) Blade component tags: or + // The attribute region handles quoted strings so that a '>' inside an + // attribute value does not end the match prematurely. + if ( ! preg_match_all( '/"\']*(?:"[^"]*"|\'[^\']*\'))*[^>"]*)\/?>/', $text, $tag_matches ) ) { + return $php; + } + + foreach ( $tag_matches[1] as $attributes ) { + // Find :prop="expression" or :prop='expression' bound attributes. + if ( preg_match_all( '/(?'; + } + } + } + + return $php; + } + /** * {@inheritdoc} * * Note: In the parent PhpCode class fromString() uses fromStringMultiple() (overridden here) */ public static function fromStringMultiple( $text, array $translations, array $options = [] ) { - $php_string = static::compileBladeToPhp( $text ); + $php_string = static::compileBladeToPhp( $text ); + $php_string .= static::extractComponentPropExpressions( $text ); return parent::fromStringMultiple( $php_string, $translations, $options ); } } diff --git a/tests/BladeGettextExtractorTest.php b/tests/BladeGettextExtractorTest.php new file mode 100644 index 00000000..3b44fa5a --- /dev/null +++ b/tests/BladeGettextExtractorTest.php @@ -0,0 +1,143 @@ +setDomain( $domain ); + + $options = array_merge( + BladeCodeExtractor::$options, + [ 'file' => 'test.blade.php' ] + ); + + BladeCodeExtractor::fromString( $blade, $translations, $options ); + + return $translations; + } + + public function test_extracts_bound_prop_with_translation_function() { + $translations = $this->extract( + '' + ); + + $this->assertNotFalse( $translations->find( null, 'Hello' ) ); + } + + public function test_extracts_multiple_bound_props() { + $translations = $this->extract( + '' + ); + + $this->assertNotFalse( $translations->find( null, 'Not found' ) ); + $this->assertNotFalse( $translations->find( null, 'Try again' ) ); + } + + public function test_extracts_bound_props_from_multiline_component_tag() { + $blade = <<<'BLADE' + +BLADE; + + $translations = $this->extract( $blade ); + + $this->assertNotFalse( $translations->find( null, 'Page not found' ) ); + $this->assertNotFalse( $translations->find( null, 'Please try again' ) ); + } + + public function test_extracts_bound_props_from_open_component_tag() { + $blade = <<<'BLADE' + + {!! __('Content inside', 'foo-theme') !!} + +BLADE; + + $translations = $this->extract( $blade ); + + $this->assertNotFalse( $translations->find( null, 'Warning message' ) ); + $this->assertNotFalse( $translations->find( null, 'Content inside' ) ); + } + + public function test_ignores_static_props() { + $translations = $this->extract( + '' + ); + + $this->assertNotFalse( $translations->find( null, 'Hello' ) ); + $this->assertFalse( $translations->find( null, 'warning' ) ); + } + + public function test_does_not_match_non_component_html() { + $translations = $this->extract( + '{{ __(\'Link text\', \'foo-theme\') }}' + ); + + $this->assertNotFalse( $translations->find( null, 'Link text' ) ); + // Only 1 translation should exist. + $this->assertCount( 1, $translations ); + } + + public function test_extracts_context_function_in_prop() { + $translations = $this->extract( + '' + ); + + $translation = $translations->find( 'verb', 'Read' ); + $this->assertNotFalse( $translation ); + } + + public function test_extracts_esc_functions_in_props() { + $blade = <<<'BLADE' + +BLADE; + + $translations = $this->extract( $blade ); + + $this->assertNotFalse( $translations->find( null, 'Username' ) ); + $this->assertNotFalse( $translations->find( null, 'Enter username' ) ); + } + + public function test_extracts_single_quoted_bound_props() { + $translations = $this->extract( + "" + ); + + $this->assertNotFalse( $translations->find( null, 'Single quoted' ) ); + } + + public function test_existing_blade_extraction_still_works() { + $blade = <<<'BLADE' +@php + __('PHP block string', 'foo-theme'); +@endphp +{{ __('Echo string', 'foo-theme') }} +{!! __('Raw string', 'foo-theme') !!} +@php(__('Directive string', 'foo-theme')) +BLADE; + + $translations = $this->extract( $blade ); + + $this->assertNotFalse( $translations->find( null, 'PHP block string' ) ); + $this->assertNotFalse( $translations->find( null, 'Echo string' ) ); + $this->assertNotFalse( $translations->find( null, 'Raw string' ) ); + $this->assertNotFalse( $translations->find( null, 'Directive string' ) ); + } +}