<?php
|
namespace JVBase\managers\SEO;
|
|
use JVBase\managers\Cache;
|
use JVBase\meta\Meta;
|
use WP_Term;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Handles SEO output: Schema.org JSON and TSF meta filtering
|
*
|
* Integrates with The SEO Framework, letting it handle defaults
|
* while we override with our configured templates.
|
*
|
* Now with integrated caching via Cache for performance.
|
*/
|
class SchemaOutputManager
|
{
|
private ConfigManager $config;
|
private SchemaBuilder $registry;
|
private ?TemplateResolver $resolver = null;
|
private Cache $cache;
|
private array $pseudoTypes = [
|
'BeforeAfter',
|
];
|
|
public function __construct()
|
{
|
$this->registry = SchemaBuilder::getInstance();
|
$this->cache = Cache::for('schema')
|
->connect('post',true)
|
->connect('taxonomy',true)
|
->connect('user',true);
|
|
// Hook into TSF for meta
|
add_filter('the_seo_framework_title_from_generation', [$this, 'filterTitle'], 10, 2);
|
add_filter('the_seo_framework_generated_description', [$this, 'filterDescription'], 10, 3);
|
|
// Add image filters
|
add_filter('the_seo_framework_image_generation_params', [$this, 'filterImage'], 10, 3);
|
|
// Disable TSF schema on our content (we'll output our own)
|
add_filter('the_seo_framework_schema_graph_data', [$this, 'filterTSFSchema'], 10, 2);
|
|
// Output our schema
|
add_action('wp_head', [$this, 'outputSchema'], 1);
|
add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeHiddenSingles'], 10, 1);
|
}
|
|
/**
|
* Exclude posts from sitemap based on hide_single and is_timeline flags
|
*
|
* @param array $ids Array of post IDs to exclude
|
* @return array Modified array with hidden posts added
|
*/
|
public function excludeHiddenSingles(array $ids): array
|
{
|
$hiddenTypes = [];
|
$timelineTypes = [];
|
|
// Find post types with hide_single or is_timeline flags
|
foreach (JVB_CONTENT as $slug => $config) {
|
$postType = BASE . $slug;
|
|
if (!empty($config['hide_single'])) {
|
$hiddenTypes[] = $postType;
|
}
|
|
if (!empty($config['is_timeline'])) {
|
$timelineTypes[] = $postType;
|
}
|
}
|
|
$hiddenIds = [];
|
|
// Get all posts from hide_single types
|
if (!empty($hiddenTypes)) {
|
$hiddenIds = $this->cache->remember(
|
'hidden_single_posts',
|
function() use ($hiddenTypes) {
|
return get_posts([
|
'post_type' => $hiddenTypes,
|
'posts_per_page' => -1,
|
'fields' => 'ids',
|
'post_status' => 'publish',
|
]);
|
}
|
);
|
}
|
|
// Get child posts from timeline types
|
if (!empty($timelineTypes)) {
|
$timelineChildIds = $this->cache->remember(
|
'timeline_child_posts',
|
function() use ($timelineTypes) {
|
return get_posts([
|
'post_type' => $timelineTypes,
|
'posts_per_page' => -1,
|
'fields' => 'ids',
|
'post_status' => 'publish',
|
'post_parent__not_in' => [0], // Only get posts with a parent
|
]);
|
}
|
);
|
|
$hiddenIds = array_merge($hiddenIds, $timelineChildIds);
|
}
|
|
return array_merge($ids, $hiddenIds);
|
}
|
|
/**
|
* Filter the SEO title
|
*/
|
public function filterTitle(string $title, ?array $args): string
|
{
|
if ($args !== null) {
|
// Not in the loop (admin, etc.)
|
return $title;
|
}
|
|
$context = $this->getCurrentContext();
|
if (!$context) {
|
return $title;
|
}
|
|
$metaConfig = $this->config->meta();
|
|
if (empty($metaConfig['title'])) {
|
return $title;
|
}
|
|
$resolver = $this->getResolver();
|
$customTitle = $resolver->resolve($metaConfig['title']);
|
|
return $customTitle ?: $title;
|
}
|
|
/**
|
* Filter the SEO description
|
*/
|
public function filterDescription(string $description, ?array $args, string $type): string
|
{
|
if ($args !== null) {
|
return $description;
|
}
|
|
$context = $this->getCurrentContext();
|
if (!$context) {
|
return $description;
|
}
|
|
$metaConfig = $this->config->meta();
|
|
if (empty($metaConfig['description'])) {
|
return $description;
|
}
|
|
$resolver = $this->getResolver();
|
$customDescription = $resolver->resolve($metaConfig['description']);
|
|
// Truncate to reasonable length
|
if (strlen($customDescription) > 160) {
|
$customDescription = substr($customDescription, 0, 157) . '...';
|
}
|
|
return $customDescription ?: $description;
|
}
|
|
/**
|
* Filter the SEO image for social previews
|
*/
|
public function filterImage(array $params, ?array $args, $tsf_id): array
|
{
|
if ($args !== null) {
|
return $params;
|
}
|
|
$context = $this->getCurrentContext();
|
if (!$context) {
|
return $params;
|
}
|
|
$metaConfig = $this->config->meta();
|
|
// Check for custom image
|
if (!empty($metaConfig['image'])) {
|
$resolver = $this->getResolver();
|
$imageUrl = $resolver->resolve($metaConfig['image']);
|
|
if ($imageUrl) {
|
$params['og:image'] = $imageUrl;
|
|
// Use twitter-specific image if set, otherwise use main image
|
if (!empty($metaConfig['twitter_image'])) {
|
$twitterImage = $resolver->resolve($metaConfig['twitter_image']);
|
$params['twitter:image'] = $twitterImage ?: $imageUrl;
|
} else {
|
$params['twitter:image'] = $imageUrl;
|
}
|
}
|
}
|
|
return $params;
|
}
|
|
/**
|
* Disable TSF schema for our custom content types
|
*/
|
public function filterTSFSchema(array $graph, ?array $args): array
|
{
|
if ($args !== null) {
|
return $graph;
|
}
|
|
$context = $this->getCurrentContext();
|
if ($context) {
|
// We're handling schema for this content
|
return [];
|
}
|
|
return $graph;
|
}
|
|
/**
|
* Output schema JSON-LD
|
*/
|
public function outputSchema(): void
|
{
|
// Build cache key
|
$context = $this->getCurrentContext();
|
$cacheKey = $this->buildCacheKey($context);
|
|
// Try to get from cache
|
$schema = $this->cache->get($cacheKey);
|
|
if ($schema === false) {
|
// Build schema
|
$schema = $this->buildSchema();
|
|
// Cache for 1 hour (will auto-invalidate on content update)
|
$this->cache->set($cacheKey, $schema, HOUR_IN_SECONDS);
|
}
|
|
if (empty($schema)) {
|
return;
|
}
|
|
echo "\n<!-- SEO Schema by Jake Van -->\n";
|
echo '<script type="application/ld+json">' . "\n";
|
echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
echo "\n" . '</script>' . "\n";
|
}
|
|
private function resolveSchemaType(string $configuredType): string
|
{
|
// Only resolve pseudo-types (custom types not in schema.org)
|
if (in_array($configuredType, $this->pseudoTypes)) {
|
$typeDef = $this->registry->getTypeDefinition($configuredType);
|
if ($typeDef && !empty($typeDef['extends'])) {
|
// Recursively resolve in case parent is also pseudo
|
return $this->resolveSchemaType($typeDef['extends']);
|
}
|
}
|
|
// Use configured type (it's a real schema.org type)
|
return $configuredType;
|
}
|
|
/**
|
* Build cache key for current context
|
*/
|
private function buildCacheKey(?array $context): string
|
{
|
if (!$context) {
|
return 'home_' . get_current_blog_id();
|
}
|
|
return "{$context['objectType']}_{$context['objectId']}_{$context['type']}";
|
}
|
|
/**
|
* Build complete schema structure
|
*/
|
private function buildSchema(): array
|
{
|
$schema = [
|
'@context' => 'https://schema.org',
|
'@graph' => []
|
];
|
|
// Always include Website schema
|
$websiteSchema = $this->buildSchemaForType('website', 'WebSite', '/#website');
|
if ($websiteSchema) {
|
$websiteSchema['url'] = $websiteSchema['url'] ?? get_home_url();
|
$websiteSchema['name'] = $websiteSchema['name'] ?? get_bloginfo('name');
|
$websiteSchema['publisher'] = ['@id' => get_home_url() . '/#organization'];
|
$websiteSchema['creator'] = SchemaFieldHelpers::getCreator();
|
$schema['@graph'][] = $websiteSchema;
|
}
|
|
// Include Organization schema on home page
|
if (is_front_page()) {
|
$orgSchema = $this->buildSchemaForType('organization', null, '/#organization');
|
if ($orgSchema && !empty($orgSchema['name'])) {
|
$schema['@graph'][] = $orgSchema;
|
}
|
}
|
|
$webPageSchema = $this->buildWebPageSchema();
|
if ($webPageSchema) {
|
$schema['@graph'][] = $webPageSchema;
|
}
|
|
// Include context-specific schema
|
$contextSchema = $this->buildContextSchema();
|
if ($contextSchema) {
|
$schema['@graph'][] = $contextSchema;
|
}
|
|
// Include breadcrumbs
|
$breadcrumbs = $this->buildBreadcrumbSchema();
|
if ($breadcrumbs) {
|
$schema['@graph'][] = $breadcrumbs;
|
}
|
|
return $schema;
|
}
|
|
/**
|
* Generic schema builder - replaces buildWebsiteSchema, buildOrganizationSchema, etc.
|
*
|
* @param string $configKey Config key (site, business, post_type, etc.)
|
* @param string|null $forceType Force a specific schema type (optional)
|
* @param string|null $id Schema @id suffix
|
*/
|
private function buildSchemaForType(string $configKey, ?string $forceType = null, ?string $id = null): ?array
|
{
|
$this->config = ConfigManager::for($configKey);
|
$config = $this->config->schema();
|
|
if (empty($config)) {
|
return null;
|
}
|
|
$schemaType = $forceType ?? $config['type'] ?? null;
|
if (!$schemaType) {
|
return null;
|
}
|
|
// Build full @id if suffix provided
|
$fullId = $id ? get_home_url() . $id : null;
|
|
// Use the generic builder
|
return $this->buildSchemaFromConfig($config, $schemaType, $fullId);
|
}
|
|
/**
|
* Build schema for current context (page, post, term, etc.)
|
*/
|
private function buildContextSchema(): ?array
|
{
|
$context = $this->getCurrentContext();
|
|
if (!$context) {
|
return null;
|
}
|
|
// For archives, use archive config
|
if (in_array($context['objectType'], ['archive', 'term'])) {
|
return $this->buildArchiveSchema($context);
|
}
|
|
$schemaConfig = $this->config->schema();
|
|
if (empty($schemaConfig) || empty($schemaConfig['type'])) {
|
return null;
|
}
|
|
$resolver = $this->getResolver();
|
$schemaType = $schemaConfig['type'];
|
|
// Resolve all field values from templates
|
$resolvedConfig = $this->resolveConfigTemplates($schemaConfig, $resolver);
|
|
// Build schema with resolved values
|
$schema = $this->buildSchemaFromConfig(
|
$resolvedConfig,
|
$schemaType,
|
$resolver->resolveVariable('permalink') . '#' . strtolower($schemaType)
|
);
|
|
// Add mainEntityOfPage for content items
|
if ($schema && $schemaType !== 'FAQPage') {
|
$schema['mainEntityOfPage'] = [
|
'@type' => 'WebPage',
|
'@id' => $resolver->resolveVariable('permalink'),
|
];
|
}
|
|
return $schema;
|
}
|
/**
|
* Build schema for archive pages
|
* Automatically generates mainEntity from archive posts
|
*/
|
private function buildArchiveSchema(array $context): ?array
|
{
|
// Ensure archive config is initialized
|
if (!$this->config->archive()) {
|
$this->config->setupArchive();
|
}
|
|
$archiveConfig = $this->config->archive();
|
|
// Return null if no config or no type defined
|
if (empty($archiveConfig) || empty($archiveConfig['type'])) {
|
return null;
|
}
|
|
$resolver = $this->getResolver();
|
$schemaType = $archiveConfig['type'];
|
|
// Resolve templates from archive config
|
$resolvedConfig = $this->resolveConfigTemplates($archiveConfig, $resolver);
|
|
// Build base schema
|
$schema = $this->buildSchemaFromConfig(
|
$resolvedConfig,
|
$schemaType,
|
$resolver->resolveVariable('permalink') . '#' . strtolower($schemaType)
|
);
|
|
if (!$schema) {
|
return null;
|
}
|
|
// Automatically add mainEntity for types that need it
|
$mainEntity = $this->buildMainEntity($schemaType, $context['type']);
|
if ($mainEntity) {
|
$schema['mainEntity'] = $mainEntity;
|
}
|
|
return $schema;
|
}
|
|
/**
|
* Automatically build mainEntity for archive pages
|
* Uses SchemaReferenceBuilder to generate entities from archive posts
|
*
|
* @param string $archiveSchemaType The archive's @type (FAQPage, CollectionPage, etc.)
|
* @param string $contentType The content type being archived (faq, artwork, etc.)
|
* @return array|null Array of entities or null if not applicable
|
*/
|
private function buildMainEntity(string $archiveSchemaType, string $contentType): ?array
|
{
|
// Only certain archive types need mainEntity
|
$typesNeedingMainEntity = ['FAQPage', 'CollectionPage', 'ItemList'];
|
if (!in_array($archiveSchemaType, $typesNeedingMainEntity)) {
|
return null;
|
}
|
|
$context = $this->getCurrentContext();
|
|
// For taxonomy term archives, get posts from the term
|
if ($context['objectType'] === 'term') {
|
// Get the post type(s) this taxonomy is for
|
$taxonomy = defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$contentType])
|
? JVB_TAXONOMY[$contentType]
|
: null;
|
|
if (!$taxonomy || empty($taxonomy['for_content'])) {
|
return null;
|
}
|
|
// Use the first post type (most common case)
|
$postType = $taxonomy['for_content'][0];
|
|
return SchemaReferenceBuilder::buildFromTerm(
|
$context['objectId'],
|
$postType,
|
10, // limit
|
null, // auto-infer type
|
true // include context
|
);
|
}
|
|
// For post type archives
|
if ($context['objectType'] === 'archive') {
|
return SchemaReferenceBuilder::buildFromArchive($contentType);
|
}
|
|
return null;
|
}
|
|
/**
|
* Resolve all template patterns in config
|
*/
|
private function resolveConfigTemplates(array $config, TemplateResolver $resolver): array
|
{
|
$resolved = ['type' => $config['type']];
|
|
foreach ($config as $fieldName => $value) {
|
if ($fieldName === 'type') {
|
continue;
|
}
|
|
$resolvedValue = $this->resolveFieldValue($fieldName, $value, $resolver);
|
|
if ($resolvedValue !== null && $resolvedValue !== '') {
|
$resolved[$fieldName] = $resolvedValue;
|
}
|
}
|
|
return $resolved;
|
}
|
|
/**
|
* Enhanced buildSchemaFromConfig with Meta integration
|
*/
|
private function buildSchemaFromConfig(array $config, string $schemaType, ?string $id = null): ?array
|
{
|
// Build base schema
|
$schema = ['@type' => $this->resolveSchemaType($schemaType)];
|
|
if ($id) {
|
$schema['@id'] = $id;
|
}
|
|
// Get Meta if we have a context
|
$meta = null;
|
$context = $this->getCurrentContext();
|
if ($context) {
|
$meta = new Meta($context['objectId'], $context['objectType']);
|
}
|
|
// Process each field
|
foreach ($config as $fieldName => $value) {
|
// Skip meta fields and empty values
|
if ($fieldName === 'type' || $value === null || $value === '' || $value === []) {
|
continue;
|
}
|
|
// Auto-resolve field value (handles images, locations, etc.)
|
$value = SchemaFieldHelpers::autoResolve($fieldName, $value, $meta);
|
|
// Get field definition for transformer
|
$fieldDef = $this->registry->getFieldDefinition($fieldName);
|
|
// Apply transformer if defined
|
if ($fieldDef && !empty($fieldDef['transformer'])) {
|
$value = $this->applyTransformer($value, $fieldDef['transformer'], $fieldName);
|
}
|
|
// Skip if empty after transformation
|
if ($value === null || $value === '' || $value === []) {
|
continue;
|
}
|
|
// Handle multi-property transformers (like location_complex returns address + geo)
|
if (is_array($value) && !isset($value['@type']) && !isset($value[0])) {
|
$multiProps = ['address', 'geo', 'openingHours', 'sameAs'];
|
if (!empty(array_intersect(array_keys($value), $multiProps))) {
|
foreach ($value as $subKey => $subValue) {
|
if ($subValue !== null && $subValue !== '' && $subValue !== []) {
|
$schema[$subKey] = $subValue;
|
}
|
}
|
continue;
|
}
|
}
|
|
// Normal case: add single property
|
$schema[$fieldName] = $value;
|
}
|
|
// Return null if only @type remains
|
return (count($schema) > 1) ? $schema : null;
|
}
|
|
/**
|
* Apply transformer to a field value
|
*/
|
private function applyTransformer(mixed $value, string $transformer, string $fieldName): mixed
|
{
|
// Check if transformer method exists in SchemaFieldHelpers
|
if (method_exists(SchemaFieldHelpers::class, $transformer)) {
|
try {
|
return SchemaFieldHelpers::$transformer($value);
|
} catch (\Throwable $e) {
|
// Log error but don't break schema output
|
error_log("Schema transformer error for {$fieldName}: {$e->getMessage()}");
|
return $value;
|
}
|
}
|
|
// No transformer found, return value as-is
|
return $value;
|
}
|
|
/**
|
* Resolve a field value from template
|
*/
|
private function resolveFieldValue(string $key, mixed $template, TemplateResolver $resolver): mixed
|
{
|
if (is_string($template)) {
|
// Simple template pattern
|
$value = $resolver->resolve($template);
|
|
// If it's still a pattern (unresolved), skip it
|
if (SchemaFieldHelpers::isPattern($value)) {
|
return null;
|
}
|
|
return $value !== '' ? $value : null;
|
}
|
|
if (is_array($template)) {
|
// Complex nested structure - resolve recursively
|
$resolved = [];
|
foreach ($template as $subKey => $subValue) {
|
$resolvedValue = $this->resolveFieldValue($subKey, $subValue, $resolver);
|
if ($resolvedValue !== null) {
|
$resolved[$subKey] = $resolvedValue;
|
}
|
}
|
return !empty($resolved) ? $resolved : null;
|
}
|
|
// Direct value (not a template)
|
return $template;
|
}
|
|
/**
|
* Build WebPage schema for current page (including homepage)
|
*/
|
private function buildWebPageSchema(): ?array
|
{
|
$webpage = [
|
'@type' => 'WebPage',
|
'@id' => get_permalink() . '/#webpage',
|
'url' => get_permalink(),
|
'isPartOf' => ['@id' => get_home_url() . '/#website'],
|
];
|
|
// Add about relationship on homepage (pointing to organization)
|
if (is_front_page()) {
|
$webpage['about'] = ['@id' => get_home_url() . '/#organization'];
|
$webpage['name'] = get_bloginfo('name');
|
$webpage['description'] = get_bloginfo('description');
|
} else {
|
// For other pages, use page-specific meta
|
$resolver = $this->getResolver();
|
$metaConfig = $this->config->meta();
|
|
if (!empty($metaConfig['title'])) {
|
$webpage['name'] = $resolver->resolve($metaConfig['title']);
|
}
|
|
if (!empty($metaConfig['description'])) {
|
$webpage['description'] = $resolver->resolve($metaConfig['description']);
|
}
|
}
|
|
return $webpage;
|
}
|
|
/**
|
* Build breadcrumb schema
|
*/
|
private function buildBreadcrumbSchema(): array
|
{
|
$breadcrumbs = BreadcrumbManager::getInstance();
|
return $breadcrumbs->toSchema();
|
}
|
|
/**
|
* Get current context (what page/content are we on?)
|
*/
|
private function getCurrentContext(): ?array
|
{
|
if (is_singular()) {
|
$post = get_post();
|
if ($post) {
|
$postType = jvbNoBase($post->post_type);
|
if (defined('JVB_CONTENT') && isset(JVB_CONTENT[$postType])) {
|
$this->config = ConfigManager::for($postType);
|
return [
|
'objectType' => 'post',
|
'objectId' => $post->ID,
|
'type' => $postType,
|
];
|
}
|
}
|
} elseif (is_tax()) {
|
$term = get_queried_object();
|
if ($term instanceof WP_Term) {
|
$taxonomy = jvbNoBase($term->taxonomy);
|
if (defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$taxonomy])) {
|
$this->config = ConfigManager::for($taxonomy);
|
return [
|
'objectType' => 'term',
|
'objectId' => $term->term_id,
|
'type' => $taxonomy,
|
];
|
}
|
}
|
} elseif (is_author()) {
|
$user = get_queried_object();
|
if ($user instanceof WP_User) {
|
$role = jvbUserRole($user->ID);
|
if (defined('JVB_USER') && isset(JVB_USER[$role])) {
|
$this->config = ConfigManager::for($role);
|
return [
|
'objectType' => 'user',
|
'objectId' => $user->ID,
|
'type' => $role,
|
];
|
}
|
}
|
} elseif (is_post_type_archive()) {
|
$postType = get_query_var('post_type');
|
if (is_array($postType)) {
|
$postType = reset($postType);
|
}
|
$postType = jvbNoBase($postType);
|
|
if (defined('JVB_CONTENT') && isset(JVB_CONTENT[$postType])) {
|
$this->config = ConfigManager::for($postType);
|
return [
|
'objectType' => 'archive',
|
'objectId' => 0,
|
'type' => $postType,
|
];
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Get or create resolver for current context
|
*/
|
private function getResolver(): TemplateResolver
|
{
|
if ($this->resolver === null) {
|
$this->resolver = TemplateResolver::forCurrentObject();
|
}
|
return $this->resolver;
|
}
|
|
/**
|
* Extract URLs from array of link objects or strings
|
*/
|
private function extractUrls(array $links): array
|
{
|
$urls = [];
|
foreach ($links as $link) {
|
if (is_array($link) && isset($link['url'])) {
|
$urls[] = $link['url'];
|
} elseif (is_string($link)) {
|
$urls[] = $link;
|
}
|
}
|
return $urls;
|
}
|
}
|