PluginProbe ʕ •ᴥ•ʔ
Advanced Custom Fields (ACF®) / 6.8.5
Advanced Custom Fields (ACF®) v6.8.5
6.8.9 6.8.8 6.8.7 6.8.6 6.8.5 6.8.4 6.8.3 6.8.2 6.8.1 5.8.5 5.8.6 5.8.7 5.8.8 5.8.9 5.9.0 5.9.1 5.9.2 5.9.3 5.9.4 5.9.5 5.9.6 5.9.7 5.9.8 5.9.9 6.0.0 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.1.0 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.2.0 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.2.6 6.2.6.1 6.2.7 6.2.8 6.2.9 6.3.0 6.3.1 6.3.10.2 6.3.11 6.3.12 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.6.1 6.4.0 6.4.0.1 6.4.1 6.4.2 6.4.3 6.5.0 6.5.1 6.6.0 6.6.1 6.6.2 6.7.0 6.7.1 6.7.2 6.8.0 trunk 1.0.0 1.0.2 1.0.3 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.1.1 2.1.3 2.1.4 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.6 3.0.7 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.5 3.5.6 3.5.7 3.5.8 4.0.0 4.0.1 4.0.2 4.0.3 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.8 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.3.7 4.3.8 4.3.9 4.4.0 4.4.1 4.4.10 4.4.11 4.4.12 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 5.10 5.10.1 5.10.2 5.11 5.11.1 5.11.2 5.11.3 5.11.4 5.12 5.12.1 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.6.10 5.6.2 5.6.3 5.6.4 5.6.5 5.6.6 5.6.7 5.6.8 5.6.9 5.7.0 5.7.1 5.7.10 5.7.12 5.7.13 5.7.2 5.7.3 5.7.4 5.7.5 5.7.6 5.7.7 5.7.8 5.7.9 5.8.0 5.8.1 5.8.10 5.8.11 5.8.12 5.8.13 5.8.14 5.8.2 5.8.3 5.8.4
advanced-custom-fields / src / AI / GEO / Schema.php
advanced-custom-fields / src / AI / GEO Last commit date
Outputs 5 months ago data 5 months ago FieldSettings.php 5 months ago GEO.php 5 months ago Schema.php 5 months ago SchemaData.php 5 months ago
Schema.php
701 lines
1 <?php
2 /**
3 * @package ACF
4 * @author WP Engine
5 *
6 * © 2026 Advanced Custom Fields (ACF®). All rights reserved.
7 * "ACF" is a trademark of WP Engine.
8 * Licensed under the GNU General Public License v2 or later.
9 * https://www.gnu.org/licenses/gpl-2.0.html
10 */
11
12 namespace ACF\AI\GEO;
13
14 /**
15 * Class Schema
16 *
17 * Provides utilities for working with schema.org types and properties
18 * using pre-generated schema data from SchemaData.php.
19 */
20 class Schema {
21
22 /**
23 * Primitive types that don't require a "@type" in JSON-LD output
24 *
25 * @var array
26 */
27 private static array $primitive_types = array(
28 'Text',
29 'Number',
30 'Integer',
31 'Float',
32 'Boolean',
33 'Date',
34 'DateTime',
35 'Time',
36 'URL',
37 'CssSelectorType',
38 'PronounceableText',
39 'XPathType',
40 );
41
42 /**
43 * Schema.org types that are modeled as objects but are practically text values
44 *
45 * These types don't have meaningful sub-properties and are typically
46 * represented as formatted strings (e.g., "PT30M" for Duration).
47 * Text fields should be able to match properties expecting these types.
48 *
49 * @var array
50 */
51 private static array $text_value_types = array(
52 'Duration',
53 'Distance',
54 'Energy',
55 'Mass',
56 );
57
58 /**
59 * Cache for properties grouped by type
60 *
61 * @var array|null
62 */
63 private static $properties_by_type_cache = null;
64
65 /**
66 * Get priority schema types for common use cases
67 *
68 * Returns an array of commonly used Schema.org types that should be
69 * displayed first in selection dropdowns. When a context ID (field group ID)
70 * is provided, schema types from associated post types and blocks are
71 * prepended to the list.
72 *
73 * @since 6.8.0
74 *
75 * @param integer $context_id Optional field group ID to get context-aware priority types.
76 * @return array Array of priority type names.
77 */
78 public static function get_priority_types( int $context_id = 0 ): array {
79 $priority_types = array(
80 'Thing',
81 'Article',
82 'BlogPosting',
83 'NewsArticle',
84 'Recipe',
85 'Product',
86 'Event',
87 'HowTo',
88 'FAQPage',
89 'Person',
90 'Organization',
91 'LocalBusiness',
92 'Place',
93 'WebPage',
94 );
95
96 // Prepend context-specific schema types if a field group context is provided.
97 if ( $context_id ) {
98 $context_types = self::get_schema_types_from_field_group( $context_id );
99 if ( ! empty( $context_types ) ) {
100 $priority_types = array_unique( array_merge( $context_types, $priority_types ) );
101 }
102 }
103
104 /**
105 * Filter the priority Schema.org types
106 *
107 * Allows developers to customize which types appear first in selection lists.
108 *
109 * @param array $priority_types Array of priority type names.
110 * @param integer $context_id The field group ID providing context, or 0.
111 */
112 return apply_filters( 'acf/schema/schema_priority_types', $priority_types, $context_id );
113 }
114
115 /**
116 * Get schema types configured on post types or blocks associated with a field group.
117 *
118 * Extracts schema_type values from ACF post types and blocks that are
119 * referenced in the field group's location rules.
120 *
121 * @since 6.8.0
122 *
123 * @param integer $field_group_id The field group ID.
124 * @return array Array of unique schema type names.
125 */
126 private static function get_schema_types_from_field_group( int $field_group_id ): array {
127 $field_group = acf_get_field_group( $field_group_id );
128
129 if ( empty( $field_group['location'] ) ) {
130 return array();
131 }
132
133 $schema_types = array();
134 $acf_post_types = null; // Lazy load.
135
136 foreach ( $field_group['location'] as $group ) {
137 foreach ( $group as $rule ) {
138 if ( $rule['operator'] !== '==' ) {
139 continue;
140 }
141
142 // Handle post type location rules.
143 if ( $rule['param'] === 'post_type' ) {
144 // Lazy load ACF post types.
145 if ( $acf_post_types === null ) {
146 $acf_post_types = acf_get_acf_post_types();
147 }
148
149 foreach ( $acf_post_types as $acf_post_type ) {
150 if ( isset( $acf_post_type['post_type'] )
151 && $acf_post_type['post_type'] === $rule['value']
152 && ! empty( $acf_post_type['schema_type'] ) ) {
153 $types = (array) $acf_post_type['schema_type'];
154 $schema_types = array_merge( $schema_types, $types );
155 }
156 }
157 }
158
159 // Handle block location rules.
160 if ( $rule['param'] === 'block' && $rule['value'] !== 'all' ) {
161 $block_type = acf_get_block_type( $rule['value'] );
162 if ( $block_type && ! empty( $block_type['schema_type'] ) ) {
163 $types = (array) $block_type['schema_type'];
164 $schema_types = array_merge( $schema_types, $types );
165 }
166 }
167 }
168 }
169
170 return array_unique( $schema_types );
171 }
172
173 /**
174 * Get all parent types for a given type
175 *
176 * @since 6.8.0
177 *
178 * @param string $type The schema.org type name
179 * @return array Array of parent type names in order from direct parent to root
180 */
181 private static function get_type_parents( $type ) {
182 $parents = array();
183 $current = $type;
184
185 $type_hierarchy = SchemaData::get_type_hierarchy();
186 while ( isset( $type_hierarchy[ $current ] ) ) {
187 $parent = $type_hierarchy[ $current ];
188 $parents[] = $parent;
189 $current = $parent;
190 }
191
192 return $parents;
193 }
194
195 /**
196 * Check if type_a is a parent/ancestor of type_b
197 *
198 * @since 6.8.0
199 *
200 * @param string $type_a The potential parent type
201 * @param string $type_b The child type to check
202 * @return boolean True if type_a is an ancestor of type_b
203 */
204 private static function is_parent_of( $type_a, $type_b ) {
205 $parents = self::get_type_parents( $type_b );
206 return in_array( $type_a, $parents, true );
207 }
208
209 /**
210 * Infer the minimal set of types needed for a set of properties
211 *
212 * Given a list of properties, returns the most general types that
213 * directly define those properties, avoiding redundant child types.
214 *
215 * For example:
216 * - ['prepTime', 'cookTime'] -> ['Recipe'] (most specific type with those properties)
217 * - ['headline'] -> ['CreativeWork'] (the base type that defines headline)
218 *
219 * @since 6.8.0
220 *
221 * @param array $properties Array of property names
222 * @return array Array of type names
223 */
224 public static function infer_types_from_properties( $properties ) {
225 if ( empty( $properties ) ) {
226 return array();
227 }
228
229 // For each property, collect the types that directly define it
230 $types_per_property = array();
231 $property_domains = SchemaData::get_property_domains();
232
233 foreach ( $properties as $property ) {
234 if ( ! isset( $property_domains[ $property ] ) ) {
235 continue;
236 }
237
238 // These are the types that directly define this property
239 $types_per_property[ $property ] = $property_domains[ $property ];
240 }
241
242 if ( empty( $types_per_property ) ) {
243 return array();
244 }
245
246 // If we only have one property, return its direct types
247 if ( count( $types_per_property ) === 1 ) {
248 return array_values( reset( $types_per_property ) );
249 }
250
251 // Find types that can cover all properties (directly or through inheritance)
252 $all_types = array_unique( array_merge( ...array_values( $types_per_property ) ) );
253 $valid_types = array();
254
255 foreach ( $all_types as $type ) {
256 $type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
257 $covers_all = true;
258
259 foreach ( $types_per_property as $property => $defining_types ) {
260 // Check if this type or any of its parents define this property
261 if ( empty( array_intersect( $type_chain, $defining_types ) ) ) {
262 $covers_all = false;
263 break;
264 }
265 }
266
267 if ( $covers_all ) {
268 $valid_types[] = $type;
269 }
270 }
271
272 // If we found types that cover everything, remove redundant parents
273 if ( ! empty( $valid_types ) ) {
274 $minimal_types = array();
275 foreach ( $valid_types as $type ) {
276 $is_redundant = false;
277 foreach ( $valid_types as $other_type ) {
278 if ( $type !== $other_type && self::is_parent_of( $type, $other_type ) ) {
279 // This type is a parent of another type in the list, so it's redundant
280 $is_redundant = true;
281 break;
282 }
283 }
284 if ( ! $is_redundant ) {
285 $minimal_types[] = $type;
286 }
287 }
288 return array_values( $minimal_types );
289 }
290
291 // No single type covers all properties, need multiple types
292 return self::find_minimal_type_set( $properties );
293 }
294
295 /**
296 * Find minimal set of types to cover all properties
297 *
298 * Uses a greedy algorithm to find the smallest set of types that
299 * collectively support all given properties.
300 *
301 * @since 6.8.0
302 *
303 * @param array $properties Array of property names
304 * @return array Array of type names
305 */
306 private static function find_minimal_type_set( $properties ) {
307 $uncovered_properties = $properties;
308 $selected_types = array();
309
310 $type_hierarchy = SchemaData::get_type_hierarchy();
311 $property_domains = SchemaData::get_property_domains();
312
313 while ( ! empty( $uncovered_properties ) ) {
314 $best_type = null;
315 $best_coverage = 0;
316
317 // Find the type that covers the most uncovered properties
318 foreach ( $type_hierarchy as $type => $parent ) {
319 $coverage = 0;
320 foreach ( $uncovered_properties as $property ) {
321 if ( isset( $property_domains[ $property ] ) ) {
322 $valid_types = $property_domains[ $property ];
323 // Check if this type or any of its parents support the property
324 $type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
325 if ( ! empty( array_intersect( $type_chain, $valid_types ) ) ) {
326 ++$coverage;
327 }
328 }
329 }
330
331 if ( $coverage > $best_coverage ) {
332 $best_type = $type;
333 $best_coverage = $coverage;
334 }
335 }
336
337 if ( null === $best_type ) {
338 break; // No type covers remaining properties
339 }
340
341 $selected_types[] = $best_type;
342
343 // Remove covered properties
344 $type_chain = array_merge( array( $best_type ), self::get_type_parents( $best_type ) );
345 $uncovered_properties = array_filter(
346 $uncovered_properties,
347 function ( $property ) use ( $type_chain, $property_domains ) {
348 if ( ! isset( $property_domains[ $property ] ) ) {
349 return true;
350 }
351 $valid_types = $property_domains[ $property ];
352 return empty( array_intersect( $type_chain, $valid_types ) );
353 }
354 );
355 }
356
357 return $selected_types;
358 }
359
360 /**
361 * Get all properties grouped by type
362 *
363 * Returns an associative array where keys are type names and values
364 * are arrays of property names that belong to that type.
365 *
366 * @since 6.8.0
367 *
368 * @return array Associative array of type => properties
369 */
370 public static function get_properties_by_type() {
371 // Return cached result if available.
372 if ( self::$properties_by_type_cache !== null ) {
373 return self::$properties_by_type_cache;
374 }
375
376 $properties_by_type = array();
377 $property_domains = SchemaData::get_property_domains();
378
379 // Build reverse mapping from properties to types
380 foreach ( $property_domains as $property => $types ) {
381 foreach ( $types as $type ) {
382 if ( ! isset( $properties_by_type[ $type ] ) ) {
383 $properties_by_type[ $type ] = array();
384 }
385 $properties_by_type[ $type ][] = $property;
386 }
387 }
388
389 // Sort properties within each type
390 foreach ( $properties_by_type as $type => $properties ) {
391 sort( $properties_by_type[ $type ] );
392 }
393
394 // Sort by type name
395 ksort( $properties_by_type );
396
397 // Cache the result.
398 self::$properties_by_type_cache = $properties_by_type;
399
400 return $properties_by_type;
401 }
402
403 /**
404 * Get the expected types (range) for a property
405 *
406 * Returns the types that a property expects as its value.
407 * For example, 'author' expects ['Person', 'Organization']
408 *
409 * @since 6.8.0
410 *
411 * @param string $property The property name
412 * @return array Array of type names, or empty array if not found
413 */
414 public static function get_property_range( $property ) {
415 $property_ranges = SchemaData::get_property_ranges();
416 return $property_ranges[ $property ] ?? array();
417 }
418
419 /**
420 * Check if a property expects an object (not a primitive type)
421 *
422 * Returns true if the property expects a schema.org Type as its value,
423 * meaning it should be a nested object with @type.
424 *
425 * Primitive types: Text, Number, Boolean, Date, DateTime, Time, URL, etc.
426 *
427 * @since 6.8.0
428 *
429 * @param string $property The property name
430 * @return boolean True if property expects an object
431 */
432 public static function property_expects_object( $property ) {
433 $range = self::get_property_range( $property );
434
435 if ( empty( $range ) ) {
436 return false;
437 }
438
439 // If any range type is not a primitive, it expects an object
440 foreach ( $range as $type ) {
441 if ( ! in_array( $type, self::$primitive_types, true ) ) {
442 return true;
443 }
444 }
445
446 return false;
447 }
448
449 /**
450 * Get the preferred object type for a property
451 *
452 * When a property expects an object, this returns the most appropriate type.
453 * For properties with multiple possible types, returns the first one.
454 *
455 * @since 6.8.0
456 *
457 * @param string $property The property name
458 * @return string|null The type name, or null if property doesn't expect an object
459 */
460 public static function get_preferred_object_type( $property ) {
461 if ( ! self::property_expects_object( $property ) ) {
462 return null;
463 }
464
465 $range = self::get_property_range( $property );
466
467 // Filter out primitive types
468 $object_types = array_diff( $range, self::$primitive_types );
469
470 // Return first object type
471 return ! empty( $object_types ) ? reset( $object_types ) : null;
472 }
473
474 /**
475 * Get the supported JSON-LD ranges for a field type
476 *
477 * Returns the Schema.org types that a field type can output.
478 *
479 * @since 6.8.0
480 *
481 * @param string $field_type The ACF field type name (e.g., 'image', 'user')
482 * @return array Array of supported range types
483 */
484 public static function get_field_type_ranges( $field_type ) {
485 $field_type_obj = acf_get_field_type( $field_type );
486
487 if ( ! $field_type_obj || ! method_exists( $field_type_obj, 'get_jsonld_output_types' ) ) {
488 return array();
489 }
490
491 return $field_type_obj->get_jsonld_output_types();
492 }
493
494 /**
495 * Check if a Schema.org type has properties defined on it or its ancestors
496 *
497 * Walks up the type hierarchy checking if the type or any parent (excluding
498 * Thing, which is too generic) has properties defined. This distinguishes
499 * structural types (Person, Place, Organization) from value types (Duration,
500 * Distance) that have no meaningful sub-properties.
501 *
502 * @since 6.8.0
503 *
504 * @param string $type The Schema.org type name
505 * @return boolean True if type or an ancestor has properties defined
506 */
507 public static function type_has_properties( $type ) {
508 $current = $type;
509 $property_domains = SchemaData::get_property_domains();
510 $type_hierarchy = SchemaData::get_type_hierarchy();
511
512 // Walk up the hierarchy, but stop before Thing (too generic).
513 while ( $current && 'Thing' !== $current ) {
514 foreach ( $property_domains as $domains ) {
515 if ( in_array( $current, $domains, true ) ) {
516 return true;
517 }
518 }
519 // Move to parent type.
520 $current = $type_hierarchy[ $current ] ?? null;
521 }
522
523 return false;
524 }
525
526 /**
527 * Get valid output formats for a field/property combination
528 *
529 * When a field type supports multiple output formats (e.g., image can
530 * output URL or ImageObject), this returns the formats valid for a
531 * specific property.
532 *
533 * @since 6.8.0
534 *
535 * @param string $field_type The ACF field type name
536 * @param string $property The Schema.org property name
537 * @return array Array of valid output format types
538 */
539 public static function get_valid_output_formats( $field_type, $property ) {
540 $field_ranges = self::get_field_type_ranges( $field_type );
541 $property_ranges = self::get_property_range( $property );
542
543 if ( empty( $field_ranges ) || empty( $property_ranges ) ) {
544 return array();
545 }
546
547 $valid_formats = array();
548
549 foreach ( $field_ranges as $field_range ) {
550 // Direct match
551 if ( in_array( $field_range, $property_ranges, true ) ) {
552 $valid_formats[] = $field_range;
553 continue;
554 }
555
556 // Skip inheritance check for primitive types.
557 if ( in_array( $field_range, self::$primitive_types, true ) ) {
558 continue;
559 }
560
561 // Check if field range is a subtype of any property range
562 foreach ( $property_ranges as $property_range ) {
563 // Skip if property expects a primitive type.
564 if ( in_array( $property_range, self::$primitive_types, true ) ) {
565 continue;
566 }
567
568 if ( self::is_parent_of( $property_range, $field_range ) ) {
569 $valid_formats[] = $field_range;
570 break;
571 }
572 }
573
574 // For fields that output Thing, include property ranges and their subtypes.
575 // This allows Group/Repeater fields to show specific types like Person, HowToStep.
576 if ( 'Thing' === $field_range ) {
577 foreach ( $property_ranges as $property_range ) {
578 // Skip primitives.
579 if ( in_array( $property_range, self::$primitive_types, true ) ) {
580 continue;
581 }
582
583 // Include the property range itself if it's a structural subtype of Thing.
584 if ( self::is_parent_of( $field_range, $property_range ) ) {
585 if ( self::type_has_properties( $property_range ) ) {
586 $valid_formats[] = $property_range;
587 }
588 }
589
590 // Also include subtypes of the property range (e.g., HowToStep for CreativeWork).
591 $type_hierarchy = SchemaData::get_type_hierarchy();
592 foreach ( $type_hierarchy as $type => $parent ) {
593 if ( self::is_parent_of( $property_range, $type ) ) {
594 if ( self::type_has_properties( $type ) ) {
595 $valid_formats[] = $type;
596 }
597 }
598 }
599 }
600 }
601 }
602
603 // Handle text value types (Duration, Distance, etc.).
604 // These are modeled as objects in Schema.org but are really formatted strings.
605 // Text fields should be able to output these types.
606 if ( in_array( 'Text', $field_ranges, true ) ) {
607 foreach ( $property_ranges as $property_range ) {
608 if ( in_array( $property_range, self::$text_value_types, true ) ) {
609 $valid_formats[] = $property_range;
610 }
611 }
612 }
613
614 return array_unique( $valid_formats );
615 }
616
617 /**
618 * Parse a qualified property string (e.g., "Offer.price" or "price")
619 *
620 * Returns an array with 'type' and 'property' keys.
621 * For unqualified properties (no dot), type will be null.
622 *
623 * @since 6.8.0
624 *
625 * @param string $qualified_property The property string, optionally prefixed with Type.
626 * @return array Array with 'type' (string|null) and 'property' (string) keys.
627 */
628 public static function parse_qualified_property( $qualified_property ) {
629 if ( strpos( $qualified_property, '.' ) !== false ) {
630 list( $type, $property ) = explode( '.', $qualified_property, 2 );
631 return array(
632 'type' => $type,
633 'property' => $property,
634 );
635 }
636 return array(
637 'type' => null,
638 'property' => $qualified_property,
639 );
640 }
641
642 /**
643 * Get just the property name from a qualified property string
644 *
645 * @since 6.8.0
646 *
647 * @param string $qualified_property The property string, optionally prefixed with Type.
648 * @return string The property name without the type prefix.
649 */
650 public static function get_property_name( $qualified_property ) {
651 return self::parse_qualified_property( $qualified_property )['property'];
652 }
653
654 /**
655 * Get the type from a qualified property string
656 *
657 * @since 6.8.0
658 *
659 * @param string $qualified_property The property string, optionally prefixed with Type.
660 * @return string|null The type name, or null if not qualified.
661 */
662 public static function get_property_type( $qualified_property ) {
663 return self::parse_qualified_property( $qualified_property )['type'];
664 }
665
666 /**
667 * Get the default output format for a field/property combination
668 *
669 * When multiple formats are valid, returns the most appropriate default.
670 * Prefers object types over primitives when both are available.
671 *
672 * @since 6.8.0
673 *
674 * @param string $field_type The ACF field type name
675 * @param string $property The Schema.org property name
676 * @return string|null The default format type, or null if none valid
677 */
678 public static function get_default_output_format( $field_type, $property ) {
679 $valid_formats = self::get_valid_output_formats( $field_type, $property );
680
681 if ( empty( $valid_formats ) ) {
682 return null;
683 }
684
685 // If only one format, use it
686 if ( count( $valid_formats ) === 1 ) {
687 return $valid_formats[0];
688 }
689
690 // Prefer object types over primitives (richer data)
691 $object_formats = array_diff( $valid_formats, self::$primitive_types );
692
693 if ( ! empty( $object_formats ) ) {
694 return reset( $object_formats );
695 }
696
697 // Fall back to first primitive
698 return $valid_formats[0];
699 }
700 }
701