diff --git a/.gitignore b/.gitignore
index 6bd3701..5309cd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,7 @@ phpcs.xml
/wp-tests-config.php
/phpunit.xml
/.phpunit.result.cache
+
+# Perf protocol: generated fixtures (~100 MB) and captured results
+tests/perf/fixtures/
+tests/perf/results/
diff --git a/.wp-env/themes/icon-block-theme/functions.php b/.wp-env/themes/icon-block-theme/functions.php
index 6a40cad..6e86efb 100644
--- a/.wp-env/themes/icon-block-theme/functions.php
+++ b/.wp-env/themes/icon-block-theme/functions.php
@@ -1,8 +1,6 @@
'attachment',
- 'post_status' => 'inherit',
- 'post_mime_type' => 'image/svg+xml',
- 'posts_per_page' => 500, //phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
- 'no_found_rows' => true,
+ 'label' => __( 'Media library', 'beapi-frontend-framework' ),
+ 'type' => 'attachments',
]
);
-
- if ( $query->have_posts() ) {
- $media_collection = new Collection( 'mediatheque', __( 'Media library', 'beapi-frontend-framework' ) );
- foreach ( $query->posts as $svg ) {
- $path = get_attached_file( $svg->ID );
-
- if ( empty( $path ) ) {
- continue;
- }
-
- try {
- $items = CollectionItemsFactory::from_file(
- $path,
- [
- 'name' => $svg->post_name,
- 'label' => get_the_title( $svg ),
- ]
- );
- array_map( [ $media_collection, 'add' ], $items );
- } catch ( \Exception $e ) { // phpcs:ignore
- }
- }
- register_icon_collection( $media_collection );
- }
} );
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd1c62e..39e4829 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use
### Unreleased
+- Load an icon's SVG only when it is used. Registering a collection now builds a lightweight index, so a front-end page no longer holds every icon of every collection in memory
+- Cache icon payloads individually, and skip the object cache for payloads over 900 KB. Memcached refuses items above 1 MB and reports it only through an unchecked return value, which made oversized collections rebuild on every request forever. Adjust with the `blockparty_icons_cache_max_item_bytes` filter
+- Add an `attachments` collection type for SVGs contributed through the media library: one query and one cached index, instead of a query plus a cache round trip per icon on every request
+- Version cached index keys, so entries written by an earlier release are never read back after an update
+- Add a reproducible performance protocol under `tests/perf`
- Add a PHPUnit integration test suite covering every SVG source (folder, sprite, single file), the collection container, the registration API, front-end block rendering, both REST controllers, the object-cache wrapper and the KSES allowances — 176 tests. See `tests/README.md`
- Run the suite in CI on PHP 8.1 through 8.4
diff --git a/README.md b/README.md
index 68af1d5..6024810 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,57 @@ add_action( 'blockparty_icons_init', 'register_collection' );
This is an example for adding a SVG sprite as a source. If you want to add icons from a folder, change the `type` value to `folder` and the path of your `source` to `get_stylesheet_directory() . '/dist/icons/'` for example.
+## Icons contributed in the back office
+
+To offer the SVG files uploaded to the media library as a collection, use the `attachments` type:
+
+```php
+\Blockparty\Icons\register_icon_collection(
+ 'mediatheque',
+ [
+ 'label' => 'Media library',
+ 'type' => 'attachments',
+ ]
+);
+```
+
+This runs a single query and caches one compact index, invalidated as soon as any
+attachment changes. Pass extra `WP_Query` arguments through `query` to narrow the
+selection:
+
+```php
+\Blockparty\Icons\register_icon_collection(
+ 'mediatheque',
+ [
+ 'label' => 'Media library',
+ 'type' => 'attachments',
+ 'query' => [ 'posts_per_page' => 1000 ],
+ ]
+);
+```
+
+Do **not** build such a collection by looping over attachments and calling
+`CollectionItemsFactory::from_file()` for each one. That costs a database query and
+one object-cache round trip per icon on every request, front end included.
+
+## Performance notes
+
+An icon's SVG payload is read only when it is actually needed — one icon when a block
+renders, one page's worth when the editor lists a collection. Registering a collection
+builds a lightweight index and reads no SVG at all.
+
+Payloads are cached individually rather than inside the collection, and payloads larger
+than 900 KB are not sent to the object cache: memcached (WordPress VIP and most managed
+hosts) refuses items over 1 MB and signals it only through a return value that nothing
+checks, which would otherwise mean rebuilding the same entry on every request forever.
+Raise or disable that ceiling on Redis or APCu:
+
+```php
+add_filter( 'blockparty_icons_cache_max_item_bytes', fn() => 5 * MB_IN_BYTES );
+```
+
+A reproducible benchmark for all of this lives in [`tests/perf`](tests/perf/README.md).
+
## Params
| param | description |
@@ -41,7 +92,8 @@ This is an example for adding a SVG sprite as a source. If you want to add icons
|-----------|---------------------------|
| `label` | Label of the collection. |
| `source` | Path to the SVG sprite file or folder containing SVG files. |
-| `type` |
`sprite` for SVG sprite source.
`folder` for a folder containing SVG files.
|
+| `type` |
`sprite` for SVG sprite source.
`folder` for a folder containing SVG files.
`attachments` for the SVG files in the media library.
|
+| `query` | Optional. For `attachments`, extra `WP_Query` arguments. |
| `version` | Optional. Version string used for cache busting (e.g. theme version). When set, the sprite URL is appended with a `?v=...` query parameter. |
## How to develop
diff --git a/blockparty-icons.php b/blockparty-icons.php
index c294308..804737d 100644
--- a/blockparty-icons.php
+++ b/blockparty-icons.php
@@ -92,9 +92,10 @@ function preload_rest_endpoints( $paths ) {
* Optional. An array of additional arguments. Default empty array.
*
* @type string $label Optional. A human friendly name for the collection.
- * @type string $type Optional. The type of icons. Supported values are 'folder', 'sprite' or 'raw'.
- * @type string $source Optional. The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file.
+ * @type string $type Optional. The type of icons. Supported values are 'folder', 'sprite', 'attachments' or 'raw'.
+ * @type string $source Optional. The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file. Unused for 'attachments'.
* @type array $icon_map Optional. Use an array to override default labels for the icons.
+ * @type array $query Optional. For 'attachments', extra WP_Query arguments.
* @type string|null $version Optional. The collection version, use for cache busting in the sprite URL.
* }
*
@@ -125,6 +126,7 @@ function register_icon_collection( $name, array $args = [] ) {
'type' => '',
'source' => '',
'icon_map' => [],
+ 'query' => [],
'version' => null,
]
);
@@ -144,6 +146,9 @@ function register_icon_collection( $name, array $args = [] ) {
$collection = false;
}
break;
+ case 'attachments':
+ $collection = Collection::from_attachments( $name, $args );
+ break;
/*case 'file':
try {
$collection = new Collection( $name, $args['label'] ?? null );
@@ -165,7 +170,7 @@ function register_icon_collection( $name, array $args = [] ) {
* Add icons to an existing collection.
*
* @param string $collection_name The collection to add the icons to.
- * @param string $type The type of icons. Supported values are 'folder', 'sprite' or 'raw'.
+ * @param string $type The type of icons. Supported values are 'folder', 'sprite', 'file' or 'attachments'.
* @param string $source The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file.
* @param array $args {
* Optional. An array of additional arguments. Default empty array.
@@ -187,6 +192,7 @@ function add_icons( string $collection_name, string $type, string $source, array
[
'label' => '',
'icon_map' => [],
+ 'query' => [],
'version' => null,
]
);
@@ -214,6 +220,9 @@ function add_icons( string $collection_name, string $type, string $source, array
return false;
}
break;
+ case 'attachments':
+ $items = CollectionItemsFactory::from_attachments( $args );
+ break;
default:
return false;
}
diff --git a/includes/Helpers/Cache.php b/includes/Helpers/Cache.php
index ecd4e5f..8c9ee27 100644
--- a/includes/Helpers/Cache.php
+++ b/includes/Helpers/Cache.php
@@ -4,6 +4,21 @@
class Cache {
+ /**
+ * Largest payload worth handing to the object cache, in bytes.
+ *
+ * Memcached — what WordPress VIP and most managed hosts run — refuses any item
+ * over 1 MB and reports it only through a `false` return that nothing checks.
+ * The effect is a cache that never warms: the entry is rebuilt, refused, and
+ * rebuilt again on the next request, forever.
+ *
+ * We stay under the limit so the refusal never happens, leaving headroom for
+ * the key and the backend's own framing. Redis and APCu tolerate far more;
+ * raise it with the `blockparty_icons_cache_max_item_bytes` filter, or set 0
+ * to disable the guard entirely.
+ */
+ private const DEFAULT_MAX_ITEM_BYTES = 900000;
+
/**
* Retrieves cached data if valid and unchanged.
*
@@ -57,6 +72,11 @@ public static function set_cache( $cache_key, $data, $group, $salt, $expire = 0
return true;
}
+ // Do not pay for a write the backend is going to refuse.
+ if ( self::exceeds_max_item_bytes( $data ) ) {
+ return false;
+ }
+
if ( function_exists( 'wp_cache_set_salted' ) ) {
return wp_cache_set_salted( $cache_key, $data, $group, $salt, $expire );
}
@@ -73,6 +93,50 @@ public static function set_cache( $cache_key, $data, $group, $salt, $expire = 0
);
}
+ /**
+ * The size ceiling in force for this site.
+ *
+ * @return int Bytes, or 0 when the guard is disabled.
+ */
+ public static function max_item_bytes(): int {
+ /**
+ * Filters the largest payload the plugin will hand to the object cache.
+ *
+ * Set to 0 to store items of any size — appropriate on Redis or APCu, where
+ * there is no 1 MB per-item limit.
+ *
+ * @param int $bytes Default 900000.
+ */
+ return max( 0, (int) apply_filters( 'blockparty_icons_cache_max_item_bytes', self::DEFAULT_MAX_ITEM_BYTES ) );
+ }
+
+ /**
+ * Whether a payload is too large to be worth caching.
+ *
+ * @param mixed $data
+ *
+ * @return bool
+ */
+ private static function exceeds_max_item_bytes( $data ): bool {
+ $max = self::max_item_bytes();
+ if ( 0 === $max ) {
+ return false;
+ }
+
+ // Icon payloads are strings, and measuring one is free.
+ if ( is_string( $data ) ) {
+ return strlen( $data ) > $max;
+ }
+
+ if ( is_scalar( $data ) || null === $data ) {
+ return false;
+ }
+
+ // Anything else has to be measured. The extra serialize() costs far less
+ // than rebuilding an uncacheable entry on every request.
+ return strlen( serialize( $data ) ) > $max; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+ }
+
private static function is_cache_disabled() {
if ( function_exists( 'wp_is_development_mode' ) ) {
return wp_is_development_mode( 'all' );
diff --git a/includes/Icon/Collection.php b/includes/Icon/Collection.php
index cb2f243..3a2e140 100644
--- a/includes/Icon/Collection.php
+++ b/includes/Icon/Collection.php
@@ -65,6 +65,23 @@ public static function from_sprite( string $name, string $path, array $args = []
return $collection;
}
+ /**
+ * Create a new collection from the SVG attachments in the media library.
+ *
+ * @param string $name collection's name.
+ * @param array $args collection's args. See CollectionItemsFactory::from_attachments().
+ *
+ * @return static
+ */
+ public static function from_attachments( string $name, array $args = [] ): self {
+ $collection = new self( $name, $args['label'] ?? null );
+
+ $items = CollectionItemsFactory::from_attachments( $args );
+ array_map( [ $collection, 'add' ], $items );
+
+ return $collection;
+ }
+
public function __construct( string $name, $label = null ) {
$this->name = $name;
$this->label = $label ?? $name;
diff --git a/includes/Icon/CollectionItem.php b/includes/Icon/CollectionItem.php
index f2c716f..49453ac 100644
--- a/includes/Icon/CollectionItem.php
+++ b/includes/Icon/CollectionItem.php
@@ -8,14 +8,60 @@ class CollectionItem {
private ?string $label;
private string $type;
private ?string $version;
- private string $content;
+ /**
+ * Resolved SVG payload, or null while the item is still unresolved.
+ *
+ * @var string|null
+ */
+ private ?string $content = null;
+
+ /**
+ * Serializable descriptor saying where the payload can be read from.
+ *
+ * Null for items created with literal content. A plain array rather than a
+ * closure so the item survives a round trip through the object cache.
+ *
+ * @var array|null
+ */
+ private ?array $source = null;
+
+ /**
+ * Create an item that already holds its content.
+ *
+ * @param string $name
+ * @param string $type
+ * @param string $content
+ * @param string|null $label
+ * @param string|null $version
+ */
public function __construct( string $name, string $type, string $content, ?string $label = null, ?string $version = null ) {
$this->name = $name;
$this->type = $type;
$this->content = $content;
$this->label = $label ?? $name;
$this->version = $version;
+ $this->source = null;
+ }
+
+ /**
+ * Create an item whose content is read only when it is first needed.
+ *
+ * @param string $name
+ * @param string $type
+ * @param array $source Descriptor understood by ContentLoader.
+ * @param string|null $label
+ * @param string|null $version
+ *
+ * @return self
+ */
+ public static function from_source( string $name, string $type, array $source, ?string $label = null, ?string $version = null ): self {
+ $item = new self( $name, $type, '', $label, $version );
+
+ $item->content = null;
+ $item->source = $source;
+
+ return $item;
}
/**
@@ -48,12 +94,32 @@ public function type(): string {
/**
* Get icon's content.
*
+ * For a deferred item this is where the SVG is actually read. The result is
+ * memoized, so repeated calls within a request cost nothing.
+ *
* @return string
*/
public function content(): string {
+ if ( null === $this->content ) {
+ $this->content = null === $this->source
+ ? ''
+ : ContentLoader::load( $this->source );
+ }
+
return $this->content;
}
+ /**
+ * Whether the content is already in memory.
+ *
+ * Lets callers avoid triggering a read they do not need.
+ *
+ * @return bool
+ */
+ public function is_resolved(): bool {
+ return null !== $this->content;
+ }
+
/**
* Get icon's version.
*
diff --git a/includes/Icon/CollectionItemsFactory.php b/includes/Icon/CollectionItemsFactory.php
index dd62873..3c4e25f 100644
--- a/includes/Icon/CollectionItemsFactory.php
+++ b/includes/Icon/CollectionItemsFactory.php
@@ -8,6 +8,37 @@ class CollectionItemsFactory {
private const CACHE_GROUP = 'blockparty-icons';
+ /**
+ * Format revision of the cached index.
+ *
+ * Cached indexes hold serialized CollectionItem objects. Bump this whenever
+ * their shape changes so that entries written by an earlier release are never
+ * read back — the salt cannot do this job, because `wp_cache_get_salted()`
+ * unserializes the stored value *before* it compares salts. Only a different
+ * key keeps the two formats apart.
+ */
+ private const CACHE_FORMAT = 'v3';
+
+ /**
+ * Build a format-scoped cache key.
+ *
+ * @param string $key
+ *
+ * @return string
+ */
+ private static function cache_key( string $key ): string {
+ return self::CACHE_FORMAT . ':' . $key;
+ }
+
+ /**
+ * Salt component that invalidates cached indexes when the plugin is updated.
+ *
+ * @return string
+ */
+ private static function cache_version(): string {
+ return defined( 'BLOCKPARTY_ICONS_VERSION' ) ? BLOCKPARTY_ICONS_VERSION : '0';
+ }
+
/**
* Instantiate array of CollectionItem from a folder.
*
@@ -18,8 +49,8 @@ class CollectionItemsFactory {
* @throws CollectionCreationException
*/
public static function from_folder( string $folder, array $icon_map = [] ): array {
- $cache_key = 'from_folder:' . $folder;
- $salt = md5( wp_json_encode( $icon_map ) );
+ $cache_key = self::cache_key( 'from_folder:' . $folder );
+ $salt = [ md5( wp_json_encode( $icon_map ) ), self::cache_version() ];
$items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salt );
if ( is_array( $items ) ) {
@@ -34,14 +65,23 @@ public static function from_folder( string $folder, array $icon_map = [] ): arra
$items = [];
$folder = trailingslashit( $folder );
foreach ( glob( $folder . '*.svg' ) as $svg ) {
- $item_content = file_get_contents( $svg ); //phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read local file
- if ( ! $item_content ) {
+ // Skip empty files as before, but without reading their contents:
+ // the payload is loaded only if the icon is actually used.
+ if ( ! filesize( $svg ) ) {
continue;
}
$name = pathinfo( $svg, PATHINFO_FILENAME );
$label = $icon_map[ $name ] ?? $name;
- $items[] = new CollectionItem( $name, 'raw', $item_content, $label );
+ $items[] = CollectionItem::from_source(
+ $name,
+ 'raw',
+ [
+ 'type' => 'file',
+ 'path' => $svg,
+ ],
+ $label
+ );
}
Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS );
@@ -60,8 +100,8 @@ public static function from_folder( string $folder, array $icon_map = [] ): arra
* @throws CollectionCreationException
*/
public static function from_sprite( string $path, array $icon_map = [], ?string $version = null ): array {
- $cache_key = 'from_sprite:' . $path;
- $salts = [ md5( wp_json_encode( $icon_map ) ) ];
+ $cache_key = self::cache_key( 'from_sprite:' . $path );
+ $salts = [ md5( wp_json_encode( $icon_map ) ), self::cache_version() ];
if ( $version ) {
$salts[] = $version;
}
@@ -113,8 +153,8 @@ public static function from_sprite( string $path, array $icon_map = [], ?string
* @throws CollectionCreationException
*/
public static function from_file( string $path, array $args = [] ): array {
- $cache_key = 'from_file:' . $path;
- $salt = md5( wp_json_encode( $args ) );
+ $cache_key = self::cache_key( 'from_file:' . $path );
+ $salt = [ md5( wp_json_encode( $args ) ), self::cache_version() ];
$items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salt );
if ( is_array( $items ) ) {
@@ -134,9 +174,8 @@ public static function from_file( string $path, array $args = [] ): array {
]
);
- $items = [];
- $item_content = file_get_contents( $path ); //phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read local file
- if ( ! $item_content ) {
+ $items = [];
+ if ( ! filesize( $path ) ) {
Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS );
return $items;
@@ -144,13 +183,101 @@ public static function from_file( string $path, array $args = [] ): array {
$name = $args['name'] ?? pathinfo( $path, PATHINFO_FILENAME );
$label = $args['label'] ?? $name;
- $items[] = new CollectionItem( $name, 'raw', $item_content, $label );
+ $items[] = CollectionItem::from_source(
+ $name,
+ 'raw',
+ [
+ 'type' => 'file',
+ 'path' => $path,
+ ],
+ $label
+ );
Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS );
return $items;
}
+ /**
+ * Instantiate array of CollectionItem from SVG attachments in the media library.
+ *
+ * Registering media-library icons one file at a time — a WP_Query followed by a
+ * `from_file()` call per attachment — costs a database query and one cache
+ * round trip per icon on every request. This builds the whole collection from a
+ * single query and stores it as one small index, keyed on the posts cache's
+ * `last_changed` value so contributing a new SVG in the back office invalidates
+ * it immediately.
+ *
+ * @param array $args {
+ * Optional. An array of additional arguments. Default empty array.
+ *
+ * @type array $icon_map Optional. Override labels, keyed by attachment slug.
+ * @type array $query Optional. Extra WP_Query arguments, merged over the defaults.
+ * }
+ *
+ * @return CollectionItem[]
+ */
+ public static function from_attachments( array $args = [] ): array {
+ $args = (array) wp_parse_args(
+ $args,
+ [
+ 'icon_map' => [],
+ 'query' => [],
+ ]
+ );
+
+ $query_args = (array) wp_parse_args(
+ (array) $args['query'],
+ [
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_mime_type' => 'image/svg+xml',
+ 'posts_per_page' => 500, //phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+ 'no_found_rows' => true,
+ ]
+ );
+
+ $cache_key = self::cache_key( 'from_attachments:' . md5( wp_json_encode( $query_args ) ) );
+ $salts = [
+ md5( wp_json_encode( $args['icon_map'] ) ),
+ self::cache_version(),
+ // Any change to a post or attachment bumps this, so the index cannot go stale.
+ (string) wp_cache_get_last_changed( 'posts' ),
+ ];
+
+ $items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salts );
+ if ( is_array( $items ) ) {
+ return $items;
+ }
+
+ // Only the fields needed to build the index.
+ $query_args['fields'] = 'all';
+
+ $query = new \WP_Query( $query_args );
+
+ $items = [];
+ foreach ( $query->posts as $attachment ) {
+ $name = $attachment->post_name;
+ $label = $args['icon_map'][ $name ] ?? get_the_title( $attachment );
+
+ $items[] = CollectionItem::from_source(
+ $name,
+ 'raw',
+ [
+ 'type' => 'attachment',
+ 'id' => (int) $attachment->ID,
+ ],
+ $label
+ );
+ }
+
+ Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salts, DAY_IN_SECONDS );
+
+ return $items;
+ }
+
/**
* Format SVG name from its id attribute.
*
diff --git a/includes/Icon/ContentLoader.php b/includes/Icon/ContentLoader.php
new file mode 100644
index 0000000..493ebb7
--- /dev/null
+++ b/includes/Icon/ContentLoader.php
@@ -0,0 +1,92 @@
+./includes/
./build/
+
+ ./tests/perf/./node_modules/./src/./tools/
diff --git a/tests/perf/README.md b/tests/perf/README.md
new file mode 100644
index 0000000..d02853f
--- /dev/null
+++ b/tests/perf/README.md
@@ -0,0 +1,268 @@
+# Blockparty Icons — performance protocol
+
+A reproducible local rig for the WordPress VIP performance problem: a site with
+500+ SVG icons, some larger than 1 MB, where the plugin loads every icon's content
+into memory on every request — front end included, whether or not any icon is used.
+
+The rig exists to produce **comparable before/after numbers**, so that a fix can be
+shown to work rather than asserted to.
+
+---
+
+## What it builds
+
+An isolated WordPress instance on (the normal dev env on
+8888 is untouched and can run at the same time):
+
+| | |
+|---|---|
+| `perf-theme` | 200 SVG icons in a theme folder, registered with `type => folder` |
+| `perf-media` | 300 SVG icons contributed through the back office (media library) |
+| icon sizes | ~11 KB → ~1.95 MB, following a fixed ladder; **25 icons exceed 1 MB** |
+| total payload | ~102 MB of SVG |
+| object cache | persistent, and refuses any item over 1 MB — like VIP's memcached |
+| pages | `/perf-empty/` (no icon), `/perf-single/` (1 icon), `/perf-many/` (20 icons) |
+
+The two collections deliberately mirror the VIP topology: theme files are local,
+media-library files are remote. `get_attached_file()` is routed through a `bpifs://`
+stream wrapper that counts every open and can charge artificial latency.
+
+---
+
+## Prerequisites
+
+- Docker running
+- Node 22 (`volta` pins it)
+- `composer` on the PATH — **the plugin resolves its classes through Composer's
+ PSR-4 autoloader**, so without `vendor/` nothing loads
+
+## Usage
+
+```bash
+# One-off: generate fixtures, boot the env, import 300 icons into the media library.
+# Takes several minutes, mostly the media import.
+npm run perf:setup
+
+# Capture a run. --label names the result set.
+npm run perf:bench -- --label=baseline
+
+# After changing the plugin, capture again and diff against the baseline.
+npm run perf:bench -- --label=after
+node tests/perf/bin/report.mjs --label=after --compare=baseline
+
+# Tear down (removes containers and the database, keeps the fixtures).
+npm run perf:teardown
+```
+
+Useful flags:
+
+| flag | effect |
+|---|---|
+| `--scale=small` (setup) | 50 icons instead of 500, for quick iteration on the rig itself |
+| `--skip-fixtures` (setup) | reuse the SVGs already generated |
+| `--runs=N` (bench) | samples per scenario, default 5; the first is dropped as warm-up |
+| `--latency-us=2000` (bench) | charge 2 ms per media-library file open, emulating VIP Files |
+
+---
+
+## Metrics
+
+The probe (`mu-plugins/bpi-perf-probe.php`) writes one JSON record per request.
+`report.mjs` reduces each scenario to a **median** so one container hiccup cannot
+move a number.
+
+| column | meaning |
+|---|---|
+| `icons` | icons held in registered collections |
+| `response KB` | bytes on the wire |
+| `resident` | **MB of SVG content held in memory.** The direct measure of the problem |
+| `peak mem` | `memory_get_peak_usage(true)` |
+| `media reads` | media-library file opens, exact, via the `bpifs://` wrapper |
+| `cache get` / `cache miss` | object-cache operations in the `blockparty-icons` group |
+| `set >1MB refused` | cache writes memcached would reject. **These never warm up** |
+| `init hook` | wall time inside `blockparty_icons_init` |
+| `wall` | total request time |
+
+`resident` is measured by reflecting on `CollectionItem`'s private properties, not
+by calling `content()`. Calling the getter would force lazily-loaded icons to
+resolve and destroy the very thing being measured — the number stays honest across
+the refactor.
+
+---
+
+## Faithfulness, and where it stops
+
+Deliberate choices worth knowing before trusting a number:
+
+- **The object cache is file-backed, not memcached.** It reproduces the *semantics*
+ exactly — persistence across requests, a 1 MB item ceiling, a silent `false` from
+ `wp_cache_set()` — but not the latency. Local file I/O is far cheaper than a
+ network round trip. **Treat operation counts as the transferable metric and wall
+ time as indicative only.** To model VIP, multiply `cache get` by a memcached RTT.
+- **Theme-folder file reads are not counted directly.** `glob()` bypasses PHP stream
+ wrappers, so only media-library reads get exact counts. Folder reads are inferred
+ from cache misses: a miss on `from_folder:*` means all 200 files were re-read.
+ `resident` covers what actually ended up in memory either way.
+- **Latency emulation is off by default** (`--latency-us=0`) so the baseline states
+ only what was really measured. Turn it on for a VIP-shaped picture.
+- **PHP memory limit is raised to 512 MB**, matching VIP's web limit. At PHP's
+ common 128 MB default this dataset does not merely run slowly — see below.
+
+---
+
+## Baseline (2026-09-10, 500 icons, latency 0, 4 runs)
+
+### At a 128 MB PHP memory limit, the plugin does not run at all
+
+Activating the plugin against this dataset fatals outright:
+
+```
+PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
+(tried to allocate 31887360 bytes) in .../object-cache.php on line 216
+```
+
+The allocation that fails is `serialize()` on the 200-icon folder collection. The
+collection is built in full (~40 MB), then serialized for the cache (~32 MB more).
+Everything below therefore runs at 512 MB.
+
+### Measured
+
+| scenario | icons | response KB | resident MB | peak MB | media reads | cache get | cache miss | >1MB refused | init ms | wall ms |
+|---|---|---|---|---|---|---|---|---|---|---|
+| front-empty-warm | 500 | 20 | 101.7 | 154 | 15 | 301 | 16 | 16 | 602 | 640 |
+| front-empty-cold | 500 | 20 | 101.7 | 154 | 300 | 301 | 301 | 16 | 2557 | 2658 |
+| front-single-warm | 500 | 39 | 101.7 | 154 | 15 | 301 | 16 | 16 | 679 | 768 |
+| front-single-cold | 500 | 39 | 101.7 | 154 | 300 | 301 | 301 | 16 | 860 | 934 |
+| front-many-warm | 500 | 3513 | 101.7 | 154 | 15 | 301 | 16 | 16 | 305 | 363 |
+| rest-collections | 500 | <1 | 101.7 | 173 | 15 | 301 | 16 | 16 | 539 | 601 |
+| rest-theme-p1 | 500 | 12065 | 101.7 | 154 | 15 | 301 | 16 | 16 | 568 | 729 |
+| rest-media-p1 | 500 | 11805 | 101.7 | 171 | 15 | 301 | 16 | 16 | 230 | 284 |
+| rest-theme-search | 500 | 8818 | 101.7 | 154 | 15 | 301 | 16 | 16 | 459 | 558 |
+| editor-new-page | 500 | **24530** | 101.7 | 239 | 15 | 301 | 16 | 16 | 506 | 1201 |
+
+### What the numbers say
+
+1. **`resident` is 101.7 MB on every single row**, including `front-empty-*`, a page
+ with no icon block at all. The entire 102 MB icon corpus is loaded to render a
+ paragraph. It never varies, because nothing about the request influences it.
+
+2. **16 cache entries are permanently refused** for exceeding 1 MB:
+ - `from_folder:.../theme-icons` — the whole 200-icon collection as one entry
+ - 15 × `from_file:...` — each individual media icon above 1 MB
+
+ These are not slow-to-warm; they *never* warm. `wp_cache_set()` returns `false`,
+ no caller checks it, and the work is redone on every request forever. The
+ `from_folder` rejection alone means all 200 theme SVGs are re-read from disk on
+ every request.
+
+3. **301 object-cache gets per request**, 300 of them from the media-library
+ collection's per-file cache keys. On VIP each is a memcached round trip; at a
+ conservative 0.3 ms that is ~90 ms of pure latency before any rendering.
+
+4. **A cold cache costs 300 media reads and 61.8 MB** of remote reads. With
+ `--latency-us=2000` that alone adds ~600 ms.
+
+5. **The editor bootstraps 24.5 MB of HTML** because
+ `block_editor_rest_api_preload_paths` inlines the first 50 icons *with content*
+ for each collection. A single REST page is 12 MB.
+
+These reproduce the three failure modes in the audit: eager whole-corpus loading
+(point 1), cache entries too large to store (point 3), and the media-library
+registration pattern (point 5).
+
+---
+
+## After the fix
+
+Points 1, 3 and 5 applied: lazy payload loading, per-icon caching with a size
+guard, and the `attachments` collection type. Same dataset, same 4 runs.
+
+| scenario | resident MB | peak MB | media reads | cache get | >1MB refused | init ms | wall ms |
+|---|---|---|---|---|---|---|---|
+| front-empty-warm | 0.015 (−100%) | 12 (−92%) | 0 (−100%) | 2 (−99%) | 0 | 2.3 (−100%) | 58 (−91%) |
+| front-empty-cold | 0.015 (−100%) | 16 (−90%) | 0 (−100%) | 2 (−99%) | 0 | 329 (−87%) | 388 (−85%) |
+| front-single-warm | 0.032 (−100%) | 10 (−94%) | 0 (−100%) | 2 (−99%) | 0 | 1.3 (−100%) | 32 (−96%) |
+| front-many-warm | 3.42 (−97%) | 31 (−80%) | 0 (−100%) | 2 (−99%) | 0 | 1.5 (−99%) | 95 (−74%) |
+| rest-theme-p1 | 11.70 (−88%) | 33 (−79%) | 0 (−100%) | 2 (−99%) | 0 | 0.5 (−100%) | 55 (−92%) |
+| rest-media-p1 | 9.11 (−91%) | 27 (−84%) | 3 (−80%) | 2 (−99%) | 0 | 0.8 (−100%) | 67 (−77%) |
+| editor-new-page | 20.80 (−80%) | 147 (−38%) | 3 (−80%) | 2 (−99%) | 0 | 81 (−84%) | 432 (−64%) |
+
+Reading the rows:
+
+- **`resident` collapses from a flat 101.7 MB to what the request actually uses** —
+ 15 KB for a page with no icon, 32 KB for a page with one, 11.7 MB for an editor
+ page that genuinely lists 50 icons.
+- **`cache get` drops from 301 to 2**: one index per collection, instead of one
+ lookup per media attachment.
+- **No cache entry is refused any more.** Indexes are small enough to store, and
+ payloads are cached individually — a 1.8 MB icon no longer poisons a collection.
+- `response KB` is unchanged where the payload is genuinely wanted: the same icons
+ are still delivered, byte for byte.
+
+### With VIP-shaped latency (`--latency-us=2000`)
+
+Charging 2 ms per media-library file open, which is what a VIP Files fetch costs:
+
+| scenario | before | after |
+|---|---|---|
+| front-empty-warm | 402 ms | 25 ms (−94%) |
+| front-empty-cold | **4546 ms** | 435 ms (−90%) |
+| front-single-warm | 414 ms | 41 ms (−90%) |
+| front-single-cold | 4739 ms | 493 ms (−90%) |
+| editor-new-page | 834 ms | 364 ms (−56%) |
+
+Both sides of this table were measured in the same session, on the same machine,
+against the same 500 icons — the "before" column by checking the original classes
+back out into the working tree, not by reusing an older run. Wall time moves by a
+few tens of percent between sessions depending on what else the machine is doing;
+the operation counts above do not move at all.
+
+### What is not addressed
+
+- **The editor still bootstraps ~22 MB.** `block_editor_rest_api_preload_paths`
+ inlines the first 50 icons of every collection, with content. Cutting that means
+ changing what the editor asks for — dropping `content` from the `view` context, or
+ having the picker fetch payloads only for icons it draws. That was point 4 of the
+ audit and is out of scope here.
+- Rendering a page with 20 icons still resolves 20 payloads (3.4 MB). That is the
+ work the page actually requires.
+
+---
+
+## Files
+
+```
+tests/perf/
+ bin/
+ _env.sh shared config; works around three wp-env quirks
+ generate-fixtures.mjs deterministic SVG generator (seeded, reproducible)
+ setup.sh boot the env, import media, create pages
+ bench.sh run the scenarios, capture metrics
+ report.mjs aggregate to medians, render the table, diff runs
+ teardown.sh destroy the env
+ drop-ins/object-cache.php instrumented cache with memcached's 1 MB ceiling
+ mu-plugins/
+ bpi-perf-probe.php metrics + the bpifs:// remote-filesystem wrapper
+ bpi-perf-auth.php test-only auth shim for REST scenarios
+ theme/blockparty-perf/ fixture theme; registers both collections
+ env/.wp-env.json the isolated environment definition
+ fixtures/ generated SVGs (git-ignored, ~102 MB)
+ results/ captured runs (git-ignored)
+```
+
+### wp-env quirks worked around
+
+`@wordpress/env` 10.39:
+
+1. has **no `--config` flag** on any subcommand — it reads `.wp-env.json` from the
+ working directory, which is why the perf env lives in `tests/perf/env/`;
+2. ignores the `port` key, templating `${WP_ENV_PORT:-8888}` into docker-compose
+ instead — so the ports are set through environment variables;
+3. gives `wp-env run` no way to target a non-default env — so the harness talks to
+ the containers with `docker exec`, resolving them by published port.
+
+### Safety
+
+`bpi-perf-auth.php` authenticates any request presenting the `BPI_PERF_TOKEN`
+constant's value. It is inert unless that constant is defined, and it is defined
+only in `tests/perf/env/.wp-env.json`. It must never be copied into a real site.
diff --git a/tests/perf/bin/_env.sh b/tests/perf/bin/_env.sh
new file mode 100644
index 0000000..3776501
--- /dev/null
+++ b/tests/perf/bin/_env.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+#
+# Shared settings for the perf harness. Sourced by setup.sh and bench.sh.
+#
+# Three wp-env facts shape this file (@wordpress/env 10.39):
+#
+# 1. There is no --config flag. wp-env always reads .wp-env.json from the current
+# working directory. The perf environment therefore lives in its own directory,
+# tests/perf/env/, with its own .wp-env.json. That also gives it its own project
+# hash, so its containers and database are fully isolated from the dev env.
+#
+# 2. Ports come from WP_ENV_PORT / WP_ENV_TESTS_PORT, not from the config file.
+# We pin them so the perf instance can run alongside the dev env on 8888/8889.
+#
+# 3. `wp-env run` takes no config either, so we address the containers directly
+# with docker exec, resolving them by the port they publish.
+
+export WP_ENV_PORT="${WP_ENV_PORT:-8899}"
+export WP_ENV_TESTS_PORT="${WP_ENV_TESTS_PORT:-8898}"
+
+PERF_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../../.." && pwd )"
+PERF_ENV_DIR="${PERF_ROOT}/tests/perf/env"
+PERF_BASE="http://localhost:${WP_ENV_PORT}"
+PERF_TOKEN="local-perf-harness-only"
+
+export PERF_ROOT PERF_ENV_DIR PERF_BASE PERF_TOKEN
+
+say() {
+ printf '\n\033[1m==> %s\033[0m\n' "$1"
+}
+
+# Run a wp-env subcommand against the perf environment.
+perf_wp_env() {
+ ( cd "$PERF_ENV_DIR" && npx --prefix "$PERF_ROOT" wp-env "$@" )
+}
+
+# Resolve the perf environment's container names from the published port.
+perf_resolve_containers() {
+ local wp
+ wp="$( docker ps \
+ --filter "publish=${WP_ENV_PORT}" \
+ --filter "name=-wordpress-1" \
+ --format '{{.Names}}' | head -n1 )"
+
+ if [ -z "$wp" ]; then
+ echo "No running wp-env WordPress container publishing port ${WP_ENV_PORT}." >&2
+ echo "Start it with: tests/perf/bin/setup.sh" >&2
+ return 1
+ fi
+
+ PERF_WP_CONTAINER="$wp"
+ PERF_PROJECT="${wp%-wordpress-1}"
+ PERF_CLI_CONTAINER="${PERF_PROJECT}-cli-1"
+
+ export PERF_WP_CONTAINER PERF_PROJECT PERF_CLI_CONTAINER
+}
+
+# Run a command inside the CLI container, as the web user, at the WordPress root.
+incli() {
+ docker exec -u 33 -w /var/www/html "$PERF_CLI_CONTAINER" "$@"
+}
+
+# Run WP-CLI inside the perf environment.
+wpcli() {
+ incli wp "$@"
+}
diff --git a/tests/perf/bin/bench.sh b/tests/perf/bin/bench.sh
new file mode 100755
index 0000000..e857a36
--- /dev/null
+++ b/tests/perf/bin/bench.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+#
+# Run the Blockparty Icons perf scenarios and write a comparable result set.
+#
+# tests/perf/bin/bench.sh --label=baseline [--runs=5] [--latency-us=0]
+#
+# --label names the result file, so before/after runs can be diffed
+# --runs samples per scenario (the first is discarded as a warm-up)
+# --latency-us artificial per-file latency for media-library reads, emulating VIP Files
+#
+set -euo pipefail
+
+# shellcheck source=tests/perf/bin/_env.sh
+source "$( dirname "${BASH_SOURCE[0]}" )/_env.sh"
+
+BASE="$PERF_BASE"
+TOKEN="$PERF_TOKEN"
+
+LABEL=""
+RUNS=5
+LATENCY=0
+
+for arg in "$@"; do
+ case "$arg" in
+ --label=*) LABEL="${arg#*=}" ;;
+ --runs=*) RUNS="${arg#*=}" ;;
+ --latency-us=*) LATENCY="${arg#*=}" ;;
+ *) echo "unknown argument: $arg" >&2; exit 1 ;;
+ esac
+done
+
+if [ -z "$LABEL" ]; then
+ echo "--label is required (e.g. --label=baseline)" >&2
+ exit 1
+fi
+
+cd "$PERF_ROOT"
+mkdir -p tests/perf/results
+
+perf_resolve_containers
+
+LOG_HOST="tests/perf/results/${LABEL}.log"
+SIZES_FILE="tests/perf/results/${LABEL}.sizes"
+: > "$SIZES_FILE"
+
+# wp-admin is gated by auth_redirect(), which validates the *auth*-scheme cookie,
+# while REST and the rest of WordPress read the logged_in one. Mint both.
+say "Minting an admin session for the editor scenario"
+ADMIN_ID="$( wpcli user list --role=administrator --field=ID --number=1 | tr -d '\r' )"
+LOGIN_COOKIE_NAME="$( wpcli eval 'echo LOGGED_IN_COOKIE;' | tr -d '\r' )"
+AUTH_COOKIE_NAME="$( wpcli eval 'echo AUTH_COOKIE;' | tr -d '\r' )"
+LOGIN_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'logged_in' );" | tr -d '\r' )"
+AUTH_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'auth' );" | tr -d '\r' )"
+
+if [ -z "$LOGIN_COOKIE_VALUE" ] || [ -z "$AUTH_COOKIE_VALUE" ]; then
+ echo "Could not mint auth cookies; the editor scenario will redirect." >&2
+else
+ echo " admin user ${ADMIN_ID}, cookies minted"
+fi
+
+say "Resetting perf log"
+incli rm -f /var/www/html/wp-content/bpi-perf.log >/dev/null 2>&1 || true
+
+# hit URL, scenario-name, cold|warm, [auth]
+hit() {
+ local url="$1" name="$2" cache="$3" auth="${4:-noauth}"
+ local sep="?"
+ case "$url" in *\?*) sep="&" ;; esac
+
+ local full="${BASE}${url}${sep}bpi_perf_label=${name}&bpi_perf_latency_us=${LATENCY}"
+ local -a curl_args=( -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 600 )
+
+ if [ "$auth" = "auth" ]; then
+ curl_args+=( -H "X-BPI-Perf-Token: ${TOKEN}" )
+ if [ -n "$LOGIN_COOKIE_VALUE" ]; then
+ curl_args+=( -b "${LOGIN_COOKIE_NAME}=${LOGIN_COOKIE_VALUE}" )
+ curl_args+=( -b "${AUTH_COOKIE_NAME}=${AUTH_COOKIE_VALUE}" )
+ fi
+ fi
+
+ local last_bytes=0
+ for i in $( seq 1 "$RUNS" ); do
+ if [ "$cache" = "cold" ]; then
+ wpcli cache flush >/dev/null 2>&1 || true
+ fi
+ local out code bytes
+ out="$( curl "${curl_args[@]}" "$full" )"
+ code="${out%% *}"
+ bytes="${out##* }"
+ last_bytes="$bytes"
+ if [ "$code" != "200" ]; then
+ printf ' %s run %s -> HTTP %s\n' "$name" "$i" "$code" >&2
+ fi
+ done
+
+ printf '%s\t%s\n' "$name" "$last_bytes" >> "$SIZES_FILE"
+ printf ' %-28s %s cache, %s runs, %s KB response\n' \
+ "$name" "$cache" "$RUNS" "$(( last_bytes / 1024 ))"
+}
+
+say "Running scenarios (latency=${LATENCY}us per media read)"
+
+# --- front end -------------------------------------------------------------
+hit "/perf-empty/" "front-empty-warm" warm
+hit "/perf-empty/" "front-empty-cold" cold
+hit "/perf-single/" "front-single-warm" warm
+hit "/perf-single/" "front-single-cold" cold
+hit "/perf-many/" "front-many-warm" warm
+
+# --- REST (editor data path) ----------------------------------------------
+hit "/wp-json/icons/v1/collections?context=edit" "rest-collections" warm auth
+hit "/wp-json/icons/v1/perf-theme?context=edit&per_page=50" "rest-theme-p1" warm auth
+hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50" "rest-media-p1" warm auth
+hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50&page=4" "rest-media-p4" warm auth
+hit "/wp-json/icons/v1/perf-theme?context=edit&search=icon-1" "rest-theme-search" warm auth
+
+# --- editor ----------------------------------------------------------------
+hit "/wp-admin/post-new.php?post_type=page" "editor-new-page" warm auth
+
+say "Collecting results"
+incli cat /var/www/html/wp-content/bpi-perf.log > "$LOG_HOST" 2>/dev/null || true
+
+if [ ! -s "$LOG_HOST" ]; then
+ echo "Perf log is empty — is the probe mu-plugin loaded?" >&2
+ exit 1
+fi
+
+node tests/perf/bin/report.mjs --label="$LABEL"
diff --git a/tests/perf/bin/generate-fixtures.mjs b/tests/perf/bin/generate-fixtures.mjs
new file mode 100755
index 0000000..9f4e722
--- /dev/null
+++ b/tests/perf/bin/generate-fixtures.mjs
@@ -0,0 +1,221 @@
+#!/usr/bin/env node
+/**
+ * Generate deterministic SVG fixtures for the Blockparty Icons perf protocol.
+ *
+ * Two sets are produced:
+ * - theme-icons/ consumed by the fixture theme as a `folder` collection (local FS, like a VIP theme)
+ * - media-icons/ imported into the media library (remote FS on VIP, see the bpifs:// wrapper)
+ *
+ * Sizes follow a fixed ladder from ~10 KB to ~2 MB so that:
+ * - the aggregate of any collection blows past memcached's 1 MB per-item limit
+ * - a handful of *individual* icons also blow past it on their own
+ *
+ * Usage:
+ * node tests/perf/bin/generate-fixtures.mjs [--scale=full|small] [--out=DIR]
+ */
+
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname( fileURLToPath( import.meta.url ) );
+const DEFAULT_OUT = resolve( HERE, '..', 'fixtures' );
+
+const KB = 1024;
+const MB = 1024 * 1024;
+
+/**
+ * Size ladder. Each bucket is [share, minBytes, maxBytes].
+ * `share` values are relative weights within a set.
+ */
+const LADDER = [
+ { key: 'a', share: 0.55, min: 10 * KB, max: 50 * KB },
+ { key: 'b', share: 0.28, min: 50 * KB, max: 250 * KB },
+ { key: 'c', share: 0.12, min: 250 * KB, max: 1 * MB },
+ { key: 'd', share: 0.05, min: 1 * MB, max: 2 * MB },
+];
+
+const SCALES = {
+ full: { theme: 200, media: 300 },
+ small: { theme: 20, media: 30 },
+};
+
+/** Deterministic PRNG (mulberry32) so runs are byte-for-byte reproducible. */
+function prng( seed ) {
+ let a = seed >>> 0;
+ return () => {
+ a = ( a + 0x6d2b79f5 ) >>> 0;
+ let t = a;
+ t = Math.imul( t ^ ( t >>> 15 ), t | 1 );
+ t ^= t + Math.imul( t ^ ( t >>> 7 ), t | 61 );
+ return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
+ };
+}
+
+/** Assign a target byte size to every icon index, deterministically. */
+function planSizes( count, rand ) {
+ const plan = [];
+ let assigned = 0;
+
+ LADDER.forEach( ( bucket, i ) => {
+ // Last bucket soaks up the rounding remainder.
+ const n =
+ i === LADDER.length - 1
+ ? count - assigned
+ : Math.round( count * bucket.share );
+ assigned += n;
+ for ( let k = 0; k < n; k++ ) {
+ plan.push( {
+ bucket: bucket.key,
+ bytes: Math.floor( bucket.min + rand() * ( bucket.max - bucket.min ) ),
+ } );
+ }
+ } );
+
+ // Interleave buckets so a paginated REST response (50 per page) sees a mix
+ // of sizes rather than 50 tiny icons on page 1.
+ const shuffled = [];
+ const stride = Math.ceil( Math.sqrt( plan.length ) ) || 1;
+ for ( let offset = 0; offset < stride; offset++ ) {
+ for ( let i = offset; i < plan.length; i += stride ) {
+ shuffled.push( plan[ i ] );
+ }
+ }
+
+ return shuffled;
+}
+
+/**
+ * Build a syntactically valid SVG padded with real path data up to `targetBytes`.
+ *
+ * Padding is genuine `` geometry rather than a comment blob, so that
+ * strip_tags(), preg_match_all() and WP_HTML_Tag_Processor do representative work.
+ */
+function buildSvg( name, targetBytes, rand ) {
+ const head =
+ ``;
+
+ const parts = [ head ];
+ let size = Buffer.byteLength( head ) + Buffer.byteLength( tail );
+
+ // Each filler path is ~600-700 bytes of plausible curve data.
+ while ( size < targetBytes ) {
+ const coords = [];
+ const segments = 24;
+ let x = ( rand() * 24 ).toFixed( 2 );
+ let y = ( rand() * 24 ).toFixed( 2 );
+ coords.push( `M${ x } ${ y }` );
+ for ( let s = 0; s < segments; s++ ) {
+ const c1x = ( rand() * 24 ).toFixed( 2 );
+ const c1y = ( rand() * 24 ).toFixed( 2 );
+ const c2x = ( rand() * 24 ).toFixed( 2 );
+ const c2y = ( rand() * 24 ).toFixed( 2 );
+ x = ( rand() * 24 ).toFixed( 2 );
+ y = ( rand() * 24 ).toFixed( 2 );
+ coords.push( `C${ c1x } ${ c1y } ${ c2x } ${ c2y } ${ x } ${ y }` );
+ }
+ coords.push( 'Z' );
+
+ const opacity = ( 0.1 + rand() * 0.9 ).toFixed( 3 );
+ const piece = ``;
+
+ parts.push( piece );
+ size += Buffer.byteLength( piece );
+ }
+
+ parts.push( tail );
+ return parts.join( '' );
+}
+
+function generateSet( outDir, prefix, count, seed ) {
+ rmSync( outDir, { recursive: true, force: true } );
+ mkdirSync( outDir, { recursive: true } );
+
+ const rand = prng( seed );
+ const plan = planSizes( count, rand );
+ const entries = [];
+
+ plan.forEach( ( item, i ) => {
+ const index = String( i + 1 ).padStart( 3, '0' );
+ const name = `${ prefix }-${ index }`;
+ const svg = buildSvg( name, item.bytes, rand );
+ const bytes = Buffer.byteLength( svg );
+
+ writeFileSync( resolve( outDir, `${ name }.svg` ), svg );
+ entries.push( { name, file: `${ name }.svg`, bucket: item.bucket, bytes } );
+ } );
+
+ return entries;
+}
+
+function summarise( entries ) {
+ const total = entries.reduce( ( acc, e ) => acc + e.bytes, 0 );
+ const overLimit = entries.filter( ( e ) => e.bytes > MB );
+ const byBucket = {};
+ for ( const e of entries ) {
+ byBucket[ e.bucket ] = byBucket[ e.bucket ] || { count: 0, bytes: 0 };
+ byBucket[ e.bucket ].count++;
+ byBucket[ e.bucket ].bytes += e.bytes;
+ }
+ return {
+ count: entries.length,
+ totalBytes: total,
+ totalMB: +( total / MB ).toFixed( 2 ),
+ minBytes: Math.min( ...entries.map( ( e ) => e.bytes ) ),
+ maxBytes: Math.max( ...entries.map( ( e ) => e.bytes ) ),
+ overOneMB: overLimit.length,
+ byBucket,
+ };
+}
+
+function main() {
+ const args = Object.fromEntries(
+ process.argv.slice( 2 ).map( ( a ) => {
+ const [ k, v ] = a.replace( /^--/, '' ).split( '=' );
+ return [ k, v ?? true ];
+ } )
+ );
+
+ const scaleKey = args.scale === 'small' ? 'small' : 'full';
+ const scale = SCALES[ scaleKey ];
+ const out = args.out ? resolve( String( args.out ) ) : DEFAULT_OUT;
+
+ mkdirSync( out, { recursive: true } );
+
+ const theme = generateSet( resolve( out, 'theme-icons' ), 'theme-icon', scale.theme, 1337 );
+ const media = generateSet( resolve( out, 'media-icons' ), 'media-icon', scale.media, 4242 );
+
+ const manifest = {
+ generatedAt: new Date().toISOString(),
+ scale: scaleKey,
+ ladder: LADDER,
+ sets: {
+ theme: { dir: 'theme-icons', ...summarise( theme ), entries: theme },
+ media: { dir: 'media-icons', ...summarise( media ), entries: media },
+ },
+ };
+
+ writeFileSync(
+ resolve( out, 'manifest.json' ),
+ JSON.stringify( manifest, null, '\t' )
+ );
+
+ const t = manifest.sets.theme;
+ const m = manifest.sets.media;
+ const fmt = ( s ) =>
+ `${ s.count } icons, ${ s.totalMB } MB total, ${ ( s.minBytes / KB ).toFixed( 0 ) } KB → ` +
+ `${ ( s.maxBytes / MB ).toFixed( 2 ) } MB, ${ s.overOneMB } over 1 MB`;
+
+ process.stdout.write(
+ `scale : ${ scaleKey }\n` +
+ `theme set : ${ fmt( t ) }\n` +
+ `media set : ${ fmt( m ) }\n` +
+ `total : ${ ( ( t.totalBytes + m.totalBytes ) / MB ).toFixed( 2 ) } MB in ${ out }\n`
+ );
+}
+
+main();
diff --git a/tests/perf/bin/report.mjs b/tests/perf/bin/report.mjs
new file mode 100755
index 0000000..c99436c
--- /dev/null
+++ b/tests/perf/bin/report.mjs
@@ -0,0 +1,224 @@
+#!/usr/bin/env node
+/**
+ * Aggregate a perf log into a comparable report.
+ *
+ * node tests/perf/bin/report.mjs --label=baseline
+ * node tests/perf/bin/report.mjs --label=after --compare=baseline
+ *
+ * Reads tests/perf/results/