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) { return $title; } $context = $this->getCurrentContext(); if (!$context) { return $title; } $metaConfig = $this->config->meta(); if (empty($metaConfig['metaTitle'])) { return $title; } $resolver = $this->getResolver(); $customTitle = $resolver->resolve($metaConfig['metaTitle']); if (!$customTitle) { return $title; } // Strip trailing site name — TSF adds its own branding $siteName = get_bloginfo('name'); $customTitle = preg_replace('/\s*[|\-–—]\s*' . preg_quote($siteName, '/') . '\s*$/i', '', $customTitle); return $customTitle; } /** * 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['metaDescription'])) { return $description; } $resolver = $this->getResolver(); $customDescription = $resolver->resolve($metaConfig['metaDescription']); // 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['socialPreviewImage'])) { $resolver = $this->getResolver(); $imageUrl = $resolver->resolve($metaConfig['socialPreviewImage']); if ($imageUrl) { $params['og:image'] = $imageUrl; // Use twitter-specific image if set, otherwise use main image if (!empty($metaConfig['twitterImage'])) { $twitterImage = $resolver->resolve($metaConfig['twitterImage']); $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\n"; echo '' . "\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['metaTitle'])) { $webpage['name'] = $resolver->resolve($metaConfig['metaTitle']); } if (!empty($metaConfig['metaDescription'])) { $webpage['description'] = $resolver->resolve($metaConfig['metaDescription']); } } 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; } }