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
← All changes | includes/abilities/settings/class-settings-key-map.php +163 -19 2.6.0 → 2.13.0 View file →
@@ -74,9 +74,9 @@
74 74 'enabled' => self::boolean( __( 'Whether site identity management is active.', 'thinkrank' ) ),
75 75 'site_name' => self::string( __( 'Official name of the site, used in titles and schema.', 'thinkrank' ) ),
76 76 'site_description' => self::string( __( 'Short description of the site.', 'thinkrank' ) ),
77 77 'tagline' => self::string( __( 'Site tagline.', 'thinkrank' ) ),
78 - 'alternate_name' => self::string( __( 'Alternate or former name of the site, published as schema alternateName.', 'thinkrank' ) ),
78 + 'alternate_name' => self::alternate_name(),
79 79 'identity_type' => self::string( __( 'What the site is, e.g. "blog", "business", "portfolio".', 'thinkrank' ) ),
80 80 'represents' => self::string( __( 'Whether the site represents a "person" or an "organization".', 'thinkrank' ) ),
81 81 'default_meta_description' => self::string( __( 'Meta description used where no more specific one is set.', 'thinkrank' ) ),
82 82 'default_social_image' => self::string( __( 'URL of the fallback social sharing image.', 'thinkrank' ) ),
@@ -90,8 +90,15 @@
90 90 // without stripping their tokens.
91 91 'title_template' => self::string( __( 'Named title layout, e.g. "default", "reverse", "category".', 'thinkrank' ) ),
92 92 'title_separator' => self::string( __( 'Separator between title parts, e.g. "pipe", "dash".', 'thinkrank' ) ),
93 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 + ),
94 101 'category_title' => self::template( __( 'category archives', 'thinkrank' ), '%category_title%, %category%' ),
95 102 'tag_title' => self::template( __( 'tag archives', 'thinkrank' ), '%tag_title%' ),
96 103 'author_title' => self::template( __( 'author archives', 'thinkrank' ), '%author_name%' ),
97 104 'search_title' => self::template( __( 'search results pages', 'thinkrank' ), '%search_term%' ),
@@ -120,9 +127,22 @@
120 127
121 128 // Business / Local SEO. Feeds LocalBusiness schema.
122 129 'local_seo_enabled' => self::boolean( __( 'Whether Local SEO output and LocalBusiness schema are enabled.', 'thinkrank' ) ),
123 130 'business_name' => self::string( __( 'Registered business name.', 'thinkrank' ) ),
124 - 'business_type' => self::string( __( 'schema.org business type, e.g. "Restaurant", "Store".', '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 + ],
125 145 'business_email' => self::string( __( 'Public contact email address.', 'thinkrank' ) ),
126 146 'business_phone' => self::string( __( 'Public contact telephone number.', 'thinkrank' ) ),
127 147 'business_address' => self::string( __( 'Street address.', 'thinkrank' ) ),
128 148 'business_city' => self::string( __( 'City or locality.', 'thinkrank' ) ),
@@ -135,9 +155,20 @@
135 155 'business_hours' => self::business_hours(),
136 156
137 157 // Indexing.
138 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 + ],
139 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' ) ),
140 171 'ai_crawler_rules' => self::ai_crawler_rules(),
141 172 ];
142 173 }
143 174
@@ -148,8 +179,13 @@
148 179 */
149 180 public static function sitemap(): array {
150 181 return [
151 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 + ],
152 188 'auto_generate' => self::boolean( __( 'Whether the sitemap regenerates automatically when content changes.', 'thinkrank' ) ),
153 189 'use_sitemap_index' => self::boolean( __( 'Whether to publish a sitemap index that links per-type sitemaps, rather than one flat file.', 'thinkrank' ) ),
154 190 'links_per_sitemap' => [
155 191 'type' => 'integer',
@@ -160,8 +196,14 @@
160 196 'custom_url_pattern' => self::string( __( 'Filename pattern for generated sitemaps, e.g. "sitemap-{type}.xml".', 'thinkrank' ) ),
161 197 'enable_styling' => self::boolean( __( 'Whether an XSL stylesheet is attached so the sitemap is readable in a browser.', 'thinkrank' ) ),
162 198 'ping_search_engines' => self::boolean( __( 'Whether search engines are notified after the sitemap is regenerated.', 'thinkrank' ) ),
163 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 +
164 206 // What goes in.
165 207 'include_posts' => self::boolean( __( 'Whether posts are included.', 'thinkrank' ) ),
166 208 'include_pages' => self::boolean( __( 'Whether pages are included.', 'thinkrank' ) ),
167 209 'include_categories' => self::boolean( __( 'Whether category archives are included.', 'thinkrank' ) ),
@@ -182,8 +224,17 @@
182 224 *
183 225 * The store keeps everything as strings ("1"/""), so each value is coerced
184 226 * back to the type the schema advertises before it reaches an agent.
185 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 + *
186 237 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
187 238 * @param array<string, mixed> $stored Settings as the manager returns them.
188 239 * @return array<string, mixed> Exposed settings, one entry per declared key.
189 240 */
@@ -190,27 +241,86 @@
190 241 public static function read( array $properties, array $stored ): array {
191 242 $out = [];
192 243
193 244 foreach ( $properties as $key => $property ) {
194 - $value = $stored[ $key ] ?? null;
245 + $out[ $key ] = self::read_value( $property, $stored[ $key ] ?? null );
246 + }
195 247
196 - switch ( $property['type'] ) {
197 - case 'boolean':
198 - $out[ $key ] = (bool) $value;
199 - break;
200 - case 'integer':
201 - $out[ $key ] = (int) $value;
202 - break;
203 - case 'array':
204 - case 'object':
205 - $out[ $key ] = is_array( $value ) ? $value : [];
206 - break;
207 - default:
208 - $out[ $key ] = (string) ( $value ?? '' );
209 - }
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 : '' );
210 277 }
211 278
212 - return $out;
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 + }
213 323 }
214 324
215 325 /**
216 326 * Coerce an incoming patch to the declared types, dropping unknown keys.
@@ -233,9 +343,21 @@
233 343 }
234 344
235 345 $value = $incoming[ $key ];
236 346
237 - switch ( $property['type'] ) {
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 ) {
238 360 case 'boolean':
239 361 $out[ $key ] = (bool) $value;
240 362 break;
241 363 case 'integer':
@@ -346,8 +468,30 @@
346 468 *
347 469 * @param string $description Translated text describing what the toggle does.
348 470 * @return array<string, mixed>
349 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 +
350 494 private static function boolean( string $description ): array {
351 495 return [
352 496 'type' => 'boolean',
353 497 'description' => $description,