PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-schema-settings.php
jetpack / jetpack_vendor / automattic / jetpack-seo / src Last commit date
class-admin-page.php 3 weeks ago class-ai-crawlers.php 3 weeks ago class-ai-seo-enhancer.php 3 days ago class-author-schema-node.php 1 month ago class-breadcrumb-schema-node.php 1 month ago class-content-coverage.php 3 weeks ago class-dashboard-data.php 2 weeks ago class-initializer.php 3 days ago class-llms-txt.php 3 weeks ago class-local-business-schema-node.php 1 month ago class-organization-schema-node.php 1 month ago class-post-schema-node.php 1 month ago class-post-types.php 3 weeks ago class-schema-builder.php 1 month ago class-schema-graph.php 1 month ago class-schema-node-ids.php 1 month ago class-schema-settings-controller.php 1 month ago class-schema-settings.php 1 month ago class-surface-visibility.php 2 weeks ago class-website-schema-node.php 1 month ago
class-schema-settings.php
506 lines
1 <?php
2 /**
3 * Package-owned store for the site-level Schema.org settings.
4 *
5 * Persists the admin-configurable values WordPress has no native source for —
6 * social profiles (`sameAs`), a contact `email`, optional `name` /
7 * `description` overrides, LocalBusiness details, and the BreadcrumbList toggle.
8 * The Organization node reads the effective values via {@see self::get_organization()} and
9 * {@see self::get_local_business()}; the Settings UI round-trips them through
10 * {@see Schema_Settings_Controller}.
11 *
12 * The option is a container keyed by schema type so later types slot in without
13 * breaking the contract. Empty Organization overrides fall back to site identity
14 * at read time, so an unconfigured site still emits a valid node and later Site
15 * Title changes track.
16 *
17 * @package automattic/jetpack-seo-package
18 */
19
20 namespace Automattic\Jetpack\SEO;
21
22 /**
23 * Reads, sanitizes, and persists the site-level Schema settings.
24 */
25 class Schema_Settings {
26
27 /**
28 * Versioned option name. The `_v1` suffix lets a future shape change ship a
29 * new option rather than migrate in place.
30 *
31 * @var string
32 */
33 const OPTION_NAME = 'jetpack_seo_schema_settings_v1';
34
35 /**
36 * Schema.org day-code order for stored opening-hours settings.
37 *
38 * @var array<int, string>
39 */
40 const OPENING_DAYS = array( 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su' );
41
42 /**
43 * The editing payload for the Settings form / REST route: the raw stored
44 * overrides (empty where unset) plus the site-identity defaults the form shows
45 * as field placeholders. Keeping the two separate lets an empty field track the
46 * Site Title instead of freezing its value.
47 *
48 * @return array{organization: array{name: string, description: string, sameAs: array<int, string>, email: string}, localBusiness: array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}, breadcrumbList: array{enabled: bool}, defaults: array{organization: array{name: string, description: string}, localBusiness: array{address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}}}}
49 */
50 public static function get_editable() {
51 $defaults = self::get_defaults();
52 $stored = self::get_stored();
53
54 return array(
55 'organization' => $stored['organization'],
56 'localBusiness' => $stored['localBusiness'],
57 'breadcrumbList' => $stored['breadcrumbList'],
58 'defaults' => array(
59 'organization' => array(
60 'name' => $defaults['organization']['name'],
61 'description' => $defaults['organization']['description'],
62 ),
63 'localBusiness' => array(
64 'address' => $defaults['localBusiness']['address'],
65 ),
66 ),
67 );
68 }
69
70 /**
71 * Site-identity defaults for the fields WordPress has a source for: `name` /
72 * `description` from the Site Title and Tagline. Shown as placeholders and used
73 * as the fallback. WooCommerce store address settings, when WooCommerce is
74 * active, provide LocalBusiness address placeholders and read-time fallbacks.
75 * `sameAs` / `email` and the other LocalBusiness fields have no source, so they
76 * aren't defaulted.
77 *
78 * @return array{organization: array{name: string, description: string}, localBusiness: array{address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}}}
79 */
80 public static function get_defaults() {
81 return array(
82 'organization' => array(
83 'name' => self::text( get_bloginfo( 'name' ) ),
84 'description' => self::text( get_bloginfo( 'description' ) ),
85 ),
86 'localBusiness' => array(
87 'address' => self::woo_address_defaults(),
88 ),
89 );
90 }
91
92 /**
93 * The effective Organization settings the node consumes: stored overrides where
94 * present, site-identity defaults otherwise. `sameAs` / `email` are stored-only.
95 * Computed live so an unconfigured `name` / `description` tracks site identity.
96 *
97 * @return array{name: string, description: string, sameAs: array<int, string>, email: string}
98 */
99 public static function get_organization() {
100 $defaults = self::get_defaults();
101 $stored = self::get_stored();
102
103 $organization = $stored['organization'];
104 $fallbacks = $defaults['organization'];
105
106 return array(
107 'name' => '' !== $organization['name'] ? $organization['name'] : $fallbacks['name'],
108 'description' => '' !== $organization['description'] ? $organization['description'] : $fallbacks['description'],
109 'sameAs' => $organization['sameAs'],
110 'email' => $organization['email'],
111 );
112 }
113
114 /**
115 * The effective LocalBusiness settings the node consumes: stored values, with
116 * each address subfield falling back to WooCommerce store-address defaults when
117 * the stored value is empty. Other LocalBusiness properties are stored-only.
118 *
119 * @return array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}
120 */
121 public static function get_local_business() {
122 $defaults = self::get_defaults();
123 $stored = self::get_stored();
124
125 $local_business = $stored['localBusiness'];
126 $fallbacks = $defaults['localBusiness']['address'];
127
128 foreach ( array( 'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry' ) as $field ) {
129 if ( '' === $local_business['address'][ $field ] ) {
130 $local_business['address'][ $field ] = $fallbacks[ $field ];
131 }
132 }
133
134 return $local_business;
135 }
136
137 /**
138 * The BreadcrumbList settings consumed by the graph builder.
139 *
140 * @return array{enabled: bool}
141 */
142 public static function get_breadcrumb_list() {
143 return self::get_stored()['breadcrumbList'];
144 }
145
146 /**
147 * Sanitize a raw submission and persist it, then return the new editing payload
148 * (so the caller can hand it straight back to the client).
149 *
150 * @param mixed $raw Raw input (expected to be the container array).
151 * @return array{organization: array{name: string, description: string, sameAs: array<int, string>, email: string}, localBusiness: array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}, breadcrumbList: array{enabled: bool}, defaults: array{organization: array{name: string, description: string}, localBusiness: array{address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}}}}
152 */
153 public static function update( $raw ) {
154 $raw = is_array( $raw ) ? $raw : array();
155 $stored = self::get_stored();
156 foreach ( array( 'organization', 'localBusiness', 'breadcrumbList' ) as $section ) {
157 if ( ! array_key_exists( $section, $raw ) ) {
158 $raw[ $section ] = $stored[ $section ];
159 }
160 }
161
162 update_option( self::OPTION_NAME, self::sanitize( $raw ) );
163 return self::get_editable();
164 }
165
166 /**
167 * Normalize and sanitize raw input into the stored option shape: trimmed plain
168 * text for `name` / `description`, validated + deduped URLs for `sameAs`, a
169 * sanitized `email`, and normalized LocalBusiness fields. Defensive against
170 * non-array / non-string input.
171 *
172 * @param mixed $raw Raw input.
173 * @return array{organization: array{name: string, description: string, sameAs: array<int, string>, email: string}, localBusiness: array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}, breadcrumbList: array{enabled: bool}}
174 */
175 public static function sanitize( $raw ) {
176 $raw = is_array( $raw ) ? $raw : array();
177 $organization = isset( $raw['organization'] ) && is_array( $raw['organization'] )
178 ? $raw['organization']
179 : array();
180
181 return array(
182 'organization' => array(
183 'name' => self::text( $organization['name'] ?? '' ),
184 'description' => self::text( $organization['description'] ?? '' ),
185 'sameAs' => self::sanitize_url_list( $organization['sameAs'] ?? array() ),
186 'email' => self::email( $organization['email'] ?? '' ),
187 ),
188 'localBusiness' => self::sanitize_local_business( $raw['localBusiness'] ?? array() ),
189 'breadcrumbList' => self::sanitize_breadcrumb_list( $raw['breadcrumbList'] ?? array() ),
190 );
191 }
192
193 /**
194 * Normalize a list of profile URLs (`sameAs`): keep only valid absolute http(s)
195 * URLs and drop duplicates. Shared by the settings store and the Organization
196 * node so what the form stores is exactly what the schema graph emits.
197 *
198 * @param mixed $value Raw value (expected to be an array of URLs).
199 * @return array<int, string>
200 */
201 public static function sanitize_url_list( $value ) {
202 if ( ! is_array( $value ) ) {
203 return array();
204 }
205
206 $urls = array();
207 foreach ( $value as $url ) {
208 if ( ! is_string( $url ) ) {
209 continue;
210 }
211
212 $url = trim( $url );
213 if ( '' === $url ) {
214 continue;
215 }
216
217 // Absolute http(s) URLs with a host only. Deliberately not
218 // wp_http_validate_url(): that resolves DNS (gethostbyname), which
219 // blocks front-end rendering and drops valid-but-unresolvable hosts.
220 // These URLs are emitted as markup, never fetched.
221 $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
222 if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || ! wp_parse_url( $url, PHP_URL_HOST ) ) {
223 continue;
224 }
225
226 $clean = esc_url_raw( $url, array( 'http', 'https' ) );
227 if ( '' !== $clean ) {
228 $urls[] = $clean;
229 }
230 }
231
232 return array_values( array_unique( $urls ) );
233 }
234
235 /**
236 * The stored settings, normalized to the full option shape (so callers can
237 * rely on every key being present even when the option is absent or partial).
238 *
239 * @return array{organization: array{name: string, description: string, sameAs: array<int, string>, email: string}, localBusiness: array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}, breadcrumbList: array{enabled: bool}}
240 */
241 private static function get_stored() {
242 return self::sanitize( get_option( self::OPTION_NAME, array() ) );
243 }
244
245 /**
246 * Normalize BreadcrumbList input. Missing legacy data defaults to enabled.
247 *
248 * @param mixed $raw Raw BreadcrumbList input.
249 * @return array{enabled: bool}
250 */
251 private static function sanitize_breadcrumb_list( $raw ) {
252 $raw = is_array( $raw ) ? $raw : array();
253
254 return array(
255 'enabled' => ! array_key_exists( 'enabled', $raw ) || rest_sanitize_boolean( $raw['enabled'] ),
256 );
257 }
258
259 /**
260 * Normalize and sanitize raw LocalBusiness input into the stored option shape.
261 *
262 * @param mixed $raw Raw LocalBusiness input.
263 * @return array{enabled: bool, address: array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}, telephone: string, geo: array{latitude: string, longitude: string}, openingHours: array<string, array{opens: string, closes: string}>, priceRange: string}
264 */
265 private static function sanitize_local_business( $raw ) {
266 $raw = is_array( $raw ) ? $raw : array();
267 $address = isset( $raw['address'] ) && is_array( $raw['address'] ) ? $raw['address'] : array();
268
269 return array(
270 'enabled' => ! empty( $raw['enabled'] ),
271 'address' => array(
272 'streetAddress' => self::text( $address['streetAddress'] ?? '' ),
273 'addressLocality' => self::text( $address['addressLocality'] ?? '' ),
274 'addressRegion' => self::text( $address['addressRegion'] ?? '' ),
275 'postalCode' => self::text( $address['postalCode'] ?? '' ),
276 'addressCountry' => self::country_code( $address['addressCountry'] ?? '' ),
277 ),
278 'telephone' => self::telephone( $raw['telephone'] ?? '' ),
279 'geo' => self::geo( $raw['geo'] ?? array() ),
280 'openingHours' => self::opening_hours( $raw['openingHours'] ?? array() ),
281 'priceRange' => self::price_range( $raw['priceRange'] ?? '' ),
282 );
283 }
284
285 /**
286 * WooCommerce store-address defaults for LocalBusiness address placeholders.
287 *
288 * @return array{streetAddress: string, addressLocality: string, addressRegion: string, postalCode: string, addressCountry: string}
289 */
290 private static function woo_address_defaults() {
291 $address = array(
292 'streetAddress' => '',
293 'addressLocality' => '',
294 'addressRegion' => '',
295 'postalCode' => '',
296 'addressCountry' => '',
297 );
298
299 if ( ! class_exists( 'WooCommerce' ) ) {
300 return $address;
301 }
302
303 $street_1 = self::text( (string) get_option( 'woocommerce_store_address', '' ) );
304 $street_2 = self::text( (string) get_option( 'woocommerce_store_address_2', '' ) );
305 if ( '' !== $street_1 ) {
306 $address['streetAddress'] = $street_1;
307 }
308 if ( '' !== $street_2 ) {
309 $address['streetAddress'] = '' !== $address['streetAddress'] ? $address['streetAddress'] . ', ' . $street_2 : $street_2;
310 }
311
312 $address['addressLocality'] = self::text( (string) get_option( 'woocommerce_store_city', '' ) );
313 $address['postalCode'] = self::text( (string) get_option( 'woocommerce_store_postcode', '' ) );
314
315 $country_region = explode( ':', self::text( (string) get_option( 'woocommerce_default_country', '' ) ), 2 );
316 $address['addressCountry'] = self::country_code( $country_region[0] ?? '' );
317 $address['addressRegion'] = $country_region[1] ?? '';
318
319 return $address;
320 }
321
322 /**
323 * Normalize latitude/longitude settings. A half-coordinate is useless, so any
324 * invalid or missing endpoint clears both.
325 *
326 * @param mixed $raw Raw geo input.
327 * @return array{latitude: string, longitude: string}
328 */
329 private static function geo( $raw ) {
330 $empty = array(
331 'latitude' => '',
332 'longitude' => '',
333 );
334
335 if ( ! is_array( $raw ) ) {
336 return $empty;
337 }
338
339 $latitude = self::coordinate( $raw['latitude'] ?? null, 90 );
340 $longitude = self::coordinate( $raw['longitude'] ?? null, 180 );
341
342 if ( null === $latitude || null === $longitude ) {
343 return $empty;
344 }
345
346 return array(
347 'latitude' => $latitude,
348 'longitude' => $longitude,
349 );
350 }
351
352 /**
353 * Validate and normalize one coordinate.
354 *
355 * @param mixed $value Raw coordinate.
356 * @param int $max Absolute allowed bound.
357 * @return string|null
358 */
359 private static function coordinate( $value, $max ) {
360 if ( ! is_scalar( $value ) ) {
361 return null;
362 }
363
364 $value = trim( (string) $value );
365 if ( '' === $value || ! is_numeric( $value ) ) {
366 return null;
367 }
368
369 $number = (float) $value;
370 if ( abs( $number ) > $max ) {
371 return null;
372 }
373
374 return $value;
375 }
376
377 /**
378 * Normalize opening hours to all seven schema.org day codes.
379 *
380 * @param mixed $raw Raw opening-hours input.
381 * @return array<string, array{opens: string, closes: string}>
382 */
383 private static function opening_hours( $raw ) {
384 $raw = is_array( $raw ) ? $raw : array();
385 $hours = array();
386
387 foreach ( self::OPENING_DAYS as $day ) {
388 $entry = isset( $raw[ $day ] ) && is_array( $raw[ $day ] ) ? $raw[ $day ] : array();
389 $opens = self::time( $entry['opens'] ?? '' );
390 $closes = self::time( $entry['closes'] ?? '' );
391
392 if ( '' === $opens || '' === $closes ) {
393 $opens = '';
394 $closes = '';
395 }
396
397 $hours[ $day ] = array(
398 'opens' => $opens,
399 'closes' => $closes,
400 );
401 }
402
403 return $hours;
404 }
405
406 /**
407 * Validate an `HH:MM` 24-hour time value.
408 *
409 * @param mixed $value Raw time value.
410 * @return string
411 */
412 private static function time( $value ) {
413 if ( ! is_string( $value ) ) {
414 return '';
415 }
416
417 $value = trim( $value );
418 return preg_match( '/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $value ) ? $value : '';
419 }
420
421 /**
422 * Normalize an optional ISO 3166-1 alpha-2 country code.
423 *
424 * @param mixed $value Raw country code.
425 * @return string
426 */
427 private static function country_code( $value ) {
428 $value = self::text( $value );
429 if ( '' === $value || 1 !== preg_match( '/^[A-Za-z]{2}$/D', $value ) ) {
430 return '';
431 }
432
433 return strtoupper( $value );
434 }
435
436 /**
437 * Normalize an optional telephone number using a permissive common format.
438 *
439 * @param mixed $value Raw telephone number.
440 * @return string
441 */
442 private static function telephone( $value ) {
443 $value = self::text( $value );
444 if ( '' === $value ) {
445 return '';
446 }
447
448 $has_valid_characters = 1 === preg_match( '/^\+?[0-9 ()-]+$/D', $value );
449 $has_digit = 1 === preg_match( '/[0-9]/', $value );
450
451 return $has_valid_characters && $has_digit ? $value : '';
452 }
453
454 /**
455 * Normalize an optional LocalBusiness price range.
456 *
457 * @param mixed $value Raw price range.
458 * @return string
459 */
460 private static function price_range( $value ) {
461 $value = self::text( $value );
462 return self::unicode_length( $value ) < 100 ? $value : '';
463 }
464
465 /**
466 * Count Unicode code points, including when the mbstring extension is absent.
467 *
468 * @param string $value Value to measure.
469 * @return int
470 */
471 private static function unicode_length( $value ) {
472 if ( function_exists( 'mb_strlen' ) ) {
473 return mb_strlen( $value, 'UTF-8' );
474 }
475
476 $length = preg_match_all( '/./us', $value, $matches );
477 return false === $length ? strlen( $value ) : $length;
478 }
479
480 /**
481 * Normalize a scalar value to trimmed plain text.
482 *
483 * @param mixed $value Raw value.
484 * @return string
485 */
486 private static function text( $value ) {
487 if ( ! is_string( $value ) ) {
488 return '';
489 }
490 return trim( wp_strip_all_tags( $value ) );
491 }
492
493 /**
494 * Normalize an email value.
495 *
496 * @param mixed $value Raw value.
497 * @return string
498 */
499 private static function email( $value ) {
500 if ( ! is_string( $value ) ) {
501 return '';
502 }
503 return sanitize_email( $value );
504 }
505 }
506