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, ]; } }