PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.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 All 55 releases
thinkrank / includes / api / class-content-type-matrix-endpoint.php

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

364 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content Type Matrix REST endpoints.
4 *
5 * Reads and writes the per-content-type feature matrix (#660): every entity —
6 * post types, taxonomies, author/date archives, search and 404 — against the
7 * features that can be switched for it.
8 *
9 * Two storage layers sit behind one screen. The tri-state feature flags and the
10 * per-entity robots directives live in `thinkrank_global_seo_settings` next to
11 * the existing per-post-type templates. Sitemap inclusion does NOT: it keeps
12 * writing the sitemap settings' own `include_*` / `exclude_*` flags, so the
13 * presets UI and the generator's regex matching keep working against a single
14 * storage format.
15 *
16 * @package ThinkRank
17 * @subpackage API
18 * @since 2.5.0
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\API;
24
25 use ThinkRank\SEO\Content_Type_Settings;
26 use ThinkRank\SEO\Sitemap_Generator;
27 use WP_Error;
28 use WP_REST_Controller;
29 use WP_REST_Request;
30 use WP_REST_Response;
31
32 // Prevent direct access.
33 if (!defined('ABSPATH')) {
34 exit;
35 }
36
37 /**
38 * Content Type Matrix endpoints.
39 *
40 * @since 2.5.0
41 */
42 class Content_Type_Matrix_Endpoint extends WP_REST_Controller {
43
44 /**
45 * API namespace.
46 *
47 * @var string
48 */
49 protected $namespace = 'thinkrank/v1';
50
51 /**
52 * API resource base.
53 *
54 * @var string
55 */
56 protected $rest_base = 'global-seo/matrix';
57
58 /**
59 * Sitemap generator, lazily created (hooks are not needed here).
60 *
61 * @var Sitemap_Generator|null
62 */
63 private ?Sitemap_Generator $sitemap = null;
64
65 /**
66 * Register routes.
67 *
68 * @return void
69 */
70 public function register_routes(): void {
71 register_rest_route(
72 $this->namespace,
73 '/' . $this->rest_base,
74 [
75 [
76 'methods' => 'GET',
77 'callback' => [$this, 'get_matrix'],
78 'permission_callback' => [$this, 'check_read_permissions'],
79 ],
80 [
81 'methods' => 'POST',
82 'callback' => [$this, 'save_entity'],
83 'permission_callback' => [$this, 'check_manage_permissions'],
84 'args' => [
85 'entity' => [
86 'required' => true,
87 'type' => 'string',
88 'description' => 'Entity key (post type slug, taxonomy:<slug>, archive:<kind>, special:<kind>)',
89 'sanitize_callback' => [$this, 'sanitize_entity_key'],
90 ],
91 'settings' => [
92 'required' => true,
93 'type' => 'object',
94 'description' => 'Feature states, robots directives, and sitemap inclusion for this entity',
95 ],
96 ],
97 ],
98 ]
99 );
100 }
101
102 /**
103 * Entity keys carry a `:` separator, which sanitize_key() strips — keep it.
104 *
105 * @param mixed $value Raw parameter.
106 * @return string
107 */
108 public function sanitize_entity_key($value): string {
109 return (string) preg_replace('/[^a-z0-9_:\-]/', '', strtolower((string) $value));
110 }
111
112 /**
113 * The whole matrix: entities, their stored values, and the global defaults
114 * an 'inherit' cell resolves to.
115 *
116 * @param WP_REST_Request $request Request.
117 * @return WP_REST_Response
118 */
119 public function get_matrix(WP_REST_Request $request): WP_REST_Response {
120 $sitemap_settings = $this->get_sitemap_settings();
121 $entities = [];
122
123 foreach (Content_Type_Settings::get_entities() as $entity) {
124 $stored = Content_Type_Settings::get_entity_settings($entity['key']);
125
126 $values = [];
127 foreach ($entity['features'] as $feature) {
128 $values[$feature] = Content_Type_Settings::feature_state($entity['key'], $feature);
129 }
130
131 $entity['values'] = $values;
132 $entity['robots_meta_enabled'] = !empty($stored['robots_meta_enabled']);
133 $entity['robots_meta'] = Content_Type_Settings::resolve_robots_meta($entity['key']);
134 // What the row falls back to with the pin off, so the matrix can
135 // label its Inherit option per row rather than guessing. It is not
136 // the same for every row: search and 404 default to noindex (#838).
137 $entity['default_noindex'] = !empty(
138 Content_Type_Settings::default_robots_meta($entity['key'])['noindex']
139 );
140 $entity['sitemap_include'] = $entity['supports_sitemap']
141 ? Content_Type_Settings::is_included_in_sitemap($entity['group'], $entity['object'], $sitemap_settings)
142 : null;
143
144 $entities[] = $entity;
145 }
146
147 return new WP_REST_Response([
148 'success' => true,
149 'data' => [
150 'entities' => $entities,
151 'globals' => $this->get_global_defaults(),
152 ],
153 ], 200);
154 }
155
156 /**
157 * Save one entity's row.
158 *
159 * @param WP_REST_Request $request Request.
160 * @return WP_REST_Response|WP_Error
161 */
162 public function save_entity(WP_REST_Request $request) {
163 $entity_key = (string) $request->get_param('entity');
164 $settings = $request->get_param('settings');
165
166 if (!Content_Type_Settings::is_valid_entity($entity_key)) {
167 return new WP_Error(
168 'invalid_entity',
169 sprintf('Entity "%s" is not configurable on this site', $entity_key),
170 ['status' => 400]
171 );
172 }
173
174 if (!is_array($settings)) {
175 return new WP_Error('invalid_settings', 'Settings must be provided as an object', ['status' => 400]);
176 }
177
178 $descriptor = $this->find_entity($entity_key);
179 $patch = Content_Type_Settings::sanitize_feature_states($settings);
180
181 // Drop any state sent for a feature this entity does not expose (e.g.
182 // schema on search results) rather than storing a value nothing reads.
183 $patch = array_intersect_key($patch, array_flip($descriptor['features']));
184
185 if (isset($settings['robots_meta_enabled'])) {
186 $patch['robots_meta_enabled'] = (bool) $settings['robots_meta_enabled'];
187 }
188
189 if (isset($settings['robots_meta']) && is_array($settings['robots_meta'])) {
190 $robots = [];
191 foreach (['index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'] as $key) {
192 if (isset($settings['robots_meta'][$key])) {
193 $robots[$key] = (bool) $settings['robots_meta'][$key];
194 }
195 }
196 $patch['robots_meta'] = array_merge(
197 Content_Type_Settings::default_robots_meta($entity_key),
198 $robots
199 );
200 }
201
202 if (!empty($patch)) {
203 Content_Type_Settings::update_entity_settings($entity_key, $patch);
204 }
205
206 $sitemap_saved = true;
207 if ($descriptor['supports_sitemap'] && isset($settings['sitemap_include'])) {
208 $sitemap_saved = $this->save_sitemap_inclusion(
209 $descriptor,
210 (bool) rest_sanitize_boolean($settings['sitemap_include'])
211 );
212 }
213
214 if (!$sitemap_saved) {
215 return new WP_Error('sitemap_save_failed', 'Failed to save sitemap inclusion', ['status' => 500]);
216 }
217
218 $sitemap_settings = $this->get_sitemap_settings();
219
220 return new WP_REST_Response([
221 'success' => true,
222 'entity' => $entity_key,
223 'data' => [
224 'values' => array_intersect_key(
225 Content_Type_Settings::get_entity_settings($entity_key),
226 array_flip(Content_Type_Settings::FEATURES)
227 ),
228 'robots_meta_enabled' => !empty(Content_Type_Settings::get_entity_settings($entity_key)['robots_meta_enabled']),
229 'robots_meta' => Content_Type_Settings::resolve_robots_meta($entity_key),
230 'default_noindex' => !empty(Content_Type_Settings::default_robots_meta($entity_key)['noindex']),
231 'sitemap_include' => $descriptor['supports_sitemap']
232 ? Content_Type_Settings::is_included_in_sitemap($descriptor['group'], $descriptor['object'], $sitemap_settings)
233 : null,
234 ],
235 ], 200);
236 }
237
238 /**
239 * Write one object's sitemap inclusion through the legacy flags.
240 *
241 * Both flags are written for non-legacy objects: `include_<slug>` is what
242 * the presets UI reads, and `exclude_<slug>` is what a preset writes to opt
243 * a type out — leaving a stale `exclude_` behind would silently outrank the
244 * value just saved.
245 *
246 * @param array $descriptor Entity descriptor.
247 * @param bool $included Whether the object belongs in the sitemap.
248 * @return bool
249 */
250 private function save_sitemap_inclusion(array $descriptor, bool $included): bool {
251 $flag = Content_Type_Settings::sitemap_flag_key($descriptor['group'], $descriptor['object']);
252 $payload = [$flag => $included];
253
254 if ($flag === 'include_' . $descriptor['object']) {
255 $payload['exclude_' . $descriptor['object']] = !$included;
256 }
257
258 return $this->sitemap()->save_settings('site', null, $payload);
259 }
260
261 /**
262 * Look up an entity descriptor by key.
263 *
264 * @param string $entity_key Entity key.
265 * @return array
266 */
267 private function find_entity(string $entity_key): array {
268 foreach (Content_Type_Settings::get_entities() as $entity) {
269 if ($entity['key'] === $entity_key) {
270 return $entity;
271 }
272 }
273
274 return [
275 'key' => $entity_key,
276 'group' => '',
277 'object' => '',
278 'features' => Content_Type_Settings::FEATURES,
279 'supports_sitemap' => false,
280 ];
281 }
282
283 /**
284 * Current site-wide value each 'inherit' cell resolves to.
285 *
286 * @return array<string, bool>
287 */
288 private function get_global_defaults(): array {
289 $social = [];
290 if (class_exists('\ThinkRank\SEO\Social_Meta_Manager')) {
291 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
292 $social = $manager->get_settings('site');
293 }
294
295 $defaults = [
296 Content_Type_Settings::FEATURE_META => true,
297 Content_Type_Settings::FEATURE_SCHEMA => true,
298 Content_Type_Settings::FEATURE_OPEN_GRAPH => !empty($social['enable_open_graph'] ?? $social['og_enabled'] ?? false),
299 Content_Type_Settings::FEATURE_TWITTER => !empty($social['enable_twitter_cards'] ?? $social['twitter_enabled'] ?? false),
300 // The GA4 tag this switch gates is installed by ThinkRank Pro,
301 // which answers the filter below with its own site-wide state.
302 Content_Type_Settings::FEATURE_ANALYTICS => false,
303 ];
304
305 /**
306 * Filters the site-wide value each 'inherit' matrix cell resolves to.
307 *
308 * @since 2.6.0
309 *
310 * @param array<string, bool> $defaults Feature key => site-wide value.
311 */
312 $filtered = apply_filters('thinkrank_content_type_matrix_global_defaults', $defaults);
313
314 return is_array($filtered) ? array_map('boolval', $filtered + $defaults) : $defaults;
315 }
316
317 /**
318 * Sitemap settings for the site context.
319 *
320 * @return array
321 */
322 private function get_sitemap_settings(): array {
323 return $this->sitemap()->get_settings('site');
324 }
325
326 /**
327 * Sitemap generator instance (no auto-generation hooks: this is a request
328 * that reads and writes settings, not one that rebuilds anything).
329 *
330 * @return Sitemap_Generator
331 */
332 private function sitemap(): Sitemap_Generator {
333 if ($this->sitemap === null) {
334 $this->sitemap = new Sitemap_Generator(false);
335 }
336
337 return $this->sitemap;
338 }
339
340 /**
341 * Read permission.
342 *
343 * The same capability the write route asks for, and the same one the
344 * plugin-wide REST guard enforces on this namespace. `edit_posts` read as
345 * an access level the endpoint never actually grants: an editor holding it
346 * was still refused by the guard, which invited a later "fix" in the wrong
347 * direction (#669 review).
348 *
349 * @return bool
350 */
351 public function check_read_permissions(): bool {
352 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
353 }
354
355 /**
356 * Write permission.
357 *
358 * @return bool
359 */
360 public function check_manage_permissions(): bool {
361 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
362 }
363 }
364