Commit d8e0517907b for woocommerce

commit d8e0517907b2e8e71e6feae108b06b5bc5ed420a
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Tue Nov 10 16:33:05 2026 +0100

    Use the site locale for default product permalinks (#67939)

    * fix: use site locale for permalink defaults

    * chore: add permalink locale changelog

    * Move site locale resolution into a SiteLocale utility

    wc_get_permalink_structure() inlined the stored-setting locale chain
    and the isolated textdomain load. Move both into
    Internal\Utilities\SiteLocale so the permalink settings screen can
    resolve the Default base with the same definition of the site locale.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT

    * Resolve the Default permalink base in the site locale on the settings screen

    settings() and settings_save() resolved the Default base through
    wc_switch_to_site_locale(), which switches to get_locale() and so
    honors the locale filter multilingual plugins apply per request. The
    initialized base is stored in the unfiltered site locale, so on such
    sites the Default radio no longer matched the stored value and the
    screen fell back to Custom base. Translate the Default base with
    SiteLocale so both sides agree; the locale window stays for the Shop
    page lookup, which those plugins also filter per locale.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT

    * Document the SiteLocale utility and the Permalinks screen change

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT

    * Preserve existing bases on unchanged saves

    * Honor catalogs supplied by translation loaders

    * Restore translation unload state after slug lookup

    * Honor gettext filters when translating permalink slugs in the site locale

    Pass the site-locale slug through the gettext filters so slugs customized
    that way are kept, prefer translations loaded for the site locale over a
    loader-supplied catalog, guard against loader re-entry, and translate only
    the bases that are missing. Correct the @since tags to 11.3.0.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017HvTz3BNu4Ya3tLEcqQudZ

    * Show the site locale to gettext filters and follow core's theme file naming

    Filters that read get_locale() now see the site locale while the slug is
    filtered, and a language directory inside the active theme is loaded by
    the locale-only file name core uses.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017HvTz3BNu4Ya3tLEcqQudZ

    * Look up site locale translations explicitly on every request

    Use the catalog already loaded for the request only when nothing is
    registered for the site locale and the request runs in that locale.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017HvTz3BNu4Ya3tLEcqQudZ

    * Fix permalink locale scope and simplify tests

    * Shorten the permalink locale changelog entry

    * fix: reset permalink locale guard on exceptions

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-7465-site-locale-permalinks b/plugins/woocommerce/changelog/wooplug-7465-site-locale-permalinks
new file mode 100644
index 00000000000..2935b9ba063
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7465-site-locale-permalinks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use the site language for default product permalinks and the Default option on the Permalinks screen.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
index b638dce9fad..c8aa4f3b419 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
@@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }

+use Automattic\WooCommerce\Internal\Utilities\SiteLocale;
+
 if ( class_exists( 'WC_Admin_Permalink_Settings', false ) ) {
 	return new WC_Admin_Permalink_Settings();
 }
@@ -71,7 +73,7 @@ class WC_Admin_Permalink_Settings {
 	 */
 	public function product_category_slug_input() {
 		?>
-		<input name="woocommerce_product_category_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['category_base'] ); ?>" placeholder="<?php echo esc_attr_x( 'product-category', 'slug', 'woocommerce' ); ?>" />
+		<input name="woocommerce_product_category_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['category_base'] ); ?>" placeholder="<?php echo esc_attr( SiteLocale::translate_slug( 'product-category' ) ); ?>" />
 		<?php
 	}

@@ -80,7 +82,7 @@ class WC_Admin_Permalink_Settings {
 	 */
 	public function product_tag_slug_input() {
 		?>
-		<input name="woocommerce_product_tag_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['tag_base'] ); ?>" placeholder="<?php echo esc_attr_x( 'product-tag', 'slug', 'woocommerce' ); ?>" />
+		<input name="woocommerce_product_tag_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['tag_base'] ); ?>" placeholder="<?php echo esc_attr( SiteLocale::translate_slug( 'product-tag' ) ); ?>" />
 		<?php
 	}

@@ -116,9 +118,9 @@ class WC_Admin_Permalink_Settings {
 	 * them separately -- which is what made every predefined structure revert to "Custom base".
 	 * See https://github.com/woocommerce/woocommerce/issues/29050.
 	 *
-	 * Must run inside a wc_switch_to_site_locale() window: the Default base is a translated slug,
-	 * and an administrator whose profile language differs from the site language would otherwise
-	 * store one translation and compare against another.
+	 * The Default base is a translated slug, resolved in the site locale directly so that neither
+	 * an administrator's profile language nor a `locale` filter can make the save path store one
+	 * translation while the comparison expects another.
 	 *
 	 * Sharing the rules is not the same as receiving identical input: the save path runs the
 	 * posted value through sanitize_text_field() first, which strips percent-encoded octets, so a
@@ -130,7 +132,7 @@ class WC_Admin_Permalink_Settings {
 	 * @return string The base as it is stored.
 	 */
 	private function get_stored_product_base( string $posted_base, ?string $posted_structure = null ): string {
-		$default_base = wc_sanitize_permalink( _x( 'product', 'slug', 'woocommerce' ) );
+		$default_base = wc_sanitize_permalink( SiteLocale::translate_slug( 'product' ) );

 		if ( 'custom' === $posted_base ) {
 			if ( null === $posted_structure ) {
@@ -158,19 +160,22 @@ class WC_Admin_Permalink_Settings {
 		/*
 		 * Resolve an empty base to the default one. A custom base that is blank, whitespace, or
 		 * nothing but hashes and slashes sanitizes down to empty, and an empty base does not
-		 * survive: wc_get_permalink_structure() drops it and refills the option from the request
-		 * locale, outside any locale window, persisting a slug the site locale never chose -- the
-		 * same divergence this resolver exists to prevent.
+		 * survive: wc_get_permalink_structure() drops it and refills the option with the default.
 		 *
-		 * This narrows that window rather than closing it. The default base is itself a translated
-		 * slug run through wc_sanitize_permalink(), so a translation or a gettext filter that
-		 * resolves it to something sanitizing away -- `/` untrailingslashits to nothing -- leaves
-		 * this assigning empty to empty.
+		 * The default base is itself a translated slug run through wc_sanitize_permalink(), so a
+		 * translation or a gettext filter that resolves it to something sanitizing away -- `/`
+		 * untrailingslashits to nothing -- leaves this assigning empty to empty.
 		 */
 		if ( '' === $base ) {
 			$base = $default_base;
 		}

+		// A former Default base can now appear as Custom; posting it back must preserve its bare form.
+		$stored_base = (string) ( $this->permalinks['product_base'] ?? '' );
+		if ( 'custom' === $posted_base && '/' . $stored_base === $base ) {
+			return $stored_base;
+		}
+
 		/*
 		 * A base equal to the Default structure is reported in Default's bare form. The Custom
 		 * base field is the next tab stop after the radio group and focusing it selects Custom
@@ -192,16 +197,9 @@ class WC_Admin_Permalink_Settings {
 		echo wp_kses_post( wpautop( sprintf( __( 'If you like, you may enter custom structures for your product URLs here. For example, using <code>shop</code> would make your product links like <code>%sshop/sample-product/</code>. This setting affects product URLs only, not things such as product categories.', 'woocommerce' ), esc_url( home_url( '/' ) ) ) ) );

 		/*
-		 * Resolve the Shop page and the translated slugs inside the same window settings_save()
-		 * opens, so the values compared below are the values a save would store.
-		 *
-		 * wc_get_page_id() is resolved here too because settings_save() resolves it inside its own
-		 * window, and the woocommerce_get_shop_page_id filter multilingual plugins attach to can
-		 * return a different page per locale.
-		 *
-		 * This holds only for a persisted product_base: wc_get_permalink_structure() initializes a
-		 * missing one in the request locale, outside any window, before this screen renders.
-		 * See https://github.com/woocommerce/woocommerce/issues/67507.
+		 * Resolve the Shop page inside the same window settings_save() opens, so the values
+		 * compared below are the values a save would store: the woocommerce_get_shop_page_id
+		 * filter multilingual plugins attach to can return a different page per locale.
 		 */
 		wc_switch_to_site_locale();
 		$shop_page_id = wc_get_page_id( 'shop' );
@@ -314,8 +312,8 @@ class WC_Admin_Permalink_Settings {
 			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by wc_sanitize_permalink() inside the resolver.
 			$posted_structure = isset( $_POST['product_permalink_structure'] ) && is_string( $_POST['product_permalink_structure'] ) ? wp_unslash( $_POST['product_permalink_structure'] ) : null;

-			// Resolved inside the wc_switch_to_site_locale() window opened above, so the stored
-			// value is the same site-locale form settings() compares against.
+			// The resolver translates the Default base in the site locale, so the stored value is
+			// the same form settings() compares against.
 			$permalinks['product_base'] = $this->get_stored_product_base( $product_base, $posted_structure );

 			update_option( 'woocommerce_permalinks', $permalinks );
diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php
index 69baceece9e..d1967fa0857 100644
--- a/plugins/woocommerce/includes/wc-core-functions.php
+++ b/plugins/woocommerce/includes/wc-core-functions.php
@@ -11,6 +11,7 @@
 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
+use Automattic\WooCommerce\Internal\Utilities\SiteLocale;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Internal\Logging\OrderLogsCleanupHelper;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseTransaction;
@@ -2171,16 +2172,25 @@ function wc_list_pluck( $list, $callback_or_field, $index_key = null ) {
  * @return array
  */
 function wc_get_permalink_structure() {
-	$saved_permalinks = (array) get_option( 'woocommerce_permalinks', array() );
-	$permalinks       = wp_parse_args(
+	$saved_permalinks   = (array) get_option( 'woocommerce_permalinks', array() );
+	$default_permalinks = array(
+		'product_base'           => 'product',
+		'category_base'          => 'product-category',
+		'tag_base'               => 'product-tag',
+		'attribute_base'         => '',
+		'use_verbose_page_rules' => false,
+	);
+
+	foreach ( array( 'product_base', 'category_base', 'tag_base' ) as $permalink_key ) {
+		if ( empty( $saved_permalinks[ $permalink_key ] ) ) {
+			// These defaults are persisted site-wide, so translate them in the site locale rather than the request locale.
+			$default_permalinks[ $permalink_key ] = SiteLocale::translate_slug( $default_permalinks[ $permalink_key ] );
+		}
+	}
+
+	$permalinks = wp_parse_args(
 		array_filter( $saved_permalinks ),
-		array(
-			'product_base'           => _x( 'product', 'slug', 'woocommerce' ),
-			'category_base'          => _x( 'product-category', 'slug', 'woocommerce' ),
-			'tag_base'               => _x( 'product-tag', 'slug', 'woocommerce' ),
-			'attribute_base'         => '',
-			'use_verbose_page_rules' => false,
-		)
+		$default_permalinks
 	);

 	if ( $saved_permalinks !== $permalinks ) {
diff --git a/plugins/woocommerce/src/Internal/Utilities/SiteLocale.php b/plugins/woocommerce/src/Internal/Utilities/SiteLocale.php
new file mode 100644
index 00000000000..5160a46d06d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/SiteLocale.php
@@ -0,0 +1,162 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+use WP_Translation_Controller;
+
+/**
+ * Resolves the configured site locale and translates WooCommerce slugs into it.
+ *
+ * Persisted slugs must not depend on an administrator's language or a request's locale filter.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+class SiteLocale {
+
+	/**
+	 * Whether a slug translation is in progress.
+	 *
+	 * @var bool
+	 */
+	private static bool $is_translating = false;
+
+	/**
+	 * Get the site locale from stored settings, bypassing the request cache and the `locale` filter.
+	 *
+	 * Mirrors the stored-setting chain that get_locale() reads underneath.
+	 *
+	 * @return string Site locale, for example en_US.
+	 *
+	 * @since 11.4.0
+	 */
+	public static function get(): string {
+		if ( is_multisite() && wp_installing() ) {
+			$site_locale = get_site_option( 'WPLANG' );
+		} else {
+			$site_locale = get_option( 'WPLANG' );
+
+			if ( false === $site_locale && is_multisite() ) {
+				$site_locale = get_site_option( 'WPLANG' );
+			}
+		}
+
+		if ( false === $site_locale ) {
+			$site_locale = defined( 'WPLANG' ) ? WPLANG : ( $GLOBALS['wp_local_package'] ?? '' );
+		}
+
+		$site_locale = is_string( $site_locale ) ? sanitize_locale_name( $site_locale ) : '';
+
+		return '' !== $site_locale ? $site_locale : 'en_US';
+	}
+
+	/**
+	 * Translate a WooCommerce slug into the site locale.
+	 *
+	 * Translation loaders and gettext filters run in the site locale without changing request translations.
+	 *
+	 * @param string $slug Untranslated slug, translated with the `slug` context.
+	 * @return string Translated slug, or the untranslated one when no translation exists.
+	 *
+	 * @since 11.4.0
+	 */
+	public static function translate_slug( string $slug ): string {
+		global $l10n, $l10n_unloaded;
+
+		// A translation loader that reads the permalink settings would otherwise re-enter this lookup without end.
+		if ( self::$is_translating ) {
+			return $slug;
+		}
+
+		self::$is_translating = true;
+		try {
+			$locale                     = self::get();
+			$is_site_locale_request     = determine_locale() === $locale;
+			$use_site_locale            = static fn() => $locale;
+			$translation_controller     = WP_Translation_Controller::get_instance();
+			$previous_controller_locale = $translation_controller->get_locale();
+			$previous_translations      = $l10n;
+			$previous_unloaded          = $l10n_unloaded;
+
+			try {
+				add_filter( 'pre_determine_locale', $use_site_locale, PHP_INT_MAX );
+				add_filter( 'locale', $use_site_locale, PHP_INT_MAX );
+
+				$translated_slug = self::load_and_translate_slug( $slug, $locale );
+
+				if ( false === $translated_slug && $is_site_locale_request ) {
+					// A loader may have supplied a request catalog without registering a translation file.
+					$translated_slug = get_translations_for_domain( 'woocommerce' )->translate( $slug, 'slug' );
+				}
+
+				$translated_slug = is_string( $translated_slug ) && '' !== $translated_slug ? $translated_slug : $slug;
+
+				// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment -- Both filters are documented in wp-includes/l10n.php.
+				$filtered_slug = apply_filters( 'gettext_with_context', $translated_slug, $slug, 'slug', 'woocommerce' );
+				$filtered_slug = apply_filters( 'gettext_with_context_woocommerce', $filtered_slug, $slug, 'slug', 'woocommerce' );
+				// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingHookComment
+			} finally {
+				remove_filter( 'pre_determine_locale', $use_site_locale, PHP_INT_MAX );
+				remove_filter( 'locale', $use_site_locale, PHP_INT_MAX );
+				$translation_controller->set_locale( $previous_controller_locale );
+				// A callback can load other text domains while the site locale is active.
+				$l10n          = $previous_translations; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore all request catalogs, including domains loaded by callbacks.
+				$l10n_unloaded = $previous_unloaded; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the request's just-in-time loading state.
+			}
+		} finally {
+			self::$is_translating = false;
+		}
+
+		return is_string( $filtered_slug ) && '' !== $filtered_slug ? $filtered_slug : $translated_slug;
+	}
+
+	/**
+	 * Load site-locale translations without merging the request's translation catalog.
+	 *
+	 * The load_textdomain() call otherwise imports an existing legacy MO file into the target locale.
+	 *
+	 * @param string $slug   Untranslated slug.
+	 * @param string $locale Locale to load.
+	 * @return string|false Translated slug, or false when no translation exists.
+	 */
+	private static function load_and_translate_slug( string $slug, string $locale ) {
+		global $l10n, $wp_textdomain_registry;
+
+		$translation_controller = WP_Translation_Controller::get_instance();
+
+		if ( $translation_controller->is_textdomain_loaded( 'woocommerce', $locale ) ) {
+			return $translation_controller->translate( $slug, 'slug', 'woocommerce', $locale );
+		}
+
+		$translation_path        = $wp_textdomain_registry->get( 'woocommerce', $locale );
+		$custom_translation_file = WP_LANG_DIR . '/woocommerce/woocommerce-' . $locale . '.mo';
+		$previous_translations   = $l10n['woocommerce'] ?? null;
+
+		unset( $l10n['woocommerce'] );
+
+		try {
+			if ( is_readable( $custom_translation_file ) ) {
+				load_textdomain( 'woocommerce', $custom_translation_file, $locale );
+			}
+
+			if ( is_string( $translation_path ) && '' !== $translation_path ) {
+				// Like core, a language directory inside the active theme holds files named after the locale only.
+				$translation_path = trailingslashit( $translation_path );
+				$is_theme_path    = str_starts_with( $translation_path, trailingslashit( get_template_directory() ) ) || str_starts_with( $translation_path, trailingslashit( get_stylesheet_directory() ) );
+				load_textdomain( 'woocommerce', $translation_path . ( $is_theme_path ? '' : 'woocommerce-' ) . $locale . '.mo', $locale );
+			}
+
+			$translated_slug = $translation_controller->translate( $slug, 'slug', 'woocommerce', $locale );
+
+			// Override loaders can supply a legacy catalog without populating the translation controller.
+			return false === $translated_slug && isset( $l10n['woocommerce'] ) ? $l10n['woocommerce']->translate( $slug, 'slug' ) : $translated_slug;
+		} finally {
+			if ( null !== $previous_translations ) {
+				$l10n['woocommerce'] = $previous_translations; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the translations isolated above.
+			} else {
+				unset( $l10n['woocommerce'] );
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php
index fa3bf2a458c..919b08c2616 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php
@@ -245,6 +245,45 @@ class WC_Admin_Permalink_Settings_Test extends WC_Unit_Test_Case {
 		$this->assert_only_radio_checked( $html, 'default' );
 	}

+	/**
+	 * A multilingual plugin can filter `locale` per request, which moves get_locale() and the
+	 * wc_switch_to_site_locale() window away from the configured site locale. The Default base is
+	 * initialized and compared in the configured site locale, so the screen must keep reporting
+	 * Default for the base wc_get_permalink_structure() persisted.
+	 *
+	 * @testdox Should keep "Default" checked when a locale filter changes the request locale.
+	 */
+	public function test_default_structure_stays_checked_when_a_locale_filter_changes_the_request_locale(): void {
+		$this->ensure_shop_page();
+		$this->activate_french_product_slug_translation();
+		add_filter( 'locale', static fn() => 'fr_FR' );
+		$this->assertSame( 'fr_FR', determine_locale(), 'The request locale should follow the locale filter.' );
+
+		delete_option( 'woocommerce_permalinks' );
+		wc_get_permalink_structure();
+		$html = $this->render_settings();
+
+		$this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'The Default base should be initialized in the site locale.' );
+		$this->assert_only_radio_checked( $html, 'default' );
+	}
+
+	/**
+	 * @testdox An unchanged save keeps a bare base shown as Custom.
+	 */
+	public function test_unchanged_save_keeps_a_bare_custom_base(): void {
+		$this->ensure_shop_page();
+		update_option( 'woocommerce_permalinks', array( 'product_base' => 'waren' ) );
+
+		$html  = $this->render_settings();
+		$xpath = $this->get_xpath( $html );
+		$this->assert_only_radio_checked( $html, 'custom' );
+		$this->save_and_render(
+			$xpath->query( '//input[@name="product_permalink"][@checked]' )->item( 0 )->getAttribute( 'value' ),
+			$xpath->query( '//input[@name="product_permalink_structure"]' )->item( 0 )->getAttribute( 'value' )
+		);
+		$this->assertSame( 'waren', get_option( 'woocommerce_permalinks' )['product_base'] );
+	}
+
 	/**
 	 * The Default radio keeps posting an empty value, so the payload stays byte-identical to what
 	 * every earlier version submitted; the resolved structure moves to a data attribute that only
@@ -424,15 +463,6 @@ class WC_Admin_Permalink_Settings_Test extends WC_Unit_Test_Case {
 	}

 	/**
-	 * A custom base carrying no usable characters sanitizes down to the empty string, and an empty
-	 * product_base never survives: wc_get_permalink_structure() drops it and refills the option
-	 * from the request locale, outside any locale window, then writes it back. An administrator
-	 * browsing in their own language therefore persisted that language's slug. Resolving to the
-	 * site-locale default at save time keeps the stored value deterministic.
-	 *
-	 * The locales have to diverge for this to be observable: when they agree, the refill produces
-	 * the same slug the fix stores and nothing looks wrong.
-	 *
 	 * @testdox Should store the site-locale Default base when the posted custom structure sanitizes to nothing.
 	 *
 	 * @testWith [""]
@@ -450,18 +480,11 @@ class WC_Admin_Permalink_Settings_Test extends WC_Unit_Test_Case {

 		$html = $this->save_and_render( 'custom', $posted_structure );

-		$this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'An empty base must never reach the option, where the request locale would refill it.' );
+		$this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'] );
 		$this->assert_only_radio_checked( $html, 'default' );
 	}

 	/**
-	 * Both permalink fields are free-form request input; an array reaching trim() or
-	 * wc_sanitize_permalink() is a fatal, since both are declared to take a string. The custom
-	 * branch has its own path: a scalar 'custom' radio with an array structure field used to store
-	 * '/', which wc_sanitize_permalink() collapses to '', leaving wc_get_permalink_structure() to
-	 * refill the option in the next request's locale. Both now resolve to the default base
-	 * directly, in the site locale.
-	 *
 	 * @testdox Should fall back to the Default base when a posted permalink field is not a string.
 	 *
 	 * @testWith [["custom"], ["widgets"]]
diff --git a/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
index 15578fdec2e..426cfabed09 100644
--- a/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
@@ -10,6 +10,216 @@
  */
 class WC_Core_Functions_Test extends \WC_Unit_Test_Case {

+	/**
+	 * @testdox Missing permalink bases use the site locale without importing the request's MO catalog.
+	 */
+	public function test_wc_get_permalink_structure_uses_site_locale_for_missing_defaults(): void {
+		global $l10n, $wp_textdomain_registry;
+
+		$site_locale           = 'wc_TEST';
+		$fixtures_dir          = WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/';
+		$controller            = WP_Translation_Controller::get_instance();
+		$original_locale       = $controller->get_locale();
+		$original_path         = $wp_textdomain_registry->get( 'woocommerce', $site_locale );
+		$original_translations = $l10n['woocommerce'] ?? null;
+		$request_file          = trailingslashit( get_temp_dir() ) . wp_unique_filename( get_temp_dir(), 'woocommerce-request-translations.mo' );
+
+		delete_option( 'woocommerce_permalinks' );
+		add_filter( 'pre_option_WPLANG', static fn() => $site_locale );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter( 'lang_dir_for_domain', static fn( $path, $domain, $locale ) => 'woocommerce' === $domain && $site_locale === $locale ? $fixtures_dir : $path, 10, 3 );
+		add_filter( 'load_translation_file', static fn( $file, $domain, $locale ) => 'woocommerce' === $domain && $site_locale === $locale ? $fixtures_dir . 'permalink-translations.php' : $file, 10, 3 );
+
+		try {
+			$request_translations = new MO();
+			$request_translations->add_entry(
+				array(
+					'singular'     => 'product-tag',
+					'context'      => 'slug',
+					'translations' => array( 'request-product-tag' ),
+				)
+			);
+			$this->assertTrue( $request_translations->export_to_file( $request_file ) );
+			$this->assertTrue( $request_translations->import_from_file( $request_file ) );
+			$l10n['woocommerce'] = $request_translations; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate a request-locale catalog with a file core could reload.
+
+			$permalinks = wc_get_permalink_structure();
+			$persisted  = get_option( 'woocommerce_permalinks' );
+			$expected   = array(
+				'product_base'  => 'site-product',
+				'category_base' => 'site-product-category',
+				'tag_base'      => 'product-tag',
+			);
+			foreach ( $expected as $key => $slug ) {
+				$this->assertSame( $slug, $permalinks[ $key ] );
+				$this->assertSame( $slug, $persisted[ $key ] );
+			}
+			$this->assertSame( $request_translations, $l10n['woocommerce'] );
+			$this->assertSame( $original_locale, $controller->get_locale() );
+			$this->assertSame( 'en_US', determine_locale() );
+		} finally {
+			$controller->unload_textdomain( 'woocommerce', $site_locale );
+			$controller->set_locale( $original_locale );
+			$wp_textdomain_registry->set( 'woocommerce', $site_locale, $original_path );
+			wp_delete_file( $request_file );
+			if ( null === $original_translations ) {
+				unset( $l10n['woocommerce'] );
+			} else {
+				$l10n['woocommerce'] = $original_translations; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the test's original translations.
+			}
+		}
+	}
+
+	/**
+	 * @testdox Existing permalink bases are preserved without translating defaults.
+	 */
+	public function test_wc_get_permalink_structure_preserves_existing_bases(): void {
+		update_option(
+			'woocommerce_permalinks',
+			array(
+				'product_base'  => 'custom-product',
+				'category_base' => 'custom-product-category',
+				'tag_base'      => 'custom-product-tag',
+			)
+		);
+		$permalinks = wc_get_permalink_structure();
+
+		$this->assertSame( 'custom-product', $permalinks['product_base'] );
+		$this->assertSame( 'custom-product-category', $permalinks['category_base'] );
+		$this->assertSame( 'custom-product-tag', $permalinks['tag_base'] );
+	}
+
+	/**
+	 * @testdox Missing permalink bases honor catalogs supplied by translation loaders.
+	 * @testWith ["pre_load_textdomain"]
+	 *           ["override_load_textdomain"]
+	 *
+	 * @param string $hook Translation loading filter.
+	 */
+	public function test_permalink_defaults_honor_translation_loaders( string $hook ): void {
+		global $l10n, $l10n_unloaded, $wp_textdomain_registry;
+
+		$locale                = 'wc_LOADER';
+		$previous_path         = $wp_textdomain_registry->get( 'woocommerce', $locale );
+		$previous_translations = $l10n['woocommerce'] ?? null;
+		$previous_unloaded     = $l10n_unloaded['woocommerce'] ?? null;
+		$previous_locale       = WP_Translation_Controller::get_instance()->get_locale();
+		$wp_textdomain_registry->set( 'woocommerce', $locale, WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/' );
+		add_filter( 'pre_option_WPLANG', static fn() => $locale );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter(
+			$hook,
+			static function ( $override, $domain ) use ( $locale ) {
+				if ( 'woocommerce' === $domain ) {
+					$catalog = new \MO();
+					$catalog->add_entry(
+						array(
+							'singular'     => 'product',
+							'context'      => 'slug',
+							'translations' => array( determine_locale() === $locale ? 'loader-product' : 'request-product' ),
+						)
+					);
+					$GLOBALS['l10n'][ $domain ] = $catalog; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate an external translation loader.
+					return true;
+				}
+				return $override;
+			},
+			10,
+			2
+		);
+		delete_option( 'woocommerce_permalinks' );
+		$l10n_unloaded['woocommerce'] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Model a request that disabled just-in-time loading.
+
+		try {
+			wc_get_permalink_structure();
+			$this->assertSame( 'loader-product', get_option( 'woocommerce_permalinks' )['product_base'] );
+			$this->assertSame( $previous_translations, $l10n['woocommerce'] ?? null, 'Restore the request catalog.' );
+			$this->assertSame( $previous_locale, WP_Translation_Controller::get_instance()->get_locale() );
+			$this->assertTrue( $l10n_unloaded['woocommerce'] ?? false, 'Preserve the request decision to disable just-in-time loading.' );
+		} finally {
+			$wp_textdomain_registry->set( 'woocommerce', $locale, $previous_path );
+			if ( null === $previous_unloaded ) {
+				unset( $l10n_unloaded['woocommerce'] );
+			} else {
+				$l10n_unloaded['woocommerce'] = $previous_unloaded; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the test's original marker.
+			}
+		}
+	}
+
+	/**
+	 * @testdox Missing permalink bases keep slugs customized through gettext filters.
+	 * @testWith ["en_US"]
+	 *           ["fr_FR"]
+	 *
+	 * @param string $request_locale Locale of the request that fills in the missing bases.
+	 */
+	public function test_missing_permalink_bases_keep_gettext_filtered_slugs( string $request_locale ): void {
+		$slugs = array(
+			'product-category' => 'waren-kategorie',
+			'product-tag'      => 'waren-schlagwort',
+		);
+		add_filter( 'pre_determine_locale', static fn() => $request_locale );
+		add_filter(
+			'gettext_with_context',
+			static fn( $translation, $text, $context, $domain ) => 'woocommerce' === $domain && 'slug' === $context ? ( $slugs[ $text ] ?? $translation ) : $translation,
+			10,
+			4
+		);
+		update_option(
+			'woocommerce_permalinks',
+			array(
+				'product_base'  => 'waren',
+				'category_base' => '',
+				'tag_base'      => '',
+			)
+		);
+
+		wc_get_permalink_structure();
+		$permalinks = get_option( 'woocommerce_permalinks' );
+
+		$this->assertSame( 'waren', $permalinks['product_base'], 'A stored base should be left alone.' );
+		$this->assertSame( 'waren-kategorie', $permalinks['category_base'], 'The filtered category slug should be stored.' );
+		$this->assertSame( 'waren-schlagwort', $permalinks['tag_base'], 'The filtered tag slug should be stored.' );
+	}
+
+	/**
+	 * @testdox A translation loader that reads the permalink settings does not re-enter the slug lookup.
+	 */
+	public function test_translate_slug_does_not_re_enter_from_a_translation_loader(): void {
+		global $wp_textdomain_registry;
+
+		$loader_calls  = 0;
+		$previous_path = $wp_textdomain_registry->get( 'woocommerce', 'wc_LOADER' );
+		$wp_textdomain_registry->set( 'woocommerce', 'wc_LOADER', WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/' );
+		add_filter( 'pre_option_WPLANG', static fn() => 'wc_LOADER' );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter(
+			'pre_load_textdomain',
+			static function ( $override, $domain ) use ( &$loader_calls ) {
+				if ( 'woocommerce' === $domain ) {
+					++$loader_calls;
+					if ( $loader_calls > 10 ) {
+						throw new \RuntimeException( 'The translation loader re-entered the slug lookup.' );
+					}
+					wc_get_permalink_structure();
+				}
+				return $override;
+			},
+			10,
+			2
+		);
+		delete_option( 'woocommerce_permalinks' );
+
+		try {
+			$permalinks = wc_get_permalink_structure();
+		} finally {
+			$wp_textdomain_registry->set( 'woocommerce', 'wc_LOADER', $previous_path );
+		}
+
+		$this->assertSame( 'product', $permalinks['product_base'], 'The lookup should finish with the untranslated slug.' );
+		$this->assertLessThanOrEqual( 3, $loader_calls, 'Each missing base should reach the loader once at most.' );
+	}
+
 	/**
 	 * Create a Shop page with one child.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/SiteLocaleTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/SiteLocaleTest.php
new file mode 100644
index 00000000000..1b704eaca5b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/SiteLocaleTest.php
@@ -0,0 +1,235 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Internal\Utilities\SiteLocale;
+use WC_Unit_Test_Case;
+use WP_Translation_Controller;
+
+/**
+ * Tests for the SiteLocale class.
+ */
+class SiteLocaleTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox translate_slug() prefers translations loaded for the site locale over a catalog supplied by a loader.
+	 */
+	public function test_translate_slug_prefers_site_locale_translations_over_a_loader_catalog(): void {
+		global $wp_textdomain_registry;
+
+		$locale        = 'wc_ORDER';
+		$custom_file   = WP_LANG_DIR . '/woocommerce/woocommerce-' . $locale . '.mo';
+		$previous_path = $wp_textdomain_registry->get( 'woocommerce', $locale );
+		$catalogs      = array();
+		foreach ( array( 'site-product', 'loader-product' ) as $translation ) {
+			$catalogs[ $translation ] = new \MO();
+			$catalogs[ $translation ]->add_entry(
+				array(
+					'singular'     => 'product',
+					'context'      => 'slug',
+					'translations' => array( $translation ),
+				)
+			);
+		}
+		wp_mkdir_p( dirname( $custom_file ) );
+		$this->assertTrue( $catalogs['site-product']->export_to_file( $custom_file ), 'The site locale translation file should be written.' );
+		$wp_textdomain_registry->set( 'woocommerce', $locale, WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/' );
+		add_filter( 'pre_option_WPLANG', static fn() => $locale );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter(
+			'override_load_textdomain',
+			static function ( $override, $domain, $mofile ) use ( $catalogs, $custom_file ) {
+				if ( 'woocommerce' === $domain && $custom_file !== $mofile ) {
+					$GLOBALS['l10n'][ $domain ] = $catalogs['loader-product']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate a loader that ignores the requested locale.
+					return true;
+				}
+				return $override;
+			},
+			10,
+			3
+		);
+
+		try {
+			$product_slug = SiteLocale::translate_slug( 'product' );
+		} finally {
+			WP_Translation_Controller::get_instance()->unload_textdomain( 'woocommerce', $locale );
+			$wp_textdomain_registry->set( 'woocommerce', $locale, $previous_path );
+			wp_delete_file( $custom_file );
+		}
+
+		$this->assertSame( 'site-product', $product_slug, 'The translation loaded for the site locale should win.' );
+	}
+
+	/**
+	 * @testdox translate_slug() looks for a file named after the locale when the language directory is inside the active theme.
+	 */
+	public function test_translate_slug_uses_the_theme_file_name_for_a_theme_language_directory(): void {
+		global $wp_textdomain_registry;
+
+		$locale        = 'wc_THEME';
+		$theme_path    = trailingslashit( get_stylesheet_directory() ) . 'languages/';
+		$previous_path = $wp_textdomain_registry->get( 'woocommerce', $locale );
+		$loaded_files  = array();
+		$wp_textdomain_registry->set( 'woocommerce', $locale, $theme_path );
+		add_filter( 'pre_option_WPLANG', static fn() => $locale );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter(
+			'load_textdomain_mofile',
+			static function ( $mofile, $domain ) use ( &$loaded_files ) {
+				if ( 'woocommerce' === $domain ) {
+					$loaded_files[] = $mofile;
+				}
+				return $mofile;
+			},
+			10,
+			2
+		);
+
+		try {
+			SiteLocale::translate_slug( 'product' );
+		} finally {
+			$wp_textdomain_registry->set( 'woocommerce', $locale, $previous_path );
+		}
+
+		$this->assertSame( array( $theme_path . 'wc_THEME.mo' ), $loaded_files, 'A theme language directory should use the locale as the file name.' );
+	}
+
+	/**
+	 * @testdox translate_slug() on a site-locale request prefers registered site locale translations over the catalog already loaded.
+	 * @testWith [true, "site-product", "wc_TEST"]
+	 *           [false, "loaded-product", "wc_TEST"]
+	 *           [false, "product", "en_US"]
+	 *
+	 * @param bool   $has_site_translations Whether translations are registered for the site locale.
+	 * @param string $expected_slug         Expected translated slug.
+	 * @param string $request_locale        Locale of the current request.
+	 */
+	public function test_translate_slug_on_a_site_locale_request( bool $has_site_translations, string $expected_slug, string $request_locale ): void {
+		global $l10n, $wp_textdomain_registry;
+
+		$locale                = 'wc_TEST';
+		$fixtures_dir          = WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/';
+		$previous_path         = $wp_textdomain_registry->get( 'woocommerce', $locale );
+		$previous_translations = $l10n['woocommerce'] ?? null;
+		$loaded_catalog        = new \MO();
+		$loaded_catalog->add_entry(
+			array(
+				'singular'     => 'product',
+				'context'      => 'slug',
+				'translations' => array( 'loaded-product' ),
+			)
+		);
+		add_filter( 'pre_option_WPLANG', static fn() => $locale );
+		add_filter( 'pre_determine_locale', static fn() => $request_locale );
+		if ( $has_site_translations ) {
+			$wp_textdomain_registry->set( 'woocommerce', $locale, $fixtures_dir );
+			add_filter(
+				'load_translation_file',
+				static fn( $file, $domain, $file_locale ) => 'woocommerce' === $domain && $locale === $file_locale ? $fixtures_dir . 'permalink-translations.php' : $file,
+				10,
+				3
+			);
+		}
+		$l10n['woocommerce'] = $loaded_catalog; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate a catalog loaded earlier in the request.
+
+		try {
+			$product_slug = SiteLocale::translate_slug( 'product' );
+		} finally {
+			WP_Translation_Controller::get_instance()->unload_textdomain( 'woocommerce', $locale );
+			$wp_textdomain_registry->set( 'woocommerce', $locale, $previous_path );
+			if ( null === $previous_translations ) {
+				unset( $l10n['woocommerce'] );
+			} else {
+				$l10n['woocommerce'] = $previous_translations; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the test's original translations.
+			}
+		}
+
+		$this->assertSame( $expected_slug, $product_slug, 'Registered site locale translations should win, with the loaded catalog as the fallback.' );
+	}
+
+	/**
+	 * @testdox get() reads the stored site locale and ignores the locale filter.
+	 */
+	public function test_get_ignores_the_locale_filter(): void {
+		add_filter( 'pre_option_WPLANG', static fn() => 'de_DE' );
+		add_filter( 'locale', static fn() => 'fr_FR' );
+
+		$this->assertSame( 'fr_FR', get_locale(), 'The request locale should follow the locale filter.' );
+		$this->assertSame( 'de_DE', SiteLocale::get(), 'The site locale should come from the stored setting.' );
+	}
+
+	/**
+	 * @testdox get() falls back to en_US when no site locale is stored.
+	 */
+	public function test_get_falls_back_to_en_us(): void {
+		add_filter( 'pre_option_WPLANG', static fn() => '' );
+
+		$this->assertSame( 'en_US', SiteLocale::get() );
+	}
+
+	/**
+	 * @testdox translate_slug() uses the site locale translations and passes them through gettext filters in the site locale.
+	 */
+	public function test_translate_slug_uses_site_locale_translations(): void {
+		global $l10n, $wp_textdomain_registry;
+
+		$site_locale                = 'wc_TEST';
+		$fixtures_dir               = WC_ABSPATH . 'tests/php/src/Internal/Utilities/fixtures/';
+		$translation_controller     = WP_Translation_Controller::get_instance();
+		$original_controller_locale = $translation_controller->get_locale();
+		$original_translation_path  = $wp_textdomain_registry->get( 'woocommerce', $site_locale );
+		$original_nested_path       = $wp_textdomain_registry->get( 'site-locale-test', $site_locale );
+		$original_nested_catalog    = $l10n['site-locale-test'] ?? null;
+
+		add_filter( 'pre_option_WPLANG', static fn() => $site_locale );
+		add_filter( 'pre_determine_locale', static fn() => 'en_US' );
+		add_filter( 'locale', static fn() => 'fr_FR' );
+		add_filter(
+			'lang_dir_for_domain',
+			static fn( $path, $domain, $locale ) => in_array( $domain, array( 'woocommerce', 'site-locale-test' ), true ) && $site_locale === $locale ? $fixtures_dir : $path,
+			10,
+			3
+		);
+		add_filter(
+			'load_translation_file',
+			static fn( $file, $domain, $locale ) => in_array( $domain, array( 'woocommerce', 'site-locale-test' ), true ) && $site_locale === $locale ? $fixtures_dir . 'permalink-translations.php' : $file,
+			10,
+			3
+		);
+		add_filter(
+			'gettext_with_context',
+			static function ( $translation, $text, $context, $domain ) {
+				if ( 'woocommerce' === $domain && 'slug' === $context && 'product' === $text ) {
+					get_translations_for_domain( 'site-locale-test' )->translate( 'product', 'slug' );
+					return $translation . '-' . determine_locale() . '-' . get_locale();
+				}
+				return $translation;
+			},
+			10,
+			4
+		);
+
+		try {
+			$product_slug     = SiteLocale::translate_slug( 'product' );
+			$product_tag_slug = SiteLocale::translate_slug( 'product-tag' );
+
+			$this->assertSame( 'site-product-wc_TEST-wc_TEST', $product_slug, 'A gettext filter should receive the site locale translation and see the site locale.' );
+			$this->assertSame( 'product-tag', $product_tag_slug, 'An untranslated slug should be returned unchanged.' );
+			$this->assertSame( $original_controller_locale, $translation_controller->get_locale(), 'The translation controller locale should be restored.' );
+			$this->assertSame( $original_nested_catalog, $l10n['site-locale-test'] ?? null, 'A nested translation should not replace the request catalog.' );
+			$this->assertSame( 'en_US', determine_locale(), 'The request locale should stay unchanged.' );
+		} finally {
+			$translation_controller->unload_textdomain( 'woocommerce', $site_locale );
+			$translation_controller->unload_textdomain( 'site-locale-test', $site_locale );
+			$translation_controller->set_locale( $original_controller_locale );
+			$wp_textdomain_registry->set( 'woocommerce', $site_locale, $original_translation_path );
+			$wp_textdomain_registry->set( 'site-locale-test', $site_locale, $original_nested_path );
+			if ( null === $original_nested_catalog ) {
+				unset( $l10n['site-locale-test'] );
+			} else {
+				$l10n['site-locale-test'] = $original_nested_catalog; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the test's original translations.
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/fixtures/permalink-translations.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/fixtures/permalink-translations.php
new file mode 100644
index 00000000000..7ab4065aaa8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/fixtures/permalink-translations.php
@@ -0,0 +1,16 @@
+<?php
+/**
+ * WooCommerce translations used by core function tests.
+ *
+ * @package WooCommerce\Tests\Util
+ */
+
+declare( strict_types = 1 );
+
+return array(
+	'language' => 'wc_TEST',
+	'messages' => array(
+		"slug\4product"          => 'site-product',
+		"slug\4product-category" => 'site-product-category',
+	),
+);