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',
);