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

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