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,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';

/**
Expand Down Expand Up @@ -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<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 +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 ),
} );
}
}
Expand Down
91 changes: 57 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 @@ -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
Expand All @@ -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' ) );
}

Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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(),
);
}

Expand Down Expand Up @@ -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<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
50 changes: 50 additions & 0 deletions projects/packages/search/src/search-blocks/store/i18n-bootstrap.js
Original file line number Diff line number Diff line change
@@ -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( '<bundle-filename>' )` 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 );
}
44 changes: 44 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,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 );
22 changes: 16 additions & 6 deletions projects/packages/search/src/search-blocks/store/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { __, _n, sprintf } from '@wordpress/i18n';
import {
store,
getContext,
withSyncEvent as originalWithSyncEvent,
} 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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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
);
}

/**
Expand Down
38 changes: 23 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,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;
Expand Down Expand Up @@ -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.
Expand All @@ -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
);
}

/**
Expand Down
Loading
Loading