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