Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { __, sprintf } from '@wordpress/i18n';
import { store, getContext } from '@wordpress/interactivity';
import { formatDateBucketLabel } from '../../store/api';
import '../../store';
Expand Down Expand Up @@ -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<object>} 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 ) ) {
Expand All @@ -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 ),
} );
}
}
Expand Down
181 changes: 147 additions & 34 deletions projects/packages/search/src/search-blocks/class-search-blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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
Expand All @@ -79,6 +80,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' ) );
}

Expand Down Expand Up @@ -129,6 +131,151 @@ 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.
*
* `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' ) ) {
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 `</script>` (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<string, mixed>|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<string, mixed>
*/
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;
}

/**
* Add a "Jetpack Search" block category so our blocks appear under that
* heading in the inserter instead of "Uncategorized".
Expand Down Expand Up @@ -615,15 +762,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(),
);
}

Expand Down Expand Up @@ -739,31 +877,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<string, string>
*/
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
Expand Down
43 changes: 43 additions & 0 deletions projects/packages/search/src/search-blocks/store/i18n-shim.js
Original file line number Diff line number Diff line change
@@ -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 );
13 changes: 7 additions & 6 deletions projects/packages/search/src/search-blocks/store/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { __, _n, sprintf } from '@wordpress/i18n';
import {
store,
getContext,
Expand Down Expand Up @@ -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
);
}

/**
Expand Down
36 changes: 21 additions & 15 deletions projects/packages/search/src/search-blocks/store/result-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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
);
}

/**
Expand Down
Loading
Loading