Skip to content
Closed
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
1 change: 1 addition & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
grumphp.yml
phpcs.xml.dist
phpunit.xml.dist
wp-tests-config.php
psalm.xml.dist

# Dependency management
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/test-php.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: PHP Tests

on:
pull_request:
push:
branches:
- main
- develop

# Cancels all previous workflow runs for pull requests that have not completed.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true

jobs:
phpunit:
name: PHPUnit (PHP ${{ matrix.php }})
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
# The full range the plugin declares support for. wp-env selects the PHP
# version through WP_ENV_PHP_VERSION, which picks the matching upstream
# `wordpress:php8.x` image.
php: [ '8.1', '8.2', '8.3', '8.4' ]

env:
# Keep off 8888/8889 for no reason other than matching the documented local setup.
WP_ENV_PORT: 8890
WP_ENV_TESTS_PORT: 8891
WP_ENV_PHP_VERSION: ${{ matrix.php }}

steps:
- name: Checkout project
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install npm dependencies
run: npm ci

# The plugin calls register_block_type() on its build/ directory. build/ is
# git-ignored, so without this the block never registers and every rendering
# test receives an empty string.
- name: Build block assets
run: npm run build

- name: Setup Composer
run: |
composer config --global http-basic.composer.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }}
composer config --global http-basic.packages.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }}

# Installed on the host: the plugin directory is mounted into the wp-env
# containers, so vendor/ is visible from inside them. The platform pin in
# composer.json keeps resolution identical across the matrix.
- name: Install composer dependencies
run: composer install --no-interaction --no-progress

# Supplies WordPress, the WordPress PHPUnit library and a database, so there
# is no wp-tests-config.php to hand-roll and the suite runs exactly the way
# it does locally.
- name: Start wp-env
run: npx wp-env start

- name: Run PHPUnit
run: npm run test:php

- name: Show wp-env logs on failure
if: failure()
run: npx wp-env logs tests --no-watch
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,12 @@ build/
.DS_Store
.nvmrc
phpcs.xml

# PHPUnit: a local test configuration is per-machine (see tests/wp-tests-config-sample.php)
/wp-tests-config.php
/phpunit.xml
/.phpunit.result.cache

# Perf protocol: generated fixtures (~100 MB) and captured results
tests/perf/fixtures/
tests/perf/results/
2 changes: 1 addition & 1 deletion .plugin-data
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "1.1.1",
"version": "1.1.2",
"slug": "blockparty-icons"
}
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 );
}
} );
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use

## Changelog

### 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

### 1.1.2 - 2026-09-10

- Fix `beapi/icon-block` migration skipping icons that have a name but no collection (registry lookup then generic fallback)
### 1.1.1 - 2026-08-26

- Fix deprecated warning for nullable string parameters in `CollectionItem`
Expand Down
4 changes: 2 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ You can also pass a `Collection` instance built with `Collection::from_sprite()`

| Old (`icon-item` / legacy parent) | New (`blockparty/icon`) |
|---|---|
| `icon` (`name`, `type`, `label`, optional `collection`) | `icon` (requires `collection` + `name`) |
| `icon` (`name`, `type`, `label`, optional `collection`) | `icon` (`collection` + `name`; collection is looked up / fallen back when missing) |
| Parent `collection.name` (legacy object) | `icon.collection` |
| `iconColorValue` / legacy `iconColor.color` | `iconColor` (value kept as-is, including `inherit`) |
| `size` (int) | `size` (int) |
Expand Down Expand Up @@ -98,7 +98,7 @@ The command:
4. Logs migrated / skipped counts
5. Lists missing icon assets (`collection/name`) so you can add them manually to theme assets / collections

When `collection` is missing on the icon object (and not on the parent attrs), the block is skipped — there is no default collection. Missing files among converted icons are reported, not skipped.
When `collection` is missing on the icon object (and not on the parent attrs) but `name` is present, the migrator looks up that name in registered collections (preferred order: `icon-pack`, `theme`, `mediatheque`, then the rest). If still unresolved, it falls back to `mediatheque` for `raw` icons and `icon-pack` otherwise (`sprite` is inferred from `<use>` in the saved HTML when `type` is absent). The block is skipped only when `name` (or collection after these steps) is still missing. Missing files among converted icons are reported as CLI warnings, not skipped.

### Revisions

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
19 changes: 14 additions & 5 deletions blockparty-icons.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Description: Provides blocks in WordPress editor to add custom SVG icons.
* Requires at least: 6.2
* Requires PHP: 8.1
* Version: 1.1.1
* Version: 1.1.2
* Author: Be API Technical Team
* Author URI: https://beapi.fr
* License: GPL-2.0-or-later
Expand All @@ -30,7 +30,7 @@
include_once __DIR__ . '/vendor/autoload.php';
}

define( 'BLOCKPARTY_ICONS_VERSION', '1.1.1' );
define( 'BLOCKPARTY_ICONS_VERSION', '1.1.2' );
define( 'BLOCKPARTY_ICONS_URL', plugin_dir_url( __FILE__ ) );
define( 'BLOCKPARTY_ICONS_DIR', plugin_dir_path( __FILE__ ) );
define( 'BLOCKPARTY_ICONS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
Expand Down 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
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"phpro/grumphp-shim": "^1.5",
"phpunit/phpunit": "^9.6",
"roave/security-advisories": "dev-latest",
"roots/wordpress-no-content": "^6.0",
"vimeo/psalm": "^5.20",
"wp-coding-standards/wpcs": "^3.0"
"wp-coding-standards/wpcs": "^3.0",
"wp-phpunit/wp-phpunit": "^6.4",
"yoast/phpunit-polyfills": "^2.0"
},
"autoload": {
"psr-4": {
Expand All @@ -72,7 +75,8 @@
"scripts": {
"cs": "./vendor/bin/phpcs",
"cb": "./vendor/bin/phpcbf",
"psalm": "./vendor/bin/psalm"
"psalm": "./vendor/bin/psalm",
"test": "./vendor/bin/phpunit"
},
"scripts-descriptions": {
"cs": "Run PHP CodeSniffer on codebase using custom ruleset.",
Expand Down
Loading
Loading