<?php
|
namespace JVBase\managers\SEO;
|
|
use JVBase\meta\Meta;
|
use WP_Post;
|
use WP_Term;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Resolves template variables like {{post_title}} and {{author.name}}
|
*
|
* Supports:
|
* - Direct field access: {{post_title}}, {{bio}}
|
* - Relation access: {{author.name}}, {{shop.location}}
|
* - Site variables: {{site_name}}, {{site_url}}
|
* - Special accessors: {{featured_image_url}}, {{permalink}}
|
* - Auto-enhancement via SchemaFieldHelpers
|
*/
|
class TemplateResolver
|
{
|
private ?int $objectId = null;
|
private ?string $objectType = null;
|
private ?string $contentType = null;
|
private ?Meta $meta = null;
|
private array $context = [];
|
private array $fieldDefinitions = [];
|
|
/**
|
* Create resolver for a specific object
|
*/
|
public function __construct(?int $objectId = null, ?string $objectType = null, ?string $contentType = null)
|
{
|
$this->objectId = $objectId;
|
$this->objectType = $objectType;
|
$this->contentType = $contentType;
|
|
if ($objectId && $objectType) {
|
$this->meta = new Meta($objectId, $objectType, $contentType);
|
$this->loadFieldDefinitions();
|
}
|
|
$this->buildContext();
|
}
|
|
/**
|
* Create resolver for current queried object
|
*/
|
public static function forCurrentObject(): self
|
{
|
if (is_singular()) {
|
$post = get_post();
|
if ($post) {
|
return new self($post->ID, 'post', $post->post_type);
|
}
|
} elseif (is_tax() || is_category() || is_tag()) {
|
$term = get_queried_object();
|
if ($term instanceof WP_Term) {
|
return new self($term->term_id, 'term', $term->taxonomy);
|
}
|
} elseif (is_author()) {
|
$author = get_queried_object();
|
if ($author instanceof WP_User) {
|
return new self($author->ID, 'user', jvbUserRole($author->ID));
|
}
|
} elseif (is_post_type_archive()) {
|
// Get the post type being archived
|
$postType = get_query_var('post_type');
|
if (is_array($postType)) {
|
$postType = reset($postType);
|
}
|
|
// Create resolver with archive context (no objectId needed)
|
return new self(null, 'archive', $postType);
|
}
|
|
// Fallback for pages without specific objects
|
return new self();
|
}
|
|
/**
|
* Resolve a template string
|
*
|
* @param string $template Template with {{variables}}
|
* @return string Resolved string
|
*/
|
public function resolve(string $template): string
|
{
|
return preg_replace_callback(
|
'/\{\{([^}]+)\}\}/',
|
fn($matches) => $this->resolveVariable($matches[1]),
|
$template
|
);
|
}
|
|
/**
|
* Resolve a single variable
|
*/
|
public function resolveVariable(string $variable): mixed
|
{
|
$variable = trim($variable);
|
|
$custom = apply_filters(
|
'jvbSEOResolveVariable',
|
null,
|
$variable,
|
$this->objectId,
|
$this->objectType,
|
$this->contentType,
|
$this->meta
|
);
|
|
if ($custom !== null) {
|
return $this->formatValue($custom, $variable);
|
}
|
|
// Check for dot notation (relation access)
|
if (str_contains($variable, '.')) {
|
return $this->resolveRelation($variable);
|
}
|
|
// Check context first (site variables, etc.)
|
if (isset($this->context[$variable])) {
|
return $this->context[$variable];
|
}
|
|
// Check special accessors
|
$special = $this->resolveSpecial($variable);
|
if ($special !== null) {
|
return $special;
|
}
|
|
// Try to get from Meta.php
|
if ($this->meta) {
|
$value = $this->meta->get($variable);
|
|
// Auto-resolve complex field types via SchemaFieldHelpers
|
$value = $this->autoResolveField($variable, $value);
|
|
return $this->formatValue($value, $variable);
|
}
|
|
// Return empty if not found
|
return '';
|
}
|
|
/**
|
* Auto-resolve field via SchemaFieldHelpers (DELEGATED)
|
*
|
* This is the main integration point - all enhancement logic
|
* is now handled by SchemaFieldHelpers.autoResolve()
|
*/
|
private function autoResolveField(string $fieldName, mixed $value): mixed
|
{
|
if ($value === null || $value === '') {
|
return $value;
|
}
|
|
// Check if this is a relational field that needs a schema reference
|
$fieldDef = $this->fieldDefinitions[$fieldName] ?? null;
|
if ($fieldDef && $this->isRelationalField($fieldDef) && is_numeric($value)) {
|
$objectType = $this->mapFieldTypeToObjectType($fieldDef['type']);
|
return SchemaReferenceBuilder::build($objectType, (int)$value);
|
}
|
|
// Check if this is a term asking for related posts (e.g., shop → artists)
|
if ($this->objectType === 'term' && $this->isRelatedPostsField($fieldName)) {
|
return $this->buildRelatedPostsReferences($fieldName);
|
}
|
|
// Check if this is a post asking for related terms (e.g., artist → styles)
|
if ($this->objectType === 'post' && $this->isRelatedTermsField($fieldName)) {
|
return $this->buildRelatedTermsReferences($fieldName);
|
}
|
|
// Delegate to SchemaFieldHelpers for all other enhancement
|
return SchemaFieldHelpers::autoResolve($fieldName, $value, $this->meta);
|
}
|
|
/**
|
* Check if field name indicates related posts (plural form of post type)
|
*
|
* Examples: "artists", "artworks", "partners"
|
*/
|
private function isRelatedPostsField(string $fieldName): bool
|
{
|
if (!defined('JVB_CONTENT')) {
|
return false;
|
}
|
|
// Check if field name matches any plural content type
|
foreach (JVB_CONTENT as $type => $config) {
|
$plural = strtolower($config['plural'] ?? '');
|
if ($plural && $fieldName === $plural) {
|
return true;
|
}
|
}
|
|
return false;
|
}
|
|
/**
|
* Check if field name indicates related terms (plural form of taxonomy)
|
*
|
* Examples: "styles", "themes", "shops"
|
*/
|
private function isRelatedTermsField(string $fieldName): bool
|
{
|
if (!defined('JVB_TAXONOMY')) {
|
return false;
|
}
|
|
// Check if field name matches any plural taxonomy
|
foreach (JVB_TAXONOMY as $taxonomy => $config) {
|
$plural = strtolower($config['plural'] ?? '');
|
if ($plural && $fieldName === $plural) {
|
return true;
|
}
|
}
|
|
return false;
|
}
|
|
/**
|
* Build references for posts related to current term
|
*/
|
private function buildRelatedPostsReferences(string $fieldName): array
|
{
|
if ($this->objectType !== 'term' || !$this->objectId) {
|
return [];
|
}
|
|
// Find the post type from the field name
|
$postType = $this->getPostTypeFromPluralName($fieldName);
|
if (!$postType) {
|
return [];
|
}
|
|
// Build references (default: 10 items, minimal context)
|
return SchemaReferenceBuilder::buildFromTerm(
|
$this->objectId,
|
$postType,
|
limit: 10,
|
includeContext: true
|
);
|
}
|
|
/**
|
* Build references for terms related to current post
|
*/
|
private function buildRelatedTermsReferences(string $fieldName): array
|
{
|
if ($this->objectType !== 'post' || !$this->objectId) {
|
return [];
|
}
|
|
// Find the taxonomy from the field name
|
$taxonomy = $this->getTaxonomyFromPluralName($fieldName);
|
if (!$taxonomy) {
|
return [];
|
}
|
|
// Build references (default: 10 items, minimal context)
|
return SchemaReferenceBuilder::buildFromPost(
|
$this->objectId,
|
$taxonomy,
|
limit: 10,
|
includeContext: false // Terms usually don't need context
|
);
|
}
|
|
/**
|
* Get post type key from plural name
|
*/
|
private function getPostTypeFromPluralName(string $pluralName): ?string
|
{
|
if (!defined('JVB_CONTENT')) {
|
return null;
|
}
|
|
foreach (JVB_CONTENT as $type => $config) {
|
$plural = strtolower($config['plural'] ?? '');
|
if ($plural === $pluralName) {
|
return $type;
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Get taxonomy key from plural name
|
*/
|
private function getTaxonomyFromPluralName(string $pluralName): ?string
|
{
|
if (!defined('JVB_TAXONOMY')) {
|
return null;
|
}
|
|
foreach (JVB_TAXONOMY as $taxonomy => $config) {
|
$plural = strtolower($config['plural'] ?? '');
|
if ($plural === $pluralName) {
|
return $taxonomy;
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Check if field is relational (references another entity)
|
*/
|
private function isRelationalField(array $fieldDef): bool
|
{
|
return in_array($fieldDef['type'] ?? '', ['post', 'post_object', 'taxonomy', 'user']);
|
}
|
|
/**
|
* Map field type to object type for SchemaReferenceBuilder
|
*/
|
private function mapFieldTypeToObjectType(string $fieldType): string
|
{
|
return match($fieldType) {
|
'post', 'post_object' => 'post',
|
'taxonomy' => 'term',
|
'user' => 'user',
|
default => 'post'
|
};
|
}
|
|
/**
|
* Resolve dot notation like {{author.name}} or {{shop.location.address}}
|
*/
|
private function resolveRelation(string $path): string
|
{
|
$parts = explode('.', $path);
|
$relation = array_shift($parts);
|
$field = implode('.', $parts);
|
|
// Get the related object
|
$related = $this->getRelatedObject($relation);
|
if (!$related) {
|
return '';
|
}
|
|
// Create a resolver for the related object and resolve the field
|
$relatedResolver = $this->createRelatedResolver($related);
|
if (!$relatedResolver) {
|
return '';
|
}
|
|
return $relatedResolver->resolveVariable($field);
|
}
|
|
/**
|
* Get a related object by relation name
|
*/
|
private function getRelatedObject(string $relation): mixed
|
{
|
if (!$this->meta) {
|
return null;
|
}
|
|
// Common relations
|
switch ($relation) {
|
case 'author':
|
if ($this->objectType === 'post') {
|
$post = get_post($this->objectId);
|
return $post ? get_user_by('id', $post->post_author) : null;
|
}
|
break;
|
|
case 'featured_image':
|
if ($this->objectType === 'post') {
|
$imageId = get_post_thumbnail_id($this->objectId);
|
return $imageId ? get_post($imageId) : null;
|
}
|
break;
|
}
|
|
// Check field definitions for taxonomy or post relations
|
if (isset($this->fieldDefinitions[$relation])) {
|
$fieldDef = $this->fieldDefinitions[$relation];
|
$value = $this->meta->get($relation);
|
|
if (!$value) {
|
return null;
|
}
|
|
switch ($fieldDef['type'] ?? '') {
|
case 'taxonomy':
|
// Get first term from taxonomy
|
$taxonomy = $fieldDef['taxonomy'] ?? $relation;
|
if ($this->objectType === 'post') {
|
$terms = wp_get_post_terms($this->objectId, jvbCheckBase($taxonomy));
|
return !empty($terms) && !is_wp_error($terms) ? $terms[0] : null;
|
}
|
return is_numeric($value) ? get_term($value) : null;
|
|
case 'post':
|
case 'post_object':
|
return get_post($value);
|
|
case 'user':
|
return get_user_by('id', $value);
|
}
|
}
|
|
// Check if it's a taxonomy on the post
|
if ($this->objectType === 'post') {
|
$taxonomyName = jvbCheckBase($relation);
|
if (taxonomy_exists($taxonomyName)) {
|
$terms = wp_get_post_terms($this->objectId, $taxonomyName);
|
return !empty($terms) && !is_wp_error($terms) ? $terms[0] : null;
|
}
|
}
|
|
return null;
|
}
|
|
/**
|
* Create a resolver for a related object
|
*/
|
private function createRelatedResolver(mixed $object): ?self
|
{
|
if ($object instanceof WP_Post) {
|
return new self($object->ID, 'post', $object->post_type);
|
} elseif ($object instanceof WP_Term) {
|
return new self($object->term_id, 'term', $object->taxonomy);
|
} elseif ($object instanceof WP_User) {
|
return new self($object->ID, 'user', jvbUserRole($object->ID));
|
}
|
|
return null;
|
}
|
|
/**
|
* Resolve special built-in variables
|
*/
|
private function resolveSpecial(string $variable): ?string
|
{
|
// Location component accessors
|
if (str_starts_with($variable, 'location_')) {
|
$component = substr($variable, 9);
|
return $this->resolveLocationComponent($component);
|
}
|
|
// Image URL accessors for different sizes
|
if (str_ends_with($variable, '_image_url')) {
|
$field = str_replace('_image_url', '', $variable);
|
$imageId = $this->meta?->get($field);
|
if ($imageId) {
|
return wp_get_attachment_image_url($imageId, 'full') ?: '';
|
}
|
}
|
|
switch ($variable) {
|
case 'permalink':
|
case 'url':
|
return $this->getObjectUrl();
|
|
case 'featured_image_url':
|
case 'thumbnail_url':
|
if ($this->objectType === 'post') {
|
$url = get_the_post_thumbnail_url($this->objectId, 'full');
|
return $url ?: '';
|
}
|
return '';
|
|
case 'post_date':
|
case 'date_published':
|
if ($this->objectType === 'post') {
|
return get_the_date('c', $this->objectId);
|
}
|
return '';
|
|
case 'post_modified':
|
case 'date_modified':
|
if ($this->objectType === 'post') {
|
return get_the_modified_date('c', $this->objectId);
|
}
|
return '';
|
|
case 'term_count':
|
case 'count':
|
if ($this->objectType === 'term') {
|
$term = get_term($this->objectId);
|
return $term ? (string)$term->count : '0';
|
}
|
return '';
|
}
|
|
return null;
|
}
|
|
/**
|
* Resolve location component (e.g., location_address, location_city)
|
*/
|
private function resolveLocationComponent(string $component): string
|
{
|
$location = $this->meta?->get('location');
|
|
if (!is_array($location)) {
|
return '';
|
}
|
|
return (string)($location[$component] ?? '');
|
}
|
|
/**
|
* Get URL for current object
|
*/
|
private function getObjectUrl(): string
|
{
|
return match($this->objectType) {
|
'post' => get_permalink($this->objectId) ?: '',
|
'term' => is_wp_error($link = get_term_link($this->objectId)) ? '' : $link,
|
'user' => get_author_posts_url($this->objectId) ?: '',
|
'archive' => $this->contentType ? (get_post_type_archive_link(jvbCheckBase($this->contentType)) ?: '') : '',
|
default => ''
|
};
|
}
|
|
/**
|
* Format a value for output
|
*/
|
private function formatValue(mixed $value, string $field = ''): string
|
{
|
if (is_null($value) || $value === '') {
|
return '';
|
}
|
|
if (is_array($value)) {
|
return $this->formatArrayValue($value, $field);
|
}
|
|
if (is_bool($value)) {
|
return $value ? 'true' : 'false';
|
}
|
|
return (string)$value;
|
}
|
|
/**
|
* Format array values
|
*/
|
private function formatArrayValue(array $value, string $field): string
|
{
|
// Check if it's a repeater with sub-fields
|
if (isset($value[0]) && is_array($value[0])) {
|
// Extract specific field if pattern indicates
|
$subField = $this->getArraySubField($field);
|
if ($subField) {
|
$extracted = array_column($value, $subField);
|
return implode(', ', array_filter($extracted));
|
}
|
|
// Default: try common field names
|
foreach (['name', 'title', 'url', 'value', 'keyword', 'language'] as $key) {
|
if (isset($value[0][$key])) {
|
return implode(', ', array_column($value, $key));
|
}
|
}
|
}
|
|
// Simple array
|
return implode(', ', array_filter($value));
|
}
|
|
/**
|
* Check if field name indicates a sub-field extraction
|
*/
|
private function getArraySubField(string $field): ?string
|
{
|
// Pattern: field_name[sub_field]
|
if (preg_match('/\[(\w+)\]$/', $field, $matches)) {
|
return $matches[1];
|
}
|
return null;
|
}
|
|
/**
|
* Build context with site-wide variables
|
*/
|
private function buildContext(): void
|
{
|
$this->context = [
|
'site_name' => get_bloginfo('name'),
|
'site_description' => get_bloginfo('description'),
|
'site_url' => get_home_url(),
|
'current_url' => $this->getCurrentUrl(),
|
'current_year' => date('Y'),
|
'current_date' => date('Y-m-d'),
|
];
|
|
// Add object-specific context
|
if ($this->objectType === 'post' && $this->objectId) {
|
$post = get_post($this->objectId);
|
if ($post) {
|
$this->context['post_title'] = $post->post_title;
|
$this->context['post_excerpt'] = $post->post_excerpt ?: wp_trim_words($post->post_content, 20);
|
$this->context['post_type'] = $post->post_type;
|
}
|
} elseif ($this->objectType === 'term' && $this->objectId) {
|
$term = get_term($this->objectId);
|
if ($term && !is_wp_error($term)) {
|
$this->context['term_name'] = html_entity_decode($term->name);
|
$this->context['term_description'] = wptexturize($term->description);
|
$this->context['taxonomy'] = $term->taxonomy;
|
}
|
} elseif ($this->objectType === 'user' && $this->objectId) {
|
$user = get_userdata($this->objectId);
|
if ($user) {
|
$this->context['user_name'] = $user->display_name;
|
$this->context['user_login'] = $user->user_login;
|
}
|
} elseif ($this->objectType === 'archive' && $this->contentType) {
|
// Archive-specific context
|
$postType = jvbCheckBase($this->contentType);
|
$postTypeObject = get_post_type_object($postType);
|
|
if ($postTypeObject) {
|
$this->context['archive_title'] = $postTypeObject->labels->name ?? '';
|
$this->context['archive_description'] = $postTypeObject->description ?? '';
|
$this->context['archive_url'] = get_post_type_archive_link($postType) ?: '';
|
$this->context['post_type'] = $postType;
|
$this->context['post_type_label'] = $postTypeObject->labels->singular_name ?? '';
|
}
|
}
|
}
|
|
/**
|
* Get current URL
|
*/
|
private function getCurrentUrl(): string
|
{
|
global $wp;
|
return home_url($wp->request);
|
}
|
|
/**
|
* Load field definitions from content config
|
*/
|
private function loadFieldDefinitions(): void
|
{
|
if (!$this->contentType) {
|
return;
|
}
|
|
$typeKey = str_replace(BASE, '', $this->contentType);
|
|
if ($this->objectType === 'post' && defined('JVB_CONTENT')) {
|
$this->fieldDefinitions = JVB_CONTENT[$typeKey]['fields'] ?? [];
|
} elseif ($this->objectType === 'term' && defined('JVB_TAXONOMY')) {
|
$this->fieldDefinitions = JVB_TAXONOMY[$typeKey]['fields'] ?? [];
|
} elseif ($this->objectType === 'user' && defined('JVB_USER')) {
|
$this->fieldDefinitions = JVB_USER[$typeKey]['fields'] ?? [];
|
}
|
}
|
}
|