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 / 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.13.0, at includes/abilities/settings/class-settings-key-map.php

541 lines 25.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::alternate_name(),
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 'homepage_description' => self::string(
95 sprintf(
96 /* translators: %s: the list of available variable tags. */
97 __( 'Meta description for the homepage when it lists the latest posts. Available tags: %s. A static front page uses its own page description instead.', 'thinkrank' ),
98 '%site_title%, %site_name%, %site_description%, %tagline%, %sep%'
99 )
100 ),
101 'category_title' => self::template( __( 'category archives', 'thinkrank' ), '%category_title%, %category%' ),
102 'tag_title' => self::template( __( 'tag archives', 'thinkrank' ), '%tag_title%' ),
103 'author_title' => self::template( __( 'author archives', 'thinkrank' ), '%author_name%' ),
104 'search_title' => self::template( __( 'search results pages', 'thinkrank' ), '%search_term%' ),
105 'archive_title' => self::template( __( 'date and other archives', 'thinkrank' ), '%archive_title%' ),
106
107 // Breadcrumbs.
108 'breadcrumbs_enabled' => self::boolean( __( 'Whether breadcrumb markup is generated.', 'thinkrank' ) ),
109 'breadcrumb_type' => self::string( __( 'Breadcrumb structure, e.g. "hierarchical".', 'thinkrank' ) ),
110 'breadcrumb_home_text' => self::string( __( 'Label for the home link in breadcrumbs.', 'thinkrank' ) ),
111 'breadcrumb_separator' => self::string( __( 'Separator drawn between breadcrumb items.', 'thinkrank' ) ),
112 'breadcrumb_prefix' => self::string( __( 'Text shown before the breadcrumb trail.', 'thinkrank' ) ),
113 'show_current_page' => self::boolean( __( 'Whether the current page appears in its own breadcrumb trail.', 'thinkrank' ) ),
114 'breadcrumb_use_seo_title' => self::boolean( __( 'Whether breadcrumb labels use the SEO title of a post or term when one is set, instead of its raw title.', 'thinkrank' ) ),
115
116 // Brand imagery.
117 'logo_url' => self::string( __( 'URL of the site logo.', 'thinkrank' ) ),
118 'favicon_url' => self::string( __( 'URL of the site favicon.', 'thinkrank' ) ),
119 'apple_touch_icon_url' => self::string( __( 'URL of the Apple touch icon.', 'thinkrank' ) ),
120
121 // Homepage hero.
122 'hero_title' => self::string( __( 'Homepage hero heading.', 'thinkrank' ) ),
123 'hero_subtitle' => self::string( __( 'Homepage hero subheading.', 'thinkrank' ) ),
124 'hero_cta_text' => self::string( __( 'Label on the homepage hero call to action.', 'thinkrank' ) ),
125 'hero_cta_url' => self::string( __( 'URL the homepage hero call to action points at.', 'thinkrank' ) ),
126 'hero_background_image' => self::string( __( 'URL of the homepage hero background image.', 'thinkrank' ) ),
127
128 // Business / Local SEO. Feeds LocalBusiness schema.
129 'local_seo_enabled' => self::boolean( __( 'Whether Local SEO output and LocalBusiness schema are enabled.', 'thinkrank' ) ),
130 'business_name' => self::string( __( 'Registered business name.', 'thinkrank' ) ),
131 // Enumerated rather than free-form: the value goes straight into
132 // LocalBusiness schema, so an invented type is invalid structured
133 // data. ~150 schema.org subtypes are accepted (#623).
134 //
135 // '' is in the list because it is a real stored state ("not set"):
136 // the store holds it on every site that never opened Local SEO, and
137 // without it the READ ability failed its own output schema on those
138 // sites and there was no way to clear the value over MCP. It goes
139 // last so the root stays first, which read() falls back to.
140 'business_type' => [
141 'type' => 'string',
142 'enum' => array_merge( \ThinkRank\Config\Local_Business_Types_Config::get_types(), [ '' ] ),
143 'description' => __( 'schema.org LocalBusiness type, e.g. "Restaurant", "HealthAndBeautyBusiness". "LocalBusiness" is the general-purpose default and is always valid. An empty string means not set, and is published as LocalBusiness.', 'thinkrank' ),
144 ],
145 'business_email' => self::string( __( 'Public contact email address.', 'thinkrank' ) ),
146 'business_phone' => self::string( __( 'Public contact telephone number.', 'thinkrank' ) ),
147 'business_address' => self::string( __( 'Street address.', 'thinkrank' ) ),
148 'business_city' => self::string( __( 'City or locality.', 'thinkrank' ) ),
149 'business_state' => self::string( __( 'State, province or region.', 'thinkrank' ) ),
150 'business_country' => self::string( __( 'Country.', 'thinkrank' ) ),
151 'business_postal_code' => self::string( __( 'Postal or ZIP code.', 'thinkrank' ) ),
152 'business_latitude' => self::string( __( 'Latitude of the business location, in decimal degrees.', 'thinkrank' ) ),
153 'business_longitude' => self::string( __( 'Longitude of the business location, in decimal degrees.', 'thinkrank' ) ),
154 'business_price_range' => self::string( __( 'Price range indicator, e.g. "$$".', 'thinkrank' ) ),
155 'business_hours' => self::business_hours(),
156
157 // Indexing.
158 'allow_search_engines' => self::boolean( __( 'Whether search engines are allowed to index the site.', 'thinkrank' ) ),
159 'query_protection' => self::boolean( __( 'Whether a URL whose content selector resolved to nothing (for example ?post_type=nosuchtype) answers 404 instead of serving the blog listing at 200.', 'thinkrank' ) ),
160 'canonical_scheme' => [
161 'type' => 'string',
162 'description' => __( 'Scheme for canonical URLs, og:url, schema @ids and sitemap entries. "automatic" follows WordPress; the other two override it, for a site behind a proxy that terminates TLS and leaves WordPress reporting the wrong scheme.', 'thinkrank' ),
163 'enum' => \ThinkRank\SEO\Url_Scheme::MODES,
164 ],
165 'robots_txt_enabled' => self::boolean( __( 'Whether ThinkRank manages robots.txt. Its contents are set with thinkrank/update-robots-txt.', 'thinkrank' ) ),
166
167 // RSS feeds.
168 'feed_excerpt_only' => self::boolean( __( 'Whether feed entries are shortened to an excerpt instead of carrying the full post.', 'thinkrank' ) ),
169 'feed_source_link' => self::boolean( __( 'Whether each feed entry is signed with a link back to the original post and the site.', 'thinkrank' ) ),
170 'feed_noindex' => self::boolean( __( 'Whether feeds are served with X-Robots-Tag: noindex. Leave off for a podcast feed, which needs to be indexable.', 'thinkrank' ) ),
171 'ai_crawler_rules' => self::ai_crawler_rules(),
172 ];
173 }
174
175 /**
176 * JSON schema properties for every exposed `sitemap` key.
177 *
178 * @return array<string, array<string, mixed>>
179 */
180 public static function sitemap(): array {
181 return [
182 'enabled' => self::boolean( __( 'Whether XML sitemap generation is active.', 'thinkrank' ) ),
183 'delivery_mode' => [
184 'type' => 'string',
185 'enum' => \ThinkRank\SEO\Sitemap_Generator::DELIVERY_MODES,
186 'description' => __( 'How the sitemap reaches crawlers. "auto" writes files when the WordPress root is writable and serves the sitemap from WordPress when it is not. "static" always writes files, and fails where that is not possible. "dynamic" always serves from WordPress and writes nothing.', 'thinkrank' ),
187 ],
188 'auto_generate' => self::boolean( __( 'Whether the sitemap regenerates automatically when content changes.', 'thinkrank' ) ),
189 'use_sitemap_index' => self::boolean( __( 'Whether to publish a sitemap index that links per-type sitemaps, rather than one flat file.', 'thinkrank' ) ),
190 'links_per_sitemap' => [
191 'type' => 'integer',
192 'description' => __( 'Maximum URLs per sitemap file before it is split.', 'thinkrank' ),
193 'minimum' => 1,
194 'maximum' => 50000,
195 ],
196 'custom_url_pattern' => self::string( __( 'Filename pattern for generated sitemaps, e.g. "sitemap-{type}.xml".', 'thinkrank' ) ),
197 'enable_styling' => self::boolean( __( 'Whether an XSL stylesheet is attached so the sitemap is readable in a browser.', 'thinkrank' ) ),
198 'ping_search_engines' => self::boolean( __( 'Whether search engines are notified after the sitemap is regenerated.', 'thinkrank' ) ),
199
200 // How the styled sitemap looks. All four need enable_styling on.
201 'styling_logo' => self::boolean( __( 'Whether a logo is shown above the sitemap heading. Requires enable_styling.', 'thinkrank' ) ),
202 'styling_logo_url' => self::string( __( 'URL of the sitemap logo image. Empty falls back to the site icon.', 'thinkrank' ) ),
203 'styling_color_main' => self::string( __( 'Hex colour ("#rrggbb") for the sitemap header, links and table head. Empty keeps the stock palette.', 'thinkrank' ) ),
204 'styling_color_accent' => self::string( __( 'Hex colour ("#rrggbb") for the header gradient end and link hovers. Empty keeps the stock palette.', 'thinkrank' ) ),
205
206 // What goes in.
207 'include_posts' => self::boolean( __( 'Whether posts are included.', 'thinkrank' ) ),
208 'include_pages' => self::boolean( __( 'Whether pages are included.', 'thinkrank' ) ),
209 'include_categories' => self::boolean( __( 'Whether category archives are included.', 'thinkrank' ) ),
210 'include_tags' => self::boolean( __( 'Whether tag archives are included.', 'thinkrank' ) ),
211 'include_images' => self::boolean( __( 'Whether image entries are included for each URL.', 'thinkrank' ) ),
212 'include_featured_images' => self::boolean( __( 'Whether featured images are included as image entries.', 'thinkrank' ) ),
213
214 // What stays out.
215 'exclude_posts' => self::string( __( 'Comma-separated post IDs to leave out.', 'thinkrank' ) ),
216 'exclude_terms' => self::string( __( 'Comma-separated term IDs to leave out.', 'thinkrank' ) ),
217 'exclude_private_posts' => self::boolean( __( 'Whether privately published posts are left out.', 'thinkrank' ) ),
218 'exclude_password_protected' => self::boolean( __( 'Whether password-protected posts are left out.', 'thinkrank' ) ),
219 ];
220 }
221
222 /**
223 * Read the exposed keys out of a manager's stored settings.
224 *
225 * The store keeps everything as strings ("1"/""), so each value is coerced
226 * back to the type the schema advertises before it reaches an agent.
227 *
228 * The same properties are the read ability's OUTPUT schema, which the
229 * Abilities API validates, and one value that fails it fails the whole
230 * call. So what comes out has to fit the schema for any stored value, not
231 * only for what today's save path writes: a business type imported before
232 * the list was enumerated, or a crawler rule whose crawler a filter has
233 * since removed, used to turn every get-site-identity-settings call into
234 * ability_invalid_output. Hence the recursion into objects, and the enum
235 * fallback in read_value().
236 *
237 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
238 * @param array<string, mixed> $stored Settings as the manager returns them.
239 * @return array<string, mixed> Exposed settings, one entry per declared key.
240 */
241 public static function read( array $properties, array $stored ): array {
242 $out = [];
243
244 foreach ( $properties as $key => $property ) {
245 $out[ $key ] = self::read_value( $property, $stored[ $key ] ?? null );
246 }
247
248 return $out;
249 }
250
251 /**
252 * Coerce one stored value to the shape its property declares.
253 *
254 * An enumerated string that holds something outside its enum reads as the
255 * enum's FIRST entry. Every enum in this map is ordered so that entry is
256 * the value its manager falls back to for anything unrecognised
257 * (`automatic`, `auto`, `allow`, `LocalBusiness`), so the agent is told
258 * what the site actually does rather than a value it would be refused if
259 * it wrote it back.
260 *
261 * @since 2.10.0
262 *
263 * @param array<string, mixed> $property Schema property.
264 * @param mixed $value Stored value, or null when absent.
265 * @return mixed
266 */
267 private static function read_value( array $property, $value ) {
268 $type = $property['type'] ?? 'string';
269
270 // A union type ('string' or a list of them) keeps whichever shape
271 // it arrived in: casting to string would flatten a list, and
272 // casting to array would replace a name with [] (#692).
273 if ( is_array( $type ) ) {
274 return is_array( $value )
275 ? array_values( array_map( 'strval', array_filter( $value, 'is_scalar' ) ) )
276 : ( is_scalar( $value ) ? (string) $value : '' );
277 }
278
279 switch ( $type ) {
280 case 'boolean':
281 return (bool) $value;
282 case 'integer':
283 return (int) $value;
284 case 'array':
285 if ( ! is_array( $value ) ) {
286 return [];
287 }
288 if ( isset( $property['items'] ) && is_array( $property['items'] ) ) {
289 $items = $property['items'];
290 return array_values( array_map( static fn( $item ) => self::read_value( $items, $item ), $value ) );
291 }
292 return $value;
293 case 'object':
294 if ( ! is_array( $value ) ) {
295 return [];
296 }
297 if ( empty( $property['properties'] ) || ! is_array( $property['properties'] ) ) {
298 return $value;
299 }
300
301 $declared = $property['properties'];
302 $closed = isset( $property['additionalProperties'] ) && false === $property['additionalProperties'];
303 $object = [];
304
305 foreach ( $value as $name => $item ) {
306 if ( isset( $declared[ $name ] ) ) {
307 $object[ $name ] = self::read_value( $declared[ $name ], $item );
308 } elseif ( ! $closed ) {
309 $object[ $name ] = $item;
310 }
311 }
312
313 return $object;
314 default:
315 $string = is_scalar( $value ) ? (string) $value : '';
316
317 if ( ! empty( $property['enum'] ) && ! in_array( $string, $property['enum'], true ) ) {
318 return (string) reset( $property['enum'] );
319 }
320
321 return $string;
322 }
323 }
324
325 /**
326 * Coerce an incoming patch to the declared types, dropping unknown keys.
327 *
328 * Strings are passed through rather than sanitized here: the manager's own
329 * save path already picks the right sanitizer per key, and running
330 * sanitize_text_field() first would strip %date% and %category% out of the
331 * title templates as percent-encoding before it ever got the chance (#521).
332 *
333 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
334 * @param array<string, mixed> $incoming Caller-supplied settings.
335 * @return array<string, mixed> Recognized keys only, coerced to type.
336 */
337 public static function coerce( array $properties, array $incoming ): array {
338 $out = [];
339
340 foreach ( $properties as $key => $property ) {
341 if ( ! array_key_exists( $key, $incoming ) ) {
342 continue;
343 }
344
345 $value = $incoming[ $key ];
346
347 $type = $property['type'] ?? 'string';
348
349 // A union type ('string' or a list of them) keeps whichever shape
350 // it arrived in: casting to string would flatten a list, and
351 // casting to array would replace a name with [] (#692).
352 if ( is_array( $type ) ) {
353 $out[ $key ] = is_array( $value )
354 ? array_values( array_map( 'strval', $value ) )
355 : (string) ( $value ?? '' );
356 continue;
357 }
358
359 switch ( $type ) {
360 case 'boolean':
361 $out[ $key ] = (bool) $value;
362 break;
363 case 'integer':
364 $number = (int) $value;
365 if ( isset( $property['minimum'] ) ) {
366 $number = max( (int) $property['minimum'], $number );
367 }
368 if ( isset( $property['maximum'] ) ) {
369 $number = min( (int) $property['maximum'], $number );
370 }
371 $out[ $key ] = $number;
372 break;
373 case 'array':
374 case 'object':
375 if ( ! is_array( $value ) ) {
376 // A structured key given a scalar is a caller error, not
377 // something to flatten into a string and store.
378 continue 2;
379 }
380 $out[ $key ] = $value;
381 break;
382 default:
383 if ( is_array( $value ) ) {
384 continue 2;
385 }
386 $out[ $key ] = (string) $value;
387 }
388 }
389
390 return $out;
391 }
392
393 /**
394 * The opening hours object, one entry per day.
395 *
396 * A real sub-schema rather than string coercion: the value is a map of day
397 * name to {open, close, closed}, and flattening it to a string would make
398 * it unwritable over MCP.
399 *
400 * @return array<string, mixed>
401 */
402 private static function business_hours(): array {
403 $day = [
404 'type' => 'object',
405 'additionalProperties' => false,
406 'properties' => [
407 'open' => [
408 'type' => 'string',
409 'description' => __( 'Opening time as 24-hour HH:MM, or "" when closed.', 'thinkrank' ),
410 ],
411 'close' => [
412 'type' => 'string',
413 'description' => __( 'Closing time as 24-hour HH:MM, or "" when closed.', 'thinkrank' ),
414 ],
415 'closed' => [
416 'type' => 'boolean',
417 'description' => __( 'Whether the business is closed all day.', 'thinkrank' ),
418 ],
419 ],
420 ];
421
422 $properties = [];
423 foreach ( [ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' ] as $name ) {
424 $properties[ $name ] = $day;
425 }
426
427 return [
428 'type' => 'object',
429 'description' => __( 'Opening hours per day of the week.', 'thinkrank' ),
430 'additionalProperties' => false,
431 'properties' => $properties,
432 ];
433 }
434
435 /**
436 * The per-agent AI crawler allow/block map.
437 *
438 * Enumerating the known slugs rather than accepting a free-form object is
439 * what makes this usable by an agent: the alternative is a caller guessing
440 * `chatgpt` for a crawler registered as `chatgpt-user`, having the key
441 * dropped as unknown, and being told the save succeeded — because it did,
442 * with nothing in it.
443 *
444 * @return array<string, mixed>
445 */
446 private static function ai_crawler_rules(): array {
447 $properties = [];
448
449 foreach ( \ThinkRank\SEO\AI_Crawlers::for_display() as $agent ) {
450 $properties[ $agent['slug'] ] = [
451 'type' => 'string',
452 'enum' => [ 'allow', 'block' ],
453 /* translators: 1: crawler user-agent token, e.g. GPTBot. 2: what that crawler is for. */
454 'description' => sprintf( __( '%1$s — %2$s', 'thinkrank' ), $agent['token'], $agent['purpose'] ),
455 ];
456 }
457
458 return [
459 'type' => 'object',
460 'description' => __( 'Per-agent AI crawler rules. "block" writes a User-agent/Disallow pair for that crawler into robots.txt; "allow" (the default for any crawler not listed) writes nothing. Blocking Google-Extended does not affect normal Google Search indexing.', 'thinkrank' ),
461 'additionalProperties' => false,
462 'properties' => $properties,
463 ];
464 }
465
466 /**
467 * A boolean property.
468 *
469 * @param string $description Translated text describing what the toggle does.
470 * @return array<string, mixed>
471 */
472 /**
473 * The site's alternate name, as one value or several.
474 *
475 * schema.org and Google both allow `alternateName` to carry a list, and the
476 * store already round-trips either shape, so the schema says so rather than
477 * forcing a caller to pick one name and drop the rest (#692).
478 *
479 * @since 2.7.0
480 * @return array<string, mixed>
481 */
482 private static function alternate_name(): array {
483 return [
484 // A union rather than anyOf: read() and coerce() switch on `type`,
485 // and an entry without one is dropped as unrecognised. WordPress's
486 // schema validator accepts a type list, so this is both valid JSON
487 // Schema and legible to this file's own consumers.
488 'type' => [ 'string', 'array' ],
489 'items' => [ 'type' => 'string' ],
490 'description' => __( 'Alternate or former name of the site, published as schema alternateName on the homepage WebSite node. Send a string for one name, or an array for several. Blank entries and duplicates are dropped, and a single surviving name is published as a string.', 'thinkrank' ),
491 ];
492 }
493
494 private static function boolean( string $description ): array {
495 return [
496 'type' => 'boolean',
497 'description' => $description,
498 ];
499 }
500
501 /**
502 * A string property.
503 *
504 * @param string $description Translated text describing the value.
505 * @return array<string, mixed>
506 */
507 private static function string( string $description ): array {
508 return [
509 'type' => 'string',
510 'description' => $description,
511 ];
512 }
513
514 /**
515 * A %token% title template property.
516 *
517 * The vocabulary here is Site Identity's, which is not the one the Global
518 * SEO per-post-type templates use — %site_title% rather than %title%,
519 * %site_name% rather than %sitename% — so the description spells it out
520 * instead of leaving an agent to guess between the two.
521 *
522 * @param string $what Translated name of the pages the template titles.
523 * @param string $extra Comma-separated tags available only on those pages, or ''.
524 * @return array<string, mixed>
525 */
526 private static function template( string $what, string $extra ): array {
527 $shared = '%site_title%, %site_name%, %site_description%, %tagline%, %sep%, %separator%, %date%';
528
529 $description = '' === $extra
530 /* translators: 1: the pages a title template applies to. 2: the list of available variable tags. */
531 ? sprintf( __( 'Title template for %1$s. Available tags: %2$s.', 'thinkrank' ), $what, $shared )
532 /* translators: 1: the pages a title template applies to. 2: tags available everywhere. 3: tags available only on those pages. */
533 : sprintf( __( 'Title template for %1$s. Available tags: %2$s, and on these pages also %3$s.', 'thinkrank' ), $what, $shared, $extra );
534
535 return [
536 'type' => 'string',
537 'description' => $description,
538 ];
539 }
540 }
541