` and shows a * label over it. The slug is the identity: it never changes, it is what a * product's terms are attached to, and it is what a storefront filter URL is * built from. The label is free text a merchant may edit at any time. * * So an attribute travels as `{key, name, values}`: the key is the slug, the * optional name is the label to show. Omitting the name preserves an existing * merchant label; creating a new attribute still requires one. Bridge protocol * 2 sent a label-to-values map and left the slug to be derived from the label, * which made a renamed attribute a second attribute and a long label an upload * this store had to refuse. Both shapes are read here, so a client and this * plugin can be deployed in either order. */ final class AttributeIdentity { /** * WooCommerce refuses a slug of this length or more, because "pa_" plus the * slug has to fit the 32-character taxonomy column. */ public const MAX_KEY_LENGTH = 28; /** * Read an attribute payload into rows, whichever shape it arrived in. * * @param array $attributes * @return array}> */ public static function rows(array $attributes): array { $rows = []; foreach ($attributes as $index => $entry) { if (is_array($entry) && (isset($entry['key']) || isset($entry['name']))) { $label = trim((string) ($entry['name'] ?? '')); $key = trim((string) ($entry['key'] ?? '')); $values = (array) ($entry['values'] ?? []); } else { $label = trim((string) $index); $key = ''; $values = (array) $entry; } if ($label === '' && $key === '') { continue; } $rows[] = [ 'key' => $key !== '' ? self::slug($key) : self::slug($label), 'label' => $label, 'values' => $values, ]; } return $rows; } /** * The keys an attribute payload states, in order. * * @param array $attributes * @return array */ public static function keys(array $attributes): array { return array_column(self::rows($attributes), 'key'); } /** * The slug WordPress would register for this name. * * A name that is already a slug comes back unchanged, so this is safe to * apply to a key as well as to a label. */ public static function slug(string $name): string { $slug = wc_sanitize_taxonomy_name($name); if (str_starts_with($slug, 'pa_')) { $slug = (string) substr($slug, 3); } if (function_exists('wc_check_if_attribute_name_is_reserved') && wc_check_if_attribute_name_is_reserved($slug)) { $slug = 'product-' . $slug; } return $slug; } /** * The identity this store keeps one attribute on a product under. * * A global attribute is a `pa_` taxonomy and the slug is the identity. A * per-product attribute has no taxonomy, so its own name is all there is; * it is reduced the same way so both sides compare alike. */ public static function fromProductAttribute(string $name): string { if (str_starts_with($name, 'pa_')) { return (string) substr($name, 3); } return self::slug($name); } }