Files
kate522 0afead968a fix(icons): emit FA5 only and upgrade legacy FA4 icon values
The theme is FA5-only, but aksrt_icon_markup() prefixed bare glyphs with
the FA4 style token, so stored icon-picker values rendered FA4 markup,
and three runtime strings still carried the FA4 double-fa form (report
modal header and submit button, comment report menu item). That left the
navigation icon contract failing at 11/12.

- aksrt_icon_markup() normalizes bare glyphs, legacy FA4 values and
  style-only values to "fa fas fa-<glyph>", keeping the FA5 brand family
  (fa fab) and trailing classes intact.
- aksrt_normalize_icon_key() strips the legacy FA4 prefix so stored
  values resolve against the brand-icon catalog again.
- report.js and the comment report item emit FA5 markup.
- An icon-picker contract test locks the upgrade behaviour.

Verification: 338 widget-system, 12 navigation, 28 admin and 13
front-submit contract tests pass; normalization also exercised against
legacy, FA5 and brand inputs.
2026-09-20 18:48:22 +08:00

457 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT Theme — Brand / contact icons (Font Awesome 5)
*
* Short-key → FA5 class map, escaped <i> markup, contact option labels,
* and public social-link renderer. Local SVG inventory removed.
*
* @package AKSRT
* @version 3.4.0
*/
defined('ABSPATH') || exit;
// ============================================================
// PHP 7.x 兼容辅助函数(替代 PHP 8.0+ 的 match 表达式)
// ============================================================
if (!function_exists('aksrt_parse_country_list')):
/**
* 解析 GeoIP 屏蔽国家列表字符串为数组
*
* @param string $raw 逗号分隔的国家代码
* @return array 大写国家代码数组
*/
function aksrt_parse_country_list($raw) {
if (empty($raw)) return array();
$parts = explode(',', wp_unslash($raw));
$result = array();
foreach ($parts as $p) {
$trimmed = trim($p);
if ($trimmed !== '') {
$result[] = strtoupper($trimmed);
}
}
return array_values(array_filter($result));
}
endif;
// ============================================================
// Brand icon map (FA 5.15.4)
// ============================================================
if (!function_exists('aksrt_brand_icon_map')) :
/**
* Short key → FA5 class string (allowlisted).
*
* @return array<string, string>
*/
function aksrt_brand_icon_map(): array {
static $map = null;
if ($map !== null) {
return $map;
}
// FA5 Free has no bilibili/gitee brands — use solid stand-ins.
$map = [
'github' => 'fa fab fa-github',
'google' => 'fa fab fa-google',
'x' => 'fa fab fa-twitter',
'twitter' => 'fa fab fa-twitter',
'email' => 'fa fas fa-envelope',
'envelope' => 'fa fas fa-envelope',
'wechat' => 'fa fab fa-weixin',
'weixin' => 'fa fab fa-weixin',
'qq' => 'fa fab fa-qq',
'weibo' => 'fa fab fa-weibo',
'telegram' => 'fa fab fa-telegram-plane',
'discord' => 'fa fab fa-discord',
'bilibili' => 'fa fas fa-tv',
'linkedin' => 'fa fab fa-linkedin',
'facebook' => 'fa fab fa-facebook-f',
'youtube' => 'fa fab fa-youtube',
'instagram' => 'fa fab fa-instagram',
'web' => 'fa fas fa-globe',
'globe' => 'fa fas fa-globe',
'gitee' => 'fa fas fa-code-branch',
'baidu' => 'fa fas fa-paw',
'alipay' => 'fa fab fa-alipay',
];
return $map;
}
endif;
if (!function_exists('aksrt_normalize_icon_key')) :
/**
* Normalize short key or full FA class string to a short map key.
*
* Examples: 'fa fab fa-github' | 'fa-github' | 'github' | 'weixin' → 'github' | 'wechat'
*
* Legacy FA4 values (a bare `fa` token followed by `fa-<glyph>`) are stripped too.
*
* @param string $name Raw icon key or FA class string.
* @return string Short key (may be empty).
*/
function aksrt_normalize_icon_key(string $name): string {
$name = trim($name);
if ($name === '') {
return '';
}
// Strip the FA5 layout/style prefixes, the glyph prefix and the legacy FA4
// double-`fa` form (a bare `fa` token followed by `fa-<glyph>`). The site
// ships FA5 only, but stored values may predate it.
$name = preg_replace('/^fa\s+(?:fas|far|fab)\s+/i', '', $name);
$name = preg_replace('/^fa\s+fa-/i', '', (string) $name);
$name = preg_replace('/^(?:fas|far|fab)\s+/i', '', (string) $name);
$name = preg_replace('/^fa-/i', '', (string) $name);
$name = trim((string) $name, "- \t\n\r\0\x0B");
$name = strtolower($name);
// Aliases → canonical keys used by the map / contact options.
$aliases = [
'weixin' => 'wechat',
'weixingzh' => 'wechat',
'twitter' => 'x',
'envelope' => 'email',
'globe' => 'web',
];
if (isset($aliases[$name])) {
return $aliases[$name];
}
return $name;
}
endif;
if (!function_exists('aksrt_brand_icon_class')) :
/**
* Resolve a short key or FA class string to an allowlisted FA5 class.
*
* @param string $name Short key or full FA class string.
* @return string e.g. 'fa fab fa-github', or '' if unknown.
*/
function aksrt_brand_icon_class(string $name): string {
$raw = trim($name);
if ($raw === '') {
return '';
}
// Pass through already-valid full FA class strings (footer contact_more).
if (preg_match('/^fa\s+(?:fas|far|fab)\s+fa-[\w-]+(?:\s+fa-[\w-]+)*$/i', $raw)) {
// Prefer mapped short key when strip matches our catalog (normalizes weixin etc.).
$key = aksrt_normalize_icon_key($raw);
$map = aksrt_brand_icon_map();
if ($key !== '' && isset($map[$key])) {
return $map[$key];
}
// Allowlisted style + glyph only — collapse whitespace, lower style prefix.
$parts = preg_split('/\s+/', strtolower($raw));
$parts = array_values(array_filter((array) $parts));
return implode(' ', $parts);
}
$key = aksrt_normalize_icon_key($raw);
if ($key === '') {
return '';
}
$map = aksrt_brand_icon_map();
return $map[$key] ?? '';
}
endif;
if (!function_exists('aksrt_brand_icon')) :
/**
* Escaped FA <i> markup for a brand/contact icon.
*
* @param string $name Short key or FA class string.
* @param array|string $extra_classes Optional extra classes.
* @return string Safe HTML or empty string when unknown.
*/
function aksrt_brand_icon(string $name, $extra_classes = []): string {
$class = aksrt_brand_icon_class($name);
if ($class === '') {
return '';
}
if (is_string($extra_classes) && $extra_classes !== '') {
$extra_classes = preg_split('/\s+/', $extra_classes) ?: [];
}
if (is_array($extra_classes) && $extra_classes !== []) {
$extra = array_filter(array_map('sanitize_html_class', $extra_classes));
if ($extra !== []) {
$class .= ' ' . implode(' ', $extra);
}
}
return '<i class="' . esc_attr($class) . '" aria-hidden="true"></i>';
}
endif;
// ============================================================
// 图标 key → 显示名称
// ============================================================
if (!function_exists('aksrt_icon_key_to_name')) :
function aksrt_icon_key_to_name($key) {
$key = aksrt_normalize_icon_key((string) $key);
$map = array(
'github' => 'GitHub', 'google' => 'Google', 'x' => 'X',
'email' => '邮箱', 'wechat' => '微信', 'qq' => 'QQ',
'weibo' => '微博', 'telegram' => 'Telegram',
'discord' => 'Discord', 'bilibili' => 'Bilibili',
'linkedin' => 'LinkedIn', 'facebook' => 'Facebook',
'youtube' => 'YouTube', 'instagram' => 'Instagram',
'web' => '网站',
'gitee' => 'Gitee',
'baidu' => 'Baidu',
'alipay' => 'Alipay',
);
return isset($map[$key]) ? $map[$key] : ucwords(str_replace('-', ' ', $key));
}
endif;
// ============================================================
// 联系方式图标选项(仅保留选定的 15 个)
// ============================================================
if (!function_exists('aksrt_contact_icon_options')) :
function aksrt_contact_icon_options($selected = '') {
$icons = array(
'github' => 'GitHub',
'google' => 'Google',
'x' => 'X',
'email' => '邮箱',
'wechat' => '微信',
'qq' => 'QQ',
'weibo' => '微博',
'telegram' => 'Telegram',
'discord' => 'Discord',
'bilibili' => 'Bilibili',
'linkedin' => 'LinkedIn',
'facebook' => 'Facebook',
'youtube' => 'YouTube',
'instagram' => 'Instagram',
'web' => '网站',
);
$selected = aksrt_normalize_icon_key((string) $selected);
$html = '<option value="">—</option>';
foreach ($icons as $key => $label) {
$sel = $key === $selected ? ' selected' : '';
$html .= '<option value="' . esc_attr($key) . '"' . $sel . '>' . esc_html($label) . '</option>';
}
return $html;
}
endif;
// ============================================================
// 统一图标渲染(FA5 / 内置 SVG / 自定义 SVG)
// ============================================================
if (!function_exists('aksrt_enqueue_fontawesome')) :
/**
* Register (once) and enqueue the bundled Font Awesome 5 pack under the
* single canonical handle. Every context — public, admin screens, widgets,
* the icon picker — must go through this helper.
*
* @param array $deps Style dependencies for the first caller.
* @return string The canonical handle.
*/
function aksrt_enqueue_fontawesome(array $deps = []): string {
if (!wp_style_is('aksrt-font-awesome', 'registered')) {
wp_register_style('aksrt-font-awesome', AKSRT_URI . '/assets/fontawesome5/css/all.min.css', $deps, '5.15.4');
}
wp_enqueue_style('aksrt-font-awesome');
return 'aksrt-font-awesome';
}
endif;
if (!function_exists('aksrt_svg_kses_tags')) :
/**
* Allowed SVG tags/attributes for user-supplied custom SVG icon markup.
*
* @return array<string, array<string, bool>>
*/
function aksrt_svg_kses_tags(): array {
static $tags = null;
if ($tags !== null) {
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] + $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;
}
endif;
if (!function_exists('aksrt_icon_markup')) :
/**
* Render an icon-picker value of any supported format.
*
* Supported values:
* - "fa fas fa-star" / "fa fab fa-weixin" — FA5 class string
* - "fa-star" — bare glyph, rendered as solid FA5
* - legacy FA4 string — double-`fa` form, upgraded to FA5
* - "zibsvg-hot" — theme built-in SVG sprite key
* - "customsvg-<svg …>" — inline user SVG markup
*
* Output is always FA5 — the legacy FA4 double-`fa` prefix is never emitted.
*
* @param string $value Raw stored value.
* @param array|string $extra_classes Extra classes for the rendered element.
* @return string Safe HTML, empty string for empty values.
*/
function aksrt_icon_markup(string $value, $extra_classes = []): string {
$value = trim($value);
if ($value === '') {
return '';
}
$extra = '';
if (is_string($extra_classes) && $extra_classes !== '') {
$extra_classes = preg_split('/\s+/', $extra_classes) ?: [];
}
if (is_array($extra_classes)) {
$clean = array_filter(array_map('sanitize_html_class', $extra_classes));
if ($clean !== []) {
$extra = ' ' . implode(' ', $clean);
}
}
if (str_starts_with($value, 'customsvg-')) {
$svg = wp_kses(substr($value, 10), aksrt_svg_kses_tags());
if ($svg === '') {
return '';
}
if ($extra !== '' && stripos($svg, '<svg') === 0 && stripos($svg, 'class=') === false) {
$svg = preg_replace('/<svg\b/i', '<svg class="' . ltrim($extra) . '"', $svg, 1);
}
return $svg;
}
if (str_starts_with($value, 'zibsvg-')) {
$key = sanitize_key(substr($value, 7));
if ($key === '') {
return '';
}
$class = $extra !== '' ? ltrim($extra) : 'icon';
return '<svg class="' . esc_attr($class) . '" aria-hidden="true"><use xlink:href="#icon-' . $key . '"></use></svg>';
}
// 本站统一 FA5 输出:遗留 FA4 双 fa 前缀、裸字形 "fa-xxx" 与缺少 fa 前缀的
// "fas/far/fab fa-xxx" 均归一为 "fa fas fa-xxx"(末尾附加类名原样保留)。
if (preg_match('/^fa\s+fa-/i', $value)) {
$value = preg_replace('/^fa\s+/i', 'fa fas ', $value, 1);
} elseif (preg_match('/^fa-/i', $value)) {
$value = 'fa fas ' . $value;
} elseif (preg_match('/^(?:fas|far|fab)\s+fa-/i', $value)) {
$value = 'fa ' . $value;
}
return '<i class="' . esc_attr($value . $extra) . '" aria-hidden="true"></i>';
}
endif;
if (!function_exists('aksrt_sanitize_icon_value')) :
/**
* Sanitize an icon-picker value. Keeps the SVG markup of customsvg- values
* (plain sanitize_text_field would strip the tags), filters FA/zip keys.
*
* @param string $value Raw value from a form/REST payload.
* @return string Sanitized value.
*/
function aksrt_sanitize_icon_value(string $value): string {
$value = trim($value);
if ($value === '') {
return '';
}
if (str_starts_with($value, 'customsvg-')) {
$svg = wp_kses(substr($value, 10), aksrt_svg_kses_tags());
return $svg === '' ? '' : 'customsvg-' . $svg;
}
return sanitize_text_field($value);
}
endif;
if (!function_exists('aksrt_empty_state_svgs')) :
/**
* Shared empty-state SVG icons (24px stroke style, not sprite keys).
*
* @return array{post:string,comment:string,favorite:string,follow:string}
*/
function aksrt_empty_state_svgs(): array {
return [
'post' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h6M9 9h1"/></svg>',
'comment' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'favorite' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'follow' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
];
}
endif;
// ============================================================
// Public social links (author hero / profile)
// ============================================================
if (!function_exists('aksrt_render_social_links')) :
/**
* Echo a list of public contact links with FA icons.
*
* @param array<int, array{name?:string,url?:string,icon?:string,title?:string,label?:string}> $contacts
*/
function aksrt_render_social_links(array $contacts): void {
$links = [];
foreach ($contacts as $contact) {
if (!is_array($contact)) {
continue;
}
$url = isset($contact['url']) ? (string) $contact['url'] : '';
$icon = isset($contact['icon']) ? (string) $contact['icon'] : '';
if ($url === '' || $icon === '') {
continue;
}
$icon_html = aksrt_brand_icon($icon);
if ($icon_html === '') {
$icon_html = '<i class="fa fas fa-globe" aria-hidden="true"></i>';
}
$label = (string) ($contact['name'] ?? $contact['title'] ?? $contact['label'] ?? '');
if ($label === '') {
$label = aksrt_icon_key_to_name($icon);
}
$links[] = [
'url' => $url,
'label' => $label,
'icon' => $icon_html,
];
}
if ($links === []) {
return;
}
echo '<div class="profile-social-links">';
foreach ($links as $link) {
printf(
'<a class="social-icon-link" href="%1$s" target="_blank" rel="noopener noreferrer" title="%2$s" aria-label="%2$s">%3$s</a>',
esc_url($link['url']),
esc_attr($link['label']),
$link['icon'] // pre-escaped via aksrt_brand_icon / fixed globe fallback
);
}
echo '</div>';
}
endif;