<?php
|
namespace JVBase\managers\SEO;
|
|
use JVBase\managers\Cache;
|
use JVBase\managers\SEO\schemas\SchemaResolverRegistry;
|
use JVBase\meta\Meta;
|
use JVBase\managers\SEO\schemas\SchemaDefinition;
|
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 templates (resolver handles transformation)
|
$resolvedConfig = $this->resolveConfigTemplates($schemaConfig, $resolver);
|
|
// Build via resolver system
|
$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.
|
* mainEntity is now handled by CollectionPageResolver::getAutoFields()
|
*/
|
private function buildArchiveSchema(array $context): ?array
|
{
|
if (!$this->config->archive()) {
|
$this->config->setupArchive();
|
}
|
|
$archiveConfig = $this->config->archive();
|
|
if (empty($archiveConfig) || empty($archiveConfig['type'])) {
|
return null;
|
}
|
|
$resolver = $this->getResolver();
|
$resolvedConfig = $this->resolveConfigTemplates($archiveConfig, $resolver);
|
|
// Resolver handles mainEntity auto-enrichment now
|
return $this->buildSchemaFromConfig(
|
$resolvedConfig,
|
$archiveConfig['type'],
|
$resolver->resolveVariable('permalink') . '#' . strtolower($archiveConfig['type'])
|
);
|
}
|
|
/**
|
* 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;
|
}
|
|
/**
|
* Build schema from config using the resolver system.
|
*
|
* Replaces the old double-transform approach with a single-pass
|
* resolver that handles template resolution and transformation.
|
*/
|
private function buildSchemaFromConfig(array $config, string $schemaType, ?string $id = null): ?array
|
{
|
$context = $this->getCurrentContext();
|
|
$definition = SchemaDefinition::fromContext(
|
$this->resolveSchemaType($schemaType),
|
$config,
|
$id,
|
$context
|
);
|
|
$resolver = SchemaResolverRegistry::getInstance()->get($schemaType);
|
$meta = $context ? new Meta($context['objectId'], $context['objectType']) : null;
|
|
return $resolver->resolve($definition, $meta);
|
}
|
|
/**
|
* 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;
|
}
|
}
|