← All changes
|
includes/abilities/settings/class-settings-key-map.php
+189
-19
2.1.1
→
2.10.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' ) ), |
| @@ -103,8 +103,9 @@ | ||
| 103 | 103 | 'breadcrumb_home_text' => self::string( __( 'Label for the home link in breadcrumbs.', 'thinkrank' ) ), |
| 104 | 104 | 'breadcrumb_separator' => self::string( __( 'Separator drawn between breadcrumb items.', 'thinkrank' ) ), |
| 105 | 105 | 'breadcrumb_prefix' => self::string( __( 'Text shown before the breadcrumb trail.', 'thinkrank' ) ), |
| 106 | 106 | 'show_current_page' => self::boolean( __( 'Whether the current page appears in its own breadcrumb trail.', 'thinkrank' ) ), |
| 107 | + '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' ) ), | |
| 107 | 108 | |
| 108 | 109 | // Brand imagery. |
| 109 | 110 | 'logo_url' => self::string( __( 'URL of the site logo.', 'thinkrank' ) ), |
| 110 | 111 | 'favicon_url' => self::string( __( 'URL of the site favicon.', 'thinkrank' ) ), |
| @@ -119,9 +120,22 @@ | ||
| 119 | 120 | |
| 120 | 121 | // Business / Local SEO. Feeds LocalBusiness schema. |
| 121 | 122 | 'local_seo_enabled' => self::boolean( __( 'Whether Local SEO output and LocalBusiness schema are enabled.', 'thinkrank' ) ), |
| 122 | 123 | 'business_name' => self::string( __( 'Registered business name.', 'thinkrank' ) ), |
| 123 | - 'business_type' => self::string( __( 'schema.org business type, e.g. "Restaurant", "Store".', 'thinkrank' ) ), | |
| 124 | + // Enumerated rather than free-form: the value goes straight into | |
| 125 | + // LocalBusiness schema, so an invented type is invalid structured | |
| 126 | + // data. ~150 schema.org subtypes are accepted (#623). | |
| 127 | + // | |
| 128 | + // '' is in the list because it is a real stored state ("not set"): | |
| 129 | + // the store holds it on every site that never opened Local SEO, and | |
| 130 | + // without it the READ ability failed its own output schema on those | |
| 131 | + // sites and there was no way to clear the value over MCP. It goes | |
| 132 | + // last so the root stays first, which read() falls back to. | |
| 133 | + 'business_type' => [ | |
| 134 | + 'type' => 'string', | |
| 135 | + 'enum' => array_merge( \ThinkRank\Config\Local_Business_Types_Config::get_types(), [ '' ] ), | |
| 136 | + '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' ), | |
| 137 | + ], | |
| 124 | 138 | 'business_email' => self::string( __( 'Public contact email address.', 'thinkrank' ) ), |
| 125 | 139 | 'business_phone' => self::string( __( 'Public contact telephone number.', 'thinkrank' ) ), |
| 126 | 140 | 'business_address' => self::string( __( 'Street address.', 'thinkrank' ) ), |
| 127 | 141 | 'business_city' => self::string( __( 'City or locality.', 'thinkrank' ) ), |
| @@ -134,9 +148,21 @@ | ||
| 134 | 148 | 'business_hours' => self::business_hours(), |
| 135 | 149 | |
| 136 | 150 | // Indexing. |
| 137 | 151 | 'allow_search_engines' => self::boolean( __( 'Whether search engines are allowed to index the site.', 'thinkrank' ) ), |
| 152 | + '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' ) ), | |
| 153 | + 'canonical_scheme' => [ | |
| 154 | + 'type' => 'string', | |
| 155 | + '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' ), | |
| 156 | + 'enum' => \ThinkRank\SEO\Url_Scheme::MODES, | |
| 157 | + ], | |
| 138 | 158 | 'robots_txt_enabled' => self::boolean( __( 'Whether ThinkRank manages robots.txt. Its contents are set with thinkrank/update-robots-txt.', 'thinkrank' ) ), |
| 159 | + | |
| 160 | + // RSS feeds. | |
| 161 | + 'feed_excerpt_only' => self::boolean( __( 'Whether feed entries are shortened to an excerpt instead of carrying the full post.', 'thinkrank' ) ), | |
| 162 | + 'feed_source_link' => self::boolean( __( 'Whether each feed entry is signed with a link back to the original post and the site.', 'thinkrank' ) ), | |
| 163 | + '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' ) ), | |
| 164 | + 'ai_crawler_rules' => self::ai_crawler_rules(), | |
| 139 | 165 | ]; |
| 140 | 166 | } |
| 141 | 167 | |
| 142 | 168 | /** |
| @@ -146,8 +172,13 @@ | ||
| 146 | 172 | */ |
| 147 | 173 | public static function sitemap(): array { |
| 148 | 174 | return [ |
| 149 | 175 | 'enabled' => self::boolean( __( 'Whether XML sitemap generation is active.', 'thinkrank' ) ), |
| 176 | + 'delivery_mode' => [ | |
| 177 | + 'type' => 'string', | |
| 178 | + 'enum' => \ThinkRank\SEO\Sitemap_Generator::DELIVERY_MODES, | |
| 179 | + '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' ), | |
| 180 | + ], | |
| 150 | 181 | 'auto_generate' => self::boolean( __( 'Whether the sitemap regenerates automatically when content changes.', 'thinkrank' ) ), |
| 151 | 182 | 'use_sitemap_index' => self::boolean( __( 'Whether to publish a sitemap index that links per-type sitemaps, rather than one flat file.', 'thinkrank' ) ), |
| 152 | 183 | 'links_per_sitemap' => [ |
| 153 | 184 | 'type' => 'integer', |
| @@ -158,8 +189,14 @@ | ||
| 158 | 189 | 'custom_url_pattern' => self::string( __( 'Filename pattern for generated sitemaps, e.g. "sitemap-{type}.xml".', 'thinkrank' ) ), |
| 159 | 190 | 'enable_styling' => self::boolean( __( 'Whether an XSL stylesheet is attached so the sitemap is readable in a browser.', 'thinkrank' ) ), |
| 160 | 191 | 'ping_search_engines' => self::boolean( __( 'Whether search engines are notified after the sitemap is regenerated.', 'thinkrank' ) ), |
| 161 | 192 | |
| 193 | + // How the styled sitemap looks. All four need enable_styling on. | |
| 194 | + 'styling_logo' => self::boolean( __( 'Whether a logo is shown above the sitemap heading. Requires enable_styling.', 'thinkrank' ) ), | |
| 195 | + 'styling_logo_url' => self::string( __( 'URL of the sitemap logo image. Empty falls back to the site icon.', 'thinkrank' ) ), | |
| 196 | + 'styling_color_main' => self::string( __( 'Hex colour ("#rrggbb") for the sitemap header, links and table head. Empty keeps the stock palette.', 'thinkrank' ) ), | |
| 197 | + 'styling_color_accent' => self::string( __( 'Hex colour ("#rrggbb") for the header gradient end and link hovers. Empty keeps the stock palette.', 'thinkrank' ) ), | |
| 198 | + | |
| 162 | 199 | // What goes in. |
| 163 | 200 | 'include_posts' => self::boolean( __( 'Whether posts are included.', 'thinkrank' ) ), |
| 164 | 201 | 'include_pages' => self::boolean( __( 'Whether pages are included.', 'thinkrank' ) ), |
| 165 | 202 | 'include_categories' => self::boolean( __( 'Whether category archives are included.', 'thinkrank' ) ), |
| @@ -180,8 +217,17 @@ | ||
| 180 | 217 | * |
| 181 | 218 | * The store keeps everything as strings ("1"/""), so each value is coerced |
| 182 | 219 | * back to the type the schema advertises before it reaches an agent. |
| 183 | 220 | * |
| 221 | + * The same properties are the read ability's OUTPUT schema, which the | |
| 222 | + * Abilities API validates, and one value that fails it fails the whole | |
| 223 | + * call. So what comes out has to fit the schema for any stored value, not | |
| 224 | + * only for what today's save path writes: a business type imported before | |
| 225 | + * the list was enumerated, or a crawler rule whose crawler a filter has | |
| 226 | + * since removed, used to turn every get-site-identity-settings call into | |
| 227 | + * ability_invalid_output. Hence the recursion into objects, and the enum | |
| 228 | + * fallback in read_value(). | |
| 229 | + * | |
| 184 | 230 | * @param array<string, array<string, mixed>> $properties Schema properties for the category. |
| 185 | 231 | * @param array<string, mixed> $stored Settings as the manager returns them. |
| 186 | 232 | * @return array<string, mixed> Exposed settings, one entry per declared key. |
| 187 | 233 | */ |
| @@ -188,27 +234,86 @@ | ||
| 188 | 234 | public static function read( array $properties, array $stored ): array { |
| 189 | 235 | $out = []; |
| 190 | 236 | |
| 191 | 237 | foreach ( $properties as $key => $property ) { |
| 192 | - $value = $stored[ $key ] ?? null; | |
| 238 | + $out[ $key ] = self::read_value( $property, $stored[ $key ] ?? null ); | |
| 239 | + } | |
| 193 | 240 | |
| 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 | - } | |
| 241 | + return $out; | |
| 242 | + } | |
| 243 | + | |
| 244 | + /** | |
| 245 | + * Coerce one stored value to the shape its property declares. | |
| 246 | + * | |
| 247 | + * An enumerated string that holds something outside its enum reads as the | |
| 248 | + * enum's FIRST entry. Every enum in this map is ordered so that entry is | |
| 249 | + * the value its manager falls back to for anything unrecognised | |
| 250 | + * (`automatic`, `auto`, `allow`, `LocalBusiness`), so the agent is told | |
| 251 | + * what the site actually does rather than a value it would be refused if | |
| 252 | + * it wrote it back. | |
| 253 | + * | |
| 254 | + * @since 2.10.0 | |
| 255 | + * | |
| 256 | + * @param array<string, mixed> $property Schema property. | |
| 257 | + * @param mixed $value Stored value, or null when absent. | |
| 258 | + * @return mixed | |
| 259 | + */ | |
| 260 | + private static function read_value( array $property, $value ) { | |
| 261 | + $type = $property['type'] ?? 'string'; | |
| 262 | + | |
| 263 | + // A union type ('string' or a list of them) keeps whichever shape | |
| 264 | + // it arrived in: casting to string would flatten a list, and | |
| 265 | + // casting to array would replace a name with [] (#692). | |
| 266 | + if ( is_array( $type ) ) { | |
| 267 | + return is_array( $value ) | |
| 268 | + ? array_values( array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ) | |
| 269 | + : ( is_scalar( $value ) ? (string) $value : '' ); | |
| 208 | 270 | } |
| 209 | 271 | |
| 210 | - return $out; | |
| 272 | + switch ( $type ) { | |
| 273 | + case 'boolean': | |
| 274 | + return (bool) $value; | |
| 275 | + case 'integer': | |
| 276 | + return (int) $value; | |
| 277 | + case 'array': | |
| 278 | + if ( ! is_array( $value ) ) { | |
| 279 | + return []; | |
| 280 | + } | |
| 281 | + if ( isset( $property['items'] ) && is_array( $property['items'] ) ) { | |
| 282 | + $items = $property['items']; | |
| 283 | + return array_values( array_map( static fn( $item ) => self::read_value( $items, $item ), $value ) ); | |
| 284 | + } | |
| 285 | + return $value; | |
| 286 | + case 'object': | |
| 287 | + if ( ! is_array( $value ) ) { | |
| 288 | + return []; | |
| 289 | + } | |
| 290 | + if ( empty( $property['properties'] ) || ! is_array( $property['properties'] ) ) { | |
| 291 | + return $value; | |
| 292 | + } | |
| 293 | + | |
| 294 | + $declared = $property['properties']; | |
| 295 | + $closed = isset( $property['additionalProperties'] ) && false === $property['additionalProperties']; | |
| 296 | + $object = []; | |
| 297 | + | |
| 298 | + foreach ( $value as $name => $item ) { | |
| 299 | + if ( isset( $declared[ $name ] ) ) { | |
| 300 | + $object[ $name ] = self::read_value( $declared[ $name ], $item ); | |
| 301 | + } elseif ( ! $closed ) { | |
| 302 | + $object[ $name ] = $item; | |
| 303 | + } | |
| 304 | + } | |
| 305 | + | |
| 306 | + return $object; | |
| 307 | + default: | |
| 308 | + $string = is_scalar( $value ) ? (string) $value : ''; | |
| 309 | + | |
| 310 | + if ( ! empty( $property['enum'] ) && ! in_array( $string, $property['enum'], true ) ) { | |
| 311 | + return (string) reset( $property['enum'] ); | |
| 312 | + } | |
| 313 | + | |
| 314 | + return $string; | |
| 315 | + } | |
| 211 | 316 | } |
| 212 | 317 | |
| 213 | 318 | /** |
| 214 | 319 | * Coerce an incoming patch to the declared types, dropping unknown keys. |
| @@ -231,9 +336,21 @@ | ||
| 231 | 336 | } |
| 232 | 337 | |
| 233 | 338 | $value = $incoming[ $key ]; |
| 234 | 339 | |
| 235 | - switch ( $property['type'] ) { | |
| 340 | + $type = $property['type'] ?? 'string'; | |
| 341 | + | |
| 342 | + // A union type ('string' or a list of them) keeps whichever shape | |
| 343 | + // it arrived in: casting to string would flatten a list, and | |
| 344 | + // casting to array would replace a name with [] (#692). | |
| 345 | + if ( is_array( $type ) ) { | |
| 346 | + $out[ $key ] = is_array( $value ) | |
| 347 | + ? array_values( array_map( 'strval', $value ) ) | |
| 348 | + : (string) ( $value ?? '' ); | |
| 349 | + continue; | |
| 350 | + } | |
| 351 | + | |
| 352 | + switch ( $type ) { | |
| 236 | 353 | case 'boolean': |
| 237 | 354 | $out[ $key ] = (bool) $value; |
| 238 | 355 | break; |
| 239 | 356 | case 'integer': |
| @@ -308,13 +425,66 @@ | ||
| 308 | 425 | ]; |
| 309 | 426 | } |
| 310 | 427 | |
| 311 | 428 | /** |
| 429 | + * The per-agent AI crawler allow/block map. | |
| 430 | + * | |
| 431 | + * Enumerating the known slugs rather than accepting a free-form object is | |
| 432 | + * what makes this usable by an agent: the alternative is a caller guessing | |
| 433 | + * `chatgpt` for a crawler registered as `chatgpt-user`, having the key | |
| 434 | + * dropped as unknown, and being told the save succeeded — because it did, | |
| 435 | + * with nothing in it. | |
| 436 | + * | |
| 437 | + * @return array<string, mixed> | |
| 438 | + */ | |
| 439 | + private static function ai_crawler_rules(): array { | |
| 440 | + $properties = []; | |
| 441 | + | |
| 442 | + foreach ( \ThinkRank\SEO\AI_Crawlers::for_display() as $agent ) { | |
| 443 | + $properties[ $agent['slug'] ] = [ | |
| 444 | + 'type' => 'string', | |
| 445 | + 'enum' => [ 'allow', 'block' ], | |
| 446 | + /* translators: 1: crawler user-agent token, e.g. GPTBot. 2: what that crawler is for. */ | |
| 447 | + 'description' => sprintf( __( '%1$s — %2$s', 'thinkrank' ), $agent['token'], $agent['purpose'] ), | |
| 448 | + ]; | |
| 449 | + } | |
| 450 | + | |
| 451 | + return [ | |
| 452 | + 'type' => 'object', | |
| 453 | + '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' ), | |
| 454 | + 'additionalProperties' => false, | |
| 455 | + 'properties' => $properties, | |
| 456 | + ]; | |
| 457 | + } | |
| 458 | + | |
| 459 | + /** | |
| 312 | 460 | * A boolean property. |
| 313 | 461 | * |
| 314 | 462 | * @param string $description Translated text describing what the toggle does. |
| 315 | 463 | * @return array<string, mixed> |
| 316 | 464 | */ |
| 465 | + /** | |
| 466 | + * The site's alternate name, as one value or several. | |
| 467 | + * | |
| 468 | + * schema.org and Google both allow `alternateName` to carry a list, and the | |
| 469 | + * store already round-trips either shape, so the schema says so rather than | |
| 470 | + * forcing a caller to pick one name and drop the rest (#692). | |
| 471 | + * | |
| 472 | + * @since 2.7.0 | |
| 473 | + * @return array<string, mixed> | |
| 474 | + */ | |
| 475 | + private static function alternate_name(): array { | |
| 476 | + return [ | |
| 477 | + // A union rather than anyOf: read() and coerce() switch on `type`, | |
| 478 | + // and an entry without one is dropped as unrecognised. WordPress's | |
| 479 | + // schema validator accepts a type list, so this is both valid JSON | |
| 480 | + // Schema and legible to this file's own consumers. | |
| 481 | + 'type' => [ 'string', 'array' ], | |
| 482 | + 'items' => [ 'type' => 'string' ], | |
| 483 | + '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' ), | |
| 484 | + ]; | |
| 485 | + } | |
| 486 | + | |
| 317 | 487 | private static function boolean( string $description ): array { |
| 318 | 488 | return [ |
| 319 | 489 | 'type' => 'boolean', |
| 320 | 490 | 'description' => $description, |