<?php
|
namespace JVBase\managers\SEO;
|
|
use JVBase\meta\MetaManager;
|
use WP_Term;
|
use WP_User;
|
use WP_Post;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Builds minimal schema references for related entities
|
*
|
* When an artist references a shop or an artwork references an artist,
|
* we don't want to embed the full schema—just a reference with minimal data.
|
*
|
* Usage:
|
* - Artist referencing a Shop: "worksFor": SchemaReferenceBuilder::build('term', $shop_id)
|
* - Artwork referencing an Artist: "creator": SchemaReferenceBuilder::build('post', $artist_id)
|
*/
|
class SchemaReferenceBuilder
|
{
|
/**
|
* Build a schema reference
|
* Automatically transforms types for archives (FAQPage → Question)
|
*
|
* @param string $objectType 'post', 'term', or 'user'
|
* @param int $objectId Object ID
|
* @param string|null $schemaType Override @type (null = auto-infer and transform)
|
* @param bool $includeContext Add contextual fields
|
* @return array|string Schema reference or empty string if invalid
|
*/
|
public static function build(
|
string $objectType,
|
int $objectId,
|
?string $schemaType = null,
|
bool $includeContext = true
|
): array|string {
|
// Get basic info
|
$url = self::getUrl($objectType, $objectId);
|
$name = self::getName($objectType, $objectId);
|
|
if (!$url || !$name) {
|
return '';
|
}
|
|
// Get config for templates and schema type
|
$config = self::getConfigFor($objectType, $objectId);
|
$schemaConfig = $config['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 = $objectType === 'post' ? new TemplateResolver($objectId,'post') :
|
($objectType === 'term' ? new TemplateResolver($objectId,'term') :
|
($objectType === 'user' ? new TemplateResolver($objectId, 'user') : 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
|
};
|
}
|
|
/**
|
* Get config for an object
|
*/
|
private static function getConfigFor(string $objectType, int $objectId): ?array
|
{
|
switch ($objectType) {
|
case 'post':
|
$postType = get_post_type($objectId);
|
$typeKey = str_replace(BASE, '', $postType);
|
return defined('JVB_CONTENT') && isset(JVB_CONTENT[$typeKey])
|
? JVB_CONTENT[$typeKey]
|
: null;
|
|
case 'term':
|
$term = get_term($objectId);
|
if (!$term || is_wp_error($term)) {
|
return null;
|
}
|
$typeKey = str_replace(BASE, '', $term->taxonomy);
|
return defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$typeKey])
|
? JVB_TAXONOMY[$typeKey]
|
: null;
|
|
case 'user':
|
$role = jvbUserRole($objectId);
|
return defined('JVB_USER') && isset(JVB_USER[$role])
|
? JVB_USER[$role]
|
: null;
|
}
|
|
return null;
|
}
|
|
/**
|
* 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
|
{
|
if ($objectType === 'post') {
|
$postType = get_post_type($objectId);
|
$typeKey = str_replace(BASE, '', $postType);
|
|
if (defined('JVB_CONTENT') && isset(JVB_CONTENT[$typeKey])) {
|
$config = JVB_CONTENT[$typeKey];
|
return $config['seo']['schema']['type'] ?? 'CreativeWork';
|
}
|
|
return 'CreativeWork';
|
}
|
|
if ($objectType === 'term') {
|
$term = get_term($objectId);
|
if (!$term || is_wp_error($term)) {
|
return 'DefinedTerm';
|
}
|
|
$taxonomyKey = str_replace(BASE, '', $term->taxonomy);
|
|
if (defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$taxonomyKey])) {
|
$config = JVB_TAXONOMY[$taxonomyKey];
|
return $config['seo']['schema']['type'] ?? 'DefinedTerm';
|
}
|
|
return 'DefinedTerm';
|
}
|
|
if ($objectType === 'user') {
|
return 'Person';
|
}
|
|
return 'Thing';
|
}
|
|
/**
|
* 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 MetaManager($objectId, $objectType);
|
$location = $meta->getValue('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;
|
}
|
}
|