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 / 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.7.0, at includes/api/class-content-type-matrix-endpoint.php

357 lines 12.4 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 $entity['sitemap_include'] = $entity['supports_sitemap']
135 ? Content_Type_Settings::is_included_in_sitemap($entity['group'], $entity['object'], $sitemap_settings)
136 : null;
137
138 $entities[] = $entity;
139 }
140
141 return new WP_REST_Response([
142 'success' => true,
143 'data' => [
144 'entities' => $entities,
145 'globals' => $this->get_global_defaults(),
146 ],
147 ], 200);
148 }
149
150 /**
151 * Save one entity's row.
152 *
153 * @param WP_REST_Request $request Request.
154 * @return WP_REST_Response|WP_Error
155 */
156 public function save_entity(WP_REST_Request $request) {
157 $entity_key = (string) $request->get_param('entity');
158 $settings = $request->get_param('settings');
159
160 if (!Content_Type_Settings::is_valid_entity($entity_key)) {
161 return new WP_Error(
162 'invalid_entity',
163 sprintf('Entity "%s" is not configurable on this site', $entity_key),
164 ['status' => 400]
165 );
166 }
167
168 if (!is_array($settings)) {
169 return new WP_Error('invalid_settings', 'Settings must be provided as an object', ['status' => 400]);
170 }
171
172 $descriptor = $this->find_entity($entity_key);
173 $patch = Content_Type_Settings::sanitize_feature_states($settings);
174
175 // Drop any state sent for a feature this entity does not expose (e.g.
176 // schema on search results) rather than storing a value nothing reads.
177 $patch = array_intersect_key($patch, array_flip($descriptor['features']));
178
179 if (isset($settings['robots_meta_enabled'])) {
180 $patch['robots_meta_enabled'] = (bool) $settings['robots_meta_enabled'];
181 }
182
183 if (isset($settings['robots_meta']) && is_array($settings['robots_meta'])) {
184 $robots = [];
185 foreach (['index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'] as $key) {
186 if (isset($settings['robots_meta'][$key])) {
187 $robots[$key] = (bool) $settings['robots_meta'][$key];
188 }
189 }
190 $patch['robots_meta'] = array_merge(
191 Content_Type_Settings::default_robots_meta($entity_key),
192 $robots
193 );
194 }
195
196 if (!empty($patch)) {
197 Content_Type_Settings::update_entity_settings($entity_key, $patch);
198 }
199
200 $sitemap_saved = true;
201 if ($descriptor['supports_sitemap'] && isset($settings['sitemap_include'])) {
202 $sitemap_saved = $this->save_sitemap_inclusion(
203 $descriptor,
204 (bool) rest_sanitize_boolean($settings['sitemap_include'])
205 );
206 }
207
208 if (!$sitemap_saved) {
209 return new WP_Error('sitemap_save_failed', 'Failed to save sitemap inclusion', ['status' => 500]);
210 }
211
212 $sitemap_settings = $this->get_sitemap_settings();
213
214 return new WP_REST_Response([
215 'success' => true,
216 'entity' => $entity_key,
217 'data' => [
218 'values' => array_intersect_key(
219 Content_Type_Settings::get_entity_settings($entity_key),
220 array_flip(Content_Type_Settings::FEATURES)
221 ),
222 'robots_meta_enabled' => !empty(Content_Type_Settings::get_entity_settings($entity_key)['robots_meta_enabled']),
223 'robots_meta' => Content_Type_Settings::resolve_robots_meta($entity_key),
224 'sitemap_include' => $descriptor['supports_sitemap']
225 ? Content_Type_Settings::is_included_in_sitemap($descriptor['group'], $descriptor['object'], $sitemap_settings)
226 : null,
227 ],
228 ], 200);
229 }
230
231 /**
232 * Write one object's sitemap inclusion through the legacy flags.
233 *
234 * Both flags are written for non-legacy objects: `include_<slug>` is what
235 * the presets UI reads, and `exclude_<slug>` is what a preset writes to opt
236 * a type out — leaving a stale `exclude_` behind would silently outrank the
237 * value just saved.
238 *
239 * @param array $descriptor Entity descriptor.
240 * @param bool $included Whether the object belongs in the sitemap.
241 * @return bool
242 */
243 private function save_sitemap_inclusion(array $descriptor, bool $included): bool {
244 $flag = Content_Type_Settings::sitemap_flag_key($descriptor['group'], $descriptor['object']);
245 $payload = [$flag => $included];
246
247 if ($flag === 'include_' . $descriptor['object']) {
248 $payload['exclude_' . $descriptor['object']] = !$included;
249 }
250
251 return $this->sitemap()->save_settings('site', null, $payload);
252 }
253
254 /**
255 * Look up an entity descriptor by key.
256 *
257 * @param string $entity_key Entity key.
258 * @return array
259 */
260 private function find_entity(string $entity_key): array {
261 foreach (Content_Type_Settings::get_entities() as $entity) {
262 if ($entity['key'] === $entity_key) {
263 return $entity;
264 }
265 }
266
267 return [
268 'key' => $entity_key,
269 'group' => '',
270 'object' => '',
271 'features' => Content_Type_Settings::FEATURES,
272 'supports_sitemap' => false,
273 ];
274 }
275
276 /**
277 * Current site-wide value each 'inherit' cell resolves to.
278 *
279 * @return array<string, bool>
280 */
281 private function get_global_defaults(): array {
282 $social = [];
283 if (class_exists('\ThinkRank\SEO\Social_Meta_Manager')) {
284 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
285 $social = $manager->get_settings('site');
286 }
287
288 $defaults = [
289 Content_Type_Settings::FEATURE_META => true,
290 Content_Type_Settings::FEATURE_SCHEMA => true,
291 Content_Type_Settings::FEATURE_OPEN_GRAPH => !empty($social['enable_open_graph'] ?? $social['og_enabled'] ?? false),
292 Content_Type_Settings::FEATURE_TWITTER => !empty($social['enable_twitter_cards'] ?? $social['twitter_enabled'] ?? false),
293 // The GA4 tag this switch gates is installed by ThinkRank Pro,
294 // which answers the filter below with its own site-wide state.
295 Content_Type_Settings::FEATURE_ANALYTICS => false,
296 ];
297
298 /**
299 * Filters the site-wide value each 'inherit' matrix cell resolves to.
300 *
301 * @since 2.6.0
302 *
303 * @param array<string, bool> $defaults Feature key => site-wide value.
304 */
305 $filtered = apply_filters('thinkrank_content_type_matrix_global_defaults', $defaults);
306
307 return is_array($filtered) ? array_map('boolval', $filtered + $defaults) : $defaults;
308 }
309
310 /**
311 * Sitemap settings for the site context.
312 *
313 * @return array
314 */
315 private function get_sitemap_settings(): array {
316 return $this->sitemap()->get_settings('site');
317 }
318
319 /**
320 * Sitemap generator instance (no auto-generation hooks: this is a request
321 * that reads and writes settings, not one that rebuilds anything).
322 *
323 * @return Sitemap_Generator
324 */
325 private function sitemap(): Sitemap_Generator {
326 if ($this->sitemap === null) {
327 $this->sitemap = new Sitemap_Generator(false);
328 }
329
330 return $this->sitemap;
331 }
332
333 /**
334 * Read permission.
335 *
336 * The same capability the write route asks for, and the same one the
337 * plugin-wide REST guard enforces on this namespace. `edit_posts` read as
338 * an access level the endpoint never actually grants: an editor holding it
339 * was still refused by the guard, which invited a later "fix" in the wrong
340 * direction (#669 review).
341 *
342 * @return bool
343 */
344 public function check_read_permissions(): bool {
345 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
346 }
347
348 /**
349 * Write permission.
350 *
351 * @return bool
352 */
353 public function check_manage_permissions(): bool {
354 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
355 }
356 }
357