From cd7082454ad1031ad05210ab70cd5e14022e0486 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 19 Sep 2026 16:39:59 +0800 Subject: [PATCH] fix(widgets): preserve badge HTML in icon card title/desc like reference theme wp_kses_post() strips the custom element, so icon card title/desc lost admin-authored markup (e.g. GO) 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 --- inc/helpers/aksrt-icon-cards.php | 65 ++++++++++++++----- inc/helpers/homepage/header-components.php | 12 +++- inc/helpers/template-tags/media-brand.php | 16 ++--- src/Admin/PageConfigMetaBox.php | 6 +- src/Admin/Schema/SchemaPageDisplay.php | 2 +- src/Admin/SchemaProvider.php | 7 ++ src/WidgetSystem/FieldSchema.php | 17 +++++ src/WidgetSystem/WidgetSchemas.php | 8 +-- .../zibll-modern-widgets-contract.test.cjs | 11 +++- 9 files changed, 108 insertions(+), 36 deletions(-) diff --git a/inc/helpers/aksrt-icon-cards.php b/inc/helpers/aksrt-icon-cards.php index 7873fbbd..c562f296 100644 --- a/inc/helpers/aksrt-icon-cards.php +++ b/inc/helpers/aksrt-icon-cards.php @@ -7,6 +7,47 @@ 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 `GO ` + * 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> + */ + 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. @@ -154,9 +195,9 @@ if (!function_exists('aksrt_icon_card')) : $title = trim((string) $args['title']); $desc = trim((string) $args['desc']); if ($url !== '' && $title !== '') { - $title_html = '' . wp_kses_post($title) . ''; + $title_html = '' . aksrt_icon_card_kses($title) . ''; } else { - $title_html = $title !== '' ? wp_kses_post($title) : ''; + $title_html = $title !== '' ? aksrt_icon_card_kses($title) : ''; } $body = ''; @@ -164,7 +205,7 @@ if (!function_exists('aksrt_icon_card')) : $body .= '
' . $title_html . '
'; } if ($desc !== '') { - $body .= '
' . wp_kses_post($desc) . '
'; + $body .= '
' . aksrt_icon_card_kses($desc) . '
'; } $class = trim((string) $args['class'] . ' ' . (string) $args['type']); @@ -302,25 +343,17 @@ if (!function_exists('aksrt_icon_cover_card')) : } $icon = '
' . $icon_inner . '
'; - // legacy allows HTML in title/desc (admin stores badges etc.). + // legacy stores/echoes title/desc raw (admin authors badge markup + // such as GO), so these fields go + // through the shared inline whitelist instead of wp_kses_post(). $title = (string) $args['title']; $desc = (string) $args['desc']; - $allow = [ - 'badge' => ['class' => true], - 'span' => ['class' => true], - 'i' => ['class' => true, 'aria-hidden' => true], - 'em' => ['class' => true], - 'strong' => ['class' => true], - 'b' => [], - 'small' => ['class' => true], - 'br' => [], - ]; $text = ''; if (trim(wp_strip_all_tags($title)) !== '' || str_contains($title, '<')) { - $text .= '
' . wp_kses_post($title) . '
'; + $text .= '
' . aksrt_icon_card_kses($title) . '
'; } if (trim(wp_strip_all_tags($desc)) !== '' || str_contains($desc, '<')) { - $text .= '
' . wp_kses_post($desc) . '
'; + $text .= '
' . aksrt_icon_card_kses($desc) . '
'; } $html = ''; diff --git a/inc/helpers/homepage/header-components.php b/inc/helpers/homepage/header-components.php index 05f94644..2b0ffc2b 100644 --- a/inc/helpers/homepage/header-components.php +++ b/inc/helpers/homepage/header-components.php @@ -421,24 +421,30 @@ if (!function_exists('aksrt_header_slide')) { $card_count = 0; if (aksrt_header_slider_device_is_visible($args['header_slider_card_s'] ?? [])) { $card_options = wp_parse_args((array) ($args['header_slider_card_option'] ?? []), [ - 'icon_radius4' => false, 'cards' => [], + 'icon_radius4' => false, 'show_widget_bg' => false, 'cards' => [], ]); + // legacy (Zibll): with module background the card shrinks to + // padding-10 inside a .zib-widget wrapper (.aksrt-panel here); the + // bg-less variant stretches each card in a .flex1 column. + $show_bg = !empty($card_options['show_widget_bg']); $items = ''; foreach ((array) $card_options['cards'] as $card) { if (!is_array($card) || (empty($card['icon']) && empty($card['customize_icon']) && empty($card['custom_icon']) && empty($card['icon_image']))) { continue; } + $card['class'] = $show_bg ? 'padding-10' : 'aksrt-panel'; $card['icon_class'] = trim((string) ($card['icon_class'] ?? '') . (!empty($card_options['icon_radius4']) ? ' radius4' : '')); $inner = function_exists('aksrt_icon_cover_card') ? aksrt_icon_cover_card($card) : ''; if ($inner === '') { continue; } - $items .= '
' . $inner . '
'; + $items .= '
' . $inner . '
'; $card_count++; } if ($items !== '') { $cards_html = '
' - . '
' . $items . '
'; + . ($show_bg ? '
' : '
') + . '
' . $items . '
'; } } diff --git a/inc/helpers/template-tags/media-brand.php b/inc/helpers/template-tags/media-brand.php index 541ebc71..07f96f90 100644 --- a/inc/helpers/template-tags/media-brand.php +++ b/inc/helpers/template-tags/media-brand.php @@ -53,14 +53,14 @@ function aksrt_render_icon_cover_card(array $card): string { $icon_container = '
' . $icon_html . '
'; - $text_html = '
'; - if ($title !== '') { - $text_html .= '
' . wp_kses_post((string) $title) . '
'; - } - if ($desc !== '') { - $text_html .= '
' . wp_kses_post((string) $desc) . '
'; - } - $text_html .= '
'; + $text_html = '
'; + if ($title !== '') { + $text_html .= '
' . aksrt_icon_card_kses((string) $title) . '
'; + } + if ($desc !== '') { + $text_html .= '
' . aksrt_icon_card_kses((string) $desc) . '
'; + } + $text_html .= '
'; $card_inner = $icon_container . $text_html; $cls = 'icon-cover-card flex ac ' . esc_attr((string) ($card['class'] ?? 'aksrt-panel')); diff --git a/src/Admin/PageConfigMetaBox.php b/src/Admin/PageConfigMetaBox.php index 89a121e5..b14ff517 100644 --- a/src/Admin/PageConfigMetaBox.php +++ b/src/Admin/PageConfigMetaBox.php @@ -250,8 +250,10 @@ final class PageConfigMetaBox { } $link = is_array($card['link'] ?? null) ? $card['link'] : []; $cards[] = [ - 'title' => wp_kses_post((string) ($card['title'] ?? '')), - 'desc' => wp_kses_post((string) ($card['desc'] ?? '')), + // legacy echoes card title/desc raw (admin authors etc.); + // wp_kses_post() would strip the custom badge element. + 'title' => aksrt_icon_card_kses((string) ($card['title'] ?? '')), + 'desc' => aksrt_icon_card_kses((string) ($card['desc'] ?? '')), // Icon picker values may carry an inline "customsvg-" payload; // sanitize_text_field would strip it down to a bare prefix. 'icon' => function_exists('aksrt_sanitize_icon_value') diff --git a/src/Admin/Schema/SchemaPageDisplay.php b/src/Admin/Schema/SchemaPageDisplay.php index e94ae279..d40374d3 100644 --- a/src/Admin/Schema/SchemaPageDisplay.php +++ b/src/Admin/Schema/SchemaPageDisplay.php @@ -181,7 +181,7 @@ class SchemaPageDisplay { } private static function card_fields(): array { - return [self::field('icon_radius4', '方形图标', 'switcher', false), self::field('cards', '添加卡片', 'group', [], ['button_title' => '添加内容', 'fields' => [self::field('title', '文案标题', 'textarea', '', ['sanitize_type' => 'html']), self::field('desc', '文案简介', 'textarea', '', ['sanitize_type' => 'html']), self::field('icon', '选择图标', 'icon', 'fa fas fa-magic'), self::field('customize_icon', '自定义图标', 'textarea', '', ['sanitize_type' => 'html']), self::field('icon_class', '图标颜色', 'palette', 'c-yellow', ['options' => self::aksrt_palette(true)]), self::field('link', '跳转链接', 'link', [])]])]; + return [self::field('icon_radius4', '方形图标', 'switcher', false), self::field('cards', '添加卡片', 'group', [], ['button_title' => '添加内容', 'fields' => [self::field('title', '文案标题', 'textarea', '', ['sanitize_type' => 'rich_html']), self::field('desc', '文案简介', 'textarea', '', ['sanitize_type' => 'rich_html']), self::field('icon', '选择图标', 'icon', 'fa fas fa-magic'), self::field('customize_icon', '自定义图标', 'textarea', '', ['sanitize_type' => 'html']), self::field('icon_class', '图标颜色', 'palette', 'c-yellow', ['options' => self::aksrt_palette(true)]), self::field('link', '跳转链接', 'link', [])]])]; } private static function orderby_fields(): array { return [self::field('lists', '显示排序方式', 'select', ['modified', 'views', 'like', 'comment_count'], ['multiple' => true, 'sortable' => true, 'options' => self::post_order_options(false)]), self::field('dropdown', '更多排序方式', 'switcher', false)]; } diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index f69ecb3e..35c6b65b 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -821,6 +821,13 @@ class SchemaProvider { return array_values(array_unique($ids)); case 'html': return wp_kses_post((string) $value); + case 'rich_html': + // Inline whitelist for card title/desc: `badge` is a custom + // element that wp_kses_post() would strip, but legacy (Zibll) + // renders such markup verbatim. + return function_exists('aksrt_icon_card_kses') + ? aksrt_icon_card_kses((string) $value) + : wp_kses_post((string) $value); case 'textarea': return sanitize_textarea_field((string) $value); case 'code': diff --git a/src/WidgetSystem/FieldSchema.php b/src/WidgetSystem/FieldSchema.php index 1894bb11..3965df25 100644 --- a/src/WidgetSystem/FieldSchema.php +++ b/src/WidgetSystem/FieldSchema.php @@ -85,12 +85,18 @@ final class FieldSchema { ? aksrt_sanitize_icon_value((string) $value) : sanitize_text_field((string) $value); case 'textarea': + if (($field['sanitize'] ?? '') === 'rich_html') { + return self::rich_inline_html((string) $value); + } return !empty($field['allow_html']) ? wp_kses_post((string) $value) : sanitize_textarea_field((string) $value); case 'text': default: if (($field['sanitize'] ?? '') === 'url') { return esc_url_raw((string) $value); } + if (($field['sanitize'] ?? '') === 'rich_html') { + return self::rich_inline_html((string) $value); + } if (($field['sanitize'] ?? '') === 'ids') { return implode(',', array_values(array_unique(array_filter(array_map('absint', preg_split('/[,,\s]+/', (string) $value)))))); } @@ -103,6 +109,17 @@ final class FieldSchema { } } + /** + * Inline rich-HTML sanitizer for admin-authored card title/desc fields. + * `badge` and friends are not in WP's $allowedposttags, so wp_kses_post() + * would strip the exact markup legacy/Zibll renders verbatim. + */ + private static function rich_inline_html(string $value): string { + return function_exists('aksrt_icon_card_kses') + ? aksrt_icon_card_kses($value) + : wp_kses_post($value); + } + private static function empty_value(string $type) { if ($type === 'switcher') { return false; diff --git a/src/WidgetSystem/WidgetSchemas.php b/src/WidgetSystem/WidgetSchemas.php index a6b77801..ba1b1264 100644 --- a/src/WidgetSystem/WidgetSchemas.php +++ b/src/WidgetSystem/WidgetSchemas.php @@ -537,8 +537,8 @@ final class WidgetSchemas { ['id' => 'icon_class', 'type' => 'palette', 'label' => '图标样式', 'default' => 'c-yellow', 'options' => self::icon_background_palette(), 'dependency' => ['icon_radius', '!=', '']], ['id' => 'icon_color', 'type' => 'palette', 'label' => '图标颜色', 'default' => 'key-color', 'options' => self::icon_text_palette(), 'dependency' => ['icon_radius|icon_custom_color', '==|==', '|']], ['id' => 'icon_custom_color', 'type' => 'color', 'label' => '自定义图标颜色', 'default' => '', 'dependency' => ['icon_radius', '==', '']], - ['id' => 'title', 'type' => 'textarea', 'label' => '文案标题', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1]], - ['id' => 'desc', 'type' => 'textarea', 'label' => '文案简介', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 2]], + ['id' => 'title', 'type' => 'textarea', 'label' => '文案标题', 'default' => '', 'allow_html' => true, 'sanitize' => 'rich_html', 'attributes' => ['rows' => 1]], + ['id' => 'desc', 'type' => 'textarea', 'label' => '文案简介', 'default' => '', 'allow_html' => true, 'sanitize' => 'rich_html', 'attributes' => ['rows' => 2]], ]], ]; } @@ -550,8 +550,8 @@ final class WidgetSchemas { ['id' => 'show_widget_bg', 'type' => 'switcher', 'label' => '显示模块背景', 'default' => false], ['id' => 'icon_radius4', 'type' => 'switcher', 'label' => '方形图标', 'default' => false], ['id' => 'cards', 'type' => 'group', 'label' => '添加卡片', 'max_items' => 40, 'default' => [], 'fields' => [ - ['id' => 'title', 'type' => 'textarea', 'label' => '文案标题', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1]], - ['id' => 'desc', 'type' => 'textarea', 'label' => '文案简介', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 2]], + ['id' => 'title', 'type' => 'textarea', 'label' => '文案标题', 'default' => '', 'allow_html' => true, 'sanitize' => 'rich_html', 'attributes' => ['rows' => 1]], + ['id' => 'desc', 'type' => 'textarea', 'label' => '文案简介', 'default' => '', 'allow_html' => true, 'sanitize' => 'rich_html', 'attributes' => ['rows' => 2]], ['id' => 'icon', 'type' => 'icon', 'label' => '选择图标', 'default' => 'fa fas fa-magic'], ['id' => 'customize_icon', 'type' => 'textarea', 'label' => '自定义图标', 'default' => '', 'allow_html' => true, 'attributes' => ['rows' => 1]], ['id' => 'icon_class', 'type' => 'palette', 'label' => '图标颜色', 'default' => 'c-yellow', 'options' => self::icon_background_palette()], diff --git a/tests/widget-system/zibll-modern-widgets-contract.test.cjs b/tests/widget-system/zibll-modern-widgets-contract.test.cjs index 635b0274..c431761d 100644 --- a/tests/widget-system/zibll-modern-widgets-contract.test.cjs +++ b/tests/widget-system/zibll-modern-widgets-contract.test.cjs @@ -42,8 +42,15 @@ test('icon card widgets expose Zibll vertical and horizontal contracts', () => { for (const token of ['clearfix', 'row gutters-5', 'icon-card', 'icon-cover-card', 'icon-cover-icon', 'icon-cover-desc']) { assert.match(renderer + helper, new RegExp(token.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'))); } - assert.match(helper, /wp_kses_post\(\$title\)/); - assert.match(helper, /wp_kses_post\(\$desc\)/); + // Card title/desc must keep legacy badge markup: fields go through the + // shared inline whitelist (aksrt_icon_card_kses), never bare wp_kses_post + // which strips the custom element. + assert.match(helper, /function aksrt_icon_card_kses_tags/); + assert.match(helper, /'badge'\s+=>\s*\['class' => true\]/); + assert.match(helper, /aksrt_icon_card_kses\(\$title\)/); + assert.match(helper, /aksrt_icon_card_kses\(\$desc\)/); + assert.doesNotMatch(helper, /wp_kses_post\(\$title\)/); + assert.doesNotMatch(helper, /wp_kses_post\(\$desc\)/); }); test('button group uses the complete Zibll schema, DOM and responsive sizing contract', () => {