PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 54 releases
thinkrank / includes / admin / importers / class-slim-seo-exporter.php

class-slim-seo-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.13.0, at includes/admin/importers/class-slim-seo-exporter.php

1,889 lines 69.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Slim SEO Exporter
5 *
6 * Reads Slim SEO data and normalizes it into the canonical snapshot format
7 * (#886).
8 *
9 * Slim SEO keeps everything an object needs in ONE serialized meta array,
10 * `slim_seo`, on both posts and terms: title, description, facebook_image,
11 * twitter_image, canonical and noindex. The primary term of each taxonomy is
12 * its own post meta, `_slim_seo_primary_term_{taxonomy}`. There is no per-user
13 * SEO and no focus keyword.
14 *
15 * Settings live in the `slim_seo` option: per-context templates keyed by
16 * `home`, `author`, a post type slug, `{post_type}_archive` or a taxonomy
17 * slug, plus social defaults, the feature toggles, robots.txt and header/
18 * body/footer code. Redirects are the `ss_redirects` option, an id-keyed map.
19 *
20 * Slim SEO Pro adds post meta `slim_seo_pro` (the Writing assistant's
21 * keywords), `slim_seo_schema` (the post's own schemas) and options
22 * `slim_seo_schemas` (global schemas) and `slim_seo_pro` (its features).
23 * Schemas go through Slim_SEO_Schema_Converter.
24 *
25 * Templates use Slim Twig: `{{ post.title }} {{ sep }} {{ site.title }}` —
26 * dotted paths into a data tree, no filters, arrays joined with ", ", unknown
27 * paths left as written. Slim SEO then drops empty segments between
28 * separators, so a title whose `{{ page }}` is empty never shows "- -". The
29 * resolver below follows the same rules.
30 *
31 * @package ThinkRank\Admin\Importers
32 * @since 2.13.0
33 */
34
35 declare(strict_types=1);
36
37 namespace ThinkRank\Admin\Importers;
38
39 if (!defined('ABSPATH')) {
40 exit;
41 }
42
43 /**
44 * Slim SEO Exporter Class
45 *
46 * @since 2.13.0
47 */
48 class Slim_SEO_Exporter extends Abstract_Plugin_Exporter {
49
50 /**
51 * Source plugin slug.
52 */
53 public const SLUG = 'slimseo';
54
55 /**
56 * Post/term meta key holding Slim SEO's per-object array.
57 */
58 public const META_KEY = 'slim_seo';
59
60 /**
61 * Prefix of the per-taxonomy primary term post meta.
62 */
63 public const PRIMARY_TERM_PREFIX = '_slim_seo_primary_term_';
64
65 /**
66 * Settings option.
67 */
68 public const OPTION = 'slim_seo';
69
70 /**
71 * Redirects option (Slim SEO's SLIM_SEO_REDIRECTS constant).
72 */
73 public const REDIRECTS_OPTION = 'ss_redirects';
74
75 /**
76 * 404 log table, without the WordPress prefix. Only created while Slim
77 * SEO's "Enable 404 logs" setting is on.
78 */
79 public const LOG_404_TABLE = 'slim_seo_404';
80
81 /**
82 * Features Slim SEO switches on when the `features` list was never saved.
83 * Settings::is_feature_active() treats an empty list as "all of these"
84 * minus FEATURES_OFF_UNTIL_SAVED.
85 */
86 private const DEFAULT_FEATURES = [
87 'meta_title', 'meta_description', 'meta_robots', 'open_graph', 'twitter_cards',
88 'canonical_url', 'rel_links', 'sitemaps', 'images_alt', 'breadcrumbs', 'feed',
89 'schema', 'redirection', 'no_category_base',
90 ];
91
92 /**
93 * Default features Slim SEO nevertheless keeps OFF until the user saves
94 * the Features tab ("Set features OFF by default" in is_feature_active()).
95 */
96 private const FEATURES_OFF_UNTIL_SAVED = ['no_category_base'];
97
98 /**
99 * Slim SEO's built-in templates (Title::DEFAULTS / Description::DEFAULTS)
100 * for the contexts ThinkRank has no template store for. A stored value
101 * equal to one of these is Slim SEO's behaviour, not a choice the user
102 * made, and ThinkRank renders the same thing by default — so it is not
103 * carried as preserved data.
104 */
105 private const DEFAULT_TEMPLATES = [
106 'term' => ['title' => '{{ term.name }} {{ sep }} {{ page }} {{ sep }} {{ site.title }}', 'description' => '{{ term.auto_description }}'],
107 'post_archive' => ['title' => '{{ post_type.labels.plural }} {{ sep }} {{ page }} {{ sep }} {{ site.title }}', 'description' => ''],
108 ];
109
110 /**
111 * Post meta Slim SEO's auto redirection writes on every slug change: one
112 * row per old permalink, each 301'd to the post's current URL.
113 */
114 public const OLD_PERMALINK_META = '_ss_old_permalink';
115
116 /**
117 * Slim SEO Pro post meta: the Writing assistant's keywords under
118 * `content_analysis` (`main_keyword`, and `keywords` joined with `;`).
119 */
120 public const PRO_META_KEY = 'slim_seo_pro';
121
122 /**
123 * Slim SEO Pro post meta: the post's own schemas, keyed by id.
124 */
125 public const SCHEMA_META_KEY = 'slim_seo_schema';
126
127 /**
128 * Slim SEO Pro post meta: also show the global schemas on this post.
129 */
130 public const ALLOW_GLOBAL_META = 'sss_allow_global';
131
132 /**
133 * Slim SEO Pro's global schemas (Schema\Settings::OPTION_NAME).
134 */
135 public const SCHEMAS_OPTION = 'slim_seo_schemas';
136
137 /**
138 * Slim SEO Pro's settings: the enabled `features` and `markdown_post_types`.
139 */
140 public const PRO_OPTION = 'slim_seo_pro';
141
142 /**
143 * Slim SEO Pro's features when its settings were never saved.
144 */
145 private const PRO_DEFAULT_FEATURES = ['link-manager', 'schema', 'analytics', 'content-analysis', 'auto-link', 'entities', 'markdown'];
146
147 /**
148 * Post meta keys that put a post in the export: Slim SEO's own array and
149 * Slim SEO Pro's two (primary terms are matched by prefix).
150 */
151 private const POST_META_KEYS = [self::META_KEY, self::PRO_META_KEY, self::SCHEMA_META_KEY];
152
153 /**
154 * Slim SEO `condition` => ThinkRank Pro redirect `match_type`.
155 */
156 private const MATCH_TYPES = [
157 'exact-match' => 'exact',
158 'contain' => 'contains',
159 'start-with' => 'start',
160 'end-with' => 'end',
161 'regex' => 'regex',
162 ];
163
164 /**
165 * Option keys that are settings, not per-context templates.
166 */
167 private const NON_CONTEXT_KEYS = [
168 'header_code', 'body_code', 'footer_code', 'default_facebook_image', 'default_twitter_image',
169 'facebook_app_id', 'twitter_site', 'ai_provider', 'ai_model', 'ai_api_key', 'features',
170 'robots_txt_editable', 'robots_txt_content',
171 ];
172
173 /**
174 * Redirect behaviour settings and their Slim SEO defaults. Only values a
175 * user changed are carried (and preserved, since ThinkRank has no
176 * equivalent switches) — a default here is not a choice anyone made.
177 */
178 private const REDIRECT_SETTING_DEFAULTS = [
179 'force_trailing_slash' => 0,
180 'auto_redirection' => 1,
181 'redirect_www' => '',
182 'case_sensitive' => 0,
183 'enable_404_logs' => 0,
184 'auto_delete_404_logs' => 30,
185 'redirect_404_to' => '',
186 'redirect_404_to_url' => '',
187 'disable_for_single_posts' => 0,
188 ];
189
190 /**
191 * The redirect settings Slim SEO renders as checkboxes, which it saves as
192 * -1 when unticked (Redirection\Settings::option_saved()).
193 */
194 private const REDIRECT_CHECKBOXES = [
195 'force_trailing_slash', 'auto_redirection', 'case_sensitive', 'enable_404_logs', 'disable_for_single_posts',
196 ];
197
198 /**
199 * Separator Slim SEO renders `{{ sep }}` with when no theme filters
200 * `document_title_separator`.
201 */
202 private const SEPARATOR = '-';
203
204 /**
205 * Placeholder for `{{ sep }}` while the rest of a template resolves.
206 */
207 private const SEP_MARK = "\x1F";
208
209 /**
210 * Memoised get_available_types(): the detector and detect() both ask,
211 * and the answer costs three COUNTs plus a SHOW TABLES.
212 *
213 * @var array|null
214 */
215 private ?array $available_types = null;
216
217 /**
218 * Memoised merged redirect list (manual rules + auto-redirected old
219 * permalinks), so counting and paging read it once.
220 *
221 * @var array|null
222 */
223 private ?array $all_redirects = null;
224
225 /**
226 * Slim SEO Pro schema converter, built once per export.
227 *
228 * @var Slim_SEO_Schema_Converter|null
229 */
230 private ?Slim_SEO_Schema_Converter $schema_converter = null;
231
232 /**
233 * Constructor
234 */
235 public function __construct() {
236 $this->plugin_slug = self::SLUG;
237 $this->plugin_name = 'Slim SEO';
238 $this->plugin_file = 'slim-seo/slim-seo.php';
239 // LIKE 'slim_seo%' matches the per-object array; the primary-term keys
240 // carry a leading underscore and are queried explicitly below.
241 $this->meta_key_prefix = self::META_KEY;
242 $this->option_keys = [self::OPTION, self::REDIRECTS_OPTION, self::PRO_OPTION, self::SCHEMAS_OPTION];
243 }
244
245 /**
246 * {@inheritDoc}
247 */
248 public function detect(): bool {
249 return !empty($this->get_available_types());
250 }
251
252 /**
253 * {@inheritDoc}
254 */
255 public function get_available_types(): array {
256 if ($this->available_types !== null) {
257 return $this->available_types;
258 }
259
260 global $wpdb;
261
262 $types = [];
263
264 $post_count = $this->count_posts();
265 if ($post_count > 0) {
266 $types['postmeta'] = $post_count;
267 }
268
269 $term_count = (int) $wpdb->get_var(
270 $wpdb->prepare(
271 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key = %s",
272 self::META_KEY
273 )
274 );
275 if ($term_count > 0) {
276 $types['termmeta'] = $term_count;
277 }
278
279 $redirects = count($this->get_all_redirects());
280 if ($redirects > 0) {
281 $types['redirections'] = $redirects;
282 }
283
284 $logs = $this->count_404_logs();
285 if ($logs > 0) {
286 $types['404_logs'] = $logs;
287 }
288
289 $option = get_option(self::OPTION, null);
290 if ((is_array($option) && !empty($option)) || is_array(get_option(self::PRO_OPTION, null)) || is_array(get_option(self::SCHEMAS_OPTION, null))) {
291 $types['settings'] = 1;
292 }
293
294 $this->available_types = $types;
295
296 return $types;
297 }
298
299 // -------------------------------------------------------------------------
300 // Posts
301 // -------------------------------------------------------------------------
302
303 /**
304 * {@inheritDoc}
305 */
306 protected function export_postmeta_page(int $page): array {
307 $post_ids = $this->get_post_ids_with_meta($page);
308 if (empty($post_ids)) {
309 return [];
310 }
311
312 $records = [];
313 foreach ($post_ids as $post_id) {
314 $post_id = (int) $post_id;
315 $record = $this->build_post_record(
316 $post_id,
317 $this->read_meta_array(get_post_meta($post_id, self::META_KEY, true)),
318 $this->get_primary_terms($post_id),
319 $this->read_meta_array(get_post_meta($post_id, self::PRO_META_KEY, true)),
320 $this->read_meta_array(get_post_meta($post_id, self::SCHEMA_META_KEY, true))
321 );
322 if ($record !== null) {
323 $records[] = $record;
324 }
325 }
326
327 return $records;
328 }
329
330 /**
331 * Canonical record for one post.
332 *
333 * @param int $post_id Post ID
334 * @param array $meta Slim SEO's `slim_seo` array for the post
335 * @param array $primary_terms taxonomy => term ID
336 * @param array $pro Slim SEO Pro's `slim_seo_pro` array for the post
337 * @param array $schemas Slim SEO Pro's `slim_seo_schema` array for the post
338 * @return array|null Record, or null when there is nothing to carry
339 */
340 private function build_post_record(int $post_id, array $meta, array $primary_terms, array $pro = [], array $schemas = []): ?array {
341 $keywords = $this->focus_keywords($pro);
342 if (empty($meta) && empty($primary_terms) && empty($keywords) && empty($schemas)) {
343 return null;
344 }
345
346 $extended = ['primary_terms' => $primary_terms];
347 if (!empty($schemas)) {
348 $entries = $this->schema_converter()->convert_post($post_id, $schemas, $this->slim_seo_variables($post_id, $meta, $pro));
349 if (!empty($entries)) {
350 $extended['custom_schemas'] = $entries;
351 }
352 }
353
354 return [
355 'object_id' => $post_id,
356 'object_type' => 'post',
357 'source_plugin' => $this->plugin_slug,
358 'data' => [
359 'seo_title' => $this->convert_post_value($this->meta_string($meta, 'title'), $post_id),
360 'meta_description' => $this->convert_post_value($this->meta_string($meta, 'description'), $post_id),
361 'focus_keyword' => $keywords[0] ?? '',
362 'focus_keywords' => $keywords,
363 'canonical_url' => $this->meta_string($meta, 'canonical'),
364 'noindex' => empty($meta['noindex']) ? 0 : 1,
365 'nofollow' => 0,
366 'og_image' => $this->meta_string($meta, 'facebook_image'),
367 'twitter_image' => $this->meta_string($meta, 'twitter_image'),
368 // ThinkRank keeps one primary term, the category's.
369 'primary_category' => (int) ($primary_terms['category'] ?? 0),
370 'schema_type' => '',
371 ],
372 'extended' => $extended,
373 ];
374 }
375
376 /**
377 * The Writing assistant's keywords, main keyword first.
378 *
379 * @param array $pro Slim SEO Pro's `slim_seo_pro` array for the post
380 * @return string[]
381 */
382 private function focus_keywords(array $pro): array {
383 $analysis = is_array($pro['content_analysis'] ?? null) ? $pro['content_analysis'] : [];
384 $main = is_scalar($analysis['main_keyword'] ?? null) ? trim((string) $analysis['main_keyword']) : '';
385 $others = is_scalar($analysis['keywords'] ?? null) ? explode(';', (string) $analysis['keywords']) : [];
386
387 // mbstring is not guaranteed on every host; fall back as the score
388 // calculator does rather than fatal the export.
389 $lower = static fn(string $text): string => function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
390
391 $keywords = [];
392 $seen = [];
393 foreach (array_merge([$main], $others) as $keyword) {
394 $keyword = trim((string) $keyword);
395 if ($keyword !== '' && !isset($seen[$lower($keyword)])) {
396 $seen[$lower($keyword)] = true;
397 $keywords[] = $keyword;
398 }
399 }
400
401 return $keywords;
402 }
403
404 /**
405 * The `slim_seo.*` variables a schema can use: the post's rendered Slim
406 * SEO fields and the Writing assistant's main keyword.
407 *
408 * @param int $post_id Post ID
409 * @param array $meta Slim SEO's `slim_seo` array for the post
410 * @param array $pro Slim SEO Pro's `slim_seo_pro` array for the post
411 * @return array<string,string>
412 */
413 private function slim_seo_variables(int $post_id, array $meta, array $pro): array {
414 $context = $this->post_context($post_id);
415 $variables = [];
416 foreach (['title', 'description', 'facebook_image', 'twitter_image'] as $field) {
417 $value = $this->meta_string($meta, $field);
418 if ($value !== '') {
419 $variables[$field] = $this->render_template($value, $context);
420 }
421 }
422 $keywords = $this->focus_keywords($pro);
423 if (!empty($keywords)) {
424 $variables['main_keyword'] = $keywords[0];
425 }
426
427 return $variables;
428 }
429
430 /**
431 * The schema converter, reading the global schemas once.
432 */
433 private function schema_converter(): Slim_SEO_Schema_Converter {
434 if ($this->schema_converter === null) {
435 $this->schema_converter = new Slim_SEO_Schema_Converter(get_option(self::SCHEMAS_OPTION, null));
436 }
437
438 return $this->schema_converter;
439 }
440
441 /**
442 * Posts of a viewable type carrying Slim SEO's per-object array, a Slim
443 * SEO Pro array (keywords, schemas) or a primary term. Paged on the union
444 * so a post with only one of them is still exported once.
445 *
446 * {@inheritDoc}
447 */
448 protected function get_post_ids_with_meta(int $page): array {
449 global $wpdb;
450
451 $post_types = $this->get_exportable_post_types();
452 if (empty($post_types)) {
453 $this->last_page_row_count = 0;
454 return [];
455 }
456
457 $offset = ($page - 1) * $this->chunk_size;
458 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
459
460 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names are $wpdb properties and every value is a placeholder replacement.
461 $sql = $wpdb->prepare(
462 "SELECT DISTINCT pm.post_id
463 FROM {$wpdb->postmeta} pm
464 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
465 WHERE (pm.meta_key IN (%s, %s, %s) OR pm.meta_key LIKE %s)
466 AND p.post_type IN ({$placeholders})
467 ORDER BY pm.post_id ASC
468 LIMIT %d OFFSET %d",
469 array_merge(
470 array_merge(self::POST_META_KEYS, [$wpdb->esc_like(self::PRIMARY_TERM_PREFIX) . '%']),
471 $post_types,
472 [$this->chunk_size, $offset]
473 )
474 );
475 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
476
477 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- prepared above.
478 $ids = $wpdb->get_col($sql);
479 $this->last_page_row_count = count($ids);
480
481 return $ids;
482 }
483
484 /**
485 * Same population as get_post_ids_with_meta(), counted.
486 */
487 private function count_posts(): int {
488 global $wpdb;
489
490 $post_types = $this->get_exportable_post_types();
491 if (empty($post_types)) {
492 return 0;
493 }
494
495 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
496
497 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names are $wpdb properties and every value is a placeholder replacement.
498 $sql = $wpdb->prepare(
499 "SELECT COUNT(DISTINCT pm.post_id)
500 FROM {$wpdb->postmeta} pm
501 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
502 WHERE (pm.meta_key IN (%s, %s, %s) OR pm.meta_key LIKE %s)
503 AND p.post_type IN ({$placeholders})",
504 array_merge(
505 array_merge(self::POST_META_KEYS, [$wpdb->esc_like(self::PRIMARY_TERM_PREFIX) . '%']),
506 $post_types
507 )
508 );
509 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
510
511 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- prepared above.
512 return (int) $wpdb->get_var($sql);
513 }
514
515 /**
516 * taxonomy => primary term ID for one post.
517 *
518 * @param int $post_id Post ID
519 * @return array<string,int>
520 */
521 private function get_primary_terms(int $post_id): array {
522 global $wpdb;
523
524 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
525 $rows = $wpdb->get_results(
526 $wpdb->prepare(
527 "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
528 $post_id,
529 $wpdb->esc_like(self::PRIMARY_TERM_PREFIX) . '%'
530 ),
531 ARRAY_A
532 );
533
534 $terms = [];
535 foreach ((array) $rows as $row) {
536 $taxonomy = substr((string) $row['meta_key'], strlen(self::PRIMARY_TERM_PREFIX));
537 $term_id = (int) $row['meta_value'];
538 if ($taxonomy !== '' && $term_id > 0) {
539 $terms[$taxonomy] = $term_id;
540 }
541 }
542
543 return $terms;
544 }
545
546 // -------------------------------------------------------------------------
547 // Terms and users
548 // -------------------------------------------------------------------------
549
550 /**
551 * {@inheritDoc}
552 */
553 protected function get_term_ids_with_meta(int $page): array {
554 global $wpdb;
555
556 $offset = ($page - 1) * $this->chunk_size;
557
558 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
559 $ids = $wpdb->get_col(
560 $wpdb->prepare(
561 "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key = %s ORDER BY term_id ASC LIMIT %d OFFSET %d",
562 self::META_KEY,
563 $this->chunk_size,
564 $offset
565 )
566 );
567 $this->last_page_row_count = count($ids);
568
569 return $ids;
570 }
571
572 /**
573 * {@inheritDoc}
574 */
575 protected function export_termmeta_page(int $page): array {
576 $term_ids = $this->get_term_ids_with_meta($page);
577 if (empty($term_ids)) {
578 return [];
579 }
580
581 $records = [];
582 foreach ($term_ids as $term_id) {
583 $term_id = (int) $term_id;
584 $term = get_term($term_id);
585 $record = $this->build_term_record(
586 $term_id,
587 ($term instanceof \WP_Term) ? $term : null,
588 $this->read_meta_array(get_term_meta($term_id, self::META_KEY, true))
589 );
590 if ($record !== null) {
591 $records[] = $record;
592 }
593 }
594
595 return $records;
596 }
597
598 /**
599 * Canonical record for one term.
600 *
601 * @param int $term_id Term ID
602 * @param \WP_Term|null $term The term, when it still exists
603 * @param array $meta Slim SEO's `slim_seo` array for the term
604 * @return array|null Record, or null when there is nothing to carry
605 */
606 private function build_term_record(int $term_id, ?\WP_Term $term, array $meta): ?array {
607 if (empty($meta)) {
608 return null;
609 }
610
611 return [
612 'object_id' => $term_id,
613 'object_type' => 'term',
614 'source_plugin' => $this->plugin_slug,
615 'data' => [
616 'seo_title' => $this->convert_term_value($this->meta_string($meta, 'title'), $term),
617 'meta_description' => $this->convert_term_value($this->meta_string($meta, 'description'), $term),
618 'canonical_url' => $this->meta_string($meta, 'canonical'),
619 'noindex' => empty($meta['noindex']) ? 0 : 1,
620 'nofollow' => 0,
621 'og_image' => $this->meta_string($meta, 'facebook_image'),
622 'twitter_image' => $this->meta_string($meta, 'twitter_image'),
623 ],
624 'extended' => [],
625 ];
626 }
627
628 /**
629 * {@inheritDoc}
630 *
631 * Slim SEO stores no per-user SEO; the author archive is a site template.
632 */
633 protected function export_usermeta_page(int $page): array {
634 return [];
635 }
636
637 // -------------------------------------------------------------------------
638 // Redirects
639 // -------------------------------------------------------------------------
640
641 /**
642 * {@inheritDoc}
643 */
644 protected function export_redirections_page(int $page): array {
645 $redirects = array_slice($this->get_all_redirects(), ($page - 1) * $this->chunk_size, $this->chunk_size);
646 $this->last_page_row_count = count($redirects);
647
648 $records = [];
649 foreach ($redirects as $redirect) {
650 $record = $this->map_redirect(is_array($redirect) ? $redirect : []);
651 if ($record !== null) {
652 $records[] = $record;
653 }
654 }
655
656 return $records;
657 }
658
659 /**
660 * The `ss_redirects` map, always an array.
661 *
662 * @return array<string,array>
663 */
664 private function get_redirects(): array {
665 $redirects = get_option(self::REDIRECTS_OPTION, []);
666
667 return is_array($redirects) ? $redirects : [];
668 }
669
670 /**
671 * Every redirect Slim SEO serves: the manual `ss_redirects` rules followed
672 * by the auto redirection's old permalinks, in Slim SEO's row shape so
673 * map_redirect() handles both.
674 *
675 * The old permalinks are the ones a long-lived site has most of — every
676 * slug edit since Slim SEO was installed — and they vanish with the plugin.
677 *
678 * @return array<int,array>
679 */
680 private function get_all_redirects(): array {
681 if ($this->all_redirects === null) {
682 $this->all_redirects = array_merge(array_values($this->get_redirects()), $this->get_old_permalink_redirects());
683 }
684
685 return $this->all_redirects;
686 }
687
688 /**
689 * Slim SEO's auto redirection as exact-match rows: `_ss_old_permalink`
690 * holds each previous permalink of a post, and Redirection::auto_redirection()
691 * 301s a request for one to the post's current permalink.
692 *
693 * @return array<int,array>
694 */
695 private function get_old_permalink_redirects(): array {
696 global $wpdb;
697
698 $post_types = $this->get_exportable_post_types();
699 if (empty($post_types)) {
700 return [];
701 }
702
703 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
704
705 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names are $wpdb properties and every value is a placeholder replacement.
706 $sql = $wpdb->prepare(
707 "SELECT pm.post_id, pm.meta_value
708 FROM {$wpdb->postmeta} pm
709 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
710 WHERE pm.meta_key = %s
711 AND p.post_status = 'publish'
712 AND p.post_type IN ({$placeholders})
713 ORDER BY pm.post_id ASC, pm.meta_id ASC",
714 array_merge([self::OLD_PERMALINK_META], $post_types)
715 );
716 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
717
718 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- prepared above.
719 $rows = $wpdb->get_results($sql, ARRAY_A);
720
721 $redirects = [];
722 foreach ((array) $rows as $row) {
723 $redirect = $this->map_old_permalink((int) ($row['post_id'] ?? 0), (string) ($row['meta_value'] ?? ''));
724 if ($redirect !== null) {
725 $redirects[] = $redirect;
726 }
727 }
728
729 return $redirects;
730 }
731
732 /**
733 * One old permalink as a Slim SEO redirect row (from, to, type,
734 * condition), or null when it cannot redirect anywhere.
735 *
736 * Both URLs become paths relative to the home URL, the way Slim SEO
737 * stores a manual rule. A path that still resolves to the post itself is
738 * no redirect, and one carrying a query string is skipped because
739 * ThinkRank Pro matches the request path alone.
740 *
741 * @param int $post_id Post ID
742 * @param string $old_url Previous permalink
743 * @return array|null
744 */
745 private function map_old_permalink(int $post_id, string $old_url): ?array {
746 if ($post_id <= 0 || trim($old_url) === '') {
747 return null;
748 }
749
750 $current = (string) get_permalink($post_id);
751 $from = $this->relative_path($old_url);
752 $to = $this->relative_path($current);
753
754 if ($from === '' || $current === '' || $from === $to || str_contains($from, '?')) {
755 return null;
756 }
757
758 // The target keeps its trailing slash (as a manual Slim SEO rule does:
759 // `sample-page/`), or every hit pays a second hop through WordPress's
760 // canonical redirect to add it back.
761 $to = $this->relative_path($current, false);
762
763 return [
764 'from' => $from,
765 'to' => $to === '' ? '/' : $to,
766 'type' => 301,
767 'condition' => 'exact-match',
768 'enable' => 1,
769 'note' => 'Slim SEO auto redirection',
770 ];
771 }
772
773 /**
774 * A URL as a path relative to the home URL, with no surrounding slashes —
775 * Slim SEO's Redirection\Helper::normalize_url() shape.
776 *
777 * @param string $url Absolute or relative URL
778 * @param bool $rtrim Whether to drop a trailing slash as well
779 * @return string
780 */
781 private function relative_path(string $url, bool $rtrim = true): string {
782 $url = trim(html_entity_decode($url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8'));
783 $home = untrailingslashit((string) home_url());
784 if ($home !== '' && str_starts_with($url, $home)) {
785 $url = substr($url, strlen($home));
786 }
787
788 return $rtrim ? trim($url, '/') : ltrim($url, '/');
789 }
790
791 /**
792 * One Slim SEO redirect as a canonical redirection record.
793 *
794 * Slim SEO stores `from` relative to the home URL without a leading slash
795 * ("old-page"), which is what ThinkRank Pro matches against. A 410 has no
796 * target, so an empty `to` is only a reason to skip for the other codes.
797 *
798 * @param array $redirect Slim SEO redirect row
799 * @return array|null Record, or null when the row cannot be a redirect
800 */
801 private function map_redirect(array $redirect): ?array {
802 $source = trim((string) ($redirect['from'] ?? ''));
803 $target = trim((string) ($redirect['to'] ?? ''));
804 $code = (int) ($redirect['type'] ?? 301);
805 if (!in_array($code, [301, 302, 307, 410], true)) {
806 $code = 301;
807 }
808
809 if ($source === '' || ($target === '' && $code !== 410)) {
810 return null;
811 }
812
813 $condition = (string) ($redirect['condition'] ?? 'exact-match');
814 if ($condition === 'regex') {
815 $source = self::convert_regex_source($source);
816 }
817
818 return [
819 'object_type' => 'redirection',
820 'source_plugin' => $this->plugin_slug,
821 'data' => [],
822 'extended' => [
823 'source_url' => $source,
824 'target_url' => $target,
825 'http_code' => $code,
826 'match_type' => self::MATCH_TYPES[$condition] ?? 'exact',
827 'is_regex' => $condition === 'regex',
828 // Slim SEO writes 1/0; a missing flag means the rule predates it.
829 'enabled' => !array_key_exists('enable', $redirect) || !empty($redirect['enable']),
830 'note' => (string) ($redirect['note'] ?? ''),
831 'ignore_parameters' => !empty($redirect['ignoreParameters']),
832 ],
833 ];
834 }
835
836 /**
837 * Rewrite a Slim SEO regex source for ThinkRank Pro's matcher.
838 *
839 * Slim SEO tests a pattern against the request path with its slashes
840 * trimmed (`blog/post-9`); Pro tests it against the path with a leading
841 * slash (`/blog/post-9`). An unanchored pattern matches either way, but
842 * one anchored with `^` would never match again, so the slash moves into
843 * the anchor. The pattern is first trimmed the way Slim SEO trims it.
844 *
845 * @param string $pattern Slim SEO `from` value.
846 * @return string
847 */
848 public static function convert_regex_source(string $pattern): string {
849 $home = home_url();
850 if ($home !== '' && str_starts_with($pattern, $home)) {
851 $pattern = substr($pattern, strlen($home));
852 }
853
854 // Slim SEO trims surrounding slashes; a trailing `\/` is an escaped
855 // slash inside the pattern, not a delimiter, and must keep its slash
856 // or the regex ends in a dangling backslash and never compiles.
857 $pattern = ltrim($pattern, '/');
858 $pattern = (string) preg_replace('#(?<!\\\\)/+$#', '', $pattern);
859
860 if (str_starts_with($pattern, '^')
861 && !str_starts_with($pattern, '^/')
862 && !str_starts_with($pattern, '^\\/')
863 ) {
864 $pattern = '^/' . substr($pattern, 1);
865 }
866
867 return $pattern;
868 }
869
870 /**
871 * {@inheritDoc}
872 *
873 * Slim SEO's 404 log (`url`, `hit`, `updated_at`) into ThinkRank Pro's
874 * 404 Monitor. Both store the path relative to the home URL without a
875 * leading slash, so the URL carries over as written.
876 */
877 protected function export_404_logs_page(int $page): array {
878 global $wpdb;
879
880 $table = $this->log_404_table();
881 if ($table === '') {
882 $this->last_page_row_count = 0;
883 return [];
884 }
885
886 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
887 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
888 $rows = $wpdb->get_results(
889 $wpdb->prepare(
890 "SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d",
891 $this->chunk_size,
892 ($page - 1) * $this->chunk_size
893 ),
894 ARRAY_A
895 );
896 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
897
898 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
899
900 $records = [];
901 foreach ((array) $rows as $row) {
902 $uri = ltrim(trim((string) ($row['url'] ?? '')), '/');
903 if ($uri === '') {
904 continue;
905 }
906 $records[] = [
907 'object_type' => '404_log',
908 'source_plugin' => $this->plugin_slug,
909 'data' => [],
910 'extended' => [
911 'uri' => $uri,
912 'times_accessed' => max(1, (int) ($row['hit'] ?? 1)),
913 'referer' => '',
914 'user_agent' => '',
915 'last_accessed' => (string) ($row['updated_at'] ?? ''),
916 ],
917 ];
918 }
919
920 return $records;
921 }
922
923 /**
924 * Rows in the 404 log, 0 when the table was never created.
925 */
926 private function count_404_logs(): int {
927 global $wpdb;
928
929 $table = $this->log_404_table();
930 if ($table === '') {
931 return 0;
932 }
933
934 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table name is $wpdb->prefix plus a literal.
935 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
936 }
937
938 /**
939 * The prefixed 404 table name, or '' when it does not exist.
940 */
941 private function log_404_table(): string {
942 global $wpdb;
943
944 $table = $wpdb->prefix . self::LOG_404_TABLE;
945 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
946 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
947
948 return $exists ? $table : '';
949 }
950
951 // -------------------------------------------------------------------------
952 // Settings
953 // -------------------------------------------------------------------------
954
955 /**
956 * {@inheritDoc}
957 */
958 protected function export_settings(): array {
959 $option = get_option(self::OPTION, []);
960
961 return [$this->build_settings_record(is_array($option) ? $option : [])];
962 }
963
964 /**
965 * The canonical settings record from the `slim_seo` option.
966 *
967 * @param array $option `slim_seo` option value
968 * @return array Settings record
969 */
970 private function build_settings_record(array $option): array {
971 $site = $this->site_context();
972 $home = $this->context_settings($option, 'home');
973 $author = $this->context_settings($option, 'author');
974 $features = $this->active_features($option);
975
976 $twitter = $this->twitter_profile_url((string) ($option['twitter_site'] ?? ''));
977
978 $data = [
979 // Imported titles carry %sep% now, so ThinkRank's separator has to
980 // be the one Slim SEO printed: `-`, unless the theme filters
981 // document_title_separator (ThinkRank does not hook it).
982 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook, not ours to name.
983 'separator' => (string) apply_filters('document_title_separator', self::SEPARATOR),
984 'homepage_title' => $this->render_template((string) ($home['title'] ?? ''), $site),
985 // Kept as Site Identity tags like the homepage title, so a site
986 // rename still reaches it (#897).
987 'homepage_description' => $this->convert_identity_template((string) ($home['description'] ?? ''), ''),
988 'social_profiles' => $twitter !== '' ? ['twitter' => $twitter] : [],
989 'noindex_archives' => [
990 'date' => false,
991 'author' => !empty($author['noindex']),
992 ],
993 'social_defaults' => [
994 'facebook_app_id' => trim((string) ($option['facebook_app_id'] ?? '')),
995 'og_default_image' => trim((string) ($option['default_facebook_image'] ?? '')),
996 'twitter_default_image' => trim((string) ($option['default_twitter_image'] ?? '')),
997 ],
998 ];
999
1000 $extended = [
1001 'title_formats' => $this->extract_title_formats($option),
1002 'post_type_settings' => $this->extract_post_type_settings($option),
1003 'author_archives' => array_filter([
1004 'title' => $this->convert_identity_template((string) ($author['title'] ?? ''), '%author_name%'),
1005 'description' => $this->convert_identity_template((string) ($author['description'] ?? ''), '%author_name%'),
1006 ]),
1007 // Slim SEO fills an image's empty alt with its title on output.
1008 'image_seo' => in_array('images_alt', $features, true) ? ['add_missing_alt' => true] : [],
1009 'breadcrumb_settings' => ['enabled' => in_array('breadcrumbs', $features, true)],
1010 'sitemap_settings' => [
1011 'enabled' => in_array('sitemaps', $features, true),
1012 'has_data' => true,
1013 ],
1014 // Slim SEO appends "The post … appeared first on …" to feed items.
1015 'feed' => ['source_link' => in_array('feed', $features, true)],
1016 'robots_txt' => !empty($option['robots_txt_editable']) && trim((string) ($option['robots_txt_content'] ?? '')) !== ''
1017 ? ['content' => (string) $option['robots_txt_content']]
1018 : [],
1019 // Raw capture for the snapshot, minus the AI provider key: a
1020 // secret does not belong in an export file.
1021 'raw_options' => array_diff_key($option, ['ai_api_key' => true]),
1022 ];
1023
1024 // Preserved, not applied: ThinkRank has no equivalent yet. Each one
1025 // keeps /import/cleanup from deleting the only copy without a warning.
1026 $code = array_filter([
1027 'header' => trim((string) ($option['header_code'] ?? '')),
1028 'body' => trim((string) ($option['body_code'] ?? '')),
1029 'footer' => trim((string) ($option['footer_code'] ?? '')),
1030 ]);
1031 if (!empty($code)) {
1032 $extended['code_injection'] = $code;
1033 }
1034
1035 // Removing Slim SEO brings /category/ back into every category URL.
1036 if (in_array('no_category_base', $features, true)) {
1037 $extended['no_category_base'] = true;
1038 }
1039
1040 $redirect_settings = $this->changed_redirect_settings($option);
1041 if (!empty($redirect_settings)) {
1042 $extended['redirect_settings'] = $redirect_settings;
1043 }
1044
1045 // Per-taxonomy noindex is applied (Content Type Matrix); the rest of
1046 // each context — custom templates, images — is preserved.
1047 $taxonomy_settings = $this->extract_taxonomy_settings($option);
1048 if (!empty($taxonomy_settings)) {
1049 $extended['taxonomy_settings'] = $taxonomy_settings;
1050 }
1051
1052 // Post type archives: only the blog index has a ThinkRank entity, so
1053 // its noindex is applied and everything else is preserved.
1054 $archive_settings = $this->extract_post_type_archive_settings($option);
1055 if (!empty($archive_settings)) {
1056 $extended['post_type_archive_settings'] = $archive_settings;
1057 }
1058
1059 // Slim SEO Pro. Global schemas that convert become Custom Schema
1060 // entries (ThinkRank Pro); per-post ones ride on each post record and
1061 // are counted here so cleanup knows they exist. The rest are kept.
1062 $schemas = $this->schema_converter()->convert_globals($this->posts_suppressing_global_schemas());
1063 $post_schemas = $this->count_posts_with_meta(self::SCHEMA_META_KEY);
1064 if (!empty($schemas['entries']) || $post_schemas > 0) {
1065 $extended['custom_schemas'] = ['entries' => $schemas['entries'], 'post_count' => $post_schemas];
1066 }
1067 if (!empty($schemas['preserved'])) {
1068 $extended['schema_templates'] = $schemas['preserved'];
1069 }
1070
1071 $markdown = $this->markdown_settings();
1072 if (!empty($markdown)) {
1073 $extended['markdown_for_ai'] = $markdown;
1074 }
1075
1076 return [
1077 'type' => 'settings',
1078 'source_plugin' => $this->plugin_slug,
1079 'data' => $data,
1080 'extended' => $extended,
1081 ];
1082 }
1083
1084 /**
1085 * Slim SEO Pro's Markdown feature (serve posts as Markdown to clients
1086 * that ask for it) in ThinkRank Pro's Markdown for AI shape, when it is on.
1087 *
1088 * The feature is on by default, so a Pro install whose settings were
1089 * never saved still has it; a site without Slim SEO Pro carries nothing.
1090 *
1091 * @return array{enabled?: bool, post_types?: string[]}
1092 */
1093 private function markdown_settings(): array {
1094 $pro = get_option(self::PRO_OPTION, null);
1095 if (!is_array($pro) && !$this->is_pro_active()) {
1096 return [];
1097 }
1098
1099 $pro = is_array($pro) ? $pro : [];
1100 $features = is_array($pro['features'] ?? null) ? array_map('strval', $pro['features']) : self::PRO_DEFAULT_FEATURES;
1101 if (!in_array('markdown', $features, true)) {
1102 return [];
1103 }
1104
1105 $types = is_array($pro['markdown_post_types'] ?? null) ? array_values(array_filter(array_map('strval', $pro['markdown_post_types']))) : ['post'];
1106
1107 return ['enabled' => true, 'post_types' => !empty($types) ? $types : ['post']];
1108 }
1109
1110 /**
1111 * Posts on which Slim SEO Pro shows no global schema: a post with active
1112 * schemas of its own replaces the globals unless its "also show global
1113 * schemas" box (`sss_allow_global`) is ticked (Factory\Base::get_schemas()).
1114 *
1115 * @return int[]
1116 */
1117 private function posts_suppressing_global_schemas(): array {
1118 global $wpdb;
1119
1120 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1121 $rows = $wpdb->get_results(
1122 $wpdb->prepare(
1123 "SELECT pm.post_id, pm.meta_value
1124 FROM {$wpdb->postmeta} pm
1125 WHERE pm.meta_key = %s
1126 AND NOT EXISTS (
1127 SELECT 1 FROM {$wpdb->postmeta} allow
1128 WHERE allow.post_id = pm.post_id AND allow.meta_key = %s AND allow.meta_value <> ''
1129 )
1130 ORDER BY pm.post_id ASC",
1131 self::SCHEMA_META_KEY,
1132 self::ALLOW_GLOBAL_META
1133 ),
1134 ARRAY_A
1135 );
1136
1137 $post_ids = [];
1138 foreach ((array) $rows as $row) {
1139 if (Slim_SEO_Schema_Converter::has_active_schemas($this->read_meta_array($row['meta_value'] ?? ''))) {
1140 $post_ids[] = (int) $row['post_id'];
1141 }
1142 }
1143
1144 return array_values(array_unique($post_ids));
1145 }
1146
1147 /**
1148 * Whether Slim SEO Pro is active.
1149 */
1150 private function is_pro_active(): bool {
1151 return in_array('slim-seo-pro/slim-seo-pro.php', (array) get_option('active_plugins', []), true);
1152 }
1153
1154 /**
1155 * Posts carrying one meta key.
1156 *
1157 * @param string $meta_key Meta key
1158 */
1159 private function count_posts_with_meta(string $meta_key): int {
1160 global $wpdb;
1161
1162 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1163 return (int) $wpdb->get_var(
1164 $wpdb->prepare("SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s", $meta_key)
1165 );
1166 }
1167
1168 /**
1169 * Features Slim SEO has switched on. An empty or missing list means every
1170 * default feature, exactly as Settings::is_feature_active() reads it.
1171 *
1172 * @param array $option `slim_seo` option value
1173 * @return string[]
1174 */
1175 private function active_features(array $option): array {
1176 $features = $option['features'] ?? [];
1177
1178 if (is_array($features) && !empty($features)) {
1179 return array_values(array_map('strval', $features));
1180 }
1181
1182 return array_values(array_diff(self::DEFAULT_FEATURES, self::FEATURES_OFF_UNTIL_SAVED));
1183 }
1184
1185 /**
1186 * The X/Twitter profile URL for Slim SEO's `twitter_site`, which is
1187 * printed verbatim as `twitter:site`: usually `@handle`, sometimes a
1188 * full profile URL. A URL is kept; a handle becomes one.
1189 *
1190 * @param string $value Stored `twitter_site`
1191 * @return string Profile URL, or '' when there is nothing usable
1192 */
1193 private function twitter_profile_url(string $value): string {
1194 $value = trim($value);
1195 if ($value === '') {
1196 return '';
1197 }
1198 if (preg_match('#^https?://#i', $value)) {
1199 return $value;
1200 }
1201
1202 $handle = ltrim($value, '@');
1203
1204 return $handle !== '' ? 'https://x.com/' . rawurlencode($handle) : '';
1205 }
1206
1207 /**
1208 * One context's settings array (`home`, `author`, a post type, …).
1209 *
1210 * @param array $option `slim_seo` option value
1211 * @param string $key Context key
1212 * @return array
1213 */
1214 private function context_settings(array $option, string $key): array {
1215 return is_array($option[$key] ?? null) ? $option[$key] : [];
1216 }
1217
1218 /**
1219 * Slim SEO's per-context titles onto ThinkRank's Site Identity keys.
1220 *
1221 * @param array $option `slim_seo` option value
1222 * @return array ThinkRank title-format key => template
1223 */
1224 private function extract_title_formats(array $option): array {
1225 $sources = [
1226 'homepage_title' => ['home', ''],
1227 'post_title' => ['post', '%post_title%'],
1228 'page_title' => ['page', '%page_title%'],
1229 'category_title' => ['category', '%category_title%'],
1230 'tag_title' => ['post_tag', '%tag_title%'],
1231 ];
1232
1233 $formats = [];
1234 foreach ($sources as $tr_key => [$context, $context_token]) {
1235 $raw = (string) ($this->context_settings($option, $context)['title'] ?? '');
1236 $converted = $this->convert_identity_template($raw, $context_token);
1237 if ($converted !== '') {
1238 $formats[$tr_key] = $converted;
1239 }
1240 }
1241
1242 return $formats;
1243 }
1244
1245 /**
1246 * Per-post-type title/description templates and noindex in the shape
1247 * migrate_post_type_settings() consumes.
1248 *
1249 * @param array $option `slim_seo` option value
1250 * @return array post_type => {title_template, description_template, custom_robots, robots}
1251 */
1252 private function extract_post_type_settings(array $option): array {
1253 $settings = [];
1254
1255 foreach ($option as $key => $value) {
1256 $key = (string) $key;
1257 if (!is_array($value) || !$this->is_post_type_context($key)) {
1258 continue;
1259 }
1260
1261 $pt = [];
1262 $title = $this->convert_global_template((string) ($value['title'] ?? ''));
1263 if ($title !== '') {
1264 $pt['title_template'] = $title;
1265 }
1266 $description = $this->convert_global_template((string) ($value['description'] ?? ''));
1267 if ($description !== '') {
1268 $pt['description_template'] = $description;
1269 }
1270 if (!empty($value['noindex'])) {
1271 $pt['custom_robots'] = true;
1272 $pt['robots'] = ['noindex'];
1273 }
1274
1275 if (!empty($pt)) {
1276 $settings[$key] = $pt;
1277 }
1278 }
1279
1280 return $settings;
1281 }
1282
1283 /**
1284 * Per-taxonomy contexts: `noindex` (applied through the Content Type
1285 * Matrix) plus any template or image the user wrote, kept in Slim SEO's
1286 * own syntax. The category and tag titles are already in title_formats,
1287 * and a template equal to Slim SEO's default is dropped — ThinkRank
1288 * renders the same thing without it.
1289 *
1290 * @param array $option `slim_seo` option value
1291 * @return array taxonomy => {noindex?, title?, description?, facebook_image?, twitter_image?}
1292 */
1293 private function extract_taxonomy_settings(array $option): array {
1294 $kept = [];
1295 foreach ($option as $key => $value) {
1296 $key = (string) $key;
1297 if (!$this->is_taxonomy_context($key, $value)) {
1298 continue;
1299 }
1300
1301 $entry = $this->non_default_context($value, self::DEFAULT_TEMPLATES['term']);
1302 if (in_array($key, ['category', 'post_tag'], true)) {
1303 unset($entry['title']);
1304 }
1305 if (!empty($value['noindex'])) {
1306 $entry = ['noindex' => true] + $entry;
1307 }
1308 if (!empty($entry)) {
1309 $kept[$key] = $entry;
1310 }
1311 }
1312
1313 return $kept;
1314 }
1315
1316 /**
1317 * `{post_type}_archive` contexts, keyed by post type: `noindex` plus any
1318 * non-default template or image, in Slim SEO's own syntax.
1319 *
1320 * @param array $option `slim_seo` option value
1321 * @return array post_type => {noindex?, title?, description?, facebook_image?, twitter_image?}
1322 */
1323 private function extract_post_type_archive_settings(array $option): array {
1324 $kept = [];
1325 foreach ($option as $key => $value) {
1326 $key = (string) $key;
1327 if (!is_array($value) || substr($key, -8) !== '_archive' || in_array($key, self::NON_CONTEXT_KEYS, true)) {
1328 continue;
1329 }
1330 $post_type = substr($key, 0, -8);
1331 if ($post_type === '' || !function_exists('post_type_exists') || !post_type_exists($post_type)) {
1332 continue;
1333 }
1334
1335 $entry = $this->non_default_context($value, self::DEFAULT_TEMPLATES['post_archive']);
1336 if (!empty($value['noindex'])) {
1337 $entry = ['noindex' => true] + $entry;
1338 }
1339 if (!empty($entry)) {
1340 $kept[$post_type] = $entry;
1341 }
1342 }
1343
1344 return $kept;
1345 }
1346
1347 /**
1348 * Whether an option key is a taxonomy's settings array.
1349 *
1350 * @param string $key Option key
1351 * @param mixed $value Option value
1352 */
1353 private function is_taxonomy_context(string $key, $value): bool {
1354 if (!is_array($value) || in_array($key, self::NON_CONTEXT_KEYS, true) || in_array($key, ['home', 'author'], true)) {
1355 return false;
1356 }
1357 if (substr($key, -8) === '_archive' || $this->is_post_type_context($key)) {
1358 return false;
1359 }
1360
1361 return function_exists('taxonomy_exists') && taxonomy_exists($key);
1362 }
1363
1364 /**
1365 * A context's templates and images, minus empty values and minus the
1366 * templates that only restate Slim SEO's defaults.
1367 *
1368 * @param array $value Context settings
1369 * @param array $defaults Slim SEO's default title/description for it
1370 * @return array
1371 */
1372 private function non_default_context(array $value, array $defaults): array {
1373 $entry = [];
1374 foreach (['title', 'description', 'facebook_image', 'twitter_image'] as $field) {
1375 $text = is_scalar($value[$field] ?? null) ? trim((string) $value[$field]) : '';
1376 if ($text === '') {
1377 continue;
1378 }
1379 if (isset($defaults[$field]) && $this->canonical_template($text) === $this->canonical_template($defaults[$field])) {
1380 continue;
1381 }
1382 $entry[$field] = $text;
1383 }
1384
1385 return $entry;
1386 }
1387
1388 /**
1389 * A template reduced to what it renders: `{{ page }}` (empty on page
1390 * one) removed, separators collapsed, whitespace normalised — so
1391 * `{{ term.name }} {{ sep }} {{ site.title }}` equals Slim SEO's default
1392 * `{{ term.name }} {{ sep }} {{ page }} {{ sep }} {{ site.title }}`.
1393 *
1394 * @param string $template Slim SEO template
1395 */
1396 private function canonical_template(string $template): string {
1397 $template = (string) preg_replace('/\{\{\s*page\s*\}\}/', '', $template);
1398 $template = (string) preg_replace('/\{\{\s*([^}\s]+?)\s*\}\}/', '{{ $1 }}', $template);
1399 $segments = array_filter(
1400 array_map('trim', explode('{{ sep }}', $template)),
1401 static fn(string $s): bool => $s !== ''
1402 );
1403
1404 return trim((string) preg_replace('/\s+/', ' ', implode(' {{ sep }} ', $segments)));
1405 }
1406
1407 /**
1408 * Whether an option key names a post type's single-item settings.
1409 *
1410 * @param string $key Option key
1411 */
1412 private function is_post_type_context(string $key): bool {
1413 if (in_array($key, self::NON_CONTEXT_KEYS, true) || in_array($key, ['home', 'author'], true)) {
1414 return false;
1415 }
1416 if (substr($key, -8) === '_archive') {
1417 return false;
1418 }
1419 if (function_exists('taxonomy_exists') && taxonomy_exists($key)) {
1420 return false;
1421 }
1422
1423 return function_exists('post_type_exists') && post_type_exists($key);
1424 }
1425
1426 /**
1427 * Redirect behaviour settings the user moved off Slim SEO's defaults.
1428 *
1429 * @param array $option `slim_seo` option value
1430 * @return array
1431 */
1432 private function changed_redirect_settings(array $option): array {
1433 $changed = [];
1434 foreach (self::REDIRECT_SETTING_DEFAULTS as $key => $default) {
1435 if (!array_key_exists($key, $option)) {
1436 continue;
1437 }
1438 $value = $option[$key];
1439 // Slim SEO saves an unticked checkbox as -1 and reads it back as
1440 // 0 (Redirection\Settings::option_saved() / list()), so -1 is
1441 // "off", not a change (#899). Only the checkboxes: elsewhere -1 is
1442 // a real value.
1443 if (in_array($key, self::REDIRECT_CHECKBOXES, true) && is_numeric($value) && (int) $value === -1) {
1444 $value = 0;
1445 }
1446 if ((string) $value !== (string) $default) {
1447 $changed[$key] = $option[$key];
1448 }
1449 }
1450
1451 return $changed;
1452 }
1453
1454 // -------------------------------------------------------------------------
1455 // Templates
1456 // -------------------------------------------------------------------------
1457
1458 /**
1459 * {@inheritDoc}
1460 *
1461 * Resolve a Slim SEO template against a post (or the site alone).
1462 */
1463 protected function convert_template_variables($value, ?int $post_id = null): string {
1464 $value = $this->stringify_template_value($value);
1465
1466 return $this->render_template($value, $post_id ? $this->post_context($post_id) : $this->site_context());
1467 }
1468
1469 /**
1470 * Slim SEO per-post paths ThinkRank resolves per request (#886): kept as
1471 * tags rather than frozen into text. `post.excerpt` (empty when the post
1472 * has none, where %excerpt% falls back to the content) and
1473 * `post.categories` (every category, where %category% is the first) stay
1474 * literal: mapping them would change what the title says.
1475 */
1476 private const POST_TOKENS = [
1477 'post.title' => '%title%',
1478 'site.title' => '%sitename%',
1479 'sep' => '%sep%',
1480 'post.auto_description' => '%excerpt%',
1481 'post.date' => '%date%',
1482 'post.modified_date' => '%modified%',
1483 'author.display_name' => '%author%',
1484 ];
1485
1486 /**
1487 * Slim SEO per-term paths ThinkRank resolves on a term archive.
1488 * `term.description` stays literal: %excerpt% is the trimmed description.
1489 */
1490 private const TERM_TOKENS = [
1491 'term.name' => '%term%',
1492 'term.auto_description' => '%excerpt%',
1493 'site.title' => '%sitename%',
1494 'sep' => '%sep%',
1495 ];
1496
1497 /**
1498 * A per-post Slim SEO value: mapped paths as ThinkRank tags, the rest
1499 * rendered against the post. The data tree (content, every meta row,
1500 * terms, author) is only built when something is left to render.
1501 *
1502 * @param string $value Raw Slim SEO value
1503 * @param int $post_id Post ID
1504 * @return string
1505 */
1506 private function convert_post_value(string $value, int $post_id): string {
1507 return $this->tokenize_object_template(
1508 $value,
1509 self::brace_token_patterns(self::POST_TOKENS),
1510 fn(string $rest): string => $this->render_template($rest, $this->has_variables($rest) ? $this->post_context($post_id) : [])
1511 );
1512 }
1513
1514 /**
1515 * A per-term Slim SEO value: mapped paths as ThinkRank tags, the rest
1516 * rendered against the term.
1517 *
1518 * @param string $value Raw Slim SEO value
1519 * @param \WP_Term|null $term The term, when it still exists
1520 * @return string
1521 */
1522 private function convert_term_value(string $value, ?\WP_Term $term): string {
1523 return $this->tokenize_object_template(
1524 $value,
1525 self::brace_token_patterns(self::TERM_TOKENS),
1526 fn(string $rest): string => $this->render_template($rest, $this->has_variables($rest) ? $this->term_context($term) : [])
1527 );
1528 }
1529
1530 /**
1531 * Resolve `{{ path }}` variables against a data tree, then collapse
1532 * separators the way Slim SEO's Helper::normalize() does: split on
1533 * `{{ sep }}`, drop empty segments, rejoin with the separator.
1534 *
1535 * Unknown paths resolve to '' rather than staying literal — a stored
1536 * `{{ post.custom }}` must not reach a live title tag.
1537 *
1538 * @param string $template Slim SEO template
1539 * @param array $context Data tree (post, term, site, author, …)
1540 * @return string Resolved text
1541 */
1542 private function render_template(string $template, array $context): string {
1543 if (!$this->has_variables($template)) {
1544 return trim($template);
1545 }
1546
1547 $text = (string) preg_replace_callback(
1548 '/\{\{\s*([^}\s]+?)\s*\}\}/',
1549 function (array $m) use ($context): string {
1550 $path = (string) $m[1];
1551 if ($path === 'sep') {
1552 return self::SEP_MARK;
1553 }
1554 $value = $this->lookup($context, $path);
1555 if (is_array($value)) {
1556 $value = implode(', ', array_map('strval', array_filter($value, 'is_scalar')));
1557 }
1558
1559 return is_scalar($value) ? (string) $value : '';
1560 },
1561 $template
1562 );
1563
1564 $segments = array_filter(
1565 array_map('trim', explode(self::SEP_MARK, $text)),
1566 static fn(string $s): bool => $s !== ''
1567 );
1568 $text = implode(' ' . self::SEPARATOR . ' ', $segments);
1569
1570 return trim((string) preg_replace('/\s+/', ' ', $text));
1571 }
1572
1573 /**
1574 * Whether a template needs a data tree at all.
1575 *
1576 * @param string $template Slim SEO template
1577 */
1578 private function has_variables(string $template): bool {
1579 return $template !== '' && strpos($template, '{{') !== false;
1580 }
1581
1582 /**
1583 * Walk a dotted path through nested arrays.
1584 *
1585 * @param array $context Data tree
1586 * @param string $path e.g. "post.custom_field.price"
1587 * @return mixed|null
1588 */
1589 private function lookup(array $context, string $path) {
1590 $node = $context;
1591 foreach (explode('.', $path) as $segment) {
1592 if (!is_array($node) || !array_key_exists($segment, $node)) {
1593 return null;
1594 }
1595 $node = $node[$segment];
1596 }
1597
1598 return $node;
1599 }
1600
1601 /**
1602 * Data tree for site-level templates.
1603 */
1604 private function site_context(): array {
1605 $option = get_option(self::OPTION, []);
1606 $option = is_array($option) ? $option : [];
1607
1608 return [
1609 'site' => [
1610 'title' => (string) get_bloginfo('name'),
1611 'description' => (string) get_bloginfo('description'),
1612 'facebook_image' => trim((string) ($option['default_facebook_image'] ?? '')),
1613 'twitter_image' => trim((string) ($option['default_twitter_image'] ?? '')),
1614 ],
1615 'current' => [
1616 'year' => function_exists('wp_date') ? (string) wp_date('Y') : gmdate('Y'),
1617 'month' => function_exists('wp_date') ? (string) wp_date('m') : gmdate('m'),
1618 ],
1619 'page' => '',
1620 ];
1621 }
1622
1623 /**
1624 * Data tree for a post, mirroring SlimSEO\MetaTags\Data\Post.
1625 *
1626 * @param int $post_id Post ID
1627 */
1628 private function post_context(int $post_id): array {
1629 $context = $this->site_context();
1630 $post = get_post($post_id);
1631 if (!$post) {
1632 return $context;
1633 }
1634
1635 $content = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags(strip_shortcodes((string) $post->post_content))));
1636 $excerpt = (string) $post->post_excerpt;
1637 $date_format = (string) get_option('date_format', 'F j, Y');
1638
1639 $custom_fields = [];
1640 foreach ((array) get_post_meta($post_id) as $key => $values) {
1641 $custom_fields[(string) $key] = is_array($values) ? (string) reset($values) : '';
1642 }
1643
1644 $context['post'] = [
1645 'title' => (string) $post->post_title,
1646 'excerpt' => $excerpt,
1647 'content' => $content,
1648 'auto_description' => $this->truncate($excerpt !== '' ? wp_strip_all_tags($excerpt) : $content),
1649 'date' => $this->format_date((string) ($post->post_date_gmt ?? $post->post_date ?? ''), $date_format),
1650 'modified_date' => $this->format_date((string) ($post->post_modified_gmt ?? $post->post_modified ?? ''), $date_format),
1651 'thumbnail' => function_exists('get_the_post_thumbnail_url') ? (string) get_the_post_thumbnail_url($post_id, 'full') : '',
1652 'categories' => $this->term_names($post_id, 'category'),
1653 'tags' => $this->term_names($post_id, 'post_tag'),
1654 'custom_field' => $custom_fields,
1655 'tax' => $this->post_taxonomies_context($post_id, (string) $post->post_type),
1656 ];
1657
1658 $post_type = function_exists('get_post_type_object') ? get_post_type_object((string) $post->post_type) : null;
1659 $context['post_type'] = [
1660 'labels' => [
1661 'singular' => (string) ($post_type->labels->singular_name ?? ''),
1662 'plural' => (string) ($post_type->labels->name ?? ''),
1663 ],
1664 ];
1665
1666 $author_id = (int) ($post->post_author ?? 0);
1667 $context['author'] = $this->author_context($author_id);
1668
1669 return $context;
1670 }
1671
1672 /**
1673 * `post.tax.{taxonomy}` — term names per taxonomy of the post's type,
1674 * keyed the way Slim SEO's Data\Post::normalize() keys them (`-` → `_`),
1675 * so `{{ post.tax.product_cat }}` resolves on a WooCommerce site.
1676 *
1677 * @param int $post_id Post ID
1678 * @param string $post_type Post type
1679 * @return array<string,string[]>
1680 */
1681 private function post_taxonomies_context(int $post_id, string $post_type): array {
1682 if (!function_exists('get_object_taxonomies')) {
1683 return [];
1684 }
1685
1686 $tax = [];
1687 foreach ((array) get_object_taxonomies($post_type, 'names') as $taxonomy) {
1688 $tax[str_replace('-', '_', (string) $taxonomy)] = $this->term_names($post_id, (string) $taxonomy);
1689 }
1690
1691 return $tax;
1692 }
1693
1694 /**
1695 * Data tree for a term, mirroring SlimSEO\MetaTags\Data\Term.
1696 *
1697 * @param \WP_Term|null $term The term
1698 */
1699 private function term_context(?\WP_Term $term): array {
1700 $context = $this->site_context();
1701 if (!$term) {
1702 return $context;
1703 }
1704
1705 $description = trim(wp_strip_all_tags((string) $term->description));
1706 $context['term'] = [
1707 'name' => (string) $term->name,
1708 'description' => $description,
1709 'auto_description' => $this->truncate($description),
1710 ];
1711
1712 return $context;
1713 }
1714
1715 /**
1716 * Author data Slim SEO exposes as `author.*`.
1717 *
1718 * @param int $user_id User ID
1719 */
1720 private function author_context(int $user_id): array {
1721 if ($user_id <= 0 || !function_exists('get_the_author_meta')) {
1722 return [];
1723 }
1724
1725 $description = (string) get_the_author_meta('description', $user_id);
1726
1727 return [
1728 'display_name' => (string) get_the_author_meta('display_name', $user_id),
1729 'description' => $description,
1730 'auto_description' => $this->truncate($description),
1731 ];
1732 }
1733
1734 /**
1735 * Term names for a post in one taxonomy.
1736 *
1737 * @param int $post_id Post ID
1738 * @param string $taxonomy Taxonomy
1739 * @return string[]
1740 */
1741 private function term_names(int $post_id, string $taxonomy): array {
1742 if (!function_exists('get_the_terms')) {
1743 return [];
1744 }
1745 $terms = get_the_terms($post_id, $taxonomy);
1746 if (!is_array($terms)) {
1747 return [];
1748 }
1749
1750 return array_values(array_map(static fn($t) => (string) $t->name, $terms));
1751 }
1752
1753 /**
1754 * Slim SEO's Helper::truncate(): 160 characters, whitespace collapsed.
1755 *
1756 * @param string $text Text
1757 */
1758 private function truncate(string $text): string {
1759 $text = trim((string) preg_replace('/\s+/', ' ', $text));
1760
1761 return function_exists('mb_substr') ? mb_substr($text, 0, 160) : substr($text, 0, 160);
1762 }
1763
1764 /**
1765 * A stored GMT date in the site's date format.
1766 *
1767 * @param string $date MySQL datetime
1768 * @param string $format PHP date format
1769 */
1770 private function format_date(string $date, string $format): string {
1771 if ($date === '' || strpos($date, '0000-00-00') === 0) {
1772 return '';
1773 }
1774 $ts = strtotime($date . ' UTC');
1775
1776 return $ts ? (string) wp_date($format, $ts) : '';
1777 }
1778
1779 /**
1780 * Convert a Slim SEO template into ThinkRank's Site Identity vocabulary
1781 * (%site_title%/%sep%/%post_title%…). Paths ThinkRank cannot resolve are
1782 * dropped, along with a separator that would be left dangling.
1783 *
1784 * @param string $template Slim SEO template
1785 * @param string $context_token Token post.title/term.name stand for ('' when none)
1786 * @return string ThinkRank Site Identity template
1787 */
1788 private function convert_identity_template(string $template, string $context_token): string {
1789 $map = [
1790 'site.title' => '%site_title%',
1791 'site.description' => '%site_description%',
1792 'sep' => '%sep%',
1793 'post.date' => '%date%',
1794 'author.display_name' => '%author_name%',
1795 'post.categories' => '%category_title%',
1796 'post.tags' => '%tag_title%',
1797 ];
1798 if ($context_token !== '') {
1799 $map['post.title'] = $context_token;
1800 $map['term.name'] = $context_token;
1801 }
1802
1803 return $this->convert_to_tokens($template, $map, '%sep%');
1804 }
1805
1806 /**
1807 * Convert a Slim SEO template into the Global SEO Pattern_Resolver
1808 * vocabulary (%title%/%sitename%/%sep%/%excerpt%).
1809 *
1810 * @param string $template Slim SEO template
1811 * @return string Converted template
1812 */
1813 private function convert_global_template(string $template): string {
1814 $map = [
1815 'post.title' => '%title%',
1816 'site.title' => '%sitename%',
1817 'sep' => '%sep%',
1818 'post.excerpt' => '%excerpt%',
1819 'post.auto_description' => '%excerpt%',
1820 'post.date' => '%date%',
1821 'author.display_name' => '%author%',
1822 'post.categories' => '%category%',
1823 ];
1824
1825 return $this->convert_to_tokens($template, $map, '%sep%');
1826 }
1827
1828 /**
1829 * Shared token conversion: mapped paths become tokens, the rest vanish,
1830 * and separators left with nothing on one side are removed.
1831 *
1832 * @param string $template Slim SEO template
1833 * @param array $map Slim path => ThinkRank token
1834 * @param string $sep The target vocabulary's separator token
1835 * @return string
1836 */
1837 private function convert_to_tokens(string $template, array $map, string $sep): string {
1838 $template = trim($template);
1839 if ($template === '' || strpos($template, '{{') === false) {
1840 return $template;
1841 }
1842
1843 $converted = (string) preg_replace_callback(
1844 '/\{\{\s*([^}\s]+?)\s*\}\}/',
1845 static function (array $m) use ($map): string {
1846 return (string) ($map[$m[1]] ?? '');
1847 },
1848 $template
1849 );
1850
1851 $segments = array_filter(
1852 array_map('trim', explode($sep, $converted)),
1853 static fn(string $s): bool => $s !== ''
1854 );
1855
1856 return trim((string) preg_replace('/\s+/', ' ', implode(' ' . $sep . ' ', $segments)));
1857 }
1858
1859 // -------------------------------------------------------------------------
1860 // Helpers
1861 // -------------------------------------------------------------------------
1862
1863 /**
1864 * A stored `slim_seo` value as an array. WordPress unserializes it for
1865 * us; anything else is foreign data and reads as empty.
1866 *
1867 * @param mixed $value Raw meta value
1868 */
1869 private function read_meta_array($value): array {
1870 if (is_string($value) && is_serialized($value)) {
1871 $value = Safe_Unserializer::to_array($value);
1872 }
1873
1874 return is_array($value) ? $value : [];
1875 }
1876
1877 /**
1878 * One string field of a meta array, trimmed.
1879 *
1880 * @param array $meta Meta array
1881 * @param string $key Field
1882 */
1883 private function meta_string(array $meta, string $key): string {
1884 $value = $meta[$key] ?? '';
1885
1886 return is_scalar($value) ? trim((string) $value) : '';
1887 }
1888 }
1889