| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* schema.org LocalBusiness subtypes. |
| 5 |
* |
| 6 |
* The Local SEO business-type control offered five of these. A customer asking |
| 7 |
* for `HealthAndBeautyBusiness` had no way to select it, and competing plugins |
| 8 |
* expose the whole vocabulary, so anything outside those five was simply |
| 9 |
* unreachable (#623). |
| 10 |
* |
| 11 |
* Kept as a flat map of type => English label, with the hierarchy expressed by |
| 12 |
* `PARENTS` rather than by nesting. A flat list is what both consumers actually |
| 13 |
* want: the control renders one searchable list, and the MCP ability needs a |
| 14 |
* flat `enum`. Nesting would have to be flattened again at both call sites. |
| 15 |
* |
| 16 |
* Labels are the schema.org type names in readable form, deliberately NOT |
| 17 |
* translated. The value written to settings is the schema.org type, the label |
| 18 |
* differs from it only by spacing, and a translated label would leave a |
| 19 |
* German-language site searching this list in English for a term it was shown |
| 20 |
* in German. Where a type name is genuinely opaque the label stays identical to |
| 21 |
* the type rather than inventing a gloss. |
| 22 |
* |
| 23 |
* @package ThinkRank\Config |
| 24 |
* @since 2.10.0 |
| 25 |
*/ |
| 26 |
|
| 27 |
declare(strict_types=1); |
| 28 |
|
| 29 |
namespace ThinkRank\Config; |
| 30 |
|
| 31 |
if (!defined('ABSPATH')) { |
| 32 |
exit; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Local Business Types Config |
| 37 |
* |
| 38 |
* @since 2.10.0 |
| 39 |
*/ |
| 40 |
class Local_Business_Types_Config { |
| 41 |
|
| 42 |
/** |
| 43 |
* The root type. Always valid, and the default. |
| 44 |
*/ |
| 45 |
public const ROOT = 'LocalBusiness'; |
| 46 |
|
| 47 |
/** |
| 48 |
* Direct parent of each subtype, for grouping in the UI. |
| 49 |
* |
| 50 |
* Types absent from this map hang directly off LocalBusiness. |
| 51 |
* |
| 52 |
* @var array<string,string> |
| 53 |
*/ |
| 54 |
private const PARENTS = [ |
| 55 |
// AutomotiveBusiness |
| 56 |
'AutoBodyShop' => 'AutomotiveBusiness', |
| 57 |
'AutoDealer' => 'AutomotiveBusiness', |
| 58 |
'AutoPartsStore' => 'AutomotiveBusiness', |
| 59 |
'AutoRental' => 'AutomotiveBusiness', |
| 60 |
'AutoRepair' => 'AutomotiveBusiness', |
| 61 |
'AutoWash' => 'AutomotiveBusiness', |
| 62 |
'GasStation' => 'AutomotiveBusiness', |
| 63 |
'MotorcycleDealer' => 'AutomotiveBusiness', |
| 64 |
'MotorcycleRepair' => 'AutomotiveBusiness', |
| 65 |
|
| 66 |
// EmergencyService |
| 67 |
'FireStation' => 'EmergencyService', |
| 68 |
'Hospital' => 'EmergencyService', |
| 69 |
'PoliceStation' => 'EmergencyService', |
| 70 |
|
| 71 |
// EntertainmentBusiness |
| 72 |
'AdultEntertainment' => 'EntertainmentBusiness', |
| 73 |
'AmusementPark' => 'EntertainmentBusiness', |
| 74 |
'ArtGallery' => 'EntertainmentBusiness', |
| 75 |
'Casino' => 'EntertainmentBusiness', |
| 76 |
'ComedyClub' => 'EntertainmentBusiness', |
| 77 |
'MovieTheater' => 'EntertainmentBusiness', |
| 78 |
'NightClub' => 'EntertainmentBusiness', |
| 79 |
|
| 80 |
// FinancialService |
| 81 |
'AccountingService' => 'FinancialService', |
| 82 |
'AutomatedTeller' => 'FinancialService', |
| 83 |
'BankOrCreditUnion' => 'FinancialService', |
| 84 |
'InsuranceAgency' => 'FinancialService', |
| 85 |
|
| 86 |
// FoodEstablishment |
| 87 |
'Bakery' => 'FoodEstablishment', |
| 88 |
'BarOrPub' => 'FoodEstablishment', |
| 89 |
'Brewery' => 'FoodEstablishment', |
| 90 |
'CafeOrCoffeeShop' => 'FoodEstablishment', |
| 91 |
'Distillery' => 'FoodEstablishment', |
| 92 |
'FastFoodRestaurant' => 'FoodEstablishment', |
| 93 |
'IceCreamShop' => 'FoodEstablishment', |
| 94 |
'Restaurant' => 'FoodEstablishment', |
| 95 |
'Winery' => 'FoodEstablishment', |
| 96 |
|
| 97 |
// GovernmentOffice |
| 98 |
'PostOffice' => 'GovernmentOffice', |
| 99 |
|
| 100 |
// HealthAndBeautyBusiness — the type the report was about. |
| 101 |
'BeautySalon' => 'HealthAndBeautyBusiness', |
| 102 |
'DaySpa' => 'HealthAndBeautyBusiness', |
| 103 |
'HairSalon' => 'HealthAndBeautyBusiness', |
| 104 |
'HealthClub' => 'HealthAndBeautyBusiness', |
| 105 |
'NailSalon' => 'HealthAndBeautyBusiness', |
| 106 |
'TattooParlor' => 'HealthAndBeautyBusiness', |
| 107 |
|
| 108 |
// HomeAndConstructionBusiness |
| 109 |
'Electrician' => 'HomeAndConstructionBusiness', |
| 110 |
'GeneralContractor' => 'HomeAndConstructionBusiness', |
| 111 |
'HVACBusiness' => 'HomeAndConstructionBusiness', |
| 112 |
'HousePainter' => 'HomeAndConstructionBusiness', |
| 113 |
'Locksmith' => 'HomeAndConstructionBusiness', |
| 114 |
'MovingCompany' => 'HomeAndConstructionBusiness', |
| 115 |
'Plumber' => 'HomeAndConstructionBusiness', |
| 116 |
'RoofingContractor' => 'HomeAndConstructionBusiness', |
| 117 |
|
| 118 |
// LegalService |
| 119 |
'Attorney' => 'LegalService', |
| 120 |
'Notary' => 'LegalService', |
| 121 |
|
| 122 |
// LodgingBusiness |
| 123 |
'BedAndBreakfast' => 'LodgingBusiness', |
| 124 |
'Campground' => 'LodgingBusiness', |
| 125 |
'Hostel' => 'LodgingBusiness', |
| 126 |
'Hotel' => 'LodgingBusiness', |
| 127 |
'Motel' => 'LodgingBusiness', |
| 128 |
'Resort' => 'LodgingBusiness', |
| 129 |
'VacationRental' => 'LodgingBusiness', |
| 130 |
|
| 131 |
// MedicalBusiness |
| 132 |
'CommunityHealth' => 'MedicalBusiness', |
| 133 |
'Dentist' => 'MedicalBusiness', |
| 134 |
'Dermatology' => 'MedicalBusiness', |
| 135 |
'DietNutrition' => 'MedicalBusiness', |
| 136 |
'Emergency' => 'MedicalBusiness', |
| 137 |
'Geriatric' => 'MedicalBusiness', |
| 138 |
'Gynecologic' => 'MedicalBusiness', |
| 139 |
'MedicalClinic' => 'MedicalBusiness', |
| 140 |
'Midwifery' => 'MedicalBusiness', |
| 141 |
'Nursing' => 'MedicalBusiness', |
| 142 |
'Obstetric' => 'MedicalBusiness', |
| 143 |
'Oncologic' => 'MedicalBusiness', |
| 144 |
'Optician' => 'MedicalBusiness', |
| 145 |
'Optometric' => 'MedicalBusiness', |
| 146 |
'Otolaryngologic' => 'MedicalBusiness', |
| 147 |
'Pediatric' => 'MedicalBusiness', |
| 148 |
'Pharmacy' => 'MedicalBusiness', |
| 149 |
'Physician' => 'MedicalBusiness', |
| 150 |
'Physiotherapy' => 'MedicalBusiness', |
| 151 |
'PlasticSurgery' => 'MedicalBusiness', |
| 152 |
'Podiatric' => 'MedicalBusiness', |
| 153 |
'PrimaryCare' => 'MedicalBusiness', |
| 154 |
'Psychiatric' => 'MedicalBusiness', |
| 155 |
'PublicHealth' => 'MedicalBusiness', |
| 156 |
|
| 157 |
// SportsActivityLocation |
| 158 |
'BowlingAlley' => 'SportsActivityLocation', |
| 159 |
'ExerciseGym' => 'SportsActivityLocation', |
| 160 |
'GolfCourse' => 'SportsActivityLocation', |
| 161 |
'PublicSwimmingPool' => 'SportsActivityLocation', |
| 162 |
'SkiResort' => 'SportsActivityLocation', |
| 163 |
'SportsClub' => 'SportsActivityLocation', |
| 164 |
'StadiumOrArena' => 'SportsActivityLocation', |
| 165 |
'TennisComplex' => 'SportsActivityLocation', |
| 166 |
|
| 167 |
// Store |
| 168 |
'BikeStore' => 'Store', |
| 169 |
'BookStore' => 'Store', |
| 170 |
'ClothingStore' => 'Store', |
| 171 |
'ComputerStore' => 'Store', |
| 172 |
'ConvenienceStore' => 'Store', |
| 173 |
'DepartmentStore' => 'Store', |
| 174 |
'ElectronicsStore' => 'Store', |
| 175 |
'Florist' => 'Store', |
| 176 |
'FurnitureStore' => 'Store', |
| 177 |
'GardenStore' => 'Store', |
| 178 |
'GroceryStore' => 'Store', |
| 179 |
'HardwareStore' => 'Store', |
| 180 |
'HobbyShop' => 'Store', |
| 181 |
'HomeGoodsStore' => 'Store', |
| 182 |
'JewelryStore' => 'Store', |
| 183 |
'LiquorStore' => 'Store', |
| 184 |
'MensClothingStore' => 'Store', |
| 185 |
'MobilePhoneStore' => 'Store', |
| 186 |
'MovieRentalStore' => 'Store', |
| 187 |
'MusicStore' => 'Store', |
| 188 |
'OfficeEquipmentStore' => 'Store', |
| 189 |
'OutletStore' => 'Store', |
| 190 |
'PawnShop' => 'Store', |
| 191 |
'PetStore' => 'Store', |
| 192 |
'ShoeStore' => 'Store', |
| 193 |
'SportingGoodsStore' => 'Store', |
| 194 |
'TireShop' => 'Store', |
| 195 |
'ToyStore' => 'Store', |
| 196 |
'WholesaleStore' => 'Store', |
| 197 |
]; |
| 198 |
|
| 199 |
/** |
| 200 |
* Subtypes that hang directly off LocalBusiness. |
| 201 |
* |
| 202 |
* @var string[] |
| 203 |
*/ |
| 204 |
private const TOP_LEVEL = [ |
| 205 |
'AnimalShelter', |
| 206 |
'ArchiveOrganization', |
| 207 |
'AutomotiveBusiness', |
| 208 |
'ChildCare', |
| 209 |
'Dentist', |
| 210 |
'DryCleaningOrLaundry', |
| 211 |
'EmergencyService', |
| 212 |
'EmploymentAgency', |
| 213 |
'EntertainmentBusiness', |
| 214 |
'FinancialService', |
| 215 |
'FoodEstablishment', |
| 216 |
'GovernmentOffice', |
| 217 |
'HealthAndBeautyBusiness', |
| 218 |
'HomeAndConstructionBusiness', |
| 219 |
'InternetCafe', |
| 220 |
'LegalService', |
| 221 |
'Library', |
| 222 |
'LodgingBusiness', |
| 223 |
'MedicalBusiness', |
| 224 |
'ProfessionalService', |
| 225 |
'RadioStation', |
| 226 |
'RealEstateAgent', |
| 227 |
'RecyclingCenter', |
| 228 |
'SelfStorage', |
| 229 |
'ShoppingCenter', |
| 230 |
'SportsActivityLocation', |
| 231 |
'Store', |
| 232 |
'TelevisionStation', |
| 233 |
'TouristInformationCenter', |
| 234 |
'TravelAgency', |
| 235 |
]; |
| 236 |
|
| 237 |
/** |
| 238 |
* Types ThinkRank offered before this list existed that are NOT |
| 239 |
* LocalBusiness subtypes. |
| 240 |
* |
| 241 |
* `MedicalOrganization` sits under Organization, not LocalBusiness — but it |
| 242 |
* was one of the five options the control used to offer, so sites are |
| 243 |
* storing it. Dropping it would have an upgrade silently rewrite what those |
| 244 |
* sites publish, which is worse than carrying a type that is merely in the |
| 245 |
* wrong branch of the vocabulary: it is still valid schema.org, and |
| 246 |
* MedicalBusiness (the LocalBusiness-side equivalent) is offered alongside |
| 247 |
* it for anyone choosing afresh. |
| 248 |
* |
| 249 |
* @var string[] |
| 250 |
*/ |
| 251 |
private const LEGACY = [ |
| 252 |
'MedicalOrganization', |
| 253 |
]; |
| 254 |
|
| 255 |
/** |
| 256 |
* Labels that a space-separated type name would get wrong. |
| 257 |
* |
| 258 |
* Only where splitting on capitals produces something misleading. Anything |
| 259 |
* not listed is derived, so the map stays short enough to read. |
| 260 |
* |
| 261 |
* @var array<string,string> |
| 262 |
*/ |
| 263 |
private const LABEL_OVERRIDES = [ |
| 264 |
'BarOrPub' => 'Bar or Pub', |
| 265 |
'BankOrCreditUnion' => 'Bank or Credit Union', |
| 266 |
'CafeOrCoffeeShop' => 'Cafe or Coffee Shop', |
| 267 |
'StadiumOrArena' => 'Stadium or Arena', |
| 268 |
'DryCleaningOrLaundry' => 'Dry Cleaning or Laundry', |
| 269 |
'BedAndBreakfast' => 'Bed and Breakfast', |
| 270 |
'HealthAndBeautyBusiness' => 'Health and Beauty Business', |
| 271 |
'HomeAndConstructionBusiness' => 'Home and Construction Business', |
| 272 |
'HVACBusiness' => 'HVAC Business', |
| 273 |
'AutomatedTeller' => 'Automated Teller (ATM)', |
| 274 |
'DietNutrition' => 'Diet and Nutrition', |
| 275 |
'MensClothingStore' => "Men's Clothing Store", |
| 276 |
]; |
| 277 |
|
| 278 |
/** |
| 279 |
* Every selectable business type, root first. |
| 280 |
* |
| 281 |
* @return string[] |
| 282 |
*/ |
| 283 |
public static function get_types(): array { |
| 284 |
$types = array_merge( |
| 285 |
[self::ROOT], |
| 286 |
self::TOP_LEVEL, |
| 287 |
array_keys(self::PARENTS), |
| 288 |
self::LEGACY |
| 289 |
); |
| 290 |
|
| 291 |
// A type can appear in both lists — Dentist is a LocalBusiness subtype |
| 292 |
// in its own right and a MedicalBusiness one — and must be offered once. |
| 293 |
return array_values(array_unique($types)); |
| 294 |
} |
| 295 |
|
| 296 |
/** |
| 297 |
* Whether a value is a business type ThinkRank will accept. |
| 298 |
* |
| 299 |
* @param string $type Candidate type. |
| 300 |
* @return bool |
| 301 |
*/ |
| 302 |
public static function is_valid(string $type): bool { |
| 303 |
return in_array($type, self::get_types(), true); |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Whether a type is a LocalBusiness in schema.org's own hierarchy. |
| 308 |
* |
| 309 |
* Narrower than is_valid(): the LEGACY entries are accepted as stored |
| 310 |
* values but are not LocalBusiness subtypes. Anything that decides what a |
| 311 |
* node IS (its `@type`, its site-level `@id`, which validator spec applies) |
| 312 |
* asks this, not is_valid(). |
| 313 |
* |
| 314 |
* @since 2.10.0 |
| 315 |
* |
| 316 |
* @param mixed $type Candidate type. Anything but a string is not a type. |
| 317 |
* @return bool |
| 318 |
*/ |
| 319 |
public static function is_local_business($type): bool { |
| 320 |
if (!is_string($type) || '' === $type) { |
| 321 |
return false; |
| 322 |
} |
| 323 |
|
| 324 |
return self::ROOT === $type |
| 325 |
|| in_array($type, self::TOP_LEVEL, true) |
| 326 |
|| isset(self::PARENTS[$type]); |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* The `@type` to publish for a stored business type. |
| 331 |
* |
| 332 |
* The stored type when it is a LocalBusiness subtype, the root otherwise. |
| 333 |
* "Otherwise" covers an unset value, anything an older importer stored |
| 334 |
* verbatim (Rank Math's `Organization`), and the LEGACY |
| 335 |
* `MedicalOrganization`: every one of those has always been published as |
| 336 |
* `LocalBusiness`, and keeping it that way means an upgrade changes the |
| 337 |
* JSON-LD only of sites that actually chose a subtype. A LEGACY type is |
| 338 |
* also an Organization, and the node carries LocalBusiness-only properties |
| 339 |
* (openingHoursSpecification, priceRange) that would not be valid on one. |
| 340 |
* |
| 341 |
* @since 2.10.0 |
| 342 |
* |
| 343 |
* @param mixed $stored Stored business_type value. |
| 344 |
* @return string schema.org type. |
| 345 |
*/ |
| 346 |
public static function schema_type($stored): string { |
| 347 |
return self::is_local_business($stored) ? (string) $stored : self::ROOT; |
| 348 |
} |
| 349 |
|
| 350 |
/** |
| 351 |
* Readable label for a type. |
| 352 |
* |
| 353 |
* @param string $type schema.org type name. |
| 354 |
* @return string |
| 355 |
*/ |
| 356 |
public static function label(string $type): string { |
| 357 |
if (isset(self::LABEL_OVERRIDES[$type])) { |
| 358 |
return self::LABEL_OVERRIDES[$type]; |
| 359 |
} |
| 360 |
|
| 361 |
// Split on the capitals that start a word, keeping runs of capitals |
| 362 |
// (HVAC, ATM) together. |
| 363 |
$label = (string) preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $type); |
| 364 |
|
| 365 |
return trim($label); |
| 366 |
} |
| 367 |
|
| 368 |
/** |
| 369 |
* The direct parent of a type, or '' for a top-level one. |
| 370 |
* |
| 371 |
* @param string $type schema.org type name. |
| 372 |
* @return string |
| 373 |
*/ |
| 374 |
public static function parent(string $type): string { |
| 375 |
return self::PARENTS[$type] ?? ''; |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Every type as value/label pairs, for a select control. |
| 380 |
* |
| 381 |
* Sorted by label so a ~180-entry searchable list reads alphabetically, |
| 382 |
* with the root pinned first because it is the default and the safe answer. |
| 383 |
* |
| 384 |
* @return array<int,array{value:string,label:string,parent:string}> |
| 385 |
*/ |
| 386 |
public static function get_options(): array { |
| 387 |
$types = self::get_types(); |
| 388 |
$root = array_shift($types); |
| 389 |
|
| 390 |
$options = array_map( |
| 391 |
static fn(string $type): array => [ |
| 392 |
'value' => $type, |
| 393 |
'label' => self::label($type), |
| 394 |
'parent' => self::parent($type), |
| 395 |
], |
| 396 |
$types |
| 397 |
); |
| 398 |
|
| 399 |
usort($options, static fn(array $a, array $b): int => strcmp($a['label'], $b['label'])); |
| 400 |
|
| 401 |
array_unshift($options, [ |
| 402 |
'value' => $root, |
| 403 |
'label' => self::label($root), |
| 404 |
'parent' => '', |
| 405 |
]); |
| 406 |
|
| 407 |
return $options; |
| 408 |
} |
| 409 |
} |
| 410 |
|