feat(icons): add the FA5 catalog, REST endpoint and unified icon helpers

Mirror the reference theme's universal picker on FA5: a generated full
catalog of the bundled FA5 Free 5.15.4 fonts (src/Icons/fa5-icons.php,
generated from the shipped CSS + SVG fonts), a zibsvg group parsed from
the runtime sprite so it cannot drift, and GET /aksrt/v1/icons/catalog.

Shared admin pieces: aksrt_icon_markup()/aksrt_sanitize_icon_value()
with an SVG allowlist, the picker field/modal assets, and one canonical
aksrt-font-awesome handle registered through aksrt_enqueue_fontawesome().
This commit is contained in:
kate522 committed 2026-09-06 12:59:03 +08:00
1 parent 9496db5171
commit f629a4f373
12 files changed
+2541 -18

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ defined('ABSPATH') || exit;
// Stage 1: helpers
require_once AKSRT_DIR . '/inc/aksrt-ajax-debug.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-icons.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-icon-picker.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-avatar.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-domain-model.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-template-tags.php';
+1 -6
View File
@@ -448,12 +448,7 @@ add_filter('manage_post_posts_columns', function ($columns) {
add_action('admin_enqueue_scripts', function ($hook) {
if ($hook !== 'edit.php') return;
wp_enqueue_style(
'aksrt-font-awesome',
get_template_directory_uri() . '/assets/fontawesome5/css/all.min.css',
[],
'5.15.4'
);
aksrt_enqueue_fontawesome();
});
add_action('manage_post_posts_custom_column', function ($column, $post_id) {
+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* AKSRT Theme — Universal icon picker (admin side)
*
* Server-side assets and modal markup for the shared FA5 + built-in SVG +
* custom SVG picker. Field markup itself comes from aksrt_icon_picker_field().
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
if (!function_exists('aksrt_icon_picker_assets')) :
/**
* Enqueue the universal icon picker on any admin screen that needs it.
*
* Safe to call multiple times; assets and the footer modal are wired once.
* FA5 CSS, the frontend SVG sprite (defines window.aksrt_svgs and injects the
* symbol sheet) and the picker script/CSS are all included.
*
* @param array $deps Extra script dependencies for the picker script.
*/
function aksrt_icon_picker_assets(array $deps = []): void {
static $done = false;
if ($done) {
return;
}
$done = true;
$css_mtime = (string) filemtime(AKSRT_DIR . '/assets/css/admin-icon-picker.css');
$js_mtime = (string) filemtime(AKSRT_DIR . '/assets/js/admin-icon-picker.js');
aksrt_enqueue_fontawesome();
wp_enqueue_style('aksrt-icon-picker', AKSRT_URI . '/assets/css/admin-icon-picker.css', ['aksrt-font-awesome'], $css_mtime);
wp_enqueue_script('aksrt-svg-sprite', AKSRT_URI . '/assets/js/runtime/svg-icon.js', ['jquery'], $js_mtime, true);
wp_enqueue_script('aksrt-icon-picker', AKSRT_URI . '/assets/js/admin-icon-picker.js', array_merge(['jquery', 'aksrt-svg-sprite'], $deps), $js_mtime, true);
wp_localize_script('aksrt-icon-picker', 'aksrtIconPicker', [
'restUrl' => esc_url_raw(rest_url('aksrt/v1/icons/catalog')),
'nonce' => wp_create_nonce('wp_rest'),
'i18n' => [
'title' => __('选择图标', 'aksrt'),
'search' => __('搜索图标…', 'aksrt'),
'empty' => __('未找到匹配图标', 'aksrt'),
'customTitle' => __('粘贴 SVG 图标代码', 'aksrt'),
'customDesc' => __('粘贴 <svg viewBox="0 0 1024 1024">…</svg> 格式的代码,确认后以内联方式保存到当前字段。', 'aksrt'),
'customSubmit' => __('确认插入', 'aksrt'),
'customEmpty' => __('未检测到有效的 SVG 代码。', 'aksrt'),
'loadError' => __('图标目录加载失败,请刷新重试。', 'aksrt'),
'clear' => __('清除', 'aksrt'),
],
]);
add_action('admin_footer', 'aksrt_icon_picker_modal', 20);
add_action('customize_controls_print_footer_scripts', 'aksrt_icon_picker_modal', 20);
}
endif;
if (!function_exists('aksrt_icon_picker_modal')) :
/**
* Print the shared picker modal once per admin page.
*/
function aksrt_icon_picker_modal(): void {
static $printed = false;
if ($printed) {
return;
}
$printed = true;
?>
<div id="aksrt-modal-icon" class="aksrt-icon-modal hidden">
<div class="aksrt-icon-modal__overlay" data-aksrt-icon-close></div>
<div class="aksrt-icon-modal__inner" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e('选择图标', 'aksrt'); ?>">
<div class="aksrt-icon-modal__header">
<strong><?php esc_html_e('选择图标', 'aksrt'); ?></strong>
<button type="button" class="button-link aksrt-icon-modal__close" data-aksrt-icon-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>">×</button>
</div>
<div class="aksrt-icon-modal__toolbar">
<input type="search" class="aksrt-icon-modal__search regular-text" placeholder="<?php esc_attr_e('搜索图标…', 'aksrt'); ?>">
<div class="aksrt-icon-modal__tabs" role="tablist"></div>
</div>
<div class="aksrt-icon-modal__body">
<div class="aksrt-icon-modal__loading" hidden><span class="spinner is-active"></span></div>
<div class="aksrt-icon-modal__error" hidden></div>
<div class="aksrt-icon-modal__grids"></div>
<div class="aksrt-icon-modal__custom" hidden>
<p class="description"><?php esc_html_e('粘贴 SVG 图标代码', 'aksrt'); ?></p>
<textarea class="large-text code" rows="5" placeholder='&lt;svg viewBox="0 0 1024 1024"&gt;&lt;path d="…"&gt;&lt;/path&gt;&lt;/svg&gt;'></textarea>
<div class="aksrt-icon-modal__custom-actions">
<span class="aksrt-icon-modal__custom-preview" aria-hidden="true"></span>
<button type="button" class="button button-primary"><?php esc_html_e('确认插入', 'aksrt'); ?></button>
</div>
<div class="aksrt-icon-modal__grid aksrt-icon-modal__grid--session"></div>
</div>
</div>
</div>
</div>
<?php
}
endif;
if (!function_exists('aksrt_icon_picker_field')) :
/**
* Render a universal icon-picker field (preview + choose/clear + text input).
*
* The text input stays visible so raw class strings remain editable and
* dependency evaluation (which reads the first input in the field) keeps
* working; the picker simply fills it.
*
* @param string $name Input name.
* @param string $id Input id.
* @param string $value Current value.
* @param array $args {placeholder?: string, class?: string}
* @return string
*/
function aksrt_icon_picker_field(string $name, string $id, string $value, array $args = []): string {
$value = (string) $value;
$placeholder = (string) ($args['placeholder'] ?? 'fa fas fa-star');
$wrap_class = trim('aksrt-icon-picker ' . (string) ($args['class'] ?? ''));
$has_value = $value !== '';
ob_start();
?>
<div class="<?php echo esc_attr($wrap_class); ?>" data-aksrt-icon-picker>
<span class="aksrt-icon-picker__preview<?php echo $has_value ? '' : ' hidden'; ?>"><?php echo aksrt_icon_markup($value); // pre-escaped ?></span>
<span class="aksrt-icon-picker__actions">
<button type="button" class="button aksrt-icon-picker__choose"><?php esc_html_e('选择图标', 'aksrt'); ?></button>
<button type="button" class="button-link-delete aksrt-icon-picker__remove<?php echo $has_value ? '' : ' hidden'; ?>"><?php esc_html_e('清除', 'aksrt'); ?></button>
</span>
<input type="text" class="widefat aksrt-icon-picker__value" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo esc_attr($placeholder); ?>">
</div>
<?php
return (string) ob_get_clean();
}
endif;
+140
View File
@@ -239,6 +239,146 @@ function aksrt_contact_icon_options($selected = '') {
}
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;
}
$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],
];
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
* - "zibsvg-hot" — theme built-in SVG sprite key
* - "customsvg-<svg …>" — inline user SVG markup
*
* @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>';
}
if (!str_contains($value, 'fa ') && str_contains($value, 'fa-')) {
$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)
// ============================================================