diff --git a/.gitignore b/.gitignore index 13e6170afaa536..68540405db71f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules gutenberg.zip languages/gutenberg.pot /languages/gutenberg-translations.php +translation-map.json # Directories/files that may appear in your environment .DS_Store @@ -13,3 +14,4 @@ languages/gutenberg.pot phpcs.xml yarn.lock docker-compose.override.yml +*.mo diff --git a/bin/i18n-map-extractor.js b/bin/i18n-map-extractor.js new file mode 100644 index 00000000000000..c69e784e698d60 --- /dev/null +++ b/bin/i18n-map-extractor.js @@ -0,0 +1,145 @@ +const recast = require( 'recast' ); +const { forEach, reduce, includes, isEmpty, isFunction } = require( 'lodash' ); +const { readFileSync, writeFileSync } = require( 'fs' ); +const NormalModule = require( 'webpack/lib/NormalModule' ); + +class wpi18nExtractor { + constructor( options ) { + const DEFAULT_FUNCTIONS = [ + '__', + '_n', + '_x', + '_nx', + 'sprintf', + ]; + this.options = options || {}; + this.options.aliases = this.options.aliases || {}; + this.options.filename = this.options.filename || 'translation-map.json'; + this.translationMap = {}; + this.functionNames = this.options.functionNames || DEFAULT_FUNCTIONS; + } + + extractStringFromFunctionCall( args, functionName ) { + const strings = []; + switch ( functionName ) { + case '__': + case 'sprintf': + case '_n': + strings.push( args[ 0 ].value ); + break; + case '_x': + strings.push( args[ 1 ].value + '\u0004' + args[ 0 ].value ); + break; + case '_nx': + strings.push( args[ 3 ].value + '\u0004' + args[ 0 ].value ); + } + return strings; + } + + getStringsFromModule( module, extractor ) { + const source = module.originalSource().source(); + const { parse, types } = recast; + if ( isEmpty( source ) ) { + return []; + } + let strings = []; + try { + const ast = parse( source ); + types.visit( ast, { + visitCallExpression: function( path ) { + const node = path.node; + if ( includes( extractor.functionNames, node.callee.name ) && + node.arguments + ) { + strings = strings.concat( + extractor.extractStringFromFunctionCall( + types.getFieldValue( node, 'arguments' ), + node.callee.name, + ) + ); + } + this.traverse( path ); + }, + } ); + } catch ( e ) { + //we just want to skip parsing errors. + } + return strings; + } + + parseSourcesToMap( modules, chunkName, extractor ) { + const { getStringsFromModule } = extractor; + reduce( + Array.from( modules ), + function( mapped, module ) { + if ( ! ( module instanceof NormalModule ) || + ! isFunction( module.originalSource ) + ) { + return mapped; + } + if ( ! mapped.hasOwnProperty( chunkName ) ) { + mapped[ chunkName ] = []; + } + mapped[ chunkName ] = mapped[ chunkName ] + .concat( getStringsFromModule( module, extractor ) ); + return mapped; + }, + extractor.translationMap + ); + } + + apply( compiler ) { + const { processChunks } = this; + const extractor = this; + + /** + * webpack 4 registration + */ + if ( compiler.hasOwnProperty( 'hooks' ) ) { + compiler.hooks.thisCompilation.tap( 'webpack-i18n-map-extractor', compilation => { + compilation.hooks.optimizeChunks.tap( 'webpack-i18n-map-extractor', chunks => { + processChunks( chunks, extractor ); + } ); + } ); + // webpack 3 registration. + } else { + compiler.plugin( 'this-compilation', ( compilation ) => { + compilation.plugin( [ 'optimize-chunks', 'optimize-extracted-chunks' ], ( chunks ) => { + processChunks( chunks, extractor ); + } ); + } ); + } + } + + processChunks( chunks, extractor ) { + const { + options, + translationMap, + parseSourcesToMap, + } = extractor; + let chunkName, + finalMap; + forEach( chunks, function( chunk ) { + if ( chunk.name ) { + //get chunk.name from alias if it exists + chunkName = options.aliases.hasOwnProperty( chunk.name ) ? + options.aliases[ chunk.name ] : + chunk.name; + parseSourcesToMap( chunk._modules, chunkName, extractor ); + } + } ); + //get existing json and merge + try { + finalMap = JSON.parse( readFileSync( './' + options.filename ) ); + } catch ( e ) { + finalMap = {}; + } + finalMap = Object.assign( {}, finalMap, translationMap ); + writeFileSync( './' + options.filename, + JSON.stringify( finalMap, null, 2 ), + 'utf-8' + ); + } +} + +module.exports = wpi18nExtractor; diff --git a/composer.json b/composer.json index 1c35db62abd5a1..320a7b8f7f1458 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "composer/installers": "~1.0" }, "scripts": { - "lint": "phpcs" + "lint": "phpcs", + "lint:fix": "phpcbf" } } diff --git a/lib/class-gb-scripts.php b/lib/class-gb-scripts.php new file mode 100644 index 00000000000000..24cde1884872ac --- /dev/null +++ b/lib/class-gb-scripts.php @@ -0,0 +1,198 @@ +set_chunk_map( $i18n_map ); + add_filter( 'print_scripts_array', array( $this, 'queue_i18n' ) ); + } + + + /** + * Used to register a script that has i18n strings for its $handle + * + * @param string $handle The script handle reference. + * @param string $domain The i18n domain for the strings. + */ + public function register_script_i18n( $handle, $domain ) { + $this->registered_i18n[ $handle ] = $domain; + } + + + /** + * Callback on print_scripts_array to listen for scripts enqueued and handle setting up the localized data. + * + * @param array $handles Array of registered script handles. + * + * @return array + */ + public function queue_i18n( $handles ) { + if ( empty( $this->registered_i18n ) || empty( $this->i18n_map ) ) { + return $handles; + } + foreach ( (array) $handles as $handle ) { + $this->queue_i18n_translations_for_handle( $handle ); + } + if ( $this->queued_chunk_translations ) { + foreach ( $this->queued_chunk_translations as $handle => $translations_for_domain ) { + $this->register_inline_script( + $handle, + $translations_for_domain['translations'], + $translations_for_domain['domain'] + ); + } + } + return $handles; + } + + + /** + * Registers inline script with translations for given handle and domain. + * + * @param string $handle Handle used to register javascript file containing translations. + * @param array $translations Array of string translations. + * @param string $domain Domain for translations. If left empty then strings are registered with the default + * domain for the javascript. + */ + private function register_inline_script( $handle, $translations, $domain = '' ) { + $script = $domain ? + 'wp.i18n.setLocaleData( ' . json_encode( $translations ) . ', ' . $domain . ' );' : + 'wp.i18n.setLocaleData( ' . json_encode( $translations ) . ' );'; + wp_add_inline_script( $handle, $script, 'before' ); + } + + + /** + * Queues up the translation strings for the given handle. + * + * @param string $handle The script handle being queued up. + */ + private function queue_i18n_translations_for_handle( $handle ) { + if ( isset( $this->registered_i18n[ $handle ] ) ) { + $domain = $this->registered_i18n[ $handle ]; + $translations = $this->get_jed_locale_data_for_domain_and_chunk( $handle, $domain ); + if ( count( $translations ) > 1 ) { + $this->queued_chunk_translations[ $handle ] = array( + 'domain' => $domain, + 'translations' => $translations, + ); + } + unset( $this->registered_i18n[ $handle ] ); + } + } + + + /** + * Sets the internal i18n_map property. + * + * If $chunk_map is empty or not an array, will attempt to load a chunk map from a default named map. + * + * @param array $i18n_map If provided, an array of translation strings indexed by script handle names they + * correspond to. + */ + private function set_chunk_map( $i18n_map ) { + if ( empty( $i18n_map ) || ! is_array( $i18n_map ) ) { + $i18n_map = json_decode( + file_get_contents( gutenberg_dir_path() . 'translation-map.json' ), + true + ); + } + $this->i18n_map = $i18n_map; + } + + + /** + * Get the jed locale data for a given $handle and domain + * + * @param string $handle The name for the script handle we want strings returned for. + * @param string $domain The i18n domain. + * + * @return array + */ + protected function get_jed_locale_data_for_domain_and_chunk( $handle, $domain ) { + $translations = gutenberg_get_jed_locale_data( $domain ); + // get index for adding back after extracting strings for this $chunk. + $index = $translations['']; + $translations = $this->get_locale_data_matching_map( + $this->get_original_strings_for_handle_from_map( $handle ), + $translations + ); + $translations[''] = $index; + return $translations; + } + + + /** + * Get locale data for given strings from given translations + * + * @param array $string_set This is the subset of strings (msgIds) we want to extract from the translations array. + * @param array $translations Translation data to extra strings from. + * + * @return array + */ + protected function get_locale_data_matching_map( $string_set, $translations ) { + if ( ! is_array( $string_set ) || ! is_array( $translations ) || empty( $string_set ) ) { + return array(); + } + // some strings with quotes in them will break on the array_flip, so making sure quotes in the string are + // slashed also filter falsey values. + $string_set = array_unique( array_filter( wp_slash( $string_set ) ) ); + return array_intersect_key( $translations, array_flip( $string_set ) ); + } + + + /** + * Get original strings to translate for the given chunk from the map + * + * @param string $handle The script handle name to get strings from the map for. + * + * @return array + */ + protected function get_original_strings_for_handle_from_map( $handle ) { + return isset( $this->i18n_map[ $handle ] ) ? $this->i18n_map[ $handle ] : array(); + } +} diff --git a/lib/client-assets.php b/lib/client-assets.php index 0cf66a5e4c71f0..f3756f57577f41 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -74,6 +74,7 @@ function gutenberg_get_script_polyfill( $tests ) { */ function gutenberg_register_scripts_and_styles() { gutenberg_register_vendor_scripts(); + gutenberg_register_wpi18n_for_scripts(); // Editor Scripts. wp_register_script( @@ -114,31 +115,35 @@ function gutenberg_register_scripts_and_styles() { true ); global $wp_locale; - wp_add_inline_script( 'wp-date', 'window._wpDateSettings = ' . wp_json_encode( array( - 'l10n' => array( - 'locale' => get_user_locale(), - 'months' => array_values( $wp_locale->month ), - 'monthsShort' => array_values( $wp_locale->month_abbrev ), - 'weekdays' => array_values( $wp_locale->weekday ), - 'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ), - 'meridiem' => (object) $wp_locale->meridiem, - 'relative' => array( - /* translators: %s: duration */ - 'future' => __( '%s from now', 'default' ), - /* translators: %s: duration */ - 'past' => __( '%s ago', 'default' ), - ), - ), - 'formats' => array( - 'time' => get_option( 'time_format', __( 'g:i a', 'default' ) ), - 'date' => get_option( 'date_format', __( 'F j, Y', 'default' ) ), - 'datetime' => __( 'F j, Y g:i a', 'default' ), - ), - 'timezone' => array( - 'offset' => get_option( 'gmt_offset', 0 ), - 'string' => get_option( 'timezone_string', 'UTC' ), - ), - ) ), 'before' ); + wp_add_inline_script( + 'wp-date', 'window._wpDateSettings = ' . wp_json_encode( + array( + 'l10n' => array( + 'locale' => get_user_locale(), + 'months' => array_values( $wp_locale->month ), + 'monthsShort' => array_values( $wp_locale->month_abbrev ), + 'weekdays' => array_values( $wp_locale->weekday ), + 'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ), + 'meridiem' => (object) $wp_locale->meridiem, + 'relative' => array( + /* translators: %s: duration */ + 'future' => __( '%s from now', 'default' ), + /* translators: %s: duration */ + 'past' => __( '%s ago', 'default' ), + ), + ), + 'formats' => array( + 'time' => get_option( 'time_format', __( 'g:i a', 'default' ) ), + 'date' => get_option( 'date_format', __( 'F j, Y', 'default' ) ), + 'datetime' => __( 'F j, Y g:i a', 'default' ), + ), + 'timezone' => array( + 'offset' => get_option( 'gmt_offset', 0 ), + 'string' => get_option( 'timezone_string', 'UTC' ), + ), + ) + ), 'before' + ); wp_register_script( 'wp-i18n', gutenberg_url( 'i18n/build/index.js' ), @@ -169,10 +174,12 @@ function gutenberg_register_scripts_and_styles() { ); wp_add_inline_script( 'wp-blocks', - gutenberg_get_script_polyfill( array( - '\'Promise\' in window' => 'promise', - '\'fetch\' in window' => 'fetch', - ) ), + gutenberg_get_script_polyfill( + array( + '\'Promise\' in window' => 'promise', + '\'fetch\' in window' => 'fetch', + ) + ), 'before' ); wp_register_script( @@ -186,70 +193,90 @@ function gutenberg_register_scripts_and_styles() { wp_add_inline_script( 'wp-blocks', 'window.wp.oldEditor = window.wp.editor;', 'before' ); - $tinymce_settings = apply_filters( 'tiny_mce_before_init', array( - 'plugins' => implode( ',', array_unique( apply_filters( 'tiny_mce_plugins', array( - 'charmap', - 'colorpicker', - 'hr', - 'lists', - 'media', - 'paste', - 'tabfocus', - 'textcolor', - 'fullscreen', - 'wordpress', - 'wpautoresize', - 'wpeditimage', - 'wpemoji', - 'wpgallery', - 'wplink', - 'wpdialogs', - 'wptextpattern', - 'wpview', - ) ) ) ), - 'toolbar1' => implode( ',', array_merge( apply_filters( 'mce_buttons', array( - 'formatselect', - 'bold', - 'italic', - 'bullist', - 'numlist', - 'blockquote', - 'alignleft', - 'aligncenter', - 'alignright', - 'link', - 'unlink', - 'wp_more', - 'spellchecker', - ), 'editor' ), array( 'kitchensink' ) ) ), - 'toolbar2' => implode( ',', apply_filters( 'mce_buttons_2', array( - 'strikethrough', - 'hr', - 'forecolor', - 'pastetext', - 'removeformat', - 'charmap', - 'outdent', - 'indent', - 'undo', - 'redo', - 'wp_help', - ), 'editor' ) ), - 'toolbar3' => implode( ',', apply_filters( 'mce_buttons_3', array(), 'editor' ) ), - 'toolbar4' => implode( ',', apply_filters( 'mce_buttons_4', array(), 'editor' ) ), - 'external_plugins' => apply_filters( 'mce_external_plugins', array() ), - ), 'editor' ); + $tinymce_settings = apply_filters( + 'tiny_mce_before_init', array( + 'plugins' => implode( + ',', array_unique( + apply_filters( + 'tiny_mce_plugins', array( + 'charmap', + 'colorpicker', + 'hr', + 'lists', + 'media', + 'paste', + 'tabfocus', + 'textcolor', + 'fullscreen', + 'wordpress', + 'wpautoresize', + 'wpeditimage', + 'wpemoji', + 'wpgallery', + 'wplink', + 'wpdialogs', + 'wptextpattern', + 'wpview', + ) + ) + ) + ), + 'toolbar1' => implode( + ',', array_merge( + apply_filters( + 'mce_buttons', array( + 'formatselect', + 'bold', + 'italic', + 'bullist', + 'numlist', + 'blockquote', + 'alignleft', + 'aligncenter', + 'alignright', + 'link', + 'unlink', + 'wp_more', + 'spellchecker', + ), 'editor' + ), array( 'kitchensink' ) + ) + ), + 'toolbar2' => implode( + ',', apply_filters( + 'mce_buttons_2', array( + 'strikethrough', + 'hr', + 'forecolor', + 'pastetext', + 'removeformat', + 'charmap', + 'outdent', + 'indent', + 'undo', + 'redo', + 'wp_help', + ), 'editor' + ) + ), + 'toolbar3' => implode( ',', apply_filters( 'mce_buttons_3', array(), 'editor' ) ), + 'toolbar4' => implode( ',', apply_filters( 'mce_buttons_4', array(), 'editor' ) ), + 'external_plugins' => apply_filters( 'mce_external_plugins', array() ), + ), 'editor' + ); if ( isset( $tinymce_settings['style_formats'] ) && is_string( $tinymce_settings['style_formats'] ) ) { // Decode the options as we used to recommende json_encoding the TinyMCE settings. $tinymce_settings['style_formats'] = json_decode( $tinymce_settings['style_formats'] ); } - wp_localize_script( 'wp-blocks', 'wpEditorL10n', array( - 'tinymce' => array( - 'baseURL' => includes_url( 'js/tinymce' ), - 'suffix' => SCRIPT_DEBUG ? '' : '.min', - 'settings' => $tinymce_settings, - ), - ) ); + wp_localize_script( + 'wp-blocks', 'wpEditorL10n', array( + 'tinymce' => array( + 'baseURL' => includes_url( 'js/tinymce' ), + 'suffix' => SCRIPT_DEBUG ? '' : '.min', + 'settings' => $tinymce_settings, + ), + ) + ); wp_register_script( 'wp-editor', @@ -633,10 +660,12 @@ function gutenberg_extend_wp_api_backbone_client() { // Localize the wp-api settings and schema. $schema_response = rest_do_request( new WP_REST_Request( 'GET', '/' ) ); if ( ! $schema_response->is_error() ) { - wp_add_inline_script( 'wp-api', sprintf( - 'wpApiSettings.cacheSchema = true; wpApiSettings.schema = %s;', - wp_json_encode( $schema_response->get_data() ) - ), 'before' ); + wp_add_inline_script( + 'wp-api', sprintf( + 'wpApiSettings.cacheSchema = true; wpApiSettings.schema = %s;', + wp_json_encode( $schema_response->get_data() ) + ), 'before' + ); } } @@ -800,6 +829,32 @@ function gutenberg_capture_code_editor_settings( $settings ) { return false; } + +/** + * This takes care of registering script i18n strings with each handle and chunk for that handle + * + * @since 2.6.x + */ +function gutenberg_register_wpi18n_for_scripts() { + $handles = array( + 'wp-data', + 'wp-core-data', + 'wp-utils', + 'wp-hooks', + 'wp-date', + 'wp-element', + 'wp-components', + 'wp-blocks', + 'wp-viewport', + 'wp-editor', + 'wp-edit-post', + 'wp-plugins', + ); + foreach ( $handles as $handle ) { + gutenberg_register_script_i18n( $handle ); + } +} + /** * Scripts & Styles. * @@ -882,24 +937,19 @@ function gutenberg_editor_scripts_and_styles( $hook ) { ); } - // Prepare Jed locale data. - $locale_data = gutenberg_get_jed_locale_data( 'gutenberg' ); - wp_add_inline_script( - 'wp-i18n', - 'wp.i18n.setLocaleData( ' . json_encode( $locale_data ) . ' );' - ); - // Preload server-registered block schemas. wp_localize_script( 'wp-blocks', '_wpBlocks', gutenberg_prepare_blocks_for_js() ); // Get admin url for handling meta boxes. $meta_box_url = admin_url( 'post.php' ); - $meta_box_url = add_query_arg( array( - 'post' => $post_to_edit['id'], - 'action' => 'edit', - 'classic-editor' => true, - 'meta_box' => true, - ), $meta_box_url ); + $meta_box_url = add_query_arg( + array( + 'post' => $post_to_edit['id'], + 'action' => 'edit', + 'classic-editor' => true, + 'meta_box' => true, + ), $meta_box_url + ); wp_localize_script( 'wp-editor', '_wpMetaBoxUrl', $meta_box_url ); // Populate default code editor settings by short-circuiting wp_enqueue_code_editor. @@ -907,10 +957,12 @@ function gutenberg_editor_scripts_and_styles( $hook ) { add_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings' ); wp_enqueue_code_editor( array( 'type' => 'text/html' ) ); remove_filter( 'wp_code_editor_settings', 'gutenberg_capture_code_editor_settings' ); - wp_add_inline_script( 'wp-editor', sprintf( - 'window._wpGutenbergCodeEditorSettings = %s;', - wp_json_encode( $gutenberg_captured_code_editor_settings ) - ) ); + wp_add_inline_script( + 'wp-editor', sprintf( + 'window._wpGutenbergCodeEditorSettings = %s;', + wp_json_encode( $gutenberg_captured_code_editor_settings ) + ) + ); // Initialize the editor. $gutenberg_theme_support = get_theme_support( 'gutenberg' ); @@ -974,9 +1026,11 @@ function gutenberg_editor_scripts_and_styles( $hook ) { /** * Scripts */ - wp_enqueue_media( array( - 'post' => $post_to_edit['id'], - ) ); + wp_enqueue_media( + array( + 'post' => $post_to_edit['id'], + ) + ); wp_enqueue_editor(); /** diff --git a/lib/i18n.php b/lib/i18n.php index ab347c8334285b..94827d05b1b9c6 100644 --- a/lib/i18n.php +++ b/lib/i18n.php @@ -9,6 +9,27 @@ die( 'Silence is golden.' ); } + +/** + * Register script handle and chunk name for the given domain + * + * Registered scripts will be matched against the generated `translation-map.json` for any i18n strings to be loaded + * when the script with the given handle is enqueued. + * + * @since 2.6.x + * + * @param string $handle Name of the script being registered. + * @param string $domain i18n domain. + */ +function gutenberg_register_script_i18n( $handle, $domain = '' ) { + static $gb_scripts; + if ( ! $gb_scripts instanceof GB_Scripts ) { + $gb_scripts = new GB_Scripts(); + } + $gb_scripts->register_script_i18n( $handle, $domain ); +} + + /** * Returns Jed-formatted localization data. * @@ -19,6 +40,10 @@ * @return array */ function gutenberg_get_jed_locale_data( $domain ) { + // gutenberg doesn't use domain in its js strings but has a domain set by virtue of being a plugin in the WordPress + // plugin repository. So we need to use that to retrieve the gutenberg translations. When GB is merged to WordPress + // core this will be unnecessary. + $domain = $domain ? $domain : 'gutenberg'; $translations = get_translations_for_domain( $domain ); $locale = array( diff --git a/lib/load.php b/lib/load.php index 0da31fddf577cc..65e2b334c2b80f 100644 --- a/lib/load.php +++ b/lib/load.php @@ -20,6 +20,7 @@ require dirname( __FILE__ ) . '/i18n.php'; require dirname( __FILE__ ) . '/parser.php'; require dirname( __FILE__ ) . '/register.php'; +require dirname( __FILE__ ) . '/class-gb-scripts.php'; // Register server-side code for individual blocks. foreach ( glob( dirname( __FILE__ ) . '/../blocks/library/*/index.php' ) as $block_logic ) { diff --git a/package.json b/package.json index 96f331aeaf77c2..3ed4010431180c 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "lint-php": "docker-compose run --rm composer run-script lint", + "lint-php:fix": "docker-compose run --rm composer run-script lint:fix", "predev": "check-node-version --package", "dev": "cross-env webpack --watch", "test": "npm run lint && npm run test-unit", diff --git a/webpack.config.js b/webpack.config.js index bf9b00b68afc2e..b4fe803a170f8b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,7 @@ const ExtractTextPlugin = require( 'extract-text-webpack-plugin' ); const WebpackRTLPlugin = require( 'webpack-rtl-plugin' ); const { get } = require( 'lodash' ); const { basename } = require( 'path' ); +const wpi18nExtractor = require( './bin/i18n-map-extractor.js' ); /** * WordPress dependencies @@ -51,7 +52,7 @@ const extractConfig = { }; /** - * Given a string, returns a new string with dash separators converedd to + * Given a string, returns a new string with dash separators converted to * camel-case equivalent. This is not as aggressive as `_.camelCase` in * converting to uppercase, where Lodash will convert letters following * numbers. @@ -100,6 +101,8 @@ const externals = { 'lodash-es': 'lodash', }; +const aliases = {}; + [ ...entryPointNames, ...packageNames, @@ -108,6 +111,7 @@ const externals = { externals[ `@wordpress/${ name }` ] = { this: [ 'wp', camelCaseDash( name ) ], }; + aliases[ camelCaseDash( name ) ] = 'wp-' + name; } ); const config = { @@ -178,6 +182,7 @@ const config = { blocksCSSPlugin, editBlocksCSSPlugin, mainCSSExtractTextPlugin, + new wpi18nExtractor( { aliases } ), // Create RTL files with a -rtl suffix new WebpackRTLPlugin( { suffix: '-rtl',