Skip to content

Commit 7df61b3

Browse files
authored
Merge pull request #474 from retlehs/blade-component-props
Extract translatable strings from Blade component prop bindings
2 parents 4ba66aa + 48fb6bf commit 7df61b3

File tree

3 files changed

+242
-1
lines changed

3 files changed

+242
-1
lines changed

features/makepot.feature

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3092,6 +3092,69 @@ Feature: Generate a POT file of a WordPress project
30923092
msgid "Page not found."
30933093
"""
30943094

3095+
@blade
3096+
Scenario: Extract strings from Blade component prop bindings
3097+
Given an empty foo-theme directory
3098+
And a foo-theme/style.css file:
3099+
"""
3100+
/*
3101+
Theme Name: Foo Theme
3102+
Theme URI: https://example.com
3103+
Description:
3104+
Author:
3105+
Author URI:
3106+
Version: 0.1.0
3107+
License: GPL-2.0+
3108+
Text Domain: foo-theme
3109+
*/
3110+
"""
3111+
And a foo-theme/components.blade.php file:
3112+
"""
3113+
@extends('layouts.app')
3114+
3115+
@section('content')
3116+
<x-alert :message="__('Bound prop string', 'foo-theme')" />
3117+
3118+
<x-no-results
3119+
:title="__('No results found', 'foo-theme')"
3120+
:subtitle="esc_html__('Please try a new search', 'foo-theme')"
3121+
/>
3122+
3123+
<x-card type="warning">
3124+
{!! __('Card content', 'foo-theme') !!}
3125+
</x-card>
3126+
3127+
{{ __('Regular echo', 'foo-theme') }}
3128+
@endsection
3129+
"""
3130+
3131+
When I try `wp i18n make-pot foo-theme result.pot --debug`
3132+
Then STDOUT should be:
3133+
"""
3134+
Theme stylesheet detected.
3135+
Success: POT file successfully generated.
3136+
"""
3137+
And the result.pot file should contain:
3138+
"""
3139+
msgid "Bound prop string"
3140+
"""
3141+
And the result.pot file should contain:
3142+
"""
3143+
msgid "No results found"
3144+
"""
3145+
And the result.pot file should contain:
3146+
"""
3147+
msgid "Please try a new search"
3148+
"""
3149+
And the result.pot file should contain:
3150+
"""
3151+
msgid "Card content"
3152+
"""
3153+
And the result.pot file should contain:
3154+
"""
3155+
msgid "Regular echo"
3156+
"""
3157+
30953158
Scenario: Custom package name
30963159
Given an empty example-project directory
30973160
And a example-project/stuff.php file:

src/BladeGettextExtractor.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,48 @@ protected static function compileBladeToPhp( $text ) {
3939
return static::getBladeCompiler()->compileString( $text );
4040
}
4141

42+
/**
43+
* Extracts PHP expressions from Blade component prop bindings.
44+
*
45+
* BladeOne does not compile <x-component> tags, so bound prop
46+
* expressions like :prop="__('text', 'domain')" are left as-is
47+
* and invisible to the PHP scanner. This method extracts those
48+
* expressions and returns them as PHP code so that any gettext
49+
* function calls within them can be detected.
50+
*
51+
* @param string $text Blade template string.
52+
* @return string PHP code containing the extracted expressions.
53+
*/
54+
protected static function extractComponentPropExpressions( $text ) {
55+
$php = '';
56+
57+
// Match opening (and self-closing) Blade component tags: <x-name ...> or <x-name ... />
58+
// The attribute region handles quoted strings so that a '>' inside an
59+
// attribute value does not end the match prematurely.
60+
if ( ! preg_match_all( '/<x[-:\w]+\s+((?:[^>"\']*(?:"[^"]*"|\'[^\']*\'))*[^>"]*)\/?>/', $text, $tag_matches ) ) {
61+
return $php;
62+
}
63+
64+
foreach ( $tag_matches[1] as $attributes ) {
65+
// Find :prop="expression" or :prop='expression' bound attributes.
66+
if ( preg_match_all( '/(?<!\w):[\w.-]+=(["\'])(.*?)\1/s', $attributes, $attr_matches ) ) {
67+
foreach ( $attr_matches[2] as $expression ) {
68+
$php .= '<?php ' . $expression . '; ?>';
69+
}
70+
}
71+
}
72+
73+
return $php;
74+
}
75+
4276
/**
4377
* {@inheritdoc}
4478
*
4579
* Note: In the parent PhpCode class fromString() uses fromStringMultiple() (overridden here)
4680
*/
4781
public static function fromStringMultiple( $text, array $translations, array $options = [] ) {
48-
$php_string = static::compileBladeToPhp( $text );
82+
$php_string = static::compileBladeToPhp( $text );
83+
$php_string .= static::extractComponentPropExpressions( $text );
4984
return parent::fromStringMultiple( $php_string, $translations, $options );
5085
}
5186
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace WP_CLI\I18n\Tests;
4+
5+
use Gettext\Translations;
6+
use WP_CLI\I18n\BladeCodeExtractor;
7+
use WP_CLI\Tests\TestCase;
8+
9+
class BladeGettextExtractorTest extends TestCase {
10+
11+
/**
12+
* Helper to extract translations from a Blade string.
13+
*
14+
* @param string $blade Blade template content.
15+
* @param string $domain Text domain.
16+
* @return Translations
17+
*/
18+
private function extract( $blade, $domain = 'foo-theme' ) {
19+
$translations = new Translations();
20+
$translations->setDomain( $domain );
21+
22+
$options = array_merge(
23+
BladeCodeExtractor::$options,
24+
[ 'file' => 'test.blade.php' ]
25+
);
26+
27+
BladeCodeExtractor::fromString( $blade, $translations, $options );
28+
29+
return $translations;
30+
}
31+
32+
public function test_extracts_bound_prop_with_translation_function() {
33+
$translations = $this->extract(
34+
'<x-alert :message="__(\'Hello\', \'foo-theme\')" />'
35+
);
36+
37+
$this->assertNotFalse( $translations->find( null, 'Hello' ) );
38+
}
39+
40+
public function test_extracts_multiple_bound_props() {
41+
$translations = $this->extract(
42+
'<x-no-results :title="__(\'Not found\', \'foo-theme\')" :subtitle="__(\'Try again\', \'foo-theme\')" />'
43+
);
44+
45+
$this->assertNotFalse( $translations->find( null, 'Not found' ) );
46+
$this->assertNotFalse( $translations->find( null, 'Try again' ) );
47+
}
48+
49+
public function test_extracts_bound_props_from_multiline_component_tag() {
50+
$blade = <<<'BLADE'
51+
<x-no-results
52+
:title="__('Page not found', 'foo-theme')"
53+
:subtitle="esc_html__('Please try again', 'foo-theme')"
54+
/>
55+
BLADE;
56+
57+
$translations = $this->extract( $blade );
58+
59+
$this->assertNotFalse( $translations->find( null, 'Page not found' ) );
60+
$this->assertNotFalse( $translations->find( null, 'Please try again' ) );
61+
}
62+
63+
public function test_extracts_bound_props_from_open_component_tag() {
64+
$blade = <<<'BLADE'
65+
<x-alert :message="__('Warning message', 'foo-theme')">
66+
{!! __('Content inside', 'foo-theme') !!}
67+
</x-alert>
68+
BLADE;
69+
70+
$translations = $this->extract( $blade );
71+
72+
$this->assertNotFalse( $translations->find( null, 'Warning message' ) );
73+
$this->assertNotFalse( $translations->find( null, 'Content inside' ) );
74+
}
75+
76+
public function test_ignores_static_props() {
77+
$translations = $this->extract(
78+
'<x-alert type="warning" :message="__(\'Hello\', \'foo-theme\')" />'
79+
);
80+
81+
$this->assertNotFalse( $translations->find( null, 'Hello' ) );
82+
$this->assertFalse( $translations->find( null, 'warning' ) );
83+
}
84+
85+
public function test_does_not_match_non_component_html() {
86+
$translations = $this->extract(
87+
'<a href="https://example.com">{{ __(\'Link text\', \'foo-theme\') }}</a>'
88+
);
89+
90+
$this->assertNotFalse( $translations->find( null, 'Link text' ) );
91+
// Only 1 translation should exist.
92+
$this->assertCount( 1, $translations );
93+
}
94+
95+
public function test_extracts_context_function_in_prop() {
96+
$translations = $this->extract(
97+
'<x-button :label="_x(\'Read\', \'verb\', \'foo-theme\')" />'
98+
);
99+
100+
$translation = $translations->find( 'verb', 'Read' );
101+
$this->assertNotFalse( $translation );
102+
}
103+
104+
public function test_extracts_esc_functions_in_props() {
105+
$blade = <<<'BLADE'
106+
<x-field
107+
:label="esc_html__('Username', 'foo-theme')"
108+
:placeholder="esc_attr__('Enter username', 'foo-theme')"
109+
/>
110+
BLADE;
111+
112+
$translations = $this->extract( $blade );
113+
114+
$this->assertNotFalse( $translations->find( null, 'Username' ) );
115+
$this->assertNotFalse( $translations->find( null, 'Enter username' ) );
116+
}
117+
118+
public function test_extracts_single_quoted_bound_props() {
119+
$translations = $this->extract(
120+
"<x-alert :message='__(\"Single quoted\", \"foo-theme\")' />"
121+
);
122+
123+
$this->assertNotFalse( $translations->find( null, 'Single quoted' ) );
124+
}
125+
126+
public function test_existing_blade_extraction_still_works() {
127+
$blade = <<<'BLADE'
128+
@php
129+
__('PHP block string', 'foo-theme');
130+
@endphp
131+
{{ __('Echo string', 'foo-theme') }}
132+
{!! __('Raw string', 'foo-theme') !!}
133+
@php(__('Directive string', 'foo-theme'))
134+
BLADE;
135+
136+
$translations = $this->extract( $blade );
137+
138+
$this->assertNotFalse( $translations->find( null, 'PHP block string' ) );
139+
$this->assertNotFalse( $translations->find( null, 'Echo string' ) );
140+
$this->assertNotFalse( $translations->find( null, 'Raw string' ) );
141+
$this->assertNotFalse( $translations->find( null, 'Directive string' ) );
142+
}
143+
}

0 commit comments

Comments
 (0)