diff --git a/.distignore b/.distignore index 43bf6bc..10c645b 100644 --- a/.distignore +++ b/.distignore @@ -17,6 +17,7 @@ grumphp.yml phpcs.xml.dist phpunit.xml.dist +wp-tests-config.php psalm.xml.dist # Dependency management diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml new file mode 100644 index 0000000..973337f --- /dev/null +++ b/.github/workflows/test-php.yml @@ -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 diff --git a/.gitignore b/.gitignore index e03513b..5309cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.plugin-data b/.plugin-data index 91edf44..80a8f24 100644 --- a/.plugin-data +++ b/.plugin-data @@ -1,4 +1,4 @@ { - "version": "1.1.1", + "version": "1.1.2", "slug": "blockparty-icons" } 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 4756984..b9efc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use ## Changelog +### 1.1.2 - 2026-09-17 + +- Fix `beapi/icon-block` migration skipping icons that have a name but no collection (registry lookup then generic fallback) +- 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.1 - 2026-08-26 - Fix deprecated warning for nullable string parameters in `CollectionItem` diff --git a/MIGRATION.md b/MIGRATION.md index 35b1bad..a96f1d0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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) | @@ -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 `` 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 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` | | +| `type` | | +| `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..75dd4ae 100644 --- a/blockparty-icons.php +++ b/blockparty-icons.php @@ -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 @@ -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__ ) ); @@ -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/composer.json b/composer.json index a272305..8ecec0d 100644 --- a/composer.json +++ b/composer.json @@ -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": { @@ -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.", diff --git a/composer.lock b/composer.lock index 382c535..f103e80 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45a3287a5c944ea32a91f02011a65098", + "content-hash": "6342a808eb901ed9f478b953791a89ae", "packages": [ { "name": "composer/installers", @@ -920,6 +920,76 @@ }, "time": "2026-02-07T07:09:04+00:00" }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, { "name": "felixfbecker/advanced-json-rpc", "version": "v3.2.1", @@ -1143,6 +1213,66 @@ }, "time": "2024-04-01T10:36:11+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, { "name": "netresearch/jsonmapper", "version": "v4.5.0", @@ -1338,6 +1468,124 @@ ], "time": "2025-11-27T13:49:59+00:00" }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "php-parallel-lint/php-parallel-lint", "version": "v1.4.0", @@ -2206,133 +2454,445 @@ "time": "2026-01-25T14:56:51+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phpunit/php-code-coverage", + "version": "9.2.32", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for caching libraries", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "cache", - "psr", - "psr-6" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "phpunit/php-file-iterator", + "version": "3.0.6", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phpunit/php-invoker", + "version": "3.1.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "3.1-dev" } }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.36", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-11T06:25:15+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2342,47 +2902,46 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], - "description": "Standard interfaces for event handling.", + "description": "Common interface for caching libraries", "keywords": [ - "events", + "cache", "psr", - "psr-14" + "psr-6" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2021-02-03T23:26:27+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=7.4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2395,38 +2954,141 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "log", - "psr", - "psr-3" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "roave/security-advisories", - "version": "dev-latest", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "1476a499c47192447981cb2a9b63f87eca5abc83" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1476a499c47192447981cb2a9b63f87eca5abc83", - "reference": "1476a499c47192447981cb2a9b63f87eca5abc83", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, - "conflict": { - "3f/pygmentize": "<1.2", - "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<=4.3.16", - "adodb/adodb-php": "<=5.22.9", - "aheinze/cockpit": "<2.2", + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "roave/security-advisories", + "version": "dev-latest", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "1476a499c47192447981cb2a9b63f87eca5abc83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1476a499c47192447981cb2a9b63f87eca5abc83", + "reference": "1476a499c47192447981cb2a9b63f87eca5abc83", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "adaptcms/adaptcms": "<=1.3", + "admidio/admidio": "<=4.3.16", + "adodb/adodb-php": "<=5.22.9", + "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", @@ -3394,140 +4056,1041 @@ "zfr/zfr-oauth2-server-module": "<0.1.2", "zoujingli/thinkadmin": "<=6.1.53" }, - "default-branch": true, - "type": "metapackage", + "default-branch": true, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2026-02-18T01:37:03+00:00" + }, + { + "name": "roots/wordpress-no-content", + "version": "6.9.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + "reference": "6.9.1" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/release/wordpress-6.9.1-no-content.zip", + "reference": "6.9.1", + "shasum": "27121719e788b7c9e35cfcf8984ad8b42bd423d6" + }, + "require": { + "php": ">= 7.2.24" + }, + "provide": { + "wordpress/core-implementation": "6.9.1" + }, + "suggest": { + "ext-curl": "Performs remote request operations.", + "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", + "ext-exif": "Works with metadata stored in images.", + "ext-fileinfo": "Used to detect mimetype of file uploads.", + "ext-hash": "Used for hashing, including passwords and update packages.", + "ext-imagick": "Provides better image quality for media uploads.", + "ext-json": "Used for communications with other servers.", + "ext-libsodium": "Validates Signatures and provides securely random bytes.", + "ext-mbstring": "Used to properly handle UTF8 text.", + "ext-mysqli": "Connects to MySQL for database interactions.", + "ext-openssl": "Permits SSL-based connections to other hosts.", + "ext-pcre": "Increases performance of pattern matching in code searches.", + "ext-xml": "Used for XML parsing, such as from a third-party site.", + "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + }, + "type": "wordpress-core", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "docs": "https://developer.wordpress.org/", + "forum": "https://wordpress.org/support/", + "irc": "irc://irc.freenode.net/wordpress", + "issues": "https://core.trac.wordpress.org/", + "rss": "https://wordpress.org/news/feed/", + "source": "https://core.trac.wordpress.org/browser", + "wiki": "https://codex.wordpress.org/" + }, + "funding": [ + { + "url": "https://wordpressfoundation.org/donate/", + "type": "other" + } + ], + "time": "2026-02-03T17:39:17+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-08-11T04:55:59+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "c85be6922b7fd365942b986b9a50397d65407611" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "role": "maintainer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Ilya Tribusean", - "email": "slash3b@gmail.com", - "role": "maintainer" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", - "keywords": [ - "dev" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/Roave/SecurityAdvisories/issues", - "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" }, "funding": [ { - "url": "https://github.com/Ocramius", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2026-02-18T01:37:03+00:00" + "time": "2026-08-11T05:25:24+00:00" }, { - "name": "roots/wordpress-no-content", - "version": "6.9.1", + "name": "sebastian/resource-operations", + "version": "3.0.4", "source": { "type": "git", - "url": "https://github.com/WordPress/WordPress.git", - "reference": "6.9.1" + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/release/wordpress-6.9.1-no-content.zip", - "reference": "6.9.1", - "shasum": "27121719e788b7c9e35cfcf8984ad8b42bd423d6" + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" }, "require": { - "php": ">= 7.2.24" + "php": ">=7.3" }, - "provide": { - "wordpress/core-implementation": "6.9.1" + "require-dev": { + "phpunit/phpunit": "^9.0" }, - "suggest": { - "ext-curl": "Performs remote request operations.", - "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", - "ext-exif": "Works with metadata stored in images.", - "ext-fileinfo": "Used to detect mimetype of file uploads.", - "ext-hash": "Used for hashing, including passwords and update packages.", - "ext-imagick": "Provides better image quality for media uploads.", - "ext-json": "Used for communications with other servers.", - "ext-libsodium": "Validates Signatures and provides securely random bytes.", - "ext-mbstring": "Used to properly handle UTF8 text.", - "ext-mysqli": "Connects to MySQL for database interactions.", - "ext-openssl": "Permits SSL-based connections to other hosts.", - "ext-pcre": "Increases performance of pattern matching in code searches.", - "ext-xml": "Used for XML parsing, such as from a third-party site.", - "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] }, - "type": "wordpress-core", "notification-url": "https://packagist.org/downloads/", "license": [ - "GPL-2.0-or-later" + "BSD-3-Clause" ], "authors": [ { - "name": "WordPress Community", - "homepage": "https://wordpress.org/about/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "homepage": "https://wordpress.org/", - "keywords": [ - "blog", - "cms", - "wordpress" - ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "docs": "https://developer.wordpress.org/", - "forum": "https://wordpress.org/support/", - "irc": "irc://irc.freenode.net/wordpress", - "issues": "https://core.trac.wordpress.org/", - "rss": "https://wordpress.org/news/feed/", - "source": "https://core.trac.wordpress.org/browser", - "wiki": "https://codex.wordpress.org/" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { - "url": "https://wordpressfoundation.org/donate/", - "type": "other" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2026-02-03T17:39:17+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { - "name": "sebastian/diff", - "version": "5.1.1", + "name": "sebastian/type", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^6.4" + "phpunit/phpunit": "^9.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-master": "3.2-dev" } }, "autoload": { @@ -3542,25 +5105,68 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" }, "funding": [ { @@ -3568,7 +5174,7 @@ "type": "github" } ], - "time": "2024-03-02T07:15:17+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { "name": "spatie/array-to-xml", @@ -5160,6 +6766,56 @@ ], "time": "2025-12-02T11:50:18+00:00" }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, { "name": "vimeo/psalm", "version": "5.26.1", @@ -5475,6 +7131,117 @@ } ], "time": "2025-12-03T22:11:59+00:00" + }, + { + "name": "wp-phpunit/wp-phpunit", + "version": "6.9.7", + "source": { + "type": "git", + "url": "https://github.com/wp-phpunit/wp-phpunit.git", + "reference": "15fd216bf6516670d8d07b938675925bfa5c15b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/15fd216bf6516670d8d07b938675925bfa5c15b0", + "reference": "15fd216bf6516670d8d07b938675925bfa5c15b0", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "__loaded.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Evan Mattson", + "email": "me@aaemnnost.tv" + }, + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress core PHPUnit library", + "homepage": "https://github.com/wp-phpunit", + "keywords": [ + "phpunit", + "test", + "wordpress" + ], + "support": { + "docs": "https://github.com/wp-phpunit/docs", + "issues": "https://github.com/wp-phpunit/issues", + "source": "https://github.com/wp-phpunit/wp-phpunit" + }, + "time": "2026-02-04T01:48:23+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-08-10T05:13:49+00:00" } ], "aliases": [], @@ -5492,5 +7259,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } 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 @@ +resolve_collection_for_icon( $name ); + } + + // Last resort: frequent BeAPI collection names (not project-specific). + if ( '' === $collection && '' !== $name ) { + $collection = 'raw' === $type ? 'mediatheque' : 'icon-pack'; + } + + // Abort only when an icon was expected but name or collection is still missing. if ( $expects_icon && ( '' === $name || '' === $collection ) ) { return null; } @@ -237,8 +249,6 @@ private function build_blockparty_block( array $attrs, array $parent_attrs, stri $new = []; if ( '' !== $name ) { - $type = (string) ( $old_icon['type'] ?? ( false !== strpos( $html, ' $collection, 'name' => $name, @@ -326,6 +336,49 @@ private function build_blockparty_block( array $attrs, array $parent_attrs, stri ]; } + /** + * Find the first registered collection that contains the given icon name. + * + * Preferred order: icon-pack, theme, mediatheque, then remaining collections. + * + * @param string $name Icon name. + * @return string Collection name, or empty string if none match. + */ + private function resolve_collection_for_icon( string $name ): string { + if ( ! function_exists( '\\Blockparty\\Icons\\get_icon_collections' ) ) { + return ''; + } + + $collections = \Blockparty\Icons\get_icon_collections(); + if ( empty( $collections ) ) { + return ''; + } + + $preferred = [ 'icon-pack', 'theme', 'mediatheque' ]; + $ordered = []; + + foreach ( $preferred as $preferred_name ) { + if ( isset( $collections[ $preferred_name ] ) ) { + $ordered[] = $collections[ $preferred_name ]; + } + } + + foreach ( $collections as $collection_name => $collection ) { + if ( in_array( $collection_name, $preferred, true ) ) { + continue; + } + $ordered[] = $collection; + } + + foreach ( $ordered as $collection ) { + if ( $collection->get( $name ) ) { + return $collection->name(); + } + } + + return ''; + } + /** * Record a migrated icon that is not available in registered collections. * diff --git a/package-lock.json b/package-lock.json index aa22b2a..12e4c07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "license": "GPL-2.0-or-later", "dependencies": { "@beapi/icons": "^1.2.5", diff --git a/package.json b/package.json index 0c18322..1600dbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockparty-icons", - "version": "1.1.1", + "version": "1.1.2", "description": "Provides blocks in WordPress editor to add custom SVG icons from your theme.", "author": "Be API Technical team", "license": "GPL-2.0-or-later", @@ -17,7 +17,13 @@ "make-pot": "wp i18n make-pot . languages/blockparty-icons.pot --exclude=\"src\" --domain=blockparty-icons", "make-json": "wp i18n make-json languages/blockparty-icons-fr_FR.po languages/ --no-purge", "env:start": "wp-env start --config=./.wp-env.json", - "env:stop": "wp-env stop --config=./.wp-env.json" + "env:stop": "wp-env stop --config=./.wp-env.json", + "test:php": "bash tests/bin/phpunit.sh", + "perf:setup": "bash tests/perf/bin/setup.sh", + "perf:bench": "bash tests/perf/bin/bench.sh", + "perf:report": "node tests/perf/bin/report.mjs", + "perf:fixtures": "node tests/perf/bin/generate-fixtures.mjs", + "perf:teardown": "bash tests/perf/bin/teardown.sh" }, "devDependencies": { "@wordpress/env": "^10.39.0", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1c41647..59dfd95 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,6 +10,16 @@ ./includes/ ./build/ + + ./tests/perf/ ./node_modules/ ./src/ ./tools/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2a24f1a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + ./tests/phpunit + + + + + + ./includes + ./blockparty-icons.php + + + ./includes/Cli + + + diff --git a/readme.txt b/readme.txt index 3c5ef0a..91ab7d8 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: block, icons, svg, gutenberg, editor Requires at least: 6.2 Tested up to: 6.8 Requires PHP: 8.1 -Stable tag: 1.1.1 +Stable tag: 1.1.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -36,6 +36,16 @@ Yes. Register a collection with `type` set to `folder` and `source` pointing to == Changelog == += 1.1.2 = +* Fix `beapi/icon-block` migration skipping icons that have a name but no collection (registry lookup then generic fallback). +* 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. Adjust with the `blockparty_icons_cache_max_item_bytes` filter. +* Add an `attachments` collection type for SVGs contributed through the media library. +* 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. See `tests/README.md`. +* Run the suite in CI on PHP 8.1 through 8.4. + = 1.1.1 = * Fix deprecated warning for nullable string parameters in `CollectionItem`. * Align GitHub workflows with other blockparty repos and add release version consistency checks. diff --git a/src/block.json b/src/block.json index ffd022b..d447f27 100644 --- a/src/block.json +++ b/src/block.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "blockparty/icon", - "version": "1.1.1", + "version": "1.1.2", "title": "Icon", "category": "widgets", "description": "Easily add custom vector icon to your content.", diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8f0464c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,111 @@ +# Test suite + +Integration tests against a real WordPress install — not isolated unit tests. The +plugin leans on `WP_Query`, the object cache, `WP_HTML_Tag_Processor`, +`WP_REST_Controller` and a set of filters; mocking that surface would test the +mocks rather than the plugin. + +## Running + +The block assets must be compiled first, whichever way you run the suite: the +plugin registers its block from `build/block.json`, and without it nothing renders. +`build/` is git-ignored, so a fresh clone needs: + +```bash +npm ci && npm run build +``` + +The bootstrap checks for it and says so rather than letting two dozen rendering +tests fail for the wrong reason. + +Through wp-env, which supplies WordPress, the test library and a database: + +```bash +WP_ENV_PORT=8890 WP_ENV_TESTS_PORT=8891 npx wp-env start +npm run test:php +``` + +This is also what CI runs, so a green run locally means the same thing as a green +run on the pull request. + +Without Docker, you can run against the Composer copy of WordPress instead, and +supply the database yourself: + +```bash +cp tests/wp-tests-config-sample.php wp-tests-config.php +# edit the credentials, pointing at a throwaway database +composer test +``` + +Arguments pass straight through: + +```bash +npm run test:php -- --filter BlockRendererTest +composer test -- --filter test_search +``` + +To exercise a specific PHP version, set `WP_ENV_PHP_VERSION` — this is how the CI +matrix covers 8.1 through 8.4: + +```bash +WP_ENV_PHP_VERSION=8.1 npx wp-env start --update +npm run test:php +``` + +> The suite **drops and recreates** the tables in the configured database. Never +> point it at anything you want to keep. + +## What is covered + +| file | subject | +|---|---| +| `Icon/CollectionItemsFactoryTest.php` | every SVG source: folder, sprite, single file | +| `Icon/CollectionTest.php` | the collection container: lookup, count, search, named constructors | +| `Icon/CollectionItemTest.php` | a single icon, and what survives the object cache | +| `BlockRendererTest.php` | front-end markup: raw and sprite icons, size, radius, link, colour, escaping | +| `RegistrationTest.php` | the public API a theme calls, plus editor preload paths | +| `Rest/CollectionsControllerTest.php` | collection discovery endpoint and its permissions | +| `Rest/IconsControllerTest.php` | icon listing, pagination, search, single item, permissions | +| `Helpers/CacheTest.php` | the object-cache wrapper every source goes through | +| `Helpers/KsesTest.php` | the KSES allowances that keep saved block markup valid | + +### These are characterization tests + +They describe **observable behaviour** — how many icons come back, what their +names, labels, types and contents are, what markup reaches a page — and say +nothing about *when* or *how often* files are read. + +That is deliberate. It means the suite keeps its meaning across a refactor of how +loading and caching work: if a change alters what a caller can observe, a test +fails; if it only alters the mechanism, the suite stays green. + +## Known gaps + +- **Development-mode cache bypass.** WordPress resolves `wp_is_development_mode()` + from the `WP_DEVELOPMENT_MODE` constant behind a static cache, and only lets its + own core suite vary it. The suite therefore runs with caching active, which is the + production path anyway. See the docblock in `Helpers/CacheTest.php`. +- **`Collection`'s Iterator.** It is broken — positions are integers, keys are + names, so `foreach` over a populated collection yields nothing. Nothing in the + plugin iterates a collection, so this is latent. It is recorded by + `test_known_defect_iteration_yields_nothing_even_when_populated`, which will fail + when someone fixes it. Update the test then; do not delete it. +- **The WP-CLI migration command** (`includes/Cli`, `includes/Migration`) has no + coverage and is excluded from the coverage report. +- **JavaScript.** The editor sources under `src/` are not covered here. + +## Fixtures + +`TestCase` builds fixtures on demand and removes them afterwards: + +- `make_icon_folder( [ 'name.svg' => $contents ] )` — a folder of SVGs +- `make_file( $name, $contents )` — a single file +- `svg( $marker )` — a minimal valid SVG, with a marker so icons can be told apart +- `sprite( [ $id, … ] )` — a sprite containing those symbol ids + +They live under `WP_CONTENT_DIR` on purpose: `CollectionItemsFactory` derives a +sprite's public URL by swapping `WP_CONTENT_DIR` for `WP_CONTENT_URL`, so fixtures +placed anywhere else would make that path untestable. + +`TestCase::set_up()` also empties the collections global and flushes the object +cache, so no test can be influenced by another. diff --git a/tests/bin/phpunit.sh b/tests/bin/phpunit.sh new file mode 100755 index 0000000..8accc03 --- /dev/null +++ b/tests/bin/phpunit.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Run the PHPUnit suite inside the wp-env tests container. +# +# tests/bin/phpunit.sh [phpunit arguments...] +# +# The suite needs a real WordPress install, which wp-env's `tests-wordpress` +# service provides along with the WordPress PHPUnit library at /wordpress-phpunit. +# +# wp-env's own `run` subcommand cannot be pointed at a specific environment, so we +# resolve the container from the port it publishes and talk to it with docker exec. +# +# Nothing below exports WP_TESTS_DIR, and it does not need to: wp-env sets it to +# /wordpress-phpunit on the service itself, and `docker exec` inherits the +# container's environment. That variable is what makes tests/bootstrap.php take its +# "managed environment" branch and use wp-env's WordPress, test library and +# database — rather than falling back to the Composer copy and a wp-tests-config.php +# in the project root. If you ever see the suite reaching for a root config while +# running through this script, that variable is what went missing. +# +set -euo pipefail + +ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )" +PORT="${WP_ENV_TESTS_PORT:-8891}" + +cd "$ROOT" + +CONTAINER="$( docker ps \ + --filter "publish=${PORT}" \ + --filter "name=-tests-wordpress-1" \ + --format '{{.Names}}' | head -n1 )" + +if [ -z "$CONTAINER" ]; then + cat >&2 < (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 = + `` + + `${ name }` + + ``; + const tail = ``; + + 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/