PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.25.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.25.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-pattern-resolver.php

class-pattern-resolver.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.25.0, at includes/seo/class-pattern-resolver.php

225 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Variable-tag pattern resolver.
4 *
5 * Resolves the Global / Bulk SEO variable-tag patterns (e.g.
6 * "%title% %sep% %sitename%") into concrete values for a specific post,
7 * independent of the main query / loop. Used to preview, inside the post
8 * editor, the value the frontend will output when a per-post SEO field is left
9 * empty — the frontend already falls back to these same patterns.
10 *
11 * @package ThinkRank\SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Resolves Global SEO patterns for an explicit post.
25 *
26 * @since 1.0.0
27 */
28 class Pattern_Resolver {
29
30 /**
31 * Option holding the per-post-type Global SEO patterns.
32 */
33 private const OPTION_NAME = 'thinkrank_global_seo_settings';
34
35 /**
36 * Default title pattern (mirrors the Global SEO endpoint default).
37 */
38 private const DEFAULT_TITLE = '%title% %sep% %sitename%';
39
40 /**
41 * Default description pattern (mirrors the Global SEO endpoint default).
42 */
43 private const DEFAULT_DESCRIPTION = '%excerpt%';
44
45 /**
46 * Resolve the SEO title pattern for a post.
47 *
48 * @param int $post_id Post ID.
49 * @return string Resolved title, or '' when it resolves to nothing.
50 */
51 public static function title(int $post_id): string {
52 $template = self::template_for($post_id, 'title', self::DEFAULT_TITLE);
53 return self::resolve_value($template, $post_id);
54 }
55
56 /**
57 * Resolve any variable-tag string against a post's values.
58 *
59 * Replaces tokens (e.g. "%title% %sep% %sitename%") with the post's actual
60 * values. A literal string containing no tokens passes through unchanged, so
61 * this is safe to run over per-post SEO fields that may or may not hold a
62 * pattern.
63 *
64 * @param string $value Raw string, possibly containing variable tags.
65 * @param int $post_id Post ID.
66 * @return string Resolved string.
67 */
68 public static function resolve_value(string $value, int $post_id): string {
69 if (strpos($value, '%') === false) {
70 return $value;
71 }
72 return self::process($value, self::placeholders_for($post_id));
73 }
74
75 /**
76 * Token => value map for a post, keyed WITHOUT the surrounding percents
77 * (e.g. 'title' => 'My Post'). Used by the editor for live client-side
78 * preview of a pattern as the user types.
79 *
80 * @param int $post_id Post ID.
81 * @return array<string,string> Variable map.
82 */
83 public static function variables(int $post_id): array {
84 $map = [];
85 foreach (self::placeholders_for($post_id) as $token => $value) {
86 $map[trim($token, '%')] = $value;
87 }
88 return $map;
89 }
90
91 /**
92 * Resolve the meta description pattern for a post.
93 *
94 * Trimmed to the same ~160-char ceiling the frontend applies on output.
95 *
96 * @param int $post_id Post ID.
97 * @return string Resolved description, or '' when it resolves to nothing.
98 */
99 public static function description(int $post_id): string {
100 $template = self::template_for($post_id, 'description', self::DEFAULT_DESCRIPTION);
101 $description = self::resolve_value($template, $post_id);
102
103 if (strlen($description) > 160) {
104 $description = wp_trim_words($description, 25, '...');
105 }
106
107 return $description;
108 }
109
110 /**
111 * Build the full set of pattern previews for the post editor.
112 *
113 * Social fields mirror the frontend fallback: an empty og/twitter title
114 * resolves to the SEO title, and an empty og/twitter description to the
115 * meta description.
116 *
117 * @param int $post_id Post ID.
118 * @return array<string,string> Resolved previews keyed by metabox field.
119 */
120 public static function previews(int $post_id): array {
121 $title = self::title($post_id);
122 $description = self::description($post_id);
123
124 return [
125 'seo_title' => $title,
126 'meta_description' => $description,
127 'og_title' => $title,
128 'og_description' => $description,
129 'twitter_title' => $title,
130 'twitter_description' => $description,
131 ];
132 }
133
134 /**
135 * Get the configured pattern for a post type, falling back to a default.
136 *
137 * @param int $post_id Post ID.
138 * @param string $key Setting key ('title' or 'description').
139 * @param string $default Default pattern.
140 * @return string Pattern template.
141 */
142 private static function template_for(int $post_id, string $key, string $default): string {
143 $post_type = get_post_type($post_id) ?: 'post';
144 $all = get_option(self::OPTION_NAME, []);
145 $template = $all[$post_type][$key] ?? '';
146
147 return is_string($template) && $template !== '' ? $template : $default;
148 }
149
150 /**
151 * Build placeholder values for an explicit post (no loop dependency).
152 *
153 * @param int $post_id Post ID.
154 * @return array<string,string> Placeholder map.
155 */
156 private static function placeholders_for(int $post_id): array {
157 $post = get_post($post_id);
158
159 $excerpt = '';
160 if ($post) {
161 $excerpt = !empty($post->post_excerpt)
162 ? $post->post_excerpt
163 : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
164 }
165
166 $author_id = (int) get_post_field('post_author', $post_id);
167
168 $category = '';
169 if (get_post_type($post_id) === 'post') {
170 $categories = get_the_category($post_id);
171 $category = !empty($categories) ? $categories[0]->name : '';
172 }
173
174 return [
175 '%title%' => get_the_title($post_id),
176 '%sitename%' => get_bloginfo('name'),
177 '%sep%' => self::separator(),
178 '%excerpt%' => $excerpt,
179 '%date%' => get_the_date('', $post_id),
180 '%modified%' => get_the_modified_date('', $post_id),
181 '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
182 '%category%' => $category,
183 ];
184 }
185
186 /**
187 * Active title separator symbol.
188 *
189 * @return string Separator.
190 */
191 private static function separator(): string {
192 if (class_exists('\ThinkRank\SEO\Site_Identity_Manager')) {
193 return Site_Identity_Manager::get_active_separator_symbol();
194 }
195 return '-';
196 }
197
198 /**
199 * Replace placeholders and tidy the result (mirrors the frontend cleanup).
200 *
201 * @param string $template Pattern template.
202 * @param array<string,string> $placeholders Placeholder map.
203 * @return string Resolved string.
204 */
205 private static function process(string $template, array $placeholders): string {
206 $value = str_replace(array_keys($placeholders), array_values($placeholders), $template);
207
208 // Collapse whitespace.
209 $value = preg_replace('/\s+/', ' ', $value);
210 $value = trim($value);
211
212 // Collapse doubled separators left by empty tokens (e.g. "| |" -> "|").
213 $separator = $placeholders['%sep%'] ?? '|';
214 $separator_pattern = preg_quote($separator, '/');
215 $value = preg_replace(
216 '/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/',
217 ' ' . $separator . ' ',
218 $value
219 );
220
221 // Strip leading/trailing separators and whitespace.
222 return trim($value, " \t\n\r\0\x0B" . $separator);
223 }
224 }
225