PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / config / class-local-business-types-config.php

class-local-business-types-config.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/config/class-local-business-types-config.php

410 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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