Improve i18n compatibility for WPML, Polylang, and non-latin characters (#994)

* fix: improve i18n compatibility for WPML, Polylang, and non-latin character support

- Add Label::get_safe_enum_name() utility with optional transliteration
  for non-latin tax class/attribute/taxonomy names (#637, #409)
- Add "Transliterate non-latin characters" admin setting
- Replace get_page_by_path() with WP_Query for product slug resolution
  so WPML/Polylang can hook into the standard query pipeline (#403, #368)
- Split product connections: `products` (toType: Product) and
  `productsWithVariations` (toType: ProductUnion) so i18n plugins can
  register language where args on the standard type name (#811, #952)
- Add ProductTypesWithVariationsEnum for the ProductUnion connection
- Add i18n compatibility tests

* chore: Linter compliances met
This commit is contained in:
Geoff Taylor
2026-03-24 20:42:15 -04:00
committed by GitHub
parent 2e25a94314
commit f63dbb75e8
13 changed files with 389 additions and 46 deletions
+9
View File
@@ -122,6 +122,15 @@ class General extends Section {
'default' => 'keep_new_fallback_old',
'disabled' => defined( 'NO_QL_SESSION_HANDLER' ),
],
[
'name' => 'enable_transliteration',
'label' => __( 'Transliterate non-latin characters', 'wp-graphql-woocommerce' ),
'desc' => __( 'Converts non-latin characters (Cyrillic, Chinese, Arabic, etc.) to their latin equivalents in GraphQL type and enum names. Enable this if your WooCommerce tax classes, product attributes, or taxonomies use non-latin names. Requires the PHP intl extension.', 'wp-graphql-woocommerce' )
. ( ! function_exists( 'transliterator_transliterate' ) ? __( ' <strong>Warning:</strong> The PHP intl extension is not available. This setting will have no effect.', 'wp-graphql-woocommerce' ) : '' ),
'type' => 'checkbox',
'default' => 'off',
'disabled' => ! function_exists( 'transliterator_transliterate' ),
],
[
'name' => 'enable_unsupported_product_type',
'label' => __( 'Enable Unsupported types', 'wp-graphql-woocommerce' ),
@@ -162,6 +162,7 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) :
$include_directory_path = get_includes_directory();
// Include util class files.
require $include_directory_path . 'utils/class-label.php';
require $include_directory_path . 'utils/class-ql-session-handler.php';
require $include_directory_path . 'utils/class-session-transaction-manager.php';
+96 -24
View File
@@ -24,8 +24,23 @@ class Products {
* @return void
*/
public static function register_connections() {
// From RootQuery.
register_graphql_connection( self::get_connection_config() );
// Products connection (toType: Product) — compatible with i18n plugins.
register_graphql_connection(
self::get_connection_config(
[
'toType' => 'Product',
'fromFieldName' => 'products',
'connectionArgs' => self::get_product_connection_args(),
]
)
);
// Products with variations connection (toType: ProductUnion).
register_graphql_connection(
self::get_connection_config(
[ 'fromFieldName' => 'productsWithVariations' ]
)
);
// From Coupon.
register_graphql_connection(
@@ -266,7 +281,15 @@ class Products {
$to_type = $config['toType'];
$from_type = $config['fromType'];
if ( 'Product' === $to_type ) {
$config['connectionArgs'] = self::get_connection_args();
$args = self::get_product_connection_args();
$config['connectionArgs'] = ! empty( $config['connectionArgs'] )
? array_merge( $config['connectionArgs'], $args )
: $args;
} elseif ( 'ProductUnion' === $to_type ) {
$args = self::get_product_union_connection_args();
$config['connectionArgs'] = ! empty( $config['connectionArgs'] )
? array_merge( $config['connectionArgs'], $args )
: $args;
}
$taxonomies = self::get_product_connected_taxonomies();
@@ -333,13 +356,11 @@ class Products {
}
/**
* Returns array of where args.
*
* @param array $extra_args Extra connection args.
* Returns the shared base connection args used by both product connections.
*
* @return array
*/
public static function get_connection_args( $extra_args = [] ): array {
private static function get_base_connection_args(): array {
$args = [
'slugIn' => [
'type' => [ 'list_of' => 'String' ],
@@ -349,18 +370,6 @@ class Products {
'type' => 'String',
'description' => __( 'Limit result set to products assigned a specific status.', 'wp-graphql-woocommerce' ),
],
'type' => [
'type' => 'ProductTypesEnum',
'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ),
],
'typeIn' => [
'type' => [ 'list_of' => 'ProductTypesEnum' ],
'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
'typeNotIn' => [
'type' => [ 'list_of' => 'ProductTypesEnum' ],
'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
'sku' => [
'type' => 'String',
'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'wp-graphql-woocommerce' ),
@@ -495,10 +504,6 @@ class Products {
'type' => 'Boolean',
'description' => __( 'Limit result types to types supported by WooGraphQL.', 'wp-graphql-woocommerce' ),
],
'includeVariations' => [
'type' => 'Boolean',
'description' => __( 'Include variations in the result set.', 'wp-graphql-woocommerce' ),
],
'rating' => [
'type' => [ 'list_of' => 'Integer' ],
'description' => __( 'Limit result set to products with a specific average rating. Must be between 1 and 5', 'wp-graphql-woocommerce' ),
@@ -512,6 +517,73 @@ class Products {
];
}
return array_merge( get_wc_cpt_connection_args(), $args, $extra_args );
return array_merge( get_wc_cpt_connection_args(), $args );
}
/**
* Returns connection args for the Product connection (excludes variation types).
*
* @return array
*/
public static function get_product_connection_args(): array {
return array_merge(
self::get_base_connection_args(),
[
'type' => [
'type' => 'ProductTypesEnum',
'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ),
],
'typeIn' => [
'type' => [ 'list_of' => 'ProductTypesEnum' ],
'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
'typeNotIn' => [
'type' => [ 'list_of' => 'ProductTypesEnum' ],
'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
]
);
}
/**
* Returns connection args for the ProductUnion connection (includes variation types).
*
* @return array
*/
public static function get_product_union_connection_args(): array {
return array_merge(
self::get_base_connection_args(),
[
'type' => [
'type' => 'ProductTypesWithVariationsEnum',
'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ),
],
'typeIn' => [
'type' => [ 'list_of' => 'ProductTypesWithVariationsEnum' ],
'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
'typeNotIn' => [
'type' => [ 'list_of' => 'ProductTypesWithVariationsEnum' ],
'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ),
],
'includeVariations' => [
'type' => 'Boolean',
'description' => __( 'Include variations in the result set.', 'wp-graphql-woocommerce' ),
],
]
);
}
/**
* Returns array of where args.
*
* @deprecated Use get_product_connection_args() or get_product_union_connection_args() instead.
*
* @param array $extra_args Extra connection args.
*
* @return array
*/
public static function get_connection_args( $extra_args = [] ): array {
return array_merge( self::get_product_union_connection_args(), $extra_args );
}
}
@@ -8,7 +8,7 @@
namespace WPGraphQL\WooCommerce\Type\WPEnum;
use WPGraphQL\Type\WPEnumType;
use WPGraphQL\WooCommerce\Utils\Label;
/**
* Class Product_Attribute_Enum
@@ -28,7 +28,11 @@ class Product_Attribute_Enum {
$tax_object = get_taxonomy( $taxonomy );
if ( false !== $tax_object && in_array( 'product', $tax_object->object_type, true ) ) {
$taxonomy_values[ WPEnumType::get_safe_name( $taxonomy ) ] = [ 'value' => $taxonomy ];
$safe_name = Label::get_safe_enum_name( $taxonomy );
if ( null === $safe_name ) {
continue;
}
$taxonomy_values[ $safe_name ] = [ 'value' => $taxonomy ];
}
}
@@ -8,7 +8,7 @@
namespace WPGraphQL\WooCommerce\Type\WPEnum;
use WPGraphQL\Type\WPEnumType;
use WPGraphQL\WooCommerce\Utils\Label;
/**
* Class Product_Taxonomy
@@ -28,7 +28,11 @@ class Product_Taxonomy {
$tax_object = get_taxonomy( $taxonomy );
if ( false !== $tax_object && in_array( 'product', $tax_object->object_type, true ) ) {
$taxonomy_values[ WPEnumType::get_safe_name( $taxonomy ) ] = [ 'value' => $taxonomy ];
$safe_name = Label::get_safe_enum_name( $taxonomy );
if ( null === $safe_name ) {
continue;
}
$taxonomy_values[ $safe_name ] = [ 'value' => $taxonomy ];
}
}
@@ -59,5 +59,24 @@ class Product_Types {
'values' => $values,
]
);
register_graphql_enum_type(
'ProductTypesWithVariationsEnum',
[
'description' => __( 'Product type enumeration including variation types', 'wp-graphql-woocommerce' ),
'values' => apply_filters(
'graphql_product_types_with_variations_enum_values',
array_merge(
$values,
[
'VARIATION' => [
'value' => 'variation',
'description' => __( 'A product variation', 'wp-graphql-woocommerce' ),
],
]
)
),
]
);
}
}
+6 -2
View File
@@ -8,7 +8,7 @@
namespace WPGraphQL\WooCommerce\Type\WPEnum;
use WPGraphQL\Type\WPEnumType;
use WPGraphQL\WooCommerce\Utils\Label;
/**
* Class Tax_Class
@@ -33,7 +33,11 @@ class Tax_Class {
$classes = \WC_Tax::get_tax_classes();
foreach ( $classes as $class ) {
$values[ WPEnumType::get_safe_name( $class ) ] = [ 'value' => sanitize_title( $class ) ];
$safe_name = Label::get_safe_enum_name( $class );
if ( null === $safe_name ) {
continue;
}
$values[ $safe_name ] = [ 'value' => sanitize_title( $class ) ];
}
register_graphql_enum_type(
+12 -2
View File
@@ -53,8 +53,18 @@ class Product {
$product_id = \wc_get_product_id_by_sku( $id );
break;
case 'slug':
$post = get_page_by_path( $id, OBJECT, 'product' );
$product_id = ! empty( $post ) ? absint( $post->ID ) : 0;
$query = new \WP_Query(
[
'name' => $id,
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 1,
'fields' => 'ids',
]
);
/** @var int $post_id */
$post_id = ! empty( $query->posts ) ? $query->posts[0] : 0;
$product_id = absint( $post_id );
break;
case 'database_id':
$product_id = absint( $id );
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Label utility functions for GraphQL name formatting.
*
* @package WPGraphQL\WooCommerce\Utils
* @since TBD
*/
namespace WPGraphQL\WooCommerce\Utils;
use WPGraphQL\Type\WPEnumType;
/**
* Class Label
*/
class Label {
/**
* Returns a safe GraphQL enum name for the given value, optionally
* transliterating non-latin characters when the setting is enabled.
*
* Falls back to WPEnumType::get_safe_name() and returns null when
* the result contains no alphanumeric characters (i.e. is meaningless).
*
* @param string $value The raw value to convert to a safe enum name.
*
* @return string|null The safe name, or null if the value cannot produce a valid GraphQL name.
*/
public static function get_safe_enum_name( string $value ): ?string {
if (
'on' === woographql_setting( 'enable_transliteration', 'off' )
&& function_exists( 'transliterator_transliterate' )
&& preg_match( '/[^\x20-\x7E]/', $value )
) {
$value = transliterator_transliterate( 'Any-Latin; Latin-ASCII', $value );
}
if ( empty( $value ) ) {
return null;
}
$safe_name = WPEnumType::get_safe_name( $value );
if ( ! preg_match( '/[A-Za-z0-9]/', $safe_name ) ) {
return null;
}
return $safe_name;
}
}
+1 -1
View File
@@ -263,7 +263,7 @@ class ConnectionPaginationTest extends \Tests\WPGraphQL\WooCommerce\TestCase\Woo
);
$query = '
query ($first: Int, $last: Int, $after: String, $before: String, $where: RootQueryToProductUnionConnectionWhereArgs) {
query ($first: Int, $last: Int, $after: String, $before: String, $where: RootQueryToProductConnectionWhereArgs) {
products(first: $first, last: $last, after: $after, before: $before, where: $where) {
found
nodes {
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* Tests for internationalization (i18n) compatibility.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/637
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/409
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/403
*/
class I18nCompatibilityTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
/**
* Test that non-latin tax class names do not break schema introspection
* when transliteration is disabled.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/637
*/
public function testNonLatinTaxClassDoesNotBreakSchemaWhenTransliterationDisabled() {
// Ensure transliteration is disabled.
$existing = get_option( 'woographql_settings', [] );
update_option(
'woographql_settings',
array_merge( is_array( $existing ) ? $existing : [], [ 'enable_transliteration' => 'off' ] )
);
// Create tax classes with non-latin characters.
\WC_Tax::create_tax_class( 'Сниженная ставка', 'reduced-cyrillic' );
\WC_Tax::create_tax_class( '减税率', 'chinese-rate' );
\WC_Tax::create_tax_class( 'Valid Rate', 'valid-rate' );
$this->clearSchema();
// Run an introspection query — this should not throw a 500 error.
$query = '{
__schema {
types {
name
}
}
}';
$response = $this->graphql( compact( 'query' ) );
$this->assertQuerySuccessful(
$response,
[ $this->expectedField( '__schema.types', static::NOT_FALSY ) ]
);
// Query the TaxClassEnum values.
$query = '{
__type(name: "TaxClassEnum") {
enumValues {
name
}
}
}';
$response = $this->graphql( compact( 'query' ) );
$enum_values = $this->lodashGet( $response, 'data.__type.enumValues' );
$enum_names = array_column( $enum_values, 'name' );
// Non-latin names produce underscore-only values when transliteration is off.
// These should be skipped to avoid collisions and meaningless enum entries.
$this->assertNotContains( '_', $enum_names, 'Underscore-only enum values should be skipped.' );
// The valid latin class should still be present.
$this->assertContains( 'VALID_RATE', $enum_names, 'Valid latin tax class should be in the enum.' );
}
/**
* Test that enabling transliteration converts non-latin tax class names
* into valid, meaningful GraphQL enum values.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/637
*/
public function testNonLatinTaxClassTransliteratedWhenEnabled() {
if ( ! function_exists( 'transliterator_transliterate' ) ) {
$this->markTestSkipped( 'intl extension not available.' );
}
// Enable transliteration setting.
$existing = get_option( 'woographql_settings', [] );
update_option(
'woographql_settings',
array_merge( is_array( $existing ) ? $existing : [], [ 'enable_transliteration' => 'on' ] )
);
// Create tax classes with non-latin names.
\WC_Tax::create_tax_class( 'Ставка НДС', 'nds-rate' );
\WC_Tax::create_tax_class( '减税率', 'chinese-rate' );
\WC_Tax::create_tax_class( 'Valid Rate', 'valid-rate' );
$this->clearSchema();
// Query the TaxClassEnum values via introspection.
$query = '{
__type(name: "TaxClassEnum") {
enumValues {
name
}
}
}';
$response = $this->graphql( compact( 'query' ) );
$this->assertQuerySuccessful(
$response,
[ $this->expectedField( '__type.enumValues', static::NOT_FALSY ) ]
);
$enum_values = $this->lodashGet( $response, 'data.__type.enumValues' );
$enum_names = array_column( $enum_values, 'name' );
// Non-latin classes should be transliterated, not produce underscore-only names.
$this->assertNotContains( '_', $enum_names, 'Transliterated enum values should not be underscore-only.' );
// Verify all enum values have at least one alphanumeric character.
foreach ( $enum_values as $value ) {
$this->assertMatchesRegularExpression(
'/[A-Za-z0-9]/',
$value['name'],
sprintf( 'Enum value "%s" must contain at least one alphanumeric character.', $value['name'] )
);
}
// The Cyrillic class should be transliterated.
$this->assertContains( 'STAVKA_NDS', $enum_names, 'Cyrillic tax class should be transliterated.' );
// The Chinese class should be transliterated.
$this->assertContains( 'JIAN_SHUI_LU', $enum_names, 'Chinese tax class should be transliterated.' );
// The valid latin class should still be present.
$this->assertContains( 'VALID_RATE', $enum_names, 'Valid latin tax class should be in the enum.' );
}
/**
* Test that product slug resolution uses WordPress's standard query mechanisms
* which i18n plugins (WPML, Polylang) can hook into.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/403
*/
public function testProductSlugResolutionUsesNodeResolver() {
$product_id = $this->factory->product->createSimple();
$slug = get_post_field( 'post_name', $product_id );
$query = '
query ($slug: ID!) {
product(id: $slug, idType: SLUG) {
... on SimpleProduct {
databaseId
name
slug
}
}
}
';
$response = $this->graphql(
[
'query' => $query,
'variables' => [ 'slug' => $slug ],
]
);
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'product.databaseId', $product_id ),
$this->expectedField( 'product.slug', $slug ),
]
);
}
}
+1 -1
View File
@@ -1075,7 +1075,7 @@ class ProductQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQ
// Create query.
$query = '
query( $where: RootQueryToProductUnionConnectionWhereArgs ) {
query( $where: RootQueryToProductConnectionWhereArgs ) {
products( where: $where ) {
nodes {
... on SimpleProduct {
+12 -12
View File
@@ -283,8 +283,8 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\
);
$query = '
query ( $type: ProductTypesEnum, $typeIn: [ProductTypesEnum], $includeVariations: Boolean ) {
products( where: { type: $type, typeIn: $typeIn includeVariations: $includeVariations } ) {
query ( $type: ProductTypesWithVariationsEnum, $typeIn: [ProductTypesWithVariationsEnum], $includeVariations: Boolean ) {
productsWithVariations( where: { type: $type, typeIn: $typeIn includeVariations: $includeVariations } ) {
nodes {
id
}
@@ -297,9 +297,9 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\
*/
$response = $this->graphql( compact( 'query' ) );
$expected = [
$this->expectedField( 'products.nodes.0.id', $this->toRelayId( 'post', $product_id ) ),
$this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
$this->expectedField( 'productsWithVariations.nodes.0.id', $this->toRelayId( 'post', $product_id ) ),
$this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
];
$this->assertQuerySuccessful( $response, $expected );
@@ -310,22 +310,22 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\
$variables = [ 'type' => 'VARIATION' ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $product_id ) ),
$this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
$this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $product_id ) ),
$this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
];
$this->assertQuerySuccessful( $response, $expected );
/**
* Assert result with "typeIn" set to "VARIATION" & "VARIATION" products and variations are returned.
* Assert result with "includeVariations" returns products and variations.
*/
$variables = [ 'includeVariations' => true ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $product_id ) ),
$this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
$this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $product_id ) ),
$this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ),
$this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ),
];
$this->assertQuerySuccessful( $response, $expected );