From c76129c8f94638029310ad34f939447f960c115d Mon Sep 17 00:00:00 2001 From: Andres Daza Date: Sun, 10 May 2026 11:15:09 -0500 Subject: [PATCH] Add WPGraphQL support for translated ACF Options Pages Adds a new Graphql class that dynamically registers all ACF Options Page field groups as WPGraphQL root query fields, each with a `language` argument for Polylang translation support. How it works: - Discovers all ACF field groups with `options_page` location rules - Resolves the correct post_id for each options page (not hardcoded) - Registers a GraphQL object type and root query field per group - Uses ACF's `graphql_field_name` or generates one from the group title - Sets PLL()->curlang before resolving fields via get_field() - Validates language lookup before assigning to curlang - Wraps resolution in try/finally to safely restore language state - Uses String type for language arg (no wp-graphql-polylang dependency) - Uses ?? instead of ?: to preserve falsy values (false, 0) - PHP 7.4 compatible (no match expressions) - Only loaded when WPGraphQL and Polylang are active Example query: { sectionHeaders(language: "ES") { featuresTitle featuresSubtitle } } Closes #91 --- bea-acf-options-for-polylang.php | 4 + classes/graphql.php | 252 +++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 classes/graphql.php diff --git a/bea-acf-options-for-polylang.php b/bea-acf-options-for-polylang.php index 26681b3..d1b4d8a 100644 --- a/bea-acf-options-for-polylang.php +++ b/bea-acf-options-for-polylang.php @@ -83,4 +83,8 @@ function bea_acf_options_for_polylang_load() { if ( is_admin() ) { \BEA\ACF_Options_For_Polylang\Admin::get_instance(); } + + if ( class_exists( 'WPGraphQL' ) ) { + \BEA\ACF_Options_For_Polylang\Graphql::get_instance(); + } } diff --git a/classes/graphql.php b/classes/graphql.php new file mode 100644 index 0000000..e7cabf8 --- /dev/null +++ b/classes/graphql.php @@ -0,0 +1,252 @@ +curlang before + * resolving so that acf-options-for-polylang returns the correct translated values. + * + * @since 2.1.0 + */ +class Graphql { + + use Singleton; + + protected function init(): void { + add_action( 'graphql_register_types', [ $this, 'register_graphql_fields' ] ); + } + + /** + * Register all ACF Options Page field groups in WPGraphQL. + */ + public function register_graphql_fields(): void { + if ( ! function_exists( 'acf_get_field_groups' ) || ! function_exists( 'acf_get_fields' ) || ! function_exists( 'PLL' ) ) { + return; + } + + // Use the enum type from wp-graphql-polylang if available, otherwise fall back to String + $language_type = $this->get_language_arg_type(); + + $field_groups = acf_get_field_groups(); + + $options_pages = $this->get_options_pages_map(); + + foreach ( $field_groups as $group ) { + $post_id = $this->get_options_page_post_id( $group, $options_pages ); + + if ( ! $post_id ) { + continue; + } + + $acf_fields = acf_get_fields( $group['key'] ); + + if ( empty( $acf_fields ) ) { + continue; + } + + $field_name = ! empty( $group['graphql_field_name'] ) + ? $group['graphql_field_name'] + : lcfirst( $this->to_pascal_case( $group['title'] ) ); + + // Skip if the generated name is empty or starts with a digit (invalid GraphQL name) + if ( empty( $field_name ) || preg_match( '/^\d/', $field_name ) ) { + continue; + } + + $type_name = 'AofpOptions' . ucfirst( $field_name ); + + // Build GraphQL fields and map from ACF + $graphql_fields = []; + $field_map = []; + + foreach ( $acf_fields as $field ) { + $graphql_type = $this->acf_to_graphql_type( $field ); + + if ( ! $graphql_type ) { + continue; + } + + $camel_name = $this->to_camel_case( $field['name'] ); + $graphql_fields[ $camel_name ] = [ + 'type' => $graphql_type, + 'description' => $field['label'], + ]; + $field_map[ $camel_name ] = $field['name']; + } + + if ( empty( $graphql_fields ) ) { + continue; + } + + register_graphql_object_type( $type_name, [ + 'description' => sprintf( 'ACF Options Page fields: %s', $group['title'] ), + 'fields' => $graphql_fields, + ] ); + + register_graphql_field( 'RootQuery', $field_name, [ + 'type' => $type_name, + 'description' => sprintf( 'Get translated fields from ACF Options Page group: %s', $group['title'] ), + 'args' => [ + 'language' => [ + 'type' => $language_type, + 'description' => 'Polylang language code (e.g. ES, EN)', + ], + ], + 'resolve' => function ( $_root, $args ) use ( $field_map, $post_id ) { + $lang_switched = false; + $previous_lang = null; + + if ( ! empty( $args['language'] ) && function_exists( 'PLL' ) ) { + $lang = strtolower( $args['language'] ); + $language = PLL()->model->get_language( $lang ); + + if ( $language ) { + $previous_lang = PLL()->curlang; + PLL()->curlang = $language; + $lang_switched = true; + } + } + + try { + $result = []; + foreach ( $field_map as $camel_name => $acf_name ) { + $result[ $camel_name ] = get_field( $acf_name, $post_id ) ?? null; + } + + return $result; + } finally { + if ( $lang_switched ) { + PLL()->curlang = $previous_lang; + } + } + }, + ] ); + } + } + + /** + * Build a map of ACF Options Page slugs to their post_id values. + * + * @return array slug => post_id + */ + private function get_options_pages_map(): array { + if ( ! function_exists( 'acf_get_options_pages' ) ) { + return []; + } + + $pages = acf_get_options_pages(); + $map = []; + + if ( is_array( $pages ) ) { + foreach ( $pages as $page ) { + $slug = $page['menu_slug'] ?? ''; + $map[ $slug ] = $page['post_id'] ?? 'options'; + } + } + + return $map; + } + + /** + * Get the post_id for the options page a field group is assigned to. + * + * @param array $group ACF field group. + * @param array $options_pages Map of slug => post_id. + * + * @return string|false The post_id or false if not an options page group. + */ + private function get_options_page_post_id( array $group, array $options_pages ) { + if ( empty( $group['location'] ) ) { + return false; + } + + foreach ( $group['location'] as $rules ) { + foreach ( $rules as $rule ) { + if ( isset( $rule['param'] ) && $rule['param'] === 'options_page' && $rule['operator'] === '==' ) { + $slug = $rule['value'] ?? ''; + + return $options_pages[ $slug ] ?? 'options'; + } + } + } + + return false; + } + + /** + * Convert snake_case to camelCase. + */ + private function to_camel_case( string $string ): string { + return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) ); + } + + /** + * Convert a string to PascalCase. + */ + private function to_pascal_case( string $string ): string { + $cleaned = preg_replace( '/[^a-zA-Z0-9]+/', ' ', $string ); + + return str_replace( ' ', '', ucwords( $cleaned ) ); + } + + /** + * Map ACF field config to GraphQL scalar types. + * + * Returns null for complex types (image, gallery, repeater, etc.) + * and multi-value fields (multi-select, checkbox) that cannot be + * serialized as scalars. + * + * @param array $field ACF field configuration. + * + * @return string|null GraphQL type or null if unsupported. + */ + private function acf_to_graphql_type( array $field ) { + $type = $field['type'] ?? ''; + + switch ( $type ) { + case 'text': + case 'textarea': + case 'email': + case 'url': + case 'password': + case 'wysiwyg': + case 'oembed': + case 'radio': + case 'button_group': + case 'color_picker': + case 'date_picker': + case 'date_time_picker': + case 'time_picker': + return 'String'; + case 'select': + // Multi-select returns an array, which can't be serialized as String + return empty( $field['multiple'] ) ? 'String' : null; + case 'number': + case 'range': + return 'Float'; + case 'true_false': + return 'Boolean'; + case 'checkbox': + // Checkbox always returns an array + return null; + default: + // Complex types (image, file, gallery, repeater, group, + // relationship, post_object, taxonomy, user, google_map, + // flexible_content, clone, link) are not supported. + return null; + } + } + + /** + * Get the GraphQL type for the language argument. + * + * Uses String instead of LanguageCodeFilterEnum to avoid a hard dependency + * on wp-graphql-polylang. Accepts the same language codes (e.g. "ES", "EN"). + */ + private function get_language_arg_type(): string { + return 'String'; + } +}