This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,196 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI;
use ACF\AI\Abilities\Abilities;
use ACF\AI\GEO\GEO;
/**
* Initializes the ACF AI functionality if enabled.
*/
class AI {
/**
* Constructs the AI class.
*
* @since 6.8.0
*
* @return void
*/
public function __construct() {
add_action( 'acf/init', array( $this, 'initialize' ) );
}
/**
* Initializes the AI functionality.
*
* @since 6.8.0
*
* @return void
*/
public function initialize() {
if ( $this->is_geo_enabled() ) {
new GEO();
}
if ( $this->is_ai_enabled() ) {
new Abilities();
$this->add_admin_ui_hooks();
}
}
/**
* Checks if AI functionality is enabled.
*
* @since 6.8.0
*
* @return boolean
*/
public function is_ai_enabled(): bool {
return (bool) acf_get_setting( 'enable_acf_ai' );
}
/**
* Checks if GEO functionality is enabled.
*
* @since 6.8.0
*
* @return boolean
*/
public function is_geo_enabled(): bool {
return (bool) acf_get_setting( 'enable_schema' );
}
/**
* Adds admin UI hooks.
*
* @since 6.8.0
*
* @return void
*/
public function add_admin_ui_hooks() {
// Add ACF AI tab to field groups.
add_filter( 'acf/field_group/additional_group_settings_tabs', array( $this, 'add_acf_ai_tab' ) );
add_action( 'acf/field_group/render_group_settings_tab/acf-ai', array( $this, 'render_acf_ai_tab' ) );
// Add ACF AI tab to post types.
add_filter( 'acf/post_type/additional_settings_tabs', array( $this, 'add_acf_ai_tab' ) );
add_action( 'acf/post_type/render_settings_tab/acf-ai', array( $this, 'render_acf_ai_tab' ) );
// Add ACF AI tab to taxonomies.
add_filter( 'acf/taxonomy/additional_settings_tabs', array( $this, 'add_acf_ai_tab' ) );
add_action( 'acf/taxonomy/render_settings_tab/acf-ai', array( $this, 'render_acf_ai_tab' ) );
}
/**
* Registers the AI tab in various contexts.
*
* @since 6.8.0
*
* @param array $tabs The existing tabs array.
* @return array
*/
public function add_acf_ai_tab( array $tabs ): array {
$tabs['acf-ai'] = __( 'ACF AI', 'acf' );
return $tabs;
}
/**
* Renders the ACF AI tab in various contexts.
*
* @since 6.8.0
*
* @param array $item The field group, post type, taxonomy, etc. being edited.
* @return void
*/
public function render_acf_ai_tab( array $item ) {
if ( empty( $item['key'] ) ) {
return;
}
$item_type = acf_determine_internal_post_type( $item['key'] );
if ( ! $item_type ) {
return;
}
$item_type = str_replace( '-', '_', $item_type );
$post_id = (int) acf_request_arg( 'post', 0 );
$allow_ai_access_val = ! empty( $item['allow_ai_access'] ) ? 1 : 0;
// If this is a new item, default to allowing AI access.
if ( ! $post_id ) {
$allow_ai_access_val = 1;
}
$allow_access_label = __( 'Allow AI Access', 'acf' );
$allow_access_desc = __( 'Allow AI systems to access and modify this content through the WordPress Abilities API.', 'acf' );
$ai_description_label = __( 'AI Description', 'acf' );
$ai_description_desc = __( 'Provide a description that will help AI systems understand the purpose and how to use this effectively.', 'acf' );
if ( 'acf_post_type' === $item_type ) {
$allow_access_label = __( 'Allow AI Access to Post Content', 'acf' );
$allow_access_desc = __( 'When enabled, AI models can access and interact with the content of posts in this post type using supported integrations. This feature uses the WordPress Abilities API.', 'acf' );
$ai_description_label = __( 'AI Guidance for Post Type', 'acf' );
$ai_description_desc = __( "Add a short explanation of what this post type is for. The clearer your description, the better the AI's output will be.", 'acf' );
}
if ( 'acf_taxonomy' === $item_type ) {
$allow_access_label = __( 'Allow AI Access to Taxonomy Terms', 'acf' );
$allow_access_desc = __( 'When enabled, AI models can access and interact with the terms in this taxonomy using supported integrations. This feature uses the WordPress Abilities API.', 'acf' );
$ai_description_label = __( 'AI Guidance for Taxonomy', 'acf' );
$ai_description_desc = __( "Add a short explanation of what this taxonomy is for. The clearer your description, the better the AI's output will be.", 'acf' );
}
if ( 'acf_field_group' === $item_type ) {
$allow_access_label = __( 'Allow AI Access to Field Data', 'acf' );
$allow_access_desc = __( 'When enabled, AI models can access and interact with the fields in this group using supported integrations. This feature uses the WordPress Abilities API.', 'acf' );
$ai_description_label = __( 'AI Context Description', 'acf' );
$ai_description_desc = __( "Add a short explanation of what this field group is for. The clearer your description, the better the AI's output will be.", 'acf' );
}
acf_render_field_wrap(
array(
'type' => 'true_false',
'name' => 'allow_ai_access',
'key' => 'allow_ai_access',
'prefix' => $item_type,
'value' => $allow_ai_access_val,
'label' => $allow_access_label,
'instructions' => $allow_access_desc,
'ui' => true,
'default' => 1,
)
);
acf_render_field_wrap(
array(
'type' => 'textarea',
'name' => 'ai_description',
'key' => 'ai_description',
'prefix' => $item_type,
'value' => $item['ai_description'] ?? '',
'label' => $ai_description_label,
'instructions' => $ai_description_desc,
'rows' => 4,
'conditions' => array(
array(
'field' => 'allow_ai_access',
'operator' => '==',
'value' => '1',
),
),
),
'div',
'field'
);
}
}
@@ -0,0 +1,42 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_Ability;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF REST Ability
*
* Custom ability class that extends WP_Ability to skip output validation.
* This is needed because REST API schemas don't always match Abilities API schemas exactly,
* but we want to proxy directly to REST API endpoints.
*/
class ACF_REST_Ability extends WP_Ability {
/**
* Override validate_output to always return true.
*
* Since we're proxying to WordPress REST API endpoints that have their own
* validation, we trust their output and skip Abilities API output validation.
*
* @since 6.8.0
*
* @param mixed $output The output to validate.
* @return true Always returns true to skip validation.
*/
protected function validate_output( $output ) {
return true;
}
}
@@ -0,0 +1,198 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_REST_Request;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* The ACF Abilities API integration.
*
* Extends the WordPress Abilities API to expose field groups, post types,
* taxonomies, and options pages when the "Allow AI Access" setting is enabled.
*/
class Abilities {
/**
* Array of registered ability group instances
*
* @var array
*/
private array $ability_groups = array();
/**
* Constructs the class.
*
* @since 6.8.0
*
* @return void
*/
public function __construct() {
$this->init();
}
/**
* Initialize the Abilities API integration.
*
* @since 6.8.0
*
* @return void
*/
public function init() {
// Register ability group classes.
$this->register_ability_group( 'field_group', FieldGroup::class );
$this->register_ability_group( 'post_type', PostType::class );
$this->register_ability_group( 'taxonomy', Taxonomy::class );
// Register categories (v0.3.0+ requirement).
add_action( 'wp_abilities_api_categories_init', array( $this, 'register_categories' ) );
// Register abilities.
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
// Fix for WordPress 6.9 Abilities API bug: parse JSON from query parameters.
add_filter( 'rest_request_before_callbacks', array( $this, 'parse_abilities_json_input' ), 10, 3 );
}
/**
* Register an ability group class.
*
* @since 6.8.0
*
* @param string $key Unique key for this ability group.
* @param string $class_name Fully qualified class name.
* @param array $args Optional constructor arguments.
* @return void
*/
private function register_ability_group( $key, $class_name, $args = array() ) {
if ( ! class_exists( $class_name ) ) {
return;
}
// Instantiate the class with any provided arguments.
if ( ! empty( $args ) ) {
$this->ability_groups[ $key ] = new $class_name( ...$args );
} else {
$this->ability_groups[ $key ] = new $class_name();
}
}
/**
* Get an ability group instance by key
*
* @since 6.8.0
*
* @param string $key The ability group key.
* @return object|null The ability group instance or null if not found.
*/
private function get_ability_group( $key ) {
return $this->ability_groups[ $key ] ?? null;
}
/**
* Register Ability Categories
*
* @since 6.8.0
*
* @return void
*/
public function register_categories() {
if ( ! function_exists( 'wp_register_ability_category' ) ) {
return;
}
// ACF Field Management category.
wp_register_ability_category(
'acf-field-management',
array(
'label' => __( 'ACF Field Management', 'acf' ),
'description' => __( 'Abilities for managing Advanced Custom Fields field groups and field data.', 'acf' ),
)
);
// WordPress Content Discovery category.
wp_register_ability_category(
'wordpress-content-discovery',
array(
'label' => __( 'WordPress Content Discovery', 'acf' ),
'description' => __( 'Abilities for discovering WordPress content types, taxonomies, and structure.', 'acf' ),
)
);
}
/**
* Register Abilities for ACF
*
* @since 6.8.0
*
* @return void
*/
public function register_abilities() {
if ( ! function_exists( 'wp_register_ability' ) ) {
return;
}
// Register abilities from all registered ability groups.
foreach ( $this->ability_groups as $ability_group ) {
if ( method_exists( $ability_group, 'register_abilities' ) ) {
$ability_group->register_abilities();
}
}
}
/**
* Parse JSON input from query parameters for Abilities API
*
* WordPress 6.9's Abilities API REST controller doesn't parse JSON strings
* from query parameters in GET requests. This filter fixes that by detecting
* JSON strings in the 'input' parameter and parsing them into objects/arrays.
*
* @since 6.8.0
*
* @param mixed $response Response object.
* @param array $handler Route handler info.
* @param WP_REST_Request $request Request object.
* @return mixed
*/
public function parse_abilities_json_input( $response, $handler, $request ) {
// Only process ACF abilities.
$route = $request->get_route();
if ( strpos( $route, '/wp-abilities/v1/abilities/acf/' ) !== 0 ) {
return $response;
}
// Only process GET and DELETE requests (POST uses JSON body which is already parsed).
if ( ! in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) {
return $response;
}
// Get the input query parameter.
$input = $request->get_param( 'input' );
// If input is a string that looks like JSON, try to parse it.
if ( is_string( $input ) && ! empty( $input ) ) {
$first_char = substr( trim( $input ), 0, 1 );
// Check if it starts with { or [ (JSON object or array).
if ( in_array( $first_char, array( '{', '[' ), true ) ) {
$parsed = json_decode( $input, true );
if ( json_last_error() === JSON_ERROR_NONE ) {
// Successfully parsed JSON - update the request parameter.
$request->set_param( 'input', $parsed );
}
}
}
return $response;
}
}
@@ -0,0 +1,222 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_REST_Request;
use WP_Error;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Abstract Ability Group
*
* Base class for all ability groups.
*/
abstract class AbstractAbilityGroup {
const REST_ABILITY_CLASS = 'ACF\AI\Abilities\ACF_REST_Ability';
/**
* Register abilities for this ability group
*
* @since 6.8.0
*
* @return void
*/
abstract public function register_abilities();
/**
* Check if the WordPress Abilities API is available
*
* @since 6.8.0
*
* @return boolean
*/
protected function is_abilities_api_available() {
return function_exists( 'wp_register_ability' );
}
/**
* Register an ability with error handling.
*
* @since 6.8.0
*
* @param string $id Ability ID.
* @param array $ability_args Ability arguments.
* @return object|null Registered ability object or null on failure.
*/
protected function register_ability( $id, $ability_args ) {
if ( ! $this->is_abilities_api_available() ) {
return null;
}
// Ensure meta array exists.
if ( ! isset( $ability_args['meta'] ) ) {
$ability_args['meta'] = array();
}
// Ensure mcp array exists.
if ( ! isset( $ability_args['meta']['mcp'] ) ) {
$ability_args['meta']['mcp'] = array();
}
// Set public to true by default for MCP exposure.
if ( ! isset( $ability_args['meta']['mcp']['public'] ) ) {
$ability_args['meta']['mcp']['public'] = true;
}
return wp_register_ability( $id, $ability_args );
}
/**
* Retrieves the AI-enabled ACF fields for the provided object.
*
* @since 6.8.0
*
* @param string $object_type The object type being queried.
* @param integer|string $object_id The object to get ACF fields for.
* @return array
*/
protected function get_acf_fields_for_object( $object_type, $object_id ) {
// Get field groups that show on this object.
$field_groups = acf_get_field_groups(
array(
$object_type => $object_id,
)
);
$acf_fields = array();
foreach ( $field_groups as $field_group ) {
// Only include AI-accessible field groups that are exposed in REST.
if ( empty( $field_group['allow_ai_access'] ) || empty( $field_group['show_in_rest'] ) ) {
continue;
}
$fields = acf_get_fields( $field_group['key'] );
if ( $fields ) {
$acf_fields[ $field_group['key'] ] = array(
'title' => $field_group['title'],
'key' => $field_group['key'],
'fields' => $this->format_acf_fields_for_schema( $fields ),
);
}
}
return $acf_fields;
}
/**
* A helper function to format ACF fields for schema output.
*
* @since 6.8.0
*
* @param array $fields The ACF fields array.
* @return array
*/
protected function format_acf_fields_for_schema( array $fields ): array {
$formatted_fields = array();
foreach ( $fields as $field ) {
$field_schema = array(
'key' => $field['key'],
'name' => $field['name'],
'label' => $field['label'],
'field_type' => $field['type'],
);
// Add description if available
if ( ! empty( $field['instructions'] ) ) {
$field_schema['description'] = $field['instructions'];
}
$field_schema = array_merge(
$field_schema,
acf_get_field_rest_schema( $field )
);
$formatted_fields[] = $field_schema;
}
return $formatted_fields;
}
/**
* Adds ACF fields to a schema.
*
* @since 6.8.0
*
* @param array $schema The schema to add fields to.
* @param array $acf_fields The ACF fields to add.
* @return array
*/
protected function add_acf_fields_to_schema( array $schema, array $acf_fields ): array {
if ( empty( $acf_fields ) ) {
return $schema;
}
$schema['properties']['acf'] = array(
'type' => 'object',
'description' => 'ACF field values',
'required' => false,
'properties' => array(),
);
foreach ( $acf_fields as $field_group ) {
foreach ( $field_group['fields'] as $field ) {
$schema['properties']['acf']['properties'][ $field['name'] ] = $field;
}
}
return $schema;
}
/**
* Execute a REST API request.
*
* @since 6.8.0
*
* @param string $method HTTP method (GET, POST, PUT, DELETE).
* @param string $rest_base REST API base.
* @param array $input Input parameters.
* @param integer $item_id Optional item ID for single item operations.
* @return array|WP_Error Response data or error.
*/
protected function execute_rest_request( string $method, string $rest_base, $input = array(), $item_id = null ) {
$endpoint = '/wp/v2/' . $rest_base;
if ( $item_id ) {
$endpoint .= '/' . intval( $item_id );
}
$request = new WP_REST_Request( $method, $endpoint );
// Set all input parameters.
foreach ( $input as $key => $value ) {
// Skip the ID since it's in the URL for single item operations.
if ( $key === 'id' && $item_id ) {
continue;
}
$request->set_param( $key, $value );
}
$response = rest_do_request( $request );
if ( $response->is_error() ) {
return $response->as_error();
}
return $response->get_data();
}
}
@@ -0,0 +1,582 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_Error;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF Field Group Abilities
*
* Handles ACF field group related abilities for the WordPress Abilities API.
*/
class FieldGroup extends AbstractAbilityGroup {
/**
* Register field group related abilities.
*
* @since 6.8.0
*
* @return void
*/
public function register_abilities() {
if ( ! $this->is_abilities_api_available() ) {
return;
}
// Register ACF field groups ability.
$this->register_ability(
'acf/field-groups',
array(
'label' => __( 'List ACF Field Groups', 'acf' ),
'description' => __( 'Get all ACF field groups that allow AI access.', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => array( 'object', 'null' ),
'properties' => array(),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'field_groups' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
),
),
'count' => array(
'type' => 'integer',
),
'message' => array(
'type' => 'string',
),
),
),
'execute_callback' => array( $this, 'get_field_groups' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
),
'show_in_rest' => true,
),
)
);
// Register field group ability.
$this->register_ability(
'acf/register-field-group',
array(
'label' => __( 'Register ACF Field Group', 'acf' ),
'description' => __( 'Register a new ACF field group schema with field definitions. This creates the field structure that will appear on content, not the field values themselves. Field values are set when creating or updating posts, terms, or other content.', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
'description' => 'The title of the field group',
'minLength' => 1,
),
'fields' => $this->get_fields_schema(),
'location' => $this->get_location_schema(),
'description' => array(
'type' => 'string',
'description' => 'A description for this field group',
),
'position' => array(
'type' => 'string',
'description' => 'Where to show the field group (normal, side, acf_after_title)',
'enum' => array( 'normal', 'side', 'acf_after_title' ),
'default' => 'normal',
),
'style' => array(
'type' => 'string',
'description' => 'Field group style (default, seamless)',
'enum' => array( 'default', 'seamless' ),
'default' => 'default',
),
'label_placement' => array(
'type' => 'string',
'description' => 'Where to place field labels (top, left)',
'enum' => array( 'top', 'left' ),
'default' => 'top',
),
'instruction_placement' => array(
'type' => 'string',
'description' => 'Where to show field instructions (label, field)',
'enum' => array( 'label', 'field' ),
'default' => 'label',
),
'hide_on_screen' => array(
'type' => 'array',
'description' => 'Items that should be hidden from the edit screen containing this field group',
'items' => array(
'type' => 'string',
'enum' => array(
'permalink',
'the_content',
'excerpt',
'custom_fields',
'discussion',
'comments',
'revisions',
'slug',
'author',
'format',
'page_attributes',
'featured_image',
'categories',
'tags',
'send-trackbacks',
),
),
),
'active' => array(
'type' => 'boolean',
'description' => 'Whether the field group is active',
'default' => true,
),
'show_in_rest' => array(
'type' => 'boolean',
'description' => 'Whether the field group is shown in the REST API',
'default' => true,
),
'allow_ai_access' => array(
'type' => 'boolean',
'description' => 'Whether the field group allows access to AI',
'default' => true,
),
'ai_description' => array(
'type' => 'string',
'description' => 'A short description of the field group to provide AI more context',
),
),
'required' => array( 'title', 'fields', 'location' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array(
'type' => 'boolean',
),
'field_group' => array(
'type' => 'object',
'properties' => array(
'ID' => array( 'type' => 'integer' ),
'key' => array( 'type' => 'string' ),
'title' => array( 'type' => 'string' ),
'fields' => array( 'type' => 'array' ),
'location' => array( 'type' => 'array' ),
'position' => array( 'type' => 'string' ),
'style' => array( 'type' => 'string' ),
'label_placement' => array( 'type' => 'string' ),
'instruction_placement' => array( 'type' => 'string' ),
'active' => array( 'type' => 'boolean' ),
'description' => array( 'type' => 'string' ),
'show_in_rest' => array( 'type' => 'boolean' ),
'allow_ai_access' => array( 'type' => 'boolean' ),
'ai_description' => array( 'type' => 'string' ),
),
),
'field_group_id' => array(
'type' => 'integer',
'description' => 'The ID of the created field group',
),
'message' => array(
'type' => 'string',
),
),
),
'execute_callback' => array( $this, 'create_field_group' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
}
/**
* Get the field schema that includes all registered field types.
*
* Returns a JSON Schema with oneOf containing schemas for all ACF field types,
* allowing the AI to see available properties for each field type.
*
* @since 6.8.0
*
* @return array
*/
private function get_fields_schema(): array {
$field_types = acf_get_field_types();
$schemas = array();
foreach ( $field_types as $field_type ) {
// Get the schema for this field type.
$schema = array();
if ( method_exists( $field_type, 'get_field_creation_schema' ) ) {
$schema = $field_type->get_field_creation_schema();
}
// Skip if the schema is empty.
if ( empty( $schema ) ) {
continue;
}
$schemas[] = $schema;
}
return array(
'type' => 'array',
'description' => 'Array of fields to add to the field group',
'minItems' => 1,
'items' => array(
'oneOf' => $schemas,
),
);
}
/**
* Returns the schema needed to create field group location rules.
*
* @since 6.8.0
*
* @return array
*/
private function get_location_schema(): array {
// Get all location types organized by category
$location_types = acf_get_location_rule_types();
// Build oneOf schemas for each location type
$location_rule_schemas = array();
foreach ( $location_types as $types ) {
foreach ( $types as $param => $label ) {
// Create a sample rule to get operators and values
$sample_rule = array( 'param' => $param );
// Get operators for this param
$operators = acf_get_location_rule_operators( $sample_rule );
// Build schema for this specific location type
$location_rule_schemas[] = array(
'type' => 'object',
'properties' => array(
'param' => array(
'type' => 'string',
'enum' => array( $param ),
'description' => $label,
),
'operator' => array(
'type' => 'string',
'enum' => array_keys( $operators ),
'description' => 'Comparison operator',
'default' => '==',
),
'value' => array(
'type' => 'string',
'description' => sprintf( 'Value for %s', $label ),
),
),
'required' => array( 'param', 'operator', 'value' ),
);
}
}
// Return full location schema supporting multiple groups and rules
return array(
'type' => 'array',
'description' => 'Location rules determining where this field group appears. Each array item is an OR group containing AND rules.',
'minItems' => 1,
'items' => array(
'type' => 'array',
'description' => 'Group of location rules (AND logic)',
'minItems' => 1,
'items' => array(
'oneOf' => $location_rule_schemas,
),
),
);
}
/**
* Callback for the "acf/get-field-groups" ability.
*
* @since 6.8.0
*
* @param array $input Ability input (unused).
* @return array
*/
public function get_field_groups( $input = array() ) {
unset( $input ); // Not used, but required by interface.
$field_groups = $this->get_ai_accessible_field_groups();
$count = count( $field_groups );
return array(
'field_groups' => $field_groups,
'count' => $count,
'message' => sprintf(
/* translators: %d: Number of found field groups */
_n( 'Found %d ACF field group.', 'Found %d ACF field groups.', $count, 'acf' ),
$count
),
);
}
/**
* A helper function to get the field groups that allow AI access.
*
* @since 6.8.0
*
* @return array
*/
public function get_ai_accessible_field_groups(): array {
$field_groups = acf_get_field_groups();
$ai_accessible = array();
foreach ( $field_groups as $field_group ) {
if ( $this->is_field_group_ai_accessible( $field_group ) ) {
$ai_accessible[] = $field_group;
}
}
return $ai_accessible;
}
/**
* Check if a field group allows AI access.
*
* @since 6.8.0
*
* @param array $field_group Field group array.
* @return boolean
*/
private function is_field_group_ai_accessible( $field_group ): bool {
return ! empty( $field_group['allow_ai_access'] );
}
/**
* Callback for the "acf/register-field-group" ability.
*
* @since 6.8.0
*
* @param array $input Ability arguments containing title and fields.
* @return array|WP_Error
*/
public function create_field_group( $input = array() ) {
// Prepare the field group data.
$field_group_data = array(
'key' => 'group_' . uniqid(),
'title' => sanitize_text_field( $input['title'] ),
'fields' => $input['fields'],
'location' => $this->sanitize_location_rules( $input['location'] ),
'description' => isset( $input['description'] ) ? sanitize_textarea_field( $input['description'] ) : '',
'position' => $input['position'] ?? 'normal',
'style' => $input['style'] ?? 'default',
'label_placement' => $input['label_placement'] ?? 'top',
'instruction_placement' => $input['instruction_placement'] ?? 'label',
'hide_on_screen' => ! empty( $input['hide_on_screen'] ) ? $input['hide_on_screen'] : array(),
'active' => ! isset( $input['active'] ) || $input['active'],
'show_in_rest' => ! isset( $input['show_in_rest'] ) || $input['show_in_rest'],
'allow_ai_access' => ! isset( $input['allow_ai_access'] ) || $input['allow_ai_access'],
'ai_description' => isset( $input['ai_description'] ) ? sanitize_text_field( $input['ai_description'] ) : '',
);
// Create the field group using ACF's function.
add_filter( 'acf/prepare_field_for_import', array( $this, 'prepare_field_for_ability_import' ), 5 );
$field_group = acf_import_field_group( $field_group_data );
remove_filter( 'acf/prepare_field_for_import', array( $this, 'prepare_field_for_ability_import' ), 5 );
if ( empty( $field_group['ID'] ) || ! is_int( $field_group['ID'] ) ) {
return new WP_Error(
'field_group_creation_failed',
__( 'Failed to create the field group', 'acf' ),
array( 'field_group_data' => $field_group )
);
}
return array(
'success' => true,
'field_group' => $field_group,
'field_group_id' => $field_group['ID'],
'message' => sprintf(
/* translators: %s: Field group title */
__( 'Field group "%s" created successfully.', 'acf' ),
$field_group['title']
),
);
}
/**
* Ensures a field has a key and name before import and sanitizes user input.
*
* @since 6.8.0
*
* @param array $field The field being prepared for import.
* @return array The field with key, name, and sanitized values.
*/
public function prepare_field_for_ability_import( $field ) {
// Generate field name if not provided.
if ( empty( $field['name'] ) && ! empty( $field['label'] ) ) {
$field['name'] = acf_slugify( $field['label'], '_' );
}
// Generate field key if not provided.
if ( empty( $field['key'] ) ) {
$field['key'] = 'field_' . uniqid();
}
if ( ! empty( $field['label'] ) ) {
$field['label'] = sanitize_text_field( $field['label'] );
}
if ( ! empty( $field['instructions'] ) ) {
$field['instructions'] = wp_kses_post( $field['instructions'] );
}
if ( ! empty( $field['placeholder'] ) ) {
$field['placeholder'] = sanitize_text_field( $field['placeholder'] );
}
if ( ! empty( $field['prepend'] ) ) {
$field['prepend'] = sanitize_text_field( $field['prepend'] );
}
if ( ! empty( $field['append'] ) ) {
$field['append'] = sanitize_text_field( $field['append'] );
}
if ( ! empty( $field['wrapper']['class'] ) ) {
$field['wrapper']['class'] = sanitize_text_field( $field['wrapper']['class'] );
}
if ( ! empty( $field['wrapper']['id'] ) ) {
$field['wrapper']['id'] = sanitize_key( $field['wrapper']['id'] );
}
if ( ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
$field['choices'] = array_map( 'sanitize_text_field', $field['choices'] );
}
if ( isset( $field['min'] ) ) {
$field['min'] = absint( $field['min'] );
}
if ( isset( $field['max'] ) ) {
$field['max'] = absint( $field['max'] );
}
if ( isset( $field['default_value'] ) ) {
if ( is_string( $field['default_value'] ) ) {
$field['default_value'] = sanitize_text_field( $field['default_value'] );
} elseif ( is_array( $field['default_value'] ) ) {
$field['default_value'] = array_map( 'sanitize_text_field', $field['default_value'] );
}
}
if ( ! empty( $field['message'] ) ) {
$field['message'] = wp_kses_post( $field['message'] );
}
if ( ! empty( $field['conditional_logic'] ) && is_array( $field['conditional_logic'] ) ) {
$field['conditional_logic'] = $this->sanitize_conditional_logic( $field['conditional_logic'] );
}
return $field;
}
/**
* Sanitize location rules for a field group.
*
* @since 6.8.0
*
* @param array $location The location rules array.
* @return array The sanitized location rules.
*/
private function sanitize_location_rules( array $location ): array {
foreach ( $location as &$group ) {
if ( ! is_array( $group ) ) {
continue;
}
foreach ( $group as &$rule ) {
if ( ! is_array( $rule ) ) {
continue;
}
if ( isset( $rule['param'] ) ) {
$rule['param'] = sanitize_text_field( $rule['param'] );
}
if ( isset( $rule['operator'] ) ) {
$rule['operator'] = sanitize_text_field( $rule['operator'] );
}
if ( isset( $rule['value'] ) ) {
$rule['value'] = sanitize_text_field( $rule['value'] );
}
}
}
return $location;
}
/**
* Sanitize conditional logic rules for a field.
*
* @since 6.8.0
*
* @param array $conditional_logic The conditional logic array.
* @return array The sanitized conditional logic.
*/
private function sanitize_conditional_logic( array $conditional_logic ): array {
foreach ( $conditional_logic as &$group ) {
if ( ! is_array( $group ) ) {
continue;
}
foreach ( $group as &$rule ) {
if ( ! is_array( $rule ) ) {
continue;
}
if ( isset( $rule['field'] ) ) {
$rule['field'] = sanitize_text_field( $rule['field'] );
}
if ( isset( $rule['operator'] ) ) {
$rule['operator'] = sanitize_text_field( $rule['operator'] );
}
if ( isset( $rule['value'] ) ) {
$rule['value'] = sanitize_text_field( $rule['value'] );
}
}
}
return $conditional_logic;
}
}
@@ -0,0 +1,755 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_Error;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF Post Type Abilities
*
* Handles ACF custom post type related abilities for the WordPress Abilities API.
*/
class PostType extends AbstractAbilityGroup {
/**
* Register post type related abilities.
*
* @since 6.8.0
*
* @return void
*/
public function register_abilities() {
if ( ! $this->is_abilities_api_available() ) {
return;
}
// Register ACF Custom Post Types resource.
$this->register_ability(
'acf/custom-post-types',
array(
'label' => __( 'ACF Custom Post Types', 'acf' ),
'description' => __( 'Get all ACF registered custom post types', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => 'null',
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'custom_post_types' => array(
'type' => 'array',
'items' => array( 'type' => 'object' ),
),
'count' => array( 'type' => 'integer' ),
'message' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( $this, 'get_custom_post_types' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
// Register custom post type ability.
$this->register_ability(
'acf/register-custom-post-type',
array(
'label' => __( 'Register Custom Post Type', 'acf' ),
'description' => __( 'Register a new post type definition in WordPress (e.g., "Book", "Event"). This creates the post type schema itself, not individual posts. Use the create post abilities to add posts to an existing post type.', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_type' => array(
'type' => 'string',
'pattern' => '^[a-z0-9_-]*$',
'maxLength' => 20,
'description' => 'The post type key (slug)',
'required' => true,
),
'label' => array(
'type' => 'string',
'description' => 'The singular label for the post type',
'required' => true,
),
'plural_label' => array(
'type' => 'string',
'description' => 'The plural label for the post type',
'required' => true,
),
'description' => array(
'type' => 'string',
'description' => 'Description of the post type',
'required' => false,
),
'public' => array(
'type' => 'boolean',
'description' => 'Whether the post type is public',
'required' => false,
),
'hierarchical' => array(
'type' => 'boolean',
'description' => 'Whether the post type is hierarchical',
'required' => false,
),
'supports' => array(
'type' => 'array',
'description' => 'Features the post type supports. Can be array of strings ["title", "editor"] or object {"title": true, "editor": false}. Available: title, editor, author, thumbnail, excerpt, comments, trackbacks, revisions, page-attributes, custom-fields, post-formats',
'required' => false,
),
'show_in_rest' => array(
'type' => 'boolean',
'description' => 'Whether to show this post type in the REST API (required for AI abilities)',
'required' => false,
),
'rest_base' => array(
'type' => 'string',
'description' => 'Custom REST API base path (defaults to post type key)',
'required' => false,
),
'allow_ai_access' => array(
'type' => 'boolean',
'description' => 'Whether to allow AI access to this post type',
'required' => false,
),
'ai_description' => array(
'type' => 'string',
'description' => 'Description to help AI understand the purpose of this post type',
'required' => false,
),
'menu_icon' => array(
'type' => array( 'string', 'object' ),
'description' => 'Menu icon (dashicon class, URL, or object with type and value)',
'required' => false,
),
'menu_position' => array(
'type' => 'integer',
'description' => 'Position in the admin menu (5-100)',
'required' => false,
),
'has_archive' => array(
'type' => 'boolean',
'description' => 'Whether the post type has an archive page',
'required' => false,
),
'has_archive_slug' => array(
'type' => 'string',
'description' => 'Custom slug for the archive page',
'required' => false,
),
'taxonomies' => array(
'type' => 'array',
'description' => 'Array of taxonomy names to associate with this post type',
'items' => array( 'type' => 'string' ),
'required' => false,
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'post_type' => array( 'type' => 'object' ),
'message' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( $this, 'create_custom_post_type' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
'show_in_rest' => true,
),
)
);
// Register abilities for each ACF custom post type that has REST API enabled.
$this->register_acf_post_type_abilities();
}
/**
* Register abilities for each ACF custom post type that has REST API enabled.
*
* @since 6.8.0
*
* @return void
*/
private function register_acf_post_type_abilities() {
$acf_post_types = acf_get_acf_post_types();
foreach ( $acf_post_types as $acf_post_type ) {
$post_type_name = $acf_post_type['post_type'] ?? '';
if ( ! $post_type_name ) {
continue;
}
// Check if the post type is active and AI access is enabled.
if ( empty( $acf_post_type['active'] ) || empty( $acf_post_type['allow_ai_access'] ) ) {
continue;
}
// Sanitize post type name for feature ID (convert underscores to hyphens, ensure lowercase)
$sanitized_post_type_name = str_replace( '_', '-', strtolower( $post_type_name ) );
// Skip if we can't retrieve the post type object or if it isn't configured with REST API access.
$post_type_object = get_post_type_object( $post_type_name );
if ( ! $post_type_object || empty( $post_type_object->show_in_rest ) ) {
continue;
}
$rest_base = acf_get_object_type_rest_base( $post_type_object );
$post_type_label = $post_type_object->labels->singular_name ?? $post_type_name;
$post_type_label_plural = $post_type_object->labels->name ?? $post_type_name . 's';
// Get AI description for enhanced ability descriptions
$ai_description = $acf_post_type['ai_description'] ?? '';
$description_suffix = $ai_description ? ' ' . $ai_description : '';
// Get ACF fields for this post type.
$acf_fields = $this->get_acf_fields_for_object( 'post_type', $post_type_name );
// Get schemas from REST controller.
$item_schema = $this->get_rest_item_output_schema( $acf_fields, $post_type_name );
$collection_schema = $this->get_rest_item_output_schema( $acf_fields, $post_type_name, 'collection' );
// Register query/list feature for this post type
$this->register_ability(
'acf/' . $sanitized_post_type_name . 's',
array(
/* translators: %s The plural label for the custom post type. */
'label' => sprintf( __( 'Query %s', 'acf' ), $post_type_label_plural ),
/* translators: %s The plural label for the custom post type. */
'description' => sprintf( __( 'Get a list of %s that match the query parameters.', 'acf' ), strtolower( $post_type_label_plural ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => array( 'object', 'null' ),
'properties' => array(
'per_page' => array(
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
),
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'search' => array(
'type' => 'string',
'description' => 'Limit results to those matching a string.',
),
'slug' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
'description' => 'Limit result set to posts with one or more specific slugs.',
),
'orderby' => array(
'type' => 'string',
'enum' => array( 'date', 'id', 'modified', 'relevance', 'slug', 'title' ),
'default' => 'date',
'description' => 'Sort collection by post attribute.',
),
'order' => array(
'type' => 'string',
'enum' => array( 'asc', 'desc' ),
'default' => 'desc',
'description' => 'Order sort attribute ascending or descending.',
),
),
'additionalProperties' => false,
),
'output_schema' => $collection_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
return $this->execute_rest_request( 'GET', $rest_base, $input );
},
'permission_callback' => function () use ( $post_type_object ) {
// For querying, allow if user can read or if they can read private posts of this type.
return current_user_can( 'read' ) || current_user_can( $post_type_object->cap->read_private_posts );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register create ability for this post type
$this->register_ability(
'acf/create-' . $sanitized_post_type_name,
array(
/* translators: %s The singular label for the custom post type. */
'label' => sprintf( __( 'Create %s', 'acf' ), $post_type_label ),
/* translators: %s The singular label for the custom post type. */
'description' => sprintf( __( 'Create a new "%s" post item.', 'acf' ), strtolower( $post_type_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => $this->get_rest_item_input_schema( $acf_fields, $post_type_label, 'create', $post_type_name ),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
return $this->execute_rest_request( 'POST', $rest_base, $input );
},
'permission_callback' => function () use ( $post_type_object ) {
return current_user_can( $post_type_object->cap->create_posts );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register view single ability for this post type
$this->register_ability(
'acf/view-' . $sanitized_post_type_name,
array(
/* translators: %s The singular label for the custom post type. */
'label' => sprintf( __( 'View a %s', 'acf' ), $post_type_label ),
/* translators: %s The singular label for the custom post type. */
'description' => sprintf( __( 'Get a %s by its ID.', 'acf' ), strtolower( $post_type_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s to view.', strtolower( $post_type_label ) ),
'required' => true,
),
),
),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'GET', $rest_base, $input, $item_id );
},
'permission_callback' => function () {
return current_user_can( 'read' );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register update ability for this post type
$this->register_ability(
'acf/update-' . $sanitized_post_type_name,
array(
/* translators: %s The singular label for the custom post type. */
'label' => sprintf( __( 'Update a %s', 'acf' ), $post_type_label ),
/* translators: %s The singular label for the custom post type. */
'description' => sprintf( __( 'Update a %s by its ID.', 'acf' ), strtolower( $post_type_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => $this->get_rest_item_input_schema( $acf_fields, $post_type_label, 'update', $post_type_name ),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'PUT', $rest_base, $input, $item_id );
},
'permission_callback' => function () use ( $post_type_object ) {
return current_user_can( $post_type_object->cap->edit_posts );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register delete ability for this post type.
$this->register_ability(
'acf/delete-' . $sanitized_post_type_name,
array(
/* translators: %s The singular label for the custom post type. */
'label' => sprintf( __( 'Delete a %s', 'acf' ), $post_type_label ),
/* translators: %s The singular label for the custom post type. */
'description' => sprintf( __( 'Delete a %s by its ID.', 'acf' ), strtolower( $post_type_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s to delete.', strtolower( $post_type_label ) ),
'required' => true,
),
),
),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'DELETE', $rest_base, $input, $item_id );
},
'permission_callback' => function () use ( $post_type_object ) {
return current_user_can( $post_type_object->cap->delete_posts );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => true,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
}
}
/**
* Builds a basic input schema for creating or updating a post type item.
*
* We only include a few common properties since the REST API handles the
* validation and updates.
*
* @since 6.8.0
*
* @param array $acf_fields An array of ACF fields present on the post type.
* @param string $post_type_label The singular label for the post type.
* @param string $action The action being performed on the item.
* @param string $post_type_name The post type name/key.
* @return array
*/
private function get_rest_item_input_schema( array $acf_fields, string $post_type_label, string $action = 'create', string $post_type_name = '' ): array {
$schema = array(
'type' => 'object',
'properties' => array(),
);
if ( 'update' === $action ) {
$schema['properties']['id'] = array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s to update.', strtolower( $post_type_label ) ),
'required' => true,
);
}
$schema['properties']['title'] = array(
'type' => 'string',
'description' => sprintf( 'The title of the %s.', strtolower( $post_type_label ) ),
'required' => 'update' !== $action,
);
$schema['properties']['content'] = array(
'type' => 'string',
'description' => sprintf( 'The content of the %s.', strtolower( $post_type_label ) ),
'required' => false,
);
// TODO: Provide enum?
$schema['properties']['status'] = array(
'type' => 'string',
'description' => 'The status of the post (publish, draft, etc.)',
'required' => false,
);
// Only add featured_media if the post type supports thumbnails.
if ( ! empty( $post_type_name ) && post_type_supports( $post_type_name, 'thumbnail' ) ) {
$schema['properties']['featured_media'] = array(
'type' => 'integer',
'description' => 'The ID of the featured image (attachment) for this post. The attachment must exist and be an image. Set to 0 to remove; invalid or non-image IDs will not set a featured image.',
'required' => false,
);
}
// Only add author if the post type supports author.
if ( ! empty( $post_type_name ) && post_type_supports( $post_type_name, 'author' ) ) {
$schema['properties']['author'] = array(
'type' => 'integer',
'description' => 'The ID of the user to assign as the author of this post. The user must exist and have permission to be assigned as an author.',
'required' => false,
);
}
return $this->add_acf_fields_to_schema( $schema, $acf_fields );
}
/**
* Gets the REST schema for item(s) in a CPT.
*
* @since 6.8.0
*
* @param array $acf_fields An array of ACF fields present on the post type.
* @param string $post_type The post type to get the schema for.
* @param string $type Schema type: 'item' or 'collection'.
* @return array|null Schema array or null if not available.
*/
private function get_rest_item_output_schema( array $acf_fields, string $post_type, string $type = 'item' ) {
$post_type_object = get_post_type_object( $post_type );
if ( ! $post_type_object ) {
return null;
}
$controller = $post_type_object->get_rest_controller();
if ( ! $controller || ! method_exists( $controller, 'get_public_item_schema' ) ) {
return null;
}
$schema = $controller->get_public_item_schema();
$schema = $this->add_acf_fields_to_schema( $schema, $acf_fields );
if ( $type === 'collection' ) {
return array(
'type' => 'array',
'items' => $schema,
);
}
return $schema;
}
/**
* Callback for the "acf/get-custom-post-types" ability.
*
* @since 6.8.0
*
* @param array $input An array of input args.
* @return array
*/
public function get_custom_post_types( $input ) {
unset( $input ); // Not used, but required by interface.
// Get ACF custom post types.
$acf_post_types = acf_get_acf_post_types();
$custom_post_types = array();
foreach ( $acf_post_types as $acf_post_type ) {
$post_type_name = $acf_post_type['post_type'] ?? '';
if ( ! $post_type_name ) {
continue;
}
if ( empty( $acf_post_type['active'] ) || empty( $acf_post_type['allow_ai_access'] ) ) {
continue;
}
$post_type_object = get_post_type_object( $post_type_name );
if ( $post_type_object ) {
$post_type_data = array(
'post_type' => $post_type_name,
'label' => $post_type_object->label,
'labels' => (array) $post_type_object->labels,
'description' => $post_type_object->description,
'public' => $post_type_object->public,
'hierarchical' => $post_type_object->hierarchical,
'supports' => get_all_post_type_supports( $post_type_name ),
'acf_settings' => $acf_post_type,
);
// Add ACF field groups information
$acf_fields = $this->get_acf_fields_for_object( 'post_type', $post_type_name );
if ( ! empty( $acf_fields ) ) {
$post_type_data['acf_field_groups'] = $acf_fields;
}
$custom_post_types[] = $post_type_data;
}
}
$count = count( $custom_post_types );
return array(
'custom_post_types' => $custom_post_types,
'count' => $count,
'message' => sprintf(
/* translators: %d: Number of ACF custom post types */
_n( 'Found %d ACF custom post type', 'Found %d ACF custom post types', $count, 'acf' ),
$count
),
);
}
/**
* Callback for the "acf/register-custom-post-type" ability.
*
* @since 6.8.0
*
* @param array $input An array of input args.
* @return array|WP_Error
*/
public function create_custom_post_type( $input ) {
// Required parameters
$post_type = sanitize_key( $input['post_type'] );
$label = sanitize_text_field( $input['label'] );
$plural_label = sanitize_text_field( $input['plural_label'] );
// Check if post type already exists.
if ( post_type_exists( $post_type ) ) {
return new WP_Error(
'post_type_exists',
__( 'A post type with this key already exists', 'acf' ),
array( 'status' => 400 )
);
}
// Basic optional parameters
$description = sanitize_text_field( $input['description'] ?? '' );
$public = $input['public'] ?? true;
$hierarchical = $input['hierarchical'] ?? false;
$supports = $input['supports'] ?? array( 'title', 'editor' );
// REST API settings
$show_in_rest = $input['show_in_rest'] ?? true;
$rest_base = $input['rest_base'] ?? '';
// AI settings
$allow_ai_access = $input['allow_ai_access'] ?? true;
$ai_description = $input['ai_description'] ?? '';
// Menu settings
$menu_icon = $input['menu_icon'] ?? '';
$menu_position = $input['menu_position'] ?? null;
// Archive settings
$has_archive = $input['has_archive'] ?? false;
$has_archive_slug = $input['has_archive_slug'] ?? '';
// Taxonomies
$taxonomies = $input['taxonomies'] ?? array();
// Handle supports parameter - convert from associative array to simple array if needed
if ( is_array( $supports ) && ! empty( $supports ) ) {
// Check if this is an associative array (like {'title': true, 'editor': false})
if ( array_keys( $supports ) !== range( 0, count( $supports ) - 1 ) ) {
// Convert associative array to simple array of enabled features
$enabled_supports = array();
foreach ( $supports as $feature => $enabled ) {
if ( $enabled ) {
$enabled_supports[] = sanitize_key( $feature );
}
}
$supports = $enabled_supports;
} else {
$supports = array_map( 'sanitize_key', $supports );
}
}
// Use ACF's method to create the post type.
$post_type_data = array(
'key' => uniqid( 'post_type_' ),
'post_type' => $post_type,
'title' => $plural_label,
'labels' => wp_parse_args(
array(
'name' => $plural_label,
'singular_name' => $label,
),
acf_get_internal_post_type_instance( 'acf-post-type' )->get_settings_array()['labels']
),
'description' => $description,
'public' => $public ? 1 : 0,
'hierarchical' => $hierarchical ? 1 : 0,
'supports' => $supports,
'active' => 1,
// REST API settings
'show_in_rest' => $show_in_rest ? 1 : 0,
// AI settings
'allow_ai_access' => $allow_ai_access ? 1 : 0,
);
// Add optional settings only if provided
if ( ! empty( $rest_base ) ) {
$post_type_data['rest_base'] = sanitize_text_field( $rest_base );
}
if ( ! empty( $ai_description ) ) {
$post_type_data['ai_description'] = sanitize_text_field( $ai_description );
}
if ( ! empty( $menu_icon ) ) {
// Handle menu_icon which can be string or object
if ( is_string( $menu_icon ) ) {
$post_type_data['menu_icon'] = array(
'type' => strpos( $menu_icon, 'http' ) === 0 ? 'url' : 'dashicons',
'value' => sanitize_text_field( $menu_icon ),
);
} elseif ( is_array( $menu_icon ) && isset( $menu_icon['type'], $menu_icon['value'] ) ) {
$post_type_data['menu_icon'] = array(
'type' => sanitize_text_field( $menu_icon['type'] ),
'value' => sanitize_text_field( $menu_icon['value'] ),
);
}
}
if ( ! is_null( $menu_position ) ) {
$post_type_data['menu_position'] = intval( $menu_position );
}
if ( $has_archive ) {
$post_type_data['has_archive'] = 1;
if ( ! empty( $has_archive_slug ) ) {
$post_type_data['has_archive_slug'] = sanitize_title( $has_archive_slug );
}
}
if ( ! empty( $taxonomies ) && is_array( $taxonomies ) ) {
$post_type_data['taxonomies'] = array_map( 'sanitize_key', $taxonomies );
}
$result = acf_import_post_type( $post_type_data );
if ( empty( $result['ID'] ) || ! is_int( $result['ID'] ) || ! post_type_exists( $result['post_type'] ) ) {
return new WP_Error(
'post_type_creation_failed',
__( 'Failed to create the custom post type', 'acf' )
);
}
return array(
'success' => true,
'post_type' => $result,
'message' => __( 'ACF custom post type created successfully', 'acf' ),
);
}
}
@@ -0,0 +1,700 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\Abilities;
use WP_Error;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF Taxonomy Abilities
*
* Handles ACF custom taxonomy related abilities for the WordPress Abilities API.
*/
class Taxonomy extends AbstractAbilityGroup {
/**
* Register taxonomy related abilities.
*
* @since 6.8.0
*
* @return void
*/
public function register_abilities() {
if ( ! $this->is_abilities_api_available() ) {
return;
}
// Register ACF Custom Taxonomies resource.
$this->register_ability(
'acf/custom-taxonomies',
array(
'label' => __( 'ACF Custom Taxonomies', 'acf' ),
'description' => __( 'Get all ACF registered custom taxonomies', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => 'null',
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'custom_taxonomies' => array(
'type' => 'array',
'items' => array( 'type' => 'object' ),
),
'count' => array( 'type' => 'integer' ),
'message' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( $this, 'get_custom_taxonomies' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
// Register custom taxonomy ability.
$this->register_ability(
'acf/register-custom-taxonomy',
array(
'label' => __( 'Register Custom Taxonomy', 'acf' ),
'description' => __( 'Register a new taxonomy definition in WordPress (e.g., "Genre", "Color"). This creates the taxonomy schema itself, not individual terms within it. Use the create term abilities to add terms to an existing taxonomy.', 'acf' ),
'category' => 'acf-field-management',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'taxonomy' => array(
'type' => 'string',
'pattern' => '^[a-z0-9_-]*$',
'maxLength' => 32,
'description' => 'The taxonomy key (slug)',
'required' => true,
),
'label' => array(
'type' => 'string',
'description' => 'The singular label for the taxonomy',
'required' => true,
),
'plural_label' => array(
'type' => 'string',
'description' => 'The plural label for the taxonomy',
'required' => true,
),
'description' => array(
'type' => 'string',
'description' => 'Description of the taxonomy',
'required' => false,
),
'public' => array(
'type' => 'boolean',
'description' => 'Whether the taxonomy is public',
'required' => false,
),
'hierarchical' => array(
'type' => 'boolean',
'description' => 'Whether the taxonomy is hierarchical (like categories) or flat (like tags)',
'required' => false,
),
'post_types' => array(
'type' => 'array',
'description' => 'Array of post types this taxonomy applies to',
'required' => false,
'items' => array(
'type' => 'string',
),
),
'show_in_rest' => array(
'type' => 'boolean',
'description' => 'Whether to show this taxonomy in the REST API (required for AI abilities)',
'required' => false,
),
'rest_base' => array(
'type' => 'string',
'description' => 'Custom REST API base path (defaults to taxonomy key)',
'required' => false,
),
'allow_ai_access' => array(
'type' => 'boolean',
'description' => 'Whether to allow AI access to this taxonomy',
'required' => false,
),
'ai_description' => array(
'type' => 'string',
'description' => 'Description to help AI understand the purpose of this taxonomy',
'required' => false,
),
'show_ui' => array(
'type' => 'boolean',
'description' => 'Whether to generate a default UI for managing this taxonomy in the admin',
'required' => false,
),
'show_admin_column' => array(
'type' => 'boolean',
'description' => 'Whether to display a column for the taxonomy on its post type listing screens',
'required' => false,
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'taxonomy' => array( 'type' => 'object' ),
'message' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( $this, 'create_custom_taxonomy' ),
'permission_callback' => function () {
return current_user_can( acf_get_setting( 'capability' ) );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
'show_in_rest' => true,
),
)
);
// Register abilities for each ACF custom taxonomy that has REST API enabled.
$this->register_acf_taxonomy_term_abilities();
}
/**
* Register CRUD abilities for taxonomy terms.
*
* @since 6.8.0
*
* @return void
*/
private function register_acf_taxonomy_term_abilities() {
$acf_taxonomies = acf_get_acf_taxonomies();
foreach ( $acf_taxonomies as $acf_taxonomy ) {
$taxonomy_name = $acf_taxonomy['taxonomy'] ?? '';
if ( ! $taxonomy_name ) {
continue;
}
// Check if AI access is enabled for this taxonomy.
if ( empty( $acf_taxonomy['allow_ai_access'] ) || empty( $acf_taxonomy['active'] ) ) {
continue;
}
// Sanitize taxonomy name for feature ID (convert underscores to hyphens, ensure lowercase)
$sanitized_taxonomy_name = str_replace( '_', '-', strtolower( $taxonomy_name ) );
// Skip if we can't retrieve the taxonomy object or if it isn't configured with REST API access.
$taxonomy_object = get_taxonomy( $taxonomy_name );
if ( ! $taxonomy_object || empty( $taxonomy_object->show_in_rest ) ) {
continue;
}
$rest_base = acf_get_object_type_rest_base( $taxonomy_object );
$taxonomy_label = $taxonomy_object->labels->singular_name ?? $taxonomy_name;
$taxonomy_label_plural = $taxonomy_object->labels->name ?? $taxonomy_name . 's';
// Get AI description for enhanced ability descriptions
$ai_description = $acf_taxonomy['ai_description'] ?? '';
$description_suffix = $ai_description ? ' ' . $ai_description : '';
// Get ACF fields for this taxonomy.
$acf_fields = $this->get_acf_fields_for_object( 'taxonomy', $taxonomy_name );
// Get schemas from REST controller.
$item_schema = $this->get_rest_item_output_schema( $acf_fields, $taxonomy_name );
$collection_schema = $this->get_rest_item_output_schema( $acf_fields, $taxonomy_name, 'collection' );
// Register query/list feature for this taxonomy
$this->register_ability(
'acf/' . $sanitized_taxonomy_name . 's',
array(
/* translators: %s The plural label for the custom taxonomy. */
'label' => sprintf( __( 'Query %s', 'acf' ), $taxonomy_label_plural ),
/* translators: %s The plural label for the custom taxonomy. */
'description' => sprintf( __( 'Get a list of %s terms that match the query parameters.', 'acf' ), strtolower( $taxonomy_label_plural ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => array( 'object', 'null' ),
'properties' => array(
'per_page' => array(
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
),
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'search' => array(
'type' => 'string',
'default' => '',
'description' => 'Search terms by name.',
),
'post' => array(
'type' => 'integer',
'default' => null,
'description' => 'Search terms assigned to a specific post.',
),
'slug' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
'description' => 'Search terms with specific slugs.',
),
'parent' => array(
'type' => 'integer',
'description' => 'Filter by parent term ID for hierarchical taxonomies. Use 0 for top-level terms only.',
'required' => false,
),
'orderby' => array(
'type' => 'string',
'enum' => array( 'id', 'name', 'slug', 'description', 'count' ),
'default' => 'name',
'description' => 'Sort collection by term attribute.',
),
'order' => array(
'type' => 'string',
'enum' => array( 'asc', 'desc' ),
'default' => 'asc',
'description' => 'Order sort attribute ascending or descending.',
),
'hide_empty' => array(
'type' => 'boolean',
'default' => false,
'description' => 'Whether to hide terms not assigned to any posts.',
),
),
'additionalProperties' => false,
),
'output_schema' => $collection_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
return $this->execute_rest_request( 'GET', $rest_base, $input );
},
'permission_callback' => function () {
return current_user_can( 'read' );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register create ability for this taxonomy
$this->register_ability(
'acf/create-' . $sanitized_taxonomy_name,
array(
/* translators: %s The singular label for the custom taxonomy. */
'label' => sprintf( __( 'Create %s Term', 'acf' ), $taxonomy_label ),
/* translators: %s The singular label for the custom taxonomy. */
'description' => sprintf( __( 'Create a new "%s" term.', 'acf' ), strtolower( $taxonomy_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => $this->get_rest_item_input_schema( $acf_fields, $taxonomy_label, $taxonomy_object->hierarchical ),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
return $this->execute_rest_request( 'POST', $rest_base, $input );
},
'permission_callback' => function () use ( $taxonomy_object ) {
return current_user_can( $taxonomy_object->cap->manage_terms );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register view single ability for this taxonomy
$this->register_ability(
'acf/view-' . $sanitized_taxonomy_name,
array(
/* translators: %s The singular label for the custom taxonomy. */
'label' => sprintf( __( 'View a %s Term', 'acf' ), $taxonomy_label ),
/* translators: %s The singular label for the custom taxonomy. */
'description' => sprintf( __( 'Get a %s term by its ID.', 'acf' ), strtolower( $taxonomy_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s term to view.', strtolower( $taxonomy_label ) ),
'required' => true,
),
),
),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'GET', $rest_base, $input, $item_id );
},
'permission_callback' => function () {
return current_user_can( 'read' );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register update ability for this taxonomy
$this->register_ability(
'acf/update-' . $sanitized_taxonomy_name,
array(
/* translators: %s The singular label for the custom taxonomy. */
'label' => sprintf( __( 'Update a %s Term', 'acf' ), $taxonomy_label ),
/* translators: %s The singular label for the custom taxonomy. */
'description' => sprintf( __( 'Update a %s term by its ID.', 'acf' ), strtolower( $taxonomy_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => $this->get_rest_item_input_schema( $acf_fields, $taxonomy_label, $taxonomy_object->hierarchical, 'update' ),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'PUT', $rest_base, $input, $item_id );
},
'permission_callback' => function () use ( $taxonomy_object ) {
return current_user_can( $taxonomy_object->cap->edit_terms );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
// Register delete ability for this taxonomy.
$this->register_ability(
'acf/delete-' . $sanitized_taxonomy_name,
array(
/* translators: %s The singular label for the custom taxonomy. */
'label' => sprintf( __( 'Delete a %s Term', 'acf' ), $taxonomy_label ),
/* translators: %s The singular label for the custom taxonomy. */
'description' => sprintf( __( 'Delete a %s term by its ID.', 'acf' ), strtolower( $taxonomy_label ) ) . $description_suffix,
'category' => 'wordpress-content-discovery',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s term to delete.', strtolower( $taxonomy_label ) ),
'required' => true,
),
'force' => array(
'type' => 'boolean',
'description' => 'Whether to permanently delete the term. Defaults to true, as terms cannot be trashed.',
'required' => false,
'default' => true,
),
),
),
'output_schema' => $item_schema,
'execute_callback' => function ( $input = array() ) use ( $rest_base ) {
$item_id = $input['id'] ?? null;
return $this->execute_rest_request( 'DELETE', $rest_base, $input, $item_id );
},
'permission_callback' => function () use ( $taxonomy_object ) {
return current_user_can( $taxonomy_object->cap->delete_terms );
},
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => true,
'idempotent' => true,
),
'show_in_rest' => true,
),
'ability_class' => self::REST_ABILITY_CLASS,
)
);
}
}
/**
* Get REST input schema for taxonomy terms.
*
* @since 6.8.0
*
* @param array $acf_fields ACF fields for this taxonomy.
* @param string $taxonomy_label Taxonomy label for descriptions.
* @param boolean $hierarchical Whether taxonomy is hierarchical.
* @param string $action Action type ('create' or 'update').
* @return array
*/
private function get_rest_item_input_schema( array $acf_fields, string $taxonomy_label, bool $hierarchical = false, string $action = 'create' ): array {
$schema = array(
'type' => 'object',
'properties' => array(),
);
if ( 'update' === $action ) {
$schema['properties']['id'] = array(
'type' => 'integer',
'description' => sprintf( 'The ID of the %s term to update.', strtolower( $taxonomy_label ) ),
'required' => true,
);
}
$schema['properties']['name'] = array(
'type' => 'string',
'description' => sprintf( 'The name of the %s term.', strtolower( $taxonomy_label ) ),
'required' => 'update' !== $action,
);
$schema['properties']['description'] = array(
'type' => 'string',
'description' => sprintf( 'The description of the %s term.', strtolower( $taxonomy_label ) ),
'required' => false,
);
$schema['properties']['slug'] = array(
'type' => 'string',
'description' => sprintf( 'The slug of the %s term (auto-generated from name if not provided).', strtolower( $taxonomy_label ) ),
'required' => false,
);
if ( $hierarchical ) {
$schema['properties']['parent'] = array(
'type' => 'integer',
'description' => 'Parent term ID for hierarchical taxonomies. Use 0 or omit for top-level terms. For child terms, provide the ID of the parent term.',
'required' => false,
'default' => 0,
);
}
return $this->add_acf_fields_to_schema( $schema, $acf_fields );
}
/**
* Get REST output schema for taxonomy terms.
*
* @since 6.8.0
*
* @param array $acf_fields ACF fields for this taxonomy.
* @param string $taxonomy Taxonomy name.
* @param string $type Schema type ('item' or 'collection').
* @return array|null
*/
private function get_rest_item_output_schema( array $acf_fields, string $taxonomy, string $type = 'item' ) {
$taxonomy_object = get_taxonomy( $taxonomy );
if ( ! $taxonomy_object ) {
return null;
}
$controller = $taxonomy_object->get_rest_controller();
if ( ! $controller || ! method_exists( $controller, 'get_public_item_schema' ) ) {
return null;
}
$schema = $controller->get_public_item_schema();
$schema = $this->add_acf_fields_to_schema( $schema, $acf_fields );
if ( $type === 'collection' ) {
return array(
'type' => 'array',
'items' => $schema,
);
}
return $schema;
}
/**
* Callback for the "acf/get-custom-taxonomies" ability.
*
* @since 6.8.0
*
* @param array $input Input args (unused).
* @return array
*/
public function get_custom_taxonomies( $input ) {
unset( $input ); // Not used, but required by interface.
$custom_taxonomies = array();
// Get ACF custom taxonomies.
$acf_taxonomies = acf_get_acf_taxonomies();
foreach ( $acf_taxonomies as $acf_taxonomy ) {
$taxonomy_name = $acf_taxonomy['taxonomy'] ?? '';
if ( ! $taxonomy_name ) {
continue;
}
if ( empty( $acf_taxonomy['active'] ) || empty( $acf_taxonomy['allow_ai_access'] ) ) {
continue;
}
$taxonomy_object = get_taxonomy( $taxonomy_name );
if ( $taxonomy_object ) {
$taxonomy_data = array(
'taxonomy' => $taxonomy_name,
'label' => $taxonomy_object->label,
'labels' => (array) $taxonomy_object->labels,
'description' => $taxonomy_object->description,
'public' => $taxonomy_object->public,
'hierarchical' => $taxonomy_object->hierarchical,
'object_type' => $taxonomy_object->object_type,
'acf_settings' => $acf_taxonomy,
);
// Add ACF field groups information
$acf_fields = $this->get_acf_fields_for_object( 'taxonomy', $taxonomy_name );
if ( ! empty( $acf_fields ) ) {
$taxonomy_data['acf_field_groups'] = $acf_fields;
}
$custom_taxonomies[] = $taxonomy_data;
}
}
$count = count( $custom_taxonomies );
return array(
'custom_taxonomies' => $custom_taxonomies,
'count' => $count,
'message' => sprintf(
/* translators: %d: Number of ACF custom taxonomies */
_n( 'Found %d ACF custom taxonomy', 'Found %d ACF custom taxonomies', $count, 'acf' ),
$count
),
);
}
/**
* Callback for the "acf/register-custom-taxonomy" ability.
*
* @since 6.8.0
*
* @param array $input Input args.
* @return array|WP_Error
*/
public function create_custom_taxonomy( $input ) {
// Required parameters
$taxonomy = sanitize_key( $input['taxonomy'] );
$label = sanitize_text_field( $input['label'] );
$plural_label = sanitize_text_field( $input['plural_label'] );
// Basic optional parameters
$description = sanitize_text_field( $input['description'] ?? '' );
$public = $input['public'] ?? true;
$hierarchical = $input['hierarchical'] ?? false;
$post_types = array_map( 'sanitize_key', $input['post_types'] ?? array( 'post' ) );
// REST API settings
$show_in_rest = $input['show_in_rest'] ?? true;
$rest_base = $input['rest_base'] ?? '';
// AI settings
$allow_ai_access = $input['allow_ai_access'] ?? true;
$ai_description = $input['ai_description'] ?? '';
// UI settings
$show_ui = $input['show_ui'] ?? true;
$show_admin_column = $input['show_admin_column'] ?? false;
// Check if taxonomy already exists.
if ( taxonomy_exists( $taxonomy ) ) {
return new WP_Error(
'taxonomy_exists',
__( 'A taxonomy with this key already exists', 'acf' ),
array( 'status' => 400 )
);
}
// Use ACF's method to create the taxonomy.
$taxonomy_data = array(
'key' => uniqid( 'taxonomy_' ),
'taxonomy' => $taxonomy,
'title' => $plural_label,
'labels' => wp_parse_args(
array(
'name' => $plural_label,
'singular_name' => $label,
),
acf_get_internal_post_type_instance( 'acf-taxonomy' )->get_settings_array()['labels']
),
'description' => $description,
'public' => $public ? 1 : 0,
'hierarchical' => $hierarchical ? 1 : 0,
'object_type' => $post_types,
'active' => 1,
// REST API settings
'show_in_rest' => $show_in_rest ? 1 : 0,
// AI settings
'allow_ai_access' => $allow_ai_access ? 1 : 0,
// UI settings
'show_ui' => $show_ui ? 1 : 0,
'show_admin_column' => $show_admin_column ? 1 : 0,
);
// Add optional settings only if provided
if ( ! empty( $rest_base ) ) {
$taxonomy_data['rest_base'] = sanitize_text_field( $rest_base );
}
if ( ! empty( $ai_description ) ) {
$taxonomy_data['ai_description'] = sanitize_text_field( $ai_description );
}
$result = acf_import_taxonomy( $taxonomy_data );
if ( empty( $result['ID'] ) || ! is_int( $result['ID'] ) || ! taxonomy_exists( $result['taxonomy'] ) ) {
return new WP_Error(
'taxonomy_creation_failed',
__( 'Failed to create the custom taxonomy', 'acf' )
);
}
return array(
'success' => true,
'taxonomy' => $result,
'message' => __( 'ACF custom taxonomy created successfully', 'acf' ),
);
}
}
@@ -0,0 +1,364 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\GEO;
// Exit if accessed directly.
use WP_Error;
defined( 'ABSPATH' ) || exit;
/**
* ACF GEO Field Settings
*
* Adds JSON-LD field role settings to ACF fields.
*/
class FieldSettings {
/**
* Cache for schema properties by field type
*
* @var array
*/
private static $properties_cache = array();
/**
* Constructs the FieldSettings class.
*
* @since 6.8.0
*
* @return void
*/
public function __construct() {
$this->init();
}
/**
* Initialize the field settings
*
* @since 6.8.0
*
* @return void
*/
public function init() {
// Add the Schema.org Property setting to field types that support it.
add_action( 'acf/render_field_general_settings', array( $this, 'render_field_schema_settings' ) );
// AJAX output format handler (needs to be added early for AJAX requests).
add_action( 'wp_ajax_acf/schema/get_output_formats', array( $this, 'ajax_get_output_formats' ) );
}
/**
* AJAX handler to get output format choices for a field type + property combination.
*
* @since 6.8.0
*
* @return void
*/
public function ajax_get_output_formats() {
// Verify request.
if ( ! acf_verify_ajax() ) {
wp_send_json_error(
new WP_Error(
'acf_invalid_nonce',
__( 'Invalid nonce.', 'acf' )
)
);
}
// Verify user can admin.
if ( ! acf_current_user_can_admin() ) {
wp_send_json_error(
new WP_Error(
'acf_invalid_permissions',
__( 'Sorry, you do not have permission to do that.', 'acf' )
)
);
}
$field_type = acf_request_arg( 'field_type', '' );
$qualified_property = acf_request_arg( 'property', '' );
$property = Schema::get_property_name( $qualified_property );
if ( empty( $field_type ) || empty( $property ) ) {
wp_send_json_error(
new WP_Error(
'acf_invalid_param',
__( 'Missing required parameters', 'acf' )
)
);
}
$choices = array();
$valid_formats = Schema::get_valid_output_formats( $field_type, $property );
foreach ( $valid_formats as $format ) {
$choices[] = array(
'id' => $format,
'text' => $format,
);
}
$default = Schema::get_default_output_format( $field_type, $property );
wp_send_json_success(
array(
'choices' => $choices,
'default' => $default,
)
);
}
/**
* Render the field-level schema settings.
*
* @since 6.8.0
*
* @param array $field The field being edited.
* @return void
*/
public function render_field_schema_settings( $field ) {
$field_type = $field['type'] ?? '';
// Check if field type supports JSON-LD output.
$supported_ranges = Schema::get_field_type_ranges( $field_type );
if ( empty( $supported_ranges ) ) {
// Field type doesn't support JSON-LD output (e.g., tab, accordion).
return;
}
// Get available Schema.org properties filtered by field type compatibility.
$parent_id = (int) ( $field['parent'] ?? 0 );
acf_render_field_setting(
$field,
array(
'label' => __( 'Schema.org Property', 'acf' ),
'instructions' => __( 'Map this field to a Schema.org property instead of using additionalProperty.', 'acf' ),
'type' => 'select',
'name' => 'schema_property',
'class' => 'acf-schema-property',
'wrapper' => array(
'class' => 'acf-field-meta-box',
),
'choices' => $this->get_schema_properties( $field_type, $parent_id ),
'allow_null' => 1,
'ui' => 1,
'experimental' => 1,
)
);
$output_choices = $this->get_output_format_choices( $field );
// Get the default format for this field type + property.
$qualified_property = $field['schema_property'] ?? '';
$property_name = Schema::get_property_name( $qualified_property );
$default_format = Schema::get_default_output_format( $field_type, $property_name );
acf_render_field_setting(
$field,
array(
'label' => __( 'Schema.org Output Format', 'acf' ),
'type' => 'select',
'name' => 'schema_output_format',
'class' => 'acf-schema-output-format',
'wrapper' => array(
'class' => 'acf-field-meta-box',
),
'choices' => $output_choices,
'default_value' => $default_format,
'ui' => 1,
'experimental' => 1,
'conditions' => array(
'field' => 'schema_property',
'operator' => '!=',
'value' => '',
),
)
);
}
/**
* Get available Schema.org properties for a field type
*
* Returns a hierarchical array of Schema.org properties organized by type,
* filtered to only include properties compatible with the field type.
*
* Uses pre-computed compatibility data for fast lookups.
*
* @since 6.8.0
*
* @param string $field_type The ACF field type name.
* @param integer $context_id Optional field group ID for context-aware priority ordering.
* @return array Array of properties grouped by Schema.org type.
*/
public function get_schema_properties( string $field_type = '', int $context_id = 0 ): array {
// Build cache key including context.
$cache_key = $field_type . '_' . $context_id;
// Return cached result if available.
if ( isset( self::$properties_cache[ $cache_key ] ) ) {
return self::$properties_cache[ $cache_key ];
}
$roles = array();
// Get compatible properties using pre-computed data.
$compatible_set = $this->get_compatible_properties_set( $field_type );
if ( empty( $compatible_set ) ) {
self::$properties_cache[ $cache_key ] = $roles;
return $roles;
}
// Get all properties grouped by type from schema.org vocabulary.
$properties_by_type = Schema::get_properties_by_type();
// Get priority types with context-aware ordering.
$priority_types = Schema::get_priority_types( $context_id );
// Add priority types first.
foreach ( $priority_types as $type ) {
if ( isset( $properties_by_type[ $type ] ) ) {
$type_compatible = array();
foreach ( $properties_by_type[ $type ] as $property ) {
if ( isset( $compatible_set[ $property ] ) ) {
$type_compatible[ $type . '.' . $property ] = $property;
}
}
if ( ! empty( $type_compatible ) ) {
$type_label = sprintf( '%s Properties', $type );
$roles[ $type_label ] = $type_compatible;
}
}
}
// Add remaining types alphabetically.
foreach ( $properties_by_type as $type => $properties ) {
// Skip priority types (already processed).
if ( in_array( $type, $priority_types, true ) ) {
continue;
}
// Skip types with no properties.
if ( empty( $properties ) ) {
continue;
}
$type_compatible = array();
foreach ( $properties as $property ) {
if ( isset( $compatible_set[ $property ] ) ) {
$type_compatible[ $type . '.' . $property ] = $property;
}
}
if ( ! empty( $type_compatible ) ) {
$type_label = sprintf( '%s Properties', $type );
$roles[ $type_label ] = $type_compatible;
}
}
/**
* Filter the available Schema.org properties.
*
* Allows developers to add custom Schema.org properties or modify existing ones.
*
* @param array $properties The Schema.org role mappings grouped by type.
* @param string $field_type The ACF field type being configured.
*/
$roles = apply_filters( 'acf/schema/schema_properties', $roles, $field_type );
// Cache the result.
self::$properties_cache[ $cache_key ] = $roles;
return $roles;
}
/**
* Get compatible properties as a set (for fast lookup)
*
* Uses pre-computed data from SchemaData::get_compatible_properties().
*
* @since 6.8.0
*
* @param string $field_type The ACF field type name.
* @return array Properties as keys for O(1) lookup.
*/
private function get_compatible_properties_set( $field_type ) {
// Get field type's output types.
$field_ranges = Schema::get_field_type_ranges( $field_type );
if ( empty( $field_ranges ) ) {
return array();
}
// Get pre-computed compatible properties mapping.
$compatible_by_type = SchemaData::get_compatible_properties();
// Merge compatible properties for all output types.
$compatible = array();
foreach ( $field_ranges as $output_type ) {
if ( isset( $compatible_by_type[ $output_type ] ) ) {
foreach ( $compatible_by_type[ $output_type ] as $property ) {
$compatible[ $property ] = true;
}
}
}
return $compatible;
}
/**
* Get output format choices for a field
*
* Returns the valid output formats for the field's type and selected property.
* For example, an Image field mapped to the 'image' property can output
* either 'URL' or 'ImageObject'.
*
* @since 6.8.0
*
* @param array $field The field being edited.
* @return array Array of format => label pairs.
*/
private function get_output_format_choices( $field ) {
$field_type = $field['type'] ?? '';
$qualified_property = $field['schema_property'] ?? '';
// If no property selected yet, return empty choices.
if ( empty( $qualified_property ) ) {
return array();
}
// Extract just the property name from qualified property (e.g., "Recipe.recipeYield" -> "recipeYield").
$property = Schema::get_property_name( $qualified_property );
$valid_formats = Schema::get_valid_output_formats( $field_type, $property );
if ( empty( $valid_formats ) ) {
return array();
}
// Build choices array with format as both key and label.
$choices = array();
foreach ( $valid_formats as $format ) {
$choices[ $format ] = $format;
}
/**
* Filter the available output format choices.
*
* @param array $choices The output format choices.
* @param string $field_type The ACF field type.
* @param string $property The selected Schema.org property.
*/
return apply_filters( 'acf/schema/output_format_choices', $choices, $field_type, $property );
}
}
@@ -0,0 +1,424 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\GEO;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF GEO Extension
*
* Extends ACF admin interface to add AI-related settings and functionality.
*/
class GEO {
/**
* Constructs the GEO class.
*
* @since 6.8.0
*
* @return void
*/
public function __construct() {
$this->init();
}
/**
* Initialize the GEO extension,
*
* @since 6.8.0
*
* @return void
*/
public function init() {
// Add hooks for ACF admin interface extensions for post types.
add_filter( 'acf/post_type/additional_settings_tabs', array( $this, 'add_schema_tab' ) );
add_action( 'acf/post_type/render_settings_tab/schema', array( $this, 'render_post_type_schema_tab' ) );
// Initialize GEO submodules.
// Field Settings.
new FieldSettings();
// JSON-LD Outputs.
new Outputs\Posts();
// Note: Blocks output is initialized separately in PRO (see acf-pro.php).
}
/**
* Adds the "Schema" settings tab for post types.
*
* @since 6.8.0
*
* @param array $tabs An array of the existing tabs.
* @return array
*/
public function add_schema_tab( $tabs ) {
$tabs['schema'] = __( 'Schema', 'acf' );
return $tabs;
}
/**
* Render "Schema" tab content for post types
*
* @since 6.8.0
*
* @param array $acf_post_type The ACF post type data.
*/
public function render_post_type_schema_tab( $acf_post_type ) {
?>
<span class="acf-experimental-badge acf-field"><?php esc_html_e( 'Experimental', 'acf' ); ?></span>
<?php
// Add post-type-specific field: auto JSON-LD.
acf_render_field_wrap(
array(
'type' => 'true_false',
'name' => 'auto_jsonld',
'key' => 'auto_jsonld',
'prefix' => 'acf_post_type',
'value' => $acf_post_type['auto_jsonld'] ?? 0,
'label' => __( 'Automatically add JSON-LD data for fields on this post type', 'acf' ),
'instructions' => __( 'When enabled, ACF field data will be automatically included as JSON-LD structured data in the page head for better SEO and semantic markup.', 'acf' ),
'ui' => true,
'default' => 0,
)
);
// Add post-type-specific field: schema type.
acf_render_field_wrap(
array(
'type' => 'select',
'name' => 'schema_type',
'key' => 'schema_type',
'prefix' => 'acf_post_type',
'value' => $acf_post_type['schema_type'] ?? '',
'label' => __( 'Schema.org Type', 'acf' ),
'instructions' => __( 'The Schema.org @type for JSON-LD output. By default, the type is automatically detected based on the schema properties assigned to your fields. You can assign additional types here. Select multiple types if needed (e.g., Recipe + Article).', 'acf' ),
'choices' => $this->get_schema_types(),
'default' => '',
'allow_null' => 1,
'multiple' => 1,
'ui' => 1,
),
'div',
'field'
);
}
/**
* Get available Schema.org types for selection
*
* @since 6.8.0
*
* @return array A hierarchical array of Schema.org types grouped by category.
*/
public function get_schema_types() {
$types = array();
// Get all types from schema hierarchy.
$all_types = array_keys( SchemaData::get_type_hierarchy() );
// Add 'Thing' which is the root and doesn't have a parent.
$all_types[] = 'Thing';
sort( $all_types );
// Get priority types from Schema class.
$priority_types_list = Schema::get_priority_types();
$common_types_cat = __( 'Common Types', 'acf' );
$all_types_cat = __( 'All Types', 'acf' );
// Add priority types under "Common Types" group.
$types[ $common_types_cat ] = array();
foreach ( $priority_types_list as $type ) {
if ( in_array( $type, $all_types, true ) ) {
$types[ $common_types_cat ][ $type ] = $type;
}
}
// Add remaining types under "All Types".
$remaining_types = array_diff( $all_types, $priority_types_list );
if ( ! empty( $remaining_types ) ) {
$types[ $all_types_cat ] = array();
foreach ( $remaining_types as $type ) {
$types[ $all_types_cat ][ $type ] = $type;
}
}
/**
* Filter the available Schema.org types
*
* Allows developers to add custom Schema.org types or modify existing ones.
*
* @param array $types The Schema.org type mappings grouped by category.
*/
return apply_filters( 'acf/schema/schema_types', $types );
}
/**
* Process ACF fields and map them to Schema.org structure
*
* Takes an array of field objects and processes them based on their schema_property setting.
* Fields with a schema_property are mapped to core Schema.org properties. Properties that
* expect objects (like 'author' or 'publisher') automatically get proper "@type" added.
* Fields without a schema_property are skipped.
*
* @since 6.8.0
*
* @param array $field_objects Array of ACF field objects with values.
* @return array Processed data with core properties, with 'field_types' key containing types from qualified properties.
*/
public static function process_fields( $field_objects ) {
$data = array();
$field_types = array();
foreach ( $field_objects as $field_name => $field_object ) {
// Skip empty values.
if ( null === $field_object['value'] || '' === $field_object['value'] ) {
continue;
}
// Check if this field has a schema property mapping.
$schema_property = $field_object['schema_property'] ?? '';
if ( ! empty( $schema_property ) ) {
// Parse qualified property (e.g., "Offer.price" -> type: "Offer", property: "price").
$parsed = Schema::parse_qualified_property( $schema_property );
$property_name = $parsed['property'];
// Collect field types from qualified properties.
if ( $parsed['type'] ) {
$field_types[] = $parsed['type'];
}
$formatted_value = self::format_field_value_for_jsonld( $field_object['value'], $field_object );
// Field has a schema property - map to core property using just the property name.
$data[ $property_name ] = $formatted_value;
}
}
// Add @type to nested objects based on schema.org ranges.
$data = self::add_types_to_nested_objects( $data );
// Include field types from qualified properties.
if ( ! empty( $field_types ) ) {
$data['field_types'] = array_values( array_unique( $field_types ) );
}
return $data;
}
/**
* Determine the final "@type" value for JSON-LD output
*
* Merges provided types (from post type/block settings) with field types
* (from qualified properties like "Recipe.prepTime"). Falls back to the
* default type if neither source provides any types.
*
* @since 6.8.0
*
* @param string|array|null $provided_types Types explicitly set in settings (can be string, array, or null).
* @param array $field_types Types extracted from qualified properties.
* @param string $default_type Fallback type if no types provided.
* @return string|array Final @type value (string for single type, array for multiple).
*/
public static function determine_schema_type( $provided_types, $field_types, $default_type = 'Thing' ) {
$types = array();
// Add provided types from post type/block settings.
if ( ! empty( $provided_types ) ) {
$types = is_array( $provided_types ) ? $provided_types : array( $provided_types );
}
// Merge in field types from qualified properties.
if ( ! empty( $field_types ) && is_array( $field_types ) ) {
$types = array_merge( $types, $field_types );
}
$types = array_values( array_unique( $types ) );
if ( empty( $types ) ) {
return $default_type;
}
return count( $types ) === 1 ? $types[0] : $types;
}
/**
* Add "@type" to nested objects based on schema.org property ranges
*
* Examines each property in the data and if it expects an object type
* (like Person, Organization, etc.), automatically adds the appropriate @type.
*
* For example, if 'author' contains { 'name': 'John' }, it becomes:
* { '@type': 'Person', 'name': 'John' }
*
* @since 6.8.0
*
* @param array $data The data array to process.
* @return array The data with @type added to nested objects.
*/
private static function add_types_to_nested_objects( $data ) {
foreach ( $data as $property => $value ) {
// Skip if value is not an array (can't be a nested object).
if ( ! is_array( $value ) ) {
continue;
}
// Skip if already has @type.
if ( isset( $value['@type'] ) ) {
continue;
}
// Check if this is a sequential array (list) vs associative array (object).
// Sequential arrays are for properties that accept multiple values.
// We only add @type to associative arrays (objects).
$is_list = array_keys( $value ) === range( 0, count( $value ) - 1 );
if ( $is_list ) {
// This is a list/array, not a single object. Skip adding @type.
continue;
}
// Check if this property expects an object type.
if ( Schema::property_expects_object( $property ) ) {
// Get the preferred object type for this property.
$object_type = Schema::get_preferred_object_type( $property );
if ( $object_type ) {
// Add @type at the beginning of the array.
$data[ $property ] = array_merge(
array( '@type' => $object_type ),
$value
);
}
}
}
return $data;
}
/**
* Render a JSON-LD script tag with the provided data
*
* Shared helper method for outputting JSON-LD structured data.
*
* @since 6.8.0
*
* @param array $jsonld_data The JSON-LD data array to output.
*/
public static function render_jsonld_script( $jsonld_data ) {
if ( empty( $jsonld_data ) ) {
return;
}
/**
* Action fired before rendering JSON-LD script tag
*
* Allows developers to output custom schemas or capture the data.
*
* @param array $jsonld_data The JSON-LD data array.
*/
do_action( 'acf/schema/render_script', $jsonld_data );
/**
* Filter to disable ACF's default JSON-LD output
*
* Return true to prevent ACF from outputting the JSON-LD script tag.
* Useful if you want to handle the output yourself via the action above.
*
* @param bool $disable Whether to disable default output. Default false.
* @param array $jsonld_data The JSON-LD data that would be output.
*/
$disable_output = apply_filters( 'acf/schema/disable_output', false, $jsonld_data );
if ( $disable_output ) {
return;
}
// Output the JSON-LD script tag.
echo "<script type=\"application/ld+json\">\n";
echo wp_json_encode( $jsonld_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_UNESCAPED_UNICODE );
echo "\n</script>\n";
}
/**
* Format ACF field value for JSON-LD output
*
* Shared helper method for formatting field values consistently.
* Checks for field-type-specific formatting methods in this order:
* 1. Pre-filter to allow complete bypass of formatting logic
* 2. format_value_for_jsonld() - custom method for JSON-LD formatting (if field type implements it)
* 3. Field-type-specific formatting, defaulting to format_value_for_rest() for most types
* 4. Post-filter on the final formatted value
*
* @since 6.8.0
*
* @param mixed $value The field value.
* @param array $field_object The ACF field object.
* @return mixed Formatted value.
*/
public static function format_field_value_for_jsonld( $value, $field_object ) {
$field_type_name = $field_object['type'] ?? '';
$field_name = $field_object['name'] ?? '';
/**
* Filter to bypass the default formatting logic entirely
*
* Return a non-null value to bypass all default formatting.
* This runs before any other formatting logic.
*
* @param mixed|null $pre_value Return non-null to bypass default formatting.
* @param mixed $value The raw field value.
* @param array $field_object The ACF field object.
* @param string $field_type_name The field type name.
*/
$pre_value = apply_filters( 'acf/schema/format_value/pre', null, $value, $field_object, $field_type_name );
$pre_value = apply_filters( "acf/schema/format_value/pre/type={$field_type_name}", $pre_value, $value, $field_object );
$pre_value = apply_filters( "acf/schema/format_value/pre/name={$field_name}", $pre_value, $value, $field_object );
if ( null !== $pre_value ) {
return $pre_value;
}
// Get the field type class instance.
$field_type = acf_get_field_type( $field_type_name );
// First priority: Check if field type has a custom format_value_for_jsonld method.
if ( $field_type && method_exists( $field_type, 'format_value_for_jsonld' ) ) {
$formatted_value = $field_type->format_value_for_jsonld( $value, null, $field_object );
} else {
// Second priority: Use format_value_for_rest or return as-is.
if ( $field_type && method_exists( $field_type, 'format_value_for_rest' ) ) {
$formatted_value = $field_type->format_value_for_rest( $value, null, $field_object );
} else {
// Final fallback: return value as-is.
// Arrays are valid JSON-LD (e.g., multi-select values).
$formatted_value = $value;
}
}
/**
* Filter the formatted value before returning
*
* Allows modification of the value after all default formatting has been applied.
*
* @param mixed $formatted_value The formatted value.
* @param mixed $value The raw field value.
* @param array $field_object The ACF field object.
* @param string $field_type_name The field type name.
*/
$formatted_value = apply_filters( 'acf/schema/format_value', $formatted_value, $value, $field_object, $field_type_name );
$formatted_value = apply_filters( "acf/schema/format_value/type={$field_type_name}", $formatted_value, $value, $field_object );
$formatted_value = apply_filters( "acf/schema/format_value/name={$field_name}", $formatted_value, $value, $field_object );
return $formatted_value;
}
}
@@ -0,0 +1,275 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\GEO\Outputs;
use ACF\AI\GEO\GEO;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF GEO Posts Output
*
* Handles JSON-LD structured data output for ACF fields on post types.
*/
class Posts {
/**
* Constructor
*
* @since 6.8.0
*/
public function __construct() {
$this->init();
}
/**
* Initialize the GEO Posts extension
*
* @since 6.8.0
*/
public function init() {
// Add front-end JSON-LD output for posts.
add_action( 'wp_head', array( $this, 'output_jsonld_data' ) );
}
/**
* Output JSON-LD structured data for ACF fields on posts
*
* @since 6.8.0
*/
public function output_jsonld_data() {
/**
* Filters whether to output debug comments in HTML
*
* @param bool $debug Whether to output debug comments. Default false.
*/
$debug = apply_filters( 'acf/schema/debug', false );
// Only output on singular posts/pages.
if ( ! is_singular() ) {
if ( $debug ) {
echo "<!-- ACF AI JSON-LD: Not a singular post/page -->\n";
}
return;
}
global $post;
if ( ! $post ) {
if ( $debug ) {
echo "<!-- ACF AI JSON-LD: No post object found -->\n";
}
return;
}
$post_type = get_post_type( $post );
if ( ! $post_type ) {
if ( $debug ) {
echo "<!-- ACF AI JSON-LD: No post type found -->\n";
}
return;
}
if ( $debug ) {
echo '<!-- ACF AI JSON-LD: Checking post type: ' . esc_html( $post_type ) . " -->\n";
}
// Build list of post types with JSON-LD enabled.
$enabled_post_types = array();
// Get ACF custom post types with JSON-LD enabled.
$acf_post_types = acf_get_acf_post_types();
foreach ( $acf_post_types as $acf_post_type ) {
if ( ! empty( $acf_post_type['auto_jsonld'] ) && isset( $acf_post_type['post_type'] ) ) {
$enabled_post_types[] = $acf_post_type['post_type'];
}
}
/**
* Filters the list of post types that have JSON-LD output enabled.
*
* @param array $enabled_post_types Array of post type names with JSON-LD enabled.
*/
$enabled_post_types = apply_filters( 'acf/schema/enabled_post_types', $enabled_post_types );
// Exit if current post type is not in the enabled list.
if ( ! in_array( $post_type, $enabled_post_types, true ) ) {
if ( $debug ) {
echo '<!-- ACF AI JSON-LD: Post type \'' . esc_html( $post_type ) . "' does not have JSON-LD enabled -->\n";
}
return;
}
/**
* Filters the field objects before retrieval, allowing posts to provide custom data.
*
* This is useful for posts that need custom field data handling.
* Return a non-null value to short-circuit the default get_field_objects() call.
*
* @since 6.8.0
*
* @param array|null $field_objects The field objects array, or null to use default behavior.
* @param WP_Post $post The post object.
* @param string $post_type The post type.
*/
$field_objects = apply_filters( 'acf/schema/post_field_objects', null, $post, $post_type );
/**
* Filters the field objects for a specific post type.
*
* The dynamic portion of the hook name, `$post_type`, refers to the post type name.
* For example, 'acf/schema/post_field_objects/post_type=product' for the product post type.
*
* @since 6.8.0
*
* @param array|null $field_objects The field objects array, or null to use default behavior.
* @param WP_Post $post The post object.
*/
$field_objects = apply_filters( 'acf/schema/post_field_objects/post_type=' . $post_type, $field_objects, $post );
// If no custom field objects were provided, get them from the post.
if ( null === $field_objects ) {
// Get all ACF field objects for this post without formatting to get raw ACF storage format.
$field_objects = get_field_objects( $post->ID, false );
}
if ( ! $field_objects || ! is_array( $field_objects ) ) {
if ( $debug ) {
echo '<!-- ACF AI JSON-LD: No ACF fields found for post ID ' . absint( $post->ID ) . " -->\n";
}
return;
}
if ( $debug ) {
echo '<!-- ACF AI JSON-LD: Found ' . count( $field_objects ) . " ACF fields -->\n";
}
// Process ACF fields and extract types from qualified properties.
// This handles schema_property mapping.
$processed_fields = GEO::process_fields( $field_objects );
// Get any explicitly set schema type for this post type.
$acf_post_type_object = null;
foreach ( $acf_post_types as $acf_post_type ) {
if ( isset( $acf_post_type['post_type'] ) && $acf_post_type['post_type'] === $post_type ) {
$acf_post_type_object = $acf_post_type;
break;
}
}
$provided_type = $acf_post_type_object['schema_type'] ?? null;
$field_types = $processed_fields['field_types'] ?? array();
// Determine the final @type based on provided type or field types from qualified properties.
$schema_type = GEO::determine_schema_type( $provided_type, $field_types, 'Article' );
// Remove internal type data from processed fields.
unset( $processed_fields['field_types'] );
// Build base JSON-LD structured data.
$jsonld_data = array(
'@context' => 'https://schema.org',
'@type' => $schema_type,
'@id' => get_permalink( $post ),
'url' => get_permalink( $post ),
'name' => get_the_title( $post ),
'headline' => get_the_title( $post ),
);
// Add publication date if available.
if ( $post->post_date ) {
$jsonld_data['datePublished'] = get_the_date( 'c', $post );
}
if ( $post->post_modified ) {
$jsonld_data['dateModified'] = get_the_modified_date( 'c', $post );
}
/**
* Filter whether to automatically add featured image to JSON-LD output
*
* @param bool $add_image Whether to add the featured image. Default true.
* @param WP_Post $post The post object.
* @param string $post_type The post type.
*/
$add_image = apply_filters( 'acf/schema/auto_add_image', true, $post, $post_type );
// Add featured image if available and enabled.
if ( $add_image && has_post_thumbnail( $post ) ) {
$thumbnail_id = get_post_thumbnail_id( $post );
$thumbnail_url = wp_get_attachment_image_url( $thumbnail_id, 'full' );
if ( $thumbnail_url ) {
// Get image metadata for additional properties.
$image_meta = wp_get_attachment_metadata( $thumbnail_id );
$jsonld_data['image'] = array(
'@type' => 'ImageObject',
'url' => $thumbnail_url,
);
if ( isset( $image_meta['width'] ) ) {
$jsonld_data['image']['width'] = $image_meta['width'];
}
if ( isset( $image_meta['height'] ) ) {
$jsonld_data['image']['height'] = $image_meta['height'];
}
}
}
/**
* Filter whether to automatically add author to JSON-LD output
*
* @param bool $add_author Whether to add the author. Default true.
* @param WP_Post $post The post object.
* @param string $post_type The post type.
*/
$add_author = apply_filters( 'acf/schema/auto_add_author', true, $post, $post_type );
// Add author if enabled.
if ( $add_author && $post->post_author ) {
$author = get_userdata( $post->post_author );
if ( $author ) {
$jsonld_data['author'] = array(
'@type' => 'Person',
'name' => $author->display_name,
);
// Add author URL if available.
$author_url = get_author_posts_url( $post->post_author );
if ( $author_url ) {
$jsonld_data['author']['url'] = $author_url;
}
}
}
// Merge processed fields into JSON-LD data.
$jsonld_data = array_merge( $jsonld_data, $processed_fields );
/**
* Filters the JSON-LD data before output for a post.
*
* @param array $jsonld_data The JSON-LD data array.
* @param WP_Post $post The post object.
* @param string $post_type The post type.
*/
$jsonld_data = apply_filters( 'acf/schema/data', $jsonld_data, $post, $post_type );
// Only output if we have data after filtering.
if ( empty( $jsonld_data ) ) {
return;
}
// Output the JSON-LD using the shared helper.
GEO::render_jsonld_script( $jsonld_data );
}
}
@@ -0,0 +1,700 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\AI\GEO;
/**
* Class Schema
*
* Provides utilities for working with schema.org types and properties
* using pre-generated schema data from SchemaData.php.
*/
class Schema {
/**
* Primitive types that don't require a "@type" in JSON-LD output
*
* @var array
*/
private static array $primitive_types = array(
'Text',
'Number',
'Integer',
'Float',
'Boolean',
'Date',
'DateTime',
'Time',
'URL',
'CssSelectorType',
'PronounceableText',
'XPathType',
);
/**
* Schema.org types that are modeled as objects but are practically text values
*
* These types don't have meaningful sub-properties and are typically
* represented as formatted strings (e.g., "PT30M" for Duration).
* Text fields should be able to match properties expecting these types.
*
* @var array
*/
private static array $text_value_types = array(
'Duration',
'Distance',
'Energy',
'Mass',
);
/**
* Cache for properties grouped by type
*
* @var array|null
*/
private static $properties_by_type_cache = null;
/**
* Get priority schema types for common use cases
*
* Returns an array of commonly used Schema.org types that should be
* displayed first in selection dropdowns. When a context ID (field group ID)
* is provided, schema types from associated post types and blocks are
* prepended to the list.
*
* @since 6.8.0
*
* @param integer $context_id Optional field group ID to get context-aware priority types.
* @return array Array of priority type names.
*/
public static function get_priority_types( int $context_id = 0 ): array {
$priority_types = array(
'Thing',
'Article',
'BlogPosting',
'NewsArticle',
'Recipe',
'Product',
'Event',
'HowTo',
'FAQPage',
'Person',
'Organization',
'LocalBusiness',
'Place',
'WebPage',
);
// Prepend context-specific schema types if a field group context is provided.
if ( $context_id ) {
$context_types = self::get_schema_types_from_field_group( $context_id );
if ( ! empty( $context_types ) ) {
$priority_types = array_unique( array_merge( $context_types, $priority_types ) );
}
}
/**
* Filter the priority Schema.org types
*
* Allows developers to customize which types appear first in selection lists.
*
* @param array $priority_types Array of priority type names.
* @param integer $context_id The field group ID providing context, or 0.
*/
return apply_filters( 'acf/schema/schema_priority_types', $priority_types, $context_id );
}
/**
* Get schema types configured on post types or blocks associated with a field group.
*
* Extracts schema_type values from ACF post types and blocks that are
* referenced in the field group's location rules.
*
* @since 6.8.0
*
* @param integer $field_group_id The field group ID.
* @return array Array of unique schema type names.
*/
private static function get_schema_types_from_field_group( int $field_group_id ): array {
$field_group = acf_get_field_group( $field_group_id );
if ( empty( $field_group['location'] ) ) {
return array();
}
$schema_types = array();
$acf_post_types = null; // Lazy load.
foreach ( $field_group['location'] as $group ) {
foreach ( $group as $rule ) {
if ( $rule['operator'] !== '==' ) {
continue;
}
// Handle post type location rules.
if ( $rule['param'] === 'post_type' ) {
// Lazy load ACF post types.
if ( $acf_post_types === null ) {
$acf_post_types = acf_get_acf_post_types();
}
foreach ( $acf_post_types as $acf_post_type ) {
if ( isset( $acf_post_type['post_type'] )
&& $acf_post_type['post_type'] === $rule['value']
&& ! empty( $acf_post_type['schema_type'] ) ) {
$types = (array) $acf_post_type['schema_type'];
$schema_types = array_merge( $schema_types, $types );
}
}
}
// Handle block location rules.
if ( $rule['param'] === 'block' && $rule['value'] !== 'all' ) {
$block_type = acf_get_block_type( $rule['value'] );
if ( $block_type && ! empty( $block_type['schema_type'] ) ) {
$types = (array) $block_type['schema_type'];
$schema_types = array_merge( $schema_types, $types );
}
}
}
}
return array_unique( $schema_types );
}
/**
* Get all parent types for a given type
*
* @since 6.8.0
*
* @param string $type The schema.org type name
* @return array Array of parent type names in order from direct parent to root
*/
private static function get_type_parents( $type ) {
$parents = array();
$current = $type;
$type_hierarchy = SchemaData::get_type_hierarchy();
while ( isset( $type_hierarchy[ $current ] ) ) {
$parent = $type_hierarchy[ $current ];
$parents[] = $parent;
$current = $parent;
}
return $parents;
}
/**
* Check if type_a is a parent/ancestor of type_b
*
* @since 6.8.0
*
* @param string $type_a The potential parent type
* @param string $type_b The child type to check
* @return boolean True if type_a is an ancestor of type_b
*/
private static function is_parent_of( $type_a, $type_b ) {
$parents = self::get_type_parents( $type_b );
return in_array( $type_a, $parents, true );
}
/**
* Infer the minimal set of types needed for a set of properties
*
* Given a list of properties, returns the most general types that
* directly define those properties, avoiding redundant child types.
*
* For example:
* - ['prepTime', 'cookTime'] -> ['Recipe'] (most specific type with those properties)
* - ['headline'] -> ['CreativeWork'] (the base type that defines headline)
*
* @since 6.8.0
*
* @param array $properties Array of property names
* @return array Array of type names
*/
public static function infer_types_from_properties( $properties ) {
if ( empty( $properties ) ) {
return array();
}
// For each property, collect the types that directly define it
$types_per_property = array();
$property_domains = SchemaData::get_property_domains();
foreach ( $properties as $property ) {
if ( ! isset( $property_domains[ $property ] ) ) {
continue;
}
// These are the types that directly define this property
$types_per_property[ $property ] = $property_domains[ $property ];
}
if ( empty( $types_per_property ) ) {
return array();
}
// If we only have one property, return its direct types
if ( count( $types_per_property ) === 1 ) {
return array_values( reset( $types_per_property ) );
}
// Find types that can cover all properties (directly or through inheritance)
$all_types = array_unique( array_merge( ...array_values( $types_per_property ) ) );
$valid_types = array();
foreach ( $all_types as $type ) {
$type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
$covers_all = true;
foreach ( $types_per_property as $property => $defining_types ) {
// Check if this type or any of its parents define this property
if ( empty( array_intersect( $type_chain, $defining_types ) ) ) {
$covers_all = false;
break;
}
}
if ( $covers_all ) {
$valid_types[] = $type;
}
}
// If we found types that cover everything, remove redundant parents
if ( ! empty( $valid_types ) ) {
$minimal_types = array();
foreach ( $valid_types as $type ) {
$is_redundant = false;
foreach ( $valid_types as $other_type ) {
if ( $type !== $other_type && self::is_parent_of( $type, $other_type ) ) {
// This type is a parent of another type in the list, so it's redundant
$is_redundant = true;
break;
}
}
if ( ! $is_redundant ) {
$minimal_types[] = $type;
}
}
return array_values( $minimal_types );
}
// No single type covers all properties, need multiple types
return self::find_minimal_type_set( $properties );
}
/**
* Find minimal set of types to cover all properties
*
* Uses a greedy algorithm to find the smallest set of types that
* collectively support all given properties.
*
* @since 6.8.0
*
* @param array $properties Array of property names
* @return array Array of type names
*/
private static function find_minimal_type_set( $properties ) {
$uncovered_properties = $properties;
$selected_types = array();
$type_hierarchy = SchemaData::get_type_hierarchy();
$property_domains = SchemaData::get_property_domains();
while ( ! empty( $uncovered_properties ) ) {
$best_type = null;
$best_coverage = 0;
// Find the type that covers the most uncovered properties
foreach ( $type_hierarchy as $type => $parent ) {
$coverage = 0;
foreach ( $uncovered_properties as $property ) {
if ( isset( $property_domains[ $property ] ) ) {
$valid_types = $property_domains[ $property ];
// Check if this type or any of its parents support the property
$type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
if ( ! empty( array_intersect( $type_chain, $valid_types ) ) ) {
++$coverage;
}
}
}
if ( $coverage > $best_coverage ) {
$best_type = $type;
$best_coverage = $coverage;
}
}
if ( null === $best_type ) {
break; // No type covers remaining properties
}
$selected_types[] = $best_type;
// Remove covered properties
$type_chain = array_merge( array( $best_type ), self::get_type_parents( $best_type ) );
$uncovered_properties = array_filter(
$uncovered_properties,
function ( $property ) use ( $type_chain, $property_domains ) {
if ( ! isset( $property_domains[ $property ] ) ) {
return true;
}
$valid_types = $property_domains[ $property ];
return empty( array_intersect( $type_chain, $valid_types ) );
}
);
}
return $selected_types;
}
/**
* Get all properties grouped by type
*
* Returns an associative array where keys are type names and values
* are arrays of property names that belong to that type.
*
* @since 6.8.0
*
* @return array Associative array of type => properties
*/
public static function get_properties_by_type() {
// Return cached result if available.
if ( self::$properties_by_type_cache !== null ) {
return self::$properties_by_type_cache;
}
$properties_by_type = array();
$property_domains = SchemaData::get_property_domains();
// Build reverse mapping from properties to types
foreach ( $property_domains as $property => $types ) {
foreach ( $types as $type ) {
if ( ! isset( $properties_by_type[ $type ] ) ) {
$properties_by_type[ $type ] = array();
}
$properties_by_type[ $type ][] = $property;
}
}
// Sort properties within each type
foreach ( $properties_by_type as $type => $properties ) {
sort( $properties_by_type[ $type ] );
}
// Sort by type name
ksort( $properties_by_type );
// Cache the result.
self::$properties_by_type_cache = $properties_by_type;
return $properties_by_type;
}
/**
* Get the expected types (range) for a property
*
* Returns the types that a property expects as its value.
* For example, 'author' expects ['Person', 'Organization']
*
* @since 6.8.0
*
* @param string $property The property name
* @return array Array of type names, or empty array if not found
*/
public static function get_property_range( $property ) {
$property_ranges = SchemaData::get_property_ranges();
return $property_ranges[ $property ] ?? array();
}
/**
* Check if a property expects an object (not a primitive type)
*
* Returns true if the property expects a schema.org Type as its value,
* meaning it should be a nested object with @type.
*
* Primitive types: Text, Number, Boolean, Date, DateTime, Time, URL, etc.
*
* @since 6.8.0
*
* @param string $property The property name
* @return boolean True if property expects an object
*/
public static function property_expects_object( $property ) {
$range = self::get_property_range( $property );
if ( empty( $range ) ) {
return false;
}
// If any range type is not a primitive, it expects an object
foreach ( $range as $type ) {
if ( ! in_array( $type, self::$primitive_types, true ) ) {
return true;
}
}
return false;
}
/**
* Get the preferred object type for a property
*
* When a property expects an object, this returns the most appropriate type.
* For properties with multiple possible types, returns the first one.
*
* @since 6.8.0
*
* @param string $property The property name
* @return string|null The type name, or null if property doesn't expect an object
*/
public static function get_preferred_object_type( $property ) {
if ( ! self::property_expects_object( $property ) ) {
return null;
}
$range = self::get_property_range( $property );
// Filter out primitive types
$object_types = array_diff( $range, self::$primitive_types );
// Return first object type
return ! empty( $object_types ) ? reset( $object_types ) : null;
}
/**
* Get the supported JSON-LD ranges for a field type
*
* Returns the Schema.org types that a field type can output.
*
* @since 6.8.0
*
* @param string $field_type The ACF field type name (e.g., 'image', 'user')
* @return array Array of supported range types
*/
public static function get_field_type_ranges( $field_type ) {
$field_type_obj = acf_get_field_type( $field_type );
if ( ! $field_type_obj || ! method_exists( $field_type_obj, 'get_jsonld_output_types' ) ) {
return array();
}
return $field_type_obj->get_jsonld_output_types();
}
/**
* Check if a Schema.org type has properties defined on it or its ancestors
*
* Walks up the type hierarchy checking if the type or any parent (excluding
* Thing, which is too generic) has properties defined. This distinguishes
* structural types (Person, Place, Organization) from value types (Duration,
* Distance) that have no meaningful sub-properties.
*
* @since 6.8.0
*
* @param string $type The Schema.org type name
* @return boolean True if type or an ancestor has properties defined
*/
public static function type_has_properties( $type ) {
$current = $type;
$property_domains = SchemaData::get_property_domains();
$type_hierarchy = SchemaData::get_type_hierarchy();
// Walk up the hierarchy, but stop before Thing (too generic).
while ( $current && 'Thing' !== $current ) {
foreach ( $property_domains as $domains ) {
if ( in_array( $current, $domains, true ) ) {
return true;
}
}
// Move to parent type.
$current = $type_hierarchy[ $current ] ?? null;
}
return false;
}
/**
* Get valid output formats for a field/property combination
*
* When a field type supports multiple output formats (e.g., image can
* output URL or ImageObject), this returns the formats valid for a
* specific property.
*
* @since 6.8.0
*
* @param string $field_type The ACF field type name
* @param string $property The Schema.org property name
* @return array Array of valid output format types
*/
public static function get_valid_output_formats( $field_type, $property ) {
$field_ranges = self::get_field_type_ranges( $field_type );
$property_ranges = self::get_property_range( $property );
if ( empty( $field_ranges ) || empty( $property_ranges ) ) {
return array();
}
$valid_formats = array();
foreach ( $field_ranges as $field_range ) {
// Direct match
if ( in_array( $field_range, $property_ranges, true ) ) {
$valid_formats[] = $field_range;
continue;
}
// Skip inheritance check for primitive types.
if ( in_array( $field_range, self::$primitive_types, true ) ) {
continue;
}
// Check if field range is a subtype of any property range
foreach ( $property_ranges as $property_range ) {
// Skip if property expects a primitive type.
if ( in_array( $property_range, self::$primitive_types, true ) ) {
continue;
}
if ( self::is_parent_of( $property_range, $field_range ) ) {
$valid_formats[] = $field_range;
break;
}
}
// For fields that output Thing, include property ranges and their subtypes.
// This allows Group/Repeater fields to show specific types like Person, HowToStep.
if ( 'Thing' === $field_range ) {
foreach ( $property_ranges as $property_range ) {
// Skip primitives.
if ( in_array( $property_range, self::$primitive_types, true ) ) {
continue;
}
// Include the property range itself if it's a structural subtype of Thing.
if ( self::is_parent_of( $field_range, $property_range ) ) {
if ( self::type_has_properties( $property_range ) ) {
$valid_formats[] = $property_range;
}
}
// Also include subtypes of the property range (e.g., HowToStep for CreativeWork).
$type_hierarchy = SchemaData::get_type_hierarchy();
foreach ( $type_hierarchy as $type => $parent ) {
if ( self::is_parent_of( $property_range, $type ) ) {
if ( self::type_has_properties( $type ) ) {
$valid_formats[] = $type;
}
}
}
}
}
}
// Handle text value types (Duration, Distance, etc.).
// These are modeled as objects in Schema.org but are really formatted strings.
// Text fields should be able to output these types.
if ( in_array( 'Text', $field_ranges, true ) ) {
foreach ( $property_ranges as $property_range ) {
if ( in_array( $property_range, self::$text_value_types, true ) ) {
$valid_formats[] = $property_range;
}
}
}
return array_unique( $valid_formats );
}
/**
* Parse a qualified property string (e.g., "Offer.price" or "price")
*
* Returns an array with 'type' and 'property' keys.
* For unqualified properties (no dot), type will be null.
*
* @since 6.8.0
*
* @param string $qualified_property The property string, optionally prefixed with Type.
* @return array Array with 'type' (string|null) and 'property' (string) keys.
*/
public static function parse_qualified_property( $qualified_property ) {
if ( strpos( $qualified_property, '.' ) !== false ) {
list( $type, $property ) = explode( '.', $qualified_property, 2 );
return array(
'type' => $type,
'property' => $property,
);
}
return array(
'type' => null,
'property' => $qualified_property,
);
}
/**
* Get just the property name from a qualified property string
*
* @since 6.8.0
*
* @param string $qualified_property The property string, optionally prefixed with Type.
* @return string The property name without the type prefix.
*/
public static function get_property_name( $qualified_property ) {
return self::parse_qualified_property( $qualified_property )['property'];
}
/**
* Get the type from a qualified property string
*
* @since 6.8.0
*
* @param string $qualified_property The property string, optionally prefixed with Type.
* @return string|null The type name, or null if not qualified.
*/
public static function get_property_type( $qualified_property ) {
return self::parse_qualified_property( $qualified_property )['type'];
}
/**
* Get the default output format for a field/property combination
*
* When multiple formats are valid, returns the most appropriate default.
* Prefers object types over primitives when both are available.
*
* @since 6.8.0
*
* @param string $field_type The ACF field type name
* @param string $property The Schema.org property name
* @return string|null The default format type, or null if none valid
*/
public static function get_default_output_format( $field_type, $property ) {
$valid_formats = self::get_valid_output_formats( $field_type, $property );
if ( empty( $valid_formats ) ) {
return null;
}
// If only one format, use it
if ( count( $valid_formats ) === 1 ) {
return $valid_formats[0];
}
// Prefer object types over primitives (richer data)
$object_formats = array_diff( $valid_formats, self::$primitive_types );
if ( ! empty( $object_formats ) ) {
return reset( $object_formats );
}
// Fall back to first primitive
return $valid_formats[0];
}
}
@@ -0,0 +1,126 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
/**
* Schema.org type and property data with lazy loading
*
* Data files in the /data directory are auto-generated by tools/generate-schema.js
* To regenerate data files, run: npm run generate-schema
*
* @package ACF\AI\GEO
*/
namespace ACF\AI\GEO;
/**
* Class SchemaData
*
* Contains static schema.org vocabulary data for type and property validation.
* Data is lazy-loaded from separate files to reduce memory usage when not needed.
*/
class SchemaData {
/**
* Type hierarchy mapping (lazy-loaded)
*
* @var array|null
*/
private static $type_hierarchy = null;
/**
* Property domain mappings (lazy-loaded)
*
* @var array|null
*/
private static $property_domains = null;
/**
* Property range mappings (lazy-loaded)
*
* @var array|null
*/
private static $property_ranges = null;
/**
* Compatible properties by output type (lazy-loaded)
*
* @var array|null
*/
private static $compatible_properties = null;
/**
* Get the type hierarchy mapping
*
* Maps each type to its parent type in the schema.org hierarchy.
* For example: 'Recipe' => 'HowTo'
*
* @since 6.8.0
*
* @return array
*/
public static function get_type_hierarchy(): array {
if ( self::$type_hierarchy === null ) {
self::$type_hierarchy = require __DIR__ . '/data/type-hierarchy.php';
}
return self::$type_hierarchy;
}
/**
* Get the property domain mappings
*
* Maps each property to the types it can be used with.
* For example: 'prepTime' => ['HowTo', 'HowToDirection']
*
* @since 6.8.0
*
* @return array
*/
public static function get_property_domains(): array {
if ( self::$property_domains === null ) {
self::$property_domains = require __DIR__ . '/data/property-domains.php';
}
return self::$property_domains;
}
/**
* Get the property range mappings
*
* Maps each property to the types it expects as values.
* For example: 'author' => ['Person', 'Organization']
*
* @since 6.8.0
*
* @return array
*/
public static function get_property_ranges(): array {
if ( self::$property_ranges === null ) {
self::$property_ranges = require __DIR__ . '/data/property-ranges.php';
}
return self::$property_ranges;
}
/**
* Get compatible properties by output type
*
* Pre-computed mapping of output types to compatible properties.
* For example: 'Text' => ['name', 'description', ...]
*
* @since 6.8.0
*
* @return array
*/
public static function get_compatible_properties(): array {
if ( self::$compatible_properties === null ) {
self::$compatible_properties = require __DIR__ . '/data/compatible-properties.php';
}
return self::$compatible_properties;
}
}
@@ -0,0 +1,948 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
/**
* Schema.org type hierarchy data
*
* This file is auto-generated by tools/generate-schema.js
* DO NOT EDIT MANUALLY - Run 'npm run generate-schema' to regenerate
*
* Generated: 2026-02-25
* Source: https://schema.org/version/latest/schemaorg-current-https.jsonld
*
* @package ACF\AI\GEO
*/
return array(
'Conversation' => 'CreativeWork',
'TheaterEvent' => 'Event',
'WatchAction' => 'ConsumeAction',
'AnalysisNewsArticle' => 'NewsArticle',
'Room' => 'Accommodation',
'WearableSizeGroupEnumeration' => 'SizeGroupEnumeration',
'FoodEstablishment' => 'LocalBusiness',
'CriticReview' => 'Review',
'CompleteDataFeed' => 'DataFeed',
'JewelryStore' => 'Store',
'IPTCDigitalSourceEnumeration' => 'MediaEnumeration',
'LeaveAction' => 'InteractAction',
'BoatReservation' => 'Reservation',
'WholesaleStore' => 'Store',
'Diet' => 'CreativeWork',
'Certification' => 'fibo-fnd-arr-doc:Certificate',
'MedicalRiskFactor' => 'MedicalEntity',
'Continent' => 'Landform',
'TVSeason' => 'CreativeWorkSeason',
'ProductCollection' => 'Product',
'VideoObjectSnapshot' => 'VideoObject',
'MusicRecording' => 'CreativeWork',
'3DModel' => 'MediaObject',
'SchoolDistrict' => 'AdministrativeArea',
'AggregateOffer' => 'Offer',
'Distillery' => 'FoodEstablishment',
'BroadcastFrequencySpecification' => 'Intangible',
'Manuscript' => 'CreativeWork',
'GovernmentPermit' => 'Permit',
'GolfCourse' => 'SportsActivityLocation',
'EmploymentAgency' => 'LocalBusiness',
'PaymentCard' => 'FinancialProduct',
'UserTweets' => 'UserInteraction',
'QAPage' => 'WebPage',
'ParkingFacility' => 'CivicStructure',
'HowToSection' => 'CreativeWork',
'CampingPitch' => 'Accommodation',
'MapCategoryType' => 'Enumeration',
'TextObject' => 'MediaObject',
'Map' => 'CreativeWork',
'SiteNavigationElement' => 'WebPageElement',
'OnlineStore' => 'OnlineBusiness',
'AssignAction' => 'AllocateAction',
'FastFoodRestaurant' => 'FoodEstablishment',
'QuantitativeValueDistribution' => 'StructuredValue',
'NGO' => 'Organization',
'RealEstateListing' => 'WebPage',
'DigitalDocumentPermission' => 'Intangible',
'SomeProducts' => 'Product',
'DiscoverAction' => 'FindAction',
'ScholarlyArticle' => 'Article',
'TaxiReservation' => 'Reservation',
'Event' => 'Thing',
'RuntimePlatform' => 'SoftwareApplication',
'Pharmacy' => 'MedicalOrganization',
'AdministrativeArea' => 'Place',
'Patient' => 'MedicalAudience',
'Permit' => 'Intangible',
'TattooParlor' => 'HealthAndBeautyBusiness',
'PlayAction' => 'Action',
'Protein' => 'BioChemEntity',
'MotorizedBicycle' => 'Vehicle',
'MedicalIndication' => 'MedicalEntity',
'BusinessAudience' => 'Audience',
'Guide' => 'CreativeWork',
'TheaterGroup' => 'PerformingGroup',
'Playground' => 'CivicStructure',
'Apartment' => 'Accommodation',
'Course' => 'LearningResource',
'CheckAction' => 'FindAction',
'Quotation' => 'CreativeWork',
'Taxon' => 'Thing',
'AchieveAction' => 'Action',
'ShortStory' => 'CreativeWork',
'WPHeader' => 'WebPageElement',
'MedicalSignOrSymptom' => 'MedicalCondition',
'Audience' => 'Intangible',
'MusicRelease' => 'MusicPlaylist',
'PaymentService' => 'FinancialProduct',
'Episode' => 'CreativeWork',
'PhysiciansOffice' => 'Physician',
'AutoRepair' => 'AutomotiveBusiness',
'GardenStore' => 'Store',
'SocialMediaPosting' => 'Article',
'TVClip' => 'Clip',
'MediaReviewItem' => 'CreativeWork',
'AmpStory' => 'CreativeWork',
'ResetPasswordAction' => 'ControlAction',
'PlanAction' => 'OrganizeAction',
'Place' => 'Thing',
'BedDetails' => 'Intangible',
'MediaGallery' => 'CollectionPage',
'WarrantyPromise' => 'fibo-fnd-agr-ctr:MutualContractualAgreement',
'Demand' => 'Intangible',
'MedicalConditionStage' => 'MedicalIntangible',
'PublicationVolume' => 'CreativeWork',
'ImageObjectSnapshot' => 'ImageObject',
'GovernmentOrganization' => 'Organization',
'Person' => 'Thing',
'PeopleAudience' => 'Audience',
'Integer' => 'Number',
'PerformanceRole' => 'Role',
'SoftwareApplication' => 'CreativeWork',
'MenuSection' => 'CreativeWork',
'Occupation' => 'Intangible',
'FurnitureStore' => 'Store',
'ReserveAction' => 'PlanAction',
'MedicalAudienceType' => 'MedicalEnumeration',
'DrugClass' => 'MedicalEntity',
'WebPage' => 'CreativeWork',
'MedicalObservationalStudy' => 'MedicalStudy',
'BusTrip' => 'Trip',
'MerchantReturnEnumeration' => 'Enumeration',
'Organization' => 'Thing',
'InfectiousDisease' => 'MedicalCondition',
'Product' => 'Thing',
'ContactPointOption' => 'Enumeration',
'RentAction' => 'TradeAction',
'TouristDestination' => 'Place',
'DigitalDocument' => 'CreativeWork',
'PreventionIndication' => 'MedicalIndication',
'AppendAction' => 'InsertAction',
'BookStore' => 'Store',
'BloodTest' => 'MedicalTest',
'PhysicalActivity' => 'LifestyleModification',
'SuperficialAnatomy' => 'MedicalEntity',
'Comment' => 'CreativeWork',
'GeoShape' => 'StructuredValue',
'GeoCoordinates' => 'StructuredValue',
'Hackathon' => 'Event',
'HowToStep' => 'CreativeWork',
'Winery' => 'FoodEstablishment',
'PaymentMethodType' => 'Enumeration',
'MedicalStudy' => 'MedicalEntity',
'Photograph' => 'CreativeWork',
'FoodEstablishmentReservation' => 'Reservation',
'Language' => 'Intangible',
'FundingScheme' => 'Organization',
'Artery' => 'Vessel',
'FundingAgency' => 'Project',
'MusicComposition' => 'CreativeWork',
'InvestmentOrDeposit' => 'FinancialProduct',
'PriceSpecification' => 'StructuredValue',
'PublicSwimmingPool' => 'SportsActivityLocation',
'Error' => 'InstantaneousEvent',
'GameServerStatus' => 'StatusEnumeration',
'CollegeOrUniversity' => 'EducationalOrganization',
'ItemListOrderType' => 'Enumeration',
'Corporation' => 'Organization',
'OfferForPurchase' => 'Offer',
'LegalValueLevel' => 'Enumeration',
'DrugStrength' => 'MedicalIntangible',
'Order' => 'Intangible',
'MedicalTestPanel' => 'MedicalTest',
'ComicCoverArt' => 'ComicStory',
'OpeningHoursSpecification' => 'StructuredValue',
'BuddhistTemple' => 'PlaceOfWorship',
'BedAndBreakfast' => 'LodgingBusiness',
'DrugLegalStatus' => 'MedicalIntangible',
'SeekToAction' => 'Action',
'FloorPlan' => 'Intangible',
'SizeSpecification' => 'QualitativeValue',
'Courthouse' => 'GovernmentBuilding',
'VirtualLocation' => 'Intangible',
'RadioSeries' => 'CreativeWorkSeries',
'PaintAction' => 'CreateAction',
'DrugCost' => 'MedicalEntity',
'MediaObject' => 'CreativeWork',
'Painting' => 'CreativeWork',
'DefinedRegion' => 'Place',
'MedicineSystem' => 'MedicalEnumeration',
'ReplaceAction' => 'UpdateAction',
'HealthPlanNetwork' => 'Intangible',
'City' => 'AdministrativeArea',
'ApplyAction' => 'OrganizeAction',
'SpreadsheetDigitalDocument' => 'DigitalDocument',
'SportsOrganization' => 'Organization',
'MoneyTransfer' => 'TransferAction',
'Car' => 'Vehicle',
'MedicalIntangible' => 'MedicalEntity',
'DryCleaningOrLaundry' => 'LocalBusiness',
'AuthenticateAction' => 'ControlAction',
'ControlAction' => 'Action',
'Recipe' => 'HowTo',
'BookmarkAction' => 'OrganizeAction',
'InsuranceAgency' => 'FinancialService',
'TakeAction' => 'TransferAction',
'SubwayStation' => 'CivicStructure',
'PoliticalParty' => 'Organization',
'RadioClip' => 'Clip',
'TouristInformationCenter' => 'LocalBusiness',
'SeaBodyOfWater' => 'BodyOfWater',
'Service' => 'Intangible',
'OnlineBusiness' => 'Organization',
'CivicStructure' => 'Place',
'Duration' => 'Quantity',
'Grant' => 'Intangible',
'LegalForceStatus' => 'StatusEnumeration',
'HealthPlanFormulary' => 'Intangible',
'ProductGroup' => 'cmns-cls:Classifier',
'PoliceStation' => 'CivicStructure',
'SportsTeam' => 'SportsOrganization',
'PlayGameAction' => 'ConsumeAction',
'ShippingDeliveryTime' => 'StructuredValue',
'ContactPage' => 'WebPage',
'DanceGroup' => 'PerformingGroup',
'Sculpture' => 'CreativeWork',
'RecyclingCenter' => 'LocalBusiness',
'LocalBusiness' => 'Place',
'SuspendAction' => 'ControlAction',
'MediaSubscription' => 'Intangible',
'NutritionInformation' => 'StructuredValue',
'SportsActivityLocation' => 'LocalBusiness',
'Intangible' => 'Thing',
'MedicalStudyStatus' => 'MedicalEnumeration',
'Resort' => 'LodgingBusiness',
'NLNonprofitType' => 'NonprofitType',
'Atlas' => 'CreativeWork',
'SportsClub' => 'SportsActivityLocation',
'MediaEnumeration' => 'Enumeration',
'PriceTypeEnumeration' => 'Enumeration',
'ReturnFeesEnumeration' => 'Enumeration',
'UnitPriceSpecification' => 'PriceSpecification',
'RadioSeason' => 'CreativeWorkSeason',
'ReturnAction' => 'TransferAction',
'HealthAndBeautyBusiness' => 'LocalBusiness',
'OfferShippingDetails' => 'StructuredValue',
'PresentationDigitalDocument' => 'DigitalDocument',
'Drawing' => 'CreativeWork',
'BefriendAction' => 'InteractAction',
'AgreeAction' => 'ReactAction',
'RoofingContractor' => 'HomeAndConstructionBusiness',
'OceanBodyOfWater' => 'BodyOfWater',
'RiverBodyOfWater' => 'BodyOfWater',
'MedicalScholarlyArticle' => 'ScholarlyArticle',
'ConfirmAction' => 'InformAction',
'TVSeries' => 'CreativeWork',
'GasStation' => 'AutomotiveBusiness',
'WantAction' => 'ReactAction',
'WPSideBar' => 'WebPageElement',
'Library' => 'LocalBusiness',
'TrainStation' => 'CivicStructure',
'PerformAction' => 'PlayAction',
'Class' => 'Intangible',
'AboutPage' => 'WebPage',
'MathSolver' => 'CreativeWork',
'EndorseAction' => 'ReactAction',
'PrependAction' => 'InsertAction',
'FilmAction' => 'CreateAction',
'SolveMathAction' => 'Action',
'EducationEvent' => 'Event',
'ArriveAction' => 'MoveAction',
'EmployeeRole' => 'OrganizationRole',
'ExchangeRateSpecification' => 'StructuredValue',
'BusStop' => 'CivicStructure',
'SoftwareSourceCode' => 'CreativeWork',
'EmployerAggregateRating' => 'AggregateRating',
'EducationalOccupationalProgram' => 'Intangible',
'IncentiveQualifiedExpenseType' => 'Enumeration',
'Seat' => 'Intangible',
'LocationFeatureSpecification' => 'PropertyValue',
'AnimalShelter' => 'LocalBusiness',
'MerchantReturnPolicySeasonalOverride' => 'Intangible',
'UserComments' => 'UserInteraction',
'Volcano' => 'Landform',
'InteractAction' => 'Action',
'LearningResource' => 'CreativeWork',
'Hospital' => 'CivicStructure',
'Book' => 'CreativeWork',
'BuyAction' => 'TradeAction',
'PriceComponentTypeEnumeration' => 'Enumeration',
'ShippingConditions' => 'StructuredValue',
'HealthInsurancePlan' => 'Intangible',
'BroadcastEvent' => 'PublicationEvent',
'LoginAction' => 'ControlAction',
'QuantitativeValue' => 'StructuredValue',
'ReportedDoseSchedule' => 'DoseSchedule',
'WPFooter' => 'WebPageElement',
'CancelAction' => 'PlanAction',
'CatholicChurch' => 'Church',
'LoanOrCredit' => 'FinancialProduct',
'PaymentStatusType' => 'StatusEnumeration',
'OnDemandEvent' => 'PublicationEvent',
'SequentialArt' => 'VisualArtwork',
'SearchRescueOrganization' => 'Organization',
'EntertainmentBusiness' => 'LocalBusiness',
'MenuItem' => 'Intangible',
'TireShop' => 'Store',
'Drug' => 'Substance',
'ExerciseAction' => 'PlayAction',
'MonetaryGrant' => 'Grant',
'BrokerageAccount' => 'InvestmentOrDeposit',
'BowlingAlley' => 'SportsActivityLocation',
'EventVenue' => 'CivicStructure',
'DiagnosticProcedure' => 'MedicalProcedure',
'HealthPlanCostSharingSpecification' => 'Intangible',
'TravelAction' => 'MoveAction',
'MedicalObservationalStudyDesign' => 'MedicalEnumeration',
'ReservationStatusType' => 'StatusEnumeration',
'Taxi' => 'Service',
'Airport' => 'CivicStructure',
'DeactivateAction' => 'ControlAction',
'CookAction' => 'CreateAction',
'MovieSeries' => 'CreativeWorkSeries',
'Observation' => 'Intangible',
'ClothingStore' => 'Store',
'FMRadioChannel' => 'RadioChannel',
'ChemicalSubstance' => 'BioChemEntity',
'USNonprofitType' => 'NonprofitType',
'ComicIssue' => 'PublicationIssue',
'Reservoir' => 'BodyOfWater',
'CategoryCode' => 'DefinedTerm',
'TextDigitalDocument' => 'DigitalDocument',
'GenderType' => 'Enumeration',
'APIReference' => 'TechArticle',
'AutoBodyShop' => 'AutomotiveBusiness',
'EnergyStarEnergyEfficiencyEnumeration' => 'EnergyEfficiencyEnumeration',
'Quiz' => 'LearningResource',
'Embassy' => 'GovernmentBuilding',
'Vein' => 'Vessel',
'RadioEpisode' => 'Episode',
'ProfilePage' => 'WebPage',
'Legislation' => 'CreativeWork',
'VacationRental' => 'LodgingBusiness',
'HotelRoom' => 'Room',
'AcceptAction' => 'AllocateAction',
'DeliveryEvent' => 'Event',
'ListItem' => 'Intangible',
'Joint' => 'AnatomicalStructure',
'DeleteAction' => 'UpdateAction',
'ActionStatusType' => 'StatusEnumeration',
'ConferenceEvent' => 'Event',
'ConvenienceStore' => 'Store',
'Flight' => 'Trip',
'ScreeningEvent' => 'Event',
'Game' => 'CreativeWork',
'ScheduleAction' => 'PlanAction',
'MiddleSchool' => 'EducationalOrganization',
'MedicalImagingTechnique' => 'MedicalEnumeration',
'ReadAction' => 'ConsumeAction',
'CurrencyConversionService' => 'FinancialProduct',
'DrawAction' => 'CreateAction',
'MensClothingStore' => 'Store',
'ShareAction' => 'CommunicateAction',
'MedicalSign' => 'MedicalSignOrSymptom',
'Cemetery' => 'CivicStructure',
'LakeBodyOfWater' => 'BodyOfWater',
'Locksmith' => 'HomeAndConstructionBusiness',
'CollectionPage' => 'WebPage',
'ServicePeriod' => 'StructuredValue',
'OrderAction' => 'TradeAction',
'ComedyEvent' => 'Event',
'CoverArt' => 'VisualArtwork',
'Chapter' => 'CreativeWork',
'PerformingArtsEvent' => 'Event',
'RsvpResponseType' => 'Enumeration',
'EndorsementRating' => 'Rating',
'LifestyleModification' => 'MedicalEntity',
'NonprofitType' => 'Enumeration',
'LegislationObject' => 'MediaObject',
'MedicalWebPage' => 'WebPage',
'OfferItemCondition' => 'Enumeration',
'PostalCodeRangeSpecification' => 'StructuredValue',
'HowTo' => 'CreativeWork',
'TaxiService' => 'Service',
'SocialEvent' => 'Event',
'BusinessEvent' => 'Event',
'DiagnosticLab' => 'MedicalOrganization',
'BedType' => 'QualitativeValue',
'GiveAction' => 'TransferAction',
'EnergyEfficiencyEnumeration' => 'Enumeration',
'TreatmentIndication' => 'MedicalIndication',
'FireStation' => 'CivicStructure',
'AmusementPark' => 'EntertainmentBusiness',
'DeliveryChargeSpecification' => 'PriceSpecification',
'ListenAction' => 'ConsumeAction',
'TravelAgency' => 'LocalBusiness',
'BoatTerminal' => 'CivicStructure',
'BookSeries' => 'CreativeWorkSeries',
'MovingCompany' => 'HomeAndConstructionBusiness',
'ComputerStore' => 'Store',
'EnergyConsumptionDetails' => 'Intangible',
'HyperToc' => 'CreativeWork',
'ComicStory' => 'CreativeWork',
'LiveBlogPosting' => 'BlogPosting',
'MedicalEntity' => 'Thing',
'Table' => 'WebPageElement',
'SingleFamilyResidence' => 'House',
'CommentAction' => 'CommunicateAction',
'UserPageVisits' => 'UserInteraction',
'AggregateRating' => 'Rating',
'ItemAvailability' => 'Enumeration',
'TelevisionStation' => 'LocalBusiness',
'MedicalGuidelineRecommendation' => 'MedicalGuideline',
'ItemList' => 'Intangible',
'LymphaticVessel' => 'Vessel',
'PublicationIssue' => 'CreativeWork',
'CertificationStatusEnumeration' => 'Enumeration',
'UpdateAction' => 'Action',
'House' => 'Accommodation',
'MeasurementTypeEnumeration' => 'Enumeration',
'SpeakableSpecification' => 'Intangible',
'NightClub' => 'EntertainmentBusiness',
'ExhibitionEvent' => 'Event',
'NoteDigitalDocument' => 'DigitalDocument',
'AutoWash' => 'AutomotiveBusiness',
'RegisterAction' => 'InteractAction',
'DefenceEstablishment' => 'GovernmentBuilding',
'MedicalTrial' => 'MedicalStudy',
'GameServer' => 'Intangible',
'IncentiveStatus' => 'Enumeration',
'CreativeWork' => 'Thing',
'Bone' => 'AnatomicalStructure',
'SubscribeAction' => 'InteractAction',
'QualitativeValue' => 'Enumeration',
'DataDownload' => 'MediaObject',
'PaymentMethod' => 'Intangible',
'DiscussionForumPosting' => 'SocialMediaPosting',
'InviteAction' => 'CommunicateAction',
'OrganizationRole' => 'Role',
'DDxElement' => 'MedicalIntangible',
'Ligament' => 'AnatomicalStructure',
'ComicSeries' => 'Periodical',
'Store' => 'LocalBusiness',
'EmployerReview' => 'Review',
'GatedResidenceCommunity' => 'Residence',
'BusReservation' => 'Reservation',
'Season' => 'CreativeWork',
'EventSeries' => 'Event',
'BroadcastService' => 'Service',
'AllocateAction' => 'OrganizeAction',
'SpecialAnnouncement' => 'CreativeWork',
'MedicalGuideline' => 'MedicalEntity',
'Plumber' => 'HomeAndConstructionBusiness',
'Cooperative' => 'Organization',
'Consortium' => 'Organization',
'BrainStructure' => 'AnatomicalStructure',
'Series' => 'Intangible',
'RVPark' => 'CivicStructure',
'RadioStation' => 'LocalBusiness',
'MedicalRiskScore' => 'MedicalRiskEstimator',
'TaxiStand' => 'CivicStructure',
'FinancialProduct' => 'Service',
'AddAction' => 'UpdateAction',
'StructuredValue' => 'Intangible',
'MemberProgramTier' => 'Intangible',
'TypeAndQuantityNode' => 'StructuredValue',
'MortgageLoan' => 'LoanOrCredit',
'Airline' => 'Organization',
'MedicalAudience' => 'Audience',
'Reservation' => 'Intangible',
'JobPosting' => 'Intangible',
'DrugPrescriptionStatus' => 'MedicalEnumeration',
'ReactAction' => 'AssessAction',
'WebApplication' => 'SoftwareApplication',
'WorkBasedProgram' => 'EducationalOccupationalProgram',
'SkiResort' => 'SportsActivityLocation',
'BreadcrumbList' => 'ItemList',
'SelfStorage' => 'LocalBusiness',
'PodcastSeries' => 'CreativeWorkSeries',
'AudioObject' => 'MediaObject',
'ProductModel' => 'Product',
'MedicalCondition' => 'MedicalEntity',
'EventAttendanceModeEnumeration' => 'Enumeration',
'IndividualProduct' => 'Product',
'IceCreamShop' => 'FoodEstablishment',
'VisualArtwork' => 'CreativeWork',
'Florist' => 'Store',
'LodgingBusiness' => 'LocalBusiness',
'DigitalPlatformEnumeration' => 'Enumeration',
'FinancialService' => 'LocalBusiness',
'FlightReservation' => 'Reservation',
'Movie' => 'CreativeWork',
'Researcher' => 'Audience',
'DefinedTerm' => 'Intangible',
'Code' => 'CreativeWork',
'Beach' => 'CivicStructure',
'MovieClip' => 'Clip',
'OnlineMarketplace' => 'OnlineStore',
'ShippingService' => 'StructuredValue',
'Question' => 'Comment',
'HowToTool' => 'HowToItem',
'PerformingArtsTheater' => 'CivicStructure',
'CovidTestingFacility' => 'MedicalClinic',
'NewsMediaOrganization' => 'Organization',
'PreOrderAction' => 'TradeAction',
'CafeOrCoffeeShop' => 'FoodEstablishment',
'URL' => 'Text',
'AutomatedTeller' => 'FinancialService',
'InternetCafe' => 'LocalBusiness',
'Action' => 'Thing',
'PetStore' => 'Store',
'InstantaneousEvent' => 'StructuredValue',
'OfficeEquipmentStore' => 'Store',
'XPathType' => 'Text',
'DeliveryMethod' => 'Enumeration',
'Motel' => 'LodgingBusiness',
'OccupationalExperienceRequirements' => 'Intangible',
'AssessAction' => 'Action',
'MoveAction' => 'Action',
'EmergencyService' => 'LocalBusiness',
'BioChemEntity' => 'Thing',
'PublicToilet' => 'CivicStructure',
'SteeringPositionValue' => 'QualitativeValue',
'AuthorizeAction' => 'AllocateAction',
'TradeAction' => 'Action',
'BroadcastChannel' => 'Intangible',
'Trip' => 'Intangible',
'TieAction' => 'AchieveAction',
'GamePlayMode' => 'Enumeration',
'DisagreeAction' => 'ReactAction',
'Enumeration' => 'Intangible',
'Collection' => 'CreativeWork',
'ReturnMethodEnumeration' => 'Enumeration',
'AutoPartsStore' => 'Store',
'ArtGallery' => 'EntertainmentBusiness',
'OrderItem' => 'StructuredValue',
'EducationalOccupationalCredential' => 'CreativeWork',
'UserBlocks' => 'UserInteraction',
'NewsArticle' => 'Article',
'MediaReview' => 'Review',
'RadioBroadcastService' => 'BroadcastService',
'UseAction' => 'ConsumeAction',
'SurgicalProcedure' => 'MedicalProcedure',
'GovernmentService' => 'Service',
'Physician' => 'MedicalOrganization',
'Optician' => 'MedicalBusiness',
'SheetMusic' => 'CreativeWork',
'CreativeWorkSeries' => 'Series',
'LiquorStore' => 'Store',
'ChooseAction' => 'AssessAction',
'RestrictedDiet' => 'Enumeration',
'Project' => 'Organization',
'HealthTopicContent' => 'WebContent',
'Bakery' => 'FoodEstablishment',
'Answer' => 'Comment',
'ShippingRateSettings' => 'StructuredValue',
'ItemPage' => 'WebPage',
'ChildCare' => 'LocalBusiness',
'CssSelectorType' => 'Text',
'SizeSystemEnumeration' => 'Enumeration',
'Bridge' => 'CivicStructure',
'EducationalOrganization' => 'Organization',
'BusStation' => 'CivicStructure',
'RejectAction' => 'AllocateAction',
'Aquarium' => 'CivicStructure',
'AdvertiserContentArticle' => 'Article',
'CompoundPriceSpecification' => 'PriceSpecification',
'TrainTrip' => 'Trip',
'DepartAction' => 'MoveAction',
'ProgramMembership' => 'Intangible',
'Brewery' => 'FoodEstablishment',
'Ticket' => 'Intangible',
'UserInteraction' => 'Event',
'HousePainter' => 'HomeAndConstructionBusiness',
'Report' => 'Article',
'DataFeed' => 'Dataset',
'PlaceOfWorship' => 'CivicStructure',
'Notary' => 'LegalService',
'WebSite' => 'CreativeWork',
'LibrarySystem' => 'Organization',
'CreativeWorkSeason' => 'CreativeWork',
'IncentiveType' => 'Enumeration',
'AutomotiveBusiness' => 'LocalBusiness',
'Vessel' => 'AnatomicalStructure',
'DonateAction' => 'TransferAction',
'LoseAction' => 'AchieveAction',
'OrganizeAction' => 'Action',
'PayAction' => 'TradeAction',
'WearableMeasurementTypeEnumeration' => 'MeasurementTypeEnumeration',
'PodcastEpisode' => 'Episode',
'Energy' => 'Quantity',
'Campground' => 'CivicStructure',
'Schedule' => 'Intangible',
'Hotel' => 'LodgingBusiness',
'FinancialIncentive' => 'Intangible',
'Claim' => 'CreativeWork',
'ParentAudience' => 'PeopleAudience',
'Clip' => 'CreativeWork',
'HowToItem' => 'ListItem',
'HomeAndConstructionBusiness' => 'LocalBusiness',
'InformAction' => 'CommunicateAction',
'CityHall' => 'GovernmentBuilding',
'VideoGallery' => 'MediaGallery',
'MusicVideoObject' => 'MediaObject',
'TierBenefitEnumeration' => 'Enumeration',
'BikeStore' => 'Store',
'MemberProgram' => 'Intangible',
'CorrectionComment' => 'Comment',
'TrainReservation' => 'Reservation',
'MusicReleaseFormatType' => 'Enumeration',
'MusicVenue' => 'CivicStructure',
'LendAction' => 'TransferAction',
'MusicAlbum' => 'MusicPlaylist',
'CreditCard' => 'LoanOrCredit',
'Vehicle' => 'Product',
'HyperTocEntry' => 'CreativeWork',
'Newspaper' => 'Periodical',
'MedicalOrganization' => 'Organization',
'Accommodation' => 'Place',
'MedicalTrialDesign' => 'MedicalEnumeration',
'PalliativeProcedure' => 'MedicalTherapy',
'AlignmentObject' => 'Intangible',
'DepartmentStore' => 'Store',
'WebPageElement' => 'CreativeWork',
'MusicAlbumProductionType' => 'Enumeration',
'WebAPI' => 'Service',
'DislikeAction' => 'ReactAction',
'SportsEvent' => 'Event',
'AutoRental' => 'AutomotiveBusiness',
'Electrician' => 'HomeAndConstructionBusiness',
'Mosque' => 'PlaceOfWorship',
'MedicalProcedureType' => 'MedicalEnumeration',
'RealEstateAgent' => 'LocalBusiness',
'ImageObject' => 'MediaObject',
'Offer' => 'Intangible',
'MotorcycleRepair' => 'AutomotiveBusiness',
'MedicalEnumeration' => 'Enumeration',
'HowToSupply' => 'HowToItem',
'HairSalon' => 'HealthAndBeautyBusiness',
'CategoryCodeSet' => 'DefinedTermSet',
'ArchiveComponent' => 'CreativeWork',
'Waterfall' => 'BodyOfWater',
'LegislativeBuilding' => 'GovernmentBuilding',
'SatiricalArticle' => 'Article',
'ViewAction' => 'ConsumeAction',
'EUEnergyEfficiencyEnumeration' => 'EnergyEfficiencyEnumeration',
'FollowAction' => 'InteractAction',
'AnatomicalStructure' => 'MedicalEntity',
'TransferAction' => 'Action',
'PawnShop' => 'Store',
'AskAction' => 'CommunicateAction',
'ImagingTest' => 'MedicalTest',
'AdultEntertainment' => 'EntertainmentBusiness',
'GeospatialGeometry' => 'Intangible',
'ReviewAction' => 'AssessAction',
'WebContent' => 'CreativeWork',
'Barcode' => 'ImageObject',
'ReceiveAction' => 'TransferAction',
'Attorney' => 'LegalService',
'ElementarySchool' => 'EducationalOrganization',
'ExercisePlan' => 'PhysicalActivity',
'DrinkAction' => 'ConsumeAction',
'WPAdBlock' => 'WebPageElement',
'MonetaryAmountDistribution' => 'QuantitativeValueDistribution',
'ReportageNewsArticle' => 'NewsArticle',
'StatisticalPopulation' => 'Intangible',
'SizeGroupEnumeration' => 'Enumeration',
'Canal' => 'BodyOfWater',
'ShoppingCenter' => 'LocalBusiness',
'BoardingPolicyType' => 'Enumeration',
'PropertyValue' => 'StructuredValue',
'HVACBusiness' => 'HomeAndConstructionBusiness',
'DaySpa' => 'HealthAndBeautyBusiness',
'StatisticalVariable' => 'ConstraintNode',
'InstallAction' => 'ConsumeAction',
'DataType' => 'rdfs:Class',
'UserPlusOnes' => 'UserInteraction',
'Property' => 'Intangible',
'DietarySupplement' => 'Substance',
'School' => 'EducationalOrganization',
'WearableSizeSystemEnumeration' => 'SizeSystemEnumeration',
'PodcastSeason' => 'CreativeWorkSeason',
'Float' => 'Number',
'Museum' => 'CivicStructure',
'Country' => 'cmns-ge:GeopoliticalEntity',
'MovieRentalStore' => 'Store',
'MaximumDoseSchedule' => 'DoseSchedule',
'Play' => 'CreativeWork',
'PublicationEvent' => 'Event',
'HomeGoodsStore' => 'Store',
'Message' => 'CreativeWork',
'InteractionCounter' => 'StructuredValue',
'EmailMessage' => 'Message',
'BusOrCoach' => 'Vehicle',
'OccupationalTherapy' => 'MedicalTherapy',
'ClaimReview' => 'Review',
'MovieTheater' => 'CivicStructure',
'CommunicateAction' => 'InteractAction',
'MusicGroup' => 'PerformingGroup',
'MusicAlbumReleaseType' => 'Enumeration',
'MedicalTherapy' => 'TherapeuticProcedure',
'MerchantReturnPolicy' => 'Intangible',
'BodyMeasurementTypeEnumeration' => 'MeasurementTypeEnumeration',
'ComputerLanguage' => 'Intangible',
'FulfillmentTypeEnumeration' => 'Enumeration',
'InsertAction' => 'AddAction',
'VoteAction' => 'ChooseAction',
'CableOrSatelliteService' => 'Service',
'PerformingGroup' => 'Organization',
'GameAvailabilityEnumeration' => 'Enumeration',
'BankAccount' => 'FinancialProduct',
'RadiationTherapy' => 'MedicalTherapy',
'Audiobook' => 'AudioObject',
'WinAction' => 'AchieveAction',
'MotorcycleDealer' => 'AutomotiveBusiness',
'SendAction' => 'TransferAction',
'Menu' => 'CreativeWork',
'VideoObject' => 'MediaObject',
'ApartmentComplex' => 'Residence',
'ResumeAction' => 'ControlAction',
'Crematorium' => 'CivicStructure',
'UnRegisterAction' => 'InteractAction',
'MedicalBusiness' => 'LocalBusiness',
'Dataset' => 'CreativeWork',
'Gene' => 'BioChemEntity',
'IgnoreAction' => 'AssessAction',
'Restaurant' => 'FoodEstablishment',
'EatAction' => 'ConsumeAction',
'BoatTrip' => 'Trip',
'CheckoutPage' => 'WebPage',
'OutletStore' => 'Store',
'Residence' => 'Place',
'Specialty' => 'Enumeration',
'Landform' => 'Place',
'ComedyClub' => 'EntertainmentBusiness',
'ArchiveOrganization' => 'LocalBusiness',
'DrugCostCategory' => 'MedicalEnumeration',
'Hostel' => 'LodgingBusiness',
'AnatomicalSystem' => 'MedicalEntity',
'Motorcycle' => 'Vehicle',
'ParcelDelivery' => 'Intangible',
'BlogPosting' => 'SocialMediaPosting',
'BookFormatType' => 'Enumeration',
'EventStatusType' => 'StatusEnumeration',
'BeautySalon' => 'HealthAndBeautyBusiness',
'DataCatalog' => 'CreativeWork',
'MedicalDevice' => 'MedicalEntity',
'OrderStatus' => 'StatusEnumeration',
'ProfessionalService' => 'LocalBusiness',
'HighSchool' => 'EducationalOrganization',
'PhotographAction' => 'CreateAction',
'TVEpisode' => 'Episode',
'ConstraintNode' => 'Intangible',
'RefundTypeEnumeration' => 'Enumeration',
'ApprovedIndication' => 'MedicalIndication',
'ReplyAction' => 'CommunicateAction',
'ServiceChannel' => 'Intangible',
'DoseSchedule' => 'MedicalIntangible',
'InfectiousAgentClass' => 'MedicalEnumeration',
'BankOrCreditUnion' => 'FinancialService',
'Festival' => 'Event',
'LegalService' => 'LocalBusiness',
'VideoGame' => 'SoftwareApplication',
'MedicalDevicePurpose' => 'MedicalEnumeration',
'RentalCarReservation' => 'Reservation',
'Role' => 'Intangible',
'PhysicalActivityCategory' => 'Enumeration',
'Mountain' => 'Landform',
'UserReview' => 'Review',
'State' => 'AdministrativeArea',
'Synagogue' => 'PlaceOfWorship',
'LiteraryEvent' => 'Event',
'GeoCircle' => 'GeoShape',
'Poster' => 'CreativeWork',
'Quantity' => 'Intangible',
'Mass' => 'Quantity',
'EducationalAudience' => 'Audience',
'MolecularEntity' => 'BioChemEntity',
'Invoice' => 'fibo-fnd-arr-doc:LegalDocument',
'WriteAction' => 'CreateAction',
'GeneralContractor' => 'HomeAndConstructionBusiness',
'CarUsageType' => 'Enumeration',
'VideoGameClip' => 'Clip',
'DownloadAction' => 'TransferAction',
'ImageGallery' => 'MediaGallery',
'UserCheckins' => 'UserInteraction',
'EngineSpecification' => 'StructuredValue',
'PronounceableText' => 'Text',
'DigitalDocumentPermissionType' => 'Enumeration',
'Suite' => 'Accommodation',
'TelevisionChannel' => 'BroadcastChannel',
'ResearchOrganization' => 'Organization',
'CheckInAction' => 'CommunicateAction',
'MedicalCause' => 'MedicalEntity',
'BusinessFunction' => 'Enumeration',
'SellAction' => 'TradeAction',
'Blog' => 'CreativeWork',
'SaleEvent' => 'Event',
'DriveWheelConfigurationValue' => 'QualitativeValue',
'PostOffice' => 'GovernmentOffice',
'CDCPMDRecord' => 'StructuredValue',
'MedicalRiskCalculator' => 'MedicalRiskEstimator',
'HowToTip' => 'CreativeWork',
'Recommendation' => 'Review',
'MedicalCode' => 'MedicalIntangible',
'LinkRole' => 'Role',
'PurchaseType' => 'Enumeration',
'WorkersUnion' => 'Organization',
'SportingGoodsStore' => 'Store',
'BusinessEntityType' => 'Enumeration',
'AutoDealer' => 'AutomotiveBusiness',
'ChildrensEvent' => 'Event',
'MedicalProcedure' => 'MedicalEntity',
'PhysicalExam' => 'MedicalEnumeration',
'ReservationPackage' => 'Reservation',
'PhysicalTherapy' => 'MedicalTherapy',
'TechArticle' => 'Article',
'Brand' => 'Intangible',
'Park' => 'CivicStructure',
'Preschool' => 'EducationalOrganization',
'MedicalRiskEstimator' => 'MedicalEntity',
'OfferForLease' => 'Offer',
'CreateAction' => 'Action',
'MarryAction' => 'InteractAction',
'InvestmentFund' => 'InvestmentOrDeposit',
'RsvpAction' => 'InformAction',
'VeterinaryCare' => 'MedicalOrganization',
'Casino' => 'EntertainmentBusiness',
'EventReservation' => 'Reservation',
'GovernmentOffice' => 'LocalBusiness',
'HobbyShop' => 'Store',
'RecommendedDoseSchedule' => 'DoseSchedule',
'UserPlays' => 'UserInteraction',
'ShoeStore' => 'Store',
'HealthAspectEnumeration' => 'Enumeration',
'TennisComplex' => 'SportsActivityLocation',
'PsychologicalTreatment' => 'TherapeuticProcedure',
'RadioChannel' => 'BroadcastChannel',
'Dentist' => 'LocalBusiness',
'Review' => 'CreativeWork',
'MedicalEvidenceLevel' => 'MedicalEnumeration',
'TherapeuticProcedure' => 'MedicalProcedure',
'ResearchProject' => 'Project',
'CourseInstance' => 'Event',
'MedicalSpecialty' => 'Specialty',
'BackgroundNewsArticle' => 'NewsArticle',
'MonetaryAmount' => 'StructuredValue',
'MobilePhoneStore' => 'Store',
'ActivateAction' => 'ControlAction',
'CheckOutAction' => 'CommunicateAction',
'HowToDirection' => 'CreativeWork',
'FoodService' => 'Service',
'Syllabus' => 'LearningResource',
'GovernmentBenefitsType' => 'Enumeration',
'StadiumOrArena' => 'CivicStructure',
'UserDownloads' => 'UserInteraction',
'MusicPlaylist' => 'CreativeWork',
'BodyOfWater' => 'Landform',
'SearchAction' => 'Action',
'MusicEvent' => 'Event',
'ReturnLabelSourceEnumeration' => 'Enumeration',
'Substance' => 'MedicalEntity',
'OpinionNewsArticle' => 'NewsArticle',
'TouristTrip' => 'Trip',
'ContactPoint' => 'StructuredValue',
'MobileApplication' => 'SoftwareApplication',
'Statement' => 'CreativeWork',
'RepaymentSpecification' => 'StructuredValue',
'ReviewNewsArticle' => 'NewsArticle',
'MedicalContraindication' => 'MedicalEntity',
'WarrantyScope' => 'Enumeration',
'DefinedTermSet' => 'CreativeWork',
'DatedMoneySpecification' => 'StructuredValue',
'MediaManipulationRatingEnumeration' => 'Enumeration',
'NailSalon' => 'HealthAndBeautyBusiness',
'LandmarksOrHistoricalBuildings' => 'Place',
'PaymentChargeSpecification' => 'PriceSpecification',
'MedicalGuidelineContraindication' => 'MedicalGuideline',
'Rating' => 'Intangible',
'Pond' => 'BodyOfWater',
'LodgingReservation' => 'Reservation',
'TipAction' => 'TradeAction',
'OwnershipInfo' => 'StructuredValue',
'PathologyTest' => 'MedicalTest',
'HardwareStore' => 'Store',
'MedicalClinic' => 'MedicalOrganization',
'ExerciseGym' => 'SportsActivityLocation',
'VisualArtsEvent' => 'Event',
'MeasurementMethodEnum' => 'Enumeration',
'LikeAction' => 'ReactAction',
'HinduTemple' => 'PlaceOfWorship',
'VitalSign' => 'MedicalSign',
'UKNonprofitType' => 'NonprofitType',
'ElectronicsStore' => 'Store',
'UserLikes' => 'UserInteraction',
'PropertyValueSpecification' => 'Intangible',
'DanceEvent' => 'Event',
'FindAction' => 'Action',
'HealthClub' => 'HealthAndBeautyBusiness',
'DepositAccount' => 'BankAccount',
'AskPublicNewsArticle' => 'NewsArticle',
'QuoteAction' => 'TradeAction',
'AMRadioChannel' => 'RadioChannel',
'AccountingService' => 'FinancialService',
'BarOrPub' => 'FoodEstablishment',
'SearchResultsPage' => 'WebPage',
'Church' => 'PlaceOfWorship',
'VideoGameSeries' => 'CreativeWorkSeries',
'AdultOrientedEnumeration' => 'Enumeration',
'Periodical' => 'CreativeWorkSeries',
'IndividualPhysician' => 'Physician',
'WearAction' => 'UseAction',
'FoodEvent' => 'Event',
'MedicalTest' => 'MedicalEntity',
'PostalAddress' => 'ContactPoint',
'MedicalSymptom' => 'MedicalSignOrSymptom',
'FAQPage' => 'WebPage',
'TrackAction' => 'FindAction',
'GovernmentBuilding' => 'CivicStructure',
'Zoo' => 'CivicStructure',
'ConsumeAction' => 'Action',
'AudioObjectSnapshot' => 'AudioObject',
'DayOfWeek' => 'Enumeration',
'OfferCatalog' => 'ItemList',
'Distance' => 'Quantity',
'BorrowAction' => 'TransferAction',
'Article' => 'CreativeWork',
'ActionAccessSpecification' => 'Intangible',
'TouristAttraction' => 'Place',
'GroceryStore' => 'Store',
'EntryPoint' => 'Intangible',
'Thesis' => 'CreativeWork',
'StatusEnumeration' => 'Enumeration',
'Nerve' => 'AnatomicalStructure',
'MeetingRoom' => 'Room',
'DataFeedItem' => 'Intangible',
'MusicStore' => 'Store',
'ToyStore' => 'Store',
'Muscle' => 'AnatomicalStructure',
'DrugPregnancyCategory' => 'MedicalEnumeration',
'OperatingSystem' => 'SoftwareApplication',
'JoinAction' => 'InteractAction',
);
@@ -0,0 +1,98 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Blocks;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* The core ACF Blocks binding class.
*/
class Bindings {
/**
* Block Bindings constructor.
*/
public function __construct() {
// Final check we're on WP 6.5 or newer.
if ( ! function_exists( 'register_block_bindings_source' ) ) {
return;
}
add_action( 'acf/init', array( $this, 'register_binding_sources' ) );
}
/**
* Hooked to acf/init, register our binding sources.
*/
public function register_binding_sources() {
if ( acf_get_setting( 'enable_block_bindings' ) ) {
register_block_bindings_source(
'acf/field',
array(
'label' => _x( 'Custom Fields', 'The core ACF block binding source name for fields on the current page', 'acf' ),
'get_value_callback' => array( $this, 'get_value' ),
)
);
}
}
/**
* Handle returing the block binding value for an ACF meta value.
*
* @since 6.2.8
*
* @param array $source_attrs An array of the source attributes requested.
* @param \WP_Block $block_instance The block instance.
* @param string $attribute_name The block's bound attribute name.
* @return string|null The block binding value or an empty string on failure.
*/
public function get_value( array $source_attrs, \WP_Block $block_instance, string $attribute_name ) {
if ( ! isset( $source_attrs['key'] ) || ! is_string( $source_attrs['key'] ) ) {
$value = '';
} else {
$field = get_field_object( $source_attrs['key'], false, true, true, true );
if ( ! $field ) {
return '';
}
if ( ! acf_field_type_supports( $field['type'], 'bindings', true ) ) {
if ( is_preview() ) {
return apply_filters( 'acf/bindings/field_not_supported_message', '[' . esc_html__( 'The requested ACF field type does not support output in Block Bindings or the ACF shortcode.', 'acf' ) . ']' );
} else {
return '';
}
}
if ( isset( $field['allow_in_bindings'] ) && ! $field['allow_in_bindings'] ) {
if ( is_preview() ) {
return apply_filters( 'acf/bindings/field_not_allowed_message', '[' . esc_html__( 'The requested ACF field is not allowed to be output in bindings or the ACF Shortcode.', 'acf' ) . ']' );
} else {
return '';
}
}
$value = $field['value'];
if ( is_array( $value ) ) {
$value = implode( ', ', $value );
}
// If we're not a scalar we'd throw an error, so return early for safety.
if ( ! is_scalar( $value ) ) {
$value = null;
}
}
return apply_filters( 'acf/blocks/binding_value', $value, $source_attrs, $block_instance, $attribute_name );
}
}
@@ -0,0 +1,2 @@
<?php
// There are many ways to WordPress.
@@ -0,0 +1,30 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\CLI;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Bootstrapper for ACF WP-CLI commands.
*/
class CLI {
/**
* Registers all free ACF WP-CLI commands.
*
* @since 6.8
*/
public function __construct() {
\WP_CLI::add_command( 'acf json', JsonCommand::class );
}
}
@@ -0,0 +1,882 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\CLI;
use WP_CLI;
use function WP_CLI\Utils\format_items;
use function WP_CLI\Utils\get_flag_value;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* Manages ACF JSON import, export, and synchronization.
*
* ## EXAMPLES
*
* # Show sync status for all item types (field groups, post types, taxonomies, options pages)
* $ wp acf json status
*
* # Sync all pending local JSON changes to database
* $ wp acf json sync
*
* # Import from a JSON file
* $ wp acf json import ./acf-export.json
*
* # Export all items to a directory
* $ wp acf json export --dir=./exports/
*
* # Export to stdout
* $ wp acf json export --stdout
*/
class JsonCommand {
/**
* Map of CLI type flags to internal ACF post types.
*
* @var array
*/
private const TYPE_MAP = array(
'field-group' => 'acf-field-group',
'post-type' => 'acf-post-type',
'taxonomy' => 'acf-taxonomy',
'options-page' => 'acf-ui-options-page',
);
/**
* Success message when there are no items to sync.
*
* @var string
*/
private const MESSAGE_ALREADY_IN_SYNC = 'Everything is already in sync.';
/**
* Records a first-run event for a CLI sub-command.
*
* @since 6.8
*
* @param string $subcommand The sub-command name (e.g., 'status', 'sync', 'import', 'export').
*/
private function log_command( $subcommand ) {
$site_health = acf_get_instance( 'ACF\Site_Health\Site_Health' );
if ( method_exists( $site_health, 'log_cli_command' ) ) {
$site_health->log_cli_command( 'acf json ' . $subcommand );
}
}
/**
* Shows the sync status for ACF items.
*
* Displays how many items are pending sync. Items are considered "pending"
* when the JSON file is newer than the database entry, or when the item
* exists in JSON but not in the database.
*
* ## OPTIONS
*
* [--type=<type>]
* : Limit to field groups, post types, taxonomies, or options pages. Defaults to all item types (field groups, post types, taxonomies, options pages).
* ---
* options:
* - field-group
* - post-type
* - taxonomy
* - options-page
* ---
*
* [--detailed]
* : Show detailed list of modified items instead of just counts.
*
* [--format=<format>]
* : Output format.
* ---
* default: table
* options:
* - table
* - json
* - yaml
* - csv
* ---
*
* ## EXAMPLES
*
* # Check all item types
* $ wp acf json status
* +---------------+---------+-------+----------------+
* | Type | Pending | Total | Status |
* +---------------+---------+-------+----------------+
* | field-group | 3 | 12 | Sync available |
* | post-type | 0 | 2 | In sync |
* | taxonomy | 1 | 3 | Sync available |
* | options-page | 0 | 1 | In sync |
* +---------------+---------+-------+----------------+
*
* # Check only field groups
* $ wp acf json status --type=field-group
*
* # Show detailed list of pending items
* $ wp acf json status --detailed
* +-------------------+------------------+---------------+--------+
* | Key | Title | Type | Action |
* +-------------------+------------------+---------------+--------+
* | group_abc123 | Product Fields | field-group | Update |
* | group_def456 | Homepage | field-group | Create |
* | taxonomy_ghi789 | Product Category | taxonomy | Update |
* +-------------------+------------------+---------------+--------+
*
* # Output status as JSON for scripts
* $ wp acf json status --format=json
* [{"Type":"field-group","Pending":3,"Total":12,"Status":"Sync available"}]
*
* @since 6.8
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
public function status( $args, $assoc_args ) {
$this->log_command( 'status' );
$type_filter = get_flag_value( $assoc_args, 'type' );
$format = get_flag_value( $assoc_args, 'format', 'table' );
$detailed = get_flag_value( $assoc_args, 'detailed', false );
$post_types = $this->get_post_types( $type_filter );
if ( $detailed ) {
$this->display_detailed_status( $post_types, $format );
return;
}
$rows = array();
$total_pending = 0;
foreach ( $post_types as $post_type ) {
$syncable = $this->get_syncable_items( $post_type );
$all_items = acf_get_internal_post_type_posts( $post_type );
$count = count( $syncable );
$total_count = count( $all_items );
$total_pending += $count;
$rows[] = array(
'Type' => $this->get_type_label( $post_type ),
'Pending' => $count,
'Total' => $total_count,
'Status' => $count > 0 ? 'Sync available' : 'In sync',
);
}
format_items( $format, $rows, array( 'Type', 'Pending', 'Total', 'Status' ) );
if ( 'table' === $format ) {
if ( $total_pending > 0 ) {
WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) );
} else {
WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC );
}
}
}
/**
* Syncs local JSON changes to the database.
*
* Imports pending JSON changes for ACF items (field groups, post types,
* taxonomies, and options pages). This command reads JSON files from your
* theme/plugin acf-json directory and creates or updates the corresponding
* database entries.
*
* WARNING: This command modifies your database. Use --dry-run first to
* preview changes before running on production.
*
* ## OPTIONS
*
* [--type=<type>]
* : Limit sync to a specific item type. Defaults to all item types (field groups, post types, taxonomies, options pages).
* ---
* options:
* - field-group
* - post-type
* - taxonomy
* - options-page
* ---
*
* [--key=<key>]
* : Sync a specific item by its ACF key (e.g., group_abc123).
*
* [--dry-run]
* : Preview what would be synced without making changes. Recommended for
* production deployments.
*
* ## EXAMPLES
*
* # Preview what will be synced (safe)
* $ wp acf json sync --dry-run
* 3 item(s) pending sync:
* +-------------------+------------------+---------------+--------+
* | Key | Title | Type | Action |
* +-------------------+------------------+---------------+--------+
* | group_abc123 | Product Fields | field-group | Update |
* +-------------------+------------------+---------------+--------+
*
* # Sync all pending changes
* $ wp acf json sync
* Updated field-group: Product Fields (group_abc123)
* Success: 1 item(s) synced.
*
* # Sync only field groups (during deployment)
* $ wp acf json sync --type=field-group
*
* # Sync a specific field group after manual JSON edit
* $ wp acf json sync --key=group_abc123
*
* # CI/CD deployment workflow
* $ wp acf json status --format=json | jq '.[] | select(.Pending > 0)'
* $ wp acf json sync --dry-run
* $ wp acf json sync
*
* @since 6.8
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
public function sync( $args, $assoc_args ) {
$this->log_command( 'sync' );
$type_filter = get_flag_value( $assoc_args, 'type' );
$key_filter = get_flag_value( $assoc_args, 'key' );
$dry_run = get_flag_value( $assoc_args, 'dry-run', false );
$post_types = $this->get_post_types( $type_filter );
$all_syncable = array();
foreach ( $post_types as $post_type ) {
$syncable = $this->get_syncable_items( $post_type );
foreach ( $syncable as $key => $post ) {
$all_syncable[ $key ] = array(
'post' => $post,
'post_type' => $post_type,
);
}
}
if ( $key_filter ) {
if ( ! isset( $all_syncable[ $key_filter ] ) ) {
WP_CLI::error(
sprintf(
"No syncable item found with key '%s'.\n\n" .
"Possible reasons:\n" .
" - Key does not exist in JSON files\n" .
" - Item is already in sync with database\n" .
" - Item is marked as private\n\n" .
"To see all syncable items, run:\n" .
' wp acf json sync --dry-run',
$key_filter
)
);
}
$all_syncable = array( $key_filter => $all_syncable[ $key_filter ] );
}
if ( empty( $all_syncable ) ) {
WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC );
return;
}
if ( $dry_run ) {
$this->display_dry_run( $all_syncable );
return;
}
// Disable Local JSON controller to prevent .json files from being modified during import.
$json_enabled = acf_get_setting( 'json' );
acf_update_setting( 'json', false );
// Build file index per post type before the loop (matches admin UI pattern).
$files_by_type = array();
foreach ( $all_syncable as $item ) {
$pt = $item['post_type'];
if ( ! isset( $files_by_type[ $pt ] ) ) {
$files_by_type[ $pt ] = acf_get_local_json_files( $pt );
}
}
$synced_count = 0;
foreach ( $all_syncable as $key => $item ) {
$post = $item['post'];
$post_type = $item['post_type'];
$files = $files_by_type[ $post_type ];
if ( ! isset( $files[ $key ] ) ) {
WP_CLI::warning(
sprintf(
"JSON file not found for key '%s'. Skipping.\n" .
'The JSON file may have been deleted or moved.',
$key
)
);
continue;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$local_post = json_decode( file_get_contents( $files[ $key ] ), true );
if ( ! is_array( $local_post ) ) {
WP_CLI::warning( sprintf( "Invalid JSON in file for key '%s'. Skipping.", $key ) );
continue;
}
$local_post['ID'] = $post['ID'];
$result = acf_import_internal_post_type( $local_post, $post_type );
if ( empty( $result ) || ! isset( $result['ID'] ) ) {
WP_CLI::warning( sprintf( "Failed to sync item with key '%s'.", $key ) );
continue;
}
$action = $post['ID'] ? 'Updated' : 'Created';
$type_label = $this->get_type_label( $post_type );
WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $post['title'], $key ) );
++$synced_count;
}
// Restore Local JSON setting.
acf_update_setting( 'json', $json_enabled );
if ( 0 === $synced_count ) {
WP_CLI::warning( 'No items were synced.' );
return;
}
WP_CLI::success( sprintf( '%d item(s) synced.', $synced_count ) );
}
/**
* Imports field groups, post types, taxonomies, and options pages from a JSON file.
*
* Reads an ACF export JSON file and imports the items into the database,
* replicating the functionality of the import UI in the WordPress admin.
* If an item with the same key already exists, it will be updated.
* Options pages require ACF PRO.
*
* ## OPTIONS
*
* <file>
* : Path to the JSON file to import.
*
* ## EXAMPLES
*
* # Import field groups, post types, taxonomies, and options pages from a file
* $ wp acf json import ./acf-export-2025-01-01.json
* Imported field-group: My Field Group (group_abc123)
* Imported post-type: Book (post_type_def456)
* Success: Imported 2 item(s).
*
* # Import a single field group JSON file
* $ wp acf json import ./group_abc123.json
*
* # Re-import to update existing items
* $ wp acf json import ./acf-export.json
* Updated field-group: My Field Group (group_abc123)
* Success: Imported 1 item(s).
*
* @since 6.8
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
public function import( $args, $assoc_args ) {
$this->log_command( 'import' );
if ( empty( $args[0] ) ) {
WP_CLI::error(
"Missing required file argument.\n\n" .
"Usage: wp acf json import <file>\n\n" .
"Example:\n" .
" wp acf json import ./acf-export.json\n\n" .
"See: wp help acf json import"
);
}
$file_path = $args[0];
if ( ! file_exists( $file_path ) ) {
WP_CLI::error( sprintf( 'File not found: %s', $file_path ) );
}
if ( 'json' !== pathinfo( $file_path, PATHINFO_EXTENSION ) ) {
WP_CLI::error( 'File must have .json extension.' );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$json = file_get_contents( $file_path );
$json = json_decode( $json, true );
if ( ! $json || ! is_array( $json ) ) {
WP_CLI::error( 'Import file is empty or contains invalid JSON.' );
}
// Normalize single item to array (matches admin UI behavior).
if ( isset( $json['key'] ) ) {
$json = array( $json );
}
$ids = array();
foreach ( $json as $to_import ) {
if ( ! is_array( $to_import ) ) {
WP_CLI::warning( 'Skipping invalid item (expected array, got ' . gettype( $to_import ) . ').' );
continue;
}
if ( empty( $to_import['key'] ) ) {
WP_CLI::warning( 'Skipping item with no key.' );
continue;
}
$post_type = acf_determine_internal_post_type( $to_import['key'] );
if ( ! $post_type ) {
WP_CLI::warning( sprintf( "Could not determine post type for key '%s'. Skipping.", $to_import['key'] ) );
continue;
}
$post = acf_get_internal_post_type_post( $to_import['key'], $post_type );
if ( $post ) {
$to_import['ID'] = $post->ID;
}
$result = acf_import_internal_post_type( $to_import, $post_type );
if ( empty( $result ) || ! isset( $result['ID'] ) ) {
WP_CLI::warning( sprintf( "Failed to import item with key '%s'.", $to_import['key'] ) );
continue;
}
$action = ! empty( $to_import['ID'] ) ? 'Updated' : 'Imported';
$title = ! empty( $result['title'] ) ? $result['title'] : $to_import['key'];
$type_label = $this->get_type_label( $post_type );
WP_CLI::log( sprintf( '%s %s: %s (%s)', $action, $type_label, $title, $to_import['key'] ) );
$ids[] = $result['ID'];
}
if ( empty( $ids ) ) {
WP_CLI::warning( 'No items were imported.' );
return;
}
WP_CLI::success( sprintf( 'Imported %d item(s).', count( $ids ) ) );
}
/**
* Exports field groups, post types, taxonomies, and options pages to a JSON file.
*
* Exports ACF items to a JSON file, replicating the functionality of
* the export tool in the WordPress admin.
*
* ## OPTIONS
*
* [--field-groups=<keys>]
* : Export specific field groups by key or label, comma separated.
*
* [--post-types=<keys>]
* : Export specific post types by key or label, comma separated.
*
* [--taxonomies=<keys>]
* : Export specific taxonomies by key or label, comma separated.
*
* [--options-pages=<keys>]
* : Export specific options pages by key or label, comma separated. Requires ACF PRO.
*
* [--dir=<directory>]
* : Directory path to write the JSON file to.
*
* [--stdout]
* : Print the JSON to stdout instead of writing to a file.
*
* ## EXAMPLES
*
* # Export all items to a directory
* $ wp acf json export --dir=./exports/
*
* # Export specific field groups by key
* $ wp acf json export --field-groups=group_abc123,group_def456 --dir=./
*
* # Export a field group by label
* $ wp acf json export --field-groups="My Field Group" --dir=./
*
* # Export mixed items (field groups and post types)
* $ wp acf json export --field-groups=group_abc --post-types=post_type_def --dir=./
*
* # Export to stdout for piping
* $ wp acf json export --stdout
* $ wp acf json export --field-groups=group_abc123 --stdout | jq .
*
* @since 6.8
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
public function export( $args, $assoc_args ) {
$this->log_command( 'export' );
$field_groups_arg = get_flag_value( $assoc_args, 'field-groups' );
$post_types_arg = get_flag_value( $assoc_args, 'post-types' );
$taxonomies_arg = get_flag_value( $assoc_args, 'taxonomies' );
$options_pages_arg = get_flag_value( $assoc_args, 'options-pages' );
$output_dir = get_flag_value( $assoc_args, 'dir' );
$stdout = get_flag_value( $assoc_args, 'stdout', false );
if ( ! $output_dir && ! $stdout ) {
WP_CLI::error( 'You must specify --dir=<directory> or --stdout.' );
}
if ( $output_dir && $stdout ) {
WP_CLI::error( 'Cannot specify both --dir and --stdout.' );
}
if ( $output_dir && ! is_dir( $output_dir ) ) {
WP_CLI::error( sprintf( 'Directory not found: %s', $output_dir ) );
}
if ( $output_dir && ! wp_is_writable( $output_dir ) ) {
WP_CLI::error( sprintf( 'Directory is not writable: %s', $output_dir ) );
}
$keys = $this->resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg );
if ( empty( $keys ) ) {
WP_CLI::error( 'No items found to export.' );
}
$json = array();
foreach ( $keys as $key ) {
$post_type = acf_determine_internal_post_type( $key );
$post = acf_get_internal_post_type( $key, $post_type );
if ( empty( $post ) ) {
WP_CLI::warning( sprintf( "Item not found for key '%s'. Skipping.", $key ) );
continue;
}
if ( 'acf-field-group' === $post_type ) {
$post['fields'] = acf_get_fields( $post );
}
$post = acf_prepare_internal_post_type_for_export( $post, $post_type );
$json[] = $post;
}
if ( empty( $json ) ) {
WP_CLI::error( 'No items could be exported.' );
}
$encoded = acf_json_encode( $json );
if ( $stdout ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $encoded . "\n";
return;
}
$file_name = 'acf-export-' . date( 'Y-m-d' ) . '.json';
$file_path = trailingslashit( $output_dir ) . $file_name;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
$result = file_put_contents( $file_path, $encoded . "\r\n" );
if ( false === $result ) {
WP_CLI::error( sprintf( 'Failed to write to %s', $file_path ) );
}
WP_CLI::success( sprintf( 'Exported %d item(s) to %s', count( $json ), $file_path ) );
}
/**
* Resolves export arguments into an array of ACF keys.
*
* When no arguments are provided, collects all items across all types.
* Accepts keys directly (group_xxx) or labels which are matched against
* existing items.
*
* @since 6.8
*
* @param string|null $field_groups_arg Comma-separated field group keys/labels.
* @param string|null $post_types_arg Comma-separated post type keys/labels.
* @param string|null $taxonomies_arg Comma-separated taxonomy keys/labels.
* @param string|null $options_pages_arg Comma-separated options page keys/labels.
* @return array List of ACF keys to export.
*/
private function resolve_export_keys( $field_groups_arg, $post_types_arg, $taxonomies_arg, $options_pages_arg ) {
$no_filters = ! $field_groups_arg && ! $post_types_arg && ! $taxonomies_arg && ! $options_pages_arg;
$keys = array();
if ( $no_filters ) {
foreach ( $this->get_post_types() as $post_type ) {
$keys = array_merge( $keys, $this->resolve_keys_for_type( $post_type, null ) );
}
return $keys;
}
if ( $field_groups_arg ) {
$keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-field-group', $field_groups_arg ) );
}
if ( $post_types_arg ) {
$keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-post-type', $post_types_arg ) );
}
if ( $taxonomies_arg ) {
$keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-taxonomy', $taxonomies_arg ) );
}
if ( $options_pages_arg ) {
if ( ! acf_is_pro() ) {
WP_CLI::error(
"Options pages require ACF PRO.\n\n" .
"To export options pages, you need:\n" .
" - ACF PRO license\n" .
" - Active license key\n\n" .
'See: https://www.advancedcustomfields.com/pro/'
);
}
$keys = array_merge( $keys, $this->resolve_keys_for_type( 'acf-ui-options-page', $options_pages_arg ) );
}
return $keys;
}
/**
* Resolves a comma-separated list of keys or labels into ACF keys for a given post type.
*
* @since 6.8
*
* @param string $post_type The item type (field group, post type, taxonomy, or options page).
* @param string|null $arg Comma-separated keys/labels, or null for all.
* @return array List of ACF keys.
*/
private function resolve_keys_for_type( $post_type, $arg ) {
$posts = acf_get_internal_post_type_posts( $post_type );
$posts = array_filter( $posts, 'acf_internal_post_object_contains_valid_key' );
if ( ! $arg ) {
return wp_list_pluck( $posts, 'key' );
}
$identifiers = array_filter( array_map( 'trim', explode( ',', $arg ) ) );
$keys = array();
foreach ( $identifiers as $identifier ) {
$found = false;
foreach ( $posts as $post ) {
if ( $post['key'] === $identifier || strcasecmp( $post['title'], $identifier ) === 0 ) {
$keys[] = $post['key'];
$found = true;
break;
}
}
if ( ! $found ) {
WP_CLI::warning( sprintf( 'No item found matching "%s". Skipping.', $identifier ) );
}
}
return array_unique( $keys );
}
/**
* Determines which item types to process.
*
* @since 6.8
*
* @param string|null $type_filter The CLI type flag value.
* @return array List of item type slugs.
*/
private function get_post_types( $type_filter = null ) {
if ( $type_filter ) {
if ( ! isset( self::TYPE_MAP[ $type_filter ] ) ) {
WP_CLI::error(
sprintf(
"Unknown type '%s'.\n\n" .
"Valid types:\n" .
" - field-group\n" .
" - post-type\n" .
" - taxonomy\n" .
" - options-page (ACF PRO only)\n\n" .
'See: wp help acf json',
$type_filter
)
);
}
$post_type = self::TYPE_MAP[ $type_filter ];
if ( 'acf-ui-options-page' === $post_type && ! acf_is_pro() ) {
WP_CLI::error(
"Options pages require ACF PRO.\n\n" .
"To sync options pages, you need:\n" .
" - ACF PRO license\n" .
" - Active license key\n\n" .
'See: https://www.advancedcustomfields.com/pro/'
);
}
return array( $post_type );
}
$post_types = acf_get_internal_post_types();
// Remove options pages from non-PRO installs.
if ( ! acf_is_pro() ) {
$post_types = array_filter(
$post_types,
function ( $pt ) {
return 'acf-ui-options-page' !== $pt;
}
);
}
return array_values( $post_types );
}
/**
* Returns the friendly CLI type label for an internal post type slug.
*
* @since 6.8
*
* @param string $post_type The internal post type slug (e.g. 'acf-field-group').
* @return string The friendly label (e.g. 'field-group'), or the original slug if not found.
*/
private function get_type_label( $post_type ) {
$label = array_search( $post_type, self::TYPE_MAP, true );
return $label ? $label : $post_type;
}
/**
* Finds syncable items for a given item type using the same logic as the admin UI.
*
* @since 6.8
*
* @param string $post_type The item type.
* @return array Associative array of key => post data for syncable items.
*/
private function get_syncable_items( $post_type ) {
$syncable = array();
$files = acf_get_local_json_files( $post_type );
if ( empty( $files ) ) {
return $syncable;
}
$all_posts = acf_get_internal_post_type_posts( $post_type );
foreach ( $all_posts as $post ) {
$local = acf_maybe_get( $post, 'local' );
$modified = acf_maybe_get( $post, 'modified' );
$private = acf_maybe_get( $post, 'private' );
if ( $private ) {
continue;
}
if ( 'json' !== $local ) {
continue;
}
// New item (not yet in database).
if ( ! $post['ID'] ) {
$syncable[ $post['key'] ] = $post;
continue;
}
// Updated item (JSON is newer than database).
if ( $modified && $modified > get_post_modified_time( 'U', true, $post['ID'] ) ) {
$syncable[ $post['key'] ] = $post;
}
}
return $syncable;
}
/**
* Displays detailed status showing individual items that need syncing.
*
* @since 6.8
*
* @param array $post_types List of post types to check.
* @param string $format Output format.
*/
private function display_detailed_status( $post_types, $format ) {
$rows = array();
$total_pending = 0;
foreach ( $post_types as $post_type ) {
$syncable = $this->get_syncable_items( $post_type );
foreach ( $syncable as $key => $post ) {
$action = $post['ID'] ? 'Update' : 'Create';
++$total_pending;
$rows[] = array(
'Key' => $key,
'Title' => $post['title'],
'Type' => $this->get_type_label( $post_type ),
'Action' => $action,
);
}
}
if ( empty( $rows ) ) {
WP_CLI::success( self::MESSAGE_ALREADY_IN_SYNC );
return;
}
format_items( $format, $rows, array( 'Key', 'Title', 'Type', 'Action' ) );
if ( 'table' === $format ) {
WP_CLI::log( sprintf( '%d item(s) pending sync. Run `wp acf json sync` to apply changes.', $total_pending ) );
}
}
/**
* Displays a table of pending sync items for dry-run mode.
*
* @since 6.8
*
* @param array $all_syncable The syncable items.
*/
private function display_dry_run( $all_syncable ) {
$rows = array();
foreach ( $all_syncable as $key => $item ) {
$post = $item['post'];
$action = $post['ID'] ? 'Update' : 'Create';
$rows[] = array(
'Key' => $key,
'Title' => $post['title'],
'Type' => $this->get_type_label( $item['post_type'] ),
'Action' => $action,
);
}
WP_CLI::log( sprintf( '%d item(s) pending sync:', count( $rows ) ) );
format_items( 'table', $rows, array( 'Key', 'Title', 'Type', 'Action' ) );
}
}
@@ -0,0 +1,25 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* A class to add support for saving to comment meta.
*/
class Comment extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'comment';
}
@@ -0,0 +1,189 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* The MetaType base class.
*/
class MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = '';
/**
* The prefix to use for ACF reference keys/hidden meta.
*
* @var string
*/
public string $reference_prefix = '_';
/**
* Constructs the location.
*
* @since 6.4
*/
public function __construct() {
$this->register();
}
/**
* Registers the meta location with ACF, so it can be used by
* various CRUD helper functions.
*
* @since 6.4
*
* @return void
*/
public function register() {
if ( empty( $this->location_type ) ) {
return;
}
$store = acf_get_store( 'acf-meta-locations' );
if ( ! $store ) {
$store = acf_register_store( 'acf-meta-locations' );
}
$store->set( $this->location_type, get_class( $this ) );
}
/**
* Retrieves all ACF meta for the provided object ID.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get meta from.
* @return array
*/
public function get_meta( $object_id = 0 ): array {
$meta = array();
$all_meta = get_metadata( $this->location_type, $object_id );
if ( $all_meta ) {
foreach ( $all_meta as $key => $value ) {
// If a reference exists for this value, add it to the meta array.
if ( isset( $all_meta[ $this->reference_prefix . $key ] ) ) {
$meta[ $key ] = $value[0];
$meta[ $this->reference_prefix . $key ] = $all_meta[ $this->reference_prefix . $key ][0];
}
}
}
// Unserialize results and return.
return array_map( 'acf_maybe_unserialize', $meta );
}
/**
* Retrieves a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return mixed
*/
public function get_value( $object_id = 0, array $field = array() ) {
$meta = get_metadata( $this->location_type, $object_id, $field['name'] );
return $meta[0] ?? null;
}
/**
* Gets a reference key for the provided field name.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get the reference key from.
* @param string $field_name The name of the field to get the reference for.
* @return string|null
*/
public function get_reference( $object_id = 0, $field_name = '' ) {
$reference = get_metadata( $this->location_type, $object_id, $this->reference_prefix . $field_name );
return $reference[0] ?? null;
}
/**
* Updates an object ID with the provided meta array.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $meta The metadata to save to the object.
* @return void
*/
public function update_meta( $object_id = 0, array $meta = array() ) {
// Slash data. WP expects all data to be slashed and will unslash it (fixes '\' character issues).
$meta = wp_slash( $meta );
foreach ( $meta as $name => $value ) {
update_metadata( $this->location_type, $object_id, $name, $value );
}
}
/**
* Updates a field value in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @param mixed $value The metadata value.
* @return integer|boolean
*/
public function update_value( $object_id = 0, array $field = array(), $value = '' ) {
return update_metadata( $this->location_type, $object_id, $field['name'], $value );
}
/**
* Updates a reference key in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to update the reference for.
* @param string $value The value of the reference key.
* @return integer|boolean
*/
public function update_reference( $object_id = 0, string $field_name = '', string $value = '' ) {
return update_metadata( $this->location_type, $object_id, $this->reference_prefix . $field_name, $value );
}
/**
* Deletes a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return boolean
*/
public function delete_value( $object_id = 0, array $field = array() ): bool {
return delete_metadata( $this->location_type, $object_id, $field['name'] );
}
/**
* Deletes a reference key from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to delete the reference from.
* @return boolean
*/
public function delete_reference( $object_id = 0, string $field_name = '' ): bool {
return delete_metadata( $this->location_type, $object_id, $this->reference_prefix . $field_name );
}
}
@@ -0,0 +1,151 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* A class to add support for saving to options.
*/
class Option extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'option';
/**
* Retrieves all ACF meta for the provided object ID.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get meta from.
* @return array
*/
public function get_meta( $object_id = 0 ): array {
$all_meta = acf_get_option_meta( $object_id );
$meta = array();
foreach ( $all_meta as $key => $value ) {
// If a reference exists for this value, add it to the meta array.
if ( isset( $all_meta[ $this->reference_prefix . $key ] ) ) {
$meta[ $key ] = $value[0];
$meta[ $this->reference_prefix . $key ] = $all_meta[ $this->reference_prefix . $key ][0];
}
}
// Return results.
return $meta;
}
/**
* Retrieves a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return mixed
*/
public function get_value( $object_id = 0, array $field = array() ) {
return get_option( $object_id . '_' . $field['name'], null );
}
/**
* Gets a reference key for the provided field name.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get the reference key from.
* @param string $field_name The name of the field to get the reference for.
* @return string|boolean
*/
public function get_reference( $object_id = '', $field_name = '' ) {
return get_option( $this->reference_prefix . $object_id . '_' . $field_name, null );
}
/**
* Updates an object ID with the provided meta array.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $meta The metadata to save to the object.
* @return void
*/
public function update_meta( $object_id = 0, array $meta = array() ) {
$autoload = (bool) acf_get_setting( 'autoload' );
foreach ( $meta as $name => $value ) {
$value = wp_unslash( $value );
update_option( $object_id . '_' . $name, $value, $autoload );
}
}
/**
* Updates a field value in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @param mixed $value The metadata value.
* @return integer|boolean
*/
public function update_value( $object_id = 0, array $field = array(), $value = '' ) {
$value = wp_unslash( $value );
$autoload = (bool) acf_get_setting( 'autoload' );
return update_option( $object_id . '_' . $field['name'], $value, $autoload );
}
/**
* Updates a reference key in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to update the reference for.
* @param string $value The value of the reference key.
* @return integer|boolean
*/
public function update_reference( $object_id = 0, string $field_name = '', string $value = '' ) {
$autoload = (bool) acf_get_setting( 'autoload' );
return update_option( $this->reference_prefix . $object_id . '_' . $field_name, $value, $autoload );
}
/**
* Deletes a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return boolean
*/
public function delete_value( $object_id = 0, array $field = array() ): bool {
return delete_option( $object_id . '_' . $field['name'] );
}
/**
* Deletes a reference key from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to delete the reference from.
* @return boolean
*/
public function delete_reference( $object_id = 0, string $field_name = '' ): bool {
return delete_option( $this->reference_prefix . $object_id . '_' . $field_name );
}
}
@@ -0,0 +1,25 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* A class to add support for saving to standard post meta.
*/
class Post extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'post';
}
@@ -0,0 +1,25 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* A class to add support for saving to term meta.
*/
class Term extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'term';
}
@@ -0,0 +1,25 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Meta;
/**
* A class to add support for saving to user meta.
*/
class User extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'user';
}
@@ -0,0 +1,289 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\AI\GEO\Outputs;
use ACF\AI\GEO\GEO;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* ACF GEO Blocks Output
*
* Extends ACF Blocks to add JSON-LD structured data output for block fields.
*
* To enable JSON-LD output for a block, add "autoJsonLd": true to the ACF namespace
* in your block.json file, or use the acf/ai/block_jsonld_enabled filter.
*
* See README.md for complete usage examples and documentation.
*/
class Blocks {
/**
* Constructor
*/
public function __construct() {
$this->init();
}
/**
* Initialize the GEO Blocks extension.
*
* @since 6.8.0
*
* @return void
*/
public function init() {
// Add support for autoJsonLd property from block.json ACF namespace.
add_filter( 'block_type_metadata_settings', array( $this, 'add_block_json_auto_jsonld_support' ), 10, 2 );
// Add support for autoJsonLd property from programmatic registration.
add_filter( 'acf/register_block_type_args', array( $this, 'add_programmatic_auto_jsonld_support' ) );
// Add front-end JSON-LD output for blocks.
add_action( 'acf/blocks/pre_block_template_render', array( $this, 'output_block_jsonld_data' ), 10, 6 );
}
/**
* Add support for autoJsonLd property from block.json ACF namespace
*
* Maps the 'autoJsonLd' property from block.json's acf namespace to 'auto_jsonld' setting.
* Also maps 'schemaType' to 'schema_type' for custom Schema.org @type values.
* This runs after ACF's own block.json handler.
*
* @since 6.8.0
*
* @param array $settings The compiled block settings.
* @param array $metadata The raw json metadata.
* @return array Modified block settings.
*/
public function add_block_json_auto_jsonld_support( $settings, $metadata ) {
// Only process ACF blocks.
if ( ! isset( $metadata['acf'] ) || ! is_array( $metadata['acf'] ) ) {
return $settings;
}
// Map autoJsonLd from ACF namespace to auto_jsonld.
if ( isset( $metadata['acf']['autoJsonLd'] ) ) {
$settings['auto_jsonld'] = $metadata['acf']['autoJsonLd'];
}
// Map schemaType from ACF namespace to schema_type.
if ( isset( $metadata['acf']['schemaType'] ) ) {
$settings['schema_type'] = $metadata['acf']['schemaType'];
}
return $settings;
}
/**
* Add support for autoJsonLd property from programmatic registration
*
* Maps the 'autoJsonLd' property from the acf namespace to 'auto_jsonld' setting
* and 'schemaType' to 'schema_type' for blocks registered via acf_register_block_type().
*
* @since 6.8.0
*
* @param array $block The block settings array.
* @return array Modified block settings.
*/
public function add_programmatic_auto_jsonld_support( $block ) {
// Check if this is a programmatic registration with ACF namespace.
if ( isset( $block['acf'] ) && is_array( $block['acf'] ) ) {
if ( isset( $block['acf']['autoJsonLd'] ) ) {
$block['auto_jsonld'] = $block['acf']['autoJsonLd'];
}
if ( isset( $block['acf']['schemaType'] ) ) {
$block['schema_type'] = $block['acf']['schemaType'];
}
}
return $block;
}
/**
* Output JSON-LD structured data for ACF block fields.
*
* @since 6.8.0
*
* @param array $block The block props.
* @param string $content The block content.
* @param boolean $is_preview Whether or not the block is being rendered for editing preview.
* @param integer $post_id The current post being edited or viewed.
* @param WP_Block $wp_block The block instance.
* @param array $context The block context array.
*/
public function output_block_jsonld_data( $block, $content, $is_preview, $post_id, $wp_block, $context ) {
/**
* Filters whether to output debug comments in HTML
*
* @since 6.8.0
*
* @param bool $debug Whether to output debug comments. Default false.
*/
$debug = apply_filters( 'acf/schema/debug', false );
// Don't output JSON-LD in the block editor preview.
if ( $is_preview ) {
if ( $debug ) {
echo "<!-- ACF AI Block JSON-LD: Skipped (block editor preview) -->\n";
}
return;
}
// Don't output if we don't have a block name.
if ( empty( $block['name'] ) ) {
if ( $debug ) {
echo "<!-- ACF AI Block JSON-LD: Skipped (no block name) -->\n";
}
return;
}
if ( $debug ) {
echo '<!-- ACF AI Block JSON-LD: Checking block: ' . esc_html( $block['name'] ) . " -->\n";
}
// Get the block type.
$block_type = acf_get_block_type( $block['name'] );
if ( ! $block_type ) {
if ( $debug ) {
echo '<!-- ACF AI Block JSON-LD: Block type not found for ' . esc_html( $block['name'] ) . " -->\n";
}
return;
}
// Check if this block has auto_jsonld enabled.
$auto_jsonld = isset( $block_type['auto_jsonld'] ) ? $block_type['auto_jsonld'] : false;
/**
* Filters whether JSON-LD output is enabled for this specific block.
*
* @since 6.8.0
*
* @param boolean $auto_jsonld Whether JSON-LD is enabled for this block.
* @param array $block The block props.
* @param array $block_type The block type settings.
*/
$auto_jsonld = apply_filters( 'acf/schema/block_jsonld_enabled', $auto_jsonld, $block, $block_type );
// Exit if auto JSON-LD is not enabled for this block.
if ( ! $auto_jsonld ) {
if ( $debug ) {
echo '<!-- ACF AI Block JSON-LD: Block \'' . esc_html( $block['name'] ) . "' does not have JSON-LD enabled -->\n";
}
return;
}
/**
* Filters the field objects before retrieval, allowing blocks to provide custom data.
*
* This is useful for blocks that link to other post types or need custom field data handling.
* Return a non-null value to short-circuit the default get_field_objects() call.
*
* @since 6.8.0
*
* @param array|null $field_objects The field objects array, or null to use default behavior.
* @param array $block The block props.
* @param array $block_type The block type settings.
* @param int $post_id The current post ID.
*/
$field_objects = apply_filters( 'acf/schema/block_field_objects', null, $block, $block_type, $post_id );
/**
* Filters the field objects for a specific block name/type.
*
* The dynamic portion of the hook name, `$block['name']`, refers to the block type name.
* For example, 'acf/schema/block_field_objects/block_name=acf/testimonial' for the testimonial block.
*
* @since 6.8.0
*
* @param array|null $field_objects The field objects array, or null to use default behavior.
* @param array $block The block props.
* @param array $block_type The block type settings.
* @param int $post_id The current post ID.
*/
$field_objects = apply_filters( 'acf/schema/block_field_objects/block_name=' . $block['name'], $field_objects, $block, $block_type, $post_id );
// If no custom field objects were provided, get them from the block.
if ( null === $field_objects ) {
// Get all ACF field objects with values for this block.
// Use get_field_objects() to get both field metadata and values in a single call.
$field_objects = get_field_objects( $block['id'], false );
}
if ( ! $field_objects || ! is_array( $field_objects ) ) {
if ( $debug ) {
echo '<!-- ACF AI Block JSON-LD: No ACF fields found for block ' . esc_html( $block['name'] ) . " -->\n";
}
return;
}
if ( $debug ) {
echo '<!-- ACF AI Block JSON-LD: Found ' . count( $field_objects ) . " ACF fields -->\n";
}
// Process ACF fields and extract types from qualified properties.
// This handles schema_property mapping.
$processed_fields = GEO::process_fields( $field_objects );
// Get any explicitly set schema type for this block.
$provided_type = ! empty( $block_type['schema_type'] ) ? $block_type['schema_type'] : null;
$field_types = $processed_fields['field_types'] ?? array();
// Determine the final @type based on provided type or field types from qualified properties.
// Supports both string (single type) and array (multiple types).
$schema_type = GEO::determine_schema_type( $provided_type, $field_types, 'PropertyValue' );
// Remove internal type data from processed fields.
unset( $processed_fields['field_types'] );
// Build base JSON-LD structured data.
$jsonld_data = array(
'@context' => 'https://schema.org',
'@type' => $schema_type, // Can be string or array
'@id' => ! empty( $block['id'] ) ? get_permalink( $post_id ) . '#' . $block['id'] : get_permalink( $post_id ),
);
// Add block title if available.
if ( ! empty( $block_type['title'] ) ) {
$jsonld_data['name'] = $block_type['title'];
}
// Add block description if available.
if ( ! empty( $block_type['description'] ) ) {
$jsonld_data['description'] = $block_type['description'];
}
// Merge processed fields into JSON-LD data.
$jsonld_data = array_merge( $jsonld_data, $processed_fields );
/**
* Filters the JSON-LD data before output for a block.
*
* @since 6.8.0
*
* @param array $jsonld_data The JSON-LD data array.
* @param array $block The block props.
* @param array $block_type The block type settings.
*/
$jsonld_data = apply_filters( 'acf/schema/data', $jsonld_data, $block, $block_type );
// Only output if we have data after filtering.
if ( empty( $jsonld_data ) ) {
return;
}
// Output the JSON-LD using the shared helper.
GEO::render_jsonld_script( $jsonld_data );
}
}
@@ -0,0 +1,55 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Blocks;
/**
* Enqueues the JS layer that powers ACF block bindings in the block editor.
*
* The JS bindings layer registers a block binding source via the stable
* registerBlockBindingsSource API (WP 6.7+), enabling live preview and
* editing of ACF field values bound to block attributes. It runs alongside
* the shared server-side ACF\Blocks\Bindings class.
*/
class Bindings_Editor {
/**
* Constructor.
*/
public function __construct() {
// The JS bindings layer relies on the stable registerBlockBindingsSource
// API, which is only available on WP 6.7+.
global $wp_version;
if ( version_compare( $wp_version, '6.7', '<' ) ) {
return;
}
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) );
}
/**
* Enqueues the JS block bindings source script on block editor screens.
*
* @return void
*/
public function enqueue_block_editor_assets() {
if ( ! acf_get_setting( 'enable_block_bindings' ) ) {
return;
}
// JS bindings layer requires the datastore for live editor preview/editing.
if ( ! acf_is_using_datastore() ) {
return;
}
wp_enqueue_script( 'acf-field-bindings' );
}
}
@@ -0,0 +1,92 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Datastore;
/**
* Attaches datastore field data to the acf/ajax/check_screen response.
*
* The check_screen AJAX endpoint runs when WordPress loads metaboxes
* dynamically (the meta-box-loader path). When new field groups appear
* on screen, the JS-side datastore needs to know about their fields and
* values. This class collects that data and merges it into the response
* as `storeData` for groups that were not already on the page.
*/
class Check_Screen {
/**
* Constructor.
*
* @since 6.8.1
*/
public function __construct() {
add_filter( 'acf/ajax/check_screen/response', array( $this, 'attach_store_data' ), 10, 3 );
}
/**
* Attaches datastore data for newly-loaded field groups to the response.
*
* @since 6.8.1
*
* @param array $response The check_screen response array.
* @param array $field_groups The field groups returned for this screen.
* @param array $args The check_screen request args (post_id, screen, exists, ...).
* @return array
*/
public function attach_store_data( $response, $field_groups, $args ) {
if ( ! acf_is_using_datastore() ) {
return $response;
}
$store_data = array(
'context' => array(
'postId' => (int) $args['post_id'],
'postType' => get_post_type( (int) $args['post_id'] ) ?: '',
),
'fields' => array(),
'values' => array(),
'fieldGroups' => array(),
);
$localization = acf_get_instance( 'ACF\\Pro\\Datastore\\Localization' );
$exists = isset( $args['exists'] ) ? (array) $args['exists'] : array();
foreach ( (array) $field_groups as $field_group ) {
// Only collect data for field groups not already on the page.
if ( in_array( $field_group['key'], $exists, true ) ) {
continue;
}
$fields = acf_get_fields( $field_group );
if ( ! $fields ) {
continue;
}
$localization->collect_field_data( $fields, $args['post_id'], $field_group['key'], $store_data );
$store_data['fieldGroups'][] = array(
'key' => $field_group['key'],
'title' => acf_esc_html( acf_get_field_group_title( $field_group ) ),
'position' => $field_group['position'],
'style' => $field_group['style'],
'label_placement' => $field_group['label_placement'],
'instruction_placement' => $field_group['instruction_placement'],
'edit_url' => esc_url( acf_get_field_group_edit_link( $field_group['ID'] ) ),
);
}
if ( ! empty( $store_data['fields'] ) ) {
$response['storeData'] = $store_data;
}
return $response;
}
}
@@ -0,0 +1,467 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Datastore;
/**
* Enqueues the ACF datastore script and localizes field group definitions
* and values for the @wordpress/data store consumed by the JS datastore.
*
* Independently listens to the same enqueue_block_editor_assets WP action
* the free ACF_Form_Gutenberg uses, so no free-side touchpoint is required.
*/
class Localization {
/**
* Constructor.
*
* @since 6.8.1
*/
public function __construct() {
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue' ) );
add_filter( 'acf/ajax/query_users/args', array( $this, 'add_user_query_include' ), 20, 3 );
}
/**
* Allows the JS datastore to look up specific users by ID via the user
* query endpoint, so revision restores and programmatic acf.store.set()
* calls can render user labels for values not in the page-rendered options.
*
* @since 6.8.1
*
* @param array $args The query args.
* @param array $request The query request.
* @param \ACF_Ajax_Query $query The query object.
* @return array
*/
public function add_user_query_include( $args, $request, $query ) {
if ( ! acf_is_using_datastore() ) {
return $args;
}
if ( empty( $request['include'] ) ) {
return $args;
}
$args['include'] = array( absint( $request['include'] ) );
return $args;
}
/**
* Enqueues the datastore script and localizes the field store data
* when the datastore is enabled.
*
* @since 6.8.1
*
* @return void
*/
public function enqueue() {
if ( ! acf_is_using_datastore() ) {
return;
}
wp_enqueue_script( 'acf-datastore' );
$this->localize_field_store_data();
}
/**
* Localizes field group definitions and values for the ACF @wordpress/data store.
*
* Gathers all field groups visible on the current post, serializes their
* field definitions and current values, and passes them to JS via
* acf_localize_data(). This data initializes the 'acf/fields' store
* which powers JS-side block bindings and developer access.
*
* @since 6.8.1
*
* @return void
*/
private function localize_field_store_data() {
global $post;
if ( ! $post ) {
return;
}
$field_groups = acf_get_field_groups( array( 'post_id' => $post->ID ) );
if ( empty( $field_groups ) ) {
return;
}
$store_data = array(
'context' => array(
'postId' => $post->ID,
'postType' => $post->post_type,
),
'fields' => array(),
'values' => array(),
'fieldGroups' => array(),
);
foreach ( $field_groups as $field_group ) {
$store_data['fieldGroups'][] = array(
'key' => $field_group['key'],
'title' => acf_esc_html( acf_get_field_group_title( $field_group ) ),
'position' => $field_group['position'],
'style' => $field_group['style'],
'label_placement' => $field_group['label_placement'],
'instruction_placement' => $field_group['instruction_placement'],
'edit_url' => esc_url( acf_get_field_group_edit_link( $field_group['ID'] ) ),
);
$fields = acf_get_fields( $field_group );
if ( $fields ) {
$this->collect_field_data( $fields, $post->ID, $field_group['key'], $store_data );
}
}
acf_localize_data( array( 'storeData' => $store_data ) );
}
/**
* Recursively collects field definitions and values for the store.
*
* Processes an array of fields, loading each field's value and adding
* it to the store data structure. For complex fields (repeater, group,
* flexible content), recurses into sub-fields to build nested values.
*
* @since 6.8.1
*
* @param array $fields Array of field arrays.
* @param integer $post_id The post ID to load values for.
* @param string $field_group_key The parent field group's key.
* @param array $store_data Reference to the store data being built.
* @return void
*/
public function collect_field_data( $fields, $post_id, $field_group_key, &$store_data ) {
foreach ( $fields as $field ) {
$field = apply_filters( 'acf/prepare_field', $field );
if ( ! $field ) {
continue;
}
$field_def = array(
'key' => $field['key'],
'name' => $field['name'],
'type' => $field['type'],
'label' => acf_esc_html( $field['label'] ),
'parent' => $field['parent'],
'fieldGroupKey' => $field_group_key,
);
if ( ! acf_field_type_supports( $field['type'], 'bindings', true ) ) {
$field_def['supportsBindings'] = false;
}
if ( isset( $field['allow_in_bindings'] ) ) {
$field_def['allowInBindings'] = (bool) $field['allow_in_bindings'];
}
// Repeaters with pagination enabled need unique handling in the datastore.
if ( 'repeater' === $field['type'] ) {
$field_def['pagination'] = ! empty( $field['pagination'] );
}
// Include sub_fields metadata for complex types.
if ( ! empty( $field['sub_fields'] ) ) {
$field_def['subFields'] = array();
foreach ( $field['sub_fields'] as $sub_field ) {
if ( apply_filters( 'acf/prepare_field', $sub_field ) ) {
$field_def['subFields'][] = $sub_field['key'];
}
}
}
// Include layouts for flexible content.
if ( ! empty( $field['layouts'] ) ) {
$field_def['layouts'] = array();
foreach ( $field['layouts'] as $layout ) {
$layout_def = array(
'key' => $layout['key'],
'name' => $layout['name'],
'label' => acf_esc_html( $layout['label'] ),
);
if ( ! empty( $layout['sub_fields'] ) ) {
$layout_def['subFields'] = array();
foreach ( $layout['sub_fields'] as $sub_field ) {
if ( apply_filters( 'acf/prepare_field', $sub_field ) ) {
$layout_def['subFields'][] = $sub_field['key'];
}
}
}
$field_def['layouts'][] = $layout_def;
}
}
$store_data['fields'][ $field['key'] ] = $field_def;
// Load the field value.
//
// Skip paginated repeaters: the value would be discarded by
// reconcileWithDOM in JS (visible-page DOM rows overwrite it
// on init), and acf_get_value() would cache the unsliced row
// set here -- load_value()'s pagination slice is gated on
// $is_rendering, which isn't set until pre_render_fields fires
// for the metabox. The cached unsliced value would then be
// reused by the render and show all rows on page 1.
if ( 'repeater' === $field['type'] && ! empty( $field['pagination'] ) ) {
$store_data['values'][ $field['key'] ] = array();
} else {
$value = acf_get_value( $post_id, $field );
$store_data['values'][ $field['key'] ] = $this->serialize_field_value( $field, $value, $post_id );
}
// Register sub-fields in the store for complex types.
if ( ! empty( $field['sub_fields'] ) ) {
$this->register_sub_fields( $field['sub_fields'], $field_group_key, $store_data );
}
// Register layout sub-fields for flexible content.
if ( ! empty( $field['layouts'] ) ) {
foreach ( $field['layouts'] as $layout ) {
if ( ! empty( $layout['sub_fields'] ) ) {
$this->register_sub_fields( $layout['sub_fields'], $field_group_key, $store_data );
}
}
}
}
}
/**
* Registers sub-field definitions in the store (without loading values).
*
* Sub-field values are stored as part of their parent's value structure,
* but their definitions need to be in the store for metadata access.
*
* @since 6.8.1
*
* @param array $sub_fields Array of sub-field arrays.
* @param string $field_group_key The parent field group's key.
* @param array $store_data Reference to the store data being built.
* @return void
*/
public function register_sub_fields( $sub_fields, $field_group_key, &$store_data ) {
foreach ( $sub_fields as $sub_field ) {
$sub_field = apply_filters( 'acf/prepare_field', $sub_field );
if ( ! $sub_field ) {
continue;
}
// Bindings::get_value can't resolve sub-field keys at the
// post level (no row index for repeater/flex; no parent-chain
// prefix walk for group/clone), so the picker must exclude them.
$field_def = array(
'key' => $sub_field['key'],
'name' => $sub_field['name'],
'type' => $sub_field['type'],
'label' => acf_esc_html( $sub_field['label'] ),
'parent' => $sub_field['parent'],
'fieldGroupKey' => $field_group_key,
'supportsBindings' => false,
);
if ( ! empty( $sub_field['sub_fields'] ) ) {
$field_def['subFields'] = array();
foreach ( $sub_field['sub_fields'] as $nested ) {
if ( apply_filters( 'acf/prepare_field', $nested ) ) {
$field_def['subFields'][] = $nested['key'];
}
}
// Recurse for nested complex fields.
$this->register_sub_fields( $sub_field['sub_fields'], $field_group_key, $store_data );
}
if ( ! empty( $sub_field['layouts'] ) ) {
$field_def['layouts'] = array();
foreach ( $sub_field['layouts'] as $layout ) {
$layout_def = array(
'key' => $layout['key'],
'name' => $layout['name'],
'label' => acf_esc_html( $layout['label'] ),
);
if ( ! empty( $layout['sub_fields'] ) ) {
$layout_def['subFields'] = array();
foreach ( $layout['sub_fields'] as $nested ) {
if ( apply_filters( 'acf/prepare_field', $nested ) ) {
$layout_def['subFields'][] = $nested['key'];
}
}
$this->register_sub_fields( $layout['sub_fields'], $field_group_key, $store_data );
}
$field_def['layouts'][] = $layout_def;
}
}
$store_data['fields'][ $sub_field['key'] ] = $field_def;
}
}
/**
* Serializes a field value into the structure expected by the JS store.
*
* For simple fields, returns the raw value. For complex fields (repeater,
* group, flexible content), builds a nested structure matching the ACF
* REST API format.
*
* @since 6.8.1
*
* @param array $field The field array.
* @param mixed $value The raw field value.
* @param integer $post_id The post ID.
* @return mixed The serialized value.
*/
public function serialize_field_value( $field, $value, $post_id ) {
switch ( $field['type'] ) {
case 'repeater':
return $this->serialize_repeater_value( $field, $value, $post_id );
case 'group':
return $this->serialize_group_value( $field, $value, $post_id );
case 'flexible_content':
return $this->serialize_flexible_content_value( $field, $value, $post_id );
case 'wysiwyg':
return is_string( $value ) ? acf_esc_html( $value ) : $value;
default:
return $value;
}
}
/**
* Serializes a repeater field value.
*
* acf_get_value() returns the loaded value from the repeater's load_value()
* method -- an array of rows keyed by sub-field key, not the raw row count
* stored in the database.
*
* @since 6.8.1
*
* @param array $field The repeater field array.
* @param mixed $value The loaded value (array of rows from load_value).
* @param integer $post_id The post ID.
* @return array Array of row objects.
*/
public function serialize_repeater_value( $field, $value, $post_id ) {
if ( empty( $field['sub_fields'] ) || ! is_array( $value ) ) {
return array();
}
$result = array();
foreach ( $value as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$serialized_row = array();
foreach ( $field['sub_fields'] as $sub_field ) {
$sub_value = isset( $row[ $sub_field['key'] ] ) ? $row[ $sub_field['key'] ] : null;
$serialized_row[ $sub_field['key'] ] = $this->serialize_field_value( $sub_field, $sub_value, $post_id );
}
$result[] = $serialized_row;
}
return $result;
}
/**
* Serializes a group field value.
*
* When called from a parent complex field (repeater, flex content), $value
* is the already-loaded array from load_value() keyed by sub-field key.
* For top-level groups, $value may also be a loaded array. Falls back to
* loading from the database when the loaded value is not available.
*
* @since 6.8.1
*
* @param array $field The group field array.
* @param mixed $value The loaded value (array of sub-field values, or raw).
* @param integer $post_id The post ID.
* @return array|\stdClass Associative array of sub-field values keyed by field key.
*/
public function serialize_group_value( $field, $value, $post_id ) {
if ( empty( $field['sub_fields'] ) ) {
return new \stdClass();
}
$result = array();
foreach ( $field['sub_fields'] as $sub_field ) {
if ( is_array( $value ) && array_key_exists( $sub_field['key'], $value ) ) {
$sub_value = $value[ $sub_field['key'] ];
} else {
$sub_field['name'] = $field['name'] . '_' . $sub_field['name'];
$sub_value = acf_get_value( $post_id, $sub_field );
}
$result[ $sub_field['key'] ] = $this->serialize_field_value( $sub_field, $sub_value, $post_id );
}
return $result;
}
/**
* Serializes a flexible content field value.
*
* acf_get_value() returns the loaded value from the flex content's
* load_value() method -- an array of layout row objects, each containing
* an 'acf_fc_layout' key and sub-field values keyed by field key.
*
* @since 6.8.1
*
* @param array $field The flexible content field array.
* @param mixed $value The loaded value (array of layout rows from load_value).
* @param integer $post_id The post ID.
* @return array Array of layout objects.
*/
public function serialize_flexible_content_value( $field, $value, $post_id ) {
if ( ! is_array( $value ) || empty( $field['layouts'] ) ) {
return array();
}
// Build a lookup of layouts by name for sub-field definitions.
$layouts_by_name = array();
foreach ( $field['layouts'] as $layout ) {
$layouts_by_name[ $layout['name'] ] = $layout;
}
$result = array();
foreach ( $value as $row ) {
if ( ! is_array( $row ) || empty( $row['acf_fc_layout'] ) ) {
continue;
}
$layout_name = $row['acf_fc_layout'];
if ( ! isset( $layouts_by_name[ $layout_name ] ) ) {
continue;
}
$layout = $layouts_by_name[ $layout_name ];
$layout_row = array( 'acf_fc_layout' => $layout_name );
if ( ! empty( $layout['sub_fields'] ) ) {
foreach ( $layout['sub_fields'] as $sub_field ) {
$sub_value = isset( $row[ $sub_field['key'] ] ) ? $row[ $sub_field['key'] ] : null;
$layout_row[ $sub_field['key'] ] = $this->serialize_field_value( $sub_field, $sub_value, $post_id );
}
}
$result[] = $layout_row;
}
return $result;
}
}
@@ -0,0 +1,265 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Datastore;
/**
* Handles ACF datastore saves during Gutenberg / REST post requests.
*
* Decodes the _acf transport meta included on REST post requests, writes
* individual field meta to the post and (when applicable) to the revision,
* then cleans up the transport blob. Also strips the transport blob from
* REST responses so it never leaks to clients.
*/
class REST_Save {
/**
* Decoded ACF values from the current REST save.
* Set in save_post_rest(), consumed in save_revision_meta().
*
* @since 6.8.1
* @var array|null
*/
private $current_acf_values = null;
/**
* Post ID pending _acf cleanup.
* Set in save_post_rest(), consumed in cleanup_acf_transport_meta().
*
* @since 6.8.1
* @var integer|null
*/
private $pending_cleanup_post_id = null;
/**
* Constructor.
*
* Defers hook registration to rest_api_init so the
* acf/settings/enable_datastore filter is available to themes
* and plugins by the time the gate is evaluated.
*
* @since 6.8.1
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'maybe_register_rest_save_hooks' ) );
// Skip the legacy ACF_Form_Post::save_post() during the metabox AJAX
// (meta-box-loader) that follows each Gutenberg REST save -- the REST
// path has already saved the values, so re-running the legacy save
// would clobber them.
add_filter( 'acf/form-post/skip_save', array( $this, 'skip_metabox_loader_save' ), 10, 3 );
}
/**
* Returns true when the current request is the meta-box-loader AJAX
* and the datastore is enabled.
*
* @since 6.8.1
*
* @param boolean $skip Whether the save should be skipped.
* @param integer $post_id The post ID being saved.
* @param mixed $post The post being saved.
* @return boolean
*/
public function skip_metabox_loader_save( $skip, $post_id, $post ) {
if ( $skip ) {
return $skip;
}
return acf_maybe_get_GET( 'meta-box-loader', false ) && acf_is_using_datastore();
}
/**
* Conditionally registers REST save hooks for all public post types.
*
* @since 6.8.1
*
* @return void
*/
public function maybe_register_rest_save_hooks() {
if ( ! acf_is_using_datastore() ) {
return;
}
add_action( '_wp_put_post_revision', array( $this, 'save_revision_meta' ), 11, 2 );
foreach ( get_post_types( array( 'show_in_rest' => true ) ) as $post_type ) {
// Post-save: decode _acf and write individual meta keys to post + revision.
add_action( "rest_after_insert_{$post_type}", array( $this, 'save_post_rest' ), 10, 2 );
// Strip _acf from post REST responses. Revisions use
// rest_prepare_revision instead, so _acf passes through
// for the revision viewer.
add_filter( "rest_prepare_{$post_type}", array( $this, 'strip_acf_transport_meta' ) );
}
/**
* Autosave: WP_REST_Autosaves_Controller does not fire
* rest_after_insert_{post_type}, so this response filter is the only
* hook available in all autosave paths that carries the request object.
* No-ops on GET requests since the request body is empty.
*/
add_filter( 'rest_prepare_autosave', array( $this, 'save_autosave_rest' ), 10, 3 );
// Fallback cleanup for the _acf transport meta. Runs at priority 20,
// after the revision system (priority 9) has finished deciding whether
// to create a revision. Catches orphaned _acf when no revision is
// created (e.g., post type doesn't support revisions).
add_action( 'wp_after_insert_post', array( $this, 'cleanup_acf_transport_meta' ), 20 );
}
/**
* Writes ACF field values to the revision after WordPress creates it.
*
* Called via _wp_put_post_revision, which fires AFTER rest_after_insert
* (where save_post_rest writes values to the post). At this point the
* decoded values are available in $this->current_acf_values.
*
* @since 6.8.1
*
* @param integer $revision_id The revision ID.
* @param integer $post_id The parent post ID.
* @return void
*/
public function save_revision_meta( $revision_id, $post_id ) {
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
return;
}
$is_autosave = wp_is_post_autosave( $revision_id );
// Clean up the transport-only _acf blob from the post.
// wp_save_revisioned_meta_fields (priority 10) has already copied
// it to the revision for future comparison.
if ( ! $is_autosave ) {
delete_post_meta( $post_id, '_acf' );
}
if ( empty( $this->current_acf_values ) || $is_autosave ) {
return;
}
$values = $this->current_acf_values;
if ( ! acf_allow_unfiltered_html() ) {
$values = wp_kses_post_deep( $values );
}
acf_update_values( $values, $revision_id );
$this->current_acf_values = null;
}
/**
* Processes ACF field values from the REST request.
* Decodes the _acf blob and saves individual meta keys to the post.
*
* Revision meta is handled separately by save_revision_meta(), which
* fires later via _wp_put_post_revision after WordPress creates the
* revision inside wp_after_insert_post().
*
* @since 6.8.1
*
* @param \WP_Post $post The post object.
* @param \WP_REST_Request $request The REST request.
* @return void
*/
public function save_post_rest( $post, $request ) {
// Check if _acf data was included in the request.
$meta = $request->get_param( 'meta' );
if ( empty( $meta['_acf'] ) ) {
return;
}
// Retrieve and decode the JSON.
$acf_json = get_post_meta( $post->ID, '_acf', true );
$values = is_string( $acf_json ) ? json_decode( $acf_json, true ) : null;
if ( empty( $values ) || ! is_array( $values ) ) {
return;
}
// Save individual meta keys to the post.
// Fires acf/save_post action, runs all ACF processing hooks.
acf_save_post( $post->ID, $values );
// Store values for save_revision_meta(), which fires later
// via _wp_put_post_revision (after wp_after_insert_post creates
// the revision). Don't delete _acf yet -- the revision comparison
// needs it on the post when deciding whether to create a revision.
// cleanup_acf_transport_meta() handles deletion after the revision
// system finishes.
$this->current_acf_values = $values;
$this->pending_cleanup_post_id = $post->ID;
}
/**
* Handles ACF values during autosave REST requests.
*
* @since 6.8.1
*
* @param \WP_REST_Response $response The response object.
* @param \WP_Post $post The post object.
* @param \WP_REST_Request $request The REST request.
* @return \WP_REST_Response
*/
public function save_autosave_rest( $response, $post, $request ) {
$this->save_post_rest( $post, $request );
return $response;
}
/**
* Cleans up the transport-only _acf meta after the revision system finishes.
*
* Hooked to wp_after_insert_post at priority 20, which runs after
* wp_save_post_revision_on_insert (priority 9). This catches orphaned
* _acf when no revision is created (e.g., post type doesn't support
* revisions). When a revision IS created, save_revision_meta() already
* deleted _acf, so this is a harmless no-op.
*
* @since 6.8.1
*
* @param integer $post_id The post ID.
* @return void
*/
public function cleanup_acf_transport_meta( $post_id ) {
if ( $this->pending_cleanup_post_id === null || (int) $this->pending_cleanup_post_id !== (int) $post_id ) {
return;
}
delete_post_meta( $post_id, '_acf' );
$this->current_acf_values = null;
$this->pending_cleanup_post_id = null;
}
/**
* Strips _acf from post REST responses.
*
* The _acf meta is transport-only and should not appear in post
* responses. Revision responses use rest_prepare_revision instead,
* so _acf passes through for the revision viewer.
*
* @since 6.8.1
*
* @param \WP_REST_Response $response The response object.
* @return \WP_REST_Response
*/
public function strip_acf_transport_meta( $response ) {
$data = $response->get_data();
if ( isset( $data['meta']['_acf'] ) ) {
$data['meta']['_acf'] = '';
$response->set_data( $data );
}
return $response;
}
}
@@ -0,0 +1,157 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Datastore;
/**
* ACF datastore integration with the WordPress revisions system.
*
* Registers the _acf transport meta as a revisioned key so changes to ACF
* field values trigger revision creation, and short-circuits the legacy
* metabox-AJAX-driven revision path during REST requests (where REST_Save
* is in charge instead).
*/
class Revisions {
/**
* Constructor.
*
* register_meta is deferred to the `init` hook so themes and plugins
* have a chance to filter `acf/settings/enable_datastore` before the
* gate is evaluated.
*
* @since 6.8.1
*/
public function __construct() {
add_action( 'init', array( $this, 'register_meta' ) );
add_filter( 'wp_post_revision_meta_keys', array( $this, 'add_acf_to_revision_meta_keys' ) );
add_filter( 'acf/revisions/skip_legacy_metabox_handling', array( $this, 'skip_during_rest' ) );
}
/**
* Registers the _acf transport meta when the datastore is enabled.
*
* _acf carries field values in the REST request. revisions_enabled is
* false here -- _acf is conditionally added to wp_post_revision_meta_keys
* only during REST requests so it triggers revision creation without
* causing duplicate revisions from the metabox AJAX (meta-box-loader)
* that follows each REST save. _acf is stripped from non-revision REST
* responses via rest_prepare_{post_type} in REST_Save.
*
* @since 6.8.1
*
* @return void
*/
public function register_meta() {
if ( ! acf_is_using_datastore() ) {
return;
}
register_meta(
'post',
'_acf',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'revisions_enabled' => false,
'auth_callback' => function ( $allowed, $meta_key, $object_id, $user_id ) {
return user_can( $user_id, 'edit_post', $object_id );
},
'sanitize_callback' => function ( $value ) {
if ( ! is_string( $value ) ) {
return '';
}
$decoded = json_decode( $value, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
return '';
}
return wp_json_encode( self::canonicalize_acf_value( $decoded ) );
},
)
);
}
/**
* Adds _acf to the list of revisioned meta keys during REST requests.
*
* _acf triggers revision creation when ACF values change. During the
* metabox AJAX (meta-box-loader) that follows each Gutenberg REST save,
* _acf must not be compared -- wp_update_post() fires again for metabox
* re-rendering and would create a duplicate revision.
*
* @since 6.8.1
*
* @param array $keys The meta keys that should be revisioned.
* @return array
*/
public function add_acf_to_revision_meta_keys( $keys ) {
if ( acf_is_using_datastore() && defined( 'REST_REQUEST' ) && REST_REQUEST ) {
$keys[] = '_acf';
}
return $keys;
}
/**
* Tells acf_revisions to skip the legacy metabox handling on REST requests.
*
* Hooked to the acf/revisions/skip_legacy_metabox_handling filter. During
* a REST save, REST_Save copies field values to the post and revision,
* so the legacy metabox-AJAX-driven path in acf_revisions must not run.
*
* @since 6.8.1
*
* @param boolean $skip Whether to skip the legacy handling.
* @return boolean
*/
public function skip_during_rest( $skip ) {
if ( ! acf_is_using_datastore() ) {
return $skip;
}
return $skip || ( defined( 'REST_REQUEST' ) && REST_REQUEST );
}
/**
* Recursively sorts associative array keys for canonical JSON encoding.
*
* Sequential arrays (repeater rows, flexible content layouts, multi-value
* selections) keep their user-defined order; associative arrays -- at any
* level, regardless of whether keys are ACF field keys, the acf_fc_layout
* discriminator, or other string keys (e.g. link field title/url/target) --
* are sorted by key. JSON object keys are semantically unordered, so this
* is a no-op for consumers but makes the stored bytes byte-stable across
* saves so WordPress's revision meta byte comparison treats reordered
* saves as equal.
*
* @since 6.8.1
*
* @param mixed $value Decoded JSON value.
* @return mixed
*/
private static function canonicalize_acf_value( $value ) {
if ( ! is_array( $value ) || array() === $value ) {
return $value;
}
$is_sequential = array_keys( $value ) === range( 0, count( $value ) - 1 );
if ( ! $is_sequential ) {
ksort( $value );
}
foreach ( $value as $k => $v ) {
$value[ $k ] = self::canonicalize_acf_value( $v );
}
return $value;
}
}
@@ -0,0 +1,325 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Fields\FlexibleContent;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
class Layout {
/**
* The Flexible Content field the layout belongs to.
*
* @var array
*/
private $field;
/**
* The layout being rendered.
*
* @var array
*/
private $layout;
/**
* The order of the layout.
*
* @var integer|string
*/
private $order;
/**
* The value of the layout.
*
* @var mixed
*/
private $value;
/**
* The input prefix.
*
* @var string
*/
private $prefix;
/**
* If the layout is disabled.
*
* @var boolean
*/
private $disabled;
/**
* If the layout has been renamed, the new name of the layout.
*
* @var string
*/
private $renamed;
/**
* Constructs the class.
*
* @since 6.5
*
* @param array $field The Flexible Content field the layout belongs to.
* @param array $layout The layout to render.
* @param integer|string $order The order of the layout.
* @param mixed $value The value of the layout.
* @param boolean $disabled If the layout is disabled.
* @param string $renamed If the layout has been renamed, the new name of the layout.
*/
public function __construct( $field, $layout, $order, $value, $disabled = false, $renamed = '' ) {
$this->field = $field;
$this->layout = $layout;
$this->order = $order;
$this->value = $value;
$this->disabled = $disabled;
$this->renamed = $renamed;
}
/**
* Renders the layout.
*
* @since 6.5
*
* @return void
*/
public function render() {
$id = 'row-' . $this->order;
$class = 'layout';
if ( 'acfcloneindex' === $this->order ) {
$id = 'acfcloneindex';
$class .= ' acf-clone';
}
$this->prefix = $this->field['name'] . '[' . $id . ']';
$div_attrs = array(
'class' => $class,
'data-id' => $id,
'data-layout' => $this->layout['name'],
'data-label' => $this->layout['label'],
'data-min' => $this->layout['min'],
'data-max' => $this->layout['max'],
'data-enabled' => $this->disabled ? 0 : 1,
'data-renamed' => empty( $this->renamed ) ? 0 : 1,
);
echo '<div ' . acf_esc_attrs( $div_attrs ) . '>'; // Layout wrapper div.
acf_hidden_input(
array(
'name' => $this->prefix . '[acf_fc_layout]',
'value' => $this->layout['name'],
)
);
acf_hidden_input(
array(
'class' => 'acf-fc-layout-disabled',
'name' => $this->prefix . '[acf_fc_layout_disabled]',
'value' => $this->disabled ? 1 : 0,
)
);
acf_hidden_input(
array(
'class' => 'acf-fc-layout-custom-label',
'name' => $this->prefix . '[acf_fc_layout_custom_label]',
'value' => $this->renamed,
)
);
$this->action_buttons();
if ( ! empty( $this->layout['sub_fields'] ) ) {
if ( 'table' === $this->layout['display'] ) {
$this->render_as_table();
} else {
$this->render_as_div();
}
}
echo '</div>'; // End layout wrapper div.
}
/**
* Renders a layout as a table.
*
* @since 6.5
*
* @return void
*/
private function render_as_table() {
$sub_fields = $this->layout['sub_fields'];
?>
<table class="acf-table">
<thead>
<tr>
<?php
foreach ( $sub_fields as $sub_field ) {
// Set prefix to generate correct "for" attribute on <label>.
$sub_field['prefix'] = $this->prefix;
// Prepare field (allow sub fields to be removed).
$sub_field = acf_prepare_field( $sub_field );
if ( ! $sub_field ) {
continue;
}
$th_attrs = array(
'class' => 'acf-th',
'data-name' => $sub_field['_name'],
'data-type' => $sub_field['type'],
'data-key' => $sub_field['key'],
);
if ( $sub_field['wrapper']['width'] ) {
$th_attrs['data-width'] = $sub_field['wrapper']['width'];
$th_attrs['style'] = 'width: ' . $sub_field['wrapper']['width'] . '%;';
}
echo '<th ' . acf_esc_attrs( $th_attrs ) . '>';
acf_render_field_label( $sub_field );
acf_render_field_instructions( $sub_field );
echo '</th>';
}
?>
</tr>
</thead>
<tbody>
<tr><?php $this->sub_fields(); ?></tr>
</tbody>
</table>
<?php
}
/**
* Renders a layout as a div.
*
* @since 6.5
*
* @return void
*/
private function render_as_div() {
$class = 'acf-fields';
if ( 'row' === $this->layout['display'] ) {
$class .= ' -left';
}
echo '<div class="' . esc_attr( $class ) . '">';
$this->sub_fields();
echo '</div>';
}
/**
* Renders the layout actions (Add, Duplicate, Rename).
*
* @since 6.5
*
* @return void
*/
private function action_buttons() {
$title = $this->get_title();
$order = is_numeric( $this->order ) ? $this->order + 1 : 0;
?>
<div class="acf-fc-layout-actions-wrap">
<div class="acf-fc-layout-handle" title="<?php esc_attr_e( 'Drag to reorder', 'acf' ); ?>" data-name="collapse-layout">
<span class="acf-fc-layout-order"><?php echo (int) $order; ?></span>
<span class="acf-fc-layout-draggable-icon"></span>
<span class="acf-fc-layout-title">
<?php echo ! empty( $this->renamed ) ? esc_html( $this->renamed ) : $title; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped earlier in function. ?>
</span>
<span class="acf-fc-layout-original-title">
(<?php echo $title; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped earlier in function. ?>)
</span>
<span class="acf-layout-disabled"><?php esc_html_e( 'Disabled', 'acf' ); ?></span>
</div>
<div class="acf-fc-layout-controls">
<a class="acf-js-tooltip" href="#" data-name="add-layout" data-context="layout" title="<?php echo esc_attr( $this->field['button_label'] ); ?>"><span class="acf-icon -plus-alt "></span></a>
<a class="acf-js-tooltip" href="#" data-name="duplicate-layout" title="<?php esc_attr_e( 'Duplicate', 'acf' ); ?>"><span class="acf-icon -duplicate-alt"></span></a>
<a class="acf-js-tooltip" href="#" data-name="remove-layout" title="<?php esc_attr_e( 'Delete', 'acf' ); ?>"><span class="acf-icon -trash-alt"></span></a>
<a class="acf-js-tooltip" aria-haspopup="menu" href="#" data-name="more-layout-actions" title="<?php esc_attr_e( 'More layout actions...', 'acf' ); ?>"><span class="acf-icon -more-actions"></span></a>
<div class="acf-layout-collapse">
<a class="acf-icon -collapse -clear" href="#" data-name="collapse-layout" aria-label="<?php esc_attr_e( 'Toggle layout', 'acf' ); ?>"></a>
</div>
</div>
</div>
<?php
}
/**
* Renders the subfields for a layout.
*
* @since 6.5
* @return void
*/
private function sub_fields() {
foreach ( $this->layout['sub_fields'] as $sub_field ) {
// add value
if ( isset( $this->value[ $sub_field['key'] ] ) ) {
// this is a normal value
$sub_field['value'] = $this->value[ $sub_field['key'] ];
} elseif ( isset( $sub_field['default_value'] ) ) {
// no value, but this sub field has a default value
$sub_field['value'] = $sub_field['default_value'];
}
// update prefix to allow for nested values
$sub_field['prefix'] = $this->prefix;
// Render the input.
$el = 'table' === $this->layout['display'] ? 'td' : 'div';
acf_render_field_wrap( $sub_field, $el );
}
}
/**
* Returns the filtered layout title.
*
* @since 6.5
*
* @return string
*/
public function get_title() {
$rows = array();
$rows[ $this->order ] = $this->value;
acf_add_loop(
array(
'selector' => $this->field['name'],
'name' => $this->field['name'],
'value' => $rows,
'field' => $this->field,
'i' => $this->order,
'post_id' => 0,
)
);
// Make the title filterable.
$title = esc_html( $this->layout['label'] );
$title = apply_filters( 'acf/fields/flexible_content/layout_title', $title, $this->field, $this->layout, $this->order );
$title = apply_filters( 'acf/fields/flexible_content/layout_title/name=' . $this->field['_name'], $title, $this->field, $this->layout, $this->order );
$title = apply_filters( 'acf/fields/flexible_content/layout_title/key=' . $this->field['key'], $title, $this->field, $this->layout, $this->order );
$title = acf_esc_html( $title );
acf_remove_loop();
reset_rows(); // TODO: Make sure this is actually where this should go if needed at all.
return $title;
}
}
@@ -0,0 +1,272 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Fields\FlexibleContent;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
class Render {
/**
* The main field array used to render the Flexible Content field.
*
* @var array
*/
private $field;
/**
* An array of layouts used by the Flexible Content field.
*
* @var array
*/
private $layouts;
/**
* An array of meta for the layouts being rendered.
*
* @var array
*/
private $layout_meta;
/**
* Constructs the class.
*
* @since 6.5
*
* @param array $field The flexible content field being rendered.
* @param array $layout_meta An array of meta for the layouts being rendered.
* @return void
*/
public function __construct( $field, $layout_meta ) {
$this->field = $field;
$this->layout_meta = $layout_meta;
$this->setup();
}
/**
* Prepares the field for rendering.
*
* @since 6.5
*
* @return void
*/
private function setup() {
$layouts = array();
if ( ! empty( $this->field['layouts'] ) ) {
foreach ( $this->field['layouts'] as $layout ) {
$layouts[ $layout['name'] ] = $layout;
}
}
$this->layouts = $layouts;
}
/**
* Renders the Flexible Content field.
*
* @since 6.5
*
* @return void
*/
public function render() {
$div_attrs = array(
'class' => 'acf-flexible-content',
'data-min' => $this->field['min'],
'data-max' => $this->field['max'],
'data-button-label' => $this->field['button_label'],
'data-nonce' => wp_create_nonce( 'acf_field_' . $this->field['type'] . '_' . $this->field['key'] ),
);
if ( empty( $this->field['value'] ) ) {
$div_attrs['class'] .= ' -empty';
}
echo '<div ' . acf_esc_attrs( $div_attrs ) . '>'; // Main wrapper div.
acf_hidden_input( array( 'name' => $this->field['name'] ) );
$this->actions( 'top' );
$this->no_value_message();
$this->clones();
$this->layouts();
$this->actions( 'bottom' );
$this->add_layout_menu();
$this->more_layout_actions();
echo '</div>'; // End main wrapper div.
}
/**
* Renders the no value message.
*
* @since 6.5
*
* @return void
*/
private function no_value_message() {
// translators: %s the button label used for adding a new layout.
$no_value_message = __( 'Click the "%s" button below to start creating your layout', 'acf' );
$no_value_message = apply_filters( 'acf/fields/flexible_content/no_value_message', $no_value_message, $this->field );
$no_value_message = sprintf( $no_value_message, $this->field['button_label'] );
echo '<div class="no-value-message">' . acf_esc_html( $no_value_message ) . '</div>';
}
/**
* Renders the ACF clone indexes for the layouts.
*
* @since 6.5
*
* @return void
*/
private function clones() {
echo '<div class="clones">';
if ( ! empty( $this->layouts ) ) {
foreach ( $this->layouts as $layout ) {
$clone = new Layout( $this->field, $layout, 'acfcloneindex', array() );
$clone->render();
}
}
echo '</div>';
}
/**
* Renders the layouts for a Flexible Content field.
*
* @since 6.5
*
* @return void
*/
private function layouts() {
echo '<div class="values">';
$disabled_layouts = ! empty( $this->layout_meta['disabled'] ) ? $this->layout_meta['disabled'] : array();
$renamed_layouts = ! empty( $this->layout_meta['renamed'] ) ? $this->layout_meta['renamed'] : array();
if ( ! empty( $this->field['value'] ) ) {
foreach ( $this->field['value'] as $order => $value ) {
if ( ! is_array( $value ) ) {
continue;
}
if ( empty( $this->layouts[ $value['acf_fc_layout'] ] ) ) {
continue;
}
$layout = new Layout(
$this->field,
$this->layouts[ $value['acf_fc_layout'] ],
$order,
$value,
in_array( $order, $disabled_layouts, true ),
! empty( $renamed_layouts[ $order ] ) ? $renamed_layouts[ $order ] : '',
);
$layout->render();
}
}
echo '</div>';
}
/**
* Renders top-level actions for the Flexible Content field.
*
* @since 6.5
*
* @param string $which The location of the actions, either 'top' or 'bottom'.
* @return void
*/
private function actions( string $which = '' ) {
if ( 'top' === $which ) {
?>
<div class="acf-actions acf-fc-top-actions">
<button class="acf-btn acf-btn-clear acf-fc-expand-all">
<?php esc_html_e( 'Expand All', 'acf' ); ?>
</button>
<button class="acf-btn acf-btn-clear acf-fc-collapse-all">
<?php esc_html_e( 'Collapse All', 'acf' ); ?>
</button>
<span class="acf-separator"></span>
<a class="acf-button button button-primary" href="#" data-name="add-layout" data-context="top-actions">
<i class="acf-icon -plus small"></i>
<?php echo acf_esc_html( $this->field['button_label'] ); ?>
</a>
</div>
<?php
} else {
?>
<div class="acf-actions">
<a class="acf-button button button-primary" href="#" data-name="add-layout" data-context="bottom-actions">
<i class="acf-icon -plus small"></i>
<?php echo acf_esc_html( $this->field['button_label'] ); ?>
</a>
</div>
<?php
}
}
/**
* Renders the dropdown menu to add more layouts.
*
* @since 6.5
*
* @return void
*/
private function add_layout_menu() {
echo '<script type="text-html" class="tmpl-popup"><ul>';
foreach ( $this->layouts as $layout ) {
$safe_label = acf_esc_html( $layout['label'] );
$atts = array(
'href' => '#',
'data-layout' => $layout['name'],
'data-min' => $layout['min'],
'data-max' => $layout['max'],
'title' => $safe_label,
);
printf( '<li><a %s>%s</a></li>', acf_esc_attrs( $atts ), $safe_label ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
}
echo '</ul></script>';
}
/**
* Renders the dropdown menu for additional layout actions.
*
* @since 6.5
*
* @return void
*/
private function more_layout_actions() {
?>
<script type="text-html" class="tmpl-more-layout-actions">
<ul role="menu" tabindex="-1">
<li>
<a class="acf-rename-layout" data-action="rename-layout" href="#" role="menuitem">
<?php esc_html_e( 'Rename', 'acf' ); ?>
</a>
</li>
<li>
<a class="acf-toggle-layout disable" data-action="toggle-layout" href="#" role="menuitem">
<?php esc_html_e( 'Disable', 'acf' ); ?>
</a>
<a class="acf-toggle-layout enable" data-action="toggle-layout" href="#" role="menuitem">
<?php esc_html_e( 'Enable', 'acf' ); ?>
</a>
</li>
</ul>
</script>
<?php
}
}
@@ -0,0 +1,216 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Forms;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Adds ACF metaboxes to the new WooCommerce order screen.
*/
class WC_Order {
/**
* Constructs the ACF_Form_WC_Order class.
*
* @since 6.4
*/
public function __construct() {
add_action( 'load-woocommerce_page_wc-orders', array( $this, 'initialize' ) );
add_action( 'load-woocommerce_page_wc-orders--shop_subscription', array( $this, 'initialize' ) );
add_action( 'woocommerce_update_order', array( $this, 'save_order' ), 10, 1 );
}
/**
* Enqueues ACF scripts on the WooCommerce order page and
* registers actions specific to that page.
*
* @since 6.4
*
* @return void
*/
public function initialize() {
acf_enqueue_scripts( array( 'uploader' => true ) );
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ), 10, 2 );
}
/**
* Adds ACF metaboxes to the WooCommerce Order pages.
*
* @since 6.4
*
* @param string $post_type The current post type.
* @param \WP_Post $post The WP_Post object or the WC_Order object.
* @return void
*/
public function add_meta_boxes( $post_type, $post ) {
// Storage for localized postboxes.
$postboxes = array();
$location = 'shop_order';
$order = ( $post instanceof \WP_Post ) ? wc_get_order( $post->ID ) : $post;
$screen = $this->is_hpos_enabled() ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order';
if ( $order instanceof \WC_Subscription ) {
$location = 'shop_subscription';
$screen = function_exists( 'wcs_get_page_screen_id' ) ? wcs_get_page_screen_id( 'shop_subscription' ) : 'shop_subscription';
}
// Get field groups for this screen.
$field_groups = acf_get_field_groups(
array(
'post_id' => $order->get_id(),
'post_type' => $location,
)
);
// Loop over field groups.
if ( $field_groups ) {
foreach ( $field_groups as $field_group ) {
$id = "acf-{$field_group['key']}"; // acf-group_123
$context = $field_group['position']; // normal, side, acf_after_title
$priority = 'core'; // high, core, default, low
// Allow field groups assigned to after title to still be rendered.
if ( 'acf_after_title' === $context ) {
$context = 'normal';
}
/**
* Filters the metabox priority.
*
* @since 6.4
*
* @param string $priority The metabox priority (high, core, default, low).
* @param array $field_group The field group array.
*/
$priority = apply_filters( 'acf/input/meta_box_priority', $priority, $field_group );
// Localize data
$postboxes[] = array(
'id' => $id,
'key' => $field_group['key'],
'style' => $field_group['style'],
'label' => $field_group['label_placement'],
'edit' => acf_get_field_group_edit_link( $field_group['ID'] ),
);
// Add the meta box.
add_meta_box(
$id,
acf_esc_html( acf_get_field_group_title( $field_group ) ),
array( $this, 'render_meta_box' ),
$screen,
$context,
$priority,
array( 'field_group' => $field_group )
);
}
// Localize postboxes.
acf_localize_data(
array(
'postboxes' => $postboxes,
)
);
}
// Removes the WordPress core "Custom Fields" meta box.
if ( acf_get_setting( 'remove_wp_meta_box' ) ) {
remove_meta_box( 'order_custom', $screen, 'normal' );
}
// Add hidden input fields.
add_action( 'order_edit_form_top', array( $this, 'order_edit_form_top' ) );
/**
* Fires after metaboxes have been added.
*
* @date 13/12/18
* @since 5.8.0
*
* @param string $post_type The post type.
* @param \WP_Post $post The post being edited.
* @param array $field_groups The field groups added.
*/
do_action( 'acf/add_meta_boxes', $post_type, $post, $field_groups );
}
/**
* Renders hidden fields.
*
* @since 6.4
*
* @param \WC_Order $order The WooCommerce order object.
* @return void
*/
public function order_edit_form_top( $order ) {
// Render post data.
acf_form_data(
array(
'screen' => 'post',
'post_id' => 'woo_order_' . $order->get_id(),
)
);
}
/**
* Renders the ACF metabox HTML.
*
* @since 6.4
*
* @param \WP_Post|\WC_Order $post_or_order Can be a standard \WP_Post object or the \WC_Order object.
* @param array $metabox The add_meta_box() args.
* @return void
*/
public function render_meta_box( $post_or_order, $metabox ) {
$order = ( $post_or_order instanceof \WP_Post ) ? wc_get_order( $post_or_order->ID ) : $post_or_order;
$field_group = $metabox['args']['field_group'];
// Render fields.
$fields = acf_get_fields( $field_group );
acf_render_fields( $fields, 'woo_order_' . $order->get_id(), 'div', $field_group['instruction_placement'] );
}
/**
* Checks if WooCommerce HPOS is enabled.
*
* @since 6.4.2
*
* @return boolean
*/
public function is_hpos_enabled(): bool {
if ( class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && OrderUtil::custom_orders_table_usage_is_enabled() ) {
return true;
}
return false;
}
/**
* Saves ACF fields to the current order.
*
* @since 6.4
*
* @param integer $order_id The order ID.
* @return void
*/
public function save_order( int $order_id ) {
// Bail if not using HPOS to prevent a double-save.
if ( ! $this->is_hpos_enabled() ) {
return;
}
// Remove the action to prevent an infinite loop via $order->save().
remove_action( 'woocommerce_update_order', array( $this, 'save_order' ), 10 );
acf_save_post( 'woo_order_' . $order_id );
}
}
@@ -0,0 +1,239 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Pro\Meta;
use ACF\Meta\MetaLocation;
use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* A class to add support for saving to WooCommerce order meta.
*/
class WooOrder extends MetaLocation {
/**
* The unique slug/name of the meta location.
*
* @var string
*/
public string $location_type = 'woo_order';
/**
* Constructs the location.
*
* @since 6.4
*/
public function __construct() {
add_filter( 'acf/decode_post_id', array( $this, 'decode_woo_order_id' ), 10, 2 );
parent::__construct();
}
/**
* Checks numerical post IDs to see if they belong to a WC order.
*
* @since 6.4
*
* @param array $decoded The decoded post ID props.
* @param integer|string $post_id The original post ID.
* @return array
*/
public function decode_woo_order_id( $decoded, $post_id ) {
// Bail if not a standard numeric post ID.
if ( ! is_numeric( $post_id ) || empty( $decoded['type'] ) || 'post' !== $decoded['type'] ) {
return $decoded;
}
// Bail if HPOS isn't enabled (traditional ACF meta methods work otherwise).
if ( ! method_exists( OrderUtil::class, 'custom_orders_table_usage_is_enabled' ) ||
! OrderUtil::custom_orders_table_usage_is_enabled() ) {
return $decoded;
}
if ( 'shop_order_placehold' === get_post_type( $post_id ) ) {
$decoded['type'] = 'woo_order';
}
return $decoded;
}
/**
* Retrieves all ACF meta for the provided object ID.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get meta from.
* @return array
*/
public function get_meta( $object_id = 0 ): array {
$meta = array();
$order = wc_get_order( $object_id );
if ( ! $order ) {
return $meta;
}
$all_meta = $order->get_meta_data();
$field_names = wp_list_pluck( $all_meta, 'key' );
$field_values = wp_list_pluck( $all_meta, 'value' );
foreach ( $field_names as $key => $field_name ) {
$reference = $this->reference_prefix . $field_name;
$reference_key = array_search( $reference, $field_names, true );
if ( false !== $reference_key ) {
$meta[ $field_name ] = $field_values[ $key ];
$meta[ $reference ] = $field_values[ $reference_key ];
}
}
// Unserialize results and return.
return array_map( 'acf_maybe_unserialize', $meta );
}
/**
* Retrieves a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return mixed
*/
public function get_value( $object_id = 0, array $field = array() ) {
$order = wc_get_order( $object_id );
if ( ! $order || ! $order->meta_exists( $field['name'] ) ) {
return null;
}
return $order->get_meta( $field['name'], true, 'edit' );
}
/**
* Gets a reference key for the provided field name.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object to get the reference key from.
* @param string $field_name The name of the field to get the reference for.
* @return string|null
*/
public function get_reference( $object_id = 0, $field_name = '' ) {
$order = wc_get_order( $object_id );
$key = $this->reference_prefix . $field_name;
if ( ! $order || ! $order->meta_exists( $key ) ) {
return null;
}
return $order->get_meta( $key, true, 'edit' );
}
/**
* Updates an object ID with the provided meta array.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $meta The metadata to save to the object.
* @return void
*/
public function update_meta( $object_id = 0, array $meta = array() ) {
$order = wc_get_order( $object_id );
if ( ! $order ) {
return;
}
foreach ( $meta as $name => $value ) {
$value = wp_unslash( $value );
$order->update_meta_data( $name, $value );
}
$order->save();
}
/**
* Updates a field value in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @param mixed $value The metadata value.
* @return integer|boolean
*/
public function update_value( $object_id = 0, array $field = array(), $value = '' ) {
$order = wc_get_order( $object_id );
if ( ! $order ) {
return false;
}
$value = wp_unslash( $value );
$order->update_meta_data( $this->reference_prefix . $field['name'], $field['key'] );
$order->update_meta_data( $field['name'], $value );
return $order->save();
}
/**
* Updates a reference key in the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to update the reference for.
* @param string $value The value of the reference key.
* @return integer|boolean
*/
public function update_reference( $object_id = 0, string $field_name = '', string $value = '' ) {
// Updated in update_value().
return true;
}
/**
* Deletes a field value from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param array $field The field array.
* @return boolean
*/
public function delete_value( $object_id = 0, array $field = array() ): bool {
$order = wc_get_order( $object_id );
if ( ! $order ) {
return false;
}
$order->delete_meta_data( $this->reference_prefix . $field['name'] );
$order->delete_meta_data( $field['name'] );
return $order->save();
}
/**
* Deletes a reference key from the database.
*
* @since 6.4
*
* @param integer|string $object_id The ID of the object the metadata is for.
* @param string $field_name The name of the field to delete the reference from.
* @return boolean
*/
public function delete_reference( $object_id = 0, string $field_name = '' ): bool {
// Deleted in delete_value().
return true;
}
}
@@ -0,0 +1,217 @@
<?php
/**
* @package ACF
* @author WP Engine
*
* © 2026 Advanced Custom Fields (ACF®). All rights reserved.
* "ACF" is a trademark of WP Engine.
* Licensed under the GNU General Public License v2 or later.
* https://www.gnu.org/licenses/gpl-2.0.html
*/
namespace ACF\Site_Health;
use WP_Error;
use WP_REST_Request;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
/**
* AI Usage
*
* Logs information about ACF AI/Abilities usage for the ACF Site Health report.
* Measures opt-in, potential (AI-ready objects), discovery (browsing), and utility (execution).
*/
class AI_Usage {
/**
* An instance of the ACF Site_Health class.
*
* @var Site_Health
*/
private Site_Health $site_health;
/**
* Constructs the class.
*
* @since 6.8.0
*
* @param Site_Health $site_health An instance of Site_Health.
* @return void
*/
public function __construct( Site_Health $site_health ) {
$this->site_health = $site_health;
add_action( 'init', array( $this, 'init' ) );
}
/**
* Initializes the class on init if the ACF Abilities API is available.
*
* @since 6.8.0
*
* @return void
*/
public function init() {
// Only hook if AI is enabled and Abilities API exists.
if ( ! $this->is_acf_abilities_api_available() ) {
return;
}
// Execution logging - hook after ability execution.
add_action( 'wp_after_execute_ability', array( $this, 'log_execution' ), 10, 3 );
}
/**
* Checks if ACF AI and the Abilities API are enabled.
*
* @since 6.8.0
*
* @return boolean
*/
private function is_acf_abilities_api_available(): bool {
return acf_get_setting( 'enable_acf_ai' ) && function_exists( 'wp_register_ability' );
}
/**
* Log execution events (when agents execute ACF abilities).
*
* Hooks into wp_after_execute_ability to log successful ability executions.
*
* @since 6.8.0
*
* @param string $ability_name The namespaced ability name.
* @param mixed $input The input data passed to the ability.
* @param mixed $result The result returned by the ability.
* @return void
*/
public function log_execution( string $ability_name, $input, $result ) {
// Only log ACF abilities.
if ( strpos( $ability_name, 'acf/' ) !== 0 ) {
return;
}
$is_error = $result instanceof WP_Error;
$this->increment_execution_count( $ability_name, $is_error );
}
/**
* Increment execution counts.
*
* @since 6.8.0
*
* @param string $ability_name The ability that was executed.
* @param boolean $is_error Whether the execution resulted in an error.
* @return boolean Success status.
*/
private function increment_execution_count( string $ability_name, bool $is_error ): bool {
$data = $this->site_health->get_site_health();
if ( ! isset( $data['ai_usage'] ) ) {
$data['ai_usage'] = $this->get_default_usage_structure();
}
// Increment total executions.
++$data['ai_usage']['total_executions'];
$data['ai_usage']['last_execution_at'] = time();
// Increment per-ability count.
if ( ! isset( $data['ai_usage']['executions_by_ability'][ $ability_name ] ) ) {
$data['ai_usage']['executions_by_ability'][ $ability_name ] = 0;
}
++$data['ai_usage']['executions_by_ability'][ $ability_name ];
if ( $is_error ) {
++$data['ai_usage']['error_count'];
}
return $this->site_health->update_site_health( $data );
}
/**
* Get the default log data structure.
*
* @since 6.8.0
*
* @return array
*/
private function get_default_usage_structure(): array {
return array(
'total_executions' => 0,
'error_count' => 0,
'last_execution_at' => null,
'executions_by_ability' => array(),
);
}
/**
* Get AI-ready object counts for Site Health display.
*
* @since 6.8.0
*
* @param array $field_groups An array of ACF field groups.
* @param array $post_types An array of ACF post types.
* @param array $taxonomies An array of ACF taxonomies.
* @return array Counts of AI-ready objects by type.
*/
public function get_ai_ready_counts( $field_groups, $post_types, $taxonomies ): array {
$counts = array(
'field_groups' => 0,
'post_types' => 0,
'taxonomies' => 0,
);
// Count AI-ready field groups.
foreach ( $field_groups as $field_group ) {
if ( ! empty( $field_group['allow_ai_access'] ) && ! empty( $field_group['active'] ) ) {
++$counts['field_groups'];
}
}
// Count AI-ready post types.
foreach ( $post_types as $post_type ) {
if ( ! empty( $post_type['allow_ai_access'] ) && ! empty( $post_type['active'] ) ) {
++$counts['post_types'];
}
}
// Count AI-ready taxonomies.
foreach ( $taxonomies as $taxonomy ) {
if ( ! empty( $taxonomy['allow_ai_access'] ) && ! empty( $taxonomy['active'] ) ) {
++$counts['taxonomies'];
}
}
return $counts;
}
/**
* Get the usage metrics for Site Health display.
*
* @since 6.8.0
*
* @return array The usage metrics.
*/
public function get_usage_metrics(): array {
$site_health = $this->site_health->get_site_health();
if ( ! isset( $site_health['ai_usage'] ) ) {
return array(
'total_discovery_hits' => 0,
'total_executions' => 0,
'error_count' => 0,
);
}
$usage = $site_health['ai_usage'];
return array(
'total_discovery_hits' => $usage['total_discovery_hits'] ?? 0,
'total_executions' => $usage['total_executions'] ?? 0,
'error_count' => $usage['error_count'] ?? 0,
'executions_by_ability' => $usage['executions_by_ability'] ?? array(),
);
}
}
File diff suppressed because it is too large Load Diff