29 KiB
CLAUDE.md — Maple Icons WordPress Plugin
Quick Reference
| Document | When to Reference |
|---|---|
| CLAUDE.md (this file) | Architecture, file structure, build order |
| SECURITY.md | Writing ANY PHP code (same patterns as Maple Local Fonts) |
Project Overview
Build a WordPress plugin called Maple Icons that:
- Fetches open-source icon sets from CDN and stores them locally
- Provides preset icon sets: Heroicons, Lucide, Feather, Phosphor, Material
- Only ONE icon set can be active at a time (though multiple can be downloaded)
- Offers a Gutenberg block "Maple Icons" for inserting icons
- Icons use
currentColorto inherit text color from Global Styles
Key principle: Download once, serve locally. Zero runtime external requests after initial fetch.
Requirements
- Minimum PHP: 7.4
- Minimum WordPress: 6.5 (for block.json apiVersion 3 and Global Styles)
- License: GPL-2.0-or-later
Preset Icon Sets
All icons fetched from jsdelivr CDN with pinned versions:
| Set | Package | Version | Styles | ~Icons | viewBox |
|---|---|---|---|---|---|
| Heroicons | heroicons |
2.1.1 | outline, solid, mini | 290 | 24×24 |
| Lucide | lucide-static |
0.303.0 | icons | 1,400 | 24×24 |
| Feather | feather-icons |
4.29.1 | icons | 280 | 24×24 |
| Phosphor | @phosphor-icons/core |
2.1.1 | regular, bold, light, thin, fill, duotone | 1,200× styles | 256×256 (normalize) |
| Material | @material-design-icons/svg |
0.14.13 | filled, outlined, round, sharp, two-tone | 2,500 | 24×24 |
CDN URL Patterns
Heroicons: https://cdn.jsdelivr.net/npm/heroicons@2.1.1/24/outline/{name}.svg
Lucide: https://cdn.jsdelivr.net/npm/lucide-static@0.303.0/icons/{name}.svg
Feather: https://cdn.jsdelivr.net/npm/feather-icons@4.29.1/dist/icons/{name}.svg
Phosphor: https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2.1.1/assets/{style}/{name}.svg
Material: https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.13/{style}/{name}.svg
SVG Normalization Required
| Set | Issue | Fix |
|---|---|---|
| Phosphor | viewBox="0 0 256 256" | Normalize to 24×24 |
| Material | fill="#000000" hardcoded |
Replace with currentColor |
| All | May have XML declarations, comments | Strip during save |
User Flow
Admin visits Settings → Maple Icons (or plugin action link)
↓
Sees list of preset icon sets with Download/Delete buttons
↓
Clicks "Download" on Heroicons → progress bar → icons saved locally
↓
Clicks "Set Active" to make Heroicons the active set
↓
User inserts "Maple Icons" block in Gutenberg editor
↓
Block shows + button → click → icon picker modal with search
↓
User searches/filters → selects icon → SVG inserted inline
↓
SVG uses currentColor → inherits from Global Styles
↓
All icons served from local storage (wp-content/maple-icons/)
File Structure
maple-icons-wp/
├── maple-icons.php # Main plugin file
├── index.php # Silence is golden
├── uninstall.php # Clean removal
├── readme.txt # WordPress.org readme
├── package.json # Block build config
├── includes/
│ ├── index.php
│ ├── class-mi-icon-sets.php # Preset icon set definitions
│ ├── class-mi-icon-registry.php # Icon management & search
│ ├── class-mi-downloader.php # CDN fetch & local storage
│ ├── class-mi-admin-page.php # Settings page
│ └── class-mi-ajax-handler.php # AJAX handlers
├── presets/ # Bundled manifests (icon names, tags)
│ ├── index.php
│ ├── heroicons.json
│ ├── lucide.json
│ ├── feather.json
│ ├── phosphor.json
│ └── material.json
├── build/ # Compiled block assets (generated)
│ ├── index.php
│ ├── index.js
│ ├── index.asset.php
│ └── style-index.css
├── src/ # Block source (for development)
│ ├── index.js # Block registration
│ ├── edit.js # Editor component
│ ├── save.js # Save component (inline SVG)
│ ├── icon-picker.js # Icon selection modal
│ ├── editor.scss # Editor-only styles
│ └── block.json # Block metadata
├── assets/
│ ├── index.php
│ ├── admin.css # Settings page styles
│ └── admin.js # Settings page JS (download progress, etc.)
└── languages/
├── index.php
└── maple-icons.pot
Local Icon Storage
Icons are stored in:
wp-content/maple-icons/{set-slug}/{style}/{icon-name}.svg
Example:
wp-content/maple-icons/heroicons/outline/academic-cap.svg
wp-content/maple-icons/heroicons/solid/academic-cap.svg
wp-content/maple-icons/lucide/icons/activity.svg
wp-content/maple-icons/phosphor/regular/airplane.svg
Using wp-content/maple-icons/ (not uploads) to avoid cleanup plugin interference.
Class Responsibilities
MI_Icon_Sets
Static definitions of all preset icon sets.
class MI_Icon_Sets {
public static function get_all(): array;
public static function get(string $slug): ?array;
public static function get_cdn_url(string $slug, string $style, string $name): string;
public static function get_manifest_path(string $slug): string;
}
Returns structure:
[
'slug' => 'heroicons',
'name' => 'Heroicons',
'version' => '2.1.1',
'license' => 'MIT',
'url' => 'https://heroicons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/heroicons@2.1.1/',
'styles' => [
'outline' => ['path' => '24/outline', 'label' => 'Outline'],
'solid' => ['path' => '24/solid', 'label' => 'Solid'],
'mini' => ['path' => '20/solid', 'label' => 'Mini'],
],
'default_style' => 'outline',
'viewbox' => '0 0 24 24',
'normalize' => false, // true for Phosphor (256→24)
'color_fix' => false, // true for Material (replace hardcoded colors)
]
MI_Icon_Registry
Manages downloaded sets and provides icon search/retrieval.
class MI_Icon_Registry {
public function get_downloaded_sets(): array;
public function get_active_set(): ?string;
public function set_active(string $slug): bool;
public function is_downloaded(string $slug): bool;
public function get_icons_for_set(string $slug, string $style): array;
public function search_icons(string $query, int $limit = 50): array;
public function get_icon_svg(string $slug, string $style, string $name): string|WP_Error;
public function delete_set(string $slug): bool;
}
MI_Downloader
Handles fetching icons from CDN and storing locally.
class MI_Downloader {
public function download_set(string $slug, callable $progress_callback = null): array|WP_Error;
public function download_icon(string $slug, string $style, string $name): string|WP_Error;
private function normalize_svg(string $svg, array $set_config): string;
private function get_local_path(string $slug, string $style, string $name): string;
}
MI_Admin_Page
Settings page under Settings → Maple Icons.
- List all preset icon sets
- Show download status (downloaded/not downloaded)
- Download button with progress indicator
- Delete button for downloaded sets
- Radio buttons to select active set
- Preview sample icons from each downloaded set
MI_Ajax_Handler
AJAX endpoints:
mi_download_set— Download an icon set from CDNmi_delete_set— Delete a downloaded icon setmi_set_active— Set the active icon setmi_search_icons— Search icons in active set (for block picker)mi_get_icon_svg— Get specific icon SVG (for block)
Settings Storage
// Option name: maple_icons_settings
[
'active_set' => 'heroicons', // Slug of active set, or empty string
'downloaded_sets' => [
'heroicons' => [
'version' => '2.1.1',
'downloaded_at' => '2024-01-15 10:30:00',
'icon_count' => 876, // Total across all styles
],
'lucide' => [
'version' => '0.303.0',
'downloaded_at' => '2024-01-15 11:00:00',
'icon_count' => 1400,
],
],
]
Manifest Format (presets/*.json)
Each preset ships with a manifest listing all icons:
{
"slug": "heroicons",
"name": "Heroicons",
"version": "2.1.1",
"icons": [
{
"name": "academic-cap",
"tags": ["education", "graduation", "school", "hat"],
"category": "objects",
"styles": ["outline", "solid", "mini"]
},
{
"name": "adjustments-horizontal",
"tags": ["settings", "controls", "sliders"],
"category": "ui",
"styles": ["outline", "solid", "mini"]
}
]
}
Manifests are bundled with the plugin to avoid runtime dependency on jsdelivr API.
Gutenberg Block Architecture
Block Registration (block.json)
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetrails/maple-icons",
"version": "1.0.0",
"title": "Maple Icons",
"category": "design",
"icon": "star-filled",
"description": "Insert an icon from your downloaded icon sets.",
"keywords": ["icon", "svg", "symbol", "maple"],
"textdomain": "maple-icons",
"attributes": {
"iconSet": {
"type": "string",
"default": ""
},
"iconStyle": {
"type": "string",
"default": ""
},
"iconName": {
"type": "string",
"default": ""
},
"iconSVG": {
"type": "string",
"default": ""
},
"size": {
"type": "number",
"default": 24
},
"label": {
"type": "string",
"default": ""
},
"strokeWidth": {
"type": "number",
"default": 0
},
"strokeColor": {
"type": "string",
"default": ""
}
},
"supports": {
"html": false,
"align": ["left", "center", "right"],
"color": {
"text": true,
"background": true,
"gradients": true
},
"spacing": {
"margin": true,
"padding": true
},
"shadow": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./style-index.css"
}
Block Style Controls (Inspector Panel)
The block sidebar will include these controls:
Icon Settings:
- Icon picker button (change icon)
- Size slider (8-256px, default 24)
- Accessibility label text input
Color Settings (via WordPress supports):
- Icon color (text color) — inherited by SVG via
currentColor - Background color
- Gradient support
Stroke Settings (custom panel):
- Stroke width slider (0-10px)
- Stroke color picker
Spacing (via WordPress supports):
- Padding controls
- Margin controls
Effects:
- Drop shadow presets (via WordPress
shadowsupport)
Style Application
// In edit.js and save.js, compute styles:
const iconStyles = {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
// Stroke is applied via CSS filter or SVG manipulation
const svgStyles = strokeWidth > 0 ? {
filter: `drop-shadow(0 0 0 ${strokeColor})`,
WebkitTextStroke: `${strokeWidth}px ${strokeColor}`,
} : {};
// Note: For true SVG stroke, we modify the SVG's stroke attribute
// This is handled in the SVG wrapper, not CSS
SVG Stroke Handling
For stroke on SVG icons, we wrap the SVG output:
// The SVG itself uses currentColor for fill/stroke
// Additional stroke effect applied via CSS or inline style
<span
className="wp-block-maple-icon__svg"
style={{
// Apply stroke via paint-order and stroke properties
'--mi-stroke-width': strokeWidth ? `${strokeWidth}px` : undefined,
'--mi-stroke-color': strokeColor || undefined,
}}
dangerouslySetInnerHTML={{ __html: iconSVG }}
/>
With CSS:
.wp-block-maple-icon__svg svg {
stroke: var(--mi-stroke-color, currentColor);
stroke-width: var(--mi-stroke-width, 0);
paint-order: stroke fill;
}
Block Behavior
Initial State (no icon selected):
- Shows + button placeholder
- Click opens icon picker modal
With icon selected:
- Displays inline SVG
- Sidebar shows: size control, accessibility label, change icon button
Icon Picker Modal:
- Search input (filters as you type)
- Style selector dropdown (if set has multiple styles)
- Grid of icon thumbnails
- Click to select → SVG fetched and stored in attributes
Save Output (save.js)
export default function save({ attributes }) {
const { iconSVG, size, label } = attributes;
if (!iconSVG) {
return null;
}
const blockProps = useBlockProps.save({
className: 'wp-block-maple-icon',
style: {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
},
});
return (
<span {...blockProps}>
<span
dangerouslySetInnerHTML={{ __html: iconSVG }}
role={label ? 'img' : 'presentation'}
aria-label={label || undefined}
aria-hidden={!label ? 'true' : undefined}
/>
</span>
);
}
Download Process
Batch Download Flow
- User clicks "Download" for an icon set
- Frontend disables button, shows progress bar
- AJAX request to
mi_download_set - Backend:
a. Load manifest from
presets/{slug}.jsonb. Create local directory structure c. For each icon in manifest:- Fetch SVG from CDN
- Normalize (viewBox, currentColor)
- Save to local filesystem d. Update settings with download info
- Return success with icon count
- Frontend updates UI to show "Downloaded" state
Batching Strategy
To avoid timeouts and memory issues:
- Process icons in batches of 50
- Use streaming/chunked approach
- Allow resume on failure (track progress in transient)
// Download in batches
$batch_size = 50;
$icons = $manifest['icons'];
$total = count($icons);
for ($i = 0; $i < $total; $i += $batch_size) {
$batch = array_slice($icons, $i, $batch_size);
foreach ($batch as $icon) {
// Download each icon in batch
}
// Update progress transient
set_transient('mi_download_progress_' . $slug, [
'completed' => min($i + $batch_size, $total),
'total' => $total,
], HOUR_IN_SECONDS);
}
SVG Sanitization
Same approach as original CLAUDE.md — strict allowlist of SVG elements and attributes.
function mi_sanitize_svg($svg) {
$allowed_tags = [
'svg' => ['xmlns', 'viewbox', 'width', 'height', 'fill', 'stroke', ...],
'path' => ['d', 'fill', 'stroke', 'stroke-width', ...],
'circle' => ['cx', 'cy', 'r', 'fill', 'stroke', ...],
'rect' => ['x', 'y', 'width', 'height', 'rx', 'ry', ...],
'line' => ['x1', 'y1', 'x2', 'y2', ...],
'polyline' => ['points', ...],
'polygon' => ['points', ...],
'ellipse' => ['cx', 'cy', 'rx', 'ry', ...],
'g' => ['fill', 'stroke', 'transform', ...],
];
// Remove dangerous content
$svg = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $svg);
$svg = preg_replace('/\son\w+\s*=/i', ' data-removed=', $svg);
return wp_kses($svg, $allowed_tags);
}
SVG Normalization
function mi_normalize_svg($svg, $set_config) {
// 1. Strip XML declaration
$svg = preg_replace('/<\?xml[^>]*\?>/i', '', $svg);
// 2. Strip comments
$svg = preg_replace('/<!--.*?-->/s', '', $svg);
// 3. Normalize viewBox for Phosphor (256 → 24)
if ($set_config['normalize']) {
$svg = preg_replace('/viewBox=["\']0 0 256 256["\']/', 'viewBox="0 0 24 24"', $svg);
}
// 4. Fix hardcoded colors for Material
if ($set_config['color_fix']) {
$svg = preg_replace('/fill=["\']#[0-9a-fA-F]{3,6}["\']/', 'fill="currentColor"', $svg);
$svg = preg_replace('/fill=["\']black["\']/', 'fill="currentColor"', $svg);
}
// 5. Ensure currentColor is used
// Most sets already use currentColor, but double-check
// 6. Remove width/height attributes (let CSS control)
$svg = preg_replace('/\s(width|height)=["\'][^"\']*["\']/', '', $svg);
return trim($svg);
}
Admin Settings Page UI
┌─────────────────────────────────────────────────────────────────┐
│ Maple Icons │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ICON SETS │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ◉ Heroicons ✓ Downloaded │ │
│ │ 290 icons · Outline, Solid, Mini · MIT │ │
│ │ [Preview: ⚙ 🏠 👤 ✉ 🔔 ⭐] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Lucide ✓ Downloaded │ │
│ │ 1,400 icons · Icons · ISC │ │
│ │ [Preview: ⚙ 📁 📄 💾 🖨 ⬇] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Feather Not downloaded │ │
│ │ 280 icons · Icons · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Phosphor Not downloaded │ │
│ │ 7,200 icons · 6 styles · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Material Not downloaded │ │
│ │ 2,500 icons · 5 styles · Apache 2.0 │ │
│ │ [Download] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ◉ = Active icon set (used in Maple Icons block) │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ USAGE │
│ 1. Download one or more icon sets above │
│ 2. Select which set should be active (radio button) │
│ 3. Insert icons using the "Maple Icons" block in the editor │
│ 4. Icons inherit your theme's text color automatically │
│ │
└─────────────────────────────────────────────────────────────────┘
Icon Picker Modal (Block Editor)
┌─────────────────────────────────────────────────────────────────┐
│ Select Icon [X] │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 🔍 Search icons... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Style: [Outline ▼] │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ ⚙ │ │ 🏠 │ │ 👤 │ │ ✉ │ │ 🔔 │ │ ⭐ │ │ ❤ │ │ 🔍 │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ cog home user mail bell star heart search │ │
│ │ │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ + │ │ ✓ │ │ ✕ │ │ ⬆ │ │ ⬇ │ │ ◀ │ │ ▶ │ │ ↻ │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ plus check x up down left right refresh │ │
│ │ │ │
│ │ [Load More...] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Selected: arrow-right │
│ │
│ [Cancel] [Insert Icon] │
└─────────────────────────────────────────────────────────────────┘
Build Order
Phase 1: Foundation
maple-icons.php— Plugin header, constants, autoloader, activation hookindex.phpfiles in ALL directoriesincludes/class-mi-icon-sets.php— Static preset definitions
Phase 2: Download Infrastructure
includes/class-mi-downloader.php— CDN fetch, normalize, storeincludes/class-mi-icon-registry.php— Downloaded set managementincludes/class-mi-ajax-handler.php— Download/delete/activate endpoints- Create manifest files:
presets/*.json
Phase 3: Admin Interface
includes/class-mi-admin-page.php— Settings pageassets/admin.css— Settings page stylesassets/admin.js— Download progress, AJAX handlers
Phase 4: Gutenberg Block
src/block.json— Block metadatasrc/index.js— Block registrationsrc/edit.js— Editor componentsrc/save.js— Save componentsrc/icon-picker.js— Modal componentsrc/editor.scss— Editor stylespackage.json+ build
Phase 5: Cleanup & Polish
uninstall.php— Clean removalreadme.txt— WordPress.org readme- Testing
Constants
define('MI_VERSION', '1.0.0');
define('MI_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MI_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MI_PLUGIN_BASENAME', plugin_basename(__FILE__));
define('MI_ICONS_DIR', WP_CONTENT_DIR . '/maple-icons/');
define('MI_ICONS_URL', content_url('/maple-icons/'));
define('MI_PRESETS_DIR', MI_PLUGIN_DIR . 'presets/');
// Limits
define('MI_DOWNLOAD_BATCH_SIZE', 50);
define('MI_SEARCH_LIMIT', 50);
define('MI_DOWNLOAD_TIMEOUT', 10); // Per-icon timeout in seconds
Security Checklist
Reference SECURITY.md for full details. Key points:
- ABSPATH check on every PHP file
- index.php in every directory
- Nonce verification FIRST in every AJAX handler
- Capability check SECOND (
manage_optionsfor settings,edit_postsfor block) - Validate icon set slugs against preset allowlist
- Validate style slugs against set's defined styles
- Sanitize all SVG content before storage
- Validate file paths (prevent path traversal)
- Use
wp_remote_get()for CDN requests - Escape all output
WordPress Hooks Used
// Activation/Deactivation
register_activation_hook(__FILE__, 'mi_activate');
register_deactivation_hook(__FILE__, 'mi_deactivate');
// Block registration
add_action('init', 'mi_register_block');
// Admin menu
add_action('admin_menu', 'mi_register_settings_page');
// Admin assets (settings page only)
add_action('admin_enqueue_scripts', 'mi_enqueue_admin_assets');
// Plugin action links (Settings link in plugin list)
add_filter('plugin_action_links_' . MI_PLUGIN_BASENAME, 'mi_add_action_links');
// AJAX handlers (admin only)
add_action('wp_ajax_mi_download_set', 'mi_ajax_download_set');
add_action('wp_ajax_mi_delete_set', 'mi_ajax_delete_set');
add_action('wp_ajax_mi_set_active', 'mi_ajax_set_active');
add_action('wp_ajax_mi_search_icons', 'mi_ajax_search_icons');
add_action('wp_ajax_mi_get_icon_svg', 'mi_ajax_get_icon_svg');
// WooCommerce HPOS compatibility
add_action('before_woocommerce_init', 'mi_declare_hpos_compatibility');
Compatibility Notes
WooCommerce HPOS
Declare compatibility (we don't touch orders):
add_action('before_woocommerce_init', function() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
Caching Plugins
Icons stored locally means no cache issues. SVG is inline in post_content.
Wordfence
CDN requests only happen during admin download action, using standard wp_remote_get().
LearnDash / WPForms
No interference — we only add a block, no frontend hooks.
Testing Checklist
Download Flow
- Download Heroicons → all icons saved locally
- Download progress shows correctly
- Download Lucide (large set) → doesn't timeout
- Delete downloaded set → files removed
- Switch active set → setting persists
Block Editor
- Insert Maple Icons block → shows placeholder
- Click + → opens icon picker
- Search filters icons correctly
- Style dropdown works (for sets with multiple styles)
- Select icon → SVG appears in editor
- Save post → icon persists
- Frontend → icon displays correctly
- Change text color in Global Styles → icon color changes
Security
- Download without nonce → 403
- Download as non-admin → 403
- Invalid set slug → rejected
- Path traversal attempt → rejected
Edge Cases
- No sets downloaded → block shows helpful message
- Active set deleted → block handles gracefully
- CDN unreachable during download → appropriate error
- Partial download failure → can retry/resume