PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.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 / abilities / settings / class-settings-key-map.php

class-settings-key-map.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.3.0, at includes/abilities/settings/class-settings-key-map.php

364 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared setting-key declarations for the settings abilities.
4 *
5 * @package ThinkRank\Abilities\Settings
6 * @since 2.1.1
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\Abilities\Settings;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * The keys each settings category exposes over the abilities layer, and their
19 * JSON types.
20 *
21 * Declared once and shared by the get/update pair for each category, because
22 * two hand-maintained allowlists per category is how the gap opened in the
23 * first place: site identity exposed 13 of its 54 keys and sitemap 8 of its 20,
24 * so an MCP agent could neither read nor set any title template, the whole
25 * business/Local SEO block, or the sitemap index toggle — and, since the read
26 * ability omitted them silently, had no way to tell its picture was incomplete
27 * (#518).
28 *
29 * AbilitySettingsCoverageTest walks each manager's get_known_setting_keys()
30 * against these maps and fails when a category gains a key that is neither
31 * exposed here nor listed as deliberately excluded, so the drift cannot recur
32 * unnoticed.
33 *
34 * @since 2.1.1
35 */
36 final class Settings_Key_Map {
37
38 /**
39 * `site_identity` keys that belong to another ability.
40 *
41 * Not gaps: each is already reachable, and exposing it twice would give an
42 * agent two ways to write the same row.
43 *
44 * @var array<string,string> Key => the ability that owns it.
45 */
46 public const SITE_IDENTITY_ELSEWHERE = [
47 'robots_txt_content' => 'thinkrank/update-robots-txt',
48 'custom_robots_rules' => 'thinkrank/update-robots-txt',
49 'knowledge_graph' => 'thinkrank/update-schema-settings',
50 'organization_schema' => 'thinkrank/update-schema-settings',
51 'post_title' => 'thinkrank/update-global-settings',
52 'page_title' => 'thinkrank/update-global-settings',
53 ];
54
55 /**
56 * `sitemap` keys that are derived state rather than settings.
57 *
58 * @var array<string,string> Key => why it is not exposed.
59 */
60 public const SITEMAP_ELSEWHERE = [
61 'last_generated' => 'written by the generator, not a setting',
62 'sitemap_urls' => 'written by the generator, not a setting',
63 'selected_preset' => 'UI marker the settings screen maintains for itself',
64 ];
65
66 /**
67 * JSON schema properties for every exposed `site_identity` key.
68 *
69 * @return array<string, array<string, mixed>>
70 */
71 public static function site_identity(): array {
72 return [
73 // Core.
74 'enabled' => self::boolean( __( 'Whether site identity management is active.', 'thinkrank' ) ),
75 'site_name' => self::string( __( 'Official name of the site, used in titles and schema.', 'thinkrank' ) ),
76 'site_description' => self::string( __( 'Short description of the site.', 'thinkrank' ) ),
77 'tagline' => self::string( __( 'Site tagline.', 'thinkrank' ) ),
78 'alternate_name' => self::string( __( 'Alternate or former name of the site, published as schema alternateName.', 'thinkrank' ) ),
79 'identity_type' => self::string( __( 'What the site is, e.g. "blog", "business", "portfolio".', 'thinkrank' ) ),
80 'represents' => self::string( __( 'Whether the site represents a "person" or an "organization".', 'thinkrank' ) ),
81 'default_meta_description' => self::string( __( 'Meta description used where no more specific one is set.', 'thinkrank' ) ),
82 'default_social_image' => self::string( __( 'URL of the fallback social sharing image.', 'thinkrank' ) ),
83 'social_media_accounts' => [
84 'type' => 'array',
85 'description' => __( 'Profile URLs for the site\'s social accounts.', 'thinkrank' ),
86 'items' => [ 'type' => 'string' ],
87 ],
88
89 // Titles. These are %token% templates; the manager sanitizes them
90 // without stripping their tokens.
91 'title_template' => self::string( __( 'Named title layout, e.g. "default", "reverse", "category".', 'thinkrank' ) ),
92 'title_separator' => self::string( __( 'Separator between title parts, e.g. "pipe", "dash".', 'thinkrank' ) ),
93 'homepage_title' => self::template( __( 'the homepage', 'thinkrank' ), '' ),
94 'category_title' => self::template( __( 'category archives', 'thinkrank' ), '%category_title%, %category%' ),
95 'tag_title' => self::template( __( 'tag archives', 'thinkrank' ), '%tag_title%' ),
96 'author_title' => self::template( __( 'author archives', 'thinkrank' ), '%author_name%' ),
97 'search_title' => self::template( __( 'search results pages', 'thinkrank' ), '%search_term%' ),
98 'archive_title' => self::template( __( 'date and other archives', 'thinkrank' ), '%archive_title%' ),
99
100 // Breadcrumbs.
101 'breadcrumbs_enabled' => self::boolean( __( 'Whether breadcrumb markup is generated.', 'thinkrank' ) ),
102 'breadcrumb_type' => self::string( __( 'Breadcrumb structure, e.g. "hierarchical".', 'thinkrank' ) ),
103 'breadcrumb_home_text' => self::string( __( 'Label for the home link in breadcrumbs.', 'thinkrank' ) ),
104 'breadcrumb_separator' => self::string( __( 'Separator drawn between breadcrumb items.', 'thinkrank' ) ),
105 'breadcrumb_prefix' => self::string( __( 'Text shown before the breadcrumb trail.', 'thinkrank' ) ),
106 'show_current_page' => self::boolean( __( 'Whether the current page appears in its own breadcrumb trail.', 'thinkrank' ) ),
107
108 // Brand imagery.
109 'logo_url' => self::string( __( 'URL of the site logo.', 'thinkrank' ) ),
110 'favicon_url' => self::string( __( 'URL of the site favicon.', 'thinkrank' ) ),
111 'apple_touch_icon_url' => self::string( __( 'URL of the Apple touch icon.', 'thinkrank' ) ),
112
113 // Homepage hero.
114 'hero_title' => self::string( __( 'Homepage hero heading.', 'thinkrank' ) ),
115 'hero_subtitle' => self::string( __( 'Homepage hero subheading.', 'thinkrank' ) ),
116 'hero_cta_text' => self::string( __( 'Label on the homepage hero call to action.', 'thinkrank' ) ),
117 'hero_cta_url' => self::string( __( 'URL the homepage hero call to action points at.', 'thinkrank' ) ),
118 'hero_background_image' => self::string( __( 'URL of the homepage hero background image.', 'thinkrank' ) ),
119
120 // Business / Local SEO. Feeds LocalBusiness schema.
121 'local_seo_enabled' => self::boolean( __( 'Whether Local SEO output and LocalBusiness schema are enabled.', 'thinkrank' ) ),
122 'business_name' => self::string( __( 'Registered business name.', 'thinkrank' ) ),
123 'business_type' => self::string( __( 'schema.org business type, e.g. "Restaurant", "Store".', 'thinkrank' ) ),
124 'business_email' => self::string( __( 'Public contact email address.', 'thinkrank' ) ),
125 'business_phone' => self::string( __( 'Public contact telephone number.', 'thinkrank' ) ),
126 'business_address' => self::string( __( 'Street address.', 'thinkrank' ) ),
127 'business_city' => self::string( __( 'City or locality.', 'thinkrank' ) ),
128 'business_state' => self::string( __( 'State, province or region.', 'thinkrank' ) ),
129 'business_country' => self::string( __( 'Country.', 'thinkrank' ) ),
130 'business_postal_code' => self::string( __( 'Postal or ZIP code.', 'thinkrank' ) ),
131 'business_latitude' => self::string( __( 'Latitude of the business location, in decimal degrees.', 'thinkrank' ) ),
132 'business_longitude' => self::string( __( 'Longitude of the business location, in decimal degrees.', 'thinkrank' ) ),
133 'business_price_range' => self::string( __( 'Price range indicator, e.g. "$$".', 'thinkrank' ) ),
134 'business_hours' => self::business_hours(),
135
136 // Indexing.
137 'allow_search_engines' => self::boolean( __( 'Whether search engines are allowed to index the site.', 'thinkrank' ) ),
138 'robots_txt_enabled' => self::boolean( __( 'Whether ThinkRank manages robots.txt. Its contents are set with thinkrank/update-robots-txt.', 'thinkrank' ) ),
139 ];
140 }
141
142 /**
143 * JSON schema properties for every exposed `sitemap` key.
144 *
145 * @return array<string, array<string, mixed>>
146 */
147 public static function sitemap(): array {
148 return [
149 'enabled' => self::boolean( __( 'Whether XML sitemap generation is active.', 'thinkrank' ) ),
150 'auto_generate' => self::boolean( __( 'Whether the sitemap regenerates automatically when content changes.', 'thinkrank' ) ),
151 'use_sitemap_index' => self::boolean( __( 'Whether to publish a sitemap index that links per-type sitemaps, rather than one flat file.', 'thinkrank' ) ),
152 'links_per_sitemap' => [
153 'type' => 'integer',
154 'description' => __( 'Maximum URLs per sitemap file before it is split.', 'thinkrank' ),
155 'minimum' => 1,
156 'maximum' => 50000,
157 ],
158 'custom_url_pattern' => self::string( __( 'Filename pattern for generated sitemaps, e.g. "sitemap-{type}.xml".', 'thinkrank' ) ),
159 'enable_styling' => self::boolean( __( 'Whether an XSL stylesheet is attached so the sitemap is readable in a browser.', 'thinkrank' ) ),
160 'ping_search_engines' => self::boolean( __( 'Whether search engines are notified after the sitemap is regenerated.', 'thinkrank' ) ),
161
162 // What goes in.
163 'include_posts' => self::boolean( __( 'Whether posts are included.', 'thinkrank' ) ),
164 'include_pages' => self::boolean( __( 'Whether pages are included.', 'thinkrank' ) ),
165 'include_categories' => self::boolean( __( 'Whether category archives are included.', 'thinkrank' ) ),
166 'include_tags' => self::boolean( __( 'Whether tag archives are included.', 'thinkrank' ) ),
167 'include_images' => self::boolean( __( 'Whether image entries are included for each URL.', 'thinkrank' ) ),
168 'include_featured_images' => self::boolean( __( 'Whether featured images are included as image entries.', 'thinkrank' ) ),
169
170 // What stays out.
171 'exclude_posts' => self::string( __( 'Comma-separated post IDs to leave out.', 'thinkrank' ) ),
172 'exclude_terms' => self::string( __( 'Comma-separated term IDs to leave out.', 'thinkrank' ) ),
173 'exclude_private_posts' => self::boolean( __( 'Whether privately published posts are left out.', 'thinkrank' ) ),
174 'exclude_password_protected' => self::boolean( __( 'Whether password-protected posts are left out.', 'thinkrank' ) ),
175 ];
176 }
177
178 /**
179 * Read the exposed keys out of a manager's stored settings.
180 *
181 * The store keeps everything as strings ("1"/""), so each value is coerced
182 * back to the type the schema advertises before it reaches an agent.
183 *
184 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
185 * @param array<string, mixed> $stored Settings as the manager returns them.
186 * @return array<string, mixed> Exposed settings, one entry per declared key.
187 */
188 public static function read( array $properties, array $stored ): array {
189 $out = [];
190
191 foreach ( $properties as $key => $property ) {
192 $value = $stored[ $key ] ?? null;
193
194 switch ( $property['type'] ) {
195 case 'boolean':
196 $out[ $key ] = (bool) $value;
197 break;
198 case 'integer':
199 $out[ $key ] = (int) $value;
200 break;
201 case 'array':
202 case 'object':
203 $out[ $key ] = is_array( $value ) ? $value : [];
204 break;
205 default:
206 $out[ $key ] = (string) ( $value ?? '' );
207 }
208 }
209
210 return $out;
211 }
212
213 /**
214 * Coerce an incoming patch to the declared types, dropping unknown keys.
215 *
216 * Strings are passed through rather than sanitized here: the manager's own
217 * save path already picks the right sanitizer per key, and running
218 * sanitize_text_field() first would strip %date% and %category% out of the
219 * title templates as percent-encoding before it ever got the chance (#521).
220 *
221 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
222 * @param array<string, mixed> $incoming Caller-supplied settings.
223 * @return array<string, mixed> Recognized keys only, coerced to type.
224 */
225 public static function coerce( array $properties, array $incoming ): array {
226 $out = [];
227
228 foreach ( $properties as $key => $property ) {
229 if ( ! array_key_exists( $key, $incoming ) ) {
230 continue;
231 }
232
233 $value = $incoming[ $key ];
234
235 switch ( $property['type'] ) {
236 case 'boolean':
237 $out[ $key ] = (bool) $value;
238 break;
239 case 'integer':
240 $number = (int) $value;
241 if ( isset( $property['minimum'] ) ) {
242 $number = max( (int) $property['minimum'], $number );
243 }
244 if ( isset( $property['maximum'] ) ) {
245 $number = min( (int) $property['maximum'], $number );
246 }
247 $out[ $key ] = $number;
248 break;
249 case 'array':
250 case 'object':
251 if ( ! is_array( $value ) ) {
252 // A structured key given a scalar is a caller error, not
253 // something to flatten into a string and store.
254 continue 2;
255 }
256 $out[ $key ] = $value;
257 break;
258 default:
259 if ( is_array( $value ) ) {
260 continue 2;
261 }
262 $out[ $key ] = (string) $value;
263 }
264 }
265
266 return $out;
267 }
268
269 /**
270 * The opening hours object, one entry per day.
271 *
272 * A real sub-schema rather than string coercion: the value is a map of day
273 * name to {open, close, closed}, and flattening it to a string would make
274 * it unwritable over MCP.
275 *
276 * @return array<string, mixed>
277 */
278 private static function business_hours(): array {
279 $day = [
280 'type' => 'object',
281 'additionalProperties' => false,
282 'properties' => [
283 'open' => [
284 'type' => 'string',
285 'description' => __( 'Opening time as 24-hour HH:MM, or "" when closed.', 'thinkrank' ),
286 ],
287 'close' => [
288 'type' => 'string',
289 'description' => __( 'Closing time as 24-hour HH:MM, or "" when closed.', 'thinkrank' ),
290 ],
291 'closed' => [
292 'type' => 'boolean',
293 'description' => __( 'Whether the business is closed all day.', 'thinkrank' ),
294 ],
295 ],
296 ];
297
298 $properties = [];
299 foreach ( [ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' ] as $name ) {
300 $properties[ $name ] = $day;
301 }
302
303 return [
304 'type' => 'object',
305 'description' => __( 'Opening hours per day of the week.', 'thinkrank' ),
306 'additionalProperties' => false,
307 'properties' => $properties,
308 ];
309 }
310
311 /**
312 * A boolean property.
313 *
314 * @param string $description Translated text describing what the toggle does.
315 * @return array<string, mixed>
316 */
317 private static function boolean( string $description ): array {
318 return [
319 'type' => 'boolean',
320 'description' => $description,
321 ];
322 }
323
324 /**
325 * A string property.
326 *
327 * @param string $description Translated text describing the value.
328 * @return array<string, mixed>
329 */
330 private static function string( string $description ): array {
331 return [
332 'type' => 'string',
333 'description' => $description,
334 ];
335 }
336
337 /**
338 * A %token% title template property.
339 *
340 * The vocabulary here is Site Identity's, which is not the one the Global
341 * SEO per-post-type templates use — %site_title% rather than %title%,
342 * %site_name% rather than %sitename% — so the description spells it out
343 * instead of leaving an agent to guess between the two.
344 *
345 * @param string $what Translated name of the pages the template titles.
346 * @param string $extra Comma-separated tags available only on those pages, or ''.
347 * @return array<string, mixed>
348 */
349 private static function template( string $what, string $extra ): array {
350 $shared = '%site_title%, %site_name%, %site_description%, %tagline%, %sep%, %separator%, %date%';
351
352 $description = '' === $extra
353 /* translators: 1: the pages a title template applies to. 2: the list of available variable tags. */
354 ? sprintf( __( 'Title template for %1$s. Available tags: %2$s.', 'thinkrank' ), $what, $shared )
355 /* translators: 1: the pages a title template applies to. 2: tags available everywhere. 3: tags available only on those pages. */
356 : sprintf( __( 'Title template for %1$s. Available tags: %2$s, and on these pages also %3$s.', 'thinkrank' ), $what, $shared, $extra );
357
358 return [
359 'type' => 'string',
360 'description' => $description,
361 ];
362 }
363 }
364