<?php
|
namespace JVBase\managers\SEO;
|
|
use JVBase\meta\Meta;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Helper methods for auto-building complex schema fields
|
*
|
* SINGLE SOURCE OF TRUTH for field enhancement.
|
* All pattern resolution and value enhancement flows through here.
|
*/
|
class SchemaFieldHelpers
|
{
|
/**
|
* Auto-resolve and enhance field values
|
* Main entry point for all field enhancement logic
|
*
|
* @param string $fieldName Field name
|
* @param mixed $value Raw value
|
* @param Meta|null $meta Optional Meta for accessing related fields
|
* @return mixed Enhanced value
|
*/
|
public static function autoResolve(string $fieldName, mixed $value, ?Meta $meta = null): mixed
|
{
|
// Skip empty values
|
if ($value === null || $value === '') {
|
return $value;
|
}
|
|
// Skip if already enhanced (has @type)
|
if (is_array($value) && isset($value['@type'])) {
|
return $value;
|
}
|
|
// Auto-enhance based on field name
|
return match($fieldName) {
|
// Location data -> PostalAddress + GeoCoordinates
|
'location', 'address' => is_array($value) ? self::buildLocation($value) : $value,
|
|
// Image fields -> ImageObject
|
'image', 'logo', 'photo','image_portrait', 'image_landscape', 'featured_image'
|
=> is_numeric($value) ? self::buildImage($value) : self::wrapImageUrl($value),
|
|
// Hours -> openingHours array
|
'hours', 'opening_hours', 'openingHoursSpecification'
|
=> is_array($value) ? self::buildOpeningHours($value)['openingHours'] ?? $value : $value,
|
|
// Links -> sameAs array
|
'links', 'sameAs'
|
=> is_array($value) ? self::buildSameAs($value)['sameAs'] ?? $value : [$value],
|
// Navigation -> SiteNavigationElement array
|
|
'hasPart'
|
=> is_array($value) ? self::buildSiteNavigation($value)['hasPart'] ?? $value : $value,
|
'hasOfferCatalog'
|
=> is_array($value) ? self::offer_catalog_array($value) : $value,
|
// Services -> OfferCatalog
|
'services'
|
=> is_array($value) ? self::buildServiceCatalog($value) : $value,
|
|
// Amenities -> amenityFeature
|
'amenities'
|
=> self::buildAmenityFeatures($value)['amenityFeature'] ?? $value,
|
|
// Languages -> availableLanguage
|
'languages'
|
=> is_array($value) ? self::buildAvailableLanguages($value)['availableLanguage'] ?? $value : $value,
|
|
// Rating -> AggregateRating (needs rating_count from meta)
|
'rating'
|
=> $meta ? self::buildAggregateRating($value, $meta->get('rating_count')) : $value,
|
|
// Geo coordinates
|
'geo'
|
=> is_array($value) ? self::buildGeoCoordinates($value) : $value,
|
'image_object' => self::image_object($value),
|
'image_url' => self::image_url($value),
|
'associatedMedia', 'image_object_array' => self::image_object_array($value),
|
// Add to the match statement:
|
'brand' => is_array($value) ? self::buildBrandObject($value) : $value,
|
'offers' => is_array($value) ? self::buildOfferObject($value) : $value,
|
'review' => is_array($value) ? self::buildReviewArray($value) : $value,
|
'parentOrganization', 'subOrganization'
|
=> is_array($value) ? self::buildOrganizationReference($value) : $value,
|
'employee' => is_array($value) ? self::buildPersonReferenceArray($value) : $value,
|
'starRating' => is_array($value) ? self::buildRatingObject($value) : $value,
|
// Default: return as-is
|
default => $value
|
};
|
}
|
|
/**
|
* Check if a value is a pattern (contains {{...}})
|
*/
|
public static function isPattern(mixed $value): bool
|
{
|
return is_string($value) && str_contains($value, '{{') && str_contains($value, '}}');
|
}
|
|
/**
|
* Get Jake Van creator attribution (ONLY for Website schema)
|
*/
|
public static function getCreator(): array
|
{
|
return [
|
'@type' => 'Person',
|
'@id' => 'https://jakevan.ca/#person',
|
'name' => 'Jake Vanderwerf',
|
'alternateName' => 'JakeVan',
|
'url' => 'https://jakevan.ca',
|
'jobTitle' => ['Graphic Designer', 'Website Designer', 'Website Developer'],
|
'sameAs' => [
|
'https://github.com/jakevanderwerf',
|
'https://www.linkedin.com/in/jakevanderwerf'
|
]
|
];
|
}
|
|
/**
|
* Create proper ImageObject from WordPress attachment ID or URL
|
*
|
* @param int|string $image Image ID or URL
|
* @param string $size Image size (default: 'full')
|
* @return array|string ImageObject schema or URL
|
*/
|
public static function buildImage(int|string $image, string $size = 'full'): array|string
|
{
|
// If it's empty, return empty string
|
if (empty($image)) {
|
return '';
|
}
|
|
// If it's already a URL, wrap it
|
if (is_string($image) && (str_starts_with($image, 'http://') || str_starts_with($image, 'https://'))) {
|
return self::wrapImageUrl($image);
|
}
|
|
// Treat as attachment ID
|
$image_id = (int)$image;
|
$image_url = wp_get_attachment_image_url($image_id, $size);
|
|
if (!$image_url) {
|
return '';
|
}
|
|
$image_meta = wp_get_attachment_metadata($image_id);
|
$image_post = get_post($image_id);
|
|
$imageObject = [
|
'@type' => 'ImageObject',
|
'url' => $image_url,
|
'contentUrl' => $image_url,
|
];
|
|
// Add dimensions if available
|
if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
|
$imageObject['width'] = $image_meta['width'];
|
$imageObject['height'] = $image_meta['height'];
|
}
|
|
// Add caption if available
|
if ($image_post && !empty($image_post->post_excerpt)) {
|
$imageObject['caption'] = $image_post->post_excerpt;
|
}
|
|
// Add alt text
|
$alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
|
if ($alt) {
|
$imageObject['description'] = $alt;
|
}
|
|
return $imageObject;
|
}
|
|
/**
|
* Wrap a URL string in minimal ImageObject
|
*/
|
private static function wrapImageUrl(mixed $value): array|string
|
{
|
if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_URL)) {
|
return $value;
|
}
|
|
return [
|
'@type' => 'ImageObject',
|
'url' => $value,
|
'contentUrl' => $value,
|
];
|
}
|
|
/**
|
* Build PostalAddress and GeoCoordinates from location data
|
*
|
* Returns array with 'address' and 'geo' keys
|
*
|
* @param array $location Location data from Meta
|
* @return array Schema with address and geo fields
|
*/
|
public static function buildLocation(array $location): array
|
{
|
$schema = [];
|
|
// Build PostalAddress
|
if (!empty($location['address'])) {
|
$address = [
|
'@type' => 'PostalAddress',
|
'streetAddress' => $location['address']
|
];
|
|
if (!empty($location['city'])) {
|
$address['addressLocality'] = $location['city'];
|
}
|
|
if (!empty($location['province'])) {
|
$address['addressRegion'] = $location['province'];
|
}
|
|
if (!empty($location['postal_code'])) {
|
$address['postalCode'] = $location['postal_code'];
|
}
|
|
if (!empty($location['country'])) {
|
$address['addressCountry'] = $location['country'];
|
}
|
|
$schema['address'] = $address;
|
}
|
|
// Build GeoCoordinates
|
if (!empty($location['lat']) && !empty($location['lng'])) {
|
$schema['geo'] = self::buildGeoCoordinates([
|
'latitude' => $location['lat'],
|
'longitude' => $location['lng']
|
]);
|
}
|
|
return $schema;
|
}
|
|
/**
|
* Build GeoCoordinates from lat/lng data
|
*/
|
public static function buildGeoCoordinates(array $coords): array
|
{
|
$lat = $coords['latitude'] ?? $coords['lat'] ?? null;
|
$lng = $coords['longitude'] ?? $coords['lng'] ?? null;
|
|
if (!$lat || !$lng) {
|
return [];
|
}
|
|
return [
|
'@type' => 'GeoCoordinates',
|
'latitude' => (float)$lat,
|
'longitude' => (float)$lng
|
];
|
}
|
|
/**
|
* Build opening hours from repeater field
|
*
|
* @param array $hours Hours data from Meta
|
* @return array Schema with openingHours field
|
*/
|
public static function buildOpeningHours(array $hours): array
|
{
|
if (empty($hours)) {
|
return [];
|
}
|
|
$formatted = [];
|
|
foreach ($hours as $entry) {
|
if (empty($entry['day'])) {
|
continue;
|
}
|
|
$day = ucfirst($entry['day']);
|
$opens = $entry['time_opens'] ?? '09:00';
|
$closes = $entry['time_closes'] ?? '17:00';
|
|
// Format: "Mo-Fr 09:00-17:00" or "Mo 09:00-17:00"
|
$formatted[] = "{$day} {$opens}-{$closes}";
|
}
|
|
return !empty($formatted) ? ['openingHours' => $formatted] : [];
|
}
|
|
/**
|
* Build sameAs array from links repeater
|
*
|
* @param array $links Links data from Meta
|
* @return array Schema with sameAs field
|
*/
|
public static function buildSameAs(array $links): array
|
{
|
if (empty($links)) {
|
return [];
|
}
|
|
$urls = [];
|
|
foreach ($links as $link) {
|
if (is_array($link) && !empty($link['url'])) {
|
$urls[] = $link['url'];
|
} elseif (is_string($link)) {
|
$urls[] = $link;
|
}
|
}
|
|
return !empty($urls) ? ['sameAs' => $urls] : [];
|
}
|
|
/**
|
* Build service catalog from services array
|
* Returns properly formatted OfferCatalog with itemListElement
|
*
|
* @param array $services Services data
|
* @return array OfferCatalog schema
|
*/
|
public static function buildServiceCatalog(array $services): array
|
{
|
if (empty($services)) {
|
return [];
|
}
|
|
$items = [];
|
|
foreach ($services as $service) {
|
// Support both 'type' and '@type' in service data
|
$serviceType = $service['type'] ?? $service['@type'] ?? 'Service';
|
|
$item = [
|
'@type' => $serviceType,
|
'name' => $service['name'] ?? $service['title'] ?? ''
|
];
|
|
if (!empty($service['description'])) {
|
$item['description'] = $service['description'];
|
}
|
|
// Handle pricing - can be simple text or structured
|
if (!empty($service['price'])) {
|
// Check if price is already an Offer object
|
if (is_array($service['price']) && isset($service['price']['@type'])) {
|
$item['offers'] = $service['price'];
|
} else {
|
// Create simple offer with price text
|
$item['offers'] = [
|
'@type' => 'Offer',
|
'price' => (string)$service['price'],
|
'priceCurrency' => $service['currency'] ?? $service['priceCurrency'] ?? 'CAD'
|
];
|
}
|
}
|
|
// Handle priceRange if provided instead of price
|
if (!empty($service['priceRange'])) {
|
$item['offers'] = [
|
'@type' => 'Offer',
|
'price' => $service['priceRange'],
|
'priceCurrency' => $service['currency'] ?? $service['priceCurrency'] ?? 'CAD'
|
];
|
}
|
|
if (!empty($item['name'])) {
|
$items[] = $item;
|
}
|
}
|
|
if (empty($items)) {
|
return [];
|
}
|
|
return [
|
'@type' => 'OfferCatalog',
|
'name' => 'Services',
|
'itemListElement' => $items
|
];
|
}
|
|
/**
|
* Build amenity features from amenities array or string
|
*
|
* @param array|string $amenities Amenities data
|
* @return array Schema with amenityFeature field
|
*/
|
public static function buildAmenityFeatures(array|string $amenities): array
|
{
|
if (empty($amenities)) {
|
return [];
|
}
|
|
// Convert string to array
|
if (is_string($amenities)) {
|
$amenities = array_map('trim', explode(',', $amenities));
|
}
|
|
$features = [];
|
|
foreach ($amenities as $amenity) {
|
if (is_array($amenity) && isset($amenity['name'])) {
|
$features[] = [
|
'@type' => 'LocationFeatureSpecification',
|
'name' => $amenity['name'],
|
'value' => true
|
];
|
} elseif (is_string($amenity) && $amenity !== '') {
|
$features[] = [
|
'@type' => 'LocationFeatureSpecification',
|
'name' => $amenity,
|
'value' => true
|
];
|
}
|
}
|
|
return !empty($features) ? ['amenityFeature' => $features] : [];
|
}
|
|
/**
|
* Build available languages from languages array
|
*
|
* @param array $languages Languages data
|
* @return array Schema with availableLanguage field
|
*/
|
public static function buildAvailableLanguages(array $languages): array
|
{
|
if (empty($languages)) {
|
return [];
|
}
|
|
$items = [];
|
|
foreach ($languages as $lang) {
|
if (is_array($lang) && isset($lang['language'])) {
|
$items[] = [
|
'@type' => 'Language',
|
'name' => $lang['language']
|
];
|
} elseif (is_string($lang) && $lang !== '') {
|
$items[] = [
|
'@type' => 'Language',
|
'name' => $lang
|
];
|
}
|
}
|
|
return !empty($items) ? ['availableLanguage' => $items] : [];
|
}
|
|
/**
|
* Build aggregate rating from rating value and count
|
*
|
* @param float|string $rating Rating value
|
* @param int|string|null $count Number of ratings
|
* @return array|null Schema with aggregateRating or null
|
*/
|
public static function buildAggregateRating(float|string $rating, int|string|null $count): ?array
|
{
|
if (empty($rating)) {
|
return null;
|
}
|
|
$ratingValue = (float)$rating;
|
$ratingCount = (int)($count ?? 0);
|
|
if ($ratingCount === 0) {
|
// Can't have aggregate rating without count
|
return null;
|
}
|
|
return [
|
'@type' => 'AggregateRating',
|
'ratingValue' => $ratingValue,
|
'ratingCount' => $ratingCount,
|
'bestRating' => 5.0,
|
'worstRating' => 1.0
|
];
|
}
|
|
/**
|
* Transform text value
|
*/
|
public static function text($value): string
|
{
|
return (string)$value;
|
}
|
|
/**
|
* Transform URL value
|
*/
|
public static function url($value): string
|
{
|
return esc_url_raw($value);
|
}
|
|
/**
|
* Transform email value
|
*/
|
public static function email($value): string
|
{
|
return sanitize_email($value);
|
}
|
|
/**
|
* Transform number value
|
*/
|
public static function number($value): float|int
|
{
|
return is_numeric($value) ? (float)$value : 0;
|
}
|
|
/**
|
* Transform date value to ISO format (YYYY-MM-DD)
|
*/
|
public static function date($value): string
|
{
|
if (empty($value)) return '';
|
|
// If already in ISO format, return as-is
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
return $value;
|
}
|
|
// Otherwise convert to ISO format
|
$timestamp = is_numeric($value) ? $value : strtotime($value);
|
return $timestamp ? date('Y-m-d', $timestamp) : '';
|
}
|
|
/**
|
* Transform datetime value to ISO 8601 format
|
*/
|
public static function datetime($value): string
|
{
|
if (empty($value)) return '';
|
|
// If already in ISO format, return as-is
|
if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/', $value)) {
|
return $value;
|
}
|
|
// Otherwise convert to ISO format
|
$timestamp = is_numeric($value) ? $value : strtotime($value);
|
return $timestamp ? date('c', $timestamp) : '';
|
}
|
|
/**
|
* Transform dimension value to QuantitativeValue schema
|
* Examples: "10cm" -> {value: 10, unitCode: "CM"}
|
*/
|
public static function dimension($value): array|string
|
{
|
if (empty($value)) return '';
|
|
// If already an object, return as-is
|
if (is_array($value) && isset($value['@type'])) {
|
return $value;
|
}
|
|
// Extract number and unit (e.g., "10cm" -> ["10", "cm"])
|
if (preg_match('/^([\d.]+)\s*([a-z]+)$/i', $value, $matches)) {
|
return [
|
'@type' => 'QuantitativeValue',
|
'value' => (float)$matches[1],
|
'unitCode' => strtoupper($matches[2])
|
];
|
}
|
|
return $value;
|
}
|
|
/**
|
* Transform array of text values from repeater
|
* Handles various repeater field formats
|
*/
|
public static function text_array($value): array
|
{
|
if (!is_array($value)) {
|
return [$value];
|
}
|
|
return array_map(function($item) {
|
if (is_array($item)) {
|
// Handle repeater format with common field names
|
return $item['name'] ?? $item['keyword'] ?? $item['topic'] ?? $item['value'] ?? '';
|
}
|
return (string)$item;
|
}, array_filter($value));
|
}
|
|
/**
|
* Transform array of URLs from repeater
|
*/
|
public static function url_array($value): array
|
{
|
if (!is_array($value)) {
|
return [$value];
|
}
|
|
return array_map(function($item) {
|
if (is_array($item)) {
|
return esc_url_raw($item['url'] ?? '');
|
}
|
return esc_url_raw($item);
|
}, array_filter($value));
|
}
|
|
/**
|
* Transform image ID to ImageObject
|
* Reuses existing buildImage method
|
*/
|
public static function image_object($imageId): array|string
|
{
|
if (!$imageId) return '';
|
return self::buildImage($imageId);
|
}
|
|
/**
|
* Transform array of image IDs to ImageObject array
|
* Handles two formats:
|
* 1. Simple array: [123, 456, 789]
|
* 2. Repeater format: [['image' => 123, 'caption' => 'Before'], ...]
|
*/
|
public static function image_object_array($value): array
|
{
|
if (!is_array($value)) {
|
return [];
|
}
|
|
return array_values(array_filter(array_map(function($item, $index) {
|
// Handle repeater format with sub-fields
|
if (is_array($item) && isset($item['image'])) {
|
$imageObject = self::buildImage($item['image']);
|
|
if (empty($imageObject)) {
|
return null;
|
}
|
|
if (!empty($item['caption'])) {
|
$imageObject['caption'] = $item['caption'];
|
}
|
|
if (isset($item['position'])) {
|
$imageObject['position'] = (int)$item['position'];
|
} else {
|
$imageObject['position'] = $index;
|
}
|
|
return $imageObject;
|
}
|
|
// Handle simple array of IDs
|
if (is_numeric($item)) {
|
$imageObject = self::buildImage($item);
|
|
if (empty($imageObject)) {
|
return null;
|
}
|
|
$imageObject['position'] = $index;
|
|
// Try to get caption from image post
|
$post = get_post($item);
|
if ($post && !empty($post->post_excerpt)) {
|
$imageObject['caption'] = $post->post_excerpt;
|
}
|
|
return $imageObject;
|
}
|
|
return null;
|
}, $value, array_keys($value))));
|
}
|
|
public static function image_url($imageId): string
|
{
|
if (!$imageId) {
|
return '';
|
}
|
|
// If already a URL string, return as-is
|
if (is_string($imageId) && (str_starts_with($imageId, 'http://') || str_starts_with($imageId, 'https://'))) {
|
return $imageId;
|
}
|
|
// Get URL from attachment ID
|
$image_url = wp_get_attachment_image_url((int)$imageId, 'full');
|
|
return $image_url ?: '';
|
}
|
/**
|
* Transform location to PostalAddress + GeoCoordinates
|
* Returns array with 'address' and 'geo' keys
|
*
|
* Special case: returns multiple schema properties
|
*/
|
public static function location_complex($location): array
|
{
|
if (!$location) return [];
|
return self::buildLocation($location);
|
}
|
|
/**
|
* Transform location to just PostalAddress
|
*/
|
public static function postal_address($location): array
|
{
|
if (!is_array($location) || empty($location['address'])) {
|
return [];
|
}
|
|
$address = [
|
'@type' => 'PostalAddress',
|
'streetAddress' => $location['address']
|
];
|
|
if (!empty($location['city'])) {
|
$address['addressLocality'] = $location['city'];
|
}
|
|
if (!empty($location['province'])) {
|
$address['addressRegion'] = $location['province'];
|
}
|
|
if (!empty($location['postal_code'])) {
|
$address['postalCode'] = $location['postal_code'];
|
}
|
|
if (!empty($location['country'])) {
|
$address['addressCountry'] = $location['country'];
|
}
|
|
return $address;
|
}
|
|
/**
|
* Transform coordinates to GeoCoordinates
|
* Reuses existing buildGeoCoordinates method
|
*/
|
public static function geo_coordinates($coords): array
|
{
|
if (!is_array($coords)) return [];
|
return self::buildGeoCoordinates($coords);
|
}
|
|
/**
|
* Transform opening hours group to OpeningHoursSpecification
|
* Reuses existing buildOpeningHours method
|
*/
|
public static function opening_hours_specification($hours): array
|
{
|
if (!is_array($hours)) return [];
|
$result = self::buildOpeningHours($hours);
|
return $result['openingHours'] ?? [];
|
}
|
|
/**
|
* Transform contact points repeater to ContactPoint array
|
*/
|
public static function contact_point_array($contacts): array
|
{
|
if (!is_array($contacts)) return [];
|
|
$contactPoints = [];
|
foreach ($contacts as $contact) {
|
if (empty($contact['contactType'])) continue;
|
|
$point = [
|
'@type' => 'ContactPoint',
|
'contactType' => $contact['contactType']
|
];
|
|
if (!empty($contact['telephone'])) {
|
$point['telephone'] = $contact['telephone'];
|
}
|
|
if (!empty($contact['email'])) {
|
$point['email'] = $contact['email'];
|
}
|
|
$contactPoints[] = $point;
|
}
|
|
return $contactPoints;
|
}
|
|
/**
|
* Transform amenity features repeater
|
* Reuses existing buildAmenityFeatures method
|
*/
|
public static function amenity_feature_array($amenities): array
|
{
|
if (!is_array($amenities)) return [];
|
$result = self::buildAmenityFeatures($amenities);
|
return $result['amenityFeature'] ?? [];
|
}
|
|
/**
|
* Transform languages repeater
|
* Reuses existing buildAvailableLanguages method
|
*/
|
public static function language_array($languages): array
|
{
|
if (!is_array($languages)) return [];
|
$result = self::buildAvailableLanguages($languages);
|
return $result['availableLanguage'] ?? [];
|
}
|
|
/**
|
* Transform aggregate rating group to AggregateRating schema
|
*/
|
public static function aggregate_rating($rating): ?array
|
{
|
if (!is_array($rating) || empty($rating['ratingValue'])) {
|
return null;
|
}
|
|
$aggregateRating = [
|
'@type' => 'AggregateRating',
|
'ratingValue' => (float)$rating['ratingValue']
|
];
|
|
if (!empty($rating['bestRating'])) {
|
$aggregateRating['bestRating'] = (float)$rating['bestRating'];
|
}
|
|
if (!empty($rating['worstRating'])) {
|
$aggregateRating['worstRating'] = (float)$rating['worstRating'];
|
}
|
|
if (!empty($rating['ratingCount'])) {
|
$aggregateRating['ratingCount'] = (int)$rating['ratingCount'];
|
}
|
|
if (!empty($rating['reviewCount'])) {
|
$aggregateRating['reviewCount'] = (int)$rating['reviewCount'];
|
}
|
|
return $aggregateRating;
|
}
|
|
/**
|
* Transform hasOfferCatalog field data to OfferCatalog schema
|
* Handles both manual items and auto-generated from post type
|
* Reuses existing buildServiceCatalog method
|
*/
|
public static function offer_catalog_array($data): array
|
{
|
if (!is_array($data)) return [];
|
|
// Extract manual items if present
|
if (array_key_exists('manual_items', $data) && !empty($data['manual_items'])) {
|
$services = $data['manual_items'];
|
}
|
// Otherwise expect array of items directly
|
else if (isset($data[0])) {
|
$services = $data;
|
}
|
else {
|
return [];
|
}
|
|
// Build the catalog using existing method
|
return self::buildServiceCatalog($services);
|
}
|
|
/**
|
* Transform credentials repeater to EducationalOccupationalCredential array
|
*/
|
public static function credential_array($credentials): array
|
{
|
if (!is_array($credentials)) return [];
|
|
$items = [];
|
foreach ($credentials as $cred) {
|
if (empty($cred['name'])) continue;
|
|
$item = [
|
'@type' => 'EducationalOccupationalCredential',
|
'name' => $cred['name']
|
];
|
|
if (!empty($cred['credentialCategory'])) {
|
$item['credentialCategory'] = $cred['credentialCategory'];
|
}
|
|
if (!empty($cred['issuedBy'])) {
|
$item['recognizedBy'] = [
|
'@type' => 'Organization',
|
'name' => $cred['issuedBy']
|
];
|
}
|
|
$items[] = $item;
|
}
|
|
return $items;
|
}
|
|
/**
|
* Transform FAQ repeater to Question schema array
|
*/
|
public static function faq_array($faqs): array
|
{
|
if (!is_array($faqs)) return [];
|
|
$questions = [];
|
foreach ($faqs as $faq) {
|
if (empty($faq['question']) || empty($faq['answer'])) {
|
continue;
|
}
|
|
$questions[] = [
|
'@type' => 'Question',
|
'name' => $faq['question'],
|
'acceptedAnswer' => [
|
'@type' => 'Answer',
|
'text' => $faq['answer']
|
]
|
];
|
}
|
|
return $questions;
|
}
|
|
/**
|
* Transform PotentialAction configurations to schema.org format
|
*
|
* @param array $actions Array of action configurations
|
* @return array Formatted PotentialAction array
|
*/
|
public static function potential_action_array($actions): array
|
{
|
if (empty($actions) || !is_array($actions)) {
|
return [];
|
}
|
|
$formatted = [];
|
|
foreach ($actions as $action) {
|
if (empty($action['type']) || empty($action['name'])) {
|
continue; // Skip invalid actions
|
}
|
|
$formattedAction = [
|
'@type' => $action['type'],
|
'name' => $action['name'],
|
];
|
|
// Add target (required for most actions)
|
if (!empty($action['target'])) {
|
$target = $action['target'];
|
|
// If target contains a query placeholder, format as EntryPoint
|
if (str_contains($target, '{')) {
|
$formattedAction['target'] = [
|
'@type' => 'EntryPoint',
|
'urlTemplate' => $target,
|
];
|
} else {
|
$formattedAction['target'] = $target;
|
}
|
}
|
|
// Add optional fields
|
if (!empty($action['description'])) {
|
$formattedAction['description'] = $action['description'];
|
}
|
|
if (!empty($action['url'])) {
|
$formattedAction['url'] = $action['url'];
|
}
|
|
$formatted[] = $formattedAction;
|
}
|
|
return $formatted;
|
}
|
|
|
/**
|
* Build a JSON-LD @id reference
|
* String → ['@id' => $value], array with @id → pass through
|
*/
|
public static function reference(mixed $value): array|string
|
{
|
if (is_array($value) && isset($value['@id'])) {
|
return $value;
|
}
|
|
if (is_string($value) && !empty($value)) {
|
return ['@id' => $value];
|
}
|
|
return $value;
|
}
|
|
|
|
/**
|
* Build SiteNavigationElement array from navigation items
|
*/
|
public static function buildSiteNavigation(array $items): array
|
{
|
$elements = [];
|
$position = 1;
|
|
foreach ($items as $item) {
|
if (empty($item['name']) || empty($item['url'])) continue;
|
|
$nav = [
|
'@type' => 'SiteNavigationElement',
|
'@id' => $item['url'] . '#navigation',
|
'position' => $position++,
|
'name' => $item['name'],
|
'url' => $item['url'],
|
];
|
|
if (!empty($item['description'])) {
|
$nav['description'] = $item['description'];
|
}
|
|
$elements[] = $nav;
|
}
|
|
return ['hasPart' => $elements];
|
}
|
|
/**
|
* Build Offer object
|
*/
|
public static function buildOfferObject(array $data): array
|
{
|
$offer = ['@type' => 'Offer'];
|
|
if (!empty($data['price'])) {
|
$offer['price'] = (string)$data['price'];
|
$offer['priceCurrency'] = $data['priceCurrency'] ?? 'USD';
|
}
|
|
if (!empty($data['availability'])) {
|
$offer['availability'] = 'https://schema.org/' . $data['availability'];
|
}
|
|
if (!empty($data['validFrom'])) {
|
$offer['validFrom'] = $data['validFrom'];
|
}
|
|
if (!empty($data['validThrough'])) {
|
$offer['validThrough'] = $data['validThrough'];
|
}
|
|
return $offer;
|
}
|
|
/**
|
* Build Brand object or simple text
|
*/
|
public static function buildBrandObject(array $data): array|string
|
{
|
if (empty($data['name'])) {
|
return '';
|
}
|
|
// Simple text brand
|
if (empty($data['type']) || $data['type'] === 'text') {
|
return $data['name'];
|
}
|
|
// Organization/Brand object
|
$brand = [
|
'@type' => 'Brand',
|
'name' => $data['name'],
|
];
|
|
if (!empty($data['url'])) {
|
$brand['url'] = $data['url'];
|
}
|
|
if (!empty($data['logo'])) {
|
$brand['logo'] = self::buildImage($data['logo']);
|
}
|
|
return $brand;
|
}
|
|
/**
|
* Build Review array
|
*/
|
public static function buildReviewArray(array $reviews): array
|
{
|
$output = [];
|
|
foreach ($reviews as $review) {
|
if (empty($review['author']) && empty($review['reviewBody'])) {
|
continue;
|
}
|
|
$item = ['@type' => 'Review'];
|
|
if (!empty($review['author'])) {
|
$item['author'] = [
|
'@type' => 'Person',
|
'name' => $review['author']
|
];
|
}
|
|
if (!empty($review['reviewRating'])) {
|
$item['reviewRating'] = [
|
'@type' => 'Rating',
|
'ratingValue' => $review['reviewRating'],
|
];
|
}
|
|
if (!empty($review['reviewBody'])) {
|
$item['reviewBody'] = $review['reviewBody'];
|
}
|
|
if (!empty($review['datePublished'])) {
|
$item['datePublished'] = $review['datePublished'];
|
}
|
|
$output[] = $item;
|
}
|
|
return $output;
|
}
|
|
/**
|
* Build organization reference
|
*/
|
public static function buildOrganizationReference(array $data): array
|
{
|
if (empty($data['name'])) {
|
return [];
|
}
|
|
$org = [
|
'@type' => 'Organization',
|
'name' => $data['name'],
|
];
|
|
if (!empty($data['url'])) {
|
$org['url'] = $data['url'];
|
}
|
|
return $org;
|
}
|
|
/**
|
* Build organization reference array
|
*/
|
public static function buildOrganizationReferenceArray(array $items): array
|
{
|
return array_map([self::class, 'buildOrganizationReference'], $items);
|
}
|
|
/**
|
* Build person reference array
|
*/
|
public static function buildPersonReferenceArray(array $items): array
|
{
|
$output = [];
|
|
foreach ($items as $item) {
|
if (empty($item['name'])) continue;
|
|
$person = [
|
'@type' => 'Person',
|
'name' => $item['name'],
|
];
|
|
if (!empty($item['jobTitle'])) {
|
$person['jobTitle'] = $item['jobTitle'];
|
}
|
|
$output[] = $person;
|
}
|
|
return $output;
|
}
|
|
/**
|
* Boolean transformer
|
*/
|
public static function buildBoolean(mixed $value): bool
|
{
|
return (bool)$value;
|
}
|
|
/**
|
* Time transformer
|
*/
|
public static function buildTime(string $value): string
|
{
|
// Ensure format is HH:MM
|
return date('H:i', strtotime($value));
|
}
|
|
/**
|
* Rating object
|
*/
|
public static function buildRatingObject(array $data): array
|
{
|
if (empty($data['ratingValue'])) {
|
return [];
|
}
|
|
return [
|
'@type' => 'Rating',
|
'ratingValue' => (float)$data['ratingValue'],
|
'bestRating' => 5,
|
];
|
}
|
}
|