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 +108 -27 2.7.0 → 2.13.0 View file →
@@ -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' ) ),
@@ -159,8 +179,13 @@
159 179 */
160 180 public static function sitemap(): array {
161 181 return [
162 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 + ],
163 188 'auto_generate' => self::boolean( __( 'Whether the sitemap regenerates automatically when content changes.', 'thinkrank' ) ),
164 189 'use_sitemap_index' => self::boolean( __( 'Whether to publish a sitemap index that links per-type sitemaps, rather than one flat file.', 'thinkrank' ) ),
165 190 'links_per_sitemap' => [
166 191 'type' => 'integer',
@@ -199,8 +224,17 @@
199 224 *
200 225 * The store keeps everything as strings ("1"/""), so each value is coerced
201 226 * back to the type the schema advertises before it reaches an agent.
202 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 + *
203 237 * @param array<string, array<string, mixed>> $properties Schema properties for the category.
204 238 * @param array<string, mixed> $stored Settings as the manager returns them.
205 239 * @return array<string, mixed> Exposed settings, one entry per declared key.
206 240 */
@@ -207,39 +241,86 @@
207 241 public static function read( array $properties, array $stored ): array {
208 242 $out = [];
209 243
210 244 foreach ( $properties as $key => $property ) {
211 - $value = $stored[ $key ] ?? null;
245 + $out[ $key ] = self::read_value( $property, $stored[ $key ] ?? null );
246 + }
212 247
213 - $type = $property['type'] ?? 'string';
248 + return $out;
249 + }
214 250
215 - // A union type ('string' or a list of them) keeps whichever shape
216 - // it arrived in: casting to string would flatten a list, and
217 - // casting to array would replace a name with [] (#692).
218 - if ( is_array( $type ) ) {
219 - $out[ $key ] = is_array( $value )
220 - ? array_values( array_map( 'strval', $value ) )
221 - : (string) ( $value ?? '' );
222 - continue;
223 - }
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';
224 269
225 - switch ( $type ) {
226 - case 'boolean':
227 - $out[ $key ] = (bool) $value;
228 - break;
229 - case 'integer':
230 - $out[ $key ] = (int) $value;
231 - break;
232 - case 'array':
233 - case 'object':
234 - $out[ $key ] = is_array( $value ) ? $value : [];
235 - break;
236 - default:
237 - $out[ $key ] = (string) ( $value ?? '' );
238 - }
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 : '' );
239 277 }
240 278
241 - 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 + }
242 323 }
243 324
244 325 /**
245 326 * Coerce an incoming patch to the declared types, dropping unknown keys.