From bb9ae0bd2f8c0da336f830464b442803d1a4592c Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Wed, 6 May 2026 15:38:38 +1200 Subject: [PATCH 1/3] Search Blocks: enable @wordpress/i18n in the IAPI view bundle (SEARCH-168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Externalize @wordpress/i18n to a script-module reference (via DEP's requestToExternalModule) and register a tiny ESM shim that re-exports window.wp.i18n as the @wordpress/i18n module. The classic wp-i18n script is enqueued on every search-blocks page so window.wp.i18n is populated synchronously before any deferred module evaluates; translations are inlined as setLocaleData() against PHP's already-loaded gettext entries for jetpack-search-pkg, so __() / _n() / sprintf() return localized strings on first paint without depending on per-handle .json files. Use the new path in: - result-utils.js buildRatingAriaLabel — fixes the SEARCH-168 frontend product-rating aria label (was hardcoded English when used outside the editor preview). - store/index.js computeResultsCountText — replaces the PHP-seeded state.strings reads with native __()/_n()/sprintf(). - active-filters/view.js activePills — same swap. Drop the now-unused build_initial_strings() PHP seed and the test assertions that pinned its shape. --- ...echo-search-168-localize-rating-aria-label | 4 + .../blocks/active-filters/view.js | 11 +- .../src/search-blocks/class-search-blocks.php | 156 ++++++++++++++---- .../src/search-blocks/store/i18n-shim.js | 43 +++++ .../search/src/search-blocks/store/index.js | 13 +- .../src/search-blocks/store/result-utils.js | 36 ++-- .../search-blocks/active-filters-view.test.js | 9 +- .../tests/js/search-blocks/store.test.js | 16 +- .../search/tests/php/Search_Blocks_Test.php | 21 --- .../search/tools/webpack.blocks.config.js | 33 +++- 10 files changed, 248 insertions(+), 94 deletions(-) create mode 100644 projects/packages/search/changelog/echo-search-168-localize-rating-aria-label create mode 100644 projects/packages/search/src/search-blocks/store/i18n-shim.js diff --git a/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label b/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label new file mode 100644 index 000000000000..c0d27d1355c7 --- /dev/null +++ b/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Search Blocks: enable @wordpress/i18n imports in the Interactivity API view bundle so block strings (rating aria-label, results count, filter pill remove) translate on the front end via the page locale. diff --git a/projects/packages/search/src/search-blocks/blocks/active-filters/view.js b/projects/packages/search/src/search-blocks/blocks/active-filters/view.js index 96e6550ec987..5157abc8dd3b 100644 --- a/projects/packages/search/src/search-blocks/blocks/active-filters/view.js +++ b/projects/packages/search/src/search-blocks/blocks/active-filters/view.js @@ -1,3 +1,4 @@ +import { __, sprintf } from '@wordpress/i18n'; import { store, getContext } from '@wordpress/interactivity'; import { formatDateBucketLabel } from '../../store/api'; import '../../store'; @@ -41,15 +42,14 @@ function resolveValueLabel( state, filterKey, filterValue ) { store( NAMESPACE, { state: { /** - * Pill descriptors for `data-wp-each`. `ariaLabel` uses the "Remove %s" - * format seeded from PHP because the view bundle cannot import - * `@wordpress/i18n`. + * Pill descriptors for `data-wp-each`. `ariaLabel` is composed via + * `@wordpress/i18n` so it picks up the page's translations through + * the i18n shim — see `Search_Blocks::register_i18n_module()`. * * @return {Array} Pill descriptors. */ get activePills() { const { state } = store( NAMESPACE ); - const removeFormat = state.strings?.removeFilter ?? 'Remove %s'; const pills = []; for ( const [ filterKey, values ] of Object.entries( state.activeFilters ?? {} ) ) { if ( ! Array.isArray( values ) ) { @@ -64,7 +64,8 @@ store( NAMESPACE, { filterKey, value, label, - ariaLabel: removeFormat.replace( '%s', label ), + /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */ + ariaLabel: sprintf( __( 'Remove %s', 'jetpack-search-pkg' ), label ), } ); } } diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 122e515cfbd6..d2aee2e55537 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -82,6 +82,7 @@ class Search_Blocks { public static function init() { add_action( 'init', array( static::class, 'register_blocks' ) ); add_action( 'init', array( static::class, 'register_search_template' ) ); + add_action( 'init', array( static::class, 'register_i18n_module' ) ); add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) ); add_filter( 'search_template_hierarchy', array( static::class, 'prepend_search_template' ) ); // FSE block-template rendering runs *before* `wp_head()` (see @@ -92,6 +93,7 @@ public static function init() { // deep-merge no-op and keeps classic-theme paths covered. add_action( 'template_redirect', array( static::class, 'seed_interactivity_state' ) ); add_action( 'wp_enqueue_scripts', array( static::class, 'seed_interactivity_state' ) ); + add_action( 'wp_enqueue_scripts', array( static::class, 'enqueue_i18n_runtime' ) ); add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) ); } @@ -167,6 +169,126 @@ public static function enqueue_editor_assets() { ); } + /** + * Register `@wordpress/i18n` as a script module pointing at the package's + * shim. The shim re-exports `window.wp.i18n`, letting the IAPI view bundle + * use `import { __, _n, sprintf } from '@wordpress/i18n'` natively. + * + * WP core only registers `@wordpress/interactivity` (and recently a11y / + * router) as script modules, so importing `@wordpress/i18n` from a view + * bundle would otherwise fail at load time with an unresolved import. + * Registering our own module under the canonical `@wordpress/i18n` ID + * means the resolver finds it first, and any future core registration + * supersedes ours via `wp_register_script_module`'s first-wins behavior. + */ + public static function register_i18n_module() { + if ( ! function_exists( 'wp_register_script_module' ) ) { + return; + } + $base_path = Package::get_installed_path() . 'build/search-blocks/store/'; + $shim_path = $base_path . 'i18n-shim.js'; + $asset_file = $base_path . 'i18n-shim.asset.php'; + if ( ! file_exists( $shim_path ) || ! file_exists( $asset_file ) ) { + return; + } + $asset = require $asset_file; + $shim_url = plugins_url( 'i18n-shim.js', $shim_path ); + wp_register_script_module( + '@wordpress/i18n', + $shim_url, + array(), + $asset['version'] ?? false + ); + } + + /** + * Enqueue the classic `wp-i18n` script and emit an inline `setLocaleData()` + * call carrying the `jetpack-search-pkg` text domain's translations. This + * populates `window.wp.i18n` synchronously before any deferred script + * module evaluates, so the i18n shim's exports resolve to translated + * strings on first paint. + * + * Translations are pulled from PHP's already-loaded gettext entries via + * `get_translations_for_domain()`; if the domain isn't loaded (e.g. en_US + * site with no .mo file), we still enqueue `wp-i18n` so the shim's + * fallbacks work and `__()` returns the source string unchanged. + */ + public static function enqueue_i18n_runtime() { + if ( ! function_exists( 'wp_enqueue_script' ) ) { + return; + } + wp_enqueue_script( 'wp-i18n' ); + + $locale_data = static::collect_locale_data( 'jetpack-search-pkg' ); + if ( null === $locale_data ) { + return; + } + // JSON_HEX_* flags neutralize a `` (or quote/ampersand) appearing + // inside a translation, which would otherwise close the inline tag and + // turn an inert string into HTML. Same hardening WP core uses when it + // inlines `setLocaleData` from `wp_set_script_translations()`. + $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; + $payload = wp_json_encode( $locale_data, $json_flags ); + if ( false === $payload ) { + return; + } + // IIFE so `$payload` is parsed once into a local variable; the guard + // avoids a TypeError if `wp.i18n` somehow failed to load before this + // inline runs (e.g. another plugin de-registered the wp-i18n handle). + wp_add_inline_script( + 'wp-i18n', + sprintf( + '( function() { var d = %s; if ( window.wp && window.wp.i18n ) { wp.i18n.setLocaleData( d, %s ); } } )();', + $payload, + wp_json_encode( 'jetpack-search-pkg', $json_flags ) + ), + 'after' + ); + } + + /** + * Build the `setLocaleData()` payload for a text domain by walking PHP's + * already-loaded gettext entries. Returns the same `{ "": header, msgid: + * [translations] }` shape that `wp_set_script_translations()` would inline + * — letting us seed the wp-i18n runtime without depending on per-handle + * .json files (which Jetpack's translation pipeline doesn't generate for + * script-module bundles today). + * + * Returns null when the domain has no translations (en_US sites, missing + * .mo files). Callers skip the inline emission in that case so the page + * still renders English source strings unchanged. + * + * @param string $domain Text domain. + * @return array|null + */ + protected static function collect_locale_data( string $domain ): ?array { + if ( ! function_exists( 'is_textdomain_loaded' ) || ! is_textdomain_loaded( $domain ) ) { + return null; + } + if ( ! function_exists( 'get_translations_for_domain' ) ) { + return null; + } + $translations = get_translations_for_domain( $domain ); + if ( empty( $translations->entries ) ) { + return null; + } + $plural_forms = $translations->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);'; + $locale_data = array( + '' => array( + 'domain' => $domain, + 'lang' => function_exists( 'determine_locale' ) ? determine_locale() : '', + 'plural-forms' => $plural_forms, + ), + ); + foreach ( $translations->entries as $entry ) { + // `entries` is keyed by msgctxt-prefixed msgid; `Translation_Entry::key()` + // reproduces the same key shape `wp.i18n` expects on the JS side. + $key = $entry->key(); + $locale_data[ $key ] = $entry->translations; + } + return $locale_data; + } + /** * Add a "Jetpack Search" block category so our blocks appear under that * heading in the inserter instead of "Uncategorized". @@ -653,15 +775,6 @@ public static function build_initial_state() { // string on first paint; `actions.search()` keeps it in lockstep // with `isLoading` / `totalResults` via `computeResultsCountText`. 'resultsCountText' => $is_initial_loading ? $searching_text : '', - - // Translated view-bundle strings. The Interactivity API view bundle - // can't import @wordpress/i18n (only @wordpress/interactivity is - // registered as a script module), so any JS-produced text is seeded - // here and read via state.strings.* on the client. Both _n() forms - // are seeded so the client can pick based on the live totalResults - // without a round trip; languages with more than two plural forms - // degrade to "plural for all count > 1" as an accepted tradeoff. - 'strings' => static::build_initial_strings(), ); } @@ -777,31 +890,6 @@ public static function emit_filter_wrapper_context( string $filter_key, bool $sh ); } - /** - * Seed translated view-bundle strings for the Interactivity API store. - * - * @return array - */ - protected static function build_initial_strings(): array { - if ( ! function_exists( '__' ) || ! function_exists( '_n' ) ) { - return array( - 'searching' => 'Searching…', - 'resultsCountSingle' => 'Found %d result', - 'resultsCountPlural' => 'Found %d results', - 'removeFilter' => 'Remove %s', - ); - } - return array( - 'searching' => __( 'Searching…', 'jetpack-search-pkg' ), - /* translators: %d: number of results. */ - 'resultsCountSingle' => _n( 'Found %d result', 'Found %d results', 1, 'jetpack-search-pkg' ), - /* translators: %d: number of results. */ - 'resultsCountPlural' => _n( 'Found %d result', 'Found %d results', 2, 'jetpack-search-pkg' ), - /* translators: %s: filter label (e.g. "Category: News"). Announced by screen readers when focus lands on a filter pill's remove button. */ - 'removeFilter' => __( 'Remove %s', 'jetpack-search-pkg' ), - ); - } - /** * Parse the search query from the URL, reading whichever key * `get_search_param_name()` says is active for this request (`s` on diff --git a/projects/packages/search/src/search-blocks/store/i18n-shim.js b/projects/packages/search/src/search-blocks/store/i18n-shim.js new file mode 100644 index 000000000000..ffaac32bf83e --- /dev/null +++ b/projects/packages/search/src/search-blocks/store/i18n-shim.js @@ -0,0 +1,43 @@ +/** + * ESM shim that exposes the classic `wp-i18n` runtime as the + * `@wordpress/i18n` script module. + * + * The Interactivity API view bundle imports `@wordpress/i18n`; webpack + * externalizes that import as a script-module reference (see + * `requestToExternalModule` in `tools/webpack.blocks.config.js`); WP resolves + * the module ID to this file via `wp_register_script_module()` in + * `class-search-blocks.php`. The shim then re-exports the methods on + * `window.wp.i18n`, which the classic `wp-i18n` script populates synchronously + * before any deferred module evaluates. + * + * Translations land on `window.wp.i18n` via an inline `setLocaleData()` call + * emitted alongside `wp-i18n` in `Search_Blocks::enqueue_i18n_runtime()`, so by + * the time this module's exports are read, calls like `__( 'Foo', 'jetpack-search-pkg' )` + * resolve through the page locale's translations. If `wp-i18n` was not enqueued + * (e.g. the page renders no Jetpack Search blocks), the identity fallbacks keep + * the page rendering English source strings instead of throwing. + */ + +const i18n = ( typeof window !== 'undefined' && window.wp && window.wp.i18n ) || {}; + +const identity = s => s; +const pluralIdentity = ( single, plural, count ) => ( count === 1 ? single : plural ); + +// Minimal `sprintf` substitute supporting the `%s`, `%d`, `%1$s`, `%2$d` forms +// used by the search blocks. Only invoked when `wp.i18n.sprintf` is missing. +const sprintfFallback = ( fmt, ...args ) => { + let i = 0; + return String( fmt ).replace( /%(?:(\d+)\$)?[sdf]/g, ( _, idx ) => { + const j = idx ? parseInt( idx, 10 ) - 1 : i++; + return String( args[ j ] ); + } ); +}; + +export const __ = i18n.__ || identity; +export const _x = i18n._x || identity; +export const _n = i18n._n || pluralIdentity; +export const _nx = i18n._nx || pluralIdentity; +export const sprintf = i18n.sprintf || sprintfFallback; +export const isRTL = i18n.isRTL || ( () => false ); +export const hasTranslation = i18n.hasTranslation || ( () => false ); +export const setLocaleData = i18n.setLocaleData || ( () => undefined ); diff --git a/projects/packages/search/src/search-blocks/store/index.js b/projects/packages/search/src/search-blocks/store/index.js index effc1487530b..a8e9d0b8e82d 100644 --- a/projects/packages/search/src/search-blocks/store/index.js +++ b/projects/packages/search/src/search-blocks/store/index.js @@ -1,3 +1,4 @@ +import { __, _n, sprintf } from '@wordpress/i18n'; import { store, getContext, @@ -174,17 +175,17 @@ let searchToken = 0; */ export function computeResultsCountText( liveState ) { if ( liveState.isLoading ) { - return liveState.strings?.searching ?? 'Searching…'; + return __( 'Searching…', 'jetpack-search-pkg' ); } const total = liveState.totalResults; if ( total === 0 ) { return ''; } - const template = - total === 1 - ? liveState.strings?.resultsCountSingle ?? 'Found %d result' - : liveState.strings?.resultsCountPlural ?? 'Found %d results'; - return template.replace( '%d', total ); + return sprintf( + /* translators: %d: number of results. */ + _n( 'Found %d result', 'Found %d results', total, 'jetpack-search-pkg' ), + total + ); } /** diff --git a/projects/packages/search/src/search-blocks/store/result-utils.js b/projects/packages/search/src/search-blocks/store/result-utils.js index a51fbeaa864e..d5cfdad3d7f6 100644 --- a/projects/packages/search/src/search-blocks/store/result-utils.js +++ b/projects/packages/search/src/search-blocks/store/result-utils.js @@ -3,13 +3,12 @@ * Interactivity API templates consume. Extracted from store/index.js so they * can be unit-tested without bootstrapping the IAPI runtime. * - * Note: this module is loaded inside the Interactivity API view bundle, where - * `@wordpress/i18n` is not available — the IAPI runtime rejects WP-script - * imports. Strings here are deliberately untranslated; the editor preview - * (edit.js) composes its own localized versions via wp.i18n. Localizing the - * frontend strings is tracked separately so it lands once the IAPI build - * pipeline gains wp.i18n support. + * `@wordpress/i18n` resolves through the package's i18n shim (registered as + * the `@wordpress/i18n` script module by `Search_Blocks::register_i18n_module()`), + * which re-exports `window.wp.i18n`. See `tools/webpack.blocks.config.js`'s + * `requestToExternalModule` for the build-side wiring. */ +import { __, _n, sprintf } from '@wordpress/i18n'; const HTTP_SCHEME_PATTERN = /^https?:\/\//i; const ANY_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i; @@ -307,10 +306,6 @@ function normalizeProductFields( fields ) { /** * Compose the screen-reader announcement for the rating row. * - * Strings are intentionally untranslated — see the file-level comment. - * Localization is tracked as a follow-up that needs IAPI build support - * for `@wordpress/i18n`. - * * @param {number} rating - 0–5 average rating. * @param {number} reviewCount - Number of reviews backing the rating. * @return {string} Aria-label, or '' when the row should be hidden. @@ -320,12 +315,23 @@ function buildRatingAriaLabel( rating, reviewCount ) { return ''; } if ( reviewCount <= 0 ) { - return `${ rating } out of 5 stars`; - } - if ( reviewCount === 1 ) { - return `${ rating } out of 5 stars based on 1 review`; + return sprintf( + /* translators: %s: average product rating (e.g. "4.5"). */ + __( '%s out of 5 stars', 'jetpack-search-pkg' ), + rating + ); } - return `${ rating } out of 5 stars based on ${ reviewCount } reviews`; + return sprintf( + /* translators: %1$s: average product rating; %2$d: number of reviews. */ + _n( + '%1$s out of 5 stars based on %2$d review', + '%1$s out of 5 stars based on %2$d reviews', + reviewCount, + 'jetpack-search-pkg' + ), + rating, + reviewCount + ); } /** diff --git a/projects/packages/search/tests/js/search-blocks/active-filters-view.test.js b/projects/packages/search/tests/js/search-blocks/active-filters-view.test.js index b0b7747d62e1..472e05981f46 100644 --- a/projects/packages/search/tests/js/search-blocks/active-filters-view.test.js +++ b/projects/packages/search/tests/js/search-blocks/active-filters-view.test.js @@ -41,7 +41,6 @@ describe( 'active-filters view store — activePills label resolution', () => { captured.state.activeFilters = {}; captured.state.aggregations = {}; captured.state.filterConfigs = {}; - captured.state.strings = { removeFilter: 'Remove %s' }; contextRef.current = { pill: null }; } ); @@ -101,11 +100,13 @@ describe( 'active-filters view store — activePills label resolution', () => { expect( captured.state.activePills[ 0 ].label ).toBe( 'Post Type: Media file' ); } ); - it( 'uses removeFilter format string for the aria-label', () => { - captured.state.strings.removeFilter = 'Remove %s filter'; + it( 'composes the aria-label via @wordpress/i18n with the resolved label', () => { + // Strings flow through `__()` + `sprintf()` from `@wordpress/i18n`; + // in the Jest env that's the unmocked npm package, so the source + // "Remove %s" string round-trips with the label substituted in. captured.state.activeFilters = { category: [ 'news' ] }; captured.state.aggregations = { category: { buckets: [ { key: 'news/News' } ] } }; captured.state.filterConfigs = { category: { label: 'Category', valueLabels: {} } }; - expect( captured.state.activePills[ 0 ].ariaLabel ).toBe( 'Remove Category: News filter' ); + expect( captured.state.activePills[ 0 ].ariaLabel ).toBe( 'Remove Category: News' ); } ); } ); diff --git a/projects/packages/search/tests/js/search-blocks/store.test.js b/projects/packages/search/tests/js/search-blocks/store.test.js index 8caf7438ed15..c03292e51223 100644 --- a/projects/packages/search/tests/js/search-blocks/store.test.js +++ b/projects/packages/search/tests/js/search-blocks/store.test.js @@ -430,11 +430,6 @@ describe( 'store getters', () => { activeFilters: {}, aggregations: {}, sortOrder: 'relevance', - strings: { - searching: 'Looking…', - resultsCountSingle: 'Found %d item', - resultsCountPlural: 'Found %d items', - }, } ); } ); @@ -445,15 +440,20 @@ describe( 'store getters', () => { // resolve server-side. Exercising `computeResultsCountText` directly // keeps the formatting contract under test without driving the full // fetch lifecycle. + // + // Strings come from `@wordpress/i18n` via the package's i18n shim; + // in the Jest env that resolves to the unmocked npm package, so + // `__()` returns the source string and `_n()` picks the singular/ + // plural form by `count`. state.isLoading = true; - expect( computeResultsCountText( state ) ).toBe( 'Looking…' ); + expect( computeResultsCountText( state ) ).toBe( 'Searching…' ); state.isLoading = false; state.totalResults = 1; - expect( computeResultsCountText( state ) ).toBe( 'Found 1 item' ); + expect( computeResultsCountText( state ) ).toBe( 'Found 1 result' ); state.totalResults = 3; - expect( computeResultsCountText( state ) ).toBe( 'Found 3 items' ); + expect( computeResultsCountText( state ) ).toBe( 'Found 3 results' ); state.totalResults = 0; expect( computeResultsCountText( state ) ).toBe( '' ); diff --git a/projects/packages/search/tests/php/Search_Blocks_Test.php b/projects/packages/search/tests/php/Search_Blocks_Test.php index f34b49affe2b..34bb761aa15c 100644 --- a/projects/packages/search/tests/php/Search_Blocks_Test.php +++ b/projects/packages/search/tests/php/Search_Blocks_Test.php @@ -47,7 +47,6 @@ public function test_build_initial_state_shape() { 'isLoading', 'isLoadingMore', 'hasError', - 'strings', ); $this->assertTrue( class_exists( Search_Blocks::class ) ); @@ -57,26 +56,6 @@ public function test_build_initial_state_shape() { } } - /** - * View-bundle strings seeded here are the sole i18n channel for the - * Interactivity API bundle — it can't import @wordpress/i18n. Both - * plural forms must be seeded so the client can pick based on the - * live totalResults, and the format string must carry a `%d` token. - */ - public function test_build_initial_state_seeds_translated_strings() { - $state = Search_Blocks::build_initial_state(); - $this->assertArrayHasKey( 'strings', $state ); - $strings = $state['strings']; - $this->assertArrayHasKey( 'searching', $strings ); - $this->assertArrayHasKey( 'resultsCountSingle', $strings ); - $this->assertArrayHasKey( 'resultsCountPlural', $strings ); - $this->assertArrayHasKey( 'removeFilter', $strings ); - $this->assertNotSame( '', $strings['searching'] ); - $this->assertStringContainsString( '%d', $strings['resultsCountSingle'] ); - $this->assertStringContainsString( '%d', $strings['resultsCountPlural'] ); - $this->assertStringContainsString( '%s', $strings['removeFilter'] ); - } - /** * A known `orderby` in the URL must seed sortOrder so SSR pre-fetches * the correct ordering. Values must stay aligned with the UI keys in diff --git a/projects/packages/search/tools/webpack.blocks.config.js b/projects/packages/search/tools/webpack.blocks.config.js index d75b020a1fb5..5bc2b20d3791 100644 --- a/projects/packages/search/tools/webpack.blocks.config.js +++ b/projects/packages/search/tools/webpack.blocks.config.js @@ -31,11 +31,20 @@ const blockViewEntries = readBlockViewEntries(); const storeIndexPath = path.join( __dirname, '../src/search-blocks/store/index.js' ); const storeEntries = fs.existsSync( storeIndexPath ) ? { 'store/index': storeIndexPath } : {}; +// The i18n shim is the runtime the `@wordpress/i18n` import resolves to in the +// IAPI module bundle (see `requestToExternalModule` below). It re-exports +// `window.wp.i18n` so the front-end view bundle can use `__()` / `_n()` / +// `sprintf()` natively, with translations seeded by the inline `setLocaleData` +// call emitted in `Search_Blocks::enqueue_i18n_runtime()`. +const i18nShimPath = path.join( __dirname, '../src/search-blocks/store/i18n-shim.js' ); +const i18nShimEntry = fs.existsSync( i18nShimPath ) ? { 'store/i18n-shim': i18nShimPath } : {}; + module.exports = { mode: jetpackWebpackConfig.mode, devtool: jetpackWebpackConfig.devtool, entry: { ...storeEntries, + ...i18nShimEntry, ...blockViewEntries, }, output: { @@ -83,7 +92,29 @@ module.exports = { }, plugins: [ ...jetpackWebpackConfig.StandardPlugins( { - DependencyExtractionPlugin: { injectPolyfill: false }, + DependencyExtractionPlugin: { + injectPolyfill: false, + // Externalize `@wordpress/i18n` to a script-module reference so + // view bundles can `import { __, _n, sprintf } from '@wordpress/i18n'` + // natively. The `@wordpress/i18n` script module is registered + // by `Search_Blocks::register_i18n_module()` and points to + // `store/i18n-shim.js`, which re-exports `window.wp.i18n`. + // Without this, DEP's default `requestToExternalModule` throws + // "Attempted to use WordPress script in a module" because core + // only registers `@wordpress/interactivity` (and a11y / router) + // as script modules today. + requestToExternalModule( request ) { + if ( request === '@wordpress/i18n' ) { + // `module` (not `import`) so webpack emits a hoisted + // static `import * from '@wordpress/i18n'` instead of + // a dynamic `import()` Promise. Same pattern DEP uses + // for `@wordpress/interactivity` itself: we need + // synchronous bindings for `__()` / `_n()` / `sprintf()` + // calls in the view bundle's pure helpers. + return 'module @wordpress/i18n'; + } + }, + }, // I18nLoaderPlugin tries to inject @wordpress/jp-i18n-loader as an // import, which isn't supported by the DependencyExtractionPlugin // in ESM/module output mode. Disable for this build. From da51c7c73efcd5c3ce03fee9f5cb149125b78528 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Wed, 6 May 2026 15:56:31 +1200 Subject: [PATCH 2/3] Address review: docblock, test coverage, explicit DEP fall-through, changelog Type - Fix the inverted "first-wins" claim in register_i18n_module()'s docblock: ours wins because we register first; flag the maintenance hazard if WP core later ships a native @wordpress/i18n module (raised by both Copilot and claude[bot]). - Extract build_locale_data_payload() out of collect_locale_data() and cover it with two unit tests (Jed shape from a fake Translations, default Plural-Forms fallback when the header is missing). Closes the gap both reviewers flagged for the new PHP code. - Make webpack.blocks.config.js's requestToExternalModule explicit: early-return for non-i18n requests so the DEP fall-through is visible at a glance (claude[bot] nit). - Update changelog Type from "changed" to "fixed" to match the SEARCH-168 framing (Copilot nit). --- ...echo-search-168-localize-rating-aria-label | 4 +- .../src/search-blocks/class-search-blocks.php | 31 +++++++++- .../search/tests/php/Search_Blocks_Test.php | 61 +++++++++++++++++++ .../search/tools/webpack.blocks.config.js | 21 ++++--- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label b/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label index c0d27d1355c7..8388616980f0 100644 --- a/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label +++ b/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label @@ -1,4 +1,4 @@ Significance: patch -Type: changed +Type: fixed -Search Blocks: enable @wordpress/i18n imports in the Interactivity API view bundle so block strings (rating aria-label, results count, filter pill remove) translate on the front end via the page locale. +Search Blocks: localize the front-end product-rating aria-label (and other Interactivity API view-bundle strings) via @wordpress/i18n on the page locale, so screen readers no longer hear English on translated stores. diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index d2aee2e55537..ea9f20fbf06b 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -177,9 +177,16 @@ public static function enqueue_editor_assets() { * WP core only registers `@wordpress/interactivity` (and recently a11y / * router) as script modules, so importing `@wordpress/i18n` from a view * bundle would otherwise fail at load time with an unresolved import. - * Registering our own module under the canonical `@wordpress/i18n` ID - * means the resolver finds it first, and any future core registration - * supersedes ours via `wp_register_script_module`'s first-wins behavior. + * + * `wp_register_script_module()` is first-registered-wins: a second call + * for the same ID is silently dropped. We hook on `init` priority 10, so + * our shim wins for the duration of this workaround. **Maintenance hazard:** + * if WP core later registers `@wordpress/i18n` natively at the same or + * higher priority, our registration would still win and quietly mask the + * native module — at which point this method should be removed (or + * deregister-then-register) to let core take over. Until then, the shim + * delegating to `window.wp.i18n` produces equivalent behavior to a native + * registration, so the dual-source state is functionally safe. */ public static function register_i18n_module() { if ( ! function_exists( 'wp_register_script_module' ) ) { @@ -272,6 +279,24 @@ protected static function collect_locale_data( string $domain ): ?array { if ( empty( $translations->entries ) ) { return null; } + return static::build_locale_data_payload( $translations, $domain ); + } + + /** + * Pure data-shape transform from a Pomo `Translations` object to the Jed + * locale-data shape `wp.i18n.setLocaleData()` expects. + * + * Split out from `collect_locale_data()` so it can be unit-tested without + * standing up the WP textdomain registry: callers can construct a fake + * `Translations` (anything with `->headers` and `->entries`, where each + * entry has `->key()` and `->translations`) and assert the shape directly. + * + * @param object $translations Pomo `Translations` (or compatible duck type) + * with `headers` array and iterable `entries`. + * @param string $domain Text domain. + * @return array + */ + public static function build_locale_data_payload( $translations, string $domain ): array { $plural_forms = $translations->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);'; $locale_data = array( '' => array( diff --git a/projects/packages/search/tests/php/Search_Blocks_Test.php b/projects/packages/search/tests/php/Search_Blocks_Test.php index 34bb761aa15c..b3fe27e4ce6f 100644 --- a/projects/packages/search/tests/php/Search_Blocks_Test.php +++ b/projects/packages/search/tests/php/Search_Blocks_Test.php @@ -56,6 +56,67 @@ public function test_build_initial_state_shape() { } } + /** + * `build_locale_data_payload()` must return the Jed-shaped + * `{ "": header, msgid: [translations] }` map that + * `wp.i18n.setLocaleData()` consumes. This is the only non-trivial + * data transform in the i18n-runtime path; a regression here would + * silently produce untranslated strings on non-English locales with + * no other test failure. + */ + public function test_build_locale_data_payload_shapes_translations_for_setLocaleData() { + $translations = new \stdClass(); + $translations->headers = array( 'Plural-Forms' => 'nplurals=2; plural=(n != 1);' ); + // Use anonymous classes for the entries so `key()` is a real method + // — `Translation_Entry::key()` returns the msgctxt-prefixed msgid, + // which is what the JS side uses to look up translations. + $singular_entry = new class() { + public $key = 'Searching…'; + public $translations = array( 'Recherche…' ); + public function key() { + return $this->key; + } + }; + $plural_entry = new class() { + public $key = "Found %d result\u{0000}Found %d results"; + public $translations = array( + '%d résultat trouvé', + '%d résultats trouvés', + ); + public function key() { + return $this->key; + } + }; + $translations->entries = array( + $singular_entry->key => $singular_entry, + $plural_entry->key => $plural_entry, + ); + + $payload = Search_Blocks::build_locale_data_payload( $translations, 'jetpack-search-pkg' ); + + $this->assertArrayHasKey( '', $payload ); + $this->assertSame( 'jetpack-search-pkg', $payload['']['domain'] ); + $this->assertSame( 'nplurals=2; plural=(n != 1);', $payload['']['plural-forms'] ); + $this->assertSame( array( 'Recherche…' ), $payload['Searching…'] ); + $this->assertSame( + array( '%d résultat trouvé', '%d résultats trouvés' ), + $payload["Found %d result\u{0000}Found %d results"] + ); + } + + /** + * Headers without an explicit `Plural-Forms` entry must fall back to the + * Western 2-form rule so plural translations still pick the right index + * even on translation files that omit the header. + */ + public function test_build_locale_data_payload_supplies_default_plural_forms() { + $translations = new \stdClass(); + $translations->headers = array(); + $translations->entries = array(); + $payload = Search_Blocks::build_locale_data_payload( $translations, 'jetpack-search-pkg' ); + $this->assertSame( 'nplurals=2; plural=(n != 1);', $payload['']['plural-forms'] ); + } + /** * A known `orderby` in the URL must seed sortOrder so SSR pre-fetches * the correct ordering. Values must stay aligned with the UI keys in diff --git a/projects/packages/search/tools/webpack.blocks.config.js b/projects/packages/search/tools/webpack.blocks.config.js index 5bc2b20d3791..cb4089befe8e 100644 --- a/projects/packages/search/tools/webpack.blocks.config.js +++ b/projects/packages/search/tools/webpack.blocks.config.js @@ -104,15 +104,20 @@ module.exports = { // only registers `@wordpress/interactivity` (and a11y / router) // as script modules today. requestToExternalModule( request ) { - if ( request === '@wordpress/i18n' ) { - // `module` (not `import`) so webpack emits a hoisted - // static `import * from '@wordpress/i18n'` instead of - // a dynamic `import()` Promise. Same pattern DEP uses - // for `@wordpress/interactivity` itself: we need - // synchronous bindings for `__()` / `_n()` / `sprintf()` - // calls in the view bundle's pure helpers. - return 'module @wordpress/i18n'; + if ( request !== '@wordpress/i18n' ) { + // Returning undefined here lets DEP fall through to its + // own defaults for every other `@wordpress/*` request, + // which is what we want — only `@wordpress/i18n` needs + // our custom module-mode handling today. + return; } + // `module` (not `import`) so webpack emits a hoisted + // static `import * from '@wordpress/i18n'` instead of + // a dynamic `import()` Promise. Same pattern DEP uses + // for `@wordpress/interactivity` itself: we need + // synchronous bindings for `__()` / `_n()` / `sprintf()` + // calls in the view bundle's pure helpers. + return 'module @wordpress/i18n'; }, }, // I18nLoaderPlugin tries to inject @wordpress/jp-i18n-loader as an From 248bef12d7718da54d3562fea6d6e2515916aa17 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 7 May 2026 10:28:30 +1200 Subject: [PATCH 3/3] Search Blocks: route i18n through wp-jp-i18n-loader instead of inline setLocaleData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of SEARCH-168 inlined a custom `setLocaleData()` payload from PHP — duplicating what `Automattic\Jetpack\Assets`' `wp-jp-i18n-loader` script + `@wordpress/jp-i18n-loader`'s `downloadI18n()` already do for classic-script bundles. Switch to the established pipeline: - `enqueue_i18n_runtime()` now just enqueues `wp-i18n` and `wp-jp-i18n-loader` (registered by `Automattic\Jetpack\Assets::wp_default_scripts_hook` with the locale + domainPath state already populated). Drop the bespoke `collect_locale_data()` / `build_locale_data_payload()` PHP helpers and their PHPUnit cases. - `store/i18n-bootstrap.js` calls `wp.jpI18nLoader.downloadI18n( bundlePath, 'jetpack-search-pkg', 'plugin' )` from each entry that has its own translatable strings (`store/index.js` and `active-filters/view.js`). jp-i18n-loader hashes the bundle path against `state.domainPaths` to match Jetpack's per-handle .json filenames, then `setLocaleData()`s the result into the live `wp.i18n` runtime — same path `@automattic/i18n-loader-webpack-plugin` injects into classic-script builds. - The shim, `register_i18n_module()`, and the `'module @wordpress/i18n'` external stay: webpack still rewrites `import '@wordpress/i18n'` to a static script-module reference under the canonical `@wordpress/i18n` ID, and the shim re-exports `window.wp.i18n` by reference so calls go through the same instance jp-i18n-loader writes translations into. `var wp.i18n` externalization was rejected during this refactor — DEP records the externalized value (not the original request) in module mode, which leaks `wp.i18n` into the .asset.php `dependencies` array; WP's script-module registry then silently refuses to enqueue any view bundle whose declared deps reference an unresolvable module ID, breaking every block that uses `__()`. --- .../blocks/active-filters/view.js | 7 + .../src/search-blocks/class-search-blocks.php | 132 +++--------------- .../src/search-blocks/store/i18n-bootstrap.js | 50 +++++++ .../src/search-blocks/store/i18n-shim.js | 23 +-- .../search/src/search-blocks/store/index.js | 9 ++ .../src/search-blocks/store/result-utils.js | 10 +- .../search/tests/php/Search_Blocks_Test.php | 61 -------- .../search/tools/webpack.blocks.config.js | 53 ++++--- 8 files changed, 130 insertions(+), 215 deletions(-) create mode 100644 projects/packages/search/src/search-blocks/store/i18n-bootstrap.js diff --git a/projects/packages/search/src/search-blocks/blocks/active-filters/view.js b/projects/packages/search/src/search-blocks/blocks/active-filters/view.js index 5157abc8dd3b..20b3d9f60c95 100644 --- a/projects/packages/search/src/search-blocks/blocks/active-filters/view.js +++ b/projects/packages/search/src/search-blocks/blocks/active-filters/view.js @@ -3,8 +3,15 @@ import { store, getContext } from '@wordpress/interactivity'; import { formatDateBucketLabel } from '../../store/api'; import '../../store'; import { bucketLabel, bucketValue } from '../../store/bucket-key'; +import { bootstrapI18n } from '../../store/i18n-bootstrap'; import './style.scss'; +// Fetch translations for *this* bundle. The store module bootstraps for +// its own filename, so result-utils strings load via that path; this one +// covers the `__('Remove %s', ...)` call below, which lives in the +// active-filters bundle's .json file rather than the store's. +bootstrapI18n( 'active-filters.js' ); + const NAMESPACE = 'jetpack-search'; /** diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index ea9f20fbf06b..b2140ee7b9bc 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -171,22 +171,19 @@ public static function enqueue_editor_assets() { /** * Register `@wordpress/i18n` as a script module pointing at the package's - * shim. The shim re-exports `window.wp.i18n`, letting the IAPI view bundle - * use `import { __, _n, sprintf } from '@wordpress/i18n'` natively. + * shim. The shim re-exports the live functions on `window.wp.i18n`, + * letting the IAPI view bundle use `import { __, _n, sprintf } from + * '@wordpress/i18n'` natively while sharing the same translation runtime + * the classic `wp-i18n` script provides — and that + * `wp.jpI18nLoader.downloadI18n()` populates with per-bundle translations. * * WP core only registers `@wordpress/interactivity` (and recently a11y / - * router) as script modules, so importing `@wordpress/i18n` from a view - * bundle would otherwise fail at load time with an unresolved import. - * - * `wp_register_script_module()` is first-registered-wins: a second call - * for the same ID is silently dropped. We hook on `init` priority 10, so - * our shim wins for the duration of this workaround. **Maintenance hazard:** - * if WP core later registers `@wordpress/i18n` natively at the same or - * higher priority, our registration would still win and quietly mask the - * native module — at which point this method should be removed (or - * deregister-then-register) to let core take over. Until then, the shim - * delegating to `window.wp.i18n` produces equivalent behavior to a native - * registration, so the dual-source state is functionally safe. + * router) as script modules, so the canonical `@wordpress/i18n` ID is + * unclaimed today; we register the shim under that ID so any future + * cross-package converger can find it. `wp_register_script_module()` is + * first-registered-wins, so if WP core later ships a native `@wordpress/i18n` + * module at the same priority this method should be removed (or + * deregister-then-register) to let core take over. */ public static function register_i18n_module() { if ( ! function_exists( 'wp_register_script_module' ) ) { @@ -209,109 +206,22 @@ public static function register_i18n_module() { } /** - * Enqueue the classic `wp-i18n` script and emit an inline `setLocaleData()` - * call carrying the `jetpack-search-pkg` text domain's translations. This - * populates `window.wp.i18n` synchronously before any deferred script - * module evaluates, so the i18n shim's exports resolve to translated - * strings on first paint. - * - * Translations are pulled from PHP's already-loaded gettext entries via - * `get_translations_for_domain()`; if the domain isn't loaded (e.g. en_US - * site with no .mo file), we still enqueue `wp-i18n` so the shim's - * fallbacks work and `__()` returns the source string unchanged. + * Enqueue the classic `wp-i18n` script and the `wp-jp-i18n-loader` runtime + * (registered by `Automattic\Jetpack\Assets`) on every page. The shim + * registered by `register_i18n_module()` re-exports `wp.i18n` from the + * classic script, and `wp.jpI18nLoader.downloadI18n( bundlePath, + * 'jetpack-search-pkg', 'plugin' )` (called from `store/i18n-bootstrap.js`) + * fetches and `setLocaleData()`s the per-bundle translation .json — the + * same path `@automattic/i18n-loader-webpack-plugin` injects into + * classic-script bundles. We're just opting our front-end view bundles + * into the existing pipeline. */ public static function enqueue_i18n_runtime() { if ( ! function_exists( 'wp_enqueue_script' ) ) { return; } wp_enqueue_script( 'wp-i18n' ); - - $locale_data = static::collect_locale_data( 'jetpack-search-pkg' ); - if ( null === $locale_data ) { - return; - } - // JSON_HEX_* flags neutralize a `` (or quote/ampersand) appearing - // inside a translation, which would otherwise close the inline tag and - // turn an inert string into HTML. Same hardening WP core uses when it - // inlines `setLocaleData` from `wp_set_script_translations()`. - $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; - $payload = wp_json_encode( $locale_data, $json_flags ); - if ( false === $payload ) { - return; - } - // IIFE so `$payload` is parsed once into a local variable; the guard - // avoids a TypeError if `wp.i18n` somehow failed to load before this - // inline runs (e.g. another plugin de-registered the wp-i18n handle). - wp_add_inline_script( - 'wp-i18n', - sprintf( - '( function() { var d = %s; if ( window.wp && window.wp.i18n ) { wp.i18n.setLocaleData( d, %s ); } } )();', - $payload, - wp_json_encode( 'jetpack-search-pkg', $json_flags ) - ), - 'after' - ); - } - - /** - * Build the `setLocaleData()` payload for a text domain by walking PHP's - * already-loaded gettext entries. Returns the same `{ "": header, msgid: - * [translations] }` shape that `wp_set_script_translations()` would inline - * — letting us seed the wp-i18n runtime without depending on per-handle - * .json files (which Jetpack's translation pipeline doesn't generate for - * script-module bundles today). - * - * Returns null when the domain has no translations (en_US sites, missing - * .mo files). Callers skip the inline emission in that case so the page - * still renders English source strings unchanged. - * - * @param string $domain Text domain. - * @return array|null - */ - protected static function collect_locale_data( string $domain ): ?array { - if ( ! function_exists( 'is_textdomain_loaded' ) || ! is_textdomain_loaded( $domain ) ) { - return null; - } - if ( ! function_exists( 'get_translations_for_domain' ) ) { - return null; - } - $translations = get_translations_for_domain( $domain ); - if ( empty( $translations->entries ) ) { - return null; - } - return static::build_locale_data_payload( $translations, $domain ); - } - - /** - * Pure data-shape transform from a Pomo `Translations` object to the Jed - * locale-data shape `wp.i18n.setLocaleData()` expects. - * - * Split out from `collect_locale_data()` so it can be unit-tested without - * standing up the WP textdomain registry: callers can construct a fake - * `Translations` (anything with `->headers` and `->entries`, where each - * entry has `->key()` and `->translations`) and assert the shape directly. - * - * @param object $translations Pomo `Translations` (or compatible duck type) - * with `headers` array and iterable `entries`. - * @param string $domain Text domain. - * @return array - */ - public static function build_locale_data_payload( $translations, string $domain ): array { - $plural_forms = $translations->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);'; - $locale_data = array( - '' => array( - 'domain' => $domain, - 'lang' => function_exists( 'determine_locale' ) ? determine_locale() : '', - 'plural-forms' => $plural_forms, - ), - ); - foreach ( $translations->entries as $entry ) { - // `entries` is keyed by msgctxt-prefixed msgid; `Translation_Entry::key()` - // reproduces the same key shape `wp.i18n` expects on the JS side. - $key = $entry->key(); - $locale_data[ $key ] = $entry->translations; - } - return $locale_data; + wp_enqueue_script( 'wp-jp-i18n-loader' ); } /** diff --git a/projects/packages/search/src/search-blocks/store/i18n-bootstrap.js b/projects/packages/search/src/search-blocks/store/i18n-bootstrap.js new file mode 100644 index 000000000000..236471e6622e --- /dev/null +++ b/projects/packages/search/src/search-blocks/store/i18n-bootstrap.js @@ -0,0 +1,50 @@ +const TEXT_DOMAIN = 'jetpack-search-pkg'; +const BUILD_PREFIX = 'build/search-blocks/'; + +const seen = new Set(); + +/** + * Lazy-load the `jetpack-search-pkg` translation .json for one entry bundle + * and feed it into `wp.i18n.setLocaleData()`. + * + * Wraps the standard Jetpack runtime translation fetcher + * (`wp.jpI18nLoader.downloadI18n`, registered as the `wp-jp-i18n-loader` + * classic script by `Automattic\Jetpack\Assets`) — the same path + * `@automattic/i18n-loader-webpack-plugin` injects into classic-script + * bundles. Each entry that has its own `__()` / `_n()` calls invokes + * `bootstrapI18n( '' )` from its own module so the loader + * hashes the per-bundle path and fetches that bundle's translation file. + * + * Translations land asynchronously: deep-linked search pages render + * source strings on first paint and re-render with locale strings once + * the fetch resolves. Acceptable trade-off vs. inlining `setLocaleData()` + * because we route entirely through the existing pipeline. + * + * Idempotent — a second call for the same `bundleFilename` is a no-op. + * + * @param {string} bundleFilename - Filename of the calling entry relative to the package's + * `build/search-blocks/` output dir, e.g. `'store/index.js'` + * or `'active-filters.js'`. + * @return {void} + */ +export function bootstrapI18n( bundleFilename ) { + if ( typeof bundleFilename !== 'string' || seen.has( bundleFilename ) ) { + return; + } + seen.add( bundleFilename ); + + const loader = ( typeof window !== 'undefined' && window.wp && window.wp.jpI18nLoader ) || null; + if ( ! loader || typeof loader.downloadI18n !== 'function' ) { + return; + } + + // jp-i18n-loader prepends `state.domainPaths['jetpack-search-pkg']` (set + // by `Assets::alias_textdomain` to `jetpack_vendor/automattic/jetpack-search/`) + // and md5-hashes the result to match the .json filename Jetpack's + // translation pipeline produced for our textdomain. + const path = BUILD_PREFIX + bundleFilename; + + // Fire-and-forget. Failures (en_US default, missing .json, network) are + // benign — strings stay in the source language. + loader.downloadI18n( path, TEXT_DOMAIN, 'plugin' ).catch( () => undefined ); +} diff --git a/projects/packages/search/src/search-blocks/store/i18n-shim.js b/projects/packages/search/src/search-blocks/store/i18n-shim.js index ffaac32bf83e..43449bcbfe54 100644 --- a/projects/packages/search/src/search-blocks/store/i18n-shim.js +++ b/projects/packages/search/src/search-blocks/store/i18n-shim.js @@ -4,18 +4,19 @@ * * The Interactivity API view bundle imports `@wordpress/i18n`; webpack * externalizes that import as a script-module reference (see - * `requestToExternalModule` in `tools/webpack.blocks.config.js`); WP resolves - * the module ID to this file via `wp_register_script_module()` in - * `class-search-blocks.php`. The shim then re-exports the methods on - * `window.wp.i18n`, which the classic `wp-i18n` script populates synchronously - * before any deferred module evaluates. + * `requestToExternalModule` in `tools/webpack.blocks.config.js`); WP + * resolves the canonical `@wordpress/i18n` ID to this file via + * `wp_register_script_module()` in `class-search-blocks.php`. The shim + * re-exports the live functions on `window.wp.i18n`, which the classic + * `wp-i18n` script populates synchronously before any deferred module + * evaluates. * - * Translations land on `window.wp.i18n` via an inline `setLocaleData()` call - * emitted alongside `wp-i18n` in `Search_Blocks::enqueue_i18n_runtime()`, so by - * the time this module's exports are read, calls like `__( 'Foo', 'jetpack-search-pkg' )` - * resolve through the page locale's translations. If `wp-i18n` was not enqueued - * (e.g. the page renders no Jetpack Search blocks), the identity fallbacks keep - * the page rendering English source strings instead of throwing. + * Translations land on `window.wp.i18n` via `wp.jpI18nLoader.downloadI18n()` + * (the standard async fetcher kicked off by `store/i18n-bootstrap.js`), + * so non-English locales pick up translated strings on the next + * reactivity tick. If `wp-i18n` was not enqueued (e.g. the page renders + * no Jetpack Search blocks), the identity fallbacks below keep the page + * rendering English source strings instead of throwing. */ const i18n = ( typeof window !== 'undefined' && window.wp && window.wp.i18n ) || {}; diff --git a/projects/packages/search/src/search-blocks/store/index.js b/projects/packages/search/src/search-blocks/store/index.js index a8e9d0b8e82d..9a5b47697a67 100644 --- a/projects/packages/search/src/search-blocks/store/index.js +++ b/projects/packages/search/src/search-blocks/store/index.js @@ -6,6 +6,7 @@ import { } from '@wordpress/interactivity'; import { buildSearchUrl, formatDateBucketLabel } from './api'; import { bucketLabel, bucketValue } from './bucket-key'; +import { bootstrapI18n } from './i18n-bootstrap'; import { isEventInsidePopoverRoot } from './popover-events'; import { countActiveFilters, normalizeResult } from './result-utils'; import { @@ -15,6 +16,14 @@ import { } from './sort-menu-dom'; import { pushStateToUrl, readStateFromUrl } from './url-state'; +// Trigger the per-bundle translation fetch as soon as the store module +// loads. Every view-bundle entry imports the store, so this runs once per +// page; the bootstrap dedupes so repeat imports are harmless. View bundles +// with their own `__()`/`_n()` calls (e.g. active-filters/view.js) call +// `bootstrapI18n` with their own filename so each entry's per-bundle .json +// gets fetched. +bootstrapI18n( 'store/index.js' ); + const NAMESPACE = 'jetpack-search'; let initialized = false; diff --git a/projects/packages/search/src/search-blocks/store/result-utils.js b/projects/packages/search/src/search-blocks/store/result-utils.js index d5cfdad3d7f6..1b7bb90116d6 100644 --- a/projects/packages/search/src/search-blocks/store/result-utils.js +++ b/projects/packages/search/src/search-blocks/store/result-utils.js @@ -3,10 +3,12 @@ * Interactivity API templates consume. Extracted from store/index.js so they * can be unit-tested without bootstrapping the IAPI runtime. * - * `@wordpress/i18n` resolves through the package's i18n shim (registered as - * the `@wordpress/i18n` script module by `Search_Blocks::register_i18n_module()`), - * which re-exports `window.wp.i18n`. See `tools/webpack.blocks.config.js`'s - * `requestToExternalModule` for the build-side wiring. + * `@wordpress/i18n` calls go through DEP's `var wp.i18n` external (configured + * in `tools/webpack.blocks.config.js`), so the import below compiles to a + * `window.wp.i18n` global read at runtime — same convention DEP uses in + * classic-script bundles. Translations land via `wp.jpI18nLoader.downloadI18n` + * (kicked off by `store/i18n-bootstrap.js`), so non-English locales pick up + * translated strings on the next reactivity tick. */ import { __, _n, sprintf } from '@wordpress/i18n'; diff --git a/projects/packages/search/tests/php/Search_Blocks_Test.php b/projects/packages/search/tests/php/Search_Blocks_Test.php index b3fe27e4ce6f..34bb761aa15c 100644 --- a/projects/packages/search/tests/php/Search_Blocks_Test.php +++ b/projects/packages/search/tests/php/Search_Blocks_Test.php @@ -56,67 +56,6 @@ public function test_build_initial_state_shape() { } } - /** - * `build_locale_data_payload()` must return the Jed-shaped - * `{ "": header, msgid: [translations] }` map that - * `wp.i18n.setLocaleData()` consumes. This is the only non-trivial - * data transform in the i18n-runtime path; a regression here would - * silently produce untranslated strings on non-English locales with - * no other test failure. - */ - public function test_build_locale_data_payload_shapes_translations_for_setLocaleData() { - $translations = new \stdClass(); - $translations->headers = array( 'Plural-Forms' => 'nplurals=2; plural=(n != 1);' ); - // Use anonymous classes for the entries so `key()` is a real method - // — `Translation_Entry::key()` returns the msgctxt-prefixed msgid, - // which is what the JS side uses to look up translations. - $singular_entry = new class() { - public $key = 'Searching…'; - public $translations = array( 'Recherche…' ); - public function key() { - return $this->key; - } - }; - $plural_entry = new class() { - public $key = "Found %d result\u{0000}Found %d results"; - public $translations = array( - '%d résultat trouvé', - '%d résultats trouvés', - ); - public function key() { - return $this->key; - } - }; - $translations->entries = array( - $singular_entry->key => $singular_entry, - $plural_entry->key => $plural_entry, - ); - - $payload = Search_Blocks::build_locale_data_payload( $translations, 'jetpack-search-pkg' ); - - $this->assertArrayHasKey( '', $payload ); - $this->assertSame( 'jetpack-search-pkg', $payload['']['domain'] ); - $this->assertSame( 'nplurals=2; plural=(n != 1);', $payload['']['plural-forms'] ); - $this->assertSame( array( 'Recherche…' ), $payload['Searching…'] ); - $this->assertSame( - array( '%d résultat trouvé', '%d résultats trouvés' ), - $payload["Found %d result\u{0000}Found %d results"] - ); - } - - /** - * Headers without an explicit `Plural-Forms` entry must fall back to the - * Western 2-form rule so plural translations still pick the right index - * even on translation files that omit the header. - */ - public function test_build_locale_data_payload_supplies_default_plural_forms() { - $translations = new \stdClass(); - $translations->headers = array(); - $translations->entries = array(); - $payload = Search_Blocks::build_locale_data_payload( $translations, 'jetpack-search-pkg' ); - $this->assertSame( 'nplurals=2; plural=(n != 1);', $payload['']['plural-forms'] ); - } - /** * A known `orderby` in the URL must seed sortOrder so SSR pre-fetches * the correct ordering. Values must stay aligned with the UI keys in diff --git a/projects/packages/search/tools/webpack.blocks.config.js b/projects/packages/search/tools/webpack.blocks.config.js index cb4089befe8e..fa1248bd8ca6 100644 --- a/projects/packages/search/tools/webpack.blocks.config.js +++ b/projects/packages/search/tools/webpack.blocks.config.js @@ -31,11 +31,11 @@ const blockViewEntries = readBlockViewEntries(); const storeIndexPath = path.join( __dirname, '../src/search-blocks/store/index.js' ); const storeEntries = fs.existsSync( storeIndexPath ) ? { 'store/index': storeIndexPath } : {}; -// The i18n shim is the runtime the `@wordpress/i18n` import resolves to in the -// IAPI module bundle (see `requestToExternalModule` below). It re-exports -// `window.wp.i18n` so the front-end view bundle can use `__()` / `_n()` / -// `sprintf()` natively, with translations seeded by the inline `setLocaleData` -// call emitted in `Search_Blocks::enqueue_i18n_runtime()`. +// The i18n shim is the runtime that `@wordpress/i18n` resolves to in the IAPI +// module bundle (see `requestToExternalModule` below). It re-exports the +// classic `wp-i18n` script's live `window.wp.i18n` functions, so calls go +// through the shared runtime that `wp.jpI18nLoader.downloadI18n()` populates +// translations into via `setLocaleData()`. const i18nShimPath = path.join( __dirname, '../src/search-blocks/store/i18n-shim.js' ); const i18nShimEntry = fs.existsSync( i18nShimPath ) ? { 'store/i18n-shim': i18nShimPath } : {}; @@ -94,35 +94,32 @@ module.exports = { ...jetpackWebpackConfig.StandardPlugins( { DependencyExtractionPlugin: { injectPolyfill: false, - // Externalize `@wordpress/i18n` to a script-module reference so - // view bundles can `import { __, _n, sprintf } from '@wordpress/i18n'` - // natively. The `@wordpress/i18n` script module is registered - // by `Search_Blocks::register_i18n_module()` and points to - // `store/i18n-shim.js`, which re-exports `window.wp.i18n`. - // Without this, DEP's default `requestToExternalModule` throws - // "Attempted to use WordPress script in a module" because core - // only registers `@wordpress/interactivity` (and a11y / router) - // as script modules today. + // Externalize `@wordpress/i18n` as a real script-module + // reference so the dependency name in .asset.php is a valid + // module ID — the package registers a matching shim under + // the same ID via `Search_Blocks::register_i18n_module()`. + // (We can't externalize to `var wp.i18n` here: DEP records + // the *external value* in module mode, which would put + // `wp.i18n` into asset.php's deps array; WP then silently + // refuses to enqueue any view bundle whose declared deps + // include an unresolvable script-module ID.) requestToExternalModule( request ) { - if ( request !== '@wordpress/i18n' ) { - // Returning undefined here lets DEP fall through to its - // own defaults for every other `@wordpress/*` request, - // which is what we want — only `@wordpress/i18n` needs - // our custom module-mode handling today. - return; + if ( request === '@wordpress/i18n' ) { + // `module` (not `import`) so webpack emits a hoisted + // static `import * from '@wordpress/i18n'` rather + // than a dynamic `import()` Promise — same pattern + // DEP uses for `@wordpress/interactivity`. + return 'module @wordpress/i18n'; } - // `module` (not `import`) so webpack emits a hoisted - // static `import * from '@wordpress/i18n'` instead of - // a dynamic `import()` Promise. Same pattern DEP uses - // for `@wordpress/interactivity` itself: we need - // synchronous bindings for `__()` / `_n()` / `sprintf()` - // calls in the view bundle's pure helpers. - return 'module @wordpress/i18n'; }, }, // I18nLoaderPlugin tries to inject @wordpress/jp-i18n-loader as an // import, which isn't supported by the DependencyExtractionPlugin - // in ESM/module output mode. Disable for this build. + // in ESM/module output mode. Disable for this build — translation + // loading runs through an explicit `wp.jpI18nLoader.downloadI18n()` + // call from `store/i18n-bootstrap.js` instead, which works in + // module mode because we read jp-i18n-loader off the global + // rather than as an import. I18nLoaderPlugin: false, } ), ],