Outputs
2 months ago
data
2 months ago
FieldSettings.php
2 months ago
GEO.php
2 months ago
Schema.php
2 months ago
SchemaData.php
2 months ago
GEO.php
423 lines
| 1 | <?php |
| 2 | /** |
| 3 | * ACF 6.8.0 feature port. |
| 4 | * |
| 5 | * @package wordpress/secure-custom-fields |
| 6 | */ |
| 7 | |
| 8 | // phpcs:disable -- Upstream ACF 6.8.0 feature-port files are kept close to source. |
| 9 | |
| 10 | namespace SCF\AI\GEO; |
| 11 | |
| 12 | // Exit if accessed directly. |
| 13 | defined( 'ABSPATH' ) || exit; |
| 14 | |
| 15 | /** |
| 16 | * SCF GEO Extension |
| 17 | * |
| 18 | * Extends ACF admin interface to add AI-related settings and functionality. |
| 19 | */ |
| 20 | class GEO { |
| 21 | |
| 22 | /** |
| 23 | * Constructs the GEO class. |
| 24 | * |
| 25 | * @since 6.8.0 |
| 26 | * |
| 27 | * @return void |
| 28 | */ |
| 29 | public function __construct() { |
| 30 | $this->init(); |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * Initialize the GEO extension, |
| 35 | * |
| 36 | * @since 6.8.0 |
| 37 | * |
| 38 | * @return void |
| 39 | */ |
| 40 | public function init() { |
| 41 | // Add hooks for ACF admin interface extensions for post types. |
| 42 | add_filter( 'acf/post_type/additional_settings_tabs', array( $this, 'add_schema_tab' ) ); |
| 43 | add_action( 'acf/post_type/render_settings_tab/schema', array( $this, 'render_post_type_schema_tab' ) ); |
| 44 | |
| 45 | // Initialize GEO submodules. |
| 46 | // Field Settings. |
| 47 | new FieldSettings(); |
| 48 | |
| 49 | // JSON-LD Outputs. |
| 50 | new Outputs\Posts(); |
| 51 | // Note: Blocks output is initialized separately in secure-custom-fields.php. |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Adds the "Schema" settings tab for post types. |
| 56 | * |
| 57 | * @since 6.8.0 |
| 58 | * |
| 59 | * @param array $tabs An array of the existing tabs. |
| 60 | * @return array |
| 61 | */ |
| 62 | public function add_schema_tab( $tabs ) { |
| 63 | $tabs['schema'] = __( 'Schema', 'secure-custom-fields' ); |
| 64 | return $tabs; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Render "Schema" tab content for post types |
| 69 | * |
| 70 | * @since 6.8.0 |
| 71 | * |
| 72 | * @param array $acf_post_type The ACF post type data. |
| 73 | */ |
| 74 | public function render_post_type_schema_tab( $acf_post_type ) { |
| 75 | ?> |
| 76 | <span class="acf-experimental-badge acf-field"><?php esc_html_e( 'Experimental', 'secure-custom-fields' ); ?></span> |
| 77 | <?php |
| 78 | // Add post-type-specific field: auto JSON-LD. |
| 79 | acf_render_field_wrap( |
| 80 | array( |
| 81 | 'type' => 'true_false', |
| 82 | 'name' => 'auto_jsonld', |
| 83 | 'key' => 'auto_jsonld', |
| 84 | 'prefix' => 'acf_post_type', |
| 85 | 'value' => $acf_post_type['auto_jsonld'] ?? 0, |
| 86 | 'label' => __( 'Automatically add JSON-LD data for fields on this post type', 'secure-custom-fields' ), |
| 87 | '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.', 'secure-custom-fields' ), |
| 88 | 'ui' => true, |
| 89 | 'default' => 0, |
| 90 | ) |
| 91 | ); |
| 92 | |
| 93 | // Add post-type-specific field: schema type. |
| 94 | acf_render_field_wrap( |
| 95 | array( |
| 96 | 'type' => 'select', |
| 97 | 'name' => 'schema_type', |
| 98 | 'key' => 'schema_type', |
| 99 | 'prefix' => 'acf_post_type', |
| 100 | 'value' => $acf_post_type['schema_type'] ?? '', |
| 101 | 'label' => __( 'Schema.org Type', 'secure-custom-fields' ), |
| 102 | '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).', 'secure-custom-fields' ), |
| 103 | 'choices' => $this->get_schema_types(), |
| 104 | 'default' => '', |
| 105 | 'allow_null' => 1, |
| 106 | 'multiple' => 1, |
| 107 | 'ui' => 1, |
| 108 | ), |
| 109 | 'div', |
| 110 | 'field' |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Get available Schema.org types for selection |
| 116 | * |
| 117 | * @since 6.8.0 |
| 118 | * |
| 119 | * @return array A hierarchical array of Schema.org types grouped by category. |
| 120 | */ |
| 121 | public function get_schema_types() { |
| 122 | $types = array(); |
| 123 | |
| 124 | // Get all types from schema hierarchy. |
| 125 | $all_types = array_keys( SchemaData::get_type_hierarchy() ); |
| 126 | // Add 'Thing' which is the root and doesn't have a parent. |
| 127 | $all_types[] = 'Thing'; |
| 128 | sort( $all_types ); |
| 129 | |
| 130 | // Get priority types from Schema class. |
| 131 | $priority_types_list = Schema::get_priority_types(); |
| 132 | $common_types_cat = __( 'Common Types', 'secure-custom-fields' ); |
| 133 | $all_types_cat = __( 'All Types', 'secure-custom-fields' ); |
| 134 | |
| 135 | // Add priority types under "Common Types" group. |
| 136 | $types[ $common_types_cat ] = array(); |
| 137 | foreach ( $priority_types_list as $type ) { |
| 138 | if ( in_array( $type, $all_types, true ) ) { |
| 139 | $types[ $common_types_cat ][ $type ] = $type; |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // Add remaining types under "All Types". |
| 144 | $remaining_types = array_diff( $all_types, $priority_types_list ); |
| 145 | if ( ! empty( $remaining_types ) ) { |
| 146 | $types[ $all_types_cat ] = array(); |
| 147 | foreach ( $remaining_types as $type ) { |
| 148 | $types[ $all_types_cat ][ $type ] = $type; |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Filter the available Schema.org types |
| 154 | * |
| 155 | * Allows developers to add custom Schema.org types or modify existing ones. |
| 156 | * |
| 157 | * @param array $types The Schema.org type mappings grouped by category. |
| 158 | */ |
| 159 | return apply_filters( 'acf/schema/schema_types', $types ); |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Process ACF fields and map them to Schema.org structure |
| 164 | * |
| 165 | * Takes an array of field objects and processes them based on their schema_property setting. |
| 166 | * Fields with a schema_property are mapped to core Schema.org properties. Properties that |
| 167 | * expect objects (like 'author' or 'publisher') automatically get proper "@type" added. |
| 168 | * Fields without a schema_property are skipped. |
| 169 | * |
| 170 | * @since 6.8.0 |
| 171 | * |
| 172 | * @param array $field_objects Array of ACF field objects with values. |
| 173 | * @return array Processed data with core properties, with 'field_types' key containing types from qualified properties. |
| 174 | */ |
| 175 | public static function process_fields( $field_objects ) { |
| 176 | $data = array(); |
| 177 | $field_types = array(); |
| 178 | |
| 179 | foreach ( $field_objects as $field_name => $field_object ) { |
| 180 | // Skip empty values. |
| 181 | if ( null === $field_object['value'] || '' === $field_object['value'] ) { |
| 182 | continue; |
| 183 | } |
| 184 | |
| 185 | // Check if this field has a schema property mapping. |
| 186 | $schema_property = $field_object['schema_property'] ?? ''; |
| 187 | |
| 188 | if ( ! empty( $schema_property ) ) { |
| 189 | // Parse qualified property (e.g., "Offer.price" -> type: "Offer", property: "price"). |
| 190 | $parsed = Schema::parse_qualified_property( $schema_property ); |
| 191 | $property_name = $parsed['property']; |
| 192 | |
| 193 | // Collect field types from qualified properties. |
| 194 | if ( $parsed['type'] ) { |
| 195 | $field_types[] = $parsed['type']; |
| 196 | } |
| 197 | |
| 198 | $formatted_value = self::format_field_value_for_jsonld( $field_object['value'], $field_object ); |
| 199 | |
| 200 | // Field has a schema property - map to core property using just the property name. |
| 201 | $data[ $property_name ] = $formatted_value; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // Add @type to nested objects based on schema.org ranges. |
| 206 | $data = self::add_types_to_nested_objects( $data ); |
| 207 | |
| 208 | // Include field types from qualified properties. |
| 209 | if ( ! empty( $field_types ) ) { |
| 210 | $data['field_types'] = array_values( array_unique( $field_types ) ); |
| 211 | } |
| 212 | |
| 213 | return $data; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Determine the final "@type" value for JSON-LD output |
| 218 | * |
| 219 | * Merges provided types (from post type/block settings) with field types |
| 220 | * (from qualified properties like "Recipe.prepTime"). Falls back to the |
| 221 | * default type if neither source provides any types. |
| 222 | * |
| 223 | * @since 6.8.0 |
| 224 | * |
| 225 | * @param string|array|null $provided_types Types explicitly set in settings (can be string, array, or null). |
| 226 | * @param array $field_types Types extracted from qualified properties. |
| 227 | * @param string $default_type Fallback type if no types provided. |
| 228 | * @return string|array Final @type value (string for single type, array for multiple). |
| 229 | */ |
| 230 | public static function determine_schema_type( $provided_types, $field_types, $default_type = 'Thing' ) { |
| 231 | $types = array(); |
| 232 | |
| 233 | // Add provided types from post type/block settings. |
| 234 | if ( ! empty( $provided_types ) ) { |
| 235 | $types = is_array( $provided_types ) ? $provided_types : array( $provided_types ); |
| 236 | } |
| 237 | |
| 238 | // Merge in field types from qualified properties. |
| 239 | if ( ! empty( $field_types ) && is_array( $field_types ) ) { |
| 240 | $types = array_merge( $types, $field_types ); |
| 241 | } |
| 242 | |
| 243 | $types = array_values( array_unique( $types ) ); |
| 244 | |
| 245 | if ( empty( $types ) ) { |
| 246 | return $default_type; |
| 247 | } |
| 248 | |
| 249 | return count( $types ) === 1 ? $types[0] : $types; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Add "@type" to nested objects based on schema.org property ranges |
| 254 | * |
| 255 | * Examines each property in the data and if it expects an object type |
| 256 | * (like Person, Organization, etc.), automatically adds the appropriate @type. |
| 257 | * |
| 258 | * For example, if 'author' contains { 'name': 'John' }, it becomes: |
| 259 | * { '@type': 'Person', 'name': 'John' } |
| 260 | * |
| 261 | * @since 6.8.0 |
| 262 | * |
| 263 | * @param array $data The data array to process. |
| 264 | * @return array The data with @type added to nested objects. |
| 265 | */ |
| 266 | private static function add_types_to_nested_objects( $data ) { |
| 267 | foreach ( $data as $property => $value ) { |
| 268 | // Skip if value is not an array (can't be a nested object). |
| 269 | if ( ! is_array( $value ) ) { |
| 270 | continue; |
| 271 | } |
| 272 | |
| 273 | // Skip if already has @type. |
| 274 | if ( isset( $value['@type'] ) ) { |
| 275 | continue; |
| 276 | } |
| 277 | |
| 278 | // Check if this is a sequential array (list) vs associative array (object). |
| 279 | // Sequential arrays are for properties that accept multiple values. |
| 280 | // We only add @type to associative arrays (objects). |
| 281 | $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); |
| 282 | |
| 283 | if ( $is_list ) { |
| 284 | // This is a list/array, not a single object. Skip adding @type. |
| 285 | continue; |
| 286 | } |
| 287 | |
| 288 | // Check if this property expects an object type. |
| 289 | if ( Schema::property_expects_object( $property ) ) { |
| 290 | // Get the preferred object type for this property. |
| 291 | $object_type = Schema::get_preferred_object_type( $property ); |
| 292 | |
| 293 | if ( $object_type ) { |
| 294 | // Add @type at the beginning of the array. |
| 295 | $data[ $property ] = array_merge( |
| 296 | array( '@type' => $object_type ), |
| 297 | $value |
| 298 | ); |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | return $data; |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Render a JSON-LD script tag with the provided data |
| 308 | * |
| 309 | * Shared helper method for outputting JSON-LD structured data. |
| 310 | * |
| 311 | * @since 6.8.0 |
| 312 | * |
| 313 | * @param array $jsonld_data The JSON-LD data array to output. |
| 314 | */ |
| 315 | public static function render_jsonld_script( $jsonld_data ) { |
| 316 | if ( empty( $jsonld_data ) ) { |
| 317 | return; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Action fired before rendering JSON-LD script tag |
| 322 | * |
| 323 | * Allows developers to output custom schemas or capture the data. |
| 324 | * |
| 325 | * @param array $jsonld_data The JSON-LD data array. |
| 326 | */ |
| 327 | do_action( 'acf/schema/render_script', $jsonld_data ); |
| 328 | |
| 329 | /** |
| 330 | * Filter to disable ACF's default JSON-LD output |
| 331 | * |
| 332 | * Return true to prevent ACF from outputting the JSON-LD script tag. |
| 333 | * Useful if you want to handle the output yourself via the action above. |
| 334 | * |
| 335 | * @param bool $disable Whether to disable default output. Default false. |
| 336 | * @param array $jsonld_data The JSON-LD data that would be output. |
| 337 | */ |
| 338 | $disable_output = apply_filters( 'acf/schema/disable_output', false, $jsonld_data ); |
| 339 | |
| 340 | if ( $disable_output ) { |
| 341 | return; |
| 342 | } |
| 343 | |
| 344 | // Output the JSON-LD script tag. |
| 345 | echo "<script type=\"application/ld+json\">\n"; |
| 346 | echo wp_json_encode( $jsonld_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); |
| 347 | echo "\n</script>\n"; |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Format ACF field value for JSON-LD output |
| 352 | * |
| 353 | * Shared helper method for formatting field values consistently. |
| 354 | * Checks for field-type-specific formatting methods in this order: |
| 355 | * 1. Pre-filter to allow complete bypass of formatting logic |
| 356 | * 2. format_value_for_jsonld() - custom method for JSON-LD formatting (if field type implements it) |
| 357 | * 3. Field-type-specific formatting, defaulting to format_value_for_rest() for most types |
| 358 | * 4. Post-filter on the final formatted value |
| 359 | * |
| 360 | * @since 6.8.0 |
| 361 | * |
| 362 | * @param mixed $value The field value. |
| 363 | * @param array $field_object The ACF field object. |
| 364 | * @return mixed Formatted value. |
| 365 | */ |
| 366 | public static function format_field_value_for_jsonld( $value, $field_object ) { |
| 367 | $field_type_name = $field_object['type'] ?? ''; |
| 368 | $field_name = $field_object['name'] ?? ''; |
| 369 | |
| 370 | /** |
| 371 | * Filter to bypass the default formatting logic entirely |
| 372 | * |
| 373 | * Return a non-null value to bypass all default formatting. |
| 374 | * This runs before any other formatting logic. |
| 375 | * |
| 376 | * @param mixed|null $pre_value Return non-null to bypass default formatting. |
| 377 | * @param mixed $value The raw field value. |
| 378 | * @param array $field_object The ACF field object. |
| 379 | * @param string $field_type_name The field type name. |
| 380 | */ |
| 381 | $pre_value = apply_filters( 'acf/schema/format_value/pre', null, $value, $field_object, $field_type_name ); |
| 382 | $pre_value = apply_filters( "acf/schema/format_value/pre/type={$field_type_name}", $pre_value, $value, $field_object ); |
| 383 | $pre_value = apply_filters( "acf/schema/format_value/pre/name={$field_name}", $pre_value, $value, $field_object ); |
| 384 | |
| 385 | if ( null !== $pre_value ) { |
| 386 | return $pre_value; |
| 387 | } |
| 388 | |
| 389 | // Get the field type class instance. |
| 390 | $field_type = acf_get_field_type( $field_type_name ); |
| 391 | |
| 392 | // First priority: Check if field type has a custom format_value_for_jsonld method. |
| 393 | if ( $field_type && method_exists( $field_type, 'format_value_for_jsonld' ) ) { |
| 394 | $formatted_value = $field_type->format_value_for_jsonld( $value, null, $field_object ); |
| 395 | } else { |
| 396 | // Second priority: Use format_value_for_rest or return as-is. |
| 397 | if ( $field_type && method_exists( $field_type, 'format_value_for_rest' ) ) { |
| 398 | $formatted_value = $field_type->format_value_for_rest( $value, null, $field_object ); |
| 399 | } else { |
| 400 | // Final fallback: return value as-is. |
| 401 | // Arrays are valid JSON-LD (e.g., multi-select values). |
| 402 | $formatted_value = $value; |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Filter the formatted value before returning |
| 408 | * |
| 409 | * Allows modification of the value after all default formatting has been applied. |
| 410 | * |
| 411 | * @param mixed $formatted_value The formatted value. |
| 412 | * @param mixed $value The raw field value. |
| 413 | * @param array $field_object The ACF field object. |
| 414 | * @param string $field_type_name The field type name. |
| 415 | */ |
| 416 | $formatted_value = apply_filters( 'acf/schema/format_value', $formatted_value, $value, $field_object, $field_type_name ); |
| 417 | $formatted_value = apply_filters( "acf/schema/format_value/type={$field_type_name}", $formatted_value, $value, $field_object ); |
| 418 | $formatted_value = apply_filters( "acf/schema/format_value/name={$field_name}", $formatted_value, $value, $field_object ); |
| 419 | |
| 420 | return $formatted_value; |
| 421 | } |
| 422 | } |
| 423 |