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

379 lines 13.1 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 * Derive a description from raw post content.
87 *
88 * wp_strip_all_tags() removes HTML but not shortcodes, so a page built with
89 * them published its shortcode source as the description — `[woocommerce_cart]`
90 * as the meta description, og:description and twitter:description of the
91 * cart page. Core's own wp_trim_excerpt() runs strip_shortcodes() and
92 * excerpt_remove_blocks() first; this path did neither, which is why the
93 * two disagreed about the same post (#387).
94 *
95 * @since 2.0.1
96 *
97 * @param string $content Raw post content.
98 * @param int $words Word cap.
99 * @return string Derived description, or '' when nothing survives.
100 */
101 public static function derive_excerpt(string $content, int $words = 25): string {
102 if ('' === trim($content)) {
103 return '';
104 }
105
106 $text = excerpt_remove_blocks($content);
107 $text = strip_shortcodes($text);
108 $text = wp_strip_all_tags($text);
109
110 return trim(wp_trim_words($text, $words, '...'));
111 }
112
113 /**
114 * Resolve any variable-tag string against a term's values.
115 *
116 * The term counterpart of resolve_value(). Term SEO fields reach the
117 * frontend from three writers — the term UI, the abilities API and the
118 * Yoast/RankMath/AIOSEO/SEOPress importer — and the importers already
119 * substitute their own term tokens (%%term_title%%, %term%) with the term
120 * name at export time, so what lands here is either literal text or
121 * ThinkRank's own tags.
122 *
123 * @since 2.0.1
124 *
125 * @param string $value Raw string, possibly containing variable tags.
126 * @param int $term_id Term ID.
127 * @return string Resolved string.
128 */
129 public static function resolve_term_value(string $value, int $term_id): string {
130 if (strpos($value, '%') === false) {
131 return $value;
132 }
133 return self::process($value, self::placeholders_for_term($term_id));
134 }
135
136 /**
137 * Token => value map for a term.
138 *
139 * The post-only tokens resolve to an empty string rather than being left
140 * unreplaced: they have no meaning on an archive, and process() collapses
141 * the separators an empty token leaves behind. A raw "%author%" in the
142 * rendered title would be worse than nothing.
143 *
144 * @since 2.0.1
145 *
146 * @param int $term_id Term ID.
147 * @return array<string,string> Placeholder map.
148 */
149 private static function placeholders_for_term(int $term_id): array {
150 $term = get_term($term_id);
151
152 $name = ($term && !is_wp_error($term)) ? $term->name : '';
153 $description = ($term && !is_wp_error($term)) ? (string) $term->description : '';
154
155 return [
156 '%title%' => $name,
157 '%term%' => $name,
158 '%sitename%' => get_bloginfo('name'),
159 '%sep%' => self::separator(),
160 '%excerpt%' => $description !== ''
161 ? wp_trim_words(wp_strip_all_tags($description), 25, '...')
162 : '',
163 '%date%' => '',
164 '%modified%' => '',
165 '%author%' => '',
166 '%category%' => '',
167 ];
168 }
169
170 /**
171 * Token => value map for a post, keyed WITHOUT the surrounding percents
172 * (e.g. 'title' => 'My Post'). Used by the editor for live client-side
173 * preview of a pattern as the user types.
174 *
175 * @param int $post_id Post ID.
176 * @return array<string,string> Variable map.
177 */
178 public static function variables(int $post_id): array {
179 $map = [];
180 foreach (self::placeholders_for($post_id) as $token => $value) {
181 $map[trim($token, '%')] = $value;
182 }
183 return $map;
184 }
185
186 /**
187 * Resolve the meta description pattern for a post.
188 *
189 * Trimmed to the same ~160-char ceiling the frontend applies on output.
190 *
191 * @param int $post_id Post ID.
192 * @return string Resolved description, or '' when it resolves to nothing.
193 */
194 public static function description(int $post_id): string {
195 $template = self::template_for($post_id, 'description', self::DEFAULT_DESCRIPTION);
196 $description = self::resolve_value($template, $post_id);
197
198 if (strlen($description) > 160) {
199 $description = wp_trim_words($description, 25, '...');
200 }
201
202 return $description;
203 }
204
205 /**
206 * Effective SEO title for a post: the per-post custom value (with any
207 * variable tags resolved) when set, otherwise the rendered Global/Bulk
208 * title pattern. This is the value the frontend actually outputs.
209 *
210 * Scoring MUST use this rather than the raw `_thinkrank_seo_title` meta —
211 * an empty meta means "inherit the global pattern", not "no title", so the
212 * raw value would make an inherited-title post score as if it had none.
213 *
214 * @param int $post_id Post ID.
215 * @return string Effective title.
216 */
217 public static function effective_title(int $post_id): string {
218 return self::effective_value(
219 (string) get_post_meta($post_id, self::META_TITLE, true),
220 $post_id,
221 'title'
222 );
223 }
224
225 /**
226 * Effective meta description for a post: the per-post custom value (with any
227 * variable tags resolved) when set, otherwise the rendered Global/Bulk
228 * description pattern. Counterpart to {@see self::effective_title()}.
229 *
230 * @param int $post_id Post ID.
231 * @return string Effective description.
232 */
233 public static function effective_description(int $post_id): string {
234 return self::effective_value(
235 (string) get_post_meta($post_id, self::META_DESCRIPTION, true),
236 $post_id,
237 'description'
238 );
239 }
240
241 /**
242 * Resolve a raw per-post field to its effective value.
243 *
244 * When the raw value is non-empty its variable tags are resolved; when it is
245 * empty the field falls back to the rendered Global/Bulk pattern. Exposed so
246 * callers that already hold a raw value (e.g. the SEO score endpoint scoring
247 * unsaved editor input) can route through the same fallback logic.
248 *
249 * @param string $raw Raw per-post field value (may hold variable tags).
250 * @param int $post_id Post ID.
251 * @param string $field Which pattern to fall back to: 'title' or 'description'.
252 * @return string Effective value.
253 */
254 public static function effective_value(string $raw, int $post_id, string $field): string {
255 if ($raw !== '') {
256 return self::resolve_value($raw, $post_id);
257 }
258
259 return 'description' === $field
260 ? self::description($post_id)
261 : self::title($post_id);
262 }
263
264 /**
265 * Build the full set of pattern previews for the post editor.
266 *
267 * Social fields mirror the frontend fallback: an empty og/twitter title
268 * resolves to the SEO title, and an empty og/twitter description to the
269 * meta description.
270 *
271 * @param int $post_id Post ID.
272 * @return array<string,string> Resolved previews keyed by metabox field.
273 */
274 public static function previews(int $post_id): array {
275 $title = self::title($post_id);
276 $description = self::description($post_id);
277
278 return [
279 'seo_title' => $title,
280 'meta_description' => $description,
281 'og_title' => $title,
282 'og_description' => $description,
283 'twitter_title' => $title,
284 'twitter_description' => $description,
285 ];
286 }
287
288 /**
289 * Get the configured pattern for a post type, falling back to a default.
290 *
291 * @param int $post_id Post ID.
292 * @param string $key Setting key ('title' or 'description').
293 * @param string $fallback Default pattern.
294 * @return string Pattern template.
295 */
296 private static function template_for(int $post_id, string $key, string $fallback): string {
297 $post_type = get_post_type($post_id) ?: 'post';
298 $all = get_option(self::OPTION_NAME, []);
299 $template = $all[$post_type][$key] ?? '';
300
301 return is_string($template) && $template !== '' ? $template : $fallback;
302 }
303
304 /**
305 * Build placeholder values for an explicit post (no loop dependency).
306 *
307 * @param int $post_id Post ID.
308 * @return array<string,string> Placeholder map.
309 */
310 private static function placeholders_for(int $post_id): array {
311 $post = get_post($post_id);
312
313 $excerpt = '';
314 if ($post) {
315 $excerpt = !empty($post->post_excerpt)
316 ? $post->post_excerpt
317 : self::derive_excerpt((string) $post->post_content);
318 }
319
320 $author_id = (int) get_post_field('post_author', $post_id);
321
322 $category = '';
323 if (get_post_type($post_id) === 'post') {
324 $categories = get_the_category($post_id);
325 $category = !empty($categories) ? $categories[0]->name : '';
326 }
327
328 return [
329 '%title%' => get_the_title($post_id),
330 '%sitename%' => get_bloginfo('name'),
331 '%sep%' => self::separator(),
332 '%excerpt%' => $excerpt,
333 '%date%' => get_the_date('', $post_id),
334 '%modified%' => get_the_modified_date('', $post_id),
335 '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
336 '%category%' => $category,
337 ];
338 }
339
340 /**
341 * Active title separator symbol.
342 *
343 * @return string Separator.
344 */
345 private static function separator(): string {
346 if (class_exists('\ThinkRank\SEO\Site_Identity_Manager')) {
347 return Site_Identity_Manager::get_active_separator_symbol();
348 }
349 return '-';
350 }
351
352 /**
353 * Replace placeholders and tidy the result (mirrors the frontend cleanup).
354 *
355 * @param string $template Pattern template.
356 * @param array<string,string> $placeholders Placeholder map.
357 * @return string Resolved string.
358 */
359 private static function process(string $template, array $placeholders): string {
360 $value = str_replace(array_keys($placeholders), array_values($placeholders), $template);
361
362 // Collapse whitespace.
363 $value = preg_replace('/\s+/', ' ', $value);
364 $value = trim($value);
365
366 // Collapse doubled separators left by empty tokens (e.g. "| |" -> "|").
367 $separator = $placeholders['%sep%'] ?? '|';
368 $separator_pattern = preg_quote($separator, '/');
369 $value = preg_replace(
370 '/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/',
371 ' ' . $separator . ' ',
372 $value
373 );
374
375 // Strip leading/trailing separators and whitespace.
376 return trim($value, " \t\n\r\0\x0B" . $separator);
377 }
378 }
379