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..8388616980f0 --- /dev/null +++ b/projects/packages/search/changelog/echo-search-168-localize-rating-aria-label @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +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/blocks/active-filters/view.js b/projects/packages/search/src/search-blocks/blocks/active-filters/view.js index 96e6550ec987..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 @@ -1,9 +1,17 @@ +import { __, sprintf } from '@wordpress/i18n'; 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'; /** @@ -41,15 +49,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 +71,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..b2140ee7b9bc 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,61 @@ public static function enqueue_editor_assets() { ); } + /** + * Register `@wordpress/i18n` as a script module pointing at the package's + * 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 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' ) ) { + 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 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' ); + wp_enqueue_script( 'wp-jp-i18n-loader' ); + } + /** * Add a "Jetpack Search" block category so our blocks appear under that * heading in the inserter instead of "Uncategorized". @@ -653,15 +710,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 +825,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-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 new file mode 100644 index 000000000000..43449bcbfe54 --- /dev/null +++ b/projects/packages/search/src/search-blocks/store/i18n-shim.js @@ -0,0 +1,44 @@ +/** + * 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 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 `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 ) || {}; + +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..9a5b47697a67 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, @@ -5,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 { @@ -14,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; @@ -174,17 +184,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..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,13 +3,14 @@ * 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` 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'; const HTTP_SCHEME_PATTERN = /^https?:\/\//i; const ANY_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i; @@ -307,10 +308,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 +317,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..fa1248bd8ca6 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 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 } : {}; + module.exports = { mode: jetpackWebpackConfig.mode, devtool: jetpackWebpackConfig.devtool, entry: { ...storeEntries, + ...i18nShimEntry, ...blockViewEntries, }, output: { @@ -83,10 +92,34 @@ module.exports = { }, plugins: [ ...jetpackWebpackConfig.StandardPlugins( { - DependencyExtractionPlugin: { injectPolyfill: false }, + DependencyExtractionPlugin: { + injectPolyfill: false, + // 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' ) { + // `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'; + } + }, + }, // 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, } ), ],