From 8b999444b273095b384588d0518c9d1e5d3eb6b0 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 18 Sep 2026 20:05:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(icons):=20=E5=9B=BE=E6=A0=87=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=AF=E6=8C=81=E5=86=85=E7=BD=AE=20SVG=20=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=20SVG=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=9B=BE=E6=A0=87=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶部多功能组件的图标卡片只能显示 FA5 图标。后台「选择图标 → 内置 SVG」 页签写入的是 zibsvg-{key},但渲染器把它拼成了 ,浏览器没有这个名字的字形,前端图标位置 空白。 根因:aksrt_icon_cover_card() 与 aksrt_icon_card() 只实现了 FA 类名一条 路径,未走全站统一渲染器 aksrt_icon_markup();函数内那条用于跳过前缀拼接的 判断用的是 aksrtsvg-(任何选择器都不会产生的废弃前缀),形同虚设。 - 新增 aksrt_card_icon_inner() 统一解析器,横版 / 竖版卡片均委派给它, FA5 类名、zibsvg- 内置图标、customsvg- 内联 SVG 三种格式统一渲染 - aksrt_icon_card() 的 icon 值改用 aksrt_sanitize_icon_value(), 原 sanitize_text_field 会把 customsvg- 的标签整段剥成裸前缀 - SchemaProvider::sanitize_field_value() 补 type === 'icon' 分支: 分组 / 重复器内的图标子字段原先落到 sanitize_text_field, header_slider_card_option.cards[].icon 的 customsvg- 在保存时即被剥空 - PageConfigMetaBox::sanitize_card_options() 同步改用图标净化 - aksrt_svg_kses_tags() 补齐 circle / ellipse / rect / polygon / line, 与选择器端 cleanSvg() 的接受范围对齐;原先粘贴以 为主的图标 会在 kses 阶段被静默剥离形状 - icon-card-contract 新增 3 项契约 内置 SVG 的 sprite 由 svg-icon.js 每页注入 body,无需额外入队。 未写数据迁移;已存量的 zibsvg- / FA 值无需转换,customsvg- 存量值本就被 旧净化逻辑破坏,不在此次范围内。 验证:node --test tests/widget-system/*.test.cjs 311 项全部通过(原 308 + 新增 3);php -l 4 个改动文件通过;隔离 PHP 探针覆盖 9 种取值并走完整 「保存净化 → 前端渲染」链路,修复前 / 后输出逐项对比。 --- inc/helpers/aksrt-icon-cards.php | 85 ++++++++++++++----- inc/helpers/aksrt-icons.php | 18 +++- src/Admin/PageConfigMetaBox.php | 6 +- src/Admin/SchemaProvider.php | 10 +++ .../widget-system/icon-card-contract.test.cjs | 55 ++++++++++++ 5 files changed, 149 insertions(+), 25 deletions(-) diff --git a/inc/helpers/aksrt-icon-cards.php b/inc/helpers/aksrt-icon-cards.php index 38bfc368..7873fbbd 100644 --- a/inc/helpers/aksrt-icon-cards.php +++ b/inc/helpers/aksrt-icon-cards.php @@ -7,6 +7,56 @@ defined('ABSPATH') || exit; +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-" — 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. ``), 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 ''; + } +endif; + if (!function_exists('aksrt_icon_card')) : /** * Vertical icon card (icon above title/desc). @@ -35,7 +85,11 @@ if (!function_exists('aksrt_icon_card')) : ]; $args = wp_parse_args($args, $defaults); - $icon_fa = sanitize_text_field((string) $args['icon']); + // 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 = ''; @@ -85,10 +139,7 @@ if (!function_exists('aksrt_icon_card')) : } elseif ($icon_img !== '') { $icon_inner = ''; } else { - if ($icon_fa !== '' && !str_contains($icon_fa, 'fa-') && !str_contains($icon_fa, ' ')) { - $icon_fa = 'fa fas fa-' . $icon_fa; - } - $icon_inner = ''; + $icon_inner = aksrt_card_icon_inner($icon_fa); } $icon = '' . $icon_inner . ''; @@ -181,7 +232,12 @@ if (!function_exists('aksrt_icon_cover_card')) : $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 = ''; - if ($icon_img === '' && $custom_src !== '' && str_contains($custom_src, '<')) { + $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], @@ -240,20 +296,9 @@ if (!function_exists('aksrt_icon_cover_card')) : } elseif ($icon_img !== '') { $icon_inner = ''; } else { - // legacy: legacy($args['icon'], 'em09') — keep FA class + em09. - if ($icon_fa !== '' && !str_contains($icon_fa, 'fa-') && !str_contains($icon_fa, ' ') && !str_starts_with($icon_fa, 'aksrtsvg-')) { - $icon_fa = 'fa fas fa-' . $icon_fa; - } - if (str_contains($icon_fa, '<')) { - $icon_inner = wp_kses($icon_fa, [ - 'i' => ['class' => true, 'aria-hidden' => true], - 'svg' => ['class' => true, 'viewbox' => true, 'viewBox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true], - 'path' => ['d' => true, 'fill' => true], - 'use' => ['href' => true, 'xlink:href' => true], - ]); - } else { - $icon_inner = ''; - } + // 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 = '
' . $icon_inner . '
'; diff --git a/inc/helpers/aksrt-icons.php b/inc/helpers/aksrt-icons.php index 582a3e28..5cd02037 100644 --- a/inc/helpers/aksrt-icons.php +++ b/inc/helpers/aksrt-icons.php @@ -272,11 +272,21 @@ function aksrt_svg_kses_tags(): array { return $tags; } + // Shape list mirrors the admin picker's cleanSvg() accept test — every + // element it lets through must survive the kses pass, otherwise a pasted + // //… SVG is silently saved without its shapes. + $shape_attrs = ['fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true]; + $tags = [ - 'svg' => ['class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true], - 'use' => ['href' => true, 'xlink:href' => true], - 'path' => ['d' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true], - 'g' => ['fill' => true, 'transform' => true], + 'svg' => ['class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true], + 'use' => ['href' => true, 'xlink:href' => true], + 'path' => ['d' => true] + $shape_attrs, + 'g' => ['fill' => true, 'transform' => true], + 'circle' => ['cx' => true, 'cy' => true, 'r' => true] + $shape_attrs, + 'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true] + $shape_attrs, + 'rect' => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true] + $shape_attrs, + 'polygon' => ['points' => true] + $shape_attrs, + 'line' => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true] + $shape_attrs, ]; return $tags; } diff --git a/src/Admin/PageConfigMetaBox.php b/src/Admin/PageConfigMetaBox.php index 7b26b76d..89a121e5 100644 --- a/src/Admin/PageConfigMetaBox.php +++ b/src/Admin/PageConfigMetaBox.php @@ -252,7 +252,11 @@ final class PageConfigMetaBox { $cards[] = [ 'title' => wp_kses_post((string) ($card['title'] ?? '')), 'desc' => wp_kses_post((string) ($card['desc'] ?? '')), - 'icon' => sanitize_text_field((string) ($card['icon'] ?? '')), + // 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') + ? aksrt_sanitize_icon_value((string) ($card['icon'] ?? '')) + : sanitize_text_field((string) ($card['icon'] ?? '')), 'customize_icon' => wp_kses_post((string) ($card['customize_icon'] ?? '')), 'icon_class' => sanitize_text_field((string) ($card['icon_class'] ?? '')), 'link' => [ diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 67c3b32b..01469823 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -903,6 +903,16 @@ class SchemaProvider { return $this->sanitize_value($value, 'attachment_ids'); } + // Icon values carry inline "customsvg-" payloads that plain + // sanitize_text_field would strip down to a bare prefix. Top-level icon + // fields already route through sanitize_value('icon'); group/repeater + // sub-fields (e.g. header_slider_card_option.cards[].icon) must too. + if ($type === 'icon') { + return function_exists('aksrt_sanitize_icon_value') + ? aksrt_sanitize_icon_value((string) $value) + : $this->sanitize_value($value, 'icon'); + } + if (in_array($type, ['select', 'radio'], true) && !empty($field['case_insensitive']) && !empty($field['options'])) { // 旧自由文本值(如小写货币代码)归一化为选项规范写法。 $normalized = $this->normalize_case_insensitive_option($value, (array) $field['options']); diff --git a/tests/widget-system/icon-card-contract.test.cjs b/tests/widget-system/icon-card-contract.test.cjs index b5f79c79..ac3f0336 100644 --- a/tests/widget-system/icon-card-contract.test.cjs +++ b/tests/widget-system/icon-card-contract.test.cjs @@ -69,3 +69,58 @@ test('header icon-card defaults use bundled Font Awesome icons', () => { } } }); + +test('icon card renderers route every picker format through the shared icon renderer', () => { + const helper = read('inc/helpers/aksrt-icon-cards.php'); + + // One resolver owns the icon-value contract for both card shapes. + assert.match(helper, /function aksrt_card_icon_inner\(string \$value, string \$extra = ''\): string/); + assert.match(helper, /if \(function_exists\('aksrt_icon_markup'\)\) \{\s*return aksrt_icon_markup\(\$value, \$extra\);/s); + + // Both renderers must delegate: emitting a bare FA class string produced + // ``, i.e. an invisible glyph, for the two + // SVG formats the admin picker can store. + assert.match(helper, /\$icon_inner = aksrt_card_icon_inner\(\$icon_fa, 'em09'\);/); + assert.match(helper, /\$icon_inner = aksrt_card_icon_inner\(\$icon_fa\);/); + + // The picker prefixes must drive the SVG branches, and the stale + // `aksrtsvg-` prefix (never emitted by any picker) must be gone. + assert.match(helper, /str_starts_with\(\$value, 'zibsvg-'\) \|\| str_starts_with\(\$value, 'customsvg-'\)/); + assert.doesNotMatch(helper, /aksrtsvg-/); + assert.doesNotMatch(helper, /'fa fas fa-'\s*\.\s*\$icon_fa/); + assert.doesNotMatch(helper, / { + const provider = read('src/Admin/SchemaProvider.php'); + const metaBox = read('src/Admin/PageConfigMetaBox.php'); + + // header_slider_card_option.cards[].icon is a group sub-field: plain + // sanitize_text_field stripped "customsvg-" down to a bare prefix. + assert.match(provider, /if \(\$type === 'icon'\) \{\s*return function_exists\('aksrt_sanitize_icon_value'\)/s); + assert.match(metaBox, /'icon'\s*=>\s*function_exists\('aksrt_sanitize_icon_value'\)/); + assert.doesNotMatch(metaBox, /'icon'\s*=>\s*sanitize_text_field\(\(string\) \(\$card\['icon'\]/); +}); + +test('inline custom SVG shapes survive the kses allow list', () => { + const icons = read('inc/helpers/aksrt-icons.php'); + const start = icons.indexOf('function aksrt_svg_kses_tags'); + const ksesBody = icons.slice(start, icons.indexOf('endif;', start)); + + // Any element the picker's cleanSvg() lets through must also be accepted + // here, otherwise the pasted icon is saved without its shapes. + const pickerFiles = ['assets/js/admin-icon-picker.js', 'assets/admin/src/utils/icons.js']; + for (const file of pickerFiles) { + assert.match( + read(file), + /\/`), `aksrt_svg_kses_tags() is missing <${tag}>`); + } + assert.match(ksesBody, /'viewbox'/); + assert.match(ksesBody, /'xlink:href'/); +});