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