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

class-content-type-settings.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-content-type-settings.php

570 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-content-type feature policy.
4 *
5 * Global SEO settings were per post type for metas, robots meta and the schema
6 * *type* only; Open Graph, Twitter cards, schema on/off and analytics were
7 * site-wide, and taxonomies, author/date archives, search results and 404 had
8 * no entry at all. This class is the single place that answers "is <feature>
9 * on for <this content type>?" for every read path (#660).
10 *
11 * Storage reuses the existing `thinkrank_global_seo_settings` option, keyed by
12 * an entity key: a post type is stored under its own slug (so every value
13 * already saved by the per-post-type screen keeps working untouched), and the
14 * new non-post-type entities live under prefixed keys that can never collide
15 * with a post type slug (`taxonomy:`, `archive:`, `special:`).
16 *
17 * Every feature flag is a THREE-state value — 'inherit' | 'on' | 'off' — and
18 * defaults to 'inherit'. Inherit resolves to the feature's existing global
19 * setting, so an install that has never touched the matrix produces byte
20 * identical output to before.
21 *
22 * @package ThinkRank
23 * @subpackage SEO
24 * @since 2.5.0
25 */
26
27 declare(strict_types=1);
28
29 namespace ThinkRank\SEO;
30
31 // Prevent direct access.
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 /**
37 * Per-content-type feature policy and resolver.
38 *
39 * @since 2.5.0
40 */
41 class Content_Type_Settings {
42
43 /**
44 * Option holding the per-entity settings (shared with Global_SEO_Endpoint).
45 *
46 * @var string
47 */
48 public const OPTION_NAME = 'thinkrank_global_seo_settings';
49
50 /**
51 * Entity key prefixes for the non-post-type entities.
52 */
53 public const PREFIX_TAXONOMY = 'taxonomy:';
54 public const PREFIX_ARCHIVE = 'archive:';
55 public const PREFIX_SPECIAL = 'special:';
56
57 public const ENTITY_BLOG_INDEX = self::PREFIX_ARCHIVE . 'blog';
58 public const ENTITY_AUTHOR_ARCHIVE = self::PREFIX_ARCHIVE . 'author';
59 public const ENTITY_DATE_ARCHIVE = self::PREFIX_ARCHIVE . 'date';
60 public const ENTITY_SEARCH = self::PREFIX_SPECIAL . 'search';
61 public const ENTITY_404 = self::PREFIX_SPECIAL . '404';
62
63 /**
64 * Switchable features. The value is the settings key each is stored under.
65 */
66 public const FEATURE_META = 'meta_enabled';
67 public const FEATURE_SCHEMA = 'schema_enabled';
68 public const FEATURE_OPEN_GRAPH = 'open_graph_enabled';
69 public const FEATURE_TWITTER = 'twitter_card_enabled';
70 public const FEATURE_ANALYTICS = 'analytics_enabled';
71
72 /**
73 * Every tri-state feature key handled by this class.
74 *
75 * Sitemap inclusion is deliberately NOT here: it keeps living in the
76 * sitemap settings' own `include_*` / `exclude_*` flags so the presets UI
77 * and the generator's regex matching stay the single storage format.
78 *
79 * @var string[]
80 */
81 public const FEATURES = [
82 self::FEATURE_META,
83 self::FEATURE_SCHEMA,
84 self::FEATURE_OPEN_GRAPH,
85 self::FEATURE_TWITTER,
86 self::FEATURE_ANALYTICS,
87 ];
88
89 /**
90 * Allowed tri-state values.
91 *
92 * @var string[]
93 */
94 public const STATES = ['inherit', 'on', 'off'];
95
96 /**
97 * Memoised copy of the option for the current request.
98 *
99 * @var array|null
100 */
101 private static ?array $cache = null;
102
103 /**
104 * Drop the memoised option copy (used after a write, and by tests).
105 *
106 * @return void
107 */
108 public static function flush_cache(): void {
109 self::$cache = null;
110 }
111
112 /**
113 * All stored per-entity settings.
114 *
115 * @return array<string, array>
116 */
117 public static function all_settings(): array {
118 if (self::$cache === null) {
119 $stored = get_option(self::OPTION_NAME, []);
120 self::$cache = is_array($stored) ? $stored : [];
121 }
122
123 return self::$cache;
124 }
125
126 /**
127 * Stored settings for one entity ('' when nothing saved).
128 *
129 * @param string $entity_key Entity key.
130 * @return array
131 */
132 public static function get_entity_settings(string $entity_key): array {
133 $all = self::all_settings();
134
135 return is_array($all[$entity_key] ?? null) ? $all[$entity_key] : [];
136 }
137
138 /**
139 * The stored tri-state for one feature on one entity.
140 *
141 * @param string $entity_key Entity key.
142 * @param string $feature One of self::FEATURES.
143 * @return string 'inherit' | 'on' | 'off'
144 */
145 public static function feature_state(string $entity_key, string $feature): string {
146 $value = self::get_entity_settings($entity_key)[$feature] ?? 'inherit';
147
148 return in_array($value, self::STATES, true) ? $value : 'inherit';
149 }
150
151 /**
152 * Resolve a feature for an entity, falling back to the global default.
153 *
154 * @param string $feature One of self::FEATURES.
155 * @param string|null $entity_key Entity key, or null when the request maps to none.
156 * @param bool $global_default The feature's current site-wide value.
157 * @return bool
158 */
159 public static function is_enabled(string $feature, ?string $entity_key, bool $global_default): bool {
160 if ($entity_key === null || $entity_key === '') {
161 return $global_default;
162 }
163
164 $state = self::feature_state($entity_key, $feature);
165
166 if ($state === 'on') {
167 return true;
168 }
169 if ($state === 'off') {
170 return false;
171 }
172
173 return $global_default;
174 }
175
176 /**
177 * Resolve a feature for the entity the current request maps to.
178 *
179 * @param string $feature One of self::FEATURES.
180 * @param bool $global_default The feature's current site-wide value.
181 * @return bool
182 */
183 public static function is_enabled_for_current(string $feature, bool $global_default): bool {
184 return self::is_enabled($feature, self::current_entity_key(), $global_default);
185 }
186
187 /**
188 * Map the current main query to an entity key.
189 *
190 * Mirrors SEO_Manager::detect_current_context() but resolves to the entity
191 * whose settings apply, so a category archive answers `taxonomy:category`
192 * rather than the generic `category` context string.
193 *
194 * @return string|null Entity key, or null when nothing applies (e.g. feeds).
195 */
196 public static function current_entity_key(): ?string {
197 if (!function_exists('is_404')) {
198 return null;
199 }
200
201 if (is_404()) {
202 return self::ENTITY_404;
203 }
204
205 if (is_search()) {
206 return self::ENTITY_SEARCH;
207 }
208
209 // The blog posts index, which is the homepage on a default install.
210 // It is not singular even when it IS the front page, so it needs its
211 // own branch or it resolves to null and every feature silently falls
212 // back to the global default.
213 if (function_exists('is_home') && is_home()) {
214 return self::ENTITY_BLOG_INDEX;
215 }
216
217 if (is_singular()) {
218 $post_type = (string) get_post_type();
219
220 return $post_type !== '' ? $post_type : null;
221 }
222
223 if (is_category() || is_tag() || is_tax()) {
224 $queried = get_queried_object();
225
226 return $queried instanceof \WP_Term ? self::PREFIX_TAXONOMY . $queried->taxonomy : null;
227 }
228
229 if (is_author()) {
230 return self::ENTITY_AUTHOR_ARCHIVE;
231 }
232
233 if (is_date()) {
234 return self::ENTITY_DATE_ARCHIVE;
235 }
236
237 if (is_post_type_archive()) {
238 $queried = get_queried_object();
239
240 return $queried instanceof \WP_Post_Type ? $queried->name : null;
241 }
242
243 return null;
244 }
245
246 /**
247 * Whether an entity key names something that exists on this site.
248 *
249 * @param string $entity_key Entity key.
250 * @return bool
251 */
252 public static function is_valid_entity(string $entity_key): bool {
253 foreach (self::get_entities() as $entity) {
254 if ($entity['key'] === $entity_key) {
255 return true;
256 }
257 }
258
259 return false;
260 }
261
262 /**
263 * Every configurable entity on this site, in display order.
264 *
265 * Each entry: key, label, group, features (applicable tri-state features),
266 * supports_sitemap, supports_robots, and — for post types and taxonomies —
267 * the object slug the sitemap flags are keyed by.
268 *
269 * @return array<int, array>
270 */
271 public static function get_entities(): array {
272 $entities = [];
273
274 foreach (get_post_types(['public' => true], 'objects') as $post_type) {
275 if (!Global_SEO_Post_Types::is_allowed($post_type)) {
276 continue;
277 }
278
279 $entities[] = [
280 'key' => $post_type->name,
281 'label' => $post_type->label,
282 'group' => 'post_type',
283 'object' => $post_type->name,
284 'features' => self::FEATURES,
285 'supports_sitemap' => self::sitemap_accepts_post_type($post_type->name),
286 'supports_robots' => true,
287 ];
288 }
289
290 foreach (get_taxonomies(['public' => true], 'objects') as $taxonomy) {
291 if (empty($taxonomy->public)) {
292 continue;
293 }
294
295 $entities[] = [
296 'key' => self::PREFIX_TAXONOMY . $taxonomy->name,
297 'label' => $taxonomy->label,
298 'group' => 'taxonomy',
299 'object' => $taxonomy->name,
300 'features' => self::FEATURES,
301 'supports_sitemap' => self::sitemap_accepts_taxonomy($taxonomy->name),
302 'supports_robots' => true,
303 ];
304 }
305
306 $entities[] = [
307 'key' => self::ENTITY_BLOG_INDEX,
308 'label' => __('Blog posts index', 'thinkrank'),
309 'group' => 'archive',
310 'object' => '',
311 'features' => self::FEATURES,
312 // The posts page is a `page` in the sitemap, included or excluded
313 // with every other page; it has no inclusion flag of its own.
314 'supports_sitemap' => false,
315 'supports_robots' => true,
316 ];
317
318 $entities[] = [
319 'key' => self::ENTITY_AUTHOR_ARCHIVE,
320 'label' => __('Author archives', 'thinkrank'),
321 'group' => 'archive',
322 'object' => '',
323 'features' => self::FEATURES,
324 'supports_sitemap' => false,
325 'supports_robots' => true,
326 ];
327
328 $entities[] = [
329 'key' => self::ENTITY_DATE_ARCHIVE,
330 'label' => __('Date archives', 'thinkrank'),
331 'group' => 'archive',
332 'object' => '',
333 'features' => self::FEATURES,
334 'supports_sitemap' => false,
335 'supports_robots' => true,
336 ];
337
338 // Search and 404 carry no schema: neither is a page with an entity to
339 // describe, and ThinkRank emits none there today.
340 $special_features = array_values(array_diff(self::FEATURES, [self::FEATURE_SCHEMA]));
341
342 $entities[] = [
343 'key' => self::ENTITY_SEARCH,
344 'label' => __('Search results', 'thinkrank'),
345 'group' => 'special',
346 'object' => '',
347 'features' => $special_features,
348 'supports_sitemap' => false,
349 'supports_robots' => true,
350 ];
351
352 $entities[] = [
353 'key' => self::ENTITY_404,
354 'label' => __('404 page', 'thinkrank'),
355 'group' => 'special',
356 'object' => '',
357 'features' => $special_features,
358 'supports_sitemap' => false,
359 'supports_robots' => true,
360 ];
361
362 /**
363 * Filter the configurable content-type matrix entities.
364 *
365 * @since 2.5.0
366 *
367 * @param array $entities Entity descriptors.
368 */
369 return (array) apply_filters('thinkrank_content_type_entities', $entities);
370 }
371
372 /**
373 * Robots defaults for an entity.
374 *
375 * Search results and 404 default to noindex/follow — the behaviour that was
376 * hardcoded in the frontend before this became settings-driven — so the
377 * default install keeps them out of the index.
378 *
379 * @param string $entity_key Entity key.
380 * @return array
381 */
382 public static function default_robots_meta(string $entity_key): array {
383 $noindexed = in_array($entity_key, [self::ENTITY_SEARCH, self::ENTITY_404], true);
384
385 return [
386 'index' => !$noindexed,
387 'noindex' => $noindexed,
388 'nofollow' => false,
389 'noarchive' => false,
390 'noimageindex' => false,
391 'nosnippet' => false,
392 ];
393 }
394
395 /**
396 * Effective robots directives for an entity, defaults included.
397 *
398 * Returns the stored `robots_meta` overlaid on the entity defaults when the
399 * entity's `robots_meta_enabled` switch is on, and the defaults otherwise.
400 *
401 * @param string $entity_key Entity key.
402 * @return array
403 */
404 public static function resolve_robots_meta(string $entity_key): array {
405 $defaults = self::default_robots_meta($entity_key);
406 $settings = self::get_entity_settings($entity_key);
407
408 if (empty($settings['robots_meta_enabled']) || !is_array($settings['robots_meta'] ?? null)) {
409 return $defaults;
410 }
411
412 $resolved = array_merge($defaults, $settings['robots_meta']);
413
414 // `index` and `noindex` are one decision stored as two booleans, and
415 // merging them separately could leave both false (or both true).
416 // build_robots_directives() resolves that sanely today, but the stored
417 // pair should not say two different things: noindex is the explicit
418 // instruction, so index is whatever it is not.
419 $resolved['index'] = empty($resolved['noindex']);
420
421 return $resolved;
422 }
423
424 /**
425 * Legacy sitemap inclusion flags, kept as the storage format.
426 *
427 * The presets UI and the generator both read these names, so the matrix is
428 * a second surface onto the same flags rather than a new format.
429 *
430 * @var array<string, string>
431 */
432 private const SITEMAP_LEGACY_FLAGS = [
433 'post_type:post' => 'include_posts',
434 'post_type:page' => 'include_pages',
435 'taxonomy:category' => 'include_categories',
436 'taxonomy:post_tag' => 'include_tags',
437 ];
438
439 /**
440 * The sitemap settings key holding one object's inclusion flag.
441 *
442 * @param string $group 'post_type' or 'taxonomy'.
443 * @param string $object_slug Post type or taxonomy slug.
444 * @return string
445 */
446 public static function sitemap_flag_key(string $group, string $object_slug): string {
447 return self::SITEMAP_LEGACY_FLAGS[$group . ':' . $object_slug] ?? 'include_' . $object_slug;
448 }
449
450 /**
451 * Whether the sitemap generator will emit this post type at all.
452 *
453 * Sitemap_Generator::should_include_post_type() delegates here, so the
454 * matrix cannot advertise a switch the generator ignores: it bails on a
455 * non-viewable or `exclude_from_search` post type BEFORE any inclusion flag
456 * is consulted, which made the toggle inert in the ON direction for
457 * `attachment` (#669 review).
458 *
459 * @param string $post_type Post type slug.
460 * @return bool
461 */
462 public static function sitemap_accepts_post_type(string $post_type): bool {
463 if (function_exists('is_post_type_viewable') && !is_post_type_viewable($post_type)) {
464 return false;
465 }
466
467 $object = get_post_type_object($post_type);
468
469 return is_object($object) && empty($object->exclude_from_search);
470 }
471
472 /**
473 * Whether the sitemap generator will emit this taxonomy at all.
474 *
475 * `category` and `post_tag` reach it through their legacy
476 * include_categories / include_tags flags; every other taxonomy reaches it
477 * through a walk over `_builtin => false` taxonomies only, so `post_format`
478 * never arrives however its flag is set.
479 *
480 * @param string $taxonomy Taxonomy slug.
481 * @return bool
482 */
483 public static function sitemap_accepts_taxonomy(string $taxonomy): bool {
484 $object = function_exists('get_taxonomy') ? get_taxonomy($taxonomy) : null;
485
486 if (!is_object($object) || empty($object->public)) {
487 return false;
488 }
489
490 return in_array($taxonomy, ['category', 'post_tag'], true) || empty($object->_builtin);
491 }
492
493 /**
494 * Whether a post type or taxonomy belongs in the sitemap.
495 *
496 * The four legacy flags are authoritative for the objects they name. Every
497 * other object honours an explicit `exclude_<slug>` first (the flag the
498 * presets UI writes), then `include_<slug>`, and otherwise defaults to
499 * included — which is what the generator did before it read these flags.
500 *
501 * @param string $group 'post_type' or 'taxonomy'.
502 * @param string $object_slug Post type or taxonomy slug.
503 * @param array $settings Sitemap settings.
504 * @return bool
505 */
506 public static function is_included_in_sitemap(string $group, string $object_slug, array $settings): bool {
507 $legacy = self::SITEMAP_LEGACY_FLAGS[$group . ':' . $object_slug] ?? null;
508
509 if ($legacy !== null) {
510 return !empty($settings[$legacy]);
511 }
512
513 if (isset($settings['exclude_' . $object_slug])) {
514 return empty($settings['exclude_' . $object_slug]);
515 }
516
517 if (isset($settings['include_' . $object_slug])) {
518 return !empty($settings['include_' . $object_slug]);
519 }
520
521 return true;
522 }
523
524 /**
525 * Sanitize the tri-state feature values in a settings patch.
526 *
527 * @param array $settings Raw values.
528 * @return array Only recognized feature keys, each a valid state.
529 */
530 public static function sanitize_feature_states(array $settings): array {
531 $out = [];
532
533 foreach (self::FEATURES as $feature) {
534 if (!isset($settings[$feature])) {
535 continue;
536 }
537
538 $value = is_string($settings[$feature]) ? $settings[$feature] : '';
539 $out[$feature] = in_array($value, self::STATES, true) ? $value : 'inherit';
540 }
541
542 return $out;
543 }
544
545 /**
546 * Persist a patch of entity settings, merged over what is stored.
547 *
548 * @param string $entity_key Entity key.
549 * @param array $patch Already-sanitized values.
550 * @return bool True when the option holds the requested values afterwards.
551 */
552 public static function update_entity_settings(string $entity_key, array $patch): bool {
553 $all = self::all_settings();
554
555 $existing = is_array($all[$entity_key] ?? null) ? $all[$entity_key] : [];
556 $merged = array_merge($existing, $patch);
557
558 if ($merged === $existing) {
559 return true;
560 }
561
562 $all[$entity_key] = $merged;
563
564 $updated = update_option(self::OPTION_NAME, $all);
565 self::flush_cache();
566
567 return (bool) $updated;
568 }
569 }
570