Build an add-on for the Icons panel
Step by step: a small plugin that checks for Easy SVG, cleans SVG with easy_svg_sanitizer() and adds its own tab to the Icons panel, without a build step.
Tutorial · Easy SVG
In this tutorial you build My SVG Check, a small add-on plugin of two files. It adds an SVG check tab to the Icons panel: paste an SVG, and the tab shows what the site's sanitizer removes and what the clean result looks like. On the way you use every public extension point of the panel: the version constants, easy_svg_sanitizer(), the easy_svg_panel_menu_tabs filter, the easy_svg_panel_enqueue action and window.EasySvgPanel.registerTab().
There is no build step. The JavaScript uses wp.element, the React that WordPress already ships, and plain createElement calls instead of JSX.
The Icons panel draws tabs from other add-ons only while Easy SVG Pro is active and licensed (that is how src/panel/App.jsx builds its tab list in Easy SVG 5.4.0). The add-on below checks for that and only adds its menu entry when the tab will be drawn. Its sanitizer route works on any site with Easy SVG. If you need a panel extension on sites without Pro, use registerCollections() instead.
What you need
- WordPress 6.6 or later and PHP 8.0 or later (the same floor as Easy SVG).
- Easy SVG with
EASY_SVG_API1 or higher (it broughteasy_svg_sanitizer()) andEASY_SVG_PANEL_API2 or higher (tab areas, tab icons and a capability per tab). Easy SVG 5.4.0 has 3 and 3. - Easy SVG Pro, active and licensed, to see the tab.
- A user with the
upload_filescapability to use the tab. Administrators, editors and authors have it.
The files
wp-content/plugins/my-svg-check/
├── my-svg-check.php main plugin file: checks, REST route, menu entry, enqueue
└── panel-tab.js the tab, registered with window.EasySvgPanel
Step by step
Create the plugin and check for Easy SVG
Plugins load in alphabetical order, so Easy SVG may load after yours. Do the checks on plugins_loaded, when every plugin is in. If something is missing, register only an admin notice and stop. The constants are the contract: compare them, not the plugin version.
Clean an SVG with the site's sanitizer
A REST route takes the pasted markup and runs it through easy_svg_sanitizer(). That function returns a fresh sanitizer configured with the site's allow-lists and remote reference setting, or null when the sanitizing library is missing. The sanitizer can return false, an empty string, or throw for XML without exactly one svg root, so all three are handled. getXmlIssues() lists what was removed.
Add a menu entry with its own capability
The easy_svg_panel_menu_tabs filter gives the tab a link under Icons and the capability upload_files. The key must be the same id the JavaScript registers.
Enqueue the tab script
On easy_svg_panel_enqueue the script is enqueued with the panel handle you receive, wp-element and wp-i18n as dependencies, in the footer.
Register the tab
panel-tab.js registers the tab with an id, a label, an area, an order, an icon, and a render function that returns an element of its own component.
Try it
Activate the plugin, open Icons, then SVG check, and paste an SVG with a script element in it.
The main plugin file
<?php
/**
* Plugin Name: My SVG Check
* Description: Adds an "SVG check" tab to the Easy SVG Icons panel.
* Version: 1.0.0
* Requires at least: 6.6
* Requires PHP: 8.0
* Text Domain: my-svg-check
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const MYSVGCHECK_VERSION = '1.0.0';
const MYSVGCHECK_TAB = 'mysvgcheck';
const MYSVGCHECK_CAP = 'upload_files';
const MYSVGCHECK_MAX_BYTES = 512000;
add_action( 'plugins_loaded', 'mysvgcheck_boot' );
/**
* Hooks everything in, once all plugins are loaded.
*/
function mysvgcheck_boot() {
if ( ! mysvgcheck_easy_svg_ready() ) {
add_action( 'admin_notices', 'mysvgcheck_missing_notice' );
return;
}
add_action( 'rest_api_init', 'mysvgcheck_register_route' );
add_filter( 'easy_svg_panel_menu_tabs', 'mysvgcheck_menu_tab' );
add_action( 'easy_svg_panel_enqueue', 'mysvgcheck_enqueue' );
}
/**
* Is Easy SVG active and new enough for this add-on?
*
* @return bool
*/
function mysvgcheck_easy_svg_ready() {
return defined( 'EASY_SVG_API' )
&& defined( 'EASY_SVG_PANEL_API' )
&& 1 <= (int) EASY_SVG_API
&& 2 <= (int) EASY_SVG_PANEL_API
&& function_exists( 'easy_svg_sanitizer' );
}
/**
* Tells administrators why the add-on does nothing.
*/
function mysvgcheck_missing_notice() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html__( 'My SVG Check needs Easy SVG with a current version of its add-on contract. Install, activate or update Easy SVG.', 'my-svg-check' )
);
}
/**
* Whether the Icons panel will draw a tab from another add-on.
*
* Mirrors includes/panel.php and src/panel/App.jsx in Easy SVG 5.4.0: extra
* tabs are drawn only while Easy SVG Pro is active.
*
* @return bool
*/
function mysvgcheck_panel_draws_tab() {
return defined( 'EASY_SVG_PRO_VERSION' );
}
/**
* The REST route the tab calls.
*/
function mysvgcheck_register_route() {
register_rest_route(
'my-svg-check/v1',
'/clean',
array(
'methods' => 'POST',
'callback' => 'mysvgcheck_clean',
'permission_callback' => 'mysvgcheck_can_use',
'args' => array(
'markup' => array(
'type' => 'string',
'required' => true,
),
),
)
);
}
/**
* The same capability as the menu entry. The REST layer checks the
* X-WP-Nonce header before this runs, so a request without it is anonymous.
*
* @return bool
*/
function mysvgcheck_can_use() {
return current_user_can( MYSVGCHECK_CAP );
}
/**
* Cleans the posted SVG and reports what the sanitizer removed.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response|WP_Error
*/
function mysvgcheck_clean( WP_REST_Request $request ) {
$markup = (string) $request->get_param( 'markup' );
// The sanitizer parses the whole document, so keep the input bounded.
if ( strlen( $markup ) > MYSVGCHECK_MAX_BYTES ) {
return new WP_Error(
'mysvgcheck_too_large',
__( 'That SVG is too large to check here.', 'my-svg-check' ),
array( 'status' => 413 )
);
}
$sanitizer = easy_svg_sanitizer();
if ( null === $sanitizer ) {
return new WP_Error(
'mysvgcheck_no_sanitizer',
__( 'The Easy SVG sanitizer is not available.', 'my-svg-check' ),
array( 'status' => 503 )
);
}
try {
$clean = $sanitizer->sanitize( $markup );
} catch ( \Throwable $e ) {
// Well-formed XML without exactly one svg root throws.
$clean = false;
}
if ( ! is_string( $clean ) || '' === trim( $clean ) ) {
return new WP_Error(
'mysvgcheck_not_svg',
__( 'That is not an SVG the sanitizer accepts.', 'my-svg-check' ),
array( 'status' => 422 )
);
}
$removed = array();
foreach ( (array) $sanitizer->getXmlIssues() as $issue ) {
$removed[] = array(
'message' => sanitize_text_field( (string) ( $issue['message'] ?? '' ) ),
'line' => (int) ( $issue['line'] ?? 0 ),
);
}
return new WP_REST_Response(
array(
'clean' => $clean,
'removed' => $removed,
)
);
}
/**
* Adds the menu entry, with its own capability, when the tab will be drawn.
*
* @param array $tabs Tab id to label, or to label and capability.
* @return array
*/
function mysvgcheck_menu_tab( $tabs ) {
$tabs = (array) $tabs;
if ( ! mysvgcheck_panel_draws_tab() ) {
return $tabs;
}
$tabs[ MYSVGCHECK_TAB ] = array(
'label' => __( 'SVG check', 'my-svg-check' ),
'capability' => MYSVGCHECK_CAP,
);
return $tabs;
}
/**
* Loads the tab script into the Icons panel.
*
* @param string $handle The panel script handle.
*/
function mysvgcheck_enqueue( $handle ) {
wp_enqueue_script(
'my-svg-check-panel',
plugins_url( 'panel-tab.js', __FILE__ ),
array( $handle, 'wp-element', 'wp-i18n' ),
MYSVGCHECK_VERSION,
true
);
wp_set_script_translations( 'my-svg-check-panel', 'my-svg-check', plugin_dir_path( __FILE__ ) . 'languages' );
}
The tab script
/* global window */
( function () {
'use strict';
const panel = window.EasySvgPanel;
if ( ! panel || 'function' !== typeof panel.registerTab ) {
return;
}
const { createElement: el, useState } = window.wp.element;
const { __ } = window.wp.i18n;
async function cleanSvg( ctx, markup ) {
const response = await window.fetch( ctx.restRoot + 'my-svg-check/v1/clean', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': ctx.nonce,
},
body: JSON.stringify( { markup } ),
} );
const data = await response.json().catch( () => ( {} ) );
if ( ! response.ok ) {
throw new Error( data.message || __( 'The check failed.', 'my-svg-check' ) );
}
return data;
}
function Removed( { items } ) {
if ( ! items.length ) {
return el( 'p', null, __( 'Nothing was removed. This SVG is clean.', 'my-svg-check' ) );
}
return el(
'ul',
null,
items.map( ( item, index ) =>
el( 'li', { key: index }, item.message + ' (' + __( 'line', 'my-svg-check' ) + ' ' + item.line + ')' )
)
);
}
function SvgCheck( { ctx } ) {
const [ markup, setMarkup ] = useState( '' );
const [ result, setResult ] = useState( null );
const [ error, setError ] = useState( '' );
const [ busy, setBusy ] = useState( false );
async function check() {
setBusy( true );
setError( '' );
setResult( null );
try {
setResult( await cleanSvg( ctx, markup ) );
} catch ( e ) {
setError( e.message );
} finally {
setBusy( false );
}
}
return el(
'div',
{ className: 'my-svg-check' },
el( 'p', null, __( 'Paste an SVG to see what this site\'s sanitizer removes from it. Nothing is saved.', 'my-svg-check' ) ),
el( 'textarea', {
rows: 10,
style: { width: '100%' },
value: markup,
'aria-label': __( 'SVG markup', 'my-svg-check' ),
onChange: ( event ) => setMarkup( event.target.value ),
} ),
el(
'p',
null,
el(
'button',
{
type: 'button',
className: 'button button-primary',
disabled: busy || '' === markup.trim(),
onClick: check,
},
busy ? __( 'Checking…', 'my-svg-check' ) : __( 'Check SVG', 'my-svg-check' )
)
),
error ? el( 'p', { role: 'alert' }, error ) : null,
result
? el(
'div',
null,
el( 'h3', null, __( 'Removed', 'my-svg-check' ) ),
el( Removed, { items: result.removed || [] } ),
el( 'h3', null, __( 'Clean result', 'my-svg-check' ) ),
el( 'textarea', {
rows: 10,
style: { width: '100%' },
readOnly: true,
value: result.clean,
'aria-label': __( 'Clean SVG markup', 'my-svg-check' ),
} )
)
: null
);
}
panel.registerTab( {
id: 'mysvgcheck',
label: __( 'SVG check', 'my-svg-check' ),
area: 'inspect',
order: 30,
// A check mark, drawn on the panel's 24 by 24 icon grid.
icon: el( 'path', { d: 'M5 12l4 4L19 7' } ),
render: ( ctx ) => el( SvgCheck, { ctx } ),
} );
} )();
How the pieces connect
What each part relies on:
- The checks read
EASY_SVG_APIandEASY_SVG_PANEL_API, the two numbers the readme promises to change only when the contract changes shape. - The menu entry makes the tab reachable from Icons and tells the panel its capability. The panel passes every tab's capability and the current user's capabilities to the browser, and draws the tab only for users who hold
upload_files. - The enqueue uses the handle it receives. Because
panel-tab.jsdepends on it, the browser runs the panel bundle first, and its first statement createswindow.EasySvgPanel. renderreturns an element ofSvgCheckand nothing else. The panel callsrenderfor every tab on every render, so state and requests live in the component, which the panel mounts only while the tab is open.ctx.restRootandctx.noncecome from the panel.restRootalready ends with a slash and also works with plain permalinks.
Who sees what
| User | Icons menu | Tabs shown | Can call the route |
|---|---|---|---|
| Administrator | yes | all tabs, including SVG check | yes |
| Editor or author | yes | only SVG check | yes |
| Contributor or subscriber | no | none | no, 403 |
| Anyone, without Pro licensed | menu without SVG check | panel without the tab | yes, with upload_files |
The panel opens for anyone who may see at least one tab. Editors and authors see only this tab, because Easy SVG's own tabs require manage_options.
Test it
Install
Put both files into wp-content/plugins/my-svg-check/ and activate My SVG Check. Without Easy SVG you should see the warning notice on the Plugins screen.
Open the tab
With Easy SVG Pro licensed, open Icons, then SVG check. The tab sits in the Inspect area of the rail.
Check an unsafe SVG
Paste this and click Check SVG:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><script>alert(1)</script><path d="M4 12h16"/></svg>
The list shows Suspicious tag 'script' with its line, and the clean result keeps only the path.
Check as an editor
Log in as an editor. The Icons menu shows SVG check and nothing else.
Troubleshooting
Pitfalls
- Check the constants on
plugins_loaded, not when your file loads. Easy SVG may not be loaded yet at that point. - The menu entry and the JavaScript id must be identical, here
mysvgcheck, or the panel looks up the wrong capability. - The capability in the menu entry hides the tab, it does not protect the route.
mysvgcheck_can_use()is what keeps the route closed. - Do not show the clean result with
dangerouslySetInnerHTML. The example puts it into a read-only text field, where it is text. If you want a preview, render it only after the server-side sanitizer, as here, and never the original input. - Bound the input. The sanitizer parses the whole document. Easy SVG's own uploader also checks size and structure before parsing; your route needs its own limit.
- Translations for
panel-tab.jsare loaded fromlanguages/my-svg-check-<locale>-<md5>.jsonbywp_set_script_translations(). Without such files the English strings are shown.