Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions features/makepot.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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')
<x-alert :message="__('Bound prop string', 'foo-theme')" />

<x-no-results
:title="__('No results found', 'foo-theme')"
:subtitle="esc_html__('Please try a new search', 'foo-theme')"
/>

<x-card type="warning">
{!! __('Card content', 'foo-theme') !!}
</x-card>

{{ __('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:
Expand Down
37 changes: 36 additions & 1 deletion src/BladeGettextExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <x-component> 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: <x-name ...> or <x-name ... />
// The attribute region handles quoted strings so that a '>' inside an
// attribute value does not end the match prematurely.
if ( ! preg_match_all( '/<x[-:\w]+\s+((?:[^>"\']*(?:"[^"]*"|\'[^\']*\'))*[^>"]*)\/?>/', $text, $tag_matches ) ) {
return $php;
}

foreach ( $tag_matches[1] as $attributes ) {
// Find :prop="expression" or :prop='expression' bound attributes.
if ( preg_match_all( '/(?<!\w):[\w.-]+=(["\'])(.*?)\1/s', $attributes, $attr_matches ) ) {
foreach ( $attr_matches[2] as $expression ) {
$php .= '<?php ' . $expression . '; ?>';
}
}
}

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 );
}
}
143 changes: 143 additions & 0 deletions tests/BladeGettextExtractorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace WP_CLI\I18n\Tests;

use Gettext\Translations;
use WP_CLI\I18n\BladeCodeExtractor;
use WP_CLI\Tests\TestCase;

class BladeGettextExtractorTest extends TestCase {

/**
* Helper to extract translations from a Blade string.
*
* @param string $blade Blade template content.
* @param string $domain Text domain.
* @return Translations
*/
private function extract( $blade, $domain = 'foo-theme' ) {
$translations = new Translations();
$translations->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(
'<x-alert :message="__(\'Hello\', \'foo-theme\')" />'
);

$this->assertNotFalse( $translations->find( null, 'Hello' ) );
}

public function test_extracts_multiple_bound_props() {
$translations = $this->extract(
'<x-no-results :title="__(\'Not found\', \'foo-theme\')" :subtitle="__(\'Try again\', \'foo-theme\')" />'
);

$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'
<x-no-results
:title="__('Page not found', 'foo-theme')"
:subtitle="esc_html__('Please try again', 'foo-theme')"
/>
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'
<x-alert :message="__('Warning message', 'foo-theme')">
{!! __('Content inside', 'foo-theme') !!}
</x-alert>
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(
'<x-alert type="warning" :message="__(\'Hello\', \'foo-theme\')" />'
);

$this->assertNotFalse( $translations->find( null, 'Hello' ) );
$this->assertFalse( $translations->find( null, 'warning' ) );
}

public function test_does_not_match_non_component_html() {
$translations = $this->extract(
'<a href="https://example.com">{{ __(\'Link text\', \'foo-theme\') }}</a>'
);

$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(
'<x-button :label="_x(\'Read\', \'verb\', \'foo-theme\')" />'
);

$translation = $translations->find( 'verb', 'Read' );
$this->assertNotFalse( $translation );
}

public function test_extracts_esc_functions_in_props() {
$blade = <<<'BLADE'
<x-field
:label="esc_html__('Username', 'foo-theme')"
:placeholder="esc_attr__('Enter username', 'foo-theme')"
/>
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(
"<x-alert :message='__(\"Single quoted\", \"foo-theme\")' />"
);

$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' ) );
}
}