KS Icon Manager
One icon source for a whole site. Downloads Heroicons and Bootstrap Icons into uploads, accepts sanitised SVG of your own, and renders any of them inline through a static API, a block and a picker other code can reuse.
by Konstantin Sorokin · github.com/kostyasorokin/wp-ks-icon-manager · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/kostyasorokin/wp-ks-icon-manager/archive/refs/heads/main.zipOne icon source for a whole site, in place of the half-dozen mechanisms a grown
site usually accumulates — a string in a template, a sprite assembled in the DOM,
a CSS mask, a font class — none of which agree on size, stroke or colour. The
plugin itself ships no icon files. It downloads four libraries — five sets,
because Font Awesome's brand marks are switchable on their own — at versions
pinned in its own code into wp-content/uploads/icons/, takes your own SVG
beside them rebuilt from a whitelist rather than trusted as it arrives, and draws
any of them inline through one identifier, one renderer, one admin screen, one
block and one picker field other code can reuse.
Requirements
PHP 8.5, WordPress 6.7.
Declared in the plugin header, so WordPress blocks installing and activating it
below 8.5. Note that the header is checked at those two moments only: a server
downgraded under an already-active plugin fatals in Composer's
platform_check.php rather than degrading.
Install
cd wp-content/plugins
git clone https://github.com/kostyasorokin/wp-ks-icon-manager.git ks-icon-manager
Then activate. vendor/ is committed — it holds Composer's autoloader and no
packages — so a clone runs as it stands, and git pull is the update.
Activation only creates the icon directories and records the version; it never
touches the network. Downloading the libraries is a button per set on the
Icons screen, under Libraries — except that Font Awesome's two rows share
one archive, so Install on either fills both, and pressing it again only fetches
the same file twice. Until at least one set is installed, an admin notice says so
and Icon::svg() returns an empty string on the front end — the plugin draws no
placeholder squares.
The five sets
Nothing here is bundled. Each library is fetched on demand from the npm
registry, as the tarball npm publishes, and checked against a byte count and a
SHA-256 recorded in src/Provisioner/Source.php before a single file is written.
Extraction is selective: Lucide's archive is 6.1 MB, of which only
package/icons/ and the licence are taken, and Font Awesome's 6.6 MB yields two
sets and skips the webfonts and svgs-full/ entirely.
| Set slug | npm package | Version | Icons | Licence | Attribution | Home |
|---|---|---|---|---|---|---|
heroicons |
heroicons |
2.2.0 | 648 (324 drawings × outline + solid) | MIT | © Tailwind Labs | https://heroicons.com |
bootstrap |
bootstrap-icons |
1.13.1 | 2078 | MIT | © The Bootstrap Authors | https://icons.getbootstrap.com |
lucide |
lucide-static |
1.31.0 | 2025 | ISC | Lucide Icons and Contributors | https://lucide.dev |
fontawesome |
@fortawesome/fontawesome-free |
7.3.1 | 2274 (2001 solid + 273 regular) | CC BY 4.0 | © Fonticons, Inc. | https://fontawesome.com |
fontawesome-brands |
@fortawesome/fontawesome-free |
7.3.1 | 609 | CC BY 4.0 | © Fonticons, Inc. — the marks are their owners' | https://fontawesome.com |
7634 searchable rows in total, about 5 MB of SVG on disk, from roughly 14 MB of downloads.
Lucide is lucide-static, not lucide: the latter ships JavaScript modules and
no SVG files at all. Its licence is ISC where the first two are MIT — permissive
and GPL-compatible either way, but not the same text.
Font Awesome is one download and two sets. package/svgs/solid and
package/svgs/regular become the two variants of fontawesome;
package/svgs/brands becomes the flat fontawesome-brands. Both rows on the
manager screen point at the same archive and the same pinned version, so
installing either installs both and the second press changes nothing. npm
declares the package
(CC-BY-4.0 AND OFL-1.1 AND MIT): the fonts and the code are not taken, so what
reaches the disk is the CC BY 4.0 part.
The brand icons are trademarks. fontawesome-brands is 609 company marks —
GitHub, Visa, WhatsApp. CC BY 4.0 covers the drawings and grants nothing in the
marks themselves, which stay their owners' property; whether a page of yours may
carry somebody's logo is the site's decision and not the plugin's. That is the
whole reason they are a set of their own: switch fontawesome-brands off and all
609 leave the pickers in one click, with the other 2274 Font Awesome icons
untouched. They are ordinary Icon-type icons all the same — monochrome
currentColor drawings, recoloured like any other, not the fixed-palette logo
type.
Why npm and not GitHub. Neither Heroicons nor Bootstrap Icons publishes
release assets — api.github.com/repos/twbs/bootstrap-icons/releases/tags/v1.13.1
answers Not Found — and GitHub's auto-generated tag archives are not guaranteed
byte-stable, so they cannot be pinned to a checksum at all. npm tarballs are
immutable by registry policy and the registry publishes their integrity hash,
which is exactly what "pinned version plus checksum" needs. If a site sits behind
an egress proxy, the host to allow is registry.npmjs.org.
Attribution. MIT and ISC both require the copyright notice and licence text
to travel with the files. CC BY 4.0 asks for the one thing they do not:
attribution travels with use, so credit and a link to the licence belong
wherever the icons are shown, not only wherever they are stored. Nothing here
asks that of you. Font Awesome writes the notice — author, licence URL,
copyright — as a comment inside every .svg it ships; the renderer serialises
the root element with its children, so the comment goes out with the markup, and
it survives wp_kses( …, Icon::allowedTags() ), which is what the pickers and
the REST preview run. A page that draws one of these icons is already crediting
them, at a cost of some 200 bytes. The plugin rewrites the SVG root — classes
merged, width/height stripped — and never the drawing.
This section is that notice for the sources; the provisioner also copies each
package's own licence file to wp-content/uploads/icons/<set>/LICENSE.txt in the
same pass that unpacks the icons — into both Font Awesome directories, since
one archive covers both — so the notice sits beside the drawings it covers. The
manager screen repeats licence, attribution and homepage per set. The plugin's
own code is GPL-3.0-or-later; see LICENSE.
Identifiers
An icon is addressed by one string: set:name or set:name:variant.
heroicons:academic-cap:outline library icon, explicit variant
heroicons:academic-cap:solid
heroicons:academic-cap variant omitted → the set's default (outline)
bootstrap:alarm-fill Bootstrap has no variants: -fill is part of the name
lucide:alarm-clock
fontawesome:house:regular Font Awesome, explicit variant
fontawesome:house variant omitted → the set's default (solid)
fontawesome-brands:github a company mark; a set of its own, off in one click
custom:scales your own drawing
logo:telegram a brand mark, drawn in its own colours
Each colon-separated segment must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ — lower-case
letters, digits and single hyphens. No underscores, no dots, no slashes: that is
what keeps a stored id inside its own directory. Anything else, and any count of
segments other than two or three, parses to null and renders as ''. An
unknown variant is not an error either; it falls back to the set's default
variant, so heroicons:academic-cap:sparkly draws the outline icon.
Theme API
Everything public is on one class, KonstantinSorokin\IconManager\Icon. It is
static, has no global function wrappers, and every method answers with nothing
rather than throwing — these are called from templates, where one stale id must
not take a page down.
A theme should not assume the plugin is active:
function myTheme_icon( string $id, array $args = [] ): string {
if ( ! class_exists( 'KonstantinSorokin\IconManager\Icon' ) ) {
return '';
}
return \KonstantinSorokin\IconManager\Icon::svg( $id, $args );
}
Icon::svg( string $id, array $args = [] ): string
Inline SVG, or '' when there is nothing to draw. The markup is built by this
server and is echoed as it is — it is not run through wp_kses() on the way out,
so a caller that filters it must pass Icon::allowedTags() or the drawing is
stripped.
echo \KonstantinSorokin\IconManager\Icon::svg(
'bootstrap:telephone',
[
'class' => 'site-icon',
'size' => 24,
'label' => __( 'Call us', 'my-theme' ),
'attributes' => [ 'data-analytics' => 'header-phone' ],
]
);
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
class="bi bi-telephone ks-icon ks-icon--bootstrap site-icon" viewBox="0 0 16 16"
data-analytics="header-phone" focusable="false" role="img" aria-label="Call us">…</svg>
| Key | Type | What it does |
|---|---|---|
class |
string | Space-separated, each run through sanitize_html_class(), merged into the class the file carries. Bootstrap ships class="bi bi-telephone" and Lucide class="lucide lucide-scale"; a second class attribute would be invalid markup and the browser would keep the first. |
size |
int | Prints width and height in px. Omit it and both are stripped, which is what lets the stylesheet size the icon in em. |
label |
string | Accessible name: the icon gets role="img" aria-label="…" and loses aria-hidden. Without a label it gets aria-hidden="true" — an element that is both hidden and named is hidden, so this is a swap, not an addition. |
title |
string | A <title> child: a hover tooltip. It is a name only for an icon that also has a label; on an aria-hidden icon nothing reads it. |
attributes |
array | Extra attributes on the root: data-* by pattern, plus the ARIA globals — aria-describedby, aria-labelledby, aria-live, aria-controls and the rest of AllowList::aria(). Anything else, including the three attributes label decides, is dropped silently. |
The root's paint follows the set: stroke="currentColor" for Heroicons outline
and Lucide, fill="currentColor" for Heroicons solid, Bootstrap and both Font
Awesome sets, and nothing at all for the logo set — recolouring a brand mark
defaces it. Only the root is touched: two dozen Lucide icons paint inner
elements, and setting fill on descendants would flatten them. Font Awesome puts
fill="currentColor" on the <path> rather than the root, so its icons follow
the text colour either way.
An id that does not parse, names an unknown set or has no file on disk returns
'' and, under WP_DEBUG, writes one line per distinct id to the error log.
Icon::exists( string $id ): bool
Answered from the set's manifest, not by touching the filesystem.
Icon::sets(): array<string, SetDefinition>
The enabled sets, keyed by slug — what a picker may offer. A set switched off in the manager is missing here and still renders: switching one off decides what an editor is shown, not what is already published.
Icon::search( string $query, ?string $set = null, int $limit = 50, int $offset = 0 ): array
$found = \KonstantinSorokin\IconManager\Icon::search( 'alarm', 'bootstrap', 20 );
// [ 'items' => [ [ 'id' => 'bootstrap:alarm', 'set' => 'bootstrap',
// 'name' => 'alarm', 'variant' => null ], … ], 'total' => 11 ]
Case-insensitive substring match on the icon name, ranked exact → prefix →
anywhere. An empty query matches everything. total counts before the page is
cut. $limit is clamped to 1–200 and $offset floored at 0. Search covers
enabled sets only, even when a disabled one is named outright — unlike
render() and exists(), which do not care.
Icon::variants( string $id ): list<string>
The variants this drawing actually has on disk, in the set's order: ['outline', 'solid'] for a Heroicons name, [] for Bootstrap, Lucide and your own icons.
Icon::url( string $id ): string
The file's address, for the rare case that needs an <img> — an e-mail, a
favicon, a canvas — and '' for an icon that does not exist. The address is
built from the uploads URL, which does not follow the ksIconManagerRoot
filter: a site that moves the directory has to keep serving it from
…/uploads/icons/, or stop using this method.
Icon::allowedTags(): array
The wp_kses() whitelist a rendered icon survives: the elements and attributes
the sanitiser rebuilds an upload from, plus data-* and the ARIA globals the
renderer may print on the root. Use it whenever rendered markup is filtered.
echo wp_kses( \KonstantinSorokin\IconManager\Icon::svg( $id ), \KonstantinSorokin\IconManager\Icon::allowedTags() );
Filters
Four, and these are all of them.
ksIconManagerSets
apply_filters( 'ksIconManagerSets', array<string, SetDefinition> $sets ): array<string, SetDefinition>
Every set the site knows, keyed by slug, before anything reads it — add one, drop
one, or replace a definition. Entries that are not a SetDefinition are dropped
one by one, and a non-array return is ignored entirely, because a filter of the
wrong shape would otherwise fatal on every page that draws an icon.
The literal ksIconManagerSets is also the name of an option (see below).
Options and hooks live in separate namespaces so nothing breaks, but
get_option( 'ksIconManagerSets' ) answers about downloads, not about sets.
ksIconManagerHtml
apply_filters( 'ksIconManagerHtml', string $html, string $id, array $args ): string
The last word on rendered markup. It also fires with $html = '' on every
failure path, which is the hook for a site that wants its own placeholder for a
missing icon. A non-string return is ignored.
ksIconManagerRoot
apply_filters( 'ksIconManagerRoot', string $default ): string
Where icons live on disk; the default is wp-content/uploads/icons/. A
non-string or empty return falls back to the default, and a trailing slash is
added if missing. Sharp edge: only the filesystem path moves. The URL side is
fixed at …/uploads/icons/, so a site that redirects this filter must serve the
new directory from that same address or Icon::url() — and only that method —
will point at nothing.
ksIconManagerMaxUploadBytes
apply_filters( 'ksIconManagerMaxUploadBytes', int $bytes = 262144 ): int
The ceiling on one uploaded or restored SVG, 256 KB by default. Non-numeric or
zero-and-below falls back to the default. The cap is deliberate on the restore
path too: the sanitiser builds a DOM tree several times the size of its input, so
an uncapped drawing dies on memory_limit halfway through an archive.
The picker
For any form of your own — a metabox, a term form, a settings screen.
use KonstantinSorokin\IconManager\Picker\Picker;
Picker::field(
'myIcon',
(string) get_post_meta( $post->ID, 'myIcon', true ),
[
'label' => __( 'Card icon', 'my-plugin' ),
'description' => __( 'Shown next to the title.', 'my-plugin' ),
'sets' => [ 'heroicons', 'custom' ],
'allow_empty' => true,
]
);
Picker::field( string $name, string $value = '', array $args = [] ): void prints
a hidden input, a preview and a Choose button, and enqueues its own assets. It
posts the id back as a plain string under $name; the nonce and the capability
check are the caller's, as is sanitising what comes back
(sanitize_text_field() is enough — an id that does not parse renders as
nothing).
label— defaults to "Icon".description— a<p class="description">under the field.sets— restrict the choice. The list is intersected with the enabled sets, and a field that named only sets which are switched off stays restricted and says there is nothing to choose from, rather than quietly widening back to the whole library.allow_empty— defaulttrue; shows a Clear button.
A stored id whose file has gone is named in the preview rather than blanked, and the hidden input keeps it: saving an unrelated part of the same form must not turn somebody's choice into an empty field.
Picker::HANDLE is ks-icon-manager-picker (one handle for both the script and
the stylesheet), and Picker::enqueue() loads them for code that wants the
assets without printing a field.
The block
ks-icon-manager/icon, dynamic — the SVG is built when the page is served, so an
icon updated on disk updates everywhere it was inserted.
| Attribute | Type | Default | Notes |
|---|---|---|---|
icon |
string | '' |
The identifier. |
size |
number | 0 |
Pixels. Zero means "inherit": no width/height is printed and the stylesheet's 1em applies. |
label |
string | '' |
Accessible name. Empty leaves the icon aria-hidden, which is right for decoration. |
align |
string | — | left, center or right. |
Supports html: false, the three alignments and spacing.margin. An id whose
file is gone renders nothing at all, wrapper included — an empty <div> would
still claim the margins the spacing support wrote onto it.
block.json names ks-icon-manager as the block's style, which is what
carries the sizing rule into the editor iframe; without it the preview would draw
at the SVG default of 300×150.
REST
| Route | Returns |
|---|---|
GET /wp-json/ks-icon-manager/v1/icons?q=&set=&page=&per_page= |
Matching icons: id, set, name, variant, and svg rendered at 32 px and filtered through wp_kses(). Headers X-WP-Total and X-WP-TotalPages. per_page defaults to 60, maximum 200. |
GET /wp-json/ks-icon-manager/v1/sets |
Every set, including the disabled ones, with type, colour mode, variants, licence, attribution, homepage, the pinned version, and what is installed. |
Both require edit_posts. These are editorial tools, not public data: left open,
the first would let anyone page through the names of every file under
uploads/icons/. A set that is unknown or switched off answers with an empty
page rather than with everything.
The manager screen's write operations are admin-ajax actions, each gated on
manage_options and its own nonce: ksIconManagerInstall, ksIconManagerUpload,
ksIconManagerRename, ksIconManagerDelete.
Storage
wp-content/uploads/icons/
├── heroicons/
│ ├── outline/academic-cap.svg 324 files
│ ├── solid/academic-cap.svg 324 files
│ └── LICENSE.txt the library's own, copied out of the tarball
├── bootstrap/
│ ├── alarm-fill.svg 2078 files, no variant directories
│ └── LICENSE.txt
├── lucide/
│ ├── alarm-clock.svg 2025 files
│ └── LICENSE.txt
├── fontawesome/
│ ├── solid/house.svg 2001 files
│ ├── regular/house.svg 273 files
│ └── LICENSE.txt
├── fontawesome-brands/
│ ├── github.svg 609 files, no variant directories
│ └── LICENSE.txt the same file: one archive, two sets
├── custom/scales.svg your own drawings, one .svg at a time
├── logo/telegram.svg brand marks, never recoloured
├── .index/<set>.json one manifest per set
└── index.php silence guard, in every directory made here
Nothing of this is in the repository. A library directory is deleted and rewritten
wholesale when the pinned version changes, which is why uploads are refused into
one: an icon of your own filed under heroicons/ would vanish at the next
install without a word.
The manifests. 7634 rows cannot be answered by walking the filesystem on every
request. .index/<set>.json records the set, its version, a timestamp, the
variants that actually have files, and every icon name with the variants it
exists in. Search, exists() and both pickers read the manifest; only the
renderer touches an actual .svg, lazily and at most once per request. The
manifest is rebuilt on install and after every upload, rename and delete, and
cached in the object cache (group ksIconManager, key index:<slug>, 12 hours)
plus a per-request static. There is no option holding an index hash.
Your own icons. custom and logo take uploads, one .svg at a time,
through the sanitiser: the file is rebuilt from the whitelist rather than checked
by pattern, so a disallowed element takes its subtree with it, script,
foreignObject, style, image, every on* attribute, external and data:
references and any DOCTYPE never reach the disk. The screen reports what was cut,
so "the icon looks different" is not a mystery. The file name becomes the icon
name, lower-cased and hyphenated; a name nothing legal survives in is refused
rather than rewritten.
logo differs from custom in one way that matters: its type is Logo, so the
renderer touches neither fill nor stroke and the mark keeps the palette it
was drawn in.
Constants, handles, options
KS_ICON_MANAGER_VERSION (a const, not a define), KS_ICON_MANAGER_FILE,
KS_ICON_MANAGER_PATH, KS_ICON_MANAGER_URL.
Asset handles: ks-icon-manager (the front-end stylesheet, registered on init
and enqueued by the renderer on the first icon of the request),
ks-icon-manager-admin (the manager screen), ks-icon-manager-picker.
Options:
| Option | Holds |
|---|---|
ksIconManagerDisabledSets |
A list of slugs to hide from editors, registered as a setting and written through /wp/v2/settings. It is a list of what is off, and nothing is stored until something is switched off — so a set added later by an update or by ksIconManagerSets is live without a write. There is no enabled-list option; code asking for ksIconManagerEnabledSets is reading a name that was never built. |
ksIconManagerSets |
What the provisioner put on disk, per slug: version, installedAt, files. Not autoloaded. Same literal as the filter above, unrelated shape. |
ksIconManagerVersion |
Written on activation, so a later release can tell an upgrade from a first install. |
Sizing
assets/icons.css carries the rule that matters:
.ks-icon:not(:where([width])) { width: 1em; height: 1em; }
An icon takes the size of the text it sits in — a heading gets a big one, a
caption a small one, with no class of their own. Pass size and the renderer
prints width/height, the selector stops matching, and the pixels win: a CSS
rule outranks a presentation attribute whatever its specificity, so without the
:not() an explicit size could never be seen. The :where() around it keeps the
rule at single-class weight, so .ks-icon--heroicons or .card .ks-icon — the
rules this invites a site to write — still override it.
Font Awesome is the exception, and the only visual difference between it and the
rest. Its drawings are 512 tall but 384 to 640 wide — nine viewBoxes across the
two sets, where every other library is square. A 1em square would not distort
them (preserveAspectRatio defaults to xMidYMid meet) but it would letterbox
them: the drawing shrinks to fit the narrow axis and floats in side padding,
reading as "these ones are smaller". So they are sized by height and the width
follows the ratio:
.ks-icon--fontawesome:not(:where([width])),
.ks-icon--fontawesome-brands:not(:where([width])) { width: auto; height: 1em; }
Same guard, same single-class weight; it wins over the rule above on source order
alone. An explicit size is unaffected — it prints width/height and both
rules stop matching — but a pixel size is a square box, so a wide icon
letterboxes inside that. Left in em, nothing is asked of the site.
Everything else, including whatever a logo's own proportions need, is the site's
to write against .ks-icon--<set>. Dequeue the ks-icon-manager handle and every
icon falls back to the SVG default of 300×150.
Development
composer tools # install phpcs, phpstan and phpunit into tools/vendor
composer check # coding standards, static analysis (level 8), tests
npm run i18n # regenerate the POT, compile the .po files, emit the JSON
composer check is what CI runs, plus two checks that only a repository can
make: that the committed vendor/ autoloader is current, and that every
languages/*.mo matches its .po. The plugin has no runtime dependencies and no
build step — assets/*.js and blocks/icon/editor.js are plain ES5 against the
wp.* globals, no JSX and no bundler, shipped as written. blocks/icon/editor.asset.php
is the dependency list wp-scripts would otherwise have generated; it is
maintained by hand.
Licence
GPL-3.0-or-later. See LICENSE for the plugin's own terms, and
The five sets for the icon libraries it downloads — one of
which, Font Awesome, is CC BY 4.0 rather than MIT.