| @@ -201,13 +201,43 @@ | ||
| 201 | 201 | 'WebPage' => [ |
| 202 | 202 | 'name' => 'WebPage', |
| 203 | 203 | 'description' => 'Individual web pages', |
| 204 | 204 | 'required_properties' => ['name', 'url'], |
| 205 | + // 'post' as well as 'page': the per-page selector reaches this for | |
| 206 | + // any post type, and a registry limited to 'page' silently produced | |
| 207 | + // nothing for the rest (#624). | |
| 205 | 208 | 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], |
| 206 | 209 | 'rich_snippets' => ['webpage', 'breadcrumb'], |
| 207 | - 'context_types' => ['page'], | |
| 210 | + 'context_types' => ['page', 'post'], | |
| 208 | 211 | 'priority' => 'medium' |
| 209 | 212 | ], |
| 213 | + 'AboutPage' => [ | |
| 214 | + 'name' => 'AboutPage', | |
| 215 | + 'description' => 'A page describing the organisation or person behind the site', | |
| 216 | + 'required_properties' => ['name', 'url'], | |
| 217 | + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], | |
| 218 | + 'rich_snippets' => ['webpage', 'breadcrumb'], | |
| 219 | + 'context_types' => ['page', 'post'], | |
| 220 | + 'priority' => 'medium' | |
| 221 | + ], | |
| 222 | + 'ContactPage' => [ | |
| 223 | + 'name' => 'ContactPage', | |
| 224 | + 'description' => 'A page giving contact details', | |
| 225 | + 'required_properties' => ['name', 'url'], | |
| 226 | + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], | |
| 227 | + 'rich_snippets' => ['webpage', 'breadcrumb'], | |
| 228 | + 'context_types' => ['page', 'post'], | |
| 229 | + 'priority' => 'medium' | |
| 230 | + ], | |
| 231 | + 'ProfilePage' => [ | |
| 232 | + 'name' => 'ProfilePage', | |
| 233 | + 'description' => 'A page about a single person or organisation', | |
| 234 | + 'required_properties' => ['name', 'url'], | |
| 235 | + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], | |
| 236 | + 'rich_snippets' => ['webpage', 'breadcrumb'], | |
| 237 | + 'context_types' => ['page', 'post'], | |
| 238 | + 'priority' => 'medium' | |
| 239 | + ], | |
| 210 | 240 | 'FAQPage' => [ |
| 211 | 241 | 'name' => 'FAQPage', |
| 212 | 242 | 'description' => 'Frequently Asked Questions pages', |
| 213 | 243 | 'required_properties' => ['mainEntity'], |
| @@ -2258,9 +2288,12 @@ | ||
| 2258 | 2288 | } |
| 2259 | 2289 | |
| 2260 | 2290 | $type = $schema['@type'] ?? ''; |
| 2261 | 2291 | $type = is_array($type) ? reset($type) : $type; |
| 2262 | - $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true); | |
| 2292 | + // A LocalBusiness is deployed under the subtype the site chose, so the | |
| 2293 | + // exemption has to cover every subtype, not only the literal root. | |
| 2294 | + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true) | |
| 2295 | + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type); | |
| 2263 | 2296 | |
| 2264 | 2297 | if (isset($schema['url']) && !$is_entity) { |
| 2265 | 2298 | $schema['url'] = $permalink; |
| 2266 | 2299 | } |
| @@ -2288,9 +2321,15 @@ | ||
| 2288 | 2321 | * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must |
| 2289 | 2322 | * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values |
| 2290 | 2323 | * inside author/publisher/@graph entries are covered too. |
| 2291 | 2324 | * |
| 2325 | + * Also decodes HTML entities in plain-text properties. Schema_Builder | |
| 2326 | + * stored the block editor's `&` as-is until 2.10.0, and nothing | |
| 2327 | + * decodes JSON-LD downstream, so every deployed node built from post text | |
| 2328 | + * published the entity literally. | |
| 2329 | + * | |
| 2292 | 2330 | * @since 1.16.0 |
| 2331 | + * @since 2.10.0 Decodes entities in plain-text properties. | |
| 2293 | 2332 | * |
| 2294 | 2333 | * @param array $schema Decoded schema data. |
| 2295 | 2334 | * @return array Normalised schema. |
| 2296 | 2335 | */ |
| @@ -2299,8 +2338,16 @@ | ||
| 2299 | 2338 | 'datePublished', 'dateModified', 'dateCreated', 'uploadDate', |
| 2300 | 2339 | 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires', |
| 2301 | 2340 | ]; |
| 2302 | 2341 | |
| 2342 | + // Plain text in schema.org. Answer/HowToStep `text` is deliberately | |
| 2343 | + // absent: Google reads Answer.text as HTML, where an entity is correct | |
| 2344 | + // and decoding `<` would turn escaped text into live markup. | |
| 2345 | + static $text_keys = [ | |
| 2346 | + 'name', 'headline', 'alternativeHeadline', 'description', | |
| 2347 | + 'reviewBody', 'about', 'abstract', 'caption', | |
| 2348 | + ]; | |
| 2349 | + | |
| 2303 | 2350 | foreach ($schema as $key => $value) { |
| 2304 | 2351 | if (is_array($value)) { |
| 2305 | 2352 | $schema[$key] = $this->normalize_stored_schema($value); |
| 2306 | 2353 | continue; |
| @@ -2307,8 +2354,16 @@ | ||
| 2307 | 2354 | } |
| 2308 | 2355 | |
| 2309 | 2356 | if ('inLanguage' === $key && is_string($value) && '' !== $value) { |
| 2310 | 2357 | $schema[$key] = str_replace('_', '-', $value); |
| 2358 | + continue; | |
| 2359 | + } | |
| 2360 | + | |
| 2361 | + // Decode only: a snapshot already truncated with an ellipsis must | |
| 2362 | + // keep it, which the full Seo_Text::normalize_schema_text() would | |
| 2363 | + // strip as an excerpt marker. | |
| 2364 | + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) { | |
| 2365 | + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value); | |
| 2311 | 2366 | continue; |
| 2312 | 2367 | } |
| 2313 | 2368 | |
| 2314 | 2369 | if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) { |