fix(icons): 图标卡片支持内置 SVG 与自定义 SVG,修复前端图标不显示
顶部多功能组件的图标卡片只能显示 FA5 图标。后台「选择图标 → 内置 SVG」
页签写入的是 zibsvg-{key},但渲染器把它拼成了
<i class="fa fas fa-zibsvg-hot">,浏览器没有这个名字的字形,前端图标位置
空白。
根因: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-<svg> 的标签整段剥成裸前缀
- 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() 的接受范围对齐;原先粘贴以 <circle> 为主的图标
会在 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 种取值并走完整
「保存净化 → 前端渲染」链路,修复前 / 后输出逐项对比。
This commit is contained in:
@@ -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-<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).
|
||||
@@ -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 = '<img' . aksrt_get_lazy_attr('lazy_other', $icon_img, 'img-icon') . ' alt="">';
|
||||
} else {
|
||||
if ($icon_fa !== '' && !str_contains($icon_fa, 'fa-') && !str_contains($icon_fa, ' ')) {
|
||||
$icon_fa = 'fa fas fa-' . $icon_fa;
|
||||
}
|
||||
$icon_inner = '<i class="' . esc_attr($icon_fa) . '" aria-hidden="true"></i>';
|
||||
$icon_inner = aksrt_card_icon_inner($icon_fa);
|
||||
}
|
||||
|
||||
$icon = '<span class="' . esc_attr(trim($icon_class)) . '"' . $style . '>' . $icon_inner . '</span>';
|
||||
@@ -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 = '<img' . aksrt_get_lazy_attr('lazy_other', $icon_img, 'img-icon') . ' alt="">';
|
||||
} 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 = '<i class="' . esc_attr($icon_fa) . ' em09" aria-hidden="true"></i>';
|
||||
}
|
||||
// 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>';
|
||||
|
||||
|
||||
@@ -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
|
||||
// <circle>/<rect>/… 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;
|
||||
}
|
||||
|
||||
@@ -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-<svg>" 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' => [
|
||||
|
||||
@@ -903,6 +903,16 @@ class SchemaProvider {
|
||||
return $this->sanitize_value($value, 'attachment_ids');
|
||||
}
|
||||
|
||||
// Icon values carry inline "customsvg-<svg>" 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']);
|
||||
|
||||
@@ -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 class="fa fas fa-zibsvg-hot">`, 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, /<i class="'\s*\.\s*esc_attr\(\$icon_fa\)\s*\.\s*' em09"/);
|
||||
});
|
||||
|
||||
test('custom SVG payloads survive the admin save path for nested icon sub-fields', () => {
|
||||
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-<svg>" 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),
|
||||
/\/<path\|<use\|<g\\b\|<circle\|<rect\|<polygon\|<ellipse\|<line\/i/,
|
||||
`${file} must keep its cleanSvg accept test in sync with the PHP allow list`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of ['circle', 'ellipse', 'rect', 'polygon', 'line', 'path', 'g', 'use']) {
|
||||
assert.match(ksesBody, new RegExp(`'${tag}'\\s*=>`), `aksrt_svg_kses_tags() is missing <${tag}>`);
|
||||
}
|
||||
assert.match(ksesBody, /'viewbox'/);
|
||||
assert.match(ksesBody, /'xlink:href'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user