PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Helpers / FormatHelper.php

FormatHelper.php in Yatra – Travel Booking & Tour Operator Software 3.0.7, at app/Helpers/FormatHelper.php

668 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Helpers;
6
7 use Yatra\Services\SettingsService;
8
9 /**
10 * Format Helper
11 *
12 * Utility functions for formatting data.
13 * All methods are static for easy access throughout the application.
14 *
15 * Usage: FormatHelper::formatPrice(100, 'USD')
16 *
17 * @package Yatra\Helpers
18 */
19 class FormatHelper
20 {
21 // Currency symbols are now managed by CurrencyHelper
22
23 /**
24 * Country codes to names mapping
25 */
26 private const COUNTRY_NAMES = [
27 'AF' => 'Afghanistan', 'AL' => 'Albania', 'DZ' => 'Algeria', 'AD' => 'Andorra',
28 'AO' => 'Angola', 'AG' => 'Antigua and Barbuda', 'AR' => 'Argentina', 'AM' => 'Armenia',
29 'AU' => 'Australia', 'AT' => 'Austria', 'AZ' => 'Azerbaijan', 'BS' => 'Bahamas',
30 'BH' => 'Bahrain', 'BD' => 'Bangladesh', 'BB' => 'Barbados', 'BY' => 'Belarus',
31 'BE' => 'Belgium', 'BZ' => 'Belize', 'BJ' => 'Benin', 'BT' => 'Bhutan',
32 'BO' => 'Bolivia', 'BA' => 'Bosnia and Herzegovina', 'BW' => 'Botswana', 'BR' => 'Brazil',
33 'BN' => 'Brunei', 'BG' => 'Bulgaria', 'BF' => 'Burkina Faso', 'BI' => 'Burundi',
34 'KH' => 'Cambodia', 'CM' => 'Cameroon', 'CA' => 'Canada', 'CV' => 'Cape Verde',
35 'CF' => 'Central African Republic', 'TD' => 'Chad', 'CL' => 'Chile', 'CN' => 'China',
36 'CO' => 'Colombia', 'KM' => 'Comoros', 'CG' => 'Congo', 'CD' => 'DR Congo',
37 'CR' => 'Costa Rica', 'CI' => 'Ivory Coast', 'HR' => 'Croatia', 'CU' => 'Cuba',
38 'CY' => 'Cyprus', 'CZ' => 'Czech Republic', 'DK' => 'Denmark', 'DJ' => 'Djibouti',
39 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'EG' => 'Egypt',
40 'SV' => 'El Salvador', 'GQ' => 'Equatorial Guinea', 'ER' => 'Eritrea', 'EE' => 'Estonia',
41 'SZ' => 'Eswatini', 'ET' => 'Ethiopia', 'FJ' => 'Fiji', 'FI' => 'Finland',
42 'FR' => 'France', 'GA' => 'Gabon', 'GM' => 'Gambia', 'GE' => 'Georgia',
43 'DE' => 'Germany', 'GH' => 'Ghana', 'GR' => 'Greece', 'GD' => 'Grenada',
44 'GT' => 'Guatemala', 'GN' => 'Guinea', 'GW' => 'Guinea-Bissau', 'GY' => 'Guyana',
45 'HT' => 'Haiti', 'HN' => 'Honduras', 'HU' => 'Hungary', 'IS' => 'Iceland',
46 'IN' => 'India', 'ID' => 'Indonesia', 'IR' => 'Iran', 'IQ' => 'Iraq',
47 'IE' => 'Ireland', 'IL' => 'Israel', 'IT' => 'Italy', 'JM' => 'Jamaica',
48 'JP' => 'Japan', 'JO' => 'Jordan', 'KZ' => 'Kazakhstan', 'KE' => 'Kenya',
49 'KI' => 'Kiribati', 'KP' => 'North Korea', 'KR' => 'South Korea', 'KW' => 'Kuwait',
50 'KG' => 'Kyrgyzstan', 'LA' => 'Laos', 'LV' => 'Latvia', 'LB' => 'Lebanon',
51 'LS' => 'Lesotho', 'LR' => 'Liberia', 'LY' => 'Libya', 'LI' => 'Liechtenstein',
52 'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'MG' => 'Madagascar', 'MW' => 'Malawi',
53 'MY' => 'Malaysia', 'MV' => 'Maldives', 'ML' => 'Mali', 'MT' => 'Malta',
54 'MH' => 'Marshall Islands', 'MR' => 'Mauritania', 'MU' => 'Mauritius', 'MX' => 'Mexico',
55 'FM' => 'Micronesia', 'MD' => 'Moldova', 'MC' => 'Monaco', 'MN' => 'Mongolia',
56 'ME' => 'Montenegro', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'MM' => 'Myanmar',
57 'NA' => 'Namibia', 'NR' => 'Nauru', 'NP' => 'Nepal', 'NL' => 'Netherlands',
58 'NZ' => 'New Zealand', 'NI' => 'Nicaragua', 'NE' => 'Niger', 'NG' => 'Nigeria',
59 'MK' => 'North Macedonia', 'NO' => 'Norway', 'OM' => 'Oman', 'PK' => 'Pakistan',
60 'PW' => 'Palau', 'PS' => 'Palestine', 'PA' => 'Panama', 'PG' => 'Papua New Guinea',
61 'PY' => 'Paraguay', 'PE' => 'Peru', 'PH' => 'Philippines', 'PL' => 'Poland',
62 'PT' => 'Portugal', 'QA' => 'Qatar', 'RO' => 'Romania', 'RU' => 'Russia',
63 'RW' => 'Rwanda', 'KN' => 'Saint Kitts and Nevis', 'LC' => 'Saint Lucia',
64 'VC' => 'Saint Vincent and the Grenadines', 'WS' => 'Samoa', 'SM' => 'San Marino',
65 'ST' => 'Sao Tome and Principe', 'SA' => 'Saudi Arabia', 'SN' => 'Senegal',
66 'RS' => 'Serbia', 'SC' => 'Seychelles', 'SL' => 'Sierra Leone', 'SG' => 'Singapore',
67 'SK' => 'Slovakia', 'SI' => 'Slovenia', 'SB' => 'Solomon Islands', 'SO' => 'Somalia',
68 'ZA' => 'South Africa', 'SS' => 'South Sudan', 'ES' => 'Spain', 'LK' => 'Sri Lanka',
69 'SD' => 'Sudan', 'SR' => 'Suriname', 'SE' => 'Sweden', 'CH' => 'Switzerland',
70 'SY' => 'Syria', 'TW' => 'Taiwan', 'TJ' => 'Tajikistan', 'TZ' => 'Tanzania',
71 'TH' => 'Thailand', 'TL' => 'Timor-Leste', 'TG' => 'Togo', 'TO' => 'Tonga',
72 'TT' => 'Trinidad and Tobago', 'TN' => 'Tunisia', 'TR' => 'Turkey', 'TM' => 'Turkmenistan',
73 'TV' => 'Tuvalu', 'UG' => 'Uganda', 'UA' => 'Ukraine', 'AE' => 'United Arab Emirates',
74 'GB' => 'United Kingdom', 'US' => 'United States', 'UY' => 'Uruguay', 'UZ' => 'Uzbekistan',
75 'VU' => 'Vanuatu', 'VA' => 'Vatican City', 'VE' => 'Venezuela', 'VN' => 'Vietnam',
76 'YE' => 'Yemen', 'ZM' => 'Zambia', 'ZW' => 'Zimbabwe',
77 // ISO-3166-1 territories and special regions added so customers
78 // from common tourism markets (Hong Kong, Puerto Rico, Greenland,
79 // Gibraltar, Faroe Islands, etc.) can select their location.
80 'HK' => 'Hong Kong', 'MO' => 'Macao', 'PR' => 'Puerto Rico',
81 'GI' => 'Gibraltar', 'GL' => 'Greenland', 'FO' => 'Faroe Islands',
82 'GG' => 'Guernsey', 'IM' => 'Isle of Man', 'JE' => 'Jersey',
83 'AX' => 'Aland Islands', 'SJ' => 'Svalbard and Jan Mayen',
84 'BM' => 'Bermuda', 'KY' => 'Cayman Islands', 'AI' => 'Anguilla',
85 'AW' => 'Aruba', 'CW' => 'Curacao', 'SX' => 'Sint Maarten',
86 'BQ' => 'Bonaire, Sint Eustatius and Saba',
87 'MS' => 'Montserrat', 'VG' => 'British Virgin Islands',
88 'TC' => 'Turks and Caicos Islands',
89 'BL' => 'Saint Barthelemy', 'MF' => 'Saint Martin (French)',
90 'PM' => 'Saint Pierre and Miquelon',
91 'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
92 'FK' => 'Falkland Islands',
93 'GS' => 'South Georgia and the South Sandwich Islands',
94 'PF' => 'French Polynesia', 'NC' => 'New Caledonia',
95 'WF' => 'Wallis and Futuna', 'CK' => 'Cook Islands',
96 'NU' => 'Niue', 'TK' => 'Tokelau',
97 'GP' => 'Guadeloupe', 'MQ' => 'Martinique',
98 'RE' => 'Reunion', 'YT' => 'Mayotte',
99 'AS' => 'American Samoa', 'GU' => 'Guam',
100 'MP' => 'Northern Mariana Islands', 'VI' => 'U.S. Virgin Islands',
101 'UM' => 'U.S. Minor Outlying Islands',
102 'TF' => 'French Southern Territories',
103 'IO' => 'British Indian Ocean Territory', 'BV' => 'Bouvet Island',
104 'HM' => 'Heard Island and McDonald Islands',
105 'AQ' => 'Antarctica', 'PN' => 'Pitcairn',
106 'EH' => 'Western Sahara', 'XK' => 'Kosovo',
107 ];
108
109 /**
110 * Format price with currency
111 *
112 * @param float $amount Amount to format
113 * @param string $currency Currency code (default: USD)
114 * @param bool $showCode Show currency code alongside symbol
115 * @return string Formatted price
116 */
117 public static function formatPrice(float $amount, string $currency = 'USD', bool $showCode = false): string
118 {
119 if (function_exists('yatra_format_price')) {
120 $main = yatra_format_price($amount, $currency, false);
121 } else {
122 $main = CurrencyHelper::format($amount, $currency, false);
123 }
124
125 if ($showCode) {
126 return $main . ' ' . strtoupper($currency);
127 }
128
129 return $main;
130 }
131
132 /**
133 * Get currency symbol
134 *
135 * @param string $currency Currency code
136 * @return string Currency symbol
137 */
138 public static function getCurrencySymbol(string $currency): string
139 {
140 return CurrencyHelper::getSymbol($currency);
141 }
142
143 /**
144 * Format date for display
145 *
146 * @param string $date Date string
147 * @param string $format PHP date format (default: from WordPress settings)
148 * @return string Formatted date
149 */
150 public static function formatDate(string $date, string $format = ''): string
151 {
152 if (empty($date)) {
153 return '';
154 }
155
156 $format = $format ?: SettingsService::getString('date_format', (string) get_option('date_format', 'F j, Y'));
157
158 $timestamp = strtotime($date);
159 if ($timestamp === false) {
160 return $date;
161 }
162
163 return self::formatTimestampWithTz($timestamp, $format);
164 }
165
166 /**
167 * Format datetime for display
168 *
169 * @param string $datetime Datetime string
170 * @param string $format PHP date format
171 * @return string Formatted datetime
172 */
173 public static function formatDateTime(string $datetime, string $format = ''): string
174 {
175 if (empty($datetime)) {
176 return '';
177 }
178
179 $dateFormat = SettingsService::getString('date_format', (string) get_option('date_format', 'F j, Y'));
180 $timeFormat = SettingsService::getString('time_format', (string) get_option('time_format', 'g:i a'));
181 $format = $format ?: $dateFormat . ' ' . $timeFormat;
182
183 $timestamp = strtotime($datetime);
184 if ($timestamp === false) {
185 return $datetime;
186 }
187
188 return self::formatTimestampWithTz($timestamp, $format);
189 }
190
191 /**
192 * Format time for display
193 *
194 * @param string $time Time string (e.g., "14:30")
195 * @return string Formatted time (e.g., "2:30 PM")
196 */
197 public static function formatTime(string $time): string
198 {
199 return self::formatTimeForDisplay($time);
200 }
201
202 /**
203 * Format time for display using plugin settings (fallback to WordPress settings)
204 *
205 * @param string $time Time string (e.g., "14:30")
206 * @return string Formatted time (e.g., "2:30 PM")
207 */
208 public static function formatTimeForDisplay(string $time): string
209 {
210 if (empty($time)) {
211 return '';
212 }
213
214 $timestamp = strtotime($time);
215 if ($timestamp === false) {
216 return $time;
217 }
218
219 $format = SettingsService::getString('time_format', (string) get_option('time_format', 'g:i a'));
220 return self::formatTimestampWithTz($timestamp, $format);
221 }
222
223 /**
224 * Format a timestamp using Yatra timezone when set.
225 *
226 * @param int $timestamp Unix timestamp
227 * @param string $format PHP date format
228 * @return string
229 */
230 private static function formatTimestampWithTz(int $timestamp, string $format): string
231 {
232 $tz = trim((string) SettingsService::getString('timezone', ''));
233 if ($tz === '') {
234 // WP site timezone fallback
235 return function_exists('wp_date')
236 ? wp_date($format, $timestamp)
237 : date_i18n($format, $timestamp);
238 }
239
240 try {
241 $dtz = new \DateTimeZone($tz);
242 } catch (\Exception $e) {
243 return function_exists('wp_date')
244 ? wp_date($format, $timestamp)
245 : date_i18n($format, $timestamp);
246 }
247
248 if (function_exists('wp_date')) {
249 return wp_date($format, $timestamp, $dtz);
250 }
251
252 // Older WP fallback: shift timestamp into requested timezone via DateTime.
253 $d = new \DateTime('@' . $timestamp);
254 $d->setTimezone($dtz);
255 return $d->format($format);
256 }
257
258 /**
259 * Get relative time (e.g., "2 hours ago")
260 *
261 * @param string $datetime Datetime string
262 * @return string Relative time
263 */
264 public static function timeAgo(string $datetime): string
265 {
266 if (empty($datetime)) {
267 return '';
268 }
269
270 $timestamp = strtotime($datetime);
271 if ($timestamp === false) {
272 return $datetime;
273 }
274
275 return human_time_diff($timestamp, current_time('timestamp')) . ' ' . __('ago', 'yatra');
276 }
277
278 /**
279 * Get country name from code
280 *
281 * @param string $code Country code (e.g., "US")
282 * @return string Country name
283 */
284 public static function getCountryName(string $code): string
285 {
286 $code = strtoupper($code);
287 return self::COUNTRY_NAMES[$code] ?? $code;
288 }
289
290 /**
291 * Canonical country list — single source of truth used by every
292 * country / nationality dropdown in both Free and Pro plugins.
293 *
294 * Returns the full ISO-3166-1 alpha-2 set (sovereign states +
295 * dependent territories + commonly-accepted regions). Sorted
296 * alphabetically by name so the rendered dropdown is browseable
297 * without operators having to scan a code-ordered list.
298 *
299 * Filterable via `yatra_countries_list` for operators that want
300 * to:
301 * - Prepend "popular" entries (US, GB, IN, etc.) above an
302 * `---` separator for faster picking
303 * - Remove territories that don't apply to their market
304 * - Rename a region (e.g. business-language preferences)
305 *
306 * @return array<string, string> [ISO-3166 code => display name]
307 */
308 public static function getCountries(): array
309 {
310 $countries = self::COUNTRY_NAMES;
311
312 // Stable alphabetical sort by display name. Operators expect
313 // "Argentina" before "Australia" before "Austria" — code-order
314 // (AR/AU/AT) is computer-friendly but not human-friendly.
315 asort($countries, SORT_STRING | SORT_FLAG_CASE);
316
317 if (\function_exists('apply_filters')) {
318 $filtered = apply_filters('yatra_countries_list', $countries);
319 if (\is_array($filtered) && $filtered !== []) {
320 return $filtered;
321 }
322 }
323 return $countries;
324 }
325
326 /**
327 * Format phone number for display
328 *
329 * @param string $phone Phone number
330 * @return string Formatted phone
331 */
332 public static function formatPhone(string $phone): string
333 {
334 // Remove non-numeric characters except + and spaces
335 $phone = preg_replace('/[^\d\+\s\-\(\)]/', '', $phone);
336 return trim($phone);
337 }
338
339 /**
340 * Format duration (days/nights)
341 *
342 * @param int $days Number of days
343 * @param int|null $nights Number of nights (optional)
344 * @return string Formatted duration
345 */
346 public static function formatDuration(int $days, ?int $nights = null): string
347 {
348 if ($nights !== null) {
349 return sprintf(
350 _n('%d Day', '%d Days', $days, 'yatra'),
351 $days
352 ) . ' / ' . sprintf(
353 _n('%d Night', '%d Nights', $nights, 'yatra'),
354 $nights
355 );
356 }
357
358 return sprintf(_n('%d Day', '%d Days', $days, 'yatra'), $days);
359 }
360
361 /**
362 * Format file size
363 *
364 * @param int $bytes File size in bytes
365 * @return string Formatted size
366 */
367 public static function formatFileSize(int $bytes): string
368 {
369 $units = ['B', 'KB', 'MB', 'GB', 'TB'];
370 $unitIndex = 0;
371
372 while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
373 $bytes /= 1024;
374 $unitIndex++;
375 }
376
377 return round($bytes, 2) . ' ' . $units[$unitIndex];
378 }
379
380 /**
381 * Truncate text with ellipsis
382 *
383 * @param string $text Text to truncate
384 * @param int $length Maximum length
385 * @param string $suffix Suffix to add (default: ...)
386 * @return string Truncated text
387 */
388 public static function truncate(string $text, int $length = 100, string $suffix = '...'): string
389 {
390 if (mb_strlen($text) <= $length) {
391 return $text;
392 }
393
394 return mb_substr($text, 0, $length - mb_strlen($suffix)) . $suffix;
395 }
396
397 /**
398 * Generate excerpt from HTML content
399 *
400 * @param string $html HTML content
401 * @param int $length Maximum length
402 * @return string Plain text excerpt
403 */
404 public static function excerpt(string $html, int $length = 150): string
405 {
406 $text = wp_strip_all_tags($html);
407 $text = preg_replace('/\s+/', ' ', $text);
408 $text = trim($text);
409
410 return self::truncate($text, $length);
411 }
412
413 /**
414 * Format rating as stars HTML
415 *
416 * @param float $rating Rating (0-5)
417 * @param bool $showNumber Show rating number
418 * @return string HTML stars
419 */
420 public static function formatRatingStars(float $rating, bool $showNumber = false): string
421 {
422 $rating = max(0, min(5, $rating));
423 $fullStars = (int) floor($rating);
424 $hasHalfStar = ($rating - $fullStars) >= 0.5;
425
426 $html = '<span class="yatra-rating-stars">';
427
428 for ($i = 1; $i <= 5; $i++) {
429 if ($i <= $fullStars) {
430 $html .= '<span class="yatra-star filled">�
431 </span>';
432 } elseif ($i === $fullStars + 1 && $hasHalfStar) {
433 $html .= '<span class="yatra-star half">�
434 </span>';
435 } else {
436 $html .= '<span class="yatra-star">�
437 </span>';
438 }
439 }
440
441 $html .= '</span>';
442
443 if ($showNumber) {
444 $html .= '<span class="yatra-rating-number">' . number_format($rating, 1) . '</span>';
445 }
446
447 return $html;
448 }
449
450 /**
451 * Format booking status as badge
452 *
453 * @param string $status Booking status
454 * @return string HTML badge
455 */
456 public static function formatStatusBadge(string $status): string
457 {
458 $statusClasses = [
459 'pending' => 'yatra-badge yatra-badge-warning',
460 'confirmed' => 'yatra-badge yatra-badge-success',
461 'processing' => 'yatra-badge yatra-badge-info',
462 'completed' => 'yatra-badge yatra-badge-success',
463 'cancelled' => 'yatra-badge yatra-badge-danger',
464 'refunded' => 'yatra-badge yatra-badge-secondary',
465 'failed' => 'yatra-badge yatra-badge-danger',
466 'on_hold' => 'yatra-badge yatra-badge-warning',
467 ];
468
469 $class = $statusClasses[$status] ?? 'yatra-badge';
470 $label = ucfirst(str_replace('_', ' ', $status));
471
472 return '<span class="' . esc_attr($class) . '">' . esc_html($label) . '</span>';
473 }
474
475 /**
476 * Sanitize and format slug
477 *
478 * @param string $text Text to slugify
479 * @return string URL-safe slug
480 */
481 public static function slugify(string $text): string
482 {
483 return sanitize_title($text);
484 }
485
486 /**
487 * Convert array to HTML attributes string
488 *
489 * @param array $attributes Key-value pairs
490 * @return string HTML attributes
491 */
492 public static function arrayToAttributes(array $attributes): string
493 {
494 $html = [];
495
496 foreach ($attributes as $key => $value) {
497 if ($value === true) {
498 $html[] = esc_attr($key);
499 } elseif ($value !== false && $value !== null) {
500 $html[] = esc_attr($key) . '="' . esc_attr($value) . '"';
501 }
502 }
503
504 return implode(' ', $html);
505 }
506
507 /**
508 * Sanitize Quill editor HTML output
509 *
510 * This function sanitizes HTML content from the Quill rich text editor,
511 * allowing only safe HTML tags and attributes that match the Quill toolbar configuration.
512 *
513 * Allowed features based on Quill toolbar:
514 * - Headers: h1, h2, h3
515 * - Text formatting: bold, italic, underline, strike
516 * - Lists: ordered (ol, li), unordered (ul, li)
517 * - Alignment: text-align attribute on p tags
518 * - Links: a tags with href attribute
519 * - Paragraphs: p tags
520 *
521 * @param string $html Raw HTML from Quill editor
522 * @return string Sanitized HTML safe for database storage
523 */
524 public static function sanitizeQuillHtml(string $html): string
525 {
526 // Return empty string if input is empty or just whitespace
527 if (empty(trim($html)) || $html === '<p><br></p>') {
528 return '';
529 }
530
531 // Define allowed HTML tags and attributes based on Quill configuration
532 $allowed_tags = [
533 // Headers (from Quill header dropdown: 1, 2, 3)
534 'h1' => [],
535 'h2' => [],
536 'h3' => [],
537
538 // Paragraphs with alignment support
539 'p' => [
540 'style' => true, // For text-align
541 'class' => true, // Quill may add alignment classes
542 ],
543
544 // Text formatting (bold, italic, underline, strike)
545 'strong' => [],
546 'b' => [],
547 'em' => [],
548 'i' => [],
549 'u' => [],
550 's' => [],
551 'strike' => [],
552
553 // Lists (ordered and unordered)
554 'ol' => [],
555 'ul' => [],
556 'li' => [],
557
558 // Links
559 'a' => [
560 'href' => true,
561 'title' => true,
562 'target' => true,
563 'rel' => true,
564 ],
565
566 // Line breaks
567 'br' => [],
568 ];
569
570 // Use wp_kses to sanitize with allowed tags
571 $sanitized = wp_kses($html, $allowed_tags);
572
573 // Additional cleanup for alignment styles
574 // Only allow text-align in style attribute
575 $sanitized = preg_replace_callback(
576 '/style="([^"]*)"/i',
577 function ($matches) {
578 $styles = $matches[1];
579 // Extract only text-align property
580 if (preg_match('/text-align:\s*(left|center|right|justify)/i', $styles, $align)) {
581 return 'style="text-align: ' . esc_attr($align[1]) . '"';
582 }
583 return ''; // Remove style attribute if no valid text-align
584 },
585 $sanitized
586 );
587
588 // Ensure links have proper rel attribute for security
589 $sanitized = preg_replace_callback(
590 '/<a\s+([^>]*?)>/i',
591 function ($matches) {
592 $attrs = $matches[1];
593 // If target="_blank" exists, ensure rel="noopener noreferrer"
594 if (stripos($attrs, 'target="_blank"') !== false) {
595 if (stripos($attrs, 'rel=') === false) {
596 $attrs .= ' rel="noopener noreferrer"';
597 } elseif (stripos($attrs, 'noopener') === false || stripos($attrs, 'noreferrer') === false) {
598 $attrs = preg_replace(
599 '/rel="([^"]*)"/i',
600 'rel="$1 noopener noreferrer"',
601 $attrs
602 );
603 }
604 }
605 return '<a ' . $attrs . '>';
606 },
607 $sanitized
608 );
609
610 // Remove empty paragraphs and normalize whitespace
611 $sanitized = preg_replace('/<p[^>]*>(\s|&nbsp;|<br\s*\/?>)*<\/p>/i', '', $sanitized);
612
613 // Trim whitespace
614 $sanitized = trim($sanitized);
615
616 return $sanitized;
617 }
618
619 /**
620 * Prepare Quill HTML for display (output escaping)
621 *
622 * Use this when outputting sanitized Quill content to the frontend.
623 * This assumes the content was already sanitized with sanitizeQuillHtml() before storage.
624 *
625 * @param string $html Sanitized HTML from database
626 * @return string HTML safe for display
627 */
628 public static function displayQuillHtml(string $html): string
629 {
630 if (empty($html)) {
631 return '';
632 }
633
634 // Apply WordPress content filters (auto-paragraphs, shortcodes, etc.)
635 // But skip wpautop since Quill already handles paragraphs
636 remove_filter('the_content', 'wpautop');
637 $content = apply_filters('the_content', $html);
638 add_filter('the_content', 'wpautop');
639
640 return $content;
641 }
642
643 /**
644 * Strip all HTML tags from Quill content (for excerpts, meta descriptions, etc.)
645 *
646 * @param string $html Quill HTML content
647 * @return string Plain text
648 */
649 public static function quillToPlainText(string $html): string
650 {
651 if (empty($html)) {
652 return '';
653 }
654
655 // Remove all HTML tags
656 $text = wp_strip_all_tags($html);
657
658 // Normalize whitespace
659 $text = preg_replace('/\s+/', ' ', $text);
660
661 // Trim
662 $text = trim($text);
663
664 return $text;
665 }
666 }
667
668