Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
41 changes: 8 additions & 33 deletions .wp-env/themes/icon-block-theme/functions.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<?php

use function Blockparty\Icons\register_icon_collection;
use Blockparty\Icons\Icon\Collection;
use Blockparty\Icons\Icon\CollectionItemsFactory;

add_filter( 'upload_mimes', 'wpc_mime_types' );

Expand Down Expand Up @@ -39,38 +37,15 @@ function wpc_mime_types( $mimes ) {
]
);

// Register collections with icons from media library.
$query = new \WP_Query(
// Register collections with icons from the media library.
//
// `attachments` runs a single query and caches one small index; the SVG payload
// of an icon is read only when that icon is actually rendered or listed.
register_icon_collection(
'mediatheque',
[
'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
'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 );
}
} );
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` | <ul><li>`sprite` for SVG sprite source.</li><li>`folder` for a folder containing SVG files.</li></ul> |
| `type` | <ul><li>`sprite` for SVG sprite source.</li><li>`folder` for a folder containing SVG files.</li><li>`attachments` for the SVG files in the media library.</li></ul> |
| `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
Expand Down
15 changes: 12 additions & 3 deletions blockparty-icons.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* }
*
Expand Down Expand Up @@ -125,6 +126,7 @@ function register_icon_collection( $name, array $args = [] ) {
'type' => '',
'source' => '',
'icon_map' => [],
'query' => [],
'version' => null,
]
);
Expand All @@ -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 );
Expand All @@ -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.
Expand All @@ -187,6 +192,7 @@ function add_icons( string $collection_name, string $type, string $source, array
[
'label' => '',
'icon_map' => [],
'query' => [],
'version' => null,
]
);
Expand Down Expand Up @@ -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;
}
Expand Down
64 changes: 64 additions & 0 deletions includes/Helpers/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 );
}
Expand All @@ -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' );
Expand Down
17 changes: 17 additions & 0 deletions includes/Icon/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading