getConfig('seo')['schema']??[]; } // Determine schema type if ($schemaType === null) { // Auto-infer from config $schemaType = self::inferSchemaType($objectType, $objectId); $inferredType = true; } else { // Explicit type provided (for cross-references) $inferredType = false; } // If type was inferred, check for archive transformations if ($inferredType) { $schemaType = self::transformForArchive($schemaType); } // Create resolver for template resolution $resolver = new TemplateResolver($objectId, $objectType)??null; // Build reference based on schema type switch ($schemaType) { case 'Question': // Build Question from FAQPage config $questionTemplate = $schemaConfig['question'] ?? '{{post_title}}'; $answerTemplate = $schemaConfig['answer'] ?? '{{post_content}}'; return [ '@type' => 'Question', '@id' => $url . '#question', 'name' => $resolver ? $resolver->resolve($questionTemplate) : $name, 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $resolver ? $resolver->resolve($answerTemplate) : '' ] ]; default: // Standard reference: @type, @id, name, url $reference = [ '@type' => $schemaType, '@id' => $url . '#' . strtolower($schemaType), 'name' => $name, 'url' => $url, ]; // Add contextual fields if requested if ($includeContext) { // Add description if in config if ($resolver && isset($schemaConfig['description'])) { $description = $resolver->resolve($schemaConfig['description']); if ($description) { $reference['description'] = $description; } } // Add image if in config if ($resolver && isset($schemaConfig['image'])) { $imageUrl = $resolver->resolve($schemaConfig['image']); if ($imageUrl) { $reference['image'] = SchemaFieldHelpers::image_object($imageUrl); } } // Add minimal type-specific fields (existing logic) $reference = self::addMinimalFields($reference, $objectType, $objectId, $schemaType); } return $reference; } } /** * Transform schema types for archive/collection contexts * * @param string $schemaType Original schema type * @return string Transformed type (or original if no transform needed) */ private static function transformForArchive(string $schemaType): string { return match($schemaType) { 'FAQPage' => 'Question', // Add other transformations as needed default => $schemaType }; } /** * Build just an @id reference (most minimal) * * @param string $objectType 'post', 'term', or 'user' * @param int $objectId Object ID * @param string|null $schemaType Override @type * @return string @id URL or empty string */ public static function buildIdOnly( string $objectType, int $objectId, ?string $schemaType = null ): string { $url = self::getUrl($objectType, $objectId); if (!$url) { return ''; } if (!$schemaType) { $schemaType = self::inferSchemaType($objectType, $objectId); } return $url . '#' . strtolower($schemaType); } /** * Build array of references from array of IDs * * @param string $objectType 'post', 'term', or 'user' * @param array $objectIds Array of object IDs * @param string|null $schemaType Override @type * @param bool $includeContext Add contextual fields * @return array Array of schema references */ public static function buildMultiple( string $objectType, array $objectIds, ?string $schemaType = null, bool $includeContext = false ): array { $references = []; foreach ($objectIds as $id) { $ref = self::build($objectType, $id, $schemaType, $includeContext); if ($ref !== '') { $references[] = $ref; } } return $references; } /** * Build references for posts related to a term * * Perfect for: shop showing its artists, style showing its artists, etc. * * @param int $termId Term ID to get posts from * @param string $postType Post type to query (without BASE prefix) * @param int $limit Maximum number of references to return (default: 10) * @param string|null $schemaType Override @type for all references * @param bool $includeContext Add contextual fields * @param string $orderby How to order results (default: 'date') * @return array Array of schema references */ public static function buildFromTerm( int $termId, string $postType, int $limit = 10, ?string $schemaType = null, bool $includeContext = false, string $orderby = 'date' ): array { $term = get_term($termId); if (!$term || is_wp_error($term)) { return []; } // Get posts in this term $args = [ 'post_type' => jvbCheckBase($postType), 'posts_per_page' => $limit, 'post_status' => 'publish', 'orderby' => $orderby, 'order' => 'DESC', 'tax_query' => [ [ 'taxonomy' => $term->taxonomy, 'field' => 'term_id', 'terms' => $termId, ] ], 'fields' => 'ids', // Only get IDs for performance ]; $post_ids = get_posts($args); if (empty($post_ids)) { return []; } return self::buildMultiple('post', $post_ids, $schemaType, $includeContext); } /** * Build references for posts in a post type archive * * @param string $postType Post type (without BASE prefix) * @param int $limit Maximum number of references to return (default: 10) * @param bool $includeContext Add contextual fields * @param string $orderby How to order results (default: 'date') * @return array Array of schema references */ public static function buildFromArchive( string $postType, int $limit = 10, bool $includeContext = true, string $orderby = 'date' ): array { // Get posts from current query or fresh query global $wp_query; // If we're already on the archive, use those posts if (is_post_type_archive() && !empty($wp_query->posts)) { $post_ids = wp_list_pluck($wp_query->posts, 'ID'); } else { // Otherwise query fresh $args = [ 'post_type' => jvbCheckBase($postType), 'posts_per_page' => $limit, 'post_status' => 'publish', 'orderby' => $orderby, 'order' => 'DESC', 'fields' => 'ids', ]; $post_ids = get_posts($args); } if (empty($post_ids)) { return []; } // Let build() infer types and transform as needed return self::buildMultiple('post', $post_ids, null, $includeContext); } /** * Build references for terms related to a post * * Perfect for: artist showing their styles, artwork showing its themes, etc. * * @param int $postId Post ID to get terms from * @param string $taxonomy Taxonomy to query (without BASE prefix) * @param int $limit Maximum number of references to return (default: 10) * @param string|null $schemaType Override @type for all references * @param bool $includeContext Add contextual fields * @return array Array of schema references */ public static function buildFromPost( int $postId, string $taxonomy, int $limit = 10, ?string $schemaType = null, bool $includeContext = false ): array { $terms = wp_get_post_terms($postId, jvbCheckBase($taxonomy), [ 'number' => $limit, 'fields' => 'ids', ]); if (is_wp_error($terms) || empty($terms)) { return []; } return self::buildMultiple('term', $terms, $schemaType, $includeContext); } /** * Build ID-only references (most performant) * * Use when you just need the @id URIs without any additional data * * @param string $objectType 'post', 'term', or 'user' * @param array $objectIds Array of object IDs * @param string|null $schemaType Override @type * @return array Array of @id strings */ public static function buildIdOnlyMultiple( string $objectType, array $objectIds, ?string $schemaType = null ): array { $ids = []; foreach ($objectIds as $id) { $idString = self::buildIdOnly($objectType, $id, $schemaType); if ($idString !== '') { $ids[] = $idString; } } return $ids; } /** * Get URL for object */ private static function getUrl(string $objectType, int $objectId): string { return match($objectType) { 'post' => get_permalink($objectId) ?: '', 'term' => is_wp_error($link = get_term_link($objectId)) ? '' : $link, 'user' => get_author_posts_url($objectId) ?: '', default => '' }; } /** * Get name for object */ private static function getName(string $objectType, int $objectId): string { return match($objectType) { 'post' => get_the_title($objectId) ?: '', 'term' => get_term($objectId)?->name ?: '', 'user' => get_userdata($objectId)?->display_name ?: '', default => '' }; } /** * Infer schema type from content configuration */ private static function inferSchemaType(string $objectType, int $objectId): string { $default = 'Thing'; $registrar = false; if ($objectType === 'post') { $postType = get_post_type($objectId); $registrar = Registrar::getInstance($postType)); $default = 'CreativeWork'; } else if ($objectType === 'term') { $term = get_term($objectId); if (!$term || is_wp_error($term)) { return 'DefinedTerm'; } $registrar = Registrar::getInstance($term->taxonomy)); $default = 'DefinedTerm'; } elseif ($objectType === 'user') { return 'Person'; } $type = false; if ($registrar) { $type = $registrar->getConfig('seo')['schema']['type']; } return ($type && !empty($type)) ? $type : $default; } /** * Add minimal context-specific fields based on schema type */ private static function addMinimalFields( array $reference, string $objectType, int $objectId, string $schemaType ): array { switch ($schemaType) { case 'Person': // Add small thumbnail image if available $imageId = null; if ($objectType === 'user') { $imageId = get_user_meta($objectId, 'image_portrait', true); } elseif ($objectType === 'post') { $imageId = get_post_thumbnail_id($objectId); } if ($imageId) { $imageUrl = wp_get_attachment_image_url($imageId, 'thumbnail'); if ($imageUrl) { $reference['image'] = $imageUrl; // Simple URL for refs } } // Add job title if in user meta if ($objectType === 'user') { $jobTitle = get_user_meta($objectId, 'job_title', true); if ($jobTitle) { $reference['jobTitle'] = $jobTitle; } } break; case 'LocalBusiness': case 'TattooParlor': case 'Organization': // Add minimal location (just street address) $meta = new Meta($objectId, $objectType); $location = $meta->get('location'); if ($location && isset($location['address'])) { $reference['address'] = [ '@type' => 'PostalAddress', 'streetAddress' => $location['address'] ]; } break; case 'CreativeWork': case 'VisualArtwork': case 'Article': // Add featured image if available if ($objectType === 'post') { $imageId = get_post_thumbnail_id($objectId); if ($imageId) { $imageUrl = wp_get_attachment_image_url($imageId, 'medium'); if ($imageUrl) { $reference['image'] = $imageUrl; } } } break; case 'DefinedTerm': // Add term description if available if ($objectType === 'term') { $term = get_term($objectId); if ($term && !is_wp_error($term) && !empty($term->description)) { $reference['description'] = wp_trim_words($term->description, 20); } } elseif ($objectType === 'post') { // Add post content as description $post = get_post($objectId); if ($post && !empty($post->post_content)) { $reference['description'] = wp_trim_words(strip_tags($post->post_content), 20); } // If we're in an archive context, add inDefinedTermSet if (is_post_type_archive()) { $postType = get_post_type($objectId); $archiveUrl = get_post_type_archive_link($postType); if ($archiveUrl) { $reference['inDefinedTermSet'] = [ '@id' => $archiveUrl . '#definedtermset' ]; } } } break; } return $reference; } }