PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.1
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Services / Renderer / ProductCardRender.php

ProductCardRender.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.1, at app/Services/Renderer/ProductCardRender.php

603 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Models\ProductVariation;
9 use FluentCart\App\Modules\Templating\AssetLoader;
10 use FluentCart\App\Vite;
11 use FluentCart\Framework\Support\Arr;
12
13 class ProductCardRender
14 {
15 protected $product;
16
17 protected $viewUrl = '';
18
19 protected $config = [];
20
21 public function __construct(Product $product, $config = [])
22 {
23 $this->product = $product;
24 $this->viewUrl = $product->view_url;
25 $this->config = $config;
26 }
27
28 public function renderWrapperStart()
29 {
30
31 }
32
33 public function renderWrapperEnd()
34 {
35
36 }
37
38 public function render()
39 {
40 AssetLoader::loadSingleProductAssets();
41 // Lets extensions (e.g. Pro's advanced-variation selector) enqueue the
42 // CSS/JS a product purchase UI needs. Backs the card surfaces that go
43 // through this renderer's render() — Shop, Product List, the product
44 // shortcode, and the quick-view modal. Surfaces that render card parts
45 // via the individual render* methods instead of render() (Product
46 // Carousel, Related Products) fire this same hook from their own block
47 // render. Guarded with a static flag so a grid of N cards fires it once
48 // per request, not once per card. No payload — assets are uniform.
49 static $assetsHookFired = false;
50 if (!$assetsHookFired) {
51 $assetsHookFired = true;
52 do_action('fluent_cart/advanced_variation/enqueue_assets');
53 }
54 $cursor = '';
55 if (!empty($this->config['cursor'])) {
56 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
57 }
58
59 $cardWidth = '';
60 $cardWidthValue = Arr::get($this->config, 'card_width', '');
61 if ($cardWidthValue) {
62 $widthValue = $cardWidthValue === '100%' ? '100%' : intval($cardWidthValue) . 'px';
63 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
64 }
65
66
67 ?>
68 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
69 class="fct-product-card"
70 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
72 aria-label="<?php echo esc_attr(sprintf(
73 /* translators: %s: product title */
74 __('%s product card', 'fluent-cart'), $this->product->post_title));
75 ?>">
76 <?php $this->renderProductImage(); ?>
77 <?php $this->renderTitle(); ?>
78 <?php $this->renderExcerpt(); ?>
79 <?php $this->renderPrices(); ?>
80 <?php $this->showBuyButton(); ?>
81 </article>
82 <?php
83 }
84
85 /**
86 * Build package info string from snapshotted other_info fields.
87 *
88 * @param array $otherInfo Order item's other_info array
89 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 */
91 public static function buildPackageInfoFromOtherInfo($otherInfo)
92 {
93 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
94 $parts = [];
95
96 // Package name
97 $name = Arr::get($otherInfo, 'package_name', '');
98 if ($name) {
99 $parts[] = $name;
100 }
101
102 // Dimensions
103 $length = Arr::get($otherInfo, 'package_length', '');
104 $width = Arr::get($otherInfo, 'package_width', '');
105 $height = Arr::get($otherInfo, 'package_height', '');
106 $dimensionUnit = Arr::get($otherInfo, 'package_dimension_unit', 'cm');
107 $dimParts = array_filter([$length, $width, $height], function ($val) {
108 return $val !== '' && $val !== null && $val != 0;
109 });
110 if ($dimParts) {
111 $parts[] = implode(' × ', $dimParts) . ' ' . $dimensionUnit;
112 }
113
114 // Product weight
115 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
116 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
117 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
118 if ($convertedProductWeight) {
119 $formatted = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
120 $parts[] = __('Wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
121 }
122
123 // Shipping weight (product + package)
124 $packageWeight = floatval(Arr::get($otherInfo, 'package_weight', 0));
125 $packageWeightUnit = Arr::get($otherInfo, 'package_weight_unit', $storeWeightUnit);
126 $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
127 $totalWeight = $convertedProductWeight + $convertedPackageWeight;
128 if ($totalWeight && $convertedPackageWeight) {
129 $formatted = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
130 $parts[] = __('Shipping wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
131 }
132
133 $info = implode(' · ', $parts);
134
135 return $info ? __('Package:', 'fluent-cart') . ' ' . $info : '';
136 }
137
138 public function renderPackageDescription(
139 $wrapper_attributes = '',
140 $showName = true,
141 $showDimensions = true,
142 $showProductWeight = true,
143 $showTotalWeight = true,
144 $variant = null
145 ) {
146 $variant = $variant ?: $this->product->variants->first();
147
148 if (!$variant) {
149 return;
150 }
151
152 if (!$wrapper_attributes) {
153 $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
154 }
155
156 $this->renderPackageDescriptionForVariant($variant, $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight);
157 }
158
159 private function renderPackageDescriptionForVariant(
160 ProductVariation $variant,
161 $wrapper_attributes,
162 $showName,
163 $showDimensions,
164 $showProductWeight,
165 $showTotalWeight
166 ) {
167 if ($variant->fulfillment_type !== 'physical') {
168 return;
169 }
170
171 $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
172 $package = Helper::getPackageBySlug($packageSlug);
173
174 if (!$package) {
175 return;
176 }
177
178 $name = Arr::get($package, 'name', '');
179 $length = Arr::get($package, 'length', '');
180 $width = Arr::get($package, 'width', '');
181 $height = Arr::get($package, 'height', '');
182 $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
183 $packageWeight = floatval(Arr::get($package, 'weight', 0));
184 $packageWeightUnit = Arr::get($package, 'weight_unit', 'kg');
185
186 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
187 $otherInfo = $variant->other_info ?: [];
188 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
189 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
190
191 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
192 $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
193 $totalWeight = $convertedProductWeight + $convertedPackageWeight;
194
195 $hasVisibleContent = ($showName && $name)
196 || ($showDimensions && ($length || $width || $height))
197 || ($showProductWeight && $convertedProductWeight)
198 || ($showTotalWeight && $totalWeight);
199
200 if (!$hasVisibleContent) {
201 return;
202 }
203
204 echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
205 echo '<table class="fct-package-description__table" role="presentation"><tbody>';
206
207 if ($showName && $name) {
208 echo sprintf(
209 '<tr><th>%s</th><td>%s</td></tr>',
210 esc_html__('Package', 'fluent-cart'),
211 esc_html($name)
212 );
213 }
214
215 if ($showDimensions && ($length || $width || $height)) {
216 $dimensionParts = array_filter([$length, $width, $height], function ($val) {
217 return $val !== '' && $val !== null;
218 });
219 if ($dimensionParts) {
220 $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
221 echo sprintf(
222 '<tr><th>%s</th><td>%s</td></tr>',
223 esc_html__('Dimensions', 'fluent-cart'),
224 esc_html($formattedDimensions)
225 );
226 }
227 }
228
229 if ($showProductWeight && $convertedProductWeight) {
230 $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
231 echo sprintf(
232 '<tr><th>%s</th><td>%s</td></tr>',
233 esc_html__('Weight', 'fluent-cart'),
234 esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
235 );
236 }
237
238 if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
239 $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
240 echo sprintf(
241 '<tr><th>%s</th><td>%s</td></tr>',
242 esc_html__('Shipping Weight', 'fluent-cart'),
243 esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
244 );
245 }
246
247 echo '</tbody></table>';
248 echo '</div>';
249 }
250
251 public function renderExcerpt($atts = '')
252 {
253 if (empty($this->product->post_excerpt)) {
254 return;
255 }
256
257 echo sprintf(
258 '<p %1$s class="fct-product-card-excerpt">
259 %2$s
260 </p>',
261 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
262 wp_kses_post($this->product->post_excerpt),
263 );
264 }
265
266 public function renderTitle($atts = '', $config = [])
267 {
268 $link = Arr::get($config, 'isLink', true);
269 $target = Arr::get($config, 'target', '_self');
270
271 $titleText = esc_html($this->product->post_title);
272
273 if ($link) {
274 // Render as link
275 $targetAttr = $target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : '';
276 echo sprintf(
277 '<h3 class="fct-product-card-title">
278 <a %1$s data-fluent-cart-product-link
279 data-product-id="%2$s"
280 href="%3$s"
281 aria-label="%4$s"
282 %5$s>%6$s</a>
283 </h3>',
284 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
285 esc_attr($this->product->ID),
286 esc_url($this->product->view_url),
287 esc_attr($this->product->post_title),
288 $targetAttr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
289 esc_html($titleText)
290 );
291 } else {
292 // Render as plain text (no link)
293 echo sprintf(
294 '<h3 class="fct-product-card-title" %1$s>%2$s</h3>',
295 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
296 esc_html($titleText)
297 );
298 }
299 }
300
301 public function renderProductImage()
302 {
303 $image = $this->product->thumbnail;
304 $isPlaceholder = false;
305
306 if (!$image) {
307 $image = Vite::getAssetUrl('images/placeholder.svg');
308 $isPlaceholder = true;
309 }
310
311 $altText = $isPlaceholder
312 ? sprintf(
313 /* translators: %s: product title */
314 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
315 : $this->product->post_title;
316
317 do_action('fluent_cart/product/group/before_image_block', [
318 'product' => $this->product,
319 'scope' => 'product_card'
320 ]);
321 ?>
322 <a class="fct-product-card-image-wrap"
323 href="<?php echo esc_url($this->viewUrl); ?>"
324 style="display: block;"
325 aria-label="<?php echo esc_attr(sprintf(
326 /* translators: %s: product title */
327 __('View %s product image', 'fluent-cart'), $this->product->post_title)); ?>">
328 <img class="fct-product-card-image"
329 data-fluent-cart-shop-app-single-product-image
330 src="<?php echo esc_url($image); ?>"
331 alt="<?php echo esc_attr($altText); ?>"
332 loading="lazy"
333 width="300"
334 height="300"/>
335 </a>
336 <?php
337
338 do_action('fluent_cart/product/group/after_image_block', [
339 'product' => $this->product,
340 'scope' => 'product_card'
341 ]);
342 }
343
344 public function renderPrices($wrapper_attributes = '')
345 {
346 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
347 $isSimple = $this->product->detail->variation_type === 'simple';
348 $minPrice = $this->product->detail->min_price;
349 $maxPrice = $this->product->detail->max_price;
350 $comparePrice = 0;
351 $firstVariant = null;
352
353 if ($isSimple) {
354 $firstVariant = $this->product->variants->first();
355 if ($firstVariant) {
356 $minPrice = $firstVariant->item_price;
357 $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [
358 'product' => $this->product,
359 'variation' => $firstVariant,
360 ]);
361 $minPrice = (int)$minPrice;
362 if ($firstVariant->compare_price > $minPrice) {
363 $comparePrice = $firstVariant->compare_price;
364 }
365 }
366 }
367
368 $formattedMinPrice = Helper::toDecimal($minPrice);
369 $formattedMaxPrice = Helper::toDecimal($maxPrice);
370 $formattedComparePrice = Helper::toDecimal($comparePrice);
371
372 do_action('fluent_cart/product/group/before_price_block', [
373 'product' => $this->product,
374 'current_price' => $minPrice,
375 'scope' => 'product_card'
376 ]);
377 ?>
378 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
379 class="fct-product-card-prices"
380 role="region"
381 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
382 <?php if ($comparePrice): ?>
383 <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
384 /* translators: %s: product price */
385 __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
386 <del aria-hidden="true"><?php echo esc_html($formattedComparePrice); ?></del>
387 </span>
388 <?php endif; ?>
389
390 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
391 <!-- Case 2: price range -->
392 <?php if ($priceFormat === 'range'): ?>
393 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
394 /* translators: %1$s: min price, %2$s: max price */
395 __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
396 <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?> - <?php echo esc_html($formattedMaxPrice); ?></span>
397 </span>
398 <?php else: ?>
399 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
400 /* translators: %s: min price */
401 __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
402 <span aria-hidden="true"><?php
403 /* translators: %s is the minimum price */
404 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
405 ?></span>
406 </span>
407 <?php endif; ?>
408
409 <?php else: ?>
410 <!-- Case 3: Simple or single price -->
411 <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
412 /* translators: %s: product price */
413 __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
414 <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?></span>
415 </span>
416 <?php endif; ?>
417
418 <?php do_action('fluent_cart/product/after_price', [
419 'product' => $this->product,
420 'variant' => $firstVariant,
421 'current_price' => $minPrice,
422 'scope' => 'product_card'
423 ]); ?>
424 </div>
425 <?php
426 do_action('fluent_cart/product/group/after_price_block', [
427 'product' => $this->product,
428 'current_price' => $minPrice,
429 'scope' => 'product_card'
430 ]);
431 }
432
433 public function showBuyButton($atts = '')
434 {
435 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
436
437 if ($isOutOfStock) {
438 $soldOutText = __('Not Available', 'fluent-cart');
439 ?>
440 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
441 aria-label="<?php echo esc_attr($soldOutText); ?>">
442 <span class="fct-button-text">
443 <?php echo esc_html($soldOutText); ?>
444 </span>
445 </button>
446 <?php
447 return;
448 }
449
450 $enableModalCheckout = Helper::isModalCheckoutEnabled();
451 $isSimple = $this->product->detail->variation_type === 'simple';
452 $firstVariant = null;
453 $buttonHref = $this->viewUrl;
454
455 if ($isSimple) {
456 $firstVariant = $this->product->variants->first();
457 if ($firstVariant) {
458 // return '';
459 }
460 }
461
462 $isInstantCheckout = false;
463 $hasSubscription = $this->product->has_subscription;
464 $buttonText = __('View Options', 'fluent-cart');
465 $ariaLabel = sprintf(
466 /* translators: %s: product title */
467 __('View options for %s', 'fluent-cart'), $this->product->post_title);
468
469 if ($isSimple) {
470 if ($hasSubscription) {
471 $buttonText = __('Buy Now', 'fluent-cart');
472 $ariaLabel = sprintf(
473 /* translators: %s: product title */
474 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
475 $buttonHref = $firstVariant->getPurchaseUrl();
476 $isInstantCheckout = true;
477 } else {
478 $buttonText = __('Add To Cart', 'fluent-cart');
479 $ariaLabel = sprintf(
480 /* translators: %s: product title */
481 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
482 }
483 }
484
485 $buttonAttributes = [
486 'class' => 'fct-product-view-button fct-single-product-card-view-button',
487 'data-product-id' => $this->product->ID,
488 'data-fluent-cart-single-product-card-view-button' => '',
489 'aria-label' => $ariaLabel
490 ];
491
492 if ($firstVariant) {
493 $buttonAttributes = [
494 'data-cart-id' => $firstVariant->id,
495 'class' => 'fluent-cart-add-to-cart-button',
496 'data-variation-type' => $this->product->detail->variation_type,
497 'data-fluent-cart-add-to-cart-button' => '',
498 'data-is-custom' => false,
499 'aria-label' => $ariaLabel
500 ];
501 }
502 $customAttributes = $this->parseAttributes($atts);
503 if (!empty($customAttributes)) {
504 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
505 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
506 unset($customAttributes['class']);
507 }
508 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
509 }
510
511 $anchorAttributes = [
512 'href' => $buttonHref,
513 'class' => 'fct-product-view-button',
514 'aria-label' => $ariaLabel,
515 ];
516
517 if ($enableModalCheckout) {
518 $anchorAttributes['data-fct-instant-checkout-button'] = '';
519 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
520 }
521
522 if ($isInstantCheckout) {
523 $parsedCustomAttributes = $this->parseAttributes($atts);
524 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
525 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
526 unset($parsedCustomAttributes['class']);
527 }
528 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
529 }
530 ?>
531 <?php if ($isInstantCheckout): ?>
532 <a <?php $this->renderAttributes($anchorAttributes); ?>>
533 <span aria-hidden="true">
534 <?php echo esc_html($buttonText); ?>
535 </span>
536 </a>
537 <?php else: ?>
538 <button
539 type="button"
540 data-button-url="<?php echo esc_url($buttonHref); ?>"
541 <?php $this->renderAttributes($buttonAttributes); ?>>
542 <span class="fct-button-text">
543 <?php echo esc_html($buttonText); ?>
544 </span>
545 <span
546 class="fluent-cart-loader"
547 role="status"
548 aria-live="polite"
549 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
550 <svg aria-hidden="true"
551 viewBox="0 0 100 101"
552 fill="none"
553 xmlns="http://www.w3.org/2000/svg"
554 focusable="false">
555 <path
556 d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
557 fill="currentColor"></path>
558 <path
559 d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
560 fill="currentFill"></path>
561 </svg>
562 </span>
563 </button>
564 <?php endif; ?>
565 <?php
566 }
567
568 protected function renderAttributes($atts = [])
569 {
570 foreach ($atts as $attr => $value) {
571 if ($value !== '') {
572 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
573 } else {
574 echo esc_attr($attr) . ' ';
575 }
576 }
577 }
578
579 private function parseAttributes($atts)
580 {
581 if (empty($atts)) {
582 return [];
583 }
584
585 $attributes = [];
586
587 // Match attribute="value" or attribute='value' or attribute=value
588 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
589
590 foreach ($matches as $match) {
591 if (!empty($match[1])) {
592 // Quoted value
593 $attributes[$match[1]] = $match[3];
594 } elseif (!empty($match[4])) {
595 // Unquoted value
596 $attributes[$match[4]] = $match[5];
597 }
598 }
599
600 return $attributes;
601 }
602 }
603