Commit fa65c1339e1 for woocommerce

commit fa65c1339e18abd1b740da2c0419dcd1d7c5213e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Jul 14 10:34:18 2026 +0300

    Add locale-aware guidance to the product attribute slug field (#65814)

    * feat: add a locale-aware character estimate for attribute slugs

    The attribute slug limit is enforced in bytes, but users count characters,
    and different scripts encode in 1-4 UTF-8 bytes — so the character budget
    depends on the site language. A raw "29 bytes" is not actionable on a
    non-Latin site.

    Add wc_get_attribute_slug_character_estimate(), which maps a locale to the
    typical byte width of its script (Latin 1, Cyrillic/Greek/Hebrew/Arabic 2,
    CJK/Thai/Indic 3) and divides the slug byte budget by it for a rough,
    locale-appropriate character maximum. It is a heuristic — locale predicts
    the likely script, not the actual slug — so callers keep the byte limit
    authoritative.

    Refs WOOPLUG-6637

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * feat: tailor attribute slug field guidance to the site locale

    The slug field byte limit is hard for users to reason about, especially on
    non-Latin sites where 29 bytes is far fewer than 29 characters, and the
    HTML maxlength is character-based so it cannot enforce or convey the byte
    budget.

    Render a description tailored to the site locale's script (e.g. "roughly 14
    characters" on a Greek-language site) as the server-side copy, so users
    without JavaScript get a meaningful figure alongside the authoritative byte
    limit. When the live byte counter renders, swap in a leaner description,
    since the counter already shows the concrete numbers.

    Refs WOOPLUG-6637

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Refactor attribute slug character estimate into a src class

    wc_get_attribute_slug_character_estimate() was added as a standalone
    function in includes/wc-attribute-functions.php, but WooCommerce no
    longer accepts new standalone functions (they are hard to mock and the
    branch lint fails the build on them); all new code belongs in classes
    under src/.

    Move the helper unchanged into
    Automattic\WooCommerce\Internal\ProductAttributes\AttributeSlugLength
    as a static get_character_estimate() method, following the existing
    static-utility precedent of VisualAttributeTermMeta in the same
    namespace. The function was introduced on this branch and never
    released, so it is removed outright with no deprecation shim. Tests
    move to the matching tests/php/src location and gain a data provider.

    * Fix slug character estimate to use the user locale

    The character estimate defaulted to get_locale(), the site language.
    Since WordPress 4.7, admin screens render in the admin user's own
    profile language (get_user_locale()), which can differ from the site
    default. An admin with a personal language setting would read the slug
    field description in their language while the character count was
    computed for a different script — the same site-vs-user locale mixup
    WooCommerce has hit before (issue 14361).

    Default the estimate to get_user_locale() so the figure matches the
    language the user actually reads and types, and adjust the field copy
    from "your site language" to "your language" accordingly.

    * Add Vietnamese and Nepali to the three-byte script group

    Vietnamese and Nepali were missing from the three-byte language list,
    so both fell through to the single-byte (Latin) default and users were
    told the slug fits roughly 29 characters when the realistic budget is
    about a third of that. Vietnamese's precomposed tone-marked vowels
    (e.g. U+1EBF, U+1EC7) live in Latin Extended Additional and encode as
    3 bytes in UTF-8, and Nepali uses Devanagari — the same 3-byte script
    as Hindi, which was already listed.

    Add both subtags to the three-byte group so the estimate is honest for
    exactly the audiences this hint targets.

    * Fix locale mapping to cover WordPress's actual locale IDs

    The byte-width lists only held two-letter ISO subtags, but WordPress
    core installs locales under its own IDs, several of which are
    three-letter codes: kir (Kyrgyz), bel (Belarusian), sah (Sakha) are
    Cyrillic; ckb (Sorani), ary, azb, haz, skr, snd, ug_CN are Arabic
    script. None matched either list, so those users fell through to the
    Latin default and were told the slug fits roughly 29 characters when
    their scripts exhaust the 29-byte budget at about 14.

    Add the WordPress locale IDs (verified against the wordpress.org
    translations API) alongside the existing ISO aliases, and cover the
    previously missing 3-byte scripts: Ethiopic (am), Tibetan (bo, dzo),
    and Assamese (as). Also add tt (Tatar, Cyrillic).

    * Fix Vietnamese estimate to account for slug transliteration

    Vietnamese was classified as a three-byte script because its
    precomposed tone-marked vowels (U+1EA0-U+1EF9) encode as 3 bytes in
    UTF-8, telling Vietnamese admins the slug fits roughly 9 characters.

    But attribute slugs never reach validation in that form: they pass
    through wc_sanitize_taxonomy_name(), whose sanitize_title() call
    transliterates accented Latin letters to plain ASCII via
    remove_accents() — 'Tiếng Việt đậm nhạt' is stored as
    'tieng-viet-dam-nhat' (verified against the actual pipeline in
    wp-env). The stored slug is single-byte, so the realistic budget is
    the full 29 characters, not 9. Non-Latin scripts (Cyrillic, CJK,
    Devanagari, ...) are unaffected: sanitize_title() preserves them as
    raw UTF-8, so their byte-width classification stands.

    Classify vi as Latin (single-byte) and document why, so it is not
    "re-fixed" into the three-byte group later.

    * Add test coverage for hyphen-delimited locale tags

    The subtag extraction splits on both '_' and '-' to handle BCP 47
    style tags like 'zh-Hans', but only underscore-delimited locales were
    exercised by the data provider, leaving the '-' delimiter branch
    untested.

    * Document the description swap in the slug byte counter

    The slug_byte_counter_script() docblock described only the live byte
    counter, but the script also replaces the server-rendered field
    description with a leaner note once the counter activates. Note that
    behavior so the docblock matches what the code does.

    * Reset current user in tearDown to prevent locale leak between tests

    test_defaults_to_user_locale() set a ru_RU administrator as the current
    user, then reset it to 0 only after the assertion. A failed assertion
    would skip that reset and leak the locale-specific admin into later
    tests. Move the reset into tearDown() so it always runs.

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-attribute-slug-locale-hint b/plugins/woocommerce/changelog/add-attribute-slug-locale-hint
new file mode 100644
index 00000000000..2fc545923c4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-attribute-slug-locale-hint
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Product attributes: tailor the slug field hint to the user's language, showing an approximate character limit based on the locale's script alongside the authoritative byte limit, with a leaner description for clients that have the live byte counter.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php b/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
index 400c4a0e360..830e22aab78 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-attributes.php
@@ -10,6 +10,7 @@

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\ProductAttributes\AttributeSlugLength;
 use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;

 /**
@@ -163,6 +164,25 @@ class WC_Admin_Attributes {
 		return wc_delete_attribute( $attribute_id );
 	}

+	/**
+	 * Build the slug field description shown to users (and served to search engines and
+	 * no-JavaScript clients as the static copy).
+	 *
+	 * States the authoritative byte limit alongside a character estimate tailored to the
+	 * script of the user's language, so users on non-Latin scripts get a meaningful
+	 * figure rather than a raw byte count they must translate into characters themselves.
+	 *
+	 * @return string
+	 */
+	private static function slug_field_description(): string {
+		return sprintf(
+			/* translators: 1: maximum slug length in bytes, 2: approximate maximum number of characters for the user's language. */
+			__( 'Unique slug/reference for the attribute. Limited to %1$d bytes — roughly %2$d characters in your language; non-ASCII characters use 2–4 bytes each.', 'woocommerce' ),
+			wc_get_attribute_slug_max_byte_length(),
+			AttributeSlugLength::get_character_estimate()
+		);
+	}
+
 	/**
 	 * Output an inline script that shows a live UTF-8 byte count next to the
 	 * slug input and visually warns when the value approaches or exceeds the
@@ -172,6 +192,10 @@ class WC_Admin_Attributes {
 	 * multibyte slug (e.g. Cyrillic, Chinese) can pass the browser's guard
 	 * and still be rejected by `register_taxonomy()`'s 32-byte name limit.
 	 * This counter closes that feedback gap before submission.
+	 *
+	 * When the live counter activates, it also swaps the server-rendered field
+	 * description for a leaner note: the counter now carries the concrete byte
+	 * numbers, so the locale-tailored character estimate becomes redundant.
 	 */
 	private static function slug_byte_counter_script(): void {
 		$max_bytes = wc_get_attribute_slug_max_byte_length();
@@ -182,6 +206,17 @@ class WC_Admin_Attributes {
 			// string); skip the counter rather than emit broken inline JavaScript.
 			return;
 		}
+		// Leaner description for JS clients: the live counter below carries the concrete
+		// numbers, so the locale-tailored character estimate becomes redundant noise.
+		$js_description = wp_json_encode(
+			/* translators: %d: maximum slug length in bytes. */
+			sprintf( __( 'Unique slug/reference for the attribute. Non-ASCII characters use 2–4 bytes of the %d-byte limit.', 'woocommerce' ), $max_bytes ),
+			JSON_HEX_TAG | JSON_HEX_AMP
+		);
+		if ( false === $js_description ) {
+			// Keep the server-rendered description if encoding fails.
+			$js_description = 'null';
+		}
 		?>
 		<script type="text/javascript">
 			( function() {
@@ -196,10 +231,16 @@ class WC_Admin_Attributes {
 				}
 				var maxBytes = <?php echo (int) $max_bytes; ?>;
 				var template = <?php echo $template; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_TAG produces JS-safe output. ?>;
+				var jsDescription = <?php echo $js_description; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_TAG produces JS-safe output; 'null' fallback is a literal. ?>;
 				var encoder = new TextEncoder();
 				var counter = document.createElement( 'span' );
 				counter.style.display = 'block';
 				counter.style.marginTop = '4px';
+				if ( jsDescription ) {
+					// Swap the verbose server-rendered guidance for the lean note now that
+					// the live counter provides the concrete byte feedback.
+					description.textContent = jsDescription;
+				}
 				description.appendChild( counter );
 				function update() {
 					var bytes = encoder.encode( input.value ).length;
@@ -284,7 +325,7 @@ class WC_Admin_Attributes {
 								</th>
 								<td>
 									<input name="attribute_name" id="attribute_name" type="text" value="<?php echo esc_attr( $att_name ); ?>" maxlength="29" />
-									<p class="description"><?php esc_html_e( 'Unique slug/reference for the attribute. May be up to 29 bytes; non-ASCII characters each count as 2–4 bytes.', 'woocommerce' ); ?></p>
+									<p class="description"><?php echo esc_html( self::slug_field_description() ); ?></p>
 								</td>
 							</tr>
 							<tr class="form-field form-required">
@@ -503,7 +544,7 @@ class WC_Admin_Attributes {
 								<div class="form-field">
 									<label for="attribute_name"><?php esc_html_e( 'Slug', 'woocommerce' ); ?></label>
 									<input name="attribute_name" id="attribute_name" type="text" value="" maxlength="29" />
-									<p class="description"><?php esc_html_e( 'Unique slug/reference for the attribute. May be up to 29 bytes; non-ASCII characters each count as 2–4 bytes.', 'woocommerce' ); ?></p>
+									<p class="description"><?php echo esc_html( self::slug_field_description() ); ?></p>
 								</div>

 								<div class="form-field">
diff --git a/plugins/woocommerce/src/Internal/ProductAttributes/AttributeSlugLength.php b/plugins/woocommerce/src/Internal/ProductAttributes/AttributeSlugLength.php
new file mode 100644
index 00000000000..18bd64f8af1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductAttributes/AttributeSlugLength.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Product attribute slug length utilities.
+ *
+ * @package WooCommerce\Classes
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductAttributes;
+
+/**
+ * Utilities for communicating the product attribute slug length limit to users.
+ *
+ * @internal
+ *
+ * @since 11.1.0
+ */
+class AttributeSlugLength {
+
+	/**
+	 * Estimate how many characters fit in a product attribute slug for a given locale.
+	 *
+	 * The slug limit is enforced in bytes (see wc_get_attribute_slug_max_byte_length()),
+	 * but users think in characters. UTF-8 encodes a character in 1-4 bytes depending on
+	 * its script, so the character budget depends on the language. This maps the locale to
+	 * the typical byte width of its script and divides the byte budget by it, yielding a
+	 * rough, user-friendly maximum.
+	 *
+	 * This is a heuristic: a locale predicts the script its users most likely type,
+	 * not the actual slug they enter (a Greek-language store may still use Latin slugs),
+	 * so callers should present the result as an approximation and keep the byte limit
+	 * authoritative.
+	 *
+	 * @since 11.1.0
+	 * @param string $locale Locale to inspect, e.g. 'pt_BR'. Defaults to the current user's locale.
+	 * @return int Approximate maximum number of characters, never less than 1.
+	 */
+	public static function get_character_estimate( string $locale = '' ): int {
+		if ( '' === $locale ) {
+			// Admin screens render in the user's profile language (get_user_locale()),
+			// which can differ from the site language; estimate for what the user
+			// actually reads and types.
+			$locale = get_user_locale();
+		}
+
+		// Reduce the locale to its language subtag, e.g. 'pt_BR' or 'zh-Hans' -> 'pt' / 'zh'.
+		$language = strtolower( (string) strtok( $locale, '_-' ) );
+
+		// Space-padded lists of language subtags grouped by the typical UTF-8 byte width of
+		// their script, covering the locale IDs WordPress core actually installs — including
+		// its three-letter IDs (kir, ckb, snd, sah, ...) — plus common ISO aliases (ky, be).
+		// Three bytes: CJK, Thai, Georgian, Ethiopic, Tibetan, and Brahmic (Indic) scripts.
+		// Two bytes: Cyrillic, Greek, Hebrew, Arabic-script, and Armenian. Everything else
+		// (Latin and unknown) is treated as single-byte. The padding makes each match whole-word.
+		// Latin-script languages with heavy diacritics (e.g. Vietnamese) are intentionally
+		// single-byte: slugs pass through wc_sanitize_taxonomy_name(), whose sanitize_title()
+		// call transliterates accented Latin letters to plain ASCII before the byte limit
+		// applies ('Tiếng Việt đậm nhạt' is stored as 'tieng-viet-dam-nhat').
+		$three_byte = ' zh ja ko th ka hi bn ne ta te mr gu kn ml pa or si km lo my am as bo dzo ';
+		$two_byte   = ' ru uk bg sr be bel mk kk ky kir tg tt mn sah el he ar ary azb ckb fa haz ps skr snd ug ur hy ';
+
+		if ( false !== strpos( $three_byte, " {$language} " ) ) {
+			$byte_width = 3;
+		} elseif ( false !== strpos( $two_byte, " {$language} " ) ) {
+			$byte_width = 2;
+		} else {
+			$byte_width = 1;
+		}
+
+		return max( 1, intdiv( wc_get_attribute_slug_max_byte_length(), $byte_width ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/AttributeSlugLengthTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/AttributeSlugLengthTest.php
new file mode 100644
index 00000000000..fea2c663c0e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/AttributeSlugLengthTest.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Attribute slug length tests.
+ *
+ * @package WooCommerce\Tests\Internal\ProductAttributes
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductAttributes;
+
+use Automattic\WooCommerce\Internal\ProductAttributes\AttributeSlugLength;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the attribute slug length utility.
+ */
+class AttributeSlugLengthTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Reset the current user so a locale-specific admin created by a test cannot
+	 * leak into later tests, even if an assertion fails before an inline reset.
+	 */
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should derive the character estimate from the typical byte width of the locale's script.
+	 * @dataProvider locale_estimate_provider
+	 *
+	 * @param string $locale     Locale under test.
+	 * @param int    $byte_width Typical UTF-8 byte width of the locale's script.
+	 */
+	public function test_estimates_characters_by_script_byte_width( string $locale, int $byte_width ): void {
+		$this->assertSame(
+			intdiv( wc_get_attribute_slug_max_byte_length(), $byte_width ),
+			AttributeSlugLength::get_character_estimate( $locale ),
+			"Locale {$locale} should be treated as {$byte_width} byte(s) per character"
+		);
+	}
+
+	/**
+	 * Data provider for the per-locale estimate test.
+	 *
+	 * @return array
+	 */
+	public function locale_estimate_provider(): array {
+		return array(
+			'English (Latin)'                 => array( 'en_US', 1 ),
+			'Portuguese (Latin)'              => array( 'pt_BR', 1 ),
+			'Unknown locale (Latin)'          => array( 'xx_YY', 1 ),
+			// Vietnamese diacritics are transliterated to ASCII by sanitize_title()
+			// before the byte limit applies, so it gets the full Latin budget.
+			'Vietnamese (transliterated)'     => array( 'vi', 1 ),
+			'Russian (Cyrillic)'              => array( 'ru_RU', 2 ),
+			'Belarusian (Cyrillic, 3-letter)' => array( 'bel', 2 ),
+			'Kyrgyz (Cyrillic, 3-letter)'     => array( 'kir', 2 ),
+			'Sakha (Cyrillic, 3-letter)'      => array( 'sah', 2 ),
+			'Greek'                           => array( 'el', 2 ),
+			'Hebrew'                          => array( 'he_IL', 2 ),
+			'Sorani Kurdish (Arabic script)'  => array( 'ckb', 2 ),
+			'Uyghur (Arabic script)'          => array( 'ug_CN', 2 ),
+			'Chinese (CJK)'                   => array( 'zh_CN', 3 ),
+			'Chinese (hyphenated tag)'        => array( 'zh-Hans', 3 ),
+			'Japanese (CJK)'                  => array( 'ja', 3 ),
+			'Thai'                            => array( 'th', 3 ),
+			'Amharic (Ethiopic)'              => array( 'am', 3 ),
+			'Tibetan'                         => array( 'bo', 3 ),
+			'Hindi (Devanagari)'              => array( 'hi_IN', 3 ),
+			'Nepali (Devanagari)'             => array( 'ne_NP', 3 ),
+		);
+	}
+
+	/**
+	 * @testdox Should default to the current user's locale rather than the site locale.
+	 */
+	public function test_defaults_to_user_locale(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'role'   => 'administrator',
+				'locale' => 'ru_RU',
+			)
+		);
+		wp_set_current_user( $user_id );
+
+		$this->assertSame(
+			AttributeSlugLength::get_character_estimate( 'ru_RU' ),
+			AttributeSlugLength::get_character_estimate(),
+			'The default estimate should follow the user profile locale, not the site locale'
+		);
+	}
+
+	/**
+	 * @testdox Should never estimate fewer than one character.
+	 */
+	public function test_estimate_is_at_least_one_character(): void {
+		$this->assertGreaterThanOrEqual( 1, AttributeSlugLength::get_character_estimate( 'zh_CN' ) );
+	}
+}