wp_kses_post() strips the custom <badge> element, so icon card title/desc lost admin-authored markup (e.g. <badge class="ml6 jb-blue">GO</badge>) both at save time and render time, diverging from the reference theme which stores and echoes these fields raw. - add shared inline whitelist (aksrt_icon_card_kses_tags/aksrt_icon_card_kses) and route both card renderers plus the media-brand fallback through it - drop the dead unused $allow array in aksrt_icon_cover_card - add 'rich_html' sanitize type (FieldSchema + SchemaProvider) and mark icon card title/desc fields in widget schemas, page display schema and the page config meta box save path - align header slider card module with reference show_widget_bg branches (aksrt-panel/flex1 vs padding-10 wrapper/relative-h) - update widget contract tests to pin the whitelist behavior
422 lines
16 KiB
PHP
422 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Icon card helpers (legacy / legacy).
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
if (!function_exists('aksrt_icon_card_kses_tags')) :
|
|
/**
|
|
* Inline HTML allow-list for icon card title/desc fields.
|
|
*
|
|
* legacy (Zibll) stores and echoes card title/desc raw, so admin-authored
|
|
* markup such as `<badge class="ml6 jb-blue">GO <span class="fa ..."></span></badge>`
|
|
* renders verbatim. `badge` is a custom element missing from WP's
|
|
* `$allowedposttags`, so `wp_kses_post()` strips it; these fields must go
|
|
* through this richer inline whitelist instead.
|
|
*
|
|
* @return array<string,array<string,bool>>
|
|
*/
|
|
function aksrt_icon_card_kses_tags(): array {
|
|
return [
|
|
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
|
|
'badge' => ['class' => true],
|
|
'span' => ['class' => true],
|
|
'i' => ['class' => true, 'aria-hidden' => true],
|
|
'em' => ['class' => true],
|
|
'strong' => ['class' => true],
|
|
'b' => ['class' => true],
|
|
'small' => ['class' => true],
|
|
'br' => [],
|
|
];
|
|
}
|
|
endif;
|
|
|
|
if (!function_exists('aksrt_icon_card_kses')) :
|
|
/**
|
|
* KSES with the shared inline whitelist; falls back to wp_kses_post()
|
|
* when the tag helper is unavailable (e.g. file not yet loaded).
|
|
*
|
|
* @param string $value Raw admin-authored markup.
|
|
* @return string
|
|
*/
|
|
function aksrt_icon_card_kses(string $value): string {
|
|
$tags = aksrt_icon_card_kses_tags();
|
|
return $tags ? wp_kses($value, $tags) : wp_kses_post($value);
|
|
}
|
|
endif;
|
|
|
|
if (!function_exists('aksrt_card_icon_inner')) :
|
|
/**
|
|
* Resolve a card's stored `icon` value to renderable inner markup.
|
|
*
|
|
* The universal icon picker stores three formats:
|
|
* - "fa fas fa-star" — FA5 class string
|
|
* - "zibsvg-hot" — theme built-in SVG sprite key
|
|
* - "customsvg-<svg>" — inline custom SVG markup
|
|
*
|
|
* Every format must be routed through aksrt_icon_markup(); emitting a bare
|
|
* FA class string silently produced an empty glyph for the two SVG formats
|
|
* (e.g. `<i class="fa fas fa-zibsvg-hot">`), so the card rendered without an
|
|
* icon whenever the admin picked 内置 SVG or 自定义 SVG.
|
|
*
|
|
* Raw inline markup without a picker prefix is still accepted for data
|
|
* stored before the picker existed.
|
|
*
|
|
* @param string $value Stored icon value.
|
|
* @param string $extra Extra classes for the rendered element.
|
|
* @return string
|
|
*/
|
|
function aksrt_card_icon_inner(string $value, string $extra = ''): string {
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$is_picker_svg = str_starts_with($value, 'zibsvg-') || str_starts_with($value, 'customsvg-');
|
|
|
|
if (!$is_picker_svg && str_contains($value, '<')) {
|
|
$allowed = function_exists('aksrt_svg_kses_tags') ? aksrt_svg_kses_tags() : [];
|
|
$allowed['i'] = ['class' => true, 'aria-hidden' => true];
|
|
$allowed['span'] = ['class' => true];
|
|
return wp_kses($value, $allowed);
|
|
}
|
|
|
|
// Bare glyph keys ("gem", "fa-gem") predate the picker; promote them to a
|
|
// full FA5 solid class, the same contract the picker emits.
|
|
if (!$is_picker_svg && !str_contains($value, ' ')) {
|
|
$value = 'fa fas ' . (str_starts_with($value, 'fa-') ? $value : 'fa-' . $value);
|
|
}
|
|
|
|
if (function_exists('aksrt_icon_markup')) {
|
|
return aksrt_icon_markup($value, $extra);
|
|
}
|
|
|
|
return '<i class="' . esc_attr(trim($value . ' ' . $extra)) . '" aria-hidden="true"></i>';
|
|
}
|
|
endif;
|
|
|
|
if (!function_exists('aksrt_icon_card')) :
|
|
/**
|
|
* Vertical icon card (icon above title/desc).
|
|
*
|
|
* @param array<string,mixed> $args
|
|
* @param bool $echo
|
|
*/
|
|
function aksrt_icon_card(array $args = [], bool $echo = false): string {
|
|
$defaults = [
|
|
'type' => '',
|
|
'class' => 'box-body nopw-sm',
|
|
'icon' => '',
|
|
'icon_size' => 0,
|
|
'customize_icon' => '',
|
|
'icon_image' => '',
|
|
'link' => [
|
|
'url' => '',
|
|
'target' => '',
|
|
],
|
|
'icon_radius' => false,
|
|
'icon_color' => '',
|
|
'icon_custom_color' => '',
|
|
'icon_class' => '',
|
|
'title' => '',
|
|
'desc' => '',
|
|
];
|
|
$args = wp_parse_args($args, $defaults);
|
|
|
|
// Keep picker payloads intact (customsvg- carries inline SVG markup that
|
|
// sanitize_text_field would strip down to a bare prefix).
|
|
$icon_fa = function_exists('aksrt_sanitize_icon_value')
|
|
? aksrt_sanitize_icon_value((string) $args['icon'])
|
|
: sanitize_text_field((string) $args['icon']);
|
|
$icon_img = esc_url_raw(trim((string) ($args['icon_image'] !== '' ? $args['icon_image'] : $args['customize_icon'])));
|
|
// customize_icon may be raw HTML (img) from older data.
|
|
$custom_html = '';
|
|
if ($icon_img === '' && is_string($args['customize_icon']) && str_contains((string) $args['customize_icon'], '<')) {
|
|
$custom_html = wp_kses((string) $args['customize_icon'], [
|
|
'i' => ['class' => true, 'aria-hidden' => true],
|
|
'img' => ['src' => true, 'data-src' => true, 'alt' => true, 'class' => true, 'width' => true, 'height' => true],
|
|
'span'=> ['class' => true],
|
|
]);
|
|
}
|
|
if ($icon_fa === '' && $icon_img === '' && $custom_html === '') {
|
|
return '';
|
|
}
|
|
|
|
$link = $args['link'];
|
|
if (is_string($link)) {
|
|
$link = ['url' => $link, 'target' => ''];
|
|
}
|
|
if (!is_array($link)) {
|
|
$link = ['url' => '', 'target' => ''];
|
|
}
|
|
$url = esc_url(trim((string) ($link['url'] ?? '')));
|
|
$target = sanitize_text_field((string) ($link['target'] ?? ''));
|
|
$tgt = $target !== '' ? ' target="' . esc_attr($target) . '" rel="noopener noreferrer"' : '';
|
|
|
|
$icon_color = sanitize_html_class((string) $args['icon_color']);
|
|
$icon_class_extra = sanitize_html_class((string) $args['icon_class']);
|
|
$radius = !empty($args['icon_radius']);
|
|
$icon_class = $radius
|
|
? 'card-icon toggle-radius fa-3x ' . ($icon_class_extra !== '' ? $icon_class_extra : $icon_color)
|
|
: 'card-icon fa-4x ' . $icon_color;
|
|
if ($icon_class_extra !== '' && !$radius) {
|
|
$icon_class .= ' ' . $icon_class_extra;
|
|
}
|
|
|
|
$style = '';
|
|
$custom_color = sanitize_hex_color((string) $args['icon_custom_color']);
|
|
if (!$custom_color && preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', (string) $args['icon_custom_color'])) {
|
|
$custom_color = (string) $args['icon_custom_color'];
|
|
}
|
|
if (!$radius && $custom_color) {
|
|
$style = ' style="color:' . esc_attr($custom_color) . ';"';
|
|
}
|
|
|
|
if ($custom_html !== '') {
|
|
$icon_inner = $custom_html;
|
|
} elseif ($icon_img !== '') {
|
|
$icon_inner = '<img' . aksrt_get_lazy_attr('lazy_other', $icon_img, 'img-icon') . ' alt="">';
|
|
} else {
|
|
$icon_inner = aksrt_card_icon_inner($icon_fa);
|
|
}
|
|
|
|
$icon = '<span class="' . esc_attr(trim($icon_class)) . '"' . $style . '>' . $icon_inner . '</span>';
|
|
if ($url !== '') {
|
|
$icon = '<a href="' . esc_url($url) . '"' . $tgt . '>' . $icon . '</a>';
|
|
}
|
|
$icon_size = (int) $args['icon_size'];
|
|
if ($icon_size > 0) {
|
|
$icon = '<span style="font-size:' . (int) $icon_size . 'px;">' . $icon . '</span>';
|
|
}
|
|
|
|
$title = trim((string) $args['title']);
|
|
$desc = trim((string) $args['desc']);
|
|
if ($url !== '' && $title !== '') {
|
|
$title_html = '<a class="main-color" href="' . esc_url($url) . '"' . $tgt . '>' . aksrt_icon_card_kses($title) . '</a>';
|
|
} else {
|
|
$title_html = $title !== '' ? aksrt_icon_card_kses($title) : '';
|
|
}
|
|
|
|
$body = '';
|
|
if ($title_html !== '') {
|
|
$body .= '<div class="mt10 em12 text-ellipsis">' . $title_html . '</div>';
|
|
}
|
|
if ($desc !== '') {
|
|
$body .= '<div class="muted-color mt6">' . aksrt_icon_card_kses($desc) . '</div>';
|
|
}
|
|
|
|
$class = trim((string) $args['class'] . ' ' . (string) $args['type']);
|
|
$html = '<div class="icon-card ' . esc_attr($class) . '">';
|
|
$html .= $icon;
|
|
$html .= $body !== '' ? '<div class="px12-sm">' . $body . '</div>' : '';
|
|
$html .= '</div>';
|
|
|
|
if ($echo) {
|
|
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
return '';
|
|
}
|
|
return $html;
|
|
}
|
|
endif;
|
|
|
|
if (!function_exists('aksrt_icon_cover_card')) :
|
|
/**
|
|
* Horizontal icon cover card (icon left, title/desc right).
|
|
*
|
|
* @param array<string,mixed> $args
|
|
* @param bool $echo
|
|
*/
|
|
function aksrt_icon_cover_card(array $args = [], bool $echo = false): string {
|
|
$defaults = [
|
|
'class' => 'aksrt-panel',
|
|
'icon' => '',
|
|
'icon_size' => 25,
|
|
'customize_icon' => '',
|
|
'custom_icon' => '',
|
|
'icon_image' => '',
|
|
'link' => [
|
|
'url' => '',
|
|
'target' => '',
|
|
],
|
|
// Flat keys used by header_components_icon_cards admin schema.
|
|
'url' => '',
|
|
'target' => '',
|
|
'icon_radius' => false,
|
|
'icon_color' => '',
|
|
'icon_custom_color' => '',
|
|
'icon_class' => '',
|
|
'title' => '',
|
|
'desc' => '',
|
|
];
|
|
$args = wp_parse_args($args, $defaults);
|
|
|
|
// Normalize link: accept nested link[] or flat url/target (SPA schema).
|
|
$link = $args['link'];
|
|
if (is_string($link)) {
|
|
$link = ['url' => $link, 'target' => (string) $args['target']];
|
|
}
|
|
if (!is_array($link)) {
|
|
$link = ['url' => '', 'target' => ''];
|
|
}
|
|
if (trim((string) ($link['url'] ?? '')) === '' && trim((string) $args['url']) !== '') {
|
|
$link['url'] = (string) $args['url'];
|
|
}
|
|
if (trim((string) ($link['target'] ?? '')) === '' && trim((string) $args['target']) !== '') {
|
|
$link['target'] = (string) $args['target'];
|
|
}
|
|
|
|
$icon_fa = trim((string) $args['icon']);
|
|
// custom_icon (SPA) + customize_icon (legacy) + icon_image URL.
|
|
$custom_src = (string) ($args['custom_icon'] !== '' ? $args['custom_icon'] : $args['customize_icon']);
|
|
$icon_img = esc_url_raw(trim((string) ($args['icon_image'] !== '' ? $args['icon_image'] : '')));
|
|
$custom_html = '';
|
|
$custom_is_picker = str_starts_with($custom_src, 'zibsvg-') || str_starts_with($custom_src, 'customsvg-');
|
|
if ($icon_img === '' && $custom_is_picker) {
|
|
// A picker value routed into the custom slot must go through the
|
|
// shared renderer, otherwise the "customsvg-" prefix leaks as text.
|
|
$custom_html = aksrt_card_icon_inner($custom_src);
|
|
} elseif ($icon_img === '' && $custom_src !== '' && str_contains($custom_src, '<')) {
|
|
$custom_html = wp_kses($custom_src, [
|
|
'i' => ['class' => true, 'aria-hidden' => true],
|
|
'img' => ['src' => true, 'data-src' => true, 'alt' => true, 'class' => true, 'width' => true, 'height' => true],
|
|
'span' => ['class' => true],
|
|
'svg' => ['class' => true, 'viewbox' => true, 'viewBox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true],
|
|
'path' => ['d' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true],
|
|
'use' => ['href' => true, 'xlink:href' => true],
|
|
]);
|
|
} elseif ($icon_img === '' && $custom_src !== '' && filter_var($custom_src, FILTER_VALIDATE_URL)) {
|
|
$icon_img = esc_url_raw($custom_src);
|
|
}
|
|
if ($icon_fa === '' && $icon_img === '' && $custom_html === '') {
|
|
return '';
|
|
}
|
|
|
|
$url = esc_url(trim((string) ($link['url'] ?? '')));
|
|
$target = sanitize_text_field((string) ($link['target'] ?? ''));
|
|
// legacy only adds target= when non-empty; keep same for 1:1.
|
|
$tgt = ($target !== '' && $target !== '_self') ? ' target="' . esc_attr($target) . '"' : '';
|
|
|
|
// legacy: class = "icon-cover-icon badg cir" + optional icon_class (may include radius4 / c-* / jb-*).
|
|
// Do NOT sanitize_html_class on the whole string — it collapses multi-token classes.
|
|
$icon_class_extra = trim(preg_replace('/\s+/', ' ', (string) $args['icon_class']));
|
|
$icon_class = 'icon-cover-icon badg cir';
|
|
if ($icon_class_extra !== '') {
|
|
$tokens = preg_split('/\s+/', $icon_class_extra) ?: [];
|
|
$safe = [];
|
|
foreach ($tokens as $tok) {
|
|
$tok = preg_replace('/[^a-zA-Z0-9_-]/', '', $tok);
|
|
if ($tok !== '') {
|
|
$safe[] = $tok;
|
|
}
|
|
}
|
|
if ($safe) {
|
|
$icon_class .= ' ' . implode(' ', $safe);
|
|
}
|
|
}
|
|
if (!empty($args['icon_radius']) && !str_contains($icon_class, 'radius4')) {
|
|
$icon_class .= ' radius4';
|
|
}
|
|
$icon_color = trim((string) $args['icon_color']);
|
|
if ($icon_color !== '' && !str_contains($icon_class, $icon_color)) {
|
|
$icon_class .= ' ' . preg_replace('/[^a-zA-Z0-9_-]/', '', $icon_color);
|
|
}
|
|
|
|
$icon_size = max(14, min(64, (int) $args['icon_size']));
|
|
// legacy always sets font-size on the icon badge.
|
|
$style = 'font-size: ' . $icon_size . 'px;';
|
|
$custom_color = sanitize_hex_color((string) $args['icon_custom_color']);
|
|
if ($custom_color) {
|
|
$style .= 'color:' . $custom_color . ';';
|
|
}
|
|
|
|
if ($custom_html !== '') {
|
|
$icon_inner = $custom_html;
|
|
} elseif ($icon_img !== '') {
|
|
$icon_inner = '<img' . aksrt_get_lazy_attr('lazy_other', $icon_img, 'img-icon') . ' alt="">';
|
|
} else {
|
|
// FA5 class string / built-in SVG sprite key / inline custom SVG —
|
|
// all three admin picker formats must survive this branch.
|
|
$icon_inner = aksrt_card_icon_inner($icon_fa, 'em09');
|
|
}
|
|
$icon = '<div class="' . esc_attr(trim($icon_class)) . '" style="' . esc_attr($style) . '">' . $icon_inner . '</div>';
|
|
|
|
// legacy stores/echoes title/desc raw (admin authors badge markup
|
|
// such as <badge class="ml6 jb-blue">GO</badge>), so these fields go
|
|
// through the shared inline whitelist instead of wp_kses_post().
|
|
$title = (string) $args['title'];
|
|
$desc = (string) $args['desc'];
|
|
$text = '';
|
|
if (trim(wp_strip_all_tags($title)) !== '' || str_contains($title, '<')) {
|
|
$text .= '<div class="em12 text-ellipsis font-bold"> ' . aksrt_icon_card_kses($title) . '</div>';
|
|
}
|
|
if (trim(wp_strip_all_tags($desc)) !== '' || str_contains($desc, '<')) {
|
|
$text .= '<div class="muted-color mt6 text-ellipsis"> ' . aksrt_icon_card_kses($desc) . '</div>';
|
|
}
|
|
|
|
$html = '';
|
|
if ($url !== '') {
|
|
// legacy: <a class="main-color" [target] href="...">
|
|
$html .= '<a class="main-color" ' . ($tgt !== '' ? ltrim($tgt) . ' ' : '') . 'href="' . esc_url($url) . '">';
|
|
}
|
|
// Keep every horizontal icon card on the same compact visual contract,
|
|
// regardless of the page-specific wrapper that renders it.
|
|
$html .= '<div class="icon-cover-card flex ac ' . esc_attr((string) $args['class']) . '">';
|
|
$html .= $icon;
|
|
$html .= $text !== '' ? '<div class="icon-cover-desc ml10 flex1 px12-sm"> ' . $text . '</div>' : '';
|
|
$html .= '</div>';
|
|
if ($url !== '') {
|
|
$html .= '</a>';
|
|
}
|
|
|
|
if ($echo) {
|
|
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
return '';
|
|
}
|
|
return $html;
|
|
}
|
|
endif;
|
|
|
|
if (!function_exists('aksrt_obs_animate_attrs')) :
|
|
/**
|
|
* Build class + attribute string for scroll-in animation (maps to data-aksrt-animate).
|
|
*
|
|
* @param string $animation fade|slideup|slidedown|slideright|slideleft|zoomin|zoomout|''
|
|
* @param bool $repeat
|
|
* @param float|int $delay_s
|
|
* @return array{class:string,attr:string}
|
|
*/
|
|
function aksrt_obs_animate_attrs(string $animation, bool $repeat = false, $delay_s = 0): array {
|
|
$animation = sanitize_key($animation);
|
|
$map = [
|
|
'fade' => 'fadeIn',
|
|
'slideup' => 'fadeInUp',
|
|
'slidedown' => 'fadeInDown',
|
|
'slideright' => 'fadeInRight',
|
|
'slideleft' => 'fadeInLeft',
|
|
'zoomin' => 'zoomIn',
|
|
'zoomout' => 'fadeIn',
|
|
'fadein' => 'fadeIn',
|
|
'fadeinup' => 'fadeInUp',
|
|
];
|
|
if ($animation === '' || !isset($map[$animation]) && !in_array($animation, $map, true)) {
|
|
if (!isset($map[$animation])) {
|
|
return ['class' => '', 'attr' => ''];
|
|
}
|
|
}
|
|
$aksrt = $map[$animation] ?? $animation;
|
|
$delay_ms = (int) round(((float) $delay_s) * 1000);
|
|
$class = ' obs-animate ani-' . $animation;
|
|
$attr = ' data-aksrt-animate="' . esc_attr($aksrt) . '"';
|
|
if ($delay_ms > 0) {
|
|
$attr .= ' data-aksrt-animate-delay="' . esc_attr((string) $delay_ms) . '"';
|
|
}
|
|
if ($repeat) {
|
|
$attr .= ' data-animation-repeat="true"';
|
|
}
|
|
return ['class' => $class, 'attr' => $attr];
|
|
}
|
|
endif;
|