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 / GEO.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
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