WP Manifestindependent plugin directory
manifest / media / wp-ks-icon-manager

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

0stars
0forks

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.zip

One 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 — default true; 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.