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' ) );
+ }
+}