From ab786e53f0713a7d703b951f6a3a0d75baff8b29 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:04:40 +0200 Subject: [PATCH 01/18] Experiment with WordPress core support See #36. --- src/IterableCodeExtractor.php | 80 +++++++++++++++---- src/MakePotCommand.php | 144 +++++++++++++++++++++++++--------- 2 files changed, 171 insertions(+), 53 deletions(-) diff --git a/src/IterableCodeExtractor.php b/src/IterableCodeExtractor.php index f268afda..688bd871 100644 --- a/src/IterableCodeExtractor.php +++ b/src/IterableCodeExtractor.php @@ -8,6 +8,7 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use DirectoryIterator; +use SplFileInfo; use WP_CLI; trait IterableCodeExtractor { @@ -76,7 +77,10 @@ public static function fromFile( $file, Translations $translations, array $optio public static function fromDirectory( $dir, Translations $translations, array $options = [] ) { static::$dir = $dir; - $files = static::getFilesFromDirectory( $dir, isset( $options['exclude'] ) ? $options['exclude'] : [], $options['extensions'] ); + $include = isset( $options['include'] ) ? $options['include'] : []; + $exclude = isset( $options['exclude'] ) ? $options['exclude'] : []; + + $files = static::getFilesFromDirectory( $dir, $include, $exclude, $options['extensions'] ); if ( ! empty( $files ) ) { static::fromFile( $files, $translations, $options ); @@ -85,48 +89,90 @@ public static function fromDirectory( $dir, Translations $translations, array $o static::$dir = ''; } + /** + * Determines whether a file is valid based on the include and exclude options. + * + * @param SplFileInfo $file File or directory. + * @param array $include List of files and directories to include. + * @param array $exclude List of files and directories to skip. + * @return bool + */ + protected static function isValidFile( SplFileInfo $file, array $include = [], array $exclude = [] ) { + if ( ! empty( $include ) ) { + $is_valid = false; + + if ( in_array( $file->getBasename(), $include, true ) ) { + $is_valid = true; + } + + // Check for more complex paths, e.g. /some/sub/folder. + foreach ( $include as $path_or_file ) { + if ( false !== mb_ereg( preg_quote( '/' . $path_or_file ) . '$', $file->getPathname() ) ) { + $is_valid = true; + } + } + + if ( ! $is_valid ) { + return false; + } + } + + if ( ! empty( $exclude ) ) { + + if ( in_array( $file->getBasename(), $exclude, true ) ) { + return false; + } + + // Check for more complex paths, e.g. /some/sub/folder. + foreach ( $exclude as $path_or_file ) { + if ( false !== mb_ereg( preg_quote( '/' . $path_or_file ) . '$', $file->getPathname() ) ) { + return false; + } + } + } + + return true; + } + /** * Recursively gets all PHP files within a directory. * * @param string $dir A path of a directory. + * @param array $include List of files and directories to include. * @param array $exclude List of files and directories to skip. * @param array $extensions List of filename extensions to process. * * @return array File list. */ - public static function getFilesFromDirectory( $dir, array $exclude = [], $extensions = [] ) { + public static function getFilesFromDirectory( $dir, array $include = [], array $exclude = [], $extensions = [] ) { $filtered_files = []; $files = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), - function ( $file, $key, $iterator ) use ( $exclude, $extensions ) { - /** @var DirectoryIterator $file */ - if ( in_array( $file->getBasename(), $exclude, true ) ) { - return false; - } - - // Check for more complex paths, e.g. /some/sub/folder. - foreach( $exclude as $path_or_file ) { - if ( false !== mb_ereg( preg_quote( '/' . $path_or_file ) . '$', $file->getPathname() ) ) { - return false; - } - } + function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions ) { + /** @var SplFileInfo $file */ /** @var RecursiveCallbackFilterIterator $iterator */ if ( $iterator->hasChildren() ) { return true; } - return ( $file->isFile() && in_array( $file->getExtension(), $extensions, TRUE ) ); + $is_valid = static::isValidFile( $file, $include, $exclude ); + + if ( ! $is_valid ) { + return false; + } + + return ( $file->isFile() && in_array( $file->getExtension(), $extensions, true ) ); } ), RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $files as $file ) { - /** @var DirectoryIterator $file */ - if ( ! $file->isFile() || ! in_array( $file->getExtension(), $extensions, TRUE ) ) { + /** @var SplFileInfo $file */ + if ( ! $file->isFile() || ! in_array( $file->getExtension(), $extensions, true ) ) { continue; } diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 58d46bc3..7bea0c16 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -33,6 +33,11 @@ class MakePotCommand extends WP_CLI_Command { */ protected $merge; + /** + * @var array + */ + protected $include = []; + /** * @var array */ @@ -63,6 +68,16 @@ class MakePotCommand extends WP_CLI_Command { */ protected $domain; + /** + * @var string + */ + protected $copyright_holder; + + /** + * @var string + */ + protected $package_name; + /** * Create a POT file for a WordPress plugin or theme. * @@ -91,6 +106,10 @@ class MakePotCommand extends WP_CLI_Command { * : Existing POT file file whose content should be merged with the extracted strings. * If left empty, defaults to the destination POT file. * + * [--include=] + * : Only take specific files and folders into account for the string extraction. + * Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. + * * [--exclude=] * : Include additional ignored paths as CSV (e.g. 'tests,bin,.github'). * By default, the following files and folders are ignored: node_modules, .git, .svn, .CVS, .hg, vendor. @@ -102,6 +121,12 @@ class MakePotCommand extends WP_CLI_Command { * [--skip-js] * : Skips JavaScript string extraction. Useful when this is done in another build step, e.g. through Babel. * + * [--copyright-holder=] + * : Name to use for the copyright comment in the resulting POT file. + * + * [--package-name=] + * : Name to use for package name in the resulting POT file. Overrides anything found in a plugin or theme. + * * ## EXAMPLES * * # Create a POT file for the WordPress plugin/theme in the current directory @@ -124,10 +149,13 @@ public function __invoke( $args, $assoc_args ) { public function handle_arguments( $args, $assoc_args ) { $array_arguments = array( 'headers' ); $assoc_args = \WP_CLI\Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - $this->source = realpath( $args[0] ); - $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->headers = Utils\get_flag_value( $assoc_args, 'headers', $this->headers ); + + $this->source = realpath( $args[0] ); + $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->headers = Utils\get_flag_value( $assoc_args, 'headers', $this->headers ); + $this->package_name = Utils\get_flag_value( $assoc_args, 'package-name', $this->headers ); + $this->copyright_holder = Utils\get_flag_value( $assoc_args, 'copyright-holder', $this->headers ); $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); @@ -196,10 +224,20 @@ public function handle_arguments( $args, $assoc_args ) { } } + if ( isset( $assoc_args['include'] ) ) { + $this->include = array_filter( explode( ',', $assoc_args['include'] ) ); + $this->include = array_map( [ $this, 'unslashit' ], $this->include ); + $this->include = array_unique( $this->include ); + + WP_CLI::debug( sprintf( 'Only including the following files: %s', implode( ',', $this->include ) ), 'make-pot' ); + } + if ( isset( $assoc_args['exclude'] ) ) { $this->exclude = array_filter( array_merge( $this->exclude, explode( ',', $assoc_args['exclude'] ) ) ); - $this->exclude = array_map( [ $this, 'unslashit' ], $this->exclude); + $this->exclude = array_map( [ $this, 'unslashit' ], $this->exclude ); $this->exclude = array_unique( $this->exclude ); + + WP_CLI::debug( sprintf( 'Excluding the following files: %s', implode( ',', $this->exclude ) ), 'make-pot' ); } } @@ -210,7 +248,7 @@ public function handle_arguments( $args, $assoc_args ) { * @return string String without leading and trailing slashes. */ protected function unslashit( $string ) { - return ltrim( rtrim( $string, '/\\' ), '/\\' ); + return ltrim( rtrim( trim( $string ), '/\\' ), '/\\' ); } /** @@ -260,7 +298,7 @@ protected function retrieve_main_file_data() { } } - WP_CLI::error( 'No valid theme stylesheet or plugin file found!' ); + WP_CLI::debug( 'No valid theme stylesheet or plugin file found, treating as a regular project.' ); } /** @@ -326,8 +364,7 @@ protected function makepot() { $this->translations->mergeWith( $existing_translations, Merge::ADD | Merge::REMOVE ); } - $meta = $this->get_meta_data(); - PotGenerator::setCommentBeforeHeaders( $meta['comments'] ); + PotGenerator::setCommentBeforeHeaders( $this->get_file_comment() ); $this->set_default_headers(); @@ -363,6 +400,7 @@ protected function makepot() { PhpCodeExtractor::fromDirectory( $this->source, $this->translations, [ // Extract 'Template Name' headers in theme files. 'wpExtractTemplates' => isset( $file_data['Theme Name'] ), + 'include' => $this->include, 'exclude' => $this->exclude, 'extensions' => [ 'php' ], ] ); @@ -372,7 +410,8 @@ protected function makepot() { $this->source, $this->translations, [ - 'exclude' => $this->exclude, + 'include' => $this->include, + 'exclude' => $this->exclude, 'extensions' => [ 'js' ], ] ); @@ -412,37 +451,29 @@ protected function makepot() { } /** - * Returns the metadata for a plugin or theme. + * Returns the copyright comment for the given package. * * @return array Meta data. */ - protected function get_meta_data() { + protected function get_file_comment() { $file_data = $this->get_main_file_data(); + $author = 'Unknown'; + $name = 'Unknown'; + if ( isset( $file_data['Theme Name'] ) ) { - $name = $file_data['Theme Name']; - $author = $file_data['Author']; - $bugs_address = sprintf( 'https://wordpress.org/support/theme/%s', $this->slug ); - } else { - $name = $file_data['Plugin Name']; - $author = $name; - $bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug ); + $name = $file_data['Theme Name']; + $author = $file_data['Author']; + } elseif ( isset( $file_data['Plugin Name'] ) ) { + $name = $file_data['Plugin Name']; + $author = $name; } - $meta = [ - 'name' => $name, - 'version' => $file_data['Version'], - 'comments' => sprintf( - "Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s package.", - date( 'Y' ), - $author, - $name - ), - 'msgid-bugs-address' => $bugs_address, - ]; + $author = null !== $this->copyright_holder ? $this->copyright_holder : $author; + $name = null !== $this->package_name ? $this->package_name : $name; if ( isset( $file_data['License'] ) ) { - $meta['comments'] = sprintf( + return sprintf( "Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.", date( 'Y' ), $author, @@ -450,26 +481,67 @@ protected function get_meta_data() { ); } - return $meta; + return sprintf( + "Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s package.", + date( 'Y' ), + $author, + $name + ); } /** * Sets default POT file headers for the project. */ protected function set_default_headers() { - $meta = $this->get_meta_data(); + $file_data = $this->get_main_file_data(); + + $name = 'Unknown'; + $version = $this->get_wp_version(); + $bugs_address = null; + + if ( ! $version && isset( $file_data['Version'] ) ) { + $version = $file_data['Version']; + } + + if ( isset( $file_data['Theme Name'] ) ) { + $name = $file_data['Theme Name']; + $bugs_address = sprintf( 'https://wordpress.org/support/theme/%s', $this->slug ); + } elseif ( isset( $file_data['Plugin Name'] ) ) { + $name = $file_data['Plugin Name']; + $bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug ); + } + + $name = null !== $this->package_name ? $this->package_name : $name; + + $this->translations->setHeader( 'Project-Id-Version', $name . ( $version ? ' ' . $version : '' ) ); + + if ( null !== $bugs_address ) { + $this->translations->setHeader( 'Report-Msgid-Bugs-To', $bugs_address ); + } - $this->translations->setHeader( 'Project-Id-Version', $meta['name'] . ' ' . $meta['version'] ); - $this->translations->setHeader( 'Report-Msgid-Bugs-To', $meta['msgid-bugs-address'] ); $this->translations->setHeader( 'Last-Translator', 'FULL NAME ' ); $this->translations->setHeader( 'Language-Team', 'LANGUAGE ' ); $this->translations->setHeader( 'X-Generator', 'WP-CLI ' . WP_CLI_VERSION ); - foreach( $this->headers as $key => $value ) { + foreach ( $this->headers as $key => $value ) { $this->translations->setHeader( $key, $value ); } } + /** + * Extracts the WordPress version number from wp-includes/version.php. + * + * @return string|false Version number on success, false otherwise. + */ + private function get_wp_version() { + $version_php = $this->source . '/wp-includes/version.php'; + if ( ! file_exists( $version_php) || ! is_readable( $version_php ) ) { + return false; + } + + return preg_match( '/\$wp_version\s*=\s*\'(.*?)\';/', file_get_contents( $version_php ), $matches ) ? $matches[1] : false; + } + /** * Retrieves metadata from a file. * From d41a8a06a7b720ddcacfe06b0006b5cc7b459a05 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:34:54 +0200 Subject: [PATCH 02/18] Simple glob support See #45. --- src/IterableCodeExtractor.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/IterableCodeExtractor.php b/src/IterableCodeExtractor.php index 688bd871..6630c06f 100644 --- a/src/IterableCodeExtractor.php +++ b/src/IterableCodeExtractor.php @@ -107,7 +107,10 @@ protected static function isValidFile( SplFileInfo $file, array $include = [], a // Check for more complex paths, e.g. /some/sub/folder. foreach ( $include as $path_or_file ) { - if ( false !== mb_ereg( preg_quote( '/' . $path_or_file ) . '$', $file->getPathname() ) ) { + $pattern = preg_quote( str_replace( '*', '__wildcard__', '/' . $path_or_file ) ); + $pattern = str_replace( '__wildcard__', '(.+)', $pattern ); + + if ( false !== mb_ereg( $pattern . '$', $file->getPathname() ) ) { $is_valid = true; } } @@ -125,7 +128,10 @@ protected static function isValidFile( SplFileInfo $file, array $include = [], a // Check for more complex paths, e.g. /some/sub/folder. foreach ( $exclude as $path_or_file ) { - if ( false !== mb_ereg( preg_quote( '/' . $path_or_file ) . '$', $file->getPathname() ) ) { + $pattern = preg_quote( str_replace( '*', '__wildcard__', '/' . $path_or_file ) ); + $pattern = str_replace( '__wildcard__', '(.+)', $pattern ); + + if ( false !== mb_ereg( $pattern . '$', $file->getPathname() ) ) { return false; } } From 3bd07099c94157e2377575ab85c8f604918995ee Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:43:14 +0200 Subject: [PATCH 03/18] Fix wrong defaults --- src/MakePotCommand.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 7bea0c16..3497e15e 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -154,8 +154,8 @@ 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->headers = Utils\get_flag_value( $assoc_args, 'headers', $this->headers ); - $this->package_name = Utils\get_flag_value( $assoc_args, 'package-name', $this->headers ); - $this->copyright_holder = Utils\get_flag_value( $assoc_args, 'copyright-holder', $this->headers ); + $this->package_name = Utils\get_flag_value( $assoc_args, 'package-name', 'Unknown' ); + $this->copyright_holder = Utils\get_flag_value( $assoc_args, 'copyright-holder', 'Unknown' ); $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); @@ -458,8 +458,8 @@ protected function makepot() { protected function get_file_comment() { $file_data = $this->get_main_file_data(); - $author = 'Unknown'; - $name = 'Unknown'; + $author = $this->copyright_holder; + $name = $this->package_name; if ( isset( $file_data['Theme Name'] ) ) { $name = $file_data['Theme Name']; @@ -469,8 +469,8 @@ protected function get_file_comment() { $author = $name; } - $author = null !== $this->copyright_holder ? $this->copyright_holder : $author; - $name = null !== $this->package_name ? $this->package_name : $name; + $author = null === $author ? $this->copyright_holder : $author; + $name = null === $name ? $this->package_name : $name; if ( isset( $file_data['License'] ) ) { return sprintf( @@ -495,7 +495,7 @@ protected function get_file_comment() { protected function set_default_headers() { $file_data = $this->get_main_file_data(); - $name = 'Unknown'; + $name = $this->package_name; $version = $this->get_wp_version(); $bugs_address = null; @@ -511,7 +511,7 @@ protected function set_default_headers() { $bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug ); } - $name = null !== $this->package_name ? $this->package_name : $name; + $name = null === $name ? $this->package_name : $name; $this->translations->setHeader( 'Project-Id-Version', $name . ( $version ? ' ' . $version : '' ) ); From 4293c3364d4f2c845d0d2851171c6e54949b35af Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:46:38 +0200 Subject: [PATCH 04/18] Ignore minified JS files Fixes #45. --- src/MakePotCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 3497e15e..949664b0 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -41,7 +41,7 @@ class MakePotCommand extends WP_CLI_Command { /** * @var array */ - protected $exclude = [ 'node_modules', '.git', '.svn', '.CVS', '.hg', 'vendor', 'Gruntfile.js', 'webpack.config.js' ]; + protected $exclude = [ 'node_modules', '.git', '.svn', '.CVS', '.hg', 'vendor', 'Gruntfile.js', 'webpack.config.js', '*.min.js' ]; /** * @var string From 29441260b8722f563401b196cf38bdfd2678048e Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:50:15 +0200 Subject: [PATCH 05/18] Update tests --- features/makepot.feature | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/features/makepot.feature b/features/makepot.feature index 3f769941..54f3c357 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -178,24 +178,22 @@ Feature: Generate a POT file of a WordPress project Scenario: Bails when no plugin files are found Given an empty foo-plugin directory - When I try `wp i18n make-pot foo-plugin foo-plugin.pot` + When I try `wp i18n make-pot foo-plugin foo-plugin.pot --debug` Then STDERR should contain: """ - Error: No valid theme stylesheet or plugin file found! + No valid theme stylesheet or plugin file found, treating as a regular project. """ - And the return code should be 1 Scenario: Bails when no main plugin file is found Given an empty foo-plugin directory And a foo-plugin/foo-plugin.php file: """ """ - When I try `wp i18n make-pot foo-plugin foo-plugin.pot` + When I try `wp i18n make-pot foo-plugin foo-plugin.pot --debug` Then STDERR should contain: """ - Error: No valid theme stylesheet or plugin file found! + No valid theme stylesheet or plugin file found, treating as a regular project. """ - And the return code should be 1 Scenario: Adds relative paths to source file as comments. Given an empty foo-plugin directory From 535ad819c3a18073fe10e88ab8fdf30534cdd43b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 17:59:56 +0200 Subject: [PATCH 06/18] Fix directory exclusion --- features/makepot.feature | 13 +++++++++++-- src/IterableCodeExtractor.php | 23 +++++++++++------------ src/MakePotCommand.php | 4 ++-- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/features/makepot.feature b/features/makepot.feature index 54f3c357..c57204ed 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -629,7 +629,7 @@ Feature: Generate a POT file of a WordPress project /** * Plugin Name: Foo Plugin * Plugin URI: https://example.com - * Description: + * Description: Plugin Description * Version: 0.1.0 * Author: * Author URI: @@ -646,7 +646,16 @@ Feature: Generate a POT file of a WordPress project __( 'I am being ignored', 'foo-plugin' ); """ - When I run `wp i18n make-pot foo-plugin foo-plugin.pot` + When I try `wp i18n make-pot foo-plugin foo-plugin.pot --debug` + Then STDOUT should be: + """ + Plugin file detected. + Success: POT file successfully generated! + """ + And STDERR should contain: + """ + Extracted 4 strings + """ Then the foo-plugin.pot file should not contain: """ I am being ignored diff --git a/src/IterableCodeExtractor.php b/src/IterableCodeExtractor.php index 6630c06f..fad09db3 100644 --- a/src/IterableCodeExtractor.php +++ b/src/IterableCodeExtractor.php @@ -107,10 +107,10 @@ protected static function isValidFile( SplFileInfo $file, array $include = [], a // Check for more complex paths, e.g. /some/sub/folder. foreach ( $include as $path_or_file ) { - $pattern = preg_quote( str_replace( '*', '__wildcard__', '/' . $path_or_file ) ); - $pattern = str_replace( '__wildcard__', '(.+)', $pattern ); + $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); + $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; - if ( false !== mb_ereg( $pattern . '$', $file->getPathname() ) ) { + if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { $is_valid = true; } } @@ -121,17 +121,16 @@ protected static function isValidFile( SplFileInfo $file, array $include = [], a } if ( ! empty( $exclude ) ) { - if ( in_array( $file->getBasename(), $exclude, true ) ) { return false; } // Check for more complex paths, e.g. /some/sub/folder. foreach ( $exclude as $path_or_file ) { - $pattern = preg_quote( str_replace( '*', '__wildcard__', '/' . $path_or_file ) ); - $pattern = str_replace( '__wildcard__', '(.+)', $pattern ); + $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); + $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; - if ( false !== mb_ereg( $pattern . '$', $file->getPathname() ) ) { + if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { return false; } } @@ -159,17 +158,17 @@ public static function getFilesFromDirectory( $dir, array $include = [], array $ function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions ) { /** @var SplFileInfo $file */ - /** @var RecursiveCallbackFilterIterator $iterator */ - if ( $iterator->hasChildren() ) { - return true; - } - $is_valid = static::isValidFile( $file, $include, $exclude ); if ( ! $is_valid ) { return false; } + /** @var RecursiveCallbackFilterIterator $iterator */ + if ( $iterator->hasChildren() ) { + return true; + } + return ( $file->isFile() && in_array( $file->getExtension(), $extensions, true ) ); } ), diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 949664b0..552df825 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -236,9 +236,9 @@ public function handle_arguments( $args, $assoc_args ) { $this->exclude = array_filter( array_merge( $this->exclude, explode( ',', $assoc_args['exclude'] ) ) ); $this->exclude = array_map( [ $this, 'unslashit' ], $this->exclude ); $this->exclude = array_unique( $this->exclude ); - - WP_CLI::debug( sprintf( 'Excluding the following files: %s', implode( ',', $this->exclude ) ), 'make-pot' ); } + + WP_CLI::debug( sprintf( 'Excluding the following files: %s', implode( ',', $this->exclude ) ), 'make-pot' ); } /** From 9002a9ba08f0752eb01d030e138c39b112442cd1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sun, 5 Aug 2018 19:39:07 +0200 Subject: [PATCH 07/18] Fix exclusion and inclusion once again... --- src/IterableCodeExtractor.php | 83 ++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/src/IterableCodeExtractor.php b/src/IterableCodeExtractor.php index fad09db3..6fe022f7 100644 --- a/src/IterableCodeExtractor.php +++ b/src/IterableCodeExtractor.php @@ -90,53 +90,61 @@ public static function fromDirectory( $dir, Translations $translations, array $o } /** - * Determines whether a file is valid based on the include and exclude options. + * Determines whether a file is valid based on the include option. * - * @param SplFileInfo $file File or directory. - * @param array $include List of files and directories to include. - * @param array $exclude List of files and directories to skip. + * @param SplFileInfo $file File or directory. + * @param array $include List of files and directories to include. * @return bool */ - protected static function isValidFile( SplFileInfo $file, array $include = [], array $exclude = [] ) { - if ( ! empty( $include ) ) { - $is_valid = false; + protected static function isIncluded( SplFileInfo $file, array $include = [] ) { + if ( empty( $include ) ) { + return true; + } - if ( in_array( $file->getBasename(), $include, true ) ) { - $is_valid = true; - } + if ( in_array( $file->getBasename(), $include, true ) ) { + return true; + } - // Check for more complex paths, e.g. /some/sub/folder. - foreach ( $include as $path_or_file ) { - $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); - $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; + // Check for more complex paths, e.g. /some/sub/folder. + foreach ( $include as $path_or_file ) { + $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); + $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; - if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { - $is_valid = true; - } + if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { + return true; } + } - if ( ! $is_valid ) { - return false; - } + return false; + } + + /** + * Determines whether a file is valid based on the exclude option. + * + * @param SplFileInfo $file File or directory. + * @param array $exclude List of files and directories to skip. + * @return bool + */ + protected static function isExcluded( SplFileInfo $file, array $exclude = [] ) { + if ( empty( $exclude ) ) { + return false; } - if ( ! empty( $exclude ) ) { - if ( in_array( $file->getBasename(), $exclude, true ) ) { - return false; - } + if ( in_array( $file->getBasename(), $exclude, true ) ) { + return true; + } - // Check for more complex paths, e.g. /some/sub/folder. - foreach ( $exclude as $path_or_file ) { - $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); - $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; + // Check for more complex paths, e.g. /some/sub/folder. + foreach ( $exclude as $path_or_file ) { + $pattern = preg_quote( str_replace( '*', '__wildcard__', $path_or_file ) ); + $pattern = '/' . str_replace( '__wildcard__', '(.+)', $pattern ) . '$'; - if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { - return false; - } + if ( false !== mb_ereg( $pattern, $file->getPathname() ) ) { + return true; } } - return true; + return false; } /** @@ -156,15 +164,20 @@ public static function getFilesFromDirectory( $dir, array $include = [], array $ new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions ) { + /** @var RecursiveCallbackFilterIterator $iterator */ /** @var SplFileInfo $file */ - $is_valid = static::isValidFile( $file, $include, $exclude ); + $is_included = static::isIncluded( $file, $include ); + $is_excluded = static::isExcluded( $file, $exclude ); - if ( ! $is_valid ) { + if ( $is_excluded ) { + return false; + } + + if ( ! $is_included && ! $iterator->hasChildren() ) { return false; } - /** @var RecursiveCallbackFilterIterator $iterator */ if ( $iterator->hasChildren() ) { return true; } From 4cae952e4d4417e99501a34685b1bd99834b824d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 15:27:53 +0200 Subject: [PATCH 08/18] Add option for string exceptions --- README.md | 19 ++++- src/MakePotCommand.php | 168 ++++++++++++++++++++++++++--------------- 2 files changed, 124 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 7abc792b..99b89576 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ wp i18n Create a POT file for a WordPress plugin or theme. ~~~ -wp i18n make-pot [] [--slug=] [--domain=] [--ignore-domain] [--merge[=]] [--exclude=] [--skip-js] +wp i18n make-pot [] [--slug=] [--domain=] [--ignore-domain] [--merge[=]] [--except=] [--include=] [--exclude=] [--headers=] [--skip-js] [--copyright-holder=] [--package-name=] ~~~ Scans PHP and JavaScript files, as well as theme stylesheets for translatable strings. @@ -55,10 +55,17 @@ Scans PHP and JavaScript files, as well as theme stylesheets for translatable st [--ignore-domain] Ignore the text domain completely and extract strings with any text domain. - [--merge[=]] - Existing POT file file whose content should be merged with the extracted strings. + [--merge[=]] + One or more existing POT files whose contents should be merged with the extracted strings. If left empty, defaults to the destination POT file. + [--except=] + If set, only strings not already existing in one of the passed POT files will be extracted. + + [--include=] + Only take specific files and folders into account for the string extraction. + Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. + [--exclude=] Include additional ignored paths as CSV (e.g. 'tests,bin,.github'). By default, the following files and folders are ignored: node_modules, .git, .svn, .CVS, .hg, vendor. @@ -70,6 +77,12 @@ Scans PHP and JavaScript files, as well as theme stylesheets for translatable st [--skip-js] Skips JavaScript string extraction. Useful when this is done in another build step, e.g. through Babel. + [--copyright-holder=] + Name to use for the copyright comment in the resulting POT file. + + [--package-name=] + Name to use for package name in the resulting POT file. Overrides anything found in a plugin or theme. + **EXAMPLES** # Create a POT file for the WordPress plugin/theme in the current directory diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 552df825..3c799414 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -29,9 +29,14 @@ class MakePotCommand extends WP_CLI_Command { protected $destination; /** - * @var string + * @var array + */ + protected $merge = []; + + /** + * @var Translations */ - protected $merge; + protected $exceptions; /** * @var array @@ -102,10 +107,13 @@ class MakePotCommand extends WP_CLI_Command { * [--ignore-domain] * : Ignore the text domain completely and extract strings with any text domain. * - * [--merge[=]] - * : Existing POT file file whose content should be merged with the extracted strings. + * [--merge[=]] + * : One or more existing POT files whose contents should be merged with the extracted strings. * If left empty, defaults to the destination POT file. * + * [--except=] + * : If set, only strings not already existing in one of the passed POT files will be extracted. + * * [--include=] * : Only take specific files and folders into account for the string extraction. * Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. @@ -133,9 +141,12 @@ class MakePotCommand extends WP_CLI_Command { * $ wp i18n make-pot . languages/my-plugin.pot * * @when before_wp_load + * + * @throws \WP_CLI\ExitException */ public function __invoke( $args, $assoc_args ) { $this->handle_arguments( $args, $assoc_args ); + if ( ! $this->makepot() ) { WP_CLI::error( 'Could not generate a POT file!' ); } @@ -145,6 +156,11 @@ public function __invoke( $args, $assoc_args ) { /** * Process arguments from command-line in a reusable way. + * + * @throws \WP_CLI\ExitException + * + * @param array $args Command arguments. + * @param array $assoc_args Associative arguments. */ public function handle_arguments( $args, $assoc_args ) { $array_arguments = array( 'headers' ); @@ -163,9 +179,7 @@ public function handle_arguments( $args, $assoc_args ) { WP_CLI::error( 'Not a valid source directory!' ); } - $this->retrieve_main_file_data(); - - $file_data = $this->get_main_file_data(); + $this->main_file_data = $this->get_main_file_data(); if ( $ignore_domain ) { WP_CLI::debug( 'Extracting all strings regardless of text domain', 'make-pot' ); @@ -174,8 +188,8 @@ public function handle_arguments( $args, $assoc_args ) { if ( ! $ignore_domain ) { $this->domain = $this->slug; - if ( ! empty( $file_data['Text Domain'] ) ) { - $this->domain = $file_data['Text Domain']; + if ( ! empty( $this->main_file_data['Text Domain'] ) ) { + $this->domain = $this->main_file_data['Text Domain']; } $this->domain = Utils\get_flag_value( $assoc_args, 'domain', $this->domain ); @@ -186,12 +200,12 @@ public function handle_arguments( $args, $assoc_args ) { // Determine destination. $this->destination = "{$this->source}/{$this->slug}.pot"; - if ( ! empty( $file_data['Domain Path'] ) ) { + if ( ! empty( $this->main_file_data['Domain Path'] ) ) { // Domain Path inside source folder. $this->destination = sprintf( '%s/%s/%s.pot', $this->source, - $this->unslashit( $file_data['Domain Path'] ), + $this->unslashit( $this->main_file_data['Domain Path'] ), $this->slug ); } @@ -212,15 +226,60 @@ public function handle_arguments( $args, $assoc_args ) { if ( isset( $assoc_args['merge'] ) ) { if ( true === $assoc_args['merge'] ) { - $this->merge = $this->destination; + $this->merge = [ $this->destination ]; } elseif ( ! empty( $assoc_args['merge'] ) ) { - $this->merge = $assoc_args['merge']; + $this->merge = explode( ',', $assoc_args['merge'] ); } - if ( isset( $this->merge ) && ! file_exists( $this->merge ) ) { - WP_CLI::warning( sprintf( 'Invalid file provided to --merge: %s', $this->merge ) ); + $this->merge = array_filter( + $this->merge, + function ( $file ) { + if ( ! file_exists( $file ) ) { + WP_CLI::warning( sprintf( 'Invalid file provided to --merge: %s', $file ) ); - unset( $this->merge ); + return false; + } + + return true; + } + ); + + if ( ! empty( $this->merge ) ) { + WP_CLI::debug( + sprintf( + 'Merging with existing POT %s: %s', + implode( ',', $this->merge ), + WP_CLI\Utils\pluralize( 'file', count( $this->merge ) ) + ), + 'make-pot' + ); + } + } + + $this->exceptions = new Translations(); + + if ( isset( $assoc_args['except'] ) ) { + $exceptions = explode( ',', $assoc_args['except'] ); + + $exceptions = array_filter( + $exceptions, + function ( $exception ) { + if ( ! file_exists( $exception ) ) { + WP_CLI::warning( sprintf( 'Invalid file provided to --except: %s', $exception ) ); + + return false; + } + + $exception_translations = new Translations(); + + Po::fromFile( $exception, $exception_translations ); + $this->exceptions->mergeWith( $exception_translations ); + + return true; + } ); + + if ( ! empty( $exceptions ) ) { + WP_CLI::debug( sprintf( 'Ignoring any string already existing in: %s', implode( ',', $exceptions ) ), 'make-pot' ); } } @@ -254,9 +313,9 @@ protected function unslashit( $string ) { /** * Retrieves the main file data of the plugin or theme. * - * @return void + * @return array */ - protected function retrieve_main_file_data() { + protected function get_main_file_data() { $stylesheet = sprintf( '%s/style.css', $this->source ); if ( is_file( $stylesheet ) && is_readable( $stylesheet ) ) { @@ -267,9 +326,7 @@ protected function retrieve_main_file_data() { WP_CLI::log( 'Theme stylesheet detected.' ); WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $stylesheet ), 'make-pot' ); - $this->main_file_data = $theme_data; - - return; + return $theme_data; } } @@ -292,13 +349,13 @@ protected function retrieve_main_file_data() { WP_CLI::log( 'Plugin file detected.' ); WP_CLI::debug( sprintf( 'Plugin file: %s', $plugin_file ), 'make-pot' ); - $this->main_file_data = $plugin_data; - - return; + return $plugin_data; } } - WP_CLI::debug( 'No valid theme stylesheet or plugin file found, treating as a regular project.' ); + WP_CLI::debug( 'No valid theme stylesheet or plugin file found, treating as a regular project.', 'make-pot' ); + + return []; } /** @@ -338,27 +395,18 @@ protected function get_file_headers( $type ) { } } - /** - * Returns the header data of the main plugin/theme file. - * - * @return array Main file data. - */ - protected function get_main_file_data() { - return $this->main_file_data; - } - /** * Creates a POT file and stores it on disk. * + * @throws \WP_CLI\ExitException + * * @return bool True on success, false otherwise. */ protected function makepot() { $this->translations = new Translations(); // Add existing strings first but don't keep headers. - if ( $this->merge ) { - WP_CLI::debug( sprintf( 'Merging with existing POT file: %s', $this->merge ), 'make-pot' ); - + if ( ! empty( $this->merge ) ) { $existing_translations = new Translations(); Po::fromFile( $this->merge, $existing_translations ); $this->translations->mergeWith( $existing_translations, Merge::ADD | Merge::REMOVE ); @@ -375,19 +423,17 @@ protected function makepot() { $this->translations->setDomain( $this->domain ); } - $file_data = $this->get_main_file_data(); - - unset( $file_data['Version'], $file_data['License'], $file_data['Domain Path'], $file_data['Text Domain'] ); + unset( $this->main_file_data['Version'], $this->main_file_data['License'], $this->main_file_data['Domain Path'], $this->main_file_data['Text Domain'] ); // Set entries from main file data. - foreach ( $file_data as $header => $data ) { + foreach ( $this->main_file_data as $header => $data ) { if ( empty( $data ) ) { continue; } $translation = new Translation( '', $data ); - if ( isset( $file_data['Theme Name'] ) ) { + if ( isset( $this->main_file_data['Theme Name'] ) ) { $translation->addExtractedComment( sprintf( '%s of the theme', $header ) ); } else { $translation->addExtractedComment( sprintf( '%s of the plugin', $header ) ); @@ -399,7 +445,7 @@ protected function makepot() { try { PhpCodeExtractor::fromDirectory( $this->source, $this->translations, [ // Extract 'Template Name' headers in theme files. - 'wpExtractTemplates' => isset( $file_data['Theme Name'] ), + 'wpExtractTemplates' => isset( $this->main_file_data['Theme Name'] ), 'include' => $this->include, 'exclude' => $this->exclude, 'extensions' => [ 'php' ], @@ -421,6 +467,12 @@ protected function makepot() { } foreach( $this->translations as $translation ) { + /** @var Translation $translation */ + + if ( $this->exceptions->find( $translation ) ) { + unset( $this->translations[ $translation->getId() ] ); + } + if ( ! $translation->hasExtractedComments() ) { continue; } @@ -456,28 +508,26 @@ protected function makepot() { * @return array Meta data. */ protected function get_file_comment() { - $file_data = $this->get_main_file_data(); - $author = $this->copyright_holder; $name = $this->package_name; - if ( isset( $file_data['Theme Name'] ) ) { - $name = $file_data['Theme Name']; - $author = $file_data['Author']; - } elseif ( isset( $file_data['Plugin Name'] ) ) { - $name = $file_data['Plugin Name']; + if ( isset( $this->main_file_data['Theme Name'] ) ) { + $name = $this->main_file_data['Theme Name']; + $author = $this->main_file_data['Author']; + } elseif ( isset( $this->main_file_data['Plugin Name'] ) ) { + $name = $this->main_file_data['Plugin Name']; $author = $name; } $author = null === $author ? $this->copyright_holder : $author; $name = null === $name ? $this->package_name : $name; - if ( isset( $file_data['License'] ) ) { + if ( isset( $this->main_file_data['License'] ) ) { return sprintf( "Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.", date( 'Y' ), $author, - $file_data['License'] + $this->main_file_data['License'] ); } @@ -493,21 +543,19 @@ protected function get_file_comment() { * Sets default POT file headers for the project. */ protected function set_default_headers() { - $file_data = $this->get_main_file_data(); - $name = $this->package_name; $version = $this->get_wp_version(); $bugs_address = null; - if ( ! $version && isset( $file_data['Version'] ) ) { - $version = $file_data['Version']; + if ( ! $version && isset( $this->main_file_data['Version'] ) ) { + $version = $this->main_file_data['Version']; } - if ( isset( $file_data['Theme Name'] ) ) { - $name = $file_data['Theme Name']; + if ( isset( $this->main_file_data['Theme Name'] ) ) { + $name = $this->main_file_data['Theme Name']; $bugs_address = sprintf( 'https://wordpress.org/support/theme/%s', $this->slug ); - } elseif ( isset( $file_data['Plugin Name'] ) ) { - $name = $file_data['Plugin Name']; + } elseif ( isset( $this->main_file_data['Plugin Name'] ) ) { + $name = $this->main_file_data['Plugin Name']; $bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug ); } From 54130fc200a715e0998f45e3c514ad308ed5b516 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 15:36:57 +0200 Subject: [PATCH 09/18] No in-place array modifications --- src/MakePotCommand.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 3c799414..1499aa93 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -466,12 +466,14 @@ protected function makepot() { WP_CLI::error( $e->getMessage() ); } - foreach( $this->translations as $translation ) { - /** @var Translation $translation */ - - if ( $this->exceptions->find( $translation ) ) { + foreach( $this->exceptions as $translation ) { + if ( $this->translations->find( $translation ) ) { unset( $this->translations[ $translation->getId() ] ); } + } + + foreach( $this->translations as $translation ) { + /** @var Translation $translation */ if ( ! $translation->hasExtractedComments() ) { continue; From bc1bd535fdcaa8d887072158e5e43ab4b1e9e9c5 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 15:46:08 +0200 Subject: [PATCH 10/18] Fix debug output --- src/MakePotCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MakePotCommand.php b/src/MakePotCommand.php index 1499aa93..30e0145a 100644 --- a/src/MakePotCommand.php +++ b/src/MakePotCommand.php @@ -248,8 +248,8 @@ function ( $file ) { WP_CLI::debug( sprintf( 'Merging with existing POT %s: %s', - implode( ',', $this->merge ), - WP_CLI\Utils\pluralize( 'file', count( $this->merge ) ) + WP_CLI\Utils\pluralize( 'file', count( $this->merge ) ), + implode( ',', $this->merge ) ), 'make-pot' ); From 183d0d68a6449bc108b7e2ff6b0179f6213d9af6 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 16:56:14 +0200 Subject: [PATCH 11/18] Add test for exclusion glob patterns --- features/makepot.feature | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/features/makepot.feature b/features/makepot.feature index c57204ed..17285a20 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -907,6 +907,51 @@ Feature: Generate a POT file of a WordPress project I am not being ignored either """ + Scenario: Supports glob patterns for file exclusion + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.php file: + """ + Date: Mon, 6 Aug 2018 16:57:55 +0200 Subject: [PATCH 12/18] Add test for multiple merge files --- features/makepot.feature | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/features/makepot.feature b/features/makepot.feature index 17285a20..9b7b37fb 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -1002,6 +1002,85 @@ Feature: Generate a POT file of a WordPress project msgid "Foo Plugin" """ + Scenario: Merges translations with the ones from multiple existing POT files + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.pot file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + + #: foo-plugin.js:15 + msgid "Foo Plugin" + msgstr "" + """ + And a foo-plugin/bar-plugin.pot file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + + #: bar-plugin.js:15 + msgid "Bar Plugin" + msgstr "" + """ + + When I run `wp scaffold plugin hello-world --plugin_name="Hello World" --plugin_author="John Doe" --plugin_author_uri="https://example.com" --plugin_uri="https://foo.example.com"` + Then the wp-content/plugins/hello-world directory should exist + And the wp-content/plugins/hello-world/hello-world.php file should exist + + When I run `wp i18n make-pot wp-content/plugins/hello-world wp-content/plugins/hello-world/languages/hello-world.pot --merge=foo-plugin/foo-plugin.pot,foo-plugin/bar-plugin.pot` + Then the wp-content/plugins/hello-world/languages/hello-world.pot file should exist + Then STDOUT should be: + """ + Plugin file detected. + Success: POT file successfully generated! + """ + And STDERR should be empty + And the wp-content/plugins/hello-world/languages/hello-world.pot file should exist + And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: + """ + msgid "Hello World" + """ + And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: + """ + #: foo-plugin.js:15 + """ + And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: + """ + msgid "Foo Plugin" + """ + And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: + """ + #: bar-plugin.js:15 + """ + And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: + """ + msgid "Bar Plugin" + """ + Scenario: Merges translations with existing destination file When I run `wp scaffold plugin hello-world --plugin_name="Hello World" --plugin_author="John Doe" --plugin_author_uri="https://example.com" --plugin_uri="https://foo.example.com"` Then the wp-content/plugins/hello-world directory should exist From c1f88df80e04e402cc01b865e96e83abb13f4c48 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 17:22:09 +0200 Subject: [PATCH 13/18] Add test for file inclusion --- features/makepot.feature | 57 +++++++++++++++++++++++++++++++++++ src/IterableCodeExtractor.php | 14 ++++----- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/features/makepot.feature b/features/makepot.feature index 9b7b37fb..5b946beb 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -952,6 +952,63 @@ Feature: Generate a POT file of a WordPress project I am not being ignored """ + Scenario: Only extract strings from included paths + Given an empty foo-plugin directory + And a foo-plugin/foo-plugin.php file: + """ + getPathname() ) ) { + if ( + false !== mb_ereg( $pattern, $file->getPathname() . '$' ) && + false !== mb_ereg( $pattern, $file->getPathname() . '/' ) + ) { return true; } } @@ -167,14 +170,11 @@ function ( $file, $key, $iterator ) use ( $include, $exclude, $extensions ) { /** @var RecursiveCallbackFilterIterator $iterator */ /** @var SplFileInfo $file */ - $is_included = static::isIncluded( $file, $include ); - $is_excluded = static::isExcluded( $file, $exclude ); - - if ( $is_excluded ) { + if ( static::isExcluded( $file, $exclude ) ) { return false; } - if ( ! $is_included && ! $iterator->hasChildren() ) { + if ( ! static::isIncluded( $file, $include ) && ! $iterator->hasChildren() ) { return false; } From dd653d52cc837fab64afecc9b0be9981b5e081d9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 17:29:38 +0200 Subject: [PATCH 14/18] Add test for string exceptions aka black list --- features/makepot.feature | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/features/makepot.feature b/features/makepot.feature index 5b946beb..ee1dc1c9 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -1718,3 +1718,86 @@ Feature: Generate a POT file of a WordPress project """ Extracted 2 strings """ + + Scenario: Ignore strings that are part of the exception list + Given an empty directory + And a exception.pot file: + """ + # Copyright (C) 2018 Foo Plugin + # This file is distributed under the same license as the Foo Plugin package. + msgid "" + msgstr "" + "Project-Id-Version: Foo Plugin\n" + "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/foo-plugin\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "POT-Creation-Date: 2018-05-02T22:06:24+00:00\n" + "PO-Revision-Date: 2018-05-02T22:06:24+00:00\n" + "X-Domain: foo-plugin\n" + + msgid "Foo Bar" + msgstr "" + + msgid "Bar Baz" + msgstr "" + + msgid "Some other text" + msgstr "" + """ + And a foo-plugin.php file: + """ + Date: Mon, 6 Aug 2018 17:32:30 +0200 Subject: [PATCH 15/18] Add test for generic project --- features/makepot.feature | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/features/makepot.feature b/features/makepot.feature index ee1dc1c9..10294259 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -1801,3 +1801,38 @@ Feature: Generate a POT file of a WordPress project """ msgid "Some other text" """ + + Scenario: Extract strings for a generic project + Given an empty example-project directory + And a example-project/stuff.php file: + """ + Date: Mon, 6 Aug 2018 17:46:49 +0200 Subject: [PATCH 16/18] Add test for customized copyright notices --- features/makepot.feature | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/features/makepot.feature b/features/makepot.feature index 10294259..510a0d3a 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -221,7 +221,7 @@ Feature: Generate a POT file of a WordPress project """ When I run `wp i18n make-pot foo-plugin foo-plugin.pot` - And the foo-plugin.pot file should contain: + Then the foo-plugin.pot file should contain: """ #: foo-plugin.php:15 """ @@ -1836,3 +1836,31 @@ Feature: Generate a POT file of a WordPress project """ msgid "Bar" """ + + Scenario: Customized copyright notice + Given an empty example-project directory + And a example-project/stuff.php file: + """ + Date: Mon, 6 Aug 2018 22:55:44 +0200 Subject: [PATCH 17/18] Incorporate feedback from code review --- README.md | 56 +++++++++++----- features/makepot.feature | 43 ++++++++---- src/MakePotCommand.php | 138 +++++++++++++++++++++++++-------------- 3 files changed, 161 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 99b89576..98b26ec3 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,14 @@ wp i18n ### wp i18n make-pot -Create a POT file for a WordPress plugin or theme. +Create a POT file for a WordPress project. ~~~ -wp i18n make-pot [] [--slug=] [--domain=] [--ignore-domain] [--merge[=]] [--except=] [--include=] [--exclude=] [--headers=] [--skip-js] [--copyright-holder=] [--package-name=] +wp i18n make-pot [] [--slug=] [--domain=] [--ignore-domain] [--merge[=]] [--subtract=] [--include=] [--exclude=] [--headers=] [--skip-js] [--file-comment] [--package-name=] ~~~ -Scans PHP and JavaScript files, as well as theme stylesheets for translatable strings. +Scans 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** @@ -50,26 +51,34 @@ Scans PHP and JavaScript files, as well as theme stylesheets for translatable st [--domain=] Text domain to look for in the source code, unless the `--ignore-domain` option is used. By default, the "Text Domain" header of the plugin or theme is used. - If none is provided, it falls back to the plugin/theme slug. + If none is provided, it falls back to the project slug. [--ignore-domain] Ignore the text domain completely and extract strings with any text domain. - [--merge[=]] - One or more existing POT files whose contents should be merged with the extracted strings. - If left empty, defaults to the destination POT file. + [--merge[=]] + Comma-separated list of POT files whose contents should be merged with the extracted strings. + If left empty, defaults to the destination POT file. POT file headers will be ignored. - [--except=] - If set, only strings not already existing in one of the passed POT files will be extracted. + [--subtract=] + Comma-separated list of POT files whose contents should act as some sort of blacklist for string extraction. + Any string which is found on that blacklist will not be extracted. + This can be useful when you want to create multiple POT files from the same source directory with slightly + different content and no duplicate strings between them. [--include=] - Only take specific files and folders into account for the string extraction. + Comma-separated list of files and paths that should be used for string extraction. + If provided, only these files and folders will be taken into account for string extraction. + For example, `--include="src,my-file.php` will ignore anything besides `my-file.php` and files in the `src` directory. + Simple glob patterns can be used, i.e. `--include=foo-*.php` includes any PHP file with the `foo-` prefix. Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. [--exclude=] - Include additional ignored paths as CSV (e.g. 'tests,bin,.github'). - By default, the following files and folders are ignored: node_modules, .git, .svn, .CVS, .hg, vendor. + Comma-separated list of files and paths that should be skipped for string extraction. + For example, `--exclude=".github,myfile.php` would ignore any strings found within `myfile.php` or the `.github` folder. + Simple glob patterns can be used, i.e. `--exclude=foo-*.php` excludes any PHP file with the `foo-` prefix. Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. + The following files and folders are always excluded: node_modules, .git, .svn, .CVS, .hg, vendor, *.min.js. [--headers=] Array in JSON format of custom headers which will be added to the POT file. Defaults to empty array. @@ -77,17 +86,34 @@ Scans PHP and JavaScript files, as well as theme stylesheets for translatable st [--skip-js] Skips JavaScript string extraction. Useful when this is done in another build step, e.g. through Babel. - [--copyright-holder=] - Name to use for the copyright comment in the resulting POT file. + [--file-comment] + String that should be added as a comment to the top of the resulting POT file. + By default, a copyright comment is added for WordPress plugins and themes in the following manner: + + ``` + Copyright (C) 2018 Example Plugin Author + This file is distributed under the same license as the Example Plugin package. + ``` + + If a plugin or theme specifies a license in their main plugin file or stylesheet, the comment looks like this: + + ``` + Copyright (C) 2018 Example Plugin Author + This file is distributed under the GPLv2. + ``` [--package-name=] - Name to use for package name in the resulting POT file. Overrides anything found in a plugin or theme. + Name to use for package name in the resulting POT file's `Project-Id-Version` header. + Overrides plugin or theme name, if applicable. **EXAMPLES** # Create a POT file for the WordPress plugin/theme in the current directory $ wp i18n make-pot . languages/my-plugin.pot + # Create a POT file for the continents and cities list in WordPress core. + $ wp i18n make-pot . continents-and-cities.pot --include="wp-admin/includes/continents-cities.php" --ignore-domain + ## Installing This package is included with WP-CLI itself, no additional installation necessary. diff --git a/features/makepot.feature b/features/makepot.feature index 7311aa48..56d9f10f 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -77,8 +77,8 @@ Feature: Generate a POT file of a WordPress project When I run `wp i18n make-pot wp-content/plugins/hello-world wp-content/plugins/hello-world/languages/hello-world.pot` And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: """ - # Copyright (C) {YEAR} Hello World - # This file is distributed under the same license as the Hello World package. + # Copyright (C) {YEAR} YOUR NAME HERE + # This file is distributed under the same license as the Hello World plugin. """ Scenario: Sets Project-Id-Version @@ -1353,8 +1353,8 @@ Feature: Generate a POT file of a WordPress project And the wp-content/plugins/hello-world/languages/hello-world.pot file should exist And the wp-content/plugins/hello-world/languages/hello-world.pot file should contain: """ - # Copyright (C) 2018 Hello World - # This file is distributed under the same license as the Hello World package. + # Copyright (C) 2018 John Doe + # This file is distributed under the same license as the Hello World plugin. msgid "" msgstr "" "Project-Id-Version: Hello World 0.1.0\n" @@ -1902,7 +1902,7 @@ Feature: Generate a POT file of a WordPress project __( 'Some other text', 'foo-plugin' ); """ - When I run `wp i18n make-pot . foo-plugin.pot --domain=foo-plugin --except=exception.pot` + When I run `wp i18n make-pot . foo-plugin.pot --domain=foo-plugin --subtract=exception.pot` Then STDOUT should be: """ Plugin file detected. @@ -1965,7 +1965,7 @@ Feature: Generate a POT file of a WordPress project msgid "Bar" """ - Scenario: Customized copyright notice + Scenario: Custom package name Given an empty example-project directory And a example-project/stuff.php file: """ @@ -1978,17 +1978,36 @@ Feature: Generate a POT file of a WordPress project __( 'Bar' ); """ - When I run `date +"%Y"` - Then STDOUT should not be empty - And save STDOUT as {YEAR} + When I run `wp i18n make-pot example-project result.pot --ignore-domain --package-name="Acme 1.2.3"` + Then STDOUT should be: + """ + Success: POT file successfully generated! + """ + And the result.pot file should contain: + """ + Project-Id-Version: Acme 1.2.3 + """ + + Scenario: Customized file comment + Given an empty example-project directory + And a example-project/stuff.php file: + """ + ] * : Text domain to look for in the source code, unless the `--ignore-domain` option is used. * By default, the "Text Domain" header of the plugin or theme is used. - * If none is provided, it falls back to the plugin/theme slug. + * If none is provided, it falls back to the project slug. * * [--ignore-domain] * : Ignore the text domain completely and extract strings with any text domain. * - * [--merge[=]] - * : One or more existing POT files whose contents should be merged with the extracted strings. - * If left empty, defaults to the destination POT file. + * [--merge[=]] + * : Comma-separated list of POT files whose contents should be merged with the extracted strings. + * If left empty, defaults to the destination POT file. POT file headers will be ignored. * - * [--except=] - * : If set, only strings not already existing in one of the passed POT files will be extracted. + * [--subtract=] + * : Comma-separated list of POT files whose contents should act as some sort of blacklist for string extraction. + * Any string which is found on that blacklist will not be extracted. + * This can be useful when you want to create multiple POT files from the same source directory with slightly + * different content and no duplicate strings between them. * * [--include=] - * : Only take specific files and folders into account for the string extraction. + * : Comma-separated list of files and paths that should be used for string extraction. + * If provided, only these files and folders will be taken into account for string extraction. + * For example, `--include="src,my-file.php` will ignore anything besides `my-file.php` and files in the `src` directory. + * Simple glob patterns can be used, i.e. `--include=foo-*.php` includes any PHP file with the `foo-` prefix. * Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. * * [--exclude=] - * : Include additional ignored paths as CSV (e.g. 'tests,bin,.github'). - * By default, the following files and folders are ignored: node_modules, .git, .svn, .CVS, .hg, vendor. + * : Comma-separated list of files and paths that should be skipped for string extraction. + * For example, `--exclude=".github,myfile.php` would ignore any strings found within `myfile.php` or the `.github` folder. + * Simple glob patterns can be used, i.e. `--exclude=foo-*.php` excludes any PHP file with the `foo-` prefix. * Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. + * The following files and folders are always excluded: node_modules, .git, .svn, .CVS, .hg, vendor, *.min.js. * * [--headers=] * : Array in JSON format of custom headers which will be added to the POT file. Defaults to empty array. @@ -170,17 +179,34 @@ class MakePotCommand extends WP_CLI_Command { * [--skip-js] * : Skips JavaScript string extraction. Useful when this is done in another build step, e.g. through Babel. * - * [--copyright-holder=] - * : Name to use for the copyright comment in the resulting POT file. + * [--file-comment] + * : String that should be added as a comment to the top of the resulting POT file. + * By default, a copyright comment is added for WordPress plugins and themes in the following manner: + * + * ``` + * Copyright (C) 2018 Example Plugin Author + * This file is distributed under the same license as the Example Plugin package. + * ``` + * + * If a plugin or theme specifies a license in their main plugin file or stylesheet, the comment looks like this: + * + * ``` + * Copyright (C) 2018 Example Plugin Author + * This file is distributed under the GPLv2. + * ``` * * [--package-name=] - * : Name to use for package name in the resulting POT file. Overrides anything found in a plugin or theme. + * : Name to use for package name in the resulting POT file's `Project-Id-Version` header. + * Overrides plugin or theme name, if applicable. * * ## EXAMPLES * * # Create a POT file for the WordPress plugin/theme in the current directory * $ wp i18n make-pot . languages/my-plugin.pot * + * # Create a POT file for the continents and cities list in WordPress core. + * $ wp i18n make-pot . continents-and-cities.pot --include="wp-admin/includes/continents-cities.php" --ignore-domain + * * @when before_wp_load * * @throws WP_CLI\ExitException @@ -221,12 +247,12 @@ public function handle_arguments( $args, $assoc_args ) { $array_arguments = array( 'headers' ); $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - $this->source = realpath( $args[0] ); - $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->headers = Utils\get_flag_value( $assoc_args, 'headers', $this->headers ); - $this->package_name = Utils\get_flag_value( $assoc_args, 'package-name', 'Unknown' ); - $this->copyright_holder = Utils\get_flag_value( $assoc_args, 'copyright-holder', 'Unknown' ); + $this->source = realpath( $args[0] ); + $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->headers = Utils\get_flag_value( $assoc_args, 'headers', $this->headers ); + $this->file_comment = Utils\get_flag_value( $assoc_args, 'file-comment' ); + $this->package_name = Utils\get_flag_value( $assoc_args, 'package-name' ); $ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false ); @@ -313,8 +339,8 @@ function ( $file ) { $this->exceptions = new Translations(); - if ( isset( $assoc_args['except'] ) ) { - $exceptions = explode( ',', $assoc_args['except'] ); + if ( isset( $assoc_args['subtract'] ) ) { + $exceptions = explode( ',', $assoc_args['subtract'] ); $exceptions = array_filter( $exceptions, @@ -368,8 +394,6 @@ protected function unslashit( $string ) { /** * Retrieves the main file data of the plugin or theme. * - * @throws WP_CLI\ExitException - * * @return array */ protected function get_main_file_data() { @@ -634,38 +658,50 @@ protected function audit_strings( $translations ) { /** * Returns the copyright comment for the given package. * - * @return array Meta data. + * @return string File comment. */ protected function get_file_comment() { - $author = $this->copyright_holder; - $name = $this->package_name; + if ( $this->file_comment ) { + return implode( "\n", explode( '\n', $this->file_comment ) ); + } if ( isset( $this->main_file_data['Theme Name'] ) ) { - $name = $this->main_file_data['Theme Name']; - $author = $this->main_file_data['Author']; - } elseif ( isset( $this->main_file_data['Plugin Name'] ) ) { - $name = $this->main_file_data['Plugin Name']; - $author = $name; + if ( isset( $this->main_file_data['License'] ) ) { + return sprintf( + "Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.", + date( 'Y' ), + $this->main_file_data['Author'], + $this->main_file_data['License'] + ); + } + + return sprintf( + "Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s theme.", + date( 'Y' ), + $this->main_file_data['Author'], + $this->main_file_data['Theme Name'] + ); } - $author = null === $author ? $this->copyright_holder : $author; - $name = null === $name ? $this->package_name : $name; + if ( isset( $this->main_file_data['Plugin Name'] ) ) { + if ( isset( $this->main_file_data['License'] ) ) { + return sprintf( + "Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.", + date( 'Y' ), + $this->main_file_data['Author'], + $this->main_file_data['License'] + ); + } - if ( isset( $this->main_file_data['License'] ) ) { return sprintf( - "Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.", + "Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s plugin.", date( 'Y' ), - $author, - $this->main_file_data['License'] + $this->main_file_data['Author'], + $this->main_file_data['Plugin Name'] ); } - return sprintf( - "Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s package.", - date( 'Y' ), - $author, - $name - ); + return ''; } /** @@ -674,7 +710,7 @@ protected function get_file_comment() { * @param Translations $translations Translations object. */ protected function set_default_headers( $translations ) { - $name = $this->package_name; + $name = null; $version = $this->get_wp_version(); $bugs_address = null; @@ -690,9 +726,13 @@ protected function set_default_headers( $translations ) { $bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug ); } - $name = null === $name ? $this->package_name : $name; + if ( null !== $this->package_name ) { + $name = $this->package_name; + } - $translations->setHeader( 'Project-Id-Version', $name . ( $version ? ' ' . $version : '' ) ); + if ( null !== $name ) { + $translations->setHeader( 'Project-Id-Version', $name . ( $version ? ' ' . $version : '' ) ); + } if ( null !== $bugs_address ) { $translations->setHeader( 'Report-Msgid-Bugs-To', $bugs_address ); From 61c729215ab46412ef4b1280bdbaa4038a9751a4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 6 Aug 2018 22:56:45 +0200 Subject: [PATCH 18/18] Adjust test for generic project to use `_()` --- features/makepot.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/makepot.feature b/features/makepot.feature index 56d9f10f..8c9aad9a 100644 --- a/features/makepot.feature +++ b/features/makepot.feature @@ -1936,11 +1936,11 @@ Feature: Generate a POT file of a WordPress project """