PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.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.32.0, at includes/seo/class-pattern-resolver.php

294 lines 9.9 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 * Post meta key holding the per-post SEO title.
47 */
48 private const META_TITLE = '_thinkrank_seo_title';
49
50 /**
51 * Post meta key holding the per-post meta description.
52 */
53 private const META_DESCRIPTION = '_thinkrank_meta_description';
54
55 /**
56 * Resolve the SEO title pattern for a post.
57 *
58 * @param int $post_id Post ID.
59 * @return string Resolved title, or '' when it resolves to nothing.
60 */
61 public static function title(int $post_id): string {
62 $template = self::template_for($post_id, 'title', self::DEFAULT_TITLE);
63 return self::resolve_value($template, $post_id);
64 }
65
66 /**
67 * Resolve any variable-tag string against a post's values.
68 *
69 * Replaces tokens (e.g. "%title% %sep% %sitename%") with the post's actual
70 * values. A literal string containing no tokens passes through unchanged, so
71 * this is safe to run over per-post SEO fields that may or may not hold a
72 * pattern.
73 *
74 * @param string $value Raw string, possibly containing variable tags.
75 * @param int $post_id Post ID.
76 * @return string Resolved string.
77 */
78 public static function resolve_value(string $value, int $post_id): string {
79 if (strpos($value, '%') === false) {
80 return $value;
81 }
82 return self::process($value, self::placeholders_for($post_id));
83 }
84
85 /**
86 * Token => value map for a post, keyed WITHOUT the surrounding percents
87 * (e.g. 'title' => 'My Post'). Used by the editor for live client-side
88 * preview of a pattern as the user types.
89 *
90 * @param int $post_id Post ID.
91 * @return array<string,string> Variable map.
92 */
93 public static function variables(int $post_id): array {
94 $map = [];
95 foreach (self::placeholders_for($post_id) as $token => $value) {
96 $map[trim($token, '%')] = $value;
97 }
98 return $map;
99 }
100
101 /**
102 * Resolve the meta description pattern for a post.
103 *
104 * Trimmed to the same ~160-char ceiling the frontend applies on output.
105 *
106 * @param int $post_id Post ID.
107 * @return string Resolved description, or '' when it resolves to nothing.
108 */
109 public static function description(int $post_id): string {
110 $template = self::template_for($post_id, 'description', self::DEFAULT_DESCRIPTION);
111 $description = self::resolve_value($template, $post_id);
112
113 if (strlen($description) > 160) {
114 $description = wp_trim_words($description, 25, '...');
115 }
116
117 return $description;
118 }
119
120 /**
121 * Effective SEO title for a post: the per-post custom value (with any
122 * variable tags resolved) when set, otherwise the rendered Global/Bulk
123 * title pattern. This is the value the frontend actually outputs.
124 *
125 * Scoring MUST use this rather than the raw `_thinkrank_seo_title` meta —
126 * an empty meta means "inherit the global pattern", not "no title", so the
127 * raw value would make an inherited-title post score as if it had none.
128 *
129 * @param int $post_id Post ID.
130 * @return string Effective title.
131 */
132 public static function effective_title(int $post_id): string {
133 return self::effective_value(
134 (string) get_post_meta($post_id, self::META_TITLE, true),
135 $post_id,
136 'title'
137 );
138 }
139
140 /**
141 * Effective meta description for a post: the per-post custom value (with any
142 * variable tags resolved) when set, otherwise the rendered Global/Bulk
143 * description pattern. Counterpart to {@see self::effective_title()}.
144 *
145 * @param int $post_id Post ID.
146 * @return string Effective description.
147 */
148 public static function effective_description(int $post_id): string {
149 return self::effective_value(
150 (string) get_post_meta($post_id, self::META_DESCRIPTION, true),
151 $post_id,
152 'description'
153 );
154 }
155
156 /**
157 * Resolve a raw per-post field to its effective value.
158 *
159 * When the raw value is non-empty its variable tags are resolved; when it is
160 * empty the field falls back to the rendered Global/Bulk pattern. Exposed so
161 * callers that already hold a raw value (e.g. the SEO score endpoint scoring
162 * unsaved editor input) can route through the same fallback logic.
163 *
164 * @param string $raw Raw per-post field value (may hold variable tags).
165 * @param int $post_id Post ID.
166 * @param string $field Which pattern to fall back to: 'title' or 'description'.
167 * @return string Effective value.
168 */
169 public static function effective_value(string $raw, int $post_id, string $field): string {
170 if ($raw !== '') {
171 return self::resolve_value($raw, $post_id);
172 }
173
174 return 'description' === $field
175 ? self::description($post_id)
176 : self::title($post_id);
177 }
178
179 /**
180 * Build the full set of pattern previews for the post editor.
181 *
182 * Social fields mirror the frontend fallback: an empty og/twitter title
183 * resolves to the SEO title, and an empty og/twitter description to the
184 * meta description.
185 *
186 * @param int $post_id Post ID.
187 * @return array<string,string> Resolved previews keyed by metabox field.
188 */
189 public static function previews(int $post_id): array {
190 $title = self::title($post_id);
191 $description = self::description($post_id);
192
193 return [
194 'seo_title' => $title,
195 'meta_description' => $description,
196 'og_title' => $title,
197 'og_description' => $description,
198 'twitter_title' => $title,
199 'twitter_description' => $description,
200 ];
201 }
202
203 /**
204 * Get the configured pattern for a post type, falling back to a default.
205 *
206 * @param int $post_id Post ID.
207 * @param string $key Setting key ('title' or 'description').
208 * @param string $fallback Default pattern.
209 * @return string Pattern template.
210 */
211 private static function template_for(int $post_id, string $key, string $fallback): string {
212 $post_type = get_post_type($post_id) ?: 'post';
213 $all = get_option(self::OPTION_NAME, []);
214 $template = $all[$post_type][$key] ?? '';
215
216 return is_string($template) && $template !== '' ? $template : $fallback;
217 }
218
219 /**
220 * Build placeholder values for an explicit post (no loop dependency).
221 *
222 * @param int $post_id Post ID.
223 * @return array<string,string> Placeholder map.
224 */
225 private static function placeholders_for(int $post_id): array {
226 $post = get_post($post_id);
227
228 $excerpt = '';
229 if ($post) {
230 $excerpt = !empty($post->post_excerpt)
231 ? $post->post_excerpt
232 : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
233 }
234
235 $author_id = (int) get_post_field('post_author', $post_id);
236
237 $category = '';
238 if (get_post_type($post_id) === 'post') {
239 $categories = get_the_category($post_id);
240 $category = !empty($categories) ? $categories[0]->name : '';
241 }
242
243 return [
244 '%title%' => get_the_title($post_id),
245 '%sitename%' => get_bloginfo('name'),
246 '%sep%' => self::separator(),
247 '%excerpt%' => $excerpt,
248 '%date%' => get_the_date('', $post_id),
249 '%modified%' => get_the_modified_date('', $post_id),
250 '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
251 '%category%' => $category,
252 ];
253 }
254
255 /**
256 * Active title separator symbol.
257 *
258 * @return string Separator.
259 */
260 private static function separator(): string {
261 if (class_exists('\ThinkRank\SEO\Site_Identity_Manager')) {
262 return Site_Identity_Manager::get_active_separator_symbol();
263 }
264 return '-';
265 }
266
267 /**
268 * Replace placeholders and tidy the result (mirrors the frontend cleanup).
269 *
270 * @param string $template Pattern template.
271 * @param array<string,string> $placeholders Placeholder map.
272 * @return string Resolved string.
273 */
274 private static function process(string $template, array $placeholders): string {
275 $value = str_replace(array_keys($placeholders), array_values($placeholders), $template);
276
277 // Collapse whitespace.
278 $value = preg_replace('/\s+/', ' ', $value);
279 $value = trim($value);
280
281 // Collapse doubled separators left by empty tokens (e.g. "| |" -> "|").
282 $separator = $placeholders['%sep%'] ?? '|';
283 $separator_pattern = preg_quote($separator, '/');
284 $value = preg_replace(
285 '/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/',
286 ' ' . $separator . ' ',
287 $value
288 );
289
290 // Strip leading/trailing separators and whitespace.
291 return trim($value, " \t\n\r\0\x0B" . $separator);
292 }
293 }
294